Skip to content

[Low] No payment reconciliation beyond the webhook — an outage past Stripe's retry budget still strands paid orders #64

Description

@novaspatial

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):

  1. Select pending_payment projects older than N minutes that carry a stripe_payment_intent_id.
  2. 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.
  3. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestneeds-triageMaintainer needs to evaluate this issue

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions