fix(funding): close the three pipeline races + a live-Stripe backstop (#12) - #75
fix(funding): close the three pipeline races + a live-Stripe backstop (#12)#75keithfawcett wants to merge 3 commits into
Conversation
…#12) HOSTED_FUNDING_ENABLED is OFF, so all of this is latent — but every one of these costs the brand real money the day the flag flips, and a money state machine is not something to patch piecemeal. This is the coherent pass the handoff asked for, with the staging matrix extended to match. 1. AMBIGUOUS PaymentIntent CREATE re-created instead of searching. A create whose response was lost (timeout, crash) left the batch funding_failed with no PI stamped — even though Stripe may well have made a real ACH debit. Inside Stripe's ~24h idempotency window the frozen key `fbpi:<batchId>` replays harmlessly; PAST it the retry created a SECOND PaymentIntent, i.e. debited the brand twice. Now: once a batch has attempted at all, the retry SEARCHES by our metadata stamp and adopts what it finds (routing on the PI's actual status), and only creates when Stripe confirms there is nothing. Also, a create that fails *with* an intent attached (declines) now records that id, so the retry confirms it instead of making another. 2. RELEASE vs IN-FLIGHT CREATE. The PI id is only stamped when create returns. A release landing in that window saw no PI, so it skipped terminalization and freed the allocations — collecting money for commissions already back in the pool. Fixed from both sides: - the stamp is now status-predicated; losing that CAS means a release took the batch, so the orphaned PI is canceled. If Stripe won't cancel it (already processing), the batch is frozen recovery_required with a loud alert rather than left silent. - release no longer trusts "no id on the row": it asks Stripe before freeing anything, and a search that FAILS returns pi_not_terminal. Don't know ⇒ don't free. 3. INBOX CLAIMED BEFORE PROCESSING. The inbox row was written before the handler ran, so a crash mid- handler made every Stripe redelivery a no-op — the transition it carried was lost permanently. The claim is now a LEASE: only a stamped outcome is terminal, an unfinished claim older than 5 minutes can be taken over by a redelivery, and handlers are CAS-based so a takeover racing a live worker degrades to a lost CAS. A claim still unfinished an hour later (crashed AND never redelivered) is alerted by the daily reconcile. Plus the gap the handoff flagged for confirmation: refunds, disputes and transfer reversals had NO live-Stripe backstop — the reconcile only looked at locally-flagged state, which by definition cannot see a lost webhook, so a missed reversal left a payout recorded paid and its batch unfrozen. The daily job now sweeps settled money against Stripe (bounded at 50/run, and it LOGS what it didn't reach rather than looking clean): a refunded or disputed funding charge freezes the batch exactly as the webhook would, and a reversed transfer is recorded through the same reversal handler. 19 DB-backed tests with a Stripe mock cover all four, including the two that can only be seen by interleaving — a release landing mid-create, and a redelivery arriving after a claim's worker died. Runbook: docs/payout-funding-staging-runbook.md gains section H with the eight staging scenarios that prove the Stripe half of these (how to force each one), a G4 row for the stuck-claim alert, and the four invariants worth re-reading before anyone touches this pipeline again. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Turns the handoff brief into a status record: what each item was, what actually shipped, and — the part that is still open — the staging exercises that have to pass before either money path is trusted. Item A (#10, PR #73): planner/executor split with a durable payout intent and a frozen commission set. Item B (#12, PR #75): the three funding races plus a live-Stripe backstop for missed refund/reversal webhooks. Item C (#8, PR #74): the three missing tables, per-table primary keys, a portable SQL dump, and two array round-trip bugs the test found. No code left on any of the three; the remaining work is the staging checklists in docs/direct-connect-payouts.md and section H of the funding staging runbook, plus the two post-merge prod actions for #62 and #63. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…, plus six more Codex review of #75 refuted all four claims. The most important finding is that my own inbox fix only moved the failure window rather than closing it. 1. THE INBOX STILL LOST EVENTS. `claimInboxEvent` returned a bare boolean, so "already finished" and "another worker holds it" both became `inbox_replay` — and the route answered 2xx. A worker that crashed mid-handler therefore lost its event whenever Stripe's redelivery arrived INSIDE the five-minute lease: the second worker acked it, and Stripe never delivered again. Lost forever became lost on a 5-minute fuse. The claim now reports claimed/done/held, `held` raises InboxEventHeldError, and the webhook route answers 409 so Stripe redelivers after the lease expires. 2. THE LEASE HAD NO OWNER. A worker whose lease had been taken over could still stamp an outcome onto — or delete — the new owner's claim, since both keyed on event id alone. The claim's `processedAt` now doubles as an owner token: a takeover swaps it, and stamp/release are scoped to the token they were issued. 3. THE REVERSAL SWEEP SKIPPED `partially_reversed` FOREVER. Once a partial reversal was recorded, the webhook completing it could be lost and the sweep — the only backstop — never looked again. Only a fully `reversed` payout is finished. 4. BOTH SWEEP CAPS STARVED. Taking the oldest 50 of an ever-growing list meant the same 50 were re-checked nightly while newer batches — the only ones that can still change — were never checked at all. Now bounded by the 180-day reversal horizon (everything inside it is checked) and anything past the cap is REPORTED by id, not just counted. 5. AN ORPHANED PI LEFT ITS ALLOCATIONS FREED. When a release won the race and the created PI couldn't be canceled, the batch froze recovery_required but its allocations stayed `released` — so the very same commissions could be re-batched and the brand charged twice. The orphan path now reclaims allocations no newer batch has taken. 6. `release_requested` WAS TERMINAL BY ACCIDENT. A release that stopped on a Stripe failure returned 'pi_not_terminal', but no collector state matched that status and a second releaseBatch call just lost the CAS — so the batch sat forever with its allocations frozen, unalerted. It is now a re-entrant source state, the collector resumes it every tick, and the daily reconcile alerts on one stuck over a day. 7. PAYMENT-WINS SKIPPED VERIFICATION. Release CASed straight to `funded` without stripeChargeId/fundedAt, so the executor froze the batch as recovery_required on the next tick. It now goes through confirmFundingFromPaymentIntent — the one verified transition — and freezes explicitly if verification refuses. Also: the executor re-reads the batch status before each partner transfer, so a dispute freezing the batch mid-run actually stops it; and the CommissionAdjustment clawback insert is serialized under a row lock, because check-then-insert with no unique constraint could double-record when a redelivery and the sweep run concurrently. 12 new/updated tests. Runbook gains H9–H12 and the corrected invariants. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adversarial review pass (Codex, xhigh) — seven fixes in 18b72ebAll four claims were refuted. The one that matters most is that my own inbox fix was incomplete.
Plus: the executor re-reads batch status before each partner transfer (a dispute freezing the batch mid-run now actually stops it), and the 12 new/updated tests; suite 274 passing. Runbook gains H9–H12 and the corrected invariants. Still true after this pass (limits, not bugs)
|
…our more Second adversarial pass over the fixes. The headline finding is that the round-1 orphan recovery could not run at all in the case it was written for. 1. BLOCKER — RECLAIM SAT BEHIND A CALL THAT RAISES. abandonOrphanPaymentIntent escalated the batch to recovery_required BEFORE reclaiming its allocations. But `funding_disputed`/`recovery_required` are inside the "one open batch per tenant/currency" unique index, so moving a released batch back into it violates the index whenever a newer batch is already open — and casBatch raises rather than returning null. The throw skipped the reclaim entirely, leaving a live ACH debit AND freed commissions, which is the exact double-charge the path exists to prevent. Reclaim now runs first and unconditionally; the escalation is best-effort, and the alert names how many allocations a newer batch had already taken. 2. THE SAME INDEX BROKE REFUND/DISPUTE HANDLING. `charge.refunded` on a settled batch tried to drag it back to funding_disputed and threw the webhook (and the sweep) with it. A settled batch has already transferred, so freezing buys nothing: non-terminal batches are frozen, terminal ones record the clawback and alert. Both the webhook and the reconcile sweep now share one function so they cannot drift. 3. THE SWEEP STILL STARVED. Reporting skipped ids made the cap visible but didn't change the scheduling — a fixed order plus a fixed prefix re-checked the same head every night. Rows are now dealt by a per-day hash, so a capped run covers a different slice each day and the whole set over time. 4. THE 180-DAY HORIZON WAS WRONG FOR TRANSFERS. Refunds have a deadline; transfer reversals do not — Stripe places no age limit on them. The horizon now applies only to the charge sweep; the transfer sweep covers every confirmed intent, with rotation making that affordable. 5. RESUMING A RELEASE RESET ITS OWN ALERT CLOCK. casBatch bumps updatedAt and reconcile decides "stuck" from updatedAt, so a release failing every five-minute tick refreshed the 24h alert forever and stayed silent. Re-entry no longer rewrites the row. Also: transfer reversals are paged rather than trusting the ten embedded in the Transfer object — under-counting them is what decides partially_reversed vs reversed, and therefore whether the clawback adjustments get written at all. 5 new tests, including the blocker with a competing newer batch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| import { TABLES, type HostedFundingBatchRow } from '@openpartner/db'; | ||
| import { requireStripe } from '../stripe.js'; | ||
| import { TRANSFER_DEADLINE_DAYS } from './state.js'; | ||
| import { casBatch, TRANSFER_DEADLINE_DAYS } from './state.js'; |
Round-2 review (Codex, xhigh) — five more, fixed in f0f7578The headline finding is that round-1's orphan recovery could not run in the case it was written for.
Also: transfer reversals are now paged rather than trusting the ten embedded in the Transfer object — under-counting them is what decides Honest limits, unchanged. The 409-on-held fix makes delivery at-least-once within Stripe's retry budget, not unconditionally durable: an event held by a wedged worker on Stripe's final attempt can still need the manual replay the reconcile alert asks for. Confirmed sound by the review: the timestamp owner token (exact through the JS↔Postgres round-trip), the reclaim SQL's correlated alias, the payment-wins confirm path, the executor's per-partner status re-read (settlement still runs when it should), and the lock ordering on the row-locked 5 new tests, including the blocker with a competing newer batch. Suite: 279 passing. |
Closes the rest of #12 from
docs/audit-remaining-work.md(the pagination subfix shipped as #71).HOSTED_FUNDING_ENABLEDis OFF, so all of this is latent — but each one costs the brand real money the day the flag flips, and the handoff was explicit that a money state machine gets one coherent pass, not piecemeal patches. The staging matrix is extended to match.1. Ambiguous PaymentIntent create re-created instead of searching
A create whose response was lost (timeout, crash) left the batch
funding_failedwith no PI stamped — even though Stripe may well have made a real ACH debit. Inside Stripe's ~24h idempotency window the frozen keyfbpi:<batchId>replays harmlessly; past it the retry created a SECOND PaymentIntent — the brand debited twice.Now: once a batch has attempted at all, the retry searches by our metadata stamp and adopts what it finds (routing on the PI's actual status — succeeded goes through the verified confirm path), and only creates when Stripe confirms there is nothing.
Related, found while writing it:
paymentIntents.create({confirm: true})throws but still creates the intent when the bank account declines. That id was being discarded, so the retry would create another. It's now recorded off the error, and the retry confirms that intent (fbpc:key) instead.2. Release vs in-flight create
The PI id is only stamped when
createreturns. A release landing in that window saw no PI, skipped terminalization, and freed the allocations — collecting money for commissions that were already back in the pool. Fixed from both sides:processing), the batch is frozenrecovery_requiredwithfailureReason=orphan_payment_intent:<pi>and a loud alert — never left silent.pi_not_terminal. Don't know ⇒ don't free.Search is eventually consistent, so it can't cover the milliseconds-old case on its own; that's exactly what the create-side CAS is for. Belt and braces, deliberately.
3. Inbox claimed before processing
The inbox row was written before the handler ran, so a crash mid-handler made every Stripe redelivery a no-op — the transition it carried was lost permanently.
The claim is now a lease: only a stamped
outcomeis terminal, an unfinished claim older than 5 minutes can be taken over by a redelivery, and handlers are CAS-based so a takeover racing a live worker degrades to a lost CAS rather than a double transition. A claim still unfinished an hour later (crashed and never redelivered) is alerted by the daily reconcile — no schema change needed.Plus: the gap the handoff flagged for confirmation
Refunds, disputes and transfer reversals had no live-Stripe backstop. The daily reconcile only inspected locally-flagged state, which by definition cannot see a lost webhook — so a missed reversal left a payout recorded
paidand its batch unfrozen.The job now sweeps settled money against Stripe: a refunded/disputed funding charge freezes the batch exactly as the webhook would, and a reversed transfer is recorded through the same reversal handler (PayoutReversal + derived payout state + compensating CommissionAdjustment). Bounded at 50 reads per run, and it logs what it didn't reach — an unchecked batch must never look like a clean one.
Tests
funding-races.test.ts(19, DB-backed, Stripe hand-mocked). The two that matter most can only be seen by interleaving: a release landing inside the create call (via a hook that runs mid-paymentIntents.create), and a redelivery arriving after a claim's worker died. Also: adopt-instead-of-create, adopt-a-succeeded-PI, first-attempt-doesn't-pay-for-a-search, decline-records-the-intent, release-searches-before-freeing, payment-wins-the-release, search-failure-doesn't-free, orphan-cancel + orphan-freeze, lease semantics (finished / live / expired / released), handler-throws-stays-replayable, and the four reconcile-sweep cases.runFundingCollectorhad no test coverage at all before this. Full suite: 266 passing.pnpm typecheck+pnpm lintclean. No migration.Runbook
docs/payout-funding-staging-runbook.mdgains section H — eight staging scenarios that prove the Stripe half of these, each with how to force it (drop the create response with a proxy, hang inside create and release from another process, SIGKILL between claim and handler…) — aG4row for the stuck-claim alert, and the four invariants worth re-reading before anyone touches this pipeline again.🤖 Generated with Claude Code