fix(payments): persist pending/3DS payment webhook states (#4436)#4446
Conversation
Dodo `payment.processing` / `payment.requires_customer_action` / `payment.cancelled` events fell through the webhook dispatcher's `default` branch and were silently dropped. A payment stuck in 3DS/SCA therefore left no pending-payment signal, so the app could neither prevent duplicate retries (#4438) nor reconcile/recover the stuck payment (#4439). - Extend `paymentEventStatus` with `processing` / `requires_customer_action` / `cancelled` (schema union-widening; existing rows unaffected, no consumer switches exhaustively on status). - Route the three events to `handlePaymentOrRefundEvent` and replace its binary `endsWith(".succeeded") ? … : "failed"` derivation (which mislabeled every non-succeeded charge as failed) with an explicit event→status map. - Tests: each new event persists a `paymentEvents` row with the correct status; a `requires_customer_action` → `succeeded` sequence (distinct webhookIds) persists BOTH rows, proving dedup is by `webhookId` and later distinct transitions are not blocked. Refs #4436
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
…c event Review correction (validated against the installed Dodo SDK `WebhookEventType` + official docs): `payment.requires_customer_action` is NOT a Dodo webhook event type — the 3DS/SCA-pending state is delivered as a `payment.processing` event whose payload `data.status` (IntentStatus) is `requires_customer_action`. The prior case was dead code that would never fire. - Drop the dead `payment.requires_customer_action` switch case; route the real `payment.processing` event and read `data.status` to distinguish plain `processing` from `requires_customer_action`. - Replace the `Record<string, …>` event->status map + `?? "failed"` fallback with a typed `RoutedPaymentEvent` union and an exhaustive `derivePaymentEventStatus` switch that throws on an unrouted event — map/ switch drift is now a compile error or a loud throw, never a silent succeeded/failed mislabel. - Tests: drive `payment.processing` + `data.status` for the pending case. Refs #4436
|
Both review points validated and fixed in #1 (P1) —
|
…-event comment The RoutedPaymentEvent comment implied cross-file drift (a new webhookMutations.ts case) fails to compile. It does not — the call-site `eventType as RoutedPaymentEvent` cast launders it, so that direction is caught at runtime by the never-default throw. Only the intra-file union<->switch mismatch is a compile error. Comment corrected; no behavior change.
|
Good catch — valid precision nit. Fixed in You're right: the compile-time exhaustiveness guarantee is intra-file only (the |
What
Persist Dodo's non-terminal / abandoned payment webhook states so a payment stuck in 3DS/SCA is no longer invisible to us.
payment.processing,payment.requires_customer_action, andpayment.cancelledpreviously fell through the webhook dispatcher'sdefaultbranch and were silently dropped — so a payment sitting in "Requires customer action" on Dodo's dashboard left no record on our side. That's the core blindness behind the stuck-payments incident: with no pending-payment signal we can neither prevent duplicate retries (#4438) nor reconcile/recover the stuck payment (#4439).Changes
convex/schema.ts— extendpaymentEventStatuswithprocessing/requires_customer_action/cancelled. Union-widening only: existing rows keep valid values, and no consumer switches exhaustively on status (the one reader,billing.tsanon-claim, just reassignsuserId).convex/payments/subscriptionHelpers.ts— route via an explicitPAYMENT_EVENT_STATUSevent→status map, replacing the binaryendsWith(".succeeded") ? … : "failed"derivation that mislabeled every non-succeeded charge asfailed.convex/payments/webhookMutations.ts— add the three events to thehandlePaymentOrRefundEventcase group so they persist apaymentEventsrow instead of hittingdefault.Scope note
This is not an idempotency-corruption fix. Dedup is strictly by
webhookId(webhookMutations.ts:36), so later distinct transitions were never blocked — the harm was the lost pending-payment signal. The optional "stop labeling truly-unhandled events asprocessed" cleanup from #4436 is intentionally left out to keep this migration focused.Tests (test-first)
paymentEventsrow with the correct status +type: charge.requires_customer_action→succeededsequence with distinctwebhookIds persists both rows — proving dedup is bywebhookIdand later transitions still process.tsc -p convexclean.Deploy
Convex schema migration (additive union widening) — backward-compatible, no data backfill.
Part of #4440. Sibling P0 #4434 (return_url) is independent (frontend) and ships separately.
Closes #4436