Payout reconciliation feature - #279
Open
SandipBajracharya wants to merge 50 commits into
Open
Conversation
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>
- 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>
|
Too many files changed for review. ( Bypass the limit by tagging |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.