Skip to content

OUT-4010: freeze batched-deposit intent per invoice - #270

Merged
SandipBajracharya merged 6 commits into
feature/payout-reconciliationfrom
OUT-4010
Jul 28, 2026
Merged

OUT-4010: freeze batched-deposit intent per invoice#270
SandipBajracharya merged 6 commits into
feature/payout-reconciliationfrom
OUT-4010

Conversation

@SandipBajracharya

Copy link
Copy Markdown
Collaborator

OUT-4010 — Freeze batched-deposit intent per invoice

https://linear.app/assemblycom/issue/OUT-4010

Problem

bankDepositFeeFlag was read live in both handlePaymentSucceeded and handlePayoutReconciliationCompleted. Toggling it between a payment and its payout desynced the two paths — the fee got booked twice (OFF→ON) or missed while the payment stranded in Undeposited Funds (ON→OFF).

Approach

Freeze the decision per invoice. A new qb_invoice_sync.is_batched_deposit column is set from the live flag at invoice-create time; both handlers read the frozen value.

  • resolveDepositToAccountRef takes the frozen flag; the only live read stays at the freeze point (create / paid-on-create).
  • payment.succeeded: dedupe + resolve the frozen intent before the claim; a batched invoice defers to the payout with zero rows.
  • payout: resolves per-invoice intent via getSuccessfulPaidPaymentIds (now joined to qb_invoice_sync); all-batched books one deposit, all-non-batched skips before claiming, mixed is rejected.

Tests

  • New freeze/routing coverage: freezeBatchedIntent, statusPaidDepositRouting, frozenIntentRouting, frozenIntentDefer, plus payout allNonBatched / mixed.
  • Reconciled the OUT-4006 payout suite with the frozen model: ported edge-case tests to seedPaidInvoiceForPayout, updated resolvePayments for the new return shape, removed the obsolete live-flag tests, and folded the duplicate happy-path/parallel infra into one directory.
  • Full suite green: 350 passing.

Notes for reviewers

  • Migration is a plain additive column (default false); no backfill needed since the batched-deposit feature isn't in production yet, so no pre-existing invoice was created under batched behavior.

🤖 Generated with Claude Code

SandipBajracharya and others added 3 commits July 27, 2026 15:21
Reading bankDepositFeeFlag live in both handlePaymentSucceeded and
handlePayoutReconciliationCompleted desynced when the flag was toggled
between a payment and its payout (fee double-booked OFF->ON, or missed
and payment stranded in Undeposited Funds ON->OFF). Freeze the decision
on qb_invoice_sync.is_batched_deposit at row creation; both handlers now
read the frozen value.

- add is_batched_deposit column (+ migration) and freeze it on invoice
  create / paid-on-create
- resolveDepositToAccountRef takes the frozen flag; the only live read
  stays at the freeze point
- payment.succeeded: dedupe + resolve intent before the claim; batched
  intent defers to the payout with zero rows
- payout: per-invoice intent via getSuccessfulPaidPaymentIds; all-batched
  books one deposit, all-non-batched skips before claiming, mixed rejected

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- invoice.created freezes is_batched_deposit; paid-on-create deposit routing
- invoice.paid + payment.succeeded route off the frozen value, not the live flag
- payout: all-batched books one deposit, all-non-batched skips before the claim,
  mixed rejected
- shared infra: getUndepositedFundsAccountId/createDeposit mocks,
  seedPaidInvoiceForPayout, payout fixture + setup helper

Overlaps the OUT-4006 payout suite (#268) on shared infra and the removed
live-flag tests; reconcile on rebase once #268 lands on the base branch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ut tests

- remove the debug GET webhook route (captureWebhookEventGET) that replayed a
  hardcoded payout against real QBO on any authenticated GET
- correct the unresolved-invoice comment: the join is safe because
  webhookInvoicePaid throws without an invoice-sync row, not via a soft-delete
- extract repeated payload fields into locals (paymentId/invoiceId/platformFee,
  payoutId)

Reconcile the OUT-4006 payout suite with the frozen-intent behavior:
- port payout tests to seedPaidInvoiceForPayout so the new invoice-sync join
  resolves; update resolvePayments for the {paymentId, isBatchedDeposit} shape
- delete now-obsolete live-flag tests (bankDepositFlagNoOp, flagOff)
- fold payoutReconciliationCompleted/* into payoutReconciliation/ on the shared
  payout fixture; drop the duplicate happy-path and parallel infra

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@linear-code

linear-code Bot commented Jul 27, 2026

Copy link
Copy Markdown

OUT-4010

@vercel

vercel Bot commented Jul 27, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
quickbooks-sync Building Building Jul 28, 2026 6:22am
quickbooks-sync (dev) Ready Ready Preview, Comment Jul 28, 2026 6:22am

Request Review

@greptile-apps

greptile-apps Bot commented Jul 27, 2026

Copy link
Copy Markdown

Greptile Summary

Freezes batched-deposit routing per invoice and uses that persisted intent throughout payment and payout processing.

  • Adds the is_batched_deposit invoice-sync column and captures the setting when invoice mappings are created.
  • Routes invoice payments using the frozen value rather than the current setting.
  • Resolves payout line items with their frozen intent, skipping all-non-batched payouts and rejecting mixed payouts.
  • Adds integration coverage for frozen routing, deferred fees, and payout intent combinations.

Confidence Score: 4/5

The PR appears safe to merge, with the previously reported payment-redelivery recovery delay still outstanding.

The frozen invoice intent is propagated consistently through payment and payout routing, but payment webhook redeliveries still exit on any existing FAILED or PENDING log and must wait for asynchronous recovery.

Files Needing Attention: src/app/api/quickbooks/webhook/webhook.service.ts

Important Files Changed

Filename Overview
src/app/api/quickbooks/invoice/invoice.service.ts Captures the live batched-deposit setting when creating invoice-sync rows and uses the persisted value for payment routing.
src/app/api/quickbooks/syncLog/syncLog.service.ts Joins successful paid-payment logs to invoice-sync records and returns each payment ID with its frozen deposit intent.
src/app/api/quickbooks/webhook/webhook.service.ts Defers batched payment fees, skips all-non-batched payouts, rejects mixed-intent payouts, and centralizes absorbed-fee failure logging.
src/db/migrations/20260724091542_add_is_batched_deposit.sql Adds the non-null frozen-intent column with a false default.

Sequence Diagram

sequenceDiagram
    participant I as Invoice creation
    participant DB as qb_invoice_sync
    participant P as payment.succeeded
    participant PO as payout reconciliation
    I->>DB: Persist is_batched_deposit
    P->>DB: Read frozen intent
    alt Batched
        P-->>PO: Defer absorbed fee
        PO->>DB: Resolve each invoice intent
        PO->>PO: Create one bank deposit
    else Non-batched
        P->>P: Book fee immediately
        PO->>PO: Skip all-non-batched payout
    end
Loading

Reviews (2): Last reviewed commit: "style(OUT-4010): trim verbose comments t..." | Re-trigger Greptile

Comment thread src/app/api/quickbooks/webhook/webhook.service.ts
… P2)

The peek matches any prior claim row (PENDING/SUCCESS/FAILED), mirroring
claimWebhookEvent's status-blind onConflictDoNothing. A redelivery never
reprocesses either way; FAILED recovery is the resync cron's job. Reword
the comment + skip log to say "already claimed", not "processed".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@SandipBajracharya

Copy link
Copy Markdown
Collaborator Author

@greptileai review PR again

// All-non-batched skipped pre-claim; a non-batched invoice here = mixed.
// get() is non-null — every id passed the unresolved check above.
const allBatched = copilotInvoiceIds.every(
(id) => paymentIdByInvoice.get(id)!.isBatchedDeposit,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@SandipBajracharya I think better to use ? rather than ! imo.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Converted to optional chaining

@priosshrsth priosshrsth left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

lgtm

@SandipBajracharya
SandipBajracharya merged commit d899747 into feature/payout-reconciliation Jul 28, 2026
4 checks passed
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