Summary
Nothing stops a customer from spawning a fresh payment on every retry. The Dodo dashboard shows the same customer with 4-5 payments all stuck in "Requires customer action" (e.g. one card with 4 attempts). This is the expected output of our current design, not bad luck.
Evidence
- No idempotency key on
checkoutSessions.create — grep across convex/, api/, src/services/ returns zero hits in the payments path. Every create = a new session = a new payment attempt.
- The only dedup guard,
getCheckoutBlockingSubscription (convex/payments/billing.ts:765-810), blocks only when a subscriptions row already exists (active/on_hold/cancelled-with-time). A payment stuck in 3DS has not fired subscription.active, so no subscription row exists → the guard returns null → checkout proceeds.
- Frontend
_checkoutInFlight (src/services/checkout.ts:726,740) only de-dupes double-clicks within one page session; it is released in finally immediately after open() returns.
Fix direction (medium)
Block (or warn before) re-checkout when a recent payment for the user is pending 3DS. Requires the pending-payment signal from the webhook fix (see the requires_customer_action webhook issue). Cheap interim mitigations: add an idempotency key per checkout intent, and/or confirm Dodo's provider-side "Allow Multiple Subscriptions" setting is off.
Test first
Seed a pending 3DS payment for a user and assert a second createCheckout is blocked (or surfaces the resume path) instead of creating a new session.
Part of the DodoPayments 3DS stuck-payments incident. Depends on the webhook pending-state fix.
Summary
Nothing stops a customer from spawning a fresh payment on every retry. The Dodo dashboard shows the same customer with 4-5 payments all stuck in "Requires customer action" (e.g. one card with 4 attempts). This is the expected output of our current design, not bad luck.
Evidence
checkoutSessions.create— grep acrossconvex/,api/,src/services/returns zero hits in the payments path. Every create = a new session = a new payment attempt.getCheckoutBlockingSubscription(convex/payments/billing.ts:765-810), blocks only when asubscriptionsrow already exists (active/on_hold/cancelled-with-time). A payment stuck in 3DS has not firedsubscription.active, so no subscription row exists → the guard returnsnull→ checkout proceeds._checkoutInFlight(src/services/checkout.ts:726,740) only de-dupes double-clicks within one page session; it is released infinallyimmediately afteropen()returns.Fix direction (medium)
Block (or warn before) re-checkout when a recent payment for the user is pending 3DS. Requires the pending-payment signal from the webhook fix (see the
requires_customer_actionwebhook issue). Cheap interim mitigations: add an idempotency key per checkout intent, and/or confirm Dodo's provider-side "Allow Multiple Subscriptions" setting is off.Test first
Seed a pending 3DS payment for a user and assert a second
createCheckoutis blocked (or surfaces the resume path) instead of creating a new session.Part of the DodoPayments 3DS stuck-payments incident. Depends on the webhook pending-state fix.