Skip to content

eng-185 (#283)#286

Merged
Connorbelez merged 10 commits intomainfrom
Connorbelez/eng-184-transfer-domain
Mar 27, 2026
Merged

eng-185 (#283)#286
Connorbelez merged 10 commits intomainfrom
Connorbelez/eng-184-transfer-domain

Conversation

@Connorbelez
Copy link
Copy Markdown
Owner

@Connorbelez Connorbelez commented Mar 27, 2026

eng-175 (#283)

TL;DR

Added webhook infrastructure for handling payment reversals from Rotessa and Stripe, including signature verification, shared reversal processing logic, and comprehensive test coverage.

What changed?

Added webhook endpoints /webhooks/rotessa and /webhooks/stripe to handle payment reversal events from both providers. The implementation includes:

  • Signature verification for both Rotessa (HMAC-SHA256) and Stripe (timestamped HMAC) webhooks using Node.js crypto
  • Shared reversal processing that looks up collection attempts, validates state, and fires GT transitions to reverse payments
  • Provider-specific handlers that parse webhook payloads and normalize them into a common format
  • Comprehensive test suite covering signature verification, payload mapping, reversal logic, and edge cases like duplicate webhooks and invalid states

The system processes NSF, PAD returns, disputes, and manual reversals by transitioning collection attempts from "confirmed" to "reversed" state and posting cash ledger reversal entries.

How to test?

Run the test suite with npm test to verify:

  • Signature verification for both providers
  • Webhook payload parsing and normalization
  • Reversal processing logic including idempotency
  • Edge cases like already-reversed attempts and missing collection attempts

For manual testing, configure ROTESSA_WEBHOOK_SECRET and STRIPE_WEBHOOK_SECRET environment variables and send POST requests to the webhook endpoints with properly signed payloads.

Why make this change?

This enables automated handling of payment reversals from external providers, ensuring the system stays in sync when payments fail or are disputed. The webhook handlers provide real-time processing of NSF events, PAD returns, and disputes, automatically updating collection attempt states and posting appropriate cash ledger entries to maintain accurate financial records.

Summary by CodeRabbit

  • New Features

    • Transfer domain: create/initiate transfers, lifecycle state machine, provider integration (manual), and bridged-transfer bridge
    • VoPay webhook endpoint for incoming transfer status updates
  • Infrastructure

    • Cash-ledger posting for receipts and lender payouts; transfer schema extended with idempotency, provider, and audit fields
    • Reconciliation cron with orphan detection, retry/escalation and self-healing workflows
  • Tests & Docs

    • Extensive unit and state-machine tests; added Cash & Obligations Ledger developer guide

eng-184

@linear
Copy link
Copy Markdown

linear bot commented Mar 27, 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 27, 2026

Caution

Review failed

Pull request was closed or merged during review

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Implements Phase 1 of the Transfer domain (ENG-184): adds canonical transfer types/validators and schema, a transfer XState machine and registry entry, provider interfaces + manual/provider adapter, transfer CRUD/initiation, transfer engine effects wired to cash-ledger posting helpers, collection-attempt → bridged transfer insertion, VoPay webhook handling with HMAC verification, reconciliation cron for orphaned confirmed transfers, and extensive tests and docs.

Changes

Cohort / File(s) Summary
Type System & Schema
convex/payments/transfers/types.ts, convex/payments/transfers/validators.ts, convex/schema.ts, convex/engine/types.ts, convex/engine/validators.ts
Add transfer domain types, provider codes, statuses, Convex validators; evolve transferRequests schema (new fields, requiredness, indexes) and widen engine entity types/validators to include transfer.
State Machine & Engine Registration
convex/engine/machines/transfer.machine.ts, convex/engine/machines/registry.ts, convex/engine/machines/__tests__/transfer.machine.test.ts
Add XState transfer machine (id "transfer", version "1.0.0"), register it in machine registry, and provide comprehensive machine tests.
Provider Interfaces & Implementations
convex/payments/transfers/interface.ts, convex/payments/transfers/providers/manual.ts, convex/payments/transfers/providers/registry.ts, convex/payments/transfers/providers/adapter.ts, convex/payments/transfers/providers/__tests__/*
Introduce TransferProvider contract and result types; implement ManualTransferProvider, provider registry, and PaymentMethodAdapter with tests.
Transfer CRUD & Bridge
convex/payments/transfers/mutations.ts, convex/payments/transfers/queries.ts, convex/engine/effects/collectionAttempt.ts, convex/payments/transfers/__tests__/*, convex/payments/transfers/__tests__/bridge.test.ts
Add admin mutation createTransferRequest, initiation flow (initiateTransfer + internal helper mutations), read queries, and collection-attempt logic to insert idempotent bridged transferRequests linked by collectionAttemptId; include tests.
Effects & Registry
convex/engine/effects/transfer.ts, convex/engine/effects/registry.ts, convex/engine/effects/__tests__/transfer.test.ts
Add internal transfer effects: recordTransferProviderRef, publishTransferConfirmed, publishTransferFailed, publishTransferReversed; wire them into the effects registry and add unit tests for payload handling/branching.
Cash Ledger Integration
convex/payments/cashLedger/integrations.ts, convex/payments/cashLedger/__tests__/*, docs/cash-ledger-developer-guide.md
Add postCashReceiptForTransfer and postLenderPayoutForTransfer posting functions, update tests/test utils and add developer guide documenting ledger posting and transfer integration.
Reconciliation Cron
convex/payments/transfers/reconciliation.ts, convex/payments/transfers/__tests__/reconciliation.test.ts
Add reconciliation cron transferReconciliationCron and helper isFreshTransfer to detect orphaned confirmed transfers, create/patch healing attempts, schedule retries and escalate when limit reached; include unit tests.
Webhooks & Verification
convex/payments/webhooks/vopay.ts, convex/payments/webhooks/verification.ts, convex/payments/webhooks/types.ts, convex/payments/webhooks/__tests__/vopayWebhook.test.ts, convex/http.ts, convex/payments/webhooks/processReversal.ts
Add VoPay webhook handler with HMAC-SHA256 verification, raw-event persistence, processing mutation, mapping to transfer events, update reversal provider unions, tests, and register POST /webhooks/pad_vopay route.
Tests, Docs & Specs
convex/payments/transfers/__tests__/*, convex/payments/cashLedger/__tests__/*, convex/engine/machines/__tests__/*, specs/ENG-184/*, docs/cash-ledger-developer-guide.md
Add extensive unit/integration tests across machines, providers, effects, bridging, reconciliation, webhook mapping; add ENG-184 spec chunks and cash-ledger developer guide.
Other small fixes/annotations
convex/payments/payout/refs.ts, convex/payments/collectionPlan/rules/scheduleRule.ts, various test utils
Add stronger typing for function refs, small type/Doc imports, consistent timestamping and expanded test fixtures.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant HTTP as VoPay HTTP Action
    participant Verif as verifyVoPaySignatureAction
    participant DB as webhookEvents (db)
    participant Proc as processVoPayWebhook
    participant TransfersDB as transferRequests (db)
    participant Ledger as CashLedger Integrations
    participant Scheduler as Scheduler

    Client->>HTTP: POST /webhooks/pad_vopay (body + X-VoPay-Signature)
    HTTP->>Verif: verify signature (body, signature)
    Verif-->>HTTP: ok / invalid
    alt signature ok
        HTTP->>DB: persistRawWebhookEvent(provider, eventId, rawBody)
        DB-->>HTTP: webhookEventId
        HTTP->>Proc: processVoPayWebhook(transactionId, status, ...)
        Proc->>TransfersDB: find transfer by providerCode+providerRef
        alt transfer found and maps to event -> FUNDS_SETTLED / REVERSAL / FAILED
            Proc->>TransfersDB: executeTransition(...) (may invoke effects)
            alt transition posts cash
                TransfersDB->>Ledger: postCashReceiptForTransfer / postLenderPayoutForTransfer
                Ledger-->>Proc: journal entry
            end
        else no transfer or unmapped status
            Proc-->>Proc: skip
        end
        Proc->>DB: updateWebhookEventStatus(webhookEventId, "processed")
        Proc-->>HTTP: { accepted: true }
    else invalid signature
        HTTP-->>Client: 401
    end
    HTTP-->>Client: 200 / accepted
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related issues

Possibly related PRs

Poem

🐰 I stitched a bridge from attempt to trade,

ledgers hum where payments were made,
provider refs and webhook signs,
retries that hunt for missing lines,
a rabbit hops — the transfer world is laid.

🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Title check ⚠️ Warning The PR title 'eng-175 (#283)' refers to an issue tracker ID and PR number, but the actual changeset implements ENG-184 (transfer domain foundation), not ENG-175 (webhook infrastructure as noted in objectives). Update the PR title to 'eng-184: Implement transfer domain foundation with machine, effects, webhooks, and reconciliation' or similar to accurately reflect the primary changeset scope.
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% 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-184-transfer-domain

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 marked this pull request as ready for review March 27, 2026 01:42
Copilot AI review requested due to automatic review settings March 27, 2026 01:42
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

Copilot AI left a comment

Choose a reason for hiding this comment

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

Copilot reviewed 51 out of 51 changed files in this pull request and generated no comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

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: 12

🧹 Nitpick comments (6)
convex/payments/transfers/providers/adapter.ts (1)

30-31: Empty string fallback for optional IDs may cause silent failures.

Converting undefined to "" via String(request.references.mortgageId ?? "") produces empty strings that may not be handled correctly downstream. Consider preserving undefined or throwing if required fields are missing.

♻️ Proposed: Preserve undefined or validate required fields
 const params: InitiateParams = {
 	amount: request.amount,
 	borrowerId: request.counterpartyId,
-	mortgageId: String(request.references.mortgageId ?? ""),
-	planEntryId: String(request.references.planEntryId ?? ""),
+	mortgageId: request.references.mortgageId
+		? String(request.references.mortgageId)
+		: undefined,
+	planEntryId: request.references.planEntryId
+		? String(request.references.planEntryId)
+		: undefined,
 	method: request.providerCode,
 	metadata: request.metadata,
 };

This requires InitiateParams to accept undefined for these fields, which may already be the case for optional references.

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

In `@convex/payments/transfers/providers/adapter.ts` around lines 30 - 31, The
current code forces optional IDs to empty strings via
String(request.references.mortgageId ?? "") and
String(request.references.planEntryId ?? ""), which can mask missing values;
update the adapter to preserve undefined by assigning mortgageId:
request.references.mortgageId and planEntryId: request.references.planEntryId
(or explicitly validate and throw if these are required), and adjust the
InitiateParams typing if needed to accept undefined—ensure any downstream
consumers handle undefined or that the adapter throws a clear error when a
required ID is missing.
convex/payments/transfers/validators.ts (1)

33-46: Consider composing transferTypeValidator from existing validators.

The combined validator duplicates all literals from inboundTransferTypeValidator and outboundTransferTypeValidator. This works but requires manual sync if types change.

♻️ Optional: Compose validators to avoid duplication

If Convex's v.union supported spreading validators, you could compose them. Since it doesn't, consider adding a comment noting the sync requirement:

+// NOTE: Keep in sync with inboundTransferTypeValidator and outboundTransferTypeValidator
 export const transferTypeValidator = v.union(
 	// Inbound
 	v.literal("borrower_interest_collection"),

Alternatively, if this pattern appears elsewhere, consider extracting the literal arrays to a shared constant and using them for both TypeScript types and validators.

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

In `@convex/payments/transfers/validators.ts` around lines 33 - 46,
transferTypeValidator currently duplicates all literals from
inboundTransferTypeValidator and outboundTransferTypeValidator; avoid drift by
extracting the literal strings into a shared constant (e.g.,
TRANSFER_TYPE_LITERALS or separate INBOUND_TRANSFER_LITERALS and
OUTBOUND_TRANSFER_LITERALS) and build inboundTransferTypeValidator,
outboundTransferTypeValidator and transferTypeValidator from those arrays, or at
minimum add a clear comment on transferTypeValidator that it must be kept in
sync with inboundTransferTypeValidator and outboundTransferTypeValidator;
reference the symbols transferTypeValidator, inboundTransferTypeValidator, and
outboundTransferTypeValidator when making the change so you update every
validator to use the shared source-of-truth.
convex/payments/webhooks/__tests__/vopayWebhook.test.ts (1)

76-99: Tests validate local constants rather than production code.

The targetStateMap is defined locally in this test file and the tests assert against that same local definition. This validates nothing about the actual implementation in vopay.ts. Consider either:

  1. Importing the real targetStateMap from the production module (if exported), or
  2. Removing these tests since they're self-referential

The same concern applies to the "payload construction per event type" tests (lines 103-173) — they construct payloads locally and assert on those local objects rather than testing actual payload construction logic.

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

In `@convex/payments/webhooks/__tests__/vopayWebhook.test.ts` around lines 76 -
99, The tests are verifying a locally defined targetStateMap instead of the
production value; update the tests to import targetStateMap from vopay.ts
(export it if necessary) and assert against that imported symbol instead of the
locally declared one, and likewise replace locally-constructed event payloads
with calls to the actual payload-construction functions in vopay.ts (export
those builders if they aren’t exported) so the tests exercise real
implementation logic rather than self-referential constants.
convex/payments/transfers/__tests__/reconciliation.test.ts (1)

108-122: Test asserts trivially true condition.

This test iterates over non-confirmed statuses and asserts they are not equal to "confirmed", which is tautologically true. The intent (documenting that only confirmed transfers are checked) is valid, but the test provides no actual verification of cron behavior.

Consider reframing as a constant assertion or removing in favor of a comment, since the real filtering logic is in the cron implementation:

-	it("only confirmed transfers are checked (not pending, failed, etc.)", () => {
-		const statuses = [
-			"initiated",
-			"pending",
-			"processing",
-			"failed",
-			"cancelled",
-			"reversed",
-		];
-		for (const status of statuses) {
-			// The cron queries only status: "confirmed" — these would not appear
-			expect(status).not.toBe("confirmed");
-		}
-	});
+	it("documents that only confirmed transfers are eligible for orphan check", () => {
+		// The cron queries only status: "confirmed" — other statuses are not scanned.
+		// This is validated by integration tests in the actual reconciliation module.
+		const CRON_TARGET_STATUS = "confirmed";
+		expect(CRON_TARGET_STATUS).toBe("confirmed");
+	});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@convex/payments/transfers/__tests__/reconciliation.test.ts` around lines 108
- 122, The test "only confirmed transfers are checked (not pending, failed,
etc.)" currently asserts a tautology by iterating over the statuses array and
checking each is not "confirmed"; replace this with a meaningful check or remove
it: either turn the statuses array into a documented constant (e.g.,
NON_CONFIRMED_STATUSES) and assert its value exactly once, or better, call the
actual cron/filter function (the reconciliation cron or the function that
queries transfers) and assert it only returns transfers with status "confirmed";
locate the statuses array and the test case name in reconciliation.test.ts and
update the test accordingly.
convex/engine/machines/__tests__/transfer.machine.test.ts (1)

599-625: Sentinel test provides useful coverage documentation but may require maintenance.

The arithmetic breakdown documents expected test counts per section, which is helpful for understanding coverage scope. However, this test will break if tests are added or removed without updating the counts. Consider whether this maintenance burden is acceptable, or if a comment-only documentation approach would suffice.

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

In `@convex/engine/machines/__tests__/transfer.machine.test.ts` around lines 599 -
625, The sentinel test hard-codes numeric breakdowns (variables
expectedMetadata, expectedStateCells, expectedTerminalCells,
expectedActionTests, expectedHappyPaths, expectedSentinel) and will fail when
tests change; either remove the expect(expectedTotal).toBe(70) assertion and
keep the block as a documentation-only comment, or make the assertion derive its
counts programmatically from the actual state/event/action/happy-path
definitions used in the suite (compute lengths from the state matrix, terminal
list, actionTests collection, and happyPaths array and compare to their summed
total) so the check remains correct when tests are added/removed.
convex/payments/transfers/__tests__/mutations.test.ts (1)

246-264: Idempotency key tests are tautological.

These tests construct the key inline and assert it equals itself. They document the expected format but don't actually test any production code. Consider importing the actual key-generation function from the bridge module and testing it directly.

♻️ Suggested improvement
+import { buildBridgeIdempotencyKey } from "../bridge"; // if such a helper exists
+
 describe("idempotency key format", () => {
   it("bridge idempotency key follows transfer:bridge:{attemptId} format", () => {
     const attemptId = "attempt_123";
-    const key = `transfer:bridge:${attemptId}`;
-    expect(key).toBe("transfer:bridge:attempt_123");
+    const key = buildBridgeIdempotencyKey(attemptId);
+    expect(key).toBe("transfer:bridge:attempt_123");
     expect(key).toMatch(BRIDGE_PREFIX_RE);
   });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@convex/payments/transfers/__tests__/mutations.test.ts` around lines 246 -
264, Tests currently build idempotency keys inline instead of exercising the
production key generator; import the actual key-generation function from the
bridge module (e.g., generateBridgeIdempotencyKey or buildBridgeIdempotencyKey)
and replace the inline template strings with calls to that function, then assert
the returned value matches BRIDGE_PREFIX_RE, is deterministic for the same
attemptId, and differs for different attemptIds; ensure you reference the
imported function and BRIDGE_PREFIX_RE in the three existing test cases.
🤖 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/effects/transfer.ts`:
- Around line 188-196: The else branch in publishTransferReversed currently only
logs when no journal entry exists for a non-bridged transfer
(transfer.collectionAttemptId is falsy), allowing reversedAt to be set without a
cash reversal; change this to fail closed or enqueue a healing action: modify
publishTransferReversed so that when transfer.collectionAttemptId is falsy and
no journal entry is found (args.entityId), it throws an error or calls the
existing healing/retry enqueuer (e.g., the function used elsewhere for
retry/healing) instead of returning, ensuring the reversal does not complete
silently and triggers explicit remediation.
- Around line 73-103: publishTransferConfirmed currently overwrites any
provider-supplied settledAt with Date.now() after posting cash; instead, compute
a settledAt value = args.payload?.settledAt ?? Date.now(), persist it to the
transfer record before calling postCashReceiptForTransfer or
postLenderPayoutForTransfer (so those posting helpers can observe the provider
timestamp), and then proceed with the existing posting logic; update the
ctx.db.patch call in publishTransferConfirmed to write this settledAt value (and
if the posting helpers take a timestamp arg, pass the same settledAt through to
postCashReceiptForTransfer/postLenderPayoutForTransfer).

In `@convex/payments/cashLedger/integrations.ts`:
- Around line 1774-1777: The journal entry effective dates currently use
transfer.confirmedAt but the persisted timestamp is transfer.settledAt; update
the postCashEntryInternal calls (the ones passing effectiveDate:
unixMsToBusinessDate(...)) to use transfer.settledAt instead of
transfer.confirmedAt, keeping the same fallback to Date.now() (i.e.,
unixMsToBusinessDate(transfer.settledAt ?? Date.now())); apply this change to
the occurrence around the CASH_RECEIVED call shown and the other similar call
referenced (the later call at ~1848-1851).
- Around line 1759-1772: The code currently falls back to a mortgage-scoped
receivable when creditFamily === "BORROWER_RECEIVABLE" but transfer.obligationId
is missing; instead validate and fail when obligationId is absent: before
building creditAccountArgs (used to call getOrCreateCashAccount), check
creditFamily === "BORROWER_RECEIVABLE" and if transfer.obligationId is falsy,
throw/return an error explaining that obligationId is required for
receivable-backed transfers; otherwise proceed to construct creditAccountArgs
and call getOrCreateCashAccount as before.

In `@convex/payments/transfers/mutations.ts`:
- Around line 194-206: The immediate-confirm path after provider.initiate()
drops any returned providerRef, so subsequent webhook/reversal correlation
fails; to fix, extract the providerRef (or providerData containing it) from the
result of provider.initiate() and persist it before triggering the FUNDS_SETTLED
transition—e.g. call the existing internal mutation recordTransferProviderRef
(or equivalent logic) using args.transferId and the providerRef/providerData,
then proceed to
ctx.runMutation(internal.payments.transfers.mutations.fireInitiateTransition,
{...}) so the providerRef is saved for later webhook/reversal matching.

In `@convex/payments/transfers/providers/adapter.ts`:
- Around line 26-36: The initiate method unconditionally maps
request.counterpartyId to borrowerId which breaks for outbound transfers and
non-borrower counterparties; update the initiate(Request: TransferRequestInput)
logic in adapter.ts to validate request.direction and request.counterpartyType
and either (a) only allow/accept calls where direction indicates inbound and
counterpartyType === "borrower" (throw or return an error otherwise), or (b) map
the counterpartyId to the correct param based on counterpartyType (e.g., set
borrowerId only when counterpartyType === "borrower" and populate a lenderId or
appropriate field for lenders) before calling this.inner.initiate; also add or
update tests (adapter.test.ts) covering outbound transfers and non-borrower
counterparties to ensure the new guard or mapping works as intended.

In `@convex/payments/transfers/reconciliation.ts`:
- Around line 123-126: The current query retrieving confirmedTransfers uses
.withIndex("by_status", q => q.eq("status","confirmed")).take(100) which
truncates results before subsequent bridged/journaled/fresh filtering and will
repeatedly scan the same first window; change this to a paginated cursor loop
(or add a tighter index/filter) so you iterate through all confirmed rows:
implement pagination using the query cursor/skip/token API provided by ctx.db
(or use a composite index that includes the field you later filter on) and
process batches until no more results, updating where you set confirmedTransfers
and the similar queries in the block around lines 128-161 to use the same
paginated approach so every confirmed transfer is eventually visited.

In `@convex/payments/webhooks/vopay.ts`:
- Around line 112-135: The catch block currently ACKs ({ accepted: true }) even
when processing fails, dropping transient failures; change flow so the raw
webhook is durably persisted before calling
internal.payments.webhooks.vopay.processVoPayWebhook (e.g., add a prior
ctx.runMutation to save the raw payload/metadata) and only return
jsonResponse({accepted: true}) after successful persistence (and ideally
successful processing); if persistence or processing fails, return a 5xx
jsonResponse (do not return accepted: true) so the provider will retry. Ensure
you update the code paths around ctx.runMutation,
internal.payments.webhooks.vopay.processVoPayWebhook, and jsonResponse
accordingly so failed processing is retriable or the event is stored durably.

In `@specs/ENG-184/chunks/chunk-01-schema-types/context.md`:
- Around line 68-73: Define a new union type PersistedTransferStatus that
includes both current TransferStatus and LegacyTransferStatus so the type system
models all values accepted by transferStatusValidator; add it alongside the
existing declarations (near TransferStatus, LEGACY_TRANSFER_STATUSES,
LegacyTransferStatus) with a short comment noting it represents persisted DB
values during the ENG-190 migration, and plan to use PersistedTransferStatus for
query/return types where DB rows are read (instead of TransferStatus) until
migration completes.

In `@specs/ENG-184/chunks/chunk-05-mutations-bridge/context.md`:
- Around line 167-205: The code currently inserts directly into
"transferRequests" with status: "confirmed", bypassing the Transition Engine and
its effects (e.g., publishTransferConfirmed). Instead, create the transfer
record without forcing status to "confirmed" (use the normal initial state like
"pending"/"created" and omit transition timestamps such as
confirmedAt/settledAt/lastTransitionAt), keep fields like idempotencyKey and
collectionAttemptId, then invoke the Transition Engine to perform the governed
transition to "confirmed" so publishTransferConfirmed runs; update the code
around the insert into "transferRequests" and the helper
mapPlanEntryToTransferType usage to reflect this flow.
- Around line 48-63: The initiateTransfer flow is using adminMutation but must
be an action to call external provider.initiate; change the exported declaration
from adminMutation to adminAction (keep the same input schema), move the
provider.initiate call and any external I/O into the adminAction handler, and
ensure you schedule any deterministic DB updates (e.g. executeTransition calls
or status updates resolved from provider.initiate) as mutations invoked from the
action rather than performing DB mutations directly in the action handler;
reference symbols: initiateTransfer, adminMutation -> adminAction,
provider.initiate, getTransferProvider, and executeTransition.

---

Nitpick comments:
In `@convex/engine/machines/__tests__/transfer.machine.test.ts`:
- Around line 599-625: The sentinel test hard-codes numeric breakdowns
(variables expectedMetadata, expectedStateCells, expectedTerminalCells,
expectedActionTests, expectedHappyPaths, expectedSentinel) and will fail when
tests change; either remove the expect(expectedTotal).toBe(70) assertion and
keep the block as a documentation-only comment, or make the assertion derive its
counts programmatically from the actual state/event/action/happy-path
definitions used in the suite (compute lengths from the state matrix, terminal
list, actionTests collection, and happyPaths array and compare to their summed
total) so the check remains correct when tests are added/removed.

In `@convex/payments/transfers/__tests__/mutations.test.ts`:
- Around line 246-264: Tests currently build idempotency keys inline instead of
exercising the production key generator; import the actual key-generation
function from the bridge module (e.g., generateBridgeIdempotencyKey or
buildBridgeIdempotencyKey) and replace the inline template strings with calls to
that function, then assert the returned value matches BRIDGE_PREFIX_RE, is
deterministic for the same attemptId, and differs for different attemptIds;
ensure you reference the imported function and BRIDGE_PREFIX_RE in the three
existing test cases.

In `@convex/payments/transfers/__tests__/reconciliation.test.ts`:
- Around line 108-122: The test "only confirmed transfers are checked (not
pending, failed, etc.)" currently asserts a tautology by iterating over the
statuses array and checking each is not "confirmed"; replace this with a
meaningful check or remove it: either turn the statuses array into a documented
constant (e.g., NON_CONFIRMED_STATUSES) and assert its value exactly once, or
better, call the actual cron/filter function (the reconciliation cron or the
function that queries transfers) and assert it only returns transfers with
status "confirmed"; locate the statuses array and the test case name in
reconciliation.test.ts and update the test accordingly.

In `@convex/payments/transfers/providers/adapter.ts`:
- Around line 30-31: The current code forces optional IDs to empty strings via
String(request.references.mortgageId ?? "") and
String(request.references.planEntryId ?? ""), which can mask missing values;
update the adapter to preserve undefined by assigning mortgageId:
request.references.mortgageId and planEntryId: request.references.planEntryId
(or explicitly validate and throw if these are required), and adjust the
InitiateParams typing if needed to accept undefined—ensure any downstream
consumers handle undefined or that the adapter throws a clear error when a
required ID is missing.

In `@convex/payments/transfers/validators.ts`:
- Around line 33-46: transferTypeValidator currently duplicates all literals
from inboundTransferTypeValidator and outboundTransferTypeValidator; avoid drift
by extracting the literal strings into a shared constant (e.g.,
TRANSFER_TYPE_LITERALS or separate INBOUND_TRANSFER_LITERALS and
OUTBOUND_TRANSFER_LITERALS) and build inboundTransferTypeValidator,
outboundTransferTypeValidator and transferTypeValidator from those arrays, or at
minimum add a clear comment on transferTypeValidator that it must be kept in
sync with inboundTransferTypeValidator and outboundTransferTypeValidator;
reference the symbols transferTypeValidator, inboundTransferTypeValidator, and
outboundTransferTypeValidator when making the change so you update every
validator to use the shared source-of-truth.

In `@convex/payments/webhooks/__tests__/vopayWebhook.test.ts`:
- Around line 76-99: The tests are verifying a locally defined targetStateMap
instead of the production value; update the tests to import targetStateMap from
vopay.ts (export it if necessary) and assert against that imported symbol
instead of the locally declared one, and likewise replace locally-constructed
event payloads with calls to the actual payload-construction functions in
vopay.ts (export those builders if they aren’t exported) so the tests exercise
real implementation logic rather than self-referential constants.
🪄 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: b407854c-f6cd-47fc-913d-bca7a1680550

📥 Commits

Reviewing files that changed from the base of the PR and between 74ab70a and 849c5cc.

📒 Files selected for processing (51)
  • convex/engine/effects/__tests__/transfer.test.ts
  • convex/engine/effects/collectionAttempt.ts
  • convex/engine/effects/registry.ts
  • convex/engine/effects/transfer.ts
  • convex/engine/machines/__tests__/transfer.machine.test.ts
  • convex/engine/machines/registry.ts
  • convex/engine/machines/transfer.machine.ts
  • convex/engine/types.ts
  • convex/engine/validators.ts
  • convex/http.ts
  • convex/payments/cashLedger/__tests__/disbursementGate.test.ts
  • convex/payments/cashLedger/__tests__/reversalCascade.test.ts
  • convex/payments/cashLedger/__tests__/testUtils.ts
  • convex/payments/cashLedger/__tests__/transferReconciliation.test.ts
  • convex/payments/cashLedger/integrations.ts
  • convex/payments/transfers/__tests__/bridge.test.ts
  • convex/payments/transfers/__tests__/mutations.test.ts
  • convex/payments/transfers/__tests__/reconciliation.test.ts
  • convex/payments/transfers/interface.ts
  • convex/payments/transfers/mutations.ts
  • convex/payments/transfers/providers/__tests__/adapter.test.ts
  • convex/payments/transfers/providers/adapter.ts
  • convex/payments/transfers/providers/manual.ts
  • convex/payments/transfers/providers/registry.ts
  • convex/payments/transfers/queries.ts
  • convex/payments/transfers/reconciliation.ts
  • convex/payments/transfers/types.ts
  • convex/payments/transfers/validators.ts
  • convex/payments/webhooks/__tests__/vopayWebhook.test.ts
  • convex/payments/webhooks/processReversal.ts
  • convex/payments/webhooks/types.ts
  • convex/payments/webhooks/verification.ts
  • convex/payments/webhooks/vopay.ts
  • convex/schema.ts
  • docs/cash-ledger-developer-guide.md
  • specs/ENG-184/chunks/chunk-01-schema-types/context.md
  • specs/ENG-184/chunks/chunk-01-schema-types/tasks.md
  • specs/ENG-184/chunks/chunk-02-machine-registration/context.md
  • specs/ENG-184/chunks/chunk-02-machine-registration/tasks.md
  • specs/ENG-184/chunks/chunk-03-provider-interface/context.md
  • specs/ENG-184/chunks/chunk-03-provider-interface/tasks.md
  • specs/ENG-184/chunks/chunk-04-effects-ledger/context.md
  • specs/ENG-184/chunks/chunk-04-effects-ledger/tasks.md
  • specs/ENG-184/chunks/chunk-05-mutations-bridge/context.md
  • specs/ENG-184/chunks/chunk-05-mutations-bridge/tasks.md
  • specs/ENG-184/chunks/chunk-06-webhook-reconciliation/context.md
  • specs/ENG-184/chunks/chunk-06-webhook-reconciliation/tasks.md
  • specs/ENG-184/chunks/chunk-07-tests/context.md
  • specs/ENG-184/chunks/chunk-07-tests/tasks.md
  • specs/ENG-184/chunks/manifest.md
  • specs/ENG-184/tasks.md

Comment thread convex/engine/effects/transfer.ts
Comment thread convex/engine/effects/transfer.ts
Comment thread convex/payments/cashLedger/integrations.ts
Comment thread convex/payments/cashLedger/integrations.ts
Comment thread convex/payments/transfers/mutations.ts
Comment thread convex/payments/transfers/reconciliation.ts Outdated
Comment thread convex/payments/webhooks/vopay.ts Outdated
Comment thread specs/ENG-184/chunks/chunk-01-schema-types/context.md
Comment thread specs/ENG-184/chunks/chunk-05-mutations-bridge/context.md
Comment thread specs/ENG-184/chunks/chunk-05-mutations-bridge/context.md
Connorbelez and others added 7 commits March 26, 2026 22:21
…tledAt for effective dates

- Add early validation that throws ConvexError when creditFamily is
  BORROWER_RECEIVABLE but transfer.obligationId is missing, preventing
  silently posting to a generic mortgage-scoped receivable account
- Replace transfer.confirmedAt with transfer.settledAt in both
  postCashReceiptForTransfer and postLenderPayoutForTransfer so journal
  entries are dated with the actual settlement time

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…reversal without journal entry

- publishTransferConfirmed: compute settledAt from args.payload?.settledAt
  (falling back to Date.now()), and persist it BEFORE calling posting helpers
  so ledger entries use the provider's authoritative timestamp instead of
  replay time.

- publishTransferReversed: throw an error instead of silently returning when
  a non-bridged transfer has no journal entry, preventing permanent ledger
  drift with no retry/healing signal.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Addresses CodeRabbit review: TransferStatus excludes legacy statuses
('approved', 'completed') accepted by transferStatusValidator. Adds a
PersistedTransferStatus union type so query return types can accurately
model all values that may exist in the database until ENG-190 migration
retires legacy rows.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…100 rows

Replace .take(100) with cursor-based .paginate() loop so all confirmed
transfers are eventually visited during reconciliation, not just the
first 100 returned by the index. Follows existing pagination pattern
from convex/engine/reconciliation.ts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The adapter maps counterpartyId to borrowerId unconditionally, which
only makes sense for inbound borrower-initiated transfers. Add explicit
guards that reject outbound direction and non-borrower counterpartyType
with clear error messages, and add test coverage for all rejection paths.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ef on immediate-confirm path

- Add optional lenderId and borrowerId parameters to createTransferRequest
  input schema and persist them when inserting into transferRequests. These
  are required downstream by postLenderPayoutForTransfer() and for
  borrower-level ledger scoping.

- Add persistProviderRef internal mutation and call it from the
  immediate-confirm path in initiateTransfer before firing FUNDS_SETTLED.
  Previously, when provider.initiate() returned "confirmed", the providerRef
  was dropped because FUNDS_SETTLED bypasses the recordTransferProviderRef
  machine effect. This broke webhook/reversal correlation for immediate
  confirmations.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ence failure

Add a webhookEvents table for durable persistence of raw webhook payloads.
The VoPay webhook handler now persists the raw event before acknowledging
the provider. If persistence fails, it returns 5xx so VoPay can retry.
If processing fails after persistence, it returns 200 (accepted) since the
durable record ensures eventual processing via a recovery mechanism.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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: 1

🧹 Nitpick comments (3)
convex/engine/effects/transfer.ts (1)

52-59: Guard providerRef against mismatched rewrites.

This overwrites any existing transfer.providerRef. A duplicate or misrouted webhook can silently replace the canonical external reference and make later reconciliation ambiguous. Safer pattern: set when empty, no-op when equal, and throw on mismatch.

♻️ Suggested guard
 		const providerRef = args.payload?.providerRef;
 		if (typeof providerRef === "string") {
-			await ctx.db.patch(transfer._id, { providerRef });
+			if (!transfer.providerRef) {
+				await ctx.db.patch(transfer._id, { providerRef });
+			} else if (transfer.providerRef !== providerRef) {
+				throw new Error(
+					`[recordTransferProviderRef] Ref mismatch for transfer ${args.entityId}: ` +
+						`${transfer.providerRef} -> ${providerRef}`
+				);
+			}
 		} else {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@convex/engine/effects/transfer.ts` around lines 52 - 59, The current logic
unconditionally patches transfer.providerRef which can overwrite an existing
canonical value; change record handling so you only set providerRef when the
stored transfer has no providerRef, do nothing if the incoming providerRef
equals the existing transfer.providerRef, and throw an error (or return failure)
if there is a mismatch between a non-empty existing transfer.providerRef and a
different incoming providerRef; implement this by reading the existing transfer
(using transfer._id), comparing transfer.providerRef to
args.payload?.providerRef, and only calling ctx.db.patch(transfer._id, {
providerRef }) when transfer.providerRef is empty, otherwise no-op if equal, and
raise on mismatch (include args.entityId in the thrown error message).
convex/payments/transfers/reconciliation.ts (1)

41-105: Prefer a workflow primitive for this retry loop.

This is hand-rolling durable retry state, rescheduling, and escalation with transferHealingAttempts + runAfter(0). Moving the orphan-healing loop to convex-dev-workflow would give you first-class retry/backoff state and cleaner operations here.

As per coding guidelines, "Use convex-dev-workflow component for long-running code flows with built-in retries, delays, and state persistence."

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

In `@convex/payments/transfers/reconciliation.ts` around lines 41 - 105, The
current orphaned-transfer retry logic in processOrphanedTransfer (which
inserts/patches transferHealingAttempts, uses scheduleHealingEffect that calls
ctx.scheduler.runAfter(0) to invoke
internal.engine.effects.transfer.publishTransferConfirmed, and checks
MAX_HEALING_ATTEMPTS) should be migrated to use the convex-dev-workflow
primitive: replace the manual durable-state table and runAfter rescheduling with
a workflow that encapsulates retry/backoff and escalation; create a workflow
that accepts transferId, performs publishTransferConfirmed, retries with backoff
up to MAX_HEALING_ATTEMPTS, and marks/escalates the transfer on final failure
(or writes a single final transferHealingAttempts record if needed), then
refactor processOrphanedTransfer to start/trigger that workflow instead of
inserting/patching transferHealingAttempts or calling scheduleHealingEffect.
convex/payments/transfers/types.ts (1)

9-35: Make the “canonical” arrays drive the Convex validators.

convex/payments/transfers/validators.ts:11-46 and convex/schema.ts:1422-1432 still repeat these literals, so a future type/status addition can compile here and then fail at runtime when writes hit validation. Consider deriving those unions from this module, or at least add a parity test that keeps them locked together.

Also applies to: 54-76

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

In `@convex/payments/transfers/types.ts` around lines 9 - 35, The transfer type
literals are repeated in runtime validators and schema generation, causing drift
between the TypeScript unions and Convex validators; update the validators and
schema generator to import and use the canonical arrays INBOUND_TRANSFER_TYPES,
OUTBOUND_TRANSFER_TYPES or ALL_TRANSFER_TYPES (and the TransferType union) from
this module instead of re-declaring literals, or add a parity test that
programmatically compares the validator lists to these exported arrays so any
change here fails CI; ensure references in the validator functions and schema
generation logic are switched to these exported symbols (INBOUND_TRANSFER_TYPES,
OUTBOUND_TRANSFER_TYPES, ALL_TRANSFER_TYPES, TransferType) to keep compile-time
and runtime in sync.
🤖 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/payments/transfers/reconciliation.ts`:
- Around line 142-164: When a matching cash_ledger_journal_entries row is found,
the current code simply continues and leaves any transferHealingAttempts in
"retrying", so update the transferHealingAttempts record to mark it resolved
before continuing: locate the block where journalEntry is retrieved (variable
journalEntry) and, if healing exists (query via transferHealingAttempts with
index by_transfer_request) and healing.status !== "resolved", write/update that
healing row to set status = "resolved" (and optionally set resolvedAt/notes) or
call an existing resolver helper, then continue; ensure you reference the
transfer._id and the transferHealingAttempts record when performing the update
so healed transfers no longer linger.

---

Nitpick comments:
In `@convex/engine/effects/transfer.ts`:
- Around line 52-59: The current logic unconditionally patches
transfer.providerRef which can overwrite an existing canonical value; change
record handling so you only set providerRef when the stored transfer has no
providerRef, do nothing if the incoming providerRef equals the existing
transfer.providerRef, and throw an error (or return failure) if there is a
mismatch between a non-empty existing transfer.providerRef and a different
incoming providerRef; implement this by reading the existing transfer (using
transfer._id), comparing transfer.providerRef to args.payload?.providerRef, and
only calling ctx.db.patch(transfer._id, { providerRef }) when
transfer.providerRef is empty, otherwise no-op if equal, and raise on mismatch
(include args.entityId in the thrown error message).

In `@convex/payments/transfers/reconciliation.ts`:
- Around line 41-105: The current orphaned-transfer retry logic in
processOrphanedTransfer (which inserts/patches transferHealingAttempts, uses
scheduleHealingEffect that calls ctx.scheduler.runAfter(0) to invoke
internal.engine.effects.transfer.publishTransferConfirmed, and checks
MAX_HEALING_ATTEMPTS) should be migrated to use the convex-dev-workflow
primitive: replace the manual durable-state table and runAfter rescheduling with
a workflow that encapsulates retry/backoff and escalation; create a workflow
that accepts transferId, performs publishTransferConfirmed, retries with backoff
up to MAX_HEALING_ATTEMPTS, and marks/escalates the transfer on final failure
(or writes a single final transferHealingAttempts record if needed), then
refactor processOrphanedTransfer to start/trigger that workflow instead of
inserting/patching transferHealingAttempts or calling scheduleHealingEffect.

In `@convex/payments/transfers/types.ts`:
- Around line 9-35: The transfer type literals are repeated in runtime
validators and schema generation, causing drift between the TypeScript unions
and Convex validators; update the validators and schema generator to import and
use the canonical arrays INBOUND_TRANSFER_TYPES, OUTBOUND_TRANSFER_TYPES or
ALL_TRANSFER_TYPES (and the TransferType union) from this module instead of
re-declaring literals, or add a parity test that programmatically compares the
validator lists to these exported arrays so any change here fails CI; ensure
references in the validator functions and schema generation logic are switched
to these exported symbols (INBOUND_TRANSFER_TYPES, OUTBOUND_TRANSFER_TYPES,
ALL_TRANSFER_TYPES, TransferType) to keep compile-time and runtime in sync.
🪄 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: 449f5732-0905-4696-80ba-8e264e7cfcba

📥 Commits

Reviewing files that changed from the base of the PR and between 849c5cc and c814178.

📒 Files selected for processing (4)
  • convex/engine/effects/transfer.ts
  • convex/payments/cashLedger/integrations.ts
  • convex/payments/transfers/reconciliation.ts
  • convex/payments/transfers/types.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • convex/payments/cashLedger/integrations.ts

Comment thread convex/payments/transfers/reconciliation.ts Outdated
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.

🧹 Nitpick comments (2)
convex/payments/webhooks/vopay.ts (1)

231-253: Use v.id("webhookEvents") for type-safe document reference.

The webhookEventId argument is typed as v.string() and then cast to Id<"webhookEvents"> at line 238. Using v.id("webhookEvents") would provide compile-time type safety and eliminate the cast.

♻️ Proposed fix
 export const updateWebhookEventStatus = internalMutation({
 	args: {
-		webhookEventId: v.string(),
+		webhookEventId: v.id("webhookEvents"),
 		status: v.union(v.literal("processed"), v.literal("failed")),
 		error: v.optional(v.string()),
 	},
 	handler: async (ctx, args) => {
-		const doc = await ctx.db.get(args.webhookEventId as Id<"webhookEvents">);
+		const doc = await ctx.db.get(args.webhookEventId);
 		if (!doc) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@convex/payments/webhooks/vopay.ts` around lines 231 - 253, The handler
updateWebhookEventStatus currently declares webhookEventId as v.string() and
then casts it to Id<"webhookEvents">; change the args schema to use
v.id("webhookEvents") for webhookEventId so the type is enforced at compile
time, remove the manual cast in the handler, and keep the rest of the logic
(ctx.db.get(args.webhookEventId) and ctx.db.patch) unchanged so the code uses
the correctly typed args.webhookEventId throughout.
convex/payments/transfers/mutations.ts (1)

219-253: Consider propagating transition failure to the caller.

executeTransition returns a TransitionResult with a success boolean rather than throwing on failure (per the context snippet from convex/engine/transition.ts). The current code returns the result directly without checking success, so callers won't know if the transition failed unless they inspect the return value.

This is a minor observability concern for Phase 1 admin tooling — consider either:

  1. Checking result.success and throwing a ConvexError on failure, or
  2. Documenting that callers must inspect the returned TransitionResult.
🔧 Optional: Throw on transition failure
 		if (result.status === "confirmed") {
 			await ctx.runMutation(
 				internal.payments.transfers.mutations.persistProviderRef,
 				{
 					transferId: args.transferId,
 					providerRef: result.providerRef,
 				}
 			);
-			return ctx.runMutation(
+			const transitionResult = await ctx.runMutation(
 				internal.payments.transfers.mutations.fireInitiateTransition,
 				{
 					transferId: args.transferId,
 					eventType: "FUNDS_SETTLED",
 					payload: {
 						settledAt: Date.now(),
 						providerData: {},
 						providerRef: result.providerRef,
 					},
 					source,
 				}
 			);
+			if (!transitionResult.success) {
+				throw new ConvexError(`Transition failed: ${transitionResult.reason ?? "unknown"}`);
+			}
+			return transitionResult;
 		}
 
-		return ctx.runMutation(
+		const transitionResult = await ctx.runMutation(
 			internal.payments.transfers.mutations.fireInitiateTransition,
 			{
 				transferId: args.transferId,
 				eventType: "PROVIDER_INITIATED",
 				payload: { providerRef: result.providerRef },
 				source,
 			}
 		);
+		if (!transitionResult.success) {
+			throw new ConvexError(`Transition failed: ${transitionResult.reason ?? "unknown"}`);
+		}
+		return transitionResult;
🤖 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 219 - 253, The code
currently returns the result of calling
internal.payments.transfers.mutations.fireInitiateTransition (via
ctx.runMutation) without checking the TransitionResult.success flag; update the
handling in the confirmed branch (after persistProviderRef) and the
non-confirmed branch where fireInitiateTransition is invoked to inspect the
returned TransitionResult and throw a ConvexError (or otherwise raise) when
success is false so failures propagate to the caller; specifically modify the
callers that call fireInitiateTransition in this file to check result.success
and throw if not successful.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@convex/payments/transfers/mutations.ts`:
- Around line 219-253: The code currently returns the result of calling
internal.payments.transfers.mutations.fireInitiateTransition (via
ctx.runMutation) without checking the TransitionResult.success flag; update the
handling in the confirmed branch (after persistProviderRef) and the
non-confirmed branch where fireInitiateTransition is invoked to inspect the
returned TransitionResult and throw a ConvexError (or otherwise raise) when
success is false so failures propagate to the caller; specifically modify the
callers that call fireInitiateTransition in this file to check result.success
and throw if not successful.

In `@convex/payments/webhooks/vopay.ts`:
- Around line 231-253: The handler updateWebhookEventStatus currently declares
webhookEventId as v.string() and then casts it to Id<"webhookEvents">; change
the args schema to use v.id("webhookEvents") for webhookEventId so the type is
enforced at compile time, remove the manual cast in the handler, and keep the
rest of the logic (ctx.db.get(args.webhookEventId) and ctx.db.patch) unchanged
so the code uses the correctly typed args.webhookEventId throughout.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 9b8f79e8-1afc-4c59-9133-07fe783b0009

📥 Commits

Reviewing files that changed from the base of the PR and between c814178 and fe1569e.

📒 Files selected for processing (5)
  • convex/payments/transfers/mutations.ts
  • convex/payments/transfers/providers/__tests__/adapter.test.ts
  • convex/payments/transfers/providers/adapter.ts
  • convex/payments/webhooks/vopay.ts
  • convex/schema.ts
✅ Files skipped from review due to trivial changes (1)
  • convex/payments/transfers/providers/tests/adapter.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • convex/payments/transfers/providers/adapter.ts

When reconciliation finds a journal entry for a previously-orphaned
transfer, mark any open transferHealingAttempts row as "resolved" with
a resolvedAt timestamp instead of silently skipping it.

Also extracts per-transfer logic into reconcileTransfer() helper to
keep handler complexity within Biome's threshold.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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.

🧹 Nitpick comments (1)
convex/payments/transfers/reconciliation.ts (1)

184-199: Add lightweight reconciliation metrics for runtime visibility.

Consider emitting counters for scanned / retried / resolved / escalated transfers so cron health and drift are easy to monitor in production.

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

In `@convex/payments/transfers/reconciliation.ts` around lines 184 - 199, The
reconciliation loop lacks runtime metrics; add lightweight counters for
scanned/retried/resolved/escalated transfers so cron health is observable.
Increment a "scanned" counter inside the loop that pages over transferRequests
(where RECONCILIATION_PAGE_SIZE, cursor and page are used), and modify
reconcileTransfer to return a result/status (e.g., "retried" | "resolved" |
"escalated") or throw distinct errors; then increment the corresponding counters
(retried/resolved/escalated) in the caller after awaiting reconcileTransfer. Use
the existing ctx (e.g., ctx.metrics.increment or a similar metrics helper) so
metrics are emitted without changing control flow; ensure counters are
namespaced and include retries/resolution reason where available.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@convex/payments/transfers/reconciliation.ts`:
- Around line 184-199: The reconciliation loop lacks runtime metrics; add
lightweight counters for scanned/retried/resolved/escalated transfers so cron
health is observable. Increment a "scanned" counter inside the loop that pages
over transferRequests (where RECONCILIATION_PAGE_SIZE, cursor and page are
used), and modify reconcileTransfer to return a result/status (e.g., "retried" |
"resolved" | "escalated") or throw distinct errors; then increment the
corresponding counters (retried/resolved/escalated) in the caller after awaiting
reconcileTransfer. Use the existing ctx (e.g., ctx.metrics.increment or a
similar metrics helper) so metrics are emitted without changing control flow;
ensure counters are namespaced and include retries/resolution reason where
available.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 84ed4aeb-95e6-4cfb-8230-f6de831b3129

📥 Commits

Reviewing files that changed from the base of the PR and between fe1569e and a80ad48.

📒 Files selected for processing (2)
  • convex/payments/transfers/reconciliation.ts
  • convex/schema.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • convex/schema.ts

@Connorbelez Connorbelez merged commit ca05c08 into main Mar 27, 2026
0 of 3 checks passed
@Connorbelez Connorbelez changed the title eng-175 (#283) eng-185 (#283) Mar 27, 2026
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.

2 participants