Split out from epic #4440 — the P2 follow-up that was flagged ("not yet split out") but never turned into its own issue. Every other #4440 child has shipped (#4434, #4435, #4436, #4437, #4438, #4439, #4449); this is the one remaining deliverable, so #4440 is being closed in favor of this.
Problem
The redirect-mode fix (#4449) made the 3DS overlay hang rare, and the pending-payment dedup guard (#4438, PR #4456) blocks a duplicate on the next attempt — but neither actively recovers a payment that still ends up stuck in requires_customer_action (user abandons 3DS, bank ACS fails silently, a webhook gets dropped, etc.). The entitlement watchdog that used to be the only post-checkout safety net was retired as a silent no-op (#4439). So today a genuinely-stuck payment just rots: no recovery, no notification, no ops signal.
Verified: convex/crons.ts has no payment-reconciliation job today (zero reconcil / requires_customer_action / pending-payment references; the scheduled crons are all unrelated daily/hourly jobs).
What's now available (makes this cheap)
paymentEvents already persists the pending signal (#4436/#4438):
status ∈ {processing, requires_customer_action} rows, append-only.
planKey (tier group), dodoPaymentId, dodoSubscriptionId, occurredAt.
- Indexes:
by_userId, by_dodoPaymentId, by_userId_occurredAt.
So a cron can find stuck-pending payments by scanning recent paymentEvents and excluding any dodoPaymentId that later reached a terminal row — the same resolvedPaymentIds dedup the guard already uses in convex/payments/billing.ts (getBlockingPendingPayment).
Proposed work
A scheduled Convex cron (convex/crons.ts, daily or every few hours):
- Find payments stuck in
requires_customer_action / processing past a threshold (e.g. > N hours — well beyond the 15-min dedup window) with no terminal row for the same dodoPaymentId.
- For each, poll Dodo (
GET /payments/{id}) for the authoritative current status — the webhook may have been dropped:
- Terminal now (succeeded / failed / cancelled) → persist the missing terminal
paymentEvents row so the guard, entitlement, and dedup state catch up (reconcile).
- Still genuinely pending past the threshold → cancel the stale Dodo payment and/or email the customer a fresh checkout link; surface to ops (Sentry) so it is visible.
- Idempotent + bounded — stamp a reconciliation marker so the same payment is not re-notified on every run; cap the batch.
Acceptance criteria
Scope / notes
Context: epic #4440 · root cause #4449 · pending-signal #4436 · dedup guard #4438 (PR #4456) · retired watchdog #4439.
Split out from epic #4440 — the P2 follow-up that was flagged ("not yet split out") but never turned into its own issue. Every other #4440 child has shipped (#4434, #4435, #4436, #4437, #4438, #4439, #4449); this is the one remaining deliverable, so #4440 is being closed in favor of this.
Problem
The redirect-mode fix (#4449) made the 3DS overlay hang rare, and the pending-payment dedup guard (#4438, PR #4456) blocks a duplicate on the next attempt — but neither actively recovers a payment that still ends up stuck in
requires_customer_action(user abandons 3DS, bank ACS fails silently, a webhook gets dropped, etc.). The entitlement watchdog that used to be the only post-checkout safety net was retired as a silent no-op (#4439). So today a genuinely-stuck payment just rots: no recovery, no notification, no ops signal.Verified:
convex/crons.tshas no payment-reconciliation job today (zeroreconcil/requires_customer_action/ pending-payment references; the scheduled crons are all unrelated daily/hourly jobs).What's now available (makes this cheap)
paymentEventsalready persists the pending signal (#4436/#4438):status ∈ {processing, requires_customer_action}rows, append-only.planKey(tier group),dodoPaymentId,dodoSubscriptionId,occurredAt.by_userId,by_dodoPaymentId,by_userId_occurredAt.So a cron can find stuck-pending payments by scanning recent
paymentEventsand excluding anydodoPaymentIdthat later reached a terminal row — the sameresolvedPaymentIdsdedup the guard already uses inconvex/payments/billing.ts(getBlockingPendingPayment).Proposed work
A scheduled Convex cron (
convex/crons.ts, daily or every few hours):requires_customer_action/processingpast a threshold (e.g. > N hours — well beyond the 15-min dedup window) with no terminal row for the samedodoPaymentId.GET /payments/{id}) for the authoritative current status — the webhook may have been dropped:paymentEventsrow so the guard, entitlement, and dedup state catch up (reconcile).Acceptance criteria
convex/crons.tsruns on a schedule and processes stuck-pending payments.Scope / notes
checkoutSessions.create(the pre-webhook live-retry race surfaced in the PR fix(payments): pending-payment-aware checkout dedup guard (#4438) #4456 review, finding Add power grid stress & blackout alert layer #7) — closes the small window where a rapid retry stacks a payment before its webhook lands. Could also be its own issue.Context: epic #4440 · root cause #4449 · pending-signal #4436 · dedup guard #4438 (PR #4456) · retired watchdog #4439.