Skip to content

eng-206#304

Merged
Connorbelez merged 3 commits intomainfrom
Connorbelez/eng-206-dispersal-bridge
Mar 28, 2026
Merged

eng-206#304
Connorbelez merged 3 commits intomainfrom
Connorbelez/eng-206-dispersal-bridge

Conversation

@Connorbelez
Copy link
Copy Markdown
Owner

@Connorbelez Connorbelez commented Mar 28, 2026

ENG-206: Disbursement Bridge Implementation

This pull request implements the disbursement bridge that converts eligible dispersal entries into outbound lender payout transfers, completing the lender disbursement flow.

Core Features

  • Bridge Module: New disbursementBridge.ts with batch processing, per-entry validation, and idempotency
  • Transfer Effects Integration: Dispersal entry status lifecycle tied to transfer confirmation/failure
  • Daily Alert Cron: Monitors pending entries past hold period for admin visibility
  • Comprehensive Testing: Unit and integration tests covering happy path, edge cases, and failure scenarios

Key Components

Bridge Functions

  • triggerDisbursementBridge: Batch orchestrator finding eligible entries and creating transfers
  • processSingleDisbursement: Per-entry processing with disbursement gate validation
  • findEligibleEntriesInternal: Query for pending entries past hold period
  • resetFailedEntry: Recovery mechanism for failed disbursements
  • checkDisbursementsDue: Daily cron alert handler

Transfer Lifecycle Integration

  • publishTransferConfirmed: Updates dispersal entry to "disbursed" with payout date
  • publishTransferFailed: Marks dispersal entry as "failed" for retry
  • publishTransferReversed: Handles disbursement reversals

Pipeline Support

  • Multi-leg deal closing pipeline with Leg 1 (buyer → trust) and Leg 2 (trust → seller)
  • Pipeline status computation from transfer leg statuses
  • Automatic Leg 2 creation when Leg 1 confirms

Technical Details

  • Idempotency: disbursement:{dispersalEntryId} keys prevent duplicate transfers
  • Amount Preservation: Uses entry.amount as-is per ENG-219 (no recomputation)
  • Balance Gate: Validates disbursements against LENDER_PAYABLE balance
  • Provider Support: Phase 1 uses mock_eft, ready for VoPay integration
  • Error Handling: Graceful failure with detailed error reporting

Testing Coverage

  • Unit tests for helper functions and idempotency
  • Integration tests for full disbursement lifecycle
  • Edge case coverage including concurrent runs, insufficient balance, and transfer failures
  • ENG-219 guard test ensuring amount passthrough

Cron Schedule Update

Added daily disbursement check at 09:00 UTC (after payout batch at 08:00) for admin alerts.

ENG-217: Servicing Fee Model Documentation

Enhanced documentation for the servicing fee calculation model, confirming current implementation decisions.

Key Clarifications

  • Fee Basis: Current outstanding principal (mortgage.principal) at settlement time
  • Fee Timing: Pre-disbursement deduction during dispersal entry creation
  • Principal Sensitivity: Fees decrease proportionally as principal is repaid

Documentation Updates

  • Added comprehensive JSDoc to calculateServicingFee explaining principal basis
  • Inline comments in calculateServicingSplit documenting ENG-217 decisions
  • New test case verifying fee decreases when mortgage principal decreases

Technical Implementation

  • Fee calculated using current outstanding balance, not original loan amount
  • principalBalance stored in servicingFeeEntries for audit verification
  • Standard amortizing mortgage behavior where servicing fees decrease with principal paydown

This addresses Tech Design §10 Open Decision 2 and Foot Gun 7, confirming the current implementation follows standard mortgage servicing practices.

Summary by CodeRabbit

Release Notes

  • New Features

    • Introduced automated disbursement bridge to batch process eligible loan disbursements efficiently
    • Added daily automated checks to identify disbursements ready for processing
    • Enhanced tracking of disbursement status throughout the payout lifecycle
  • Bug Fixes

    • Improved error handling and retry capability for failed disbursement operations
  • Tests

    • Added comprehensive test coverage for disbursement workflows and edge cases

@linear
Copy link
Copy Markdown

linear bot commented Mar 28, 2026

Copy link
Copy Markdown

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry @Connorbelez, your pull request is larger than the review limit of 150000 diff characters

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 28, 2026

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e500ded8-f88f-4f18-8321-7f4697b90728

📥 Commits

Reviewing files that changed from the base of the PR and between 7b0fcb9 and dc6191b.

⛔ Files ignored due to path filters (1)
  • convex/_generated/api.d.ts is excluded by !**/_generated/**
📒 Files selected for processing (20)
  • convex/crons.ts
  • convex/dispersal/__tests__/createDispersalEntries.test.ts
  • convex/dispersal/__tests__/disbursementBridge.test.ts
  • convex/dispersal/disbursementBridge.ts
  • convex/engine/effects/__tests__/transfer.test.ts
  • convex/engine/effects/transfer.ts
  • convex/engine/reconciliation.ts
  • convex/engine/reconciliationAction.ts
  • convex/engine/types.ts
  • convex/engine/validators.ts
  • convex/payments/transfers/__tests__/pipeline.test.ts
  • specs/ENG-206/chunks/chunk-01-bridge-core/context.md
  • specs/ENG-206/chunks/chunk-01-bridge-core/tasks.md
  • specs/ENG-206/chunks/chunk-02-effects-cron/context.md
  • specs/ENG-206/chunks/chunk-02-effects-cron/tasks.md
  • specs/ENG-206/chunks/chunk-03-tests/context.md
  • specs/ENG-206/chunks/chunk-03-tests/tasks.md
  • specs/ENG-206/chunks/manifest.md
  • specs/ENG-206/eng-206-disbursement-bridge-plan.html
  • specs/ENG-206/tasks.md

📝 Walkthrough

Walkthrough

Implemented a disbursement bridge system that converts pending dispersal entries past their hold periods into outbound transfer requests of type lender_dispersal_payout. Includes new bridge module with deterministic idempotency, batch processing orchestration, transfer confirmation/failure lifecycle handling, and automated daily monitoring via cron.

Changes

Cohort / File(s) Summary
Disbursement Bridge Core
convex/dispersal/disbursementBridge.ts
New module implementing findEligibleEntriesInternal query, processSingleDisbursement and resetFailedEntry mutations, triggerDisbursementBridge action for batch processing, and checkDisbursementsDue daily cron function. Includes idempotency key builder and result type definitions. Enforces hold-period filtering, amount validation, lender balance gating, and mock provider opt-in.
Transfer Effect Handlers
convex/engine/effects/transfer.ts, convex/engine/effects/__tests__/transfer.test.ts
Extended publishTransferConfirmed, publishTransferFailed, and publishTransferReversed to patch linked dispersalEntries status (disbursed/failed) and optional payoutDate for lender_dispersal_payout transfers. Added audit logging for dispersal lifecycle events. Test coverage includes happy path confirmation, failure handling, missing entry resilience, and transfer-type filtering.
Cron & Admin Registration
convex/crons.ts
Registered new daily cron "check-disbursements-due" at 09:00 UTC to invoke disbursement-due monitoring function.
Type & Validator Updates
convex/engine/types.ts, convex/engine/validators.ts
Extended EntityType union with "dispersalEntry" and added corresponding ENTITY_TABLE_MAP entry. Updated entityTypeValidator to accept the new entity type.
Dispersal Test Fixtures
convex/dispersal/__tests__/disbursementBridge.test.ts
New comprehensive test suite (905 lines) using convex-test and vitest. Covers idempotency key determinism, happy-path entry processing, transfer confirmation/reversal/failure lifecycle, amount validation gates, hold-period filtering, legacy entry support, entry reset for retry, and ENG-219 guard ensuring stored amounts are used as-is.
Dispersal Test Helpers
convex/dispersal/__tests__/createDispersalEntries.test.ts
Simplified servicing-fee entry retrieval by removing unnecessary type casts. Added createDispersalEntry helper for seeding dispersal entries with configurable status and calculated fields.
Transfer Test Helpers
convex/payments/transfers/__tests__/pipeline.test.ts
Updated computePipelineStatus test assertions to narrow literal types via as const on legs arrays.
Reconciliation Skip Logic
convex/engine/reconciliation.ts, convex/engine/reconciliationAction.ts
Updated getEntityStatus and lookupStatus to treat dispersalEntry as non-governed, skipping reconciliation discrepancy checks and error logs for this entity type.
Specification Documents
specs/ENG-206/*
Added comprehensive spec structure including Chunk 01 (bridge core module definition), Chunk 02 (effects & cron automation), Chunk 03 (test coverage blueprint), manifest with inter-chunk dependencies, and full implementation-plan HTML document with architecture diagrams and state-machine visualization.

Sequence Diagram(s)

sequenceDiagram
    actor Admin
    participant Bridge as Disbursement Bridge
    participant DB as dispersalEntries DB
    participant Transfers as transferRequests
    participant Effects as Transfer Effects
    
    Admin->>Bridge: triggerDisbursementBridge(asOfDate, batchSize)
    Bridge->>DB: findEligibleEntriesInternal(asOfDate)
    DB-->>Bridge: pending entries past payoutEligibleAfter
    
    loop for each eligible entry
        Bridge->>Bridge: processSingleDisbursement(dispersalEntryId)
        Bridge->>DB: read dispersal entry
        DB-->>Bridge: entry data, validate status=pending & amount>0
        Bridge->>Transfers: insert transferRequest (lender_dispersal_payout)
        Transfers-->>Bridge: transfer created with idempotency key
    end
    
    Bridge-->>Admin: DisbursementBridgeResult (counts: created/skipped/failed)
    
    Note over Effects: Later...
    Effects->>DB: publishTransferConfirmed with settledAt
    DB->>DB: patch dispersalEntry status=disbursed, payoutDate=settledAt
    DB-->>Effects: ✓ Lifecycle synchronized
Loading
sequenceDiagram
    participant DB as dispersalEntries DB
    participant Cron as Daily Cron
    participant Check as checkDisbursementsDue
    participant Log as Admin Logs
    
    Cron->>Check: trigger at 09:00 UTC (daily)
    Check->>DB: query pending entries where payoutEligibleAfter <= today
    DB-->>Check: eligible entries grouped by lenderId
    
    alt entries due
        Check->>Log: emit warning: "N entries due across M lenders with amounts"
        Log-->>Check: ✓ Admin visibility
    else no entries
        Check->>Check: no-op
    end
    
    Check-->>Cron: ✓ Complete
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related issues

Possibly related PRs

  • eng-194 #292 — Overlaps in transfer effect handler test updates and integration test coverage for dispersal entry state transitions.
  • ENG-220: Add mock transfer provider with production gating and tests #291 — Introduces mock provider code registry and validation that the disbursement bridge and its tests directly consume (mock_eft/mock_pad flags).
  • eng-165 #271 — Adds dispersalEntryId field to transferRequests schema and cron-registration patterns that this PR's bridge and effects directly depend on.

Poem

🐰 A bridge is built, from entries to transfers we go,
Idempotency keys ensure no duplicates grow,
Hold periods honored, amounts held true,
Transfer effects sync the disbursement flow through,
Each morning at nine, a cron whispers what's due! 📋✨

🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Title check ⚠️ Warning The title 'eng-206' is a single reference to a ticket/issue identifier but does not clearly describe the actual changes or purpose of the pull request from a developer's perspective. Use a descriptive title that summarizes the main change, such as 'Add disbursement bridge for converting dispersal entries to lender payouts' or 'Implement dispersal-to-disbursement bridge with pipeline support'.
Docstring Coverage ⚠️ Warning Docstring coverage is 61.54% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (1 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch Connorbelez/eng-206-dispersal-bridge

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Owner Author

This stack of pull requests is managed by Graphite. Learn more about stacking.

@Connorbelez Connorbelez changed the title eng-207 eng-206 Mar 28, 2026
@Connorbelez Connorbelez marked this pull request as ready for review March 28, 2026 20:47
Copilot AI review requested due to automatic review settings March 28, 2026 20:47
Copy link
Copy Markdown

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your free trial has ended. If you'd like to continue receiving code reviews, you can add a payment method here.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (5)
convex/payments/cashLedger/integrations.ts (1)

485-489: Add one regression for the new settledAmount branch.

This now validates against args.settledAmount when it is supplied, but the existing allocation tests shown in convex/payments/cashLedger/__tests__/postingGroupIntegration.test.ts and convex/payments/cashLedger/__tests__/e2eLifecycle.test.ts still only exercise the fallback obligation.amount path. A case where settledAmount < obligation.amount would lock the intended accounting contract.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@convex/payments/cashLedger/integrations.ts` around lines 485 - 489, Add a
regression test that exercises the new settledAmount branch by supplying
args.settledAmount less than obligation.amount so grossAllocation uses
settledAmount; update or add a test in the existing
postingGroupIntegration.test.ts (or e2eLifecycle.test.ts) to construct the same
call-path that triggers validatePostingGroupAmounts (the code path in
integrations.ts that computes grossAllocation and calls
validatePostingGroupAmounts) and assert the expected outcome (e.g., the
allocation validation succeeds/fails as intended and the accounting contract is
locked for the correct amount). Ensure the test creates an obligation with
amount > settledAmount, populates args.entries and args.servicingFee
accordingly, invokes the integration code that leads to
validatePostingGroupAmounts, and asserts the resulting contract lock/allocations
reflect settledAmount rather than obligation.amount.
convex/dispersal/disbursementBridge.ts (2)

283-287: Timestamp-based retry key may cause issues in rapid retries.

Using Date.now() in the idempotency key works but could theoretically produce duplicates if retries happen within the same millisecond. Consider using a counter or UUID suffix instead.

However, this is low-risk since:

  1. Admin-triggered retries won't be sub-millisecond.
  2. Convex OCC would cause a retry anyway if collision occurred.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@convex/dispersal/disbursementBridge.ts` around lines 283 - 287, The
idempotency key generation using Date.now() in effectiveIdempotencyKey can
collide on rapid retries; update the fallback suffix to use a more robust unique
value (e.g., a UUID or a per-request retry counter) instead of Date.now().
Locate the logic that computes effectiveIdempotencyKey (references:
effectiveIdempotencyKey, existing.status, idempotencyKey) and replace the
`:retry:${Date.now()}` suffix with a UUID (or incrementing retry counter tied to
the request) so retries always produce a unique idempotency key without relying
on millisecond timing.

528-572: Duplicated eligibility logic between checkDisbursementsDue and findEligibleEntriesInternal.

Lines 534-553 duplicate the two-bucket query logic from findEligibleEntriesInternal (lines 105-133). Consider extracting this into a shared helper or having checkDisbursementsDue call findEligibleEntriesInternal via the internal API.

However, since checkDisbursementsDue is an internalMutation and cannot call internalQuery directly, the duplication is currently necessary. A future refactor could extract the logic into a shared function that both can call.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@convex/dispersal/disbursementBridge.ts` around lines 528 - 572, The
eligibility selection logic is duplicated between checkDisbursementsDue and
findEligibleEntriesInternal; extract the two-bucket query + filtering into a
shared helper (e.g., getEligibleDispersalEntries(ctx) or
extractEligibleEntriesInternal) that accepts the Convex ctx/db and returns the
combined eligible array, then update both checkDisbursementsDue and
findEligibleEntriesInternal to call that helper instead of reimplementing the
pendingPastHold/eligibleWithHold and pendingAll/eligibleLegacy logic; ensure the
helper uses the same indices/filters (status === "pending", payoutEligibleAfter
lte today) and preserves lenderId/amount fields so the existing byLender
aggregation in checkDisbursementsDue continues to work.
convex/payments/transfers/mutations.ts (2)

340-441: createTransferRequestInternal duplicates public mutation logic.

This mutation mirrors createTransferRequest (lines 69-176) with nearly identical validation and insertion logic. Consider extracting shared validation/insertion into a helper function to reduce duplication and ensure consistency.

♻️ Suggested refactor

Extract common validation and insertion logic:

// Shared helper for transfer creation
async function validateAndInsertTransfer(
  ctx: MutationCtx,
  args: TransferRequestArgs,
  source: CommandSource
): Promise<Id<"transferRequests">> {
  // Amount validation
  if (!Number.isInteger(args.amount) || args.amount <= 0) {
    throw new ConvexError("Amount must be a positive integer (cents)");
  }
  // ... rest of shared logic
}

// Then in both mutations:
export const createTransferRequest = paymentMutation
  .handler(async (ctx, args) => {
    const source = buildSource(ctx.viewer, "admin_dashboard");
    return validateAndInsertTransfer(ctx, args, source);
  });

export const createTransferRequestInternal = internalMutation({
  handler: async (ctx, args) => {
    return validateAndInsertTransfer(ctx, args, PIPELINE_SOURCE);
  },
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@convex/payments/transfers/mutations.ts` around lines 340 - 441,
createTransferRequestInternal duplicates the public createTransferRequest logic;
extract the shared validation and DB insertion into a single helper (e.g.,
validateAndInsertTransfer) and have both createTransferRequest and
createTransferRequestInternal call it with the appropriate source. Move amount
validation, validatePipelineFields, toDomainEntityId handling, mock provider
check (areMockTransferProvidersEnabled), idempotency lookup
(query("transferRequests").withIndex("by_idempotency")...), and the
ctx.db.insert payload into the helper; accept ctx, the mutation args (e.g.,
TransferRequestArgs), and a source/CommandSource parameter so
createTransferRequest passes buildSource(ctx.viewer, "admin_dashboard") and
createTransferRequestInternal passes PIPELINE_SOURCE. Ensure helper returns
Id<"transferRequests"> and preserve error handling for
InvalidDomainEntityIdError and ConvexError.

596-605: Status check before pipeline creation has a potential race window.

Between checking deal.status !== "fundsTransfer.pending" and creating the pipeline, the deal could transition to a different state via a concurrent FUNDS_RECEIVED event. However, since the deal machine's OCC and executeTransition provide idempotency (rejecting duplicate transitions), and the pipeline checks for existing transfers at lines 608-625, this is a low-risk window.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@convex/payments/transfers/mutations.ts` around lines 596 - 605, The current
pre-check using ctx.runQuery(internal.deals.queries.getInternalDeal) before
creating the transfer pipeline has a race where the deal may change (e.g., via
FUNDS_RECEIVED) before pipeline creation; fix by making the check-and-create
atomic: perform the deal status check and pipeline creation inside a single
transactional operation (use the transaction helper or
ctx.runMutation/ctx.withTransaction) or alternatively call the idempotent
executeTransition for the deal first and only create the pipeline if that
transition succeeds; reference getInternalDeal, executeTransition, and the
transfer pipeline creation code so the check and pipeline creation happen in one
transaction or are gated by executeTransition's result.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@convex/engine/types.ts`:
- Around line 18-19: The new entity variant "dispersalEntry" was added to the
type union but reconciliation logic doesn't handle it; update the reconciliation
code to include explicit handling for dispersalEntry in both lookupStatus (in
convex/engine/reconciliationAction.ts) and getEntityStatus (in
convex/engine/reconciliation.ts): add a branch/case for "dispersalEntry" that
mirrors the reconciliation semantics used by similar entities (e.g., how
"lenderRenewalIntent" or other entry types are processed), ensuring
dispersalEntry records return the correct status/path and do not fall through to
the default/unhandled case.

In `@specs/ENG-206/eng-206-disbursement-bridge-plan.html`:
- Around line 569-571: The buttons call global handlers toggleTheme(),
mermaidZoom(), and toggleExpand() but the script only defines underscored
versions (_toggleTheme, _mermaidZoom, _toggleExpand), causing runtime errors;
fix by exposing the underscored implementations as the expected global names
(e.g., assign window.toggleTheme = _toggleTheme, window.mermaidZoom =
_mermaidZoom, window.toggleExpand = _toggleExpand) or rename the functions to
the non-underscored names so the markup and script match; update the same
pattern for any other occurrences mentioned (lines using
toggleTheme/mermaidZoom/toggleExpand).
- Around line 703-719: Wrap all raw '>' characters inside the Mermaid source
blocks (e.g., the div with class="mermaid" id="sm") as HTML entities (&gt;) so
the spec-char-escape rule no longer fails; edit the Mermaid text lines
containing arrows like --> and -->|...| to replace each '>' with '&gt;' (do this
in both diagram blocks mentioned, including the block around lines 740-762) and
keep the rest of the diagram text unchanged.

---

Nitpick comments:
In `@convex/dispersal/disbursementBridge.ts`:
- Around line 283-287: The idempotency key generation using Date.now() in
effectiveIdempotencyKey can collide on rapid retries; update the fallback suffix
to use a more robust unique value (e.g., a UUID or a per-request retry counter)
instead of Date.now(). Locate the logic that computes effectiveIdempotencyKey
(references: effectiveIdempotencyKey, existing.status, idempotencyKey) and
replace the `:retry:${Date.now()}` suffix with a UUID (or incrementing retry
counter tied to the request) so retries always produce a unique idempotency key
without relying on millisecond timing.
- Around line 528-572: The eligibility selection logic is duplicated between
checkDisbursementsDue and findEligibleEntriesInternal; extract the two-bucket
query + filtering into a shared helper (e.g., getEligibleDispersalEntries(ctx)
or extractEligibleEntriesInternal) that accepts the Convex ctx/db and returns
the combined eligible array, then update both checkDisbursementsDue and
findEligibleEntriesInternal to call that helper instead of reimplementing the
pendingPastHold/eligibleWithHold and pendingAll/eligibleLegacy logic; ensure the
helper uses the same indices/filters (status === "pending", payoutEligibleAfter
lte today) and preserves lenderId/amount fields so the existing byLender
aggregation in checkDisbursementsDue continues to work.

In `@convex/payments/cashLedger/integrations.ts`:
- Around line 485-489: Add a regression test that exercises the new
settledAmount branch by supplying args.settledAmount less than obligation.amount
so grossAllocation uses settledAmount; update or add a test in the existing
postingGroupIntegration.test.ts (or e2eLifecycle.test.ts) to construct the same
call-path that triggers validatePostingGroupAmounts (the code path in
integrations.ts that computes grossAllocation and calls
validatePostingGroupAmounts) and assert the expected outcome (e.g., the
allocation validation succeeds/fails as intended and the accounting contract is
locked for the correct amount). Ensure the test creates an obligation with
amount > settledAmount, populates args.entries and args.servicingFee
accordingly, invokes the integration code that leads to
validatePostingGroupAmounts, and asserts the resulting contract lock/allocations
reflect settledAmount rather than obligation.amount.

In `@convex/payments/transfers/mutations.ts`:
- Around line 340-441: createTransferRequestInternal duplicates the public
createTransferRequest logic; extract the shared validation and DB insertion into
a single helper (e.g., validateAndInsertTransfer) and have both
createTransferRequest and createTransferRequestInternal call it with the
appropriate source. Move amount validation, validatePipelineFields,
toDomainEntityId handling, mock provider check
(areMockTransferProvidersEnabled), idempotency lookup
(query("transferRequests").withIndex("by_idempotency")...), and the
ctx.db.insert payload into the helper; accept ctx, the mutation args (e.g.,
TransferRequestArgs), and a source/CommandSource parameter so
createTransferRequest passes buildSource(ctx.viewer, "admin_dashboard") and
createTransferRequestInternal passes PIPELINE_SOURCE. Ensure helper returns
Id<"transferRequests"> and preserve error handling for
InvalidDomainEntityIdError and ConvexError.
- Around line 596-605: The current pre-check using
ctx.runQuery(internal.deals.queries.getInternalDeal) before creating the
transfer pipeline has a race where the deal may change (e.g., via
FUNDS_RECEIVED) before pipeline creation; fix by making the check-and-create
atomic: perform the deal status check and pipeline creation inside a single
transactional operation (use the transaction helper or
ctx.runMutation/ctx.withTransaction) or alternatively call the idempotent
executeTransition for the deal first and only create the pipeline if that
transition succeeds; reference getInternalDeal, executeTransition, and the
transfer pipeline creation code so the check and pipeline creation happen in one
transaction or are gated by executeTransition's result.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 1bafd919-9a4f-4dfe-976d-ff14ed3b6723

📥 Commits

Reviewing files that changed from the base of the PR and between c61a365 and 7b0fcb9.

⛔ Files ignored due to path filters (1)
  • convex/_generated/api.d.ts is excluded by !**/_generated/**
📒 Files selected for processing (30)
  • convex/crons.ts
  • convex/dispersal/__tests__/createDispersalEntries.test.ts
  • convex/dispersal/__tests__/disbursementBridge.test.ts
  • convex/dispersal/createDispersalEntries.ts
  • convex/dispersal/disbursementBridge.ts
  • convex/dispersal/servicingFee.ts
  • convex/engine/effects/__tests__/transfer.test.ts
  • convex/engine/effects/transfer.ts
  • convex/engine/types.ts
  • convex/engine/validators.ts
  • convex/payments/cashLedger/integrations.ts
  • convex/payments/transfers/__tests__/pipeline.test.ts
  • convex/payments/transfers/mutations.ts
  • convex/payments/transfers/pipeline.ts
  • convex/payments/transfers/pipeline.types.ts
  • convex/payments/transfers/queries.ts
  • specs/ENG-206/chunks/chunk-01-bridge-core/context.md
  • specs/ENG-206/chunks/chunk-01-bridge-core/tasks.md
  • specs/ENG-206/chunks/chunk-02-effects-cron/context.md
  • specs/ENG-206/chunks/chunk-02-effects-cron/tasks.md
  • specs/ENG-206/chunks/chunk-03-tests/context.md
  • specs/ENG-206/chunks/chunk-03-tests/tasks.md
  • specs/ENG-206/chunks/manifest.md
  • specs/ENG-206/eng-206-disbursement-bridge-plan.html
  • specs/ENG-206/tasks.md
  • specs/ENG-217/chunks/chunk-01-code-docs-test/context.md
  • specs/ENG-217/chunks/chunk-01-code-docs-test/status.md
  • specs/ENG-217/chunks/chunk-01-code-docs-test/tasks.md
  • specs/ENG-217/chunks/manifest.md
  • specs/ENG-217/tasks.md

Comment thread convex/engine/types.ts
Comment thread specs/ENG-206/eng-206-disbursement-bridge-plan.html
Comment thread specs/ENG-206/eng-206-disbursement-bridge-plan.html
@Connorbelez Connorbelez force-pushed the Connorbelez/eng-206-dispersal-bridge branch from 7b0fcb9 to 39fcfd7 Compare March 28, 2026 21:00
Connorbelez and others added 2 commits March 28, 2026 17:03
…and getEntityStatus

Prevents dispersalEntry from falling through to the unhandled default error
branch in both reconciliation functions by explicitly returning undefined
(non-governed entity, skipped by reconciliation).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…L spec

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@Connorbelez Connorbelez merged commit 8b92887 into main Mar 28, 2026
0 of 3 checks passed
Connorbelez added a commit that referenced this pull request Apr 20, 2026
# ENG-206: Disbursement Bridge Implementation

This pull request implements the disbursement bridge that converts eligible dispersal entries into outbound lender payout transfers, completing the lender disbursement flow.

## Core Features

- **Bridge Module**: New `disbursementBridge.ts` with batch processing, per-entry validation, and idempotency
- **Transfer Effects Integration**: Dispersal entry status lifecycle tied to transfer confirmation/failure
- **Daily Alert Cron**: Monitors pending entries past hold period for admin visibility
- **Comprehensive Testing**: Unit and integration tests covering happy path, edge cases, and failure scenarios

## Key Components

### Bridge Functions
- `triggerDisbursementBridge`: Batch orchestrator finding eligible entries and creating transfers
- `processSingleDisbursement`: Per-entry processing with disbursement gate validation
- `findEligibleEntriesInternal`: Query for pending entries past hold period
- `resetFailedEntry`: Recovery mechanism for failed disbursements
- `checkDisbursementsDue`: Daily cron alert handler

### Transfer Lifecycle Integration
- `publishTransferConfirmed`: Updates dispersal entry to "disbursed" with payout date
- `publishTransferFailed`: Marks dispersal entry as "failed" for retry
- `publishTransferReversed`: Handles disbursement reversals

### Pipeline Support
- Multi-leg deal closing pipeline with Leg 1 (buyer → trust) and Leg 2 (trust → seller)
- Pipeline status computation from transfer leg statuses
- Automatic Leg 2 creation when Leg 1 confirms

## Technical Details

- **Idempotency**: `disbursement:{dispersalEntryId}` keys prevent duplicate transfers
- **Amount Preservation**: Uses `entry.amount` as-is per ENG-219 (no recomputation)
- **Balance Gate**: Validates disbursements against LENDER_PAYABLE balance
- **Provider Support**: Phase 1 uses `mock_eft`, ready for VoPay integration
- **Error Handling**: Graceful failure with detailed error reporting

## Testing Coverage

- Unit tests for helper functions and idempotency
- Integration tests for full disbursement lifecycle
- Edge case coverage including concurrent runs, insufficient balance, and transfer failures
- ENG-219 guard test ensuring amount passthrough

## Cron Schedule Update

Added daily disbursement check at 09:00 UTC (after payout batch at 08:00) for admin alerts.

## ENG-217: Servicing Fee Model Documentation

Enhanced documentation for the servicing fee calculation model, confirming current implementation decisions.

## Key Clarifications

- **Fee Basis**: Current outstanding principal (`mortgage.principal`) at settlement time
- **Fee Timing**: Pre-disbursement deduction during dispersal entry creation
- **Principal Sensitivity**: Fees decrease proportionally as principal is repaid

## Documentation Updates

- Added comprehensive JSDoc to `calculateServicingFee` explaining principal basis
- Inline comments in `calculateServicingSplit` documenting ENG-217 decisions
- New test case verifying fee decreases when mortgage principal decreases

## Technical Implementation

- Fee calculated using current outstanding balance, not original loan amount
- `principalBalance` stored in `servicingFeeEntries` for audit verification
- Standard amortizing mortgage behavior where servicing fees decrease with principal paydown

This addresses Tech Design §10 Open Decision 2 and Foot Gun 7, confirming the current implementation follows standard mortgage servicing practices.

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

## Summary by CodeRabbit

* **New Features**
  * Disbursement bridge processes dispersal entries into outbound lender payouts with idempotency and retry support
  * Deal closing pipeline orchestrates multi-leg transfer flows for principal transfers and seller payouts
  * Daily cron monitors disbursement readiness and reports due entries by lender

* **Bug Fixes**
  * Enhanced servicing fee calculation with principal sensitivity testing

* **Chores**
  * Expanded transfer effects to manage dispersal entry lifecycle (status tracking and audit logging)
  * Added comprehensive disbursement and transfer pipeline test coverage

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant