Skip to content

Payout reconciliation feature - #279

Open
SandipBajracharya wants to merge 50 commits into
masterfrom
feature/payout-reconciliation
Open

Payout reconciliation feature#279
SandipBajracharya wants to merge 50 commits into
masterfrom
feature/payout-reconciliation

Conversation

@SandipBajracharya

Copy link
Copy Markdown
Collaborator

No description provided.

SandipBajracharya and others added 30 commits July 22, 2026 21:11
drizzle's migrate() runs all pending files in a single transaction, which
fails when one migration adds an enum value and a later one uses it in DDL
("unsafe use of new value"). Add migratePerFile (one commit per journal
entry) and a db:migrate runner, and switch build.sh to use it.

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

Add PAYOUT entity + SETTLED event, the bank_deposit_fee_flag and
bank_account_ref columns (schema + migration together), extend the one-shot
unique index and claimWebhookEvent predicate to cover payout/settled
(byte-equivalent), and add getSuccessfulPaidPaymentIds. Stale payout claims
flip terminal (no resync path). Retire the unused DEPOSITED enum value.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add QBDepositResponseSchema and refactor _createDeposit to the standard
assertNotQBFault + Zod-parse pattern (returning a typed response), and
parse the Undeposited Funds lookup, removing untyped {} property access.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add AccountTypeObj.Bank so checkAndUpdateAccountStatus reactivates an
archived bank account; a deleted one throws (never auto-restore a deposit
destination). Thread bankAccountRef through every IntuitAPITokensType
construction site (extractTokens, getRefreshedQbTokenInfo, auth exchange +
emptyTokens, getPortalTokens, rename-accounts cmd) so the now-required
field is always populated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Handle payout.reconciliation_completed: resolve each invoice to its QBO
Payment, assert sum(gross)-sum(fee)==netAmount in cents, and create one
Bank Deposit (N payment lines + one fee line). Abort with a FAILED log on
refund lines, negative aggregate fee, duplicate/unresolved invoices, or a
mismatch. Reshape createBankDepositForPayment to the batched N-line form,
drop the never-shipped per-payment deposit path (payment.succeeded no-ops
in batched mode), and skip payouts in the resync dispatcher for now.

On invoice.paid, route the QBO Payment through Undeposited Funds when
batched mode is on (DepositToAccountRef) so the payout deposit can link
and sweep it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Greptile PR #266 review:
- migratePerFile now wraps its loop in a Postgres advisory lock so
  concurrent deploy runners can't race the same pending migration
  (e.g. one dropping an index before the other's DROP).
- globalSetup now applies migrations via migratePerFile instead of
  drizzle's batched migrate(), so a fresh integration DB can apply the
  enum-add-then-use sequence (was failing at setup).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- invoice.service: simplify Undeposited-Funds ref to a ternary.
- payment.service: type paymentLines and build the fee line via
  push when feeTotal > 0 instead of a spread.
- webhook.service: accumulate gross/fee cents in a single reduce.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
OUT-3604: Stripe payout → batched QBO bank deposit
…chema

SettingRequestSchema gains bankDepositFeeFlag and bankAccountRef; for
invoice type the flag is required and a non-empty bankAccountRef is
required when the flag is enabled. InvoiceSettingType carries both fields.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
New endpoint backed by BankAccountService (route → controller → service).
Lists active QBO Bank-type accounts to populate the deposit-account
dropdown; selects the full account column set so the response parses
against real QBO output, capped at maxresults 100.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
getSettings returns bankAccountRef and bankDepositFeeFlag for invoice
type; updateSettings persists bankAccountRef transactionally with the
settings write (empty coerced to null), with setTransaction/unsetTransaction
paired for both services.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extract the shared AccountSelect dropdown; add an independent
bankDepositFeeFlag toggle and a "Deposit bank account" dropdown in
InvoiceDetail (fed by the bank-account endpoint), with an inline hint /
error and a canSave gate that disables the Update button until an account
is chosen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- AccountSelect distinguishes the loading state (options undefined →
  "Loading accounts…") from the genuinely-empty state ("No matching
  accounts"), and InvoiceDetail only shows the "select an account" hint
  once options have loaded — so enabling the toggle mid-fetch no longer
  shows a misleading empty message.
- changeSettings is now generic over the field key
  (<K extends keyof InvoiceSettingType>(flag: K, value: InvoiceSettingType[K])),
  so the value type is tied to the field and e.g. a string can't be passed
  for a boolean flag.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add the missing effect dependencies flagged by react-hooks/exhaustive-deps,
hoist emptyMappedItem to a module singleton, and memoize the formatted
QuickBooks item list so useMapItem's effect keeps a stable reference.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
100 could miss active bank accounts; 1000 is QBO's max single-page size and
returns them all in one query.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Collapse the let + if into a ternary that reads bankAccountRef directly off
the portal connection for INVOICE settings, and drop the now-unused NotNull
import.

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

QBAccountQueryResponseSchema requires Id/Name/SyncToken/Active/AccountType, so
selecting only Id failed the parse. Query QB_ACCOUNT_COLUMNS and add lookup
logging.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
webhookInvoiceCreated's paid branch created the payment without
DepositToAccountRef, so in batched-deposit mode it deposited straight to the
bank and the later payout deposit could not link it. Extract
resolveDepositToAccountRef, use it in both paid paths, and decouple the routing
from absorbedFeeFlag.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mock QB_ACCOUNT_COLUMNS on the intuitAPI mock, add createDeposit and
account-status (getAnAccount/updateAccount) mocks, and seed
TEST_BANK_ACCOUNT_REF on the portal connection.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Happy path plus abort guards (refund line, negative fee, sum mismatch,
duplicate/empty/unresolved line items), idempotent redelivery, flag-off
no-op, inactive/deleted bank account, claim idempotency, and
invoice->QBO-payment resolution.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
payment.succeeded creates no per-payment deposit in batched mode; stale
PENDING payout claims flip to non-retryable.

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Assertions that passed but were strictly weaker than the code's guarantees:
- assert shouldRetry:false on payout FAILED rows (hardcoded terminal invariant)
- assert exact deposit id instead of expect.any(String)
- assert getAnAccount not called when a guard trips before the QBO round-trip
- assert the full sync-log set is empty in the batched no-op path

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
… 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 and others added 17 commits July 29, 2026 13:07
- PAYOUT_MIXED_INTENT_CODE sentinel + AppActionableErrorCodes routing
- invoiceNumbers context field for multi-invoice failures
- QB_PAYOUT_MIXED_INTENT body/email copy names the affected invoices

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

- throw MixedPayoutIntentError; tag the FAILED log with the sentinel and
  stash the affected invoice numbers in remark
- getSuccessfulPaidPaymentIds returns each invoice number
- SyncErrorNotifier surfaces the invoice list, keeping the payout id as the ref

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- mixed payout persists affected invoice numbers to remark; resolvePayments
  returns them
- notifier surfaces the invoice list through the real copy (both channels)
- invoice.voided on an OPEN row surfaces QBO 5010 as error_code (QB_STALE_OBJECT)
- fix duplicate createDeposit mock key; add IU-notify Copilot mocks

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

- mixed-payout copy reads "will not be retried automatically" (both channels)
- NotificationContext nullable string fields are string | null, dropping the
  ?? undefined normalization in SyncErrorNotifier

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Show a confirmation modal when saving invoice settings that flip
bankDepositFeeFlag, so users acknowledge that the change applies only to
new invoices and that a payout mixing pre/post-change invoices may need
manual reconciliation. UX safeguard only; fires on save and only when the
flag differs from its saved value, both directions.

- add reusable ConfirmModal (portal, Escape/backdrop dismiss, a11y ids)
- gate the invoice save behind requestInvoiceSettingsSave in useSettings
- render the modal from SettingAccordion; InvoiceDetail toggle unchanged

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Address Greptile P2: the aria-modal dialog left focus on the background
save button with no trap or restoration. On open, move focus into the
dialog, trap Tab/Shift+Tab between its buttons, and restore focus to the
previously focused element on close. Keying the effect on `open` via an
onCancel ref also stops it re-subscribing on every render.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the onCancelRef workaround with two focused effects: one keyed on
`open` for focus-in/restore (runs once), one keyed on `open`+`onCancel`
for the Escape + Tab-trap listener. Clearer, honest dependency arrays.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
New table stores the payout payload so a failed reconciliation can be
rebuilt on resync. Amounts are integer cents, matching line_items.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
PayoutService.reconcile validates the payout, resolves its payments, and
builds one batched deposit. On resync it reuses an already-made deposit
(stored id, then a txn-date query on PrivateNote) so a retry can't duplicate.
Adds getDepositsByTxnDate and its schemas.

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

Webhook delegates to PayoutService (saving the payout row before claiming).
Resync claims the row FAILED->PENDING before work so overlapping runs can't
double-deposit, and skips the absorbed-fee expense for batched invoices.
Routes token-exchange resync through afterIfAvailable to keep next/server out
of the service graph.

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

The qb_payout_sync and qb_sync_logs inserts are independent (no FK), so
run them together with Promise.all instead of sequentially.

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

Adds yarn typecheck:test (tsc over test/tsconfig.json) and a CI job so type
errors in tests gate PRs. Fixes the pre-existing type errors it surfaces
(missing bankAccountRef fixtures, stale UnitPrice input, string status/id) and
loads the ambient shims in test/tsconfig. Also adds CLAUDE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Parse AB_FEATURE_TESTING_PORTALS into an allowlist (empty/unset = all
portals) and expose isPortalInBankDepositABTest for the rollout gate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Freeze gate in readBankDepositFeeFlag (non-allowlisted portals freeze
non-batched) and a reconcile gate covering both the payout webhook and
resync cron. The settings write path strips the flag + bank account for
non-AB portals, and GET returns bankDepositEnabled for the UI. The
reconcile short-circuit is logged so a rare mid-flight exclusion is
visible rather than silent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Read bankDepositEnabled from the settings GET, thread it through the
accordion into InvoiceDetail to hide the checkbox + bank-account
dropdown, and skip the bank-account fetch entirely for gated-off portals.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Unit tests for the gate util, the freeze and reconcile gates, and the
settings write path + bankDepositEnabled signal. Integration test drives
the real webhook -> invoice.service -> DB path to confirm an excluded
portal freezes non-batched. The gate is env-parsed at config load, so the
integration harness mocks it via a globalThis-pinned allowlist (default
= all portals) driven per-test by test/helpers/abTestGate.ts.

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

greptile-apps Bot commented Aug 4, 2026

Copy link
Copy Markdown

Too many files changed for review. (115 files found, 100 file limit)

Bypass the limit by tagging @greptile-apps to review.

@vercel

vercel Bot commented Aug 4, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
quickbooks-sync Error Error Aug 7, 2026 11:03am

Request Review

SandipBajracharya and others added 3 commits August 7, 2026 16:48
Query invoices that have a SUCCESS PAYMENT/SUCCEEDED sync log. The absorbed-fee
Purchase exists in QBO only when that row is written, so its presence is ground
truth rather than inferring from the batched-intent flag. Standalone @/db/service
fn to avoid a syncLog.service <-> syncErrorNotifier import cycle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A mixed-intent payout fails terminally; the notification told the IU to record
the deposit manually but did not flag that the non-batched invoices already have
their fee expensed, so following it would double-book those fees. Name the
already-recorded invoices in the copy (in-product + email) and warn against
re-recording. A recorded-fee lookup failure now drops only that detail, not the
whole notification. Share the remark delimiter so the writer and notifier split
can't drift.

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

Move the remark parse + recorded-fee lookup out of notify() into a private
resolveMixedPayoutInvoices helper returning { affectedInvoiceNumbers,
invoiceNumbersWithFee }. Behavior-preserving; keeps the fault-isolation so a
lookup failure still dispatches the notification.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants