Skip to content

fix(funding): close the three pipeline races + a live-Stripe backstop (#12) - #75

Open
keithfawcett wants to merge 3 commits into
mainfrom
fix/funding-race-hardening
Open

fix(funding): close the three pipeline races + a live-Stripe backstop (#12)#75
keithfawcett wants to merge 3 commits into
mainfrom
fix/funding-race-hardening

Conversation

@keithfawcett

Copy link
Copy Markdown
Contributor

Closes the rest of #12 from docs/audit-remaining-work.md (the pagination subfix shipped as #71).

HOSTED_FUNDING_ENABLED is 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_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 — 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 create returns. 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:

  • Create side: 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 failureReason=orphan_payment_intent:<pi> and a loud alert — never left silent.
  • Release side: it 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.

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 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 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 paid and 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.

runFundingCollector had no test coverage at all before this. Full suite: 266 passing. pnpm typecheck + pnpm lint clean. No migration.

Runbook

docs/payout-funding-staging-runbook.md gains 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…) — a G4 row for the stuck-claim alert, and the four invariants worth re-reading before anyone touches this pipeline again.

🤖 Generated with Claude Code

…#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>
keithfawcett added a commit that referenced this pull request Aug 9, 2026
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>
@keithfawcett

Copy link
Copy Markdown
Contributor Author

Adversarial review pass (Codex, xhigh) — seven fixes in 18b72eb

All four claims were refuted. The one that matters most is that my own inbox fix was incomplete.

  1. The inbox still lost events (high). 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 landed inside the 5-minute lease: the second worker acked it and Stripe never delivered again. I'd turned "lost forever" into "lost on a five-minute fuse". The claim now reports claimed/done/held; held raises InboxEventHeldError and the route answers 409 so Stripe redelivers after the lease expires.

  2. The lease had no owner (high). 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 (high). Once a partial was recorded, a lost webhook completing the reversal was invisible to the only backstop that exists. Only a fully reversed payout is finished.

  4. Both sweep caps starved (medium). Taking the oldest 50 of an ever-growing list re-checked the same 50 nightly while newer batches — the only ones that can still change — were never checked. Now bounded by the 180-day reversal horizon, and anything past the cap is reported by id, not silently truncated.

  5. An orphaned PaymentIntent left its allocations freed (high). 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 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 (high, pre-existing, now reachable). A release stopping on a Stripe failure returned pi_not_terminal, but no collector state matched that status and a second releaseBatch just lost the CAS — the batch sat forever with frozen allocations and no alert. My comment claimed "next tick retries"; it didn't. Now a re-entrant source state, resumed every tick, alerted after a day.

  7. Payment-wins skipped verification (high, pre-existing). 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 and freezes explicitly if verification refuses.

Plus: the executor re-reads batch status before each partner transfer (a dispute freezing the batch mid-run now actually stops it), and the CommissionAdjustment clawback is serialized under a row lock — check-then-insert with no unique constraint could double-record when a redelivery and the sweep overlap.

12 new/updated tests; suite 274 passing. Runbook gains H9–H12 and the corrected invariants.

Still true after this pass (limits, not bugs)

paymentIntents.search is eventually consistent, so a false empty is always possible — the search reduces the double-charge window but cannot eliminate it. That's why the status-predicated stamp and the orphan-cancel path exist on the other side, and why the staging matrix (section H) is the real gate. Codex confirmed the stripe-node error shape used by paymentIntentIdFromError is correct and the claim SQL is race-free.

…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';
@keithfawcett

Copy link
Copy Markdown
Contributor Author

Round-2 review (Codex, xhigh) — five more, fixed in f0f7578

The headline finding is that round-1's orphan recovery could not run in the case it was written for.

  1. BLOCKER — the reclaim sat behind a call that raises. abandonOrphanPaymentIntent escalated the batch to recovery_required before reclaiming its allocations. But recovery_required is inside the "one open batch per tenant/currency" partial 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: precisely the double-charge the path exists to prevent. Reclaim now runs first and unconditionally; the escalation is best-effort; the alert names how many allocations a newer batch had already taken.
  2. The same index broke refund/dispute handling (high). charge.refunded on a settled batch tried to drag it back to funding_disputed and took the webhook down with it (and the sweep logged an exception). A settled batch has already transferred, so freezing buys nothing — non-terminal batches are frozen, terminal ones record the clawback and alert. Webhook and sweep now share one function so they can't drift.
  3. The sweep still starved (high). Reporting skipped ids made the cap visible without changing the scheduling — a fixed order plus a fixed prefix re-checked the same head nightly. Rows are now dealt by a per-day hash, so coverage is eventually complete.
  4. The 180-day horizon was wrong for transfers (high). Refunds have a deadline; transfer reversals don't — Stripe places no age limit on them. The horizon now applies only to the charge sweep.
  5. Resuming a release reset its own alert clock (medium). casBatch bumps updatedAt and reconcile decides "stuck" from updatedAt, so a release failing every tick refreshed the 24h alert forever. Re-entry no longer rewrites the row.

Also: transfer reversals are now 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.

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. paymentIntents.search remains eventually consistent. Both are why section H of the staging runbook is the actual gate.

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 CommissionAdjustment insert.

5 new tests, including the blocker with a competing newer batch. Suite: 279 passing.

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.

1 participant