Filed by AI, splitting out the open question at the bottom of #63 so it stops living in a closed issue's tail.
Context
Once #63 lands, the system has exactly two payment-claim paths, both push-shaped: the Stripe webhook (durable finalizer, ~3 days of retries) and the 30s in-page poll (alive only while the checkout tab is). There is still no pull-shaped reconciliation: if the webhook endpoint fails past Stripe's retry budget — a misconfigured redeploy that nobody notices for days, an expired/rotated secret, a Stripe outage — a captured payment can once again end up with no paid_at, no receipt, and a held coupon reservation. The same stranding #63 describes, only far rarer and bounded by the retry budget instead of 30 seconds.
Silent, too: alertMoneyPathAnomaly fires on metadata mismatch, never on a payment nothing looks at. A failing webhook shows up in the Stripe dashboard's delivery list — but only if someone is looking at it.
Proposal sketch
A third daily cron in the existing pattern (CRON_SECRET-gated bearer auth, registered in vercel.json, service-role client — same shape as purge-delivered and sweep-orphans):
- Select
pending_payment projects older than N minutes that carry a stripe_payment_intent_id.
- Retrieve each intent from Stripe. If it reads
succeeded, run the exact finalize sequence the payment-status route already implements: metadata cross-checks (fail-closed, alertMoneyPathAnomaly on mismatch) → claimProjectPayment CAS → finalizeDiscountConsumption → receipt only as the claim winner.
- Log a per-run count. A nonzero sweep is itself the alarm — it means the webhook missed something, so the count line is worth a money-path alert, not just an info log.
Cheap by construction: the CAS (paid_at IS NULL fence) and the consume RPC are already idempotent, so racing the webhook or a late poll is safe by design, and most of the handler logic can be lifted from src/app/api/portal/projects/[id]/payment-status/route.ts rather than written new.
Needs a ruling before build
- N (sweep age): long enough that the webhook and a normal 3DS settle always win first (an hour is plenty), short enough to matter.
- Whether the receipt should note anything when it arrives hours late, or ship unchanged.
Explicitly out of scope
The sweep claims paid rows only. It must not touch the accepted residual that unpaid abandoned checkouts hold coupon capacity until deleted (CLAUDE.md, deliberate residuals) — reconciling intents that read canceled/payment_failed into coupon restores would reverse a recorded ruling and needs its own issue if ever wanted.
Severity
Low / fast-follow. #63 ruled this not a prerequisite for launch: the webhook plus Stripe's retry budget covers the realistic failure window. This is defense against the tail — worth doing before payment volume makes a silent multi-day webhook outage expensive.
Context
Once #63 lands, the system has exactly two payment-claim paths, both push-shaped: the Stripe webhook (durable finalizer, ~3 days of retries) and the 30s in-page poll (alive only while the checkout tab is). There is still no pull-shaped reconciliation: if the webhook endpoint fails past Stripe's retry budget — a misconfigured redeploy that nobody notices for days, an expired/rotated secret, a Stripe outage — a captured payment can once again end up with no
paid_at, no receipt, and a held coupon reservation. The same stranding #63 describes, only far rarer and bounded by the retry budget instead of 30 seconds.Silent, too:
alertMoneyPathAnomalyfires on metadata mismatch, never on a payment nothing looks at. A failing webhook shows up in the Stripe dashboard's delivery list — but only if someone is looking at it.Proposal sketch
A third daily cron in the existing pattern (
CRON_SECRET-gated bearer auth, registered invercel.json, service-role client — same shape aspurge-deliveredandsweep-orphans):pending_paymentprojects older than N minutes that carry astripe_payment_intent_id.succeeded, run the exact finalize sequence the payment-status route already implements: metadata cross-checks (fail-closed,alertMoneyPathAnomalyon mismatch) →claimProjectPaymentCAS →finalizeDiscountConsumption→ receipt only as the claim winner.Cheap by construction: the CAS (
paid_at IS NULLfence) and the consume RPC are already idempotent, so racing the webhook or a late poll is safe by design, and most of the handler logic can be lifted fromsrc/app/api/portal/projects/[id]/payment-status/route.tsrather than written new.Needs a ruling before build
Explicitly out of scope
The sweep claims paid rows only. It must not touch the accepted residual that unpaid abandoned checkouts hold coupon capacity until deleted (CLAUDE.md, deliberate residuals) — reconciling intents that read
canceled/payment_failedinto coupon restores would reverse a recorded ruling and needs its own issue if ever wanted.Severity
Low / fast-follow. #63 ruled this not a prerequisite for launch: the webhook plus Stripe's retry budget covers the realistic failure window. This is defense against the tail — worth doing before payment volume makes a silent multi-day webhook outage expensive.