fix(payments): pending-payment-aware checkout dedup guard (#4438)#4456
Conversation
…t planKey on pending payment rows (#4438) Adds metadata.wm_plan_key at checkout-session create (resolveProductToPlan) and persists it on the paymentEvents row from data.metadata.wm_plan_key, so a pending 3DS payment can be resolved to its PRODUCT_CATALOG tierGroup. Optional/ backward-compatible: legacy in-flight sessions simply carry no planKey. Claude-Session: https://claude.ai/code/session_01C2WR72ZeDg1vBQQpcVzaYi
…g payment (#4438) Adds getBlockingPendingPayment (15-min staleness window, tier-group scoped, fails open when planKey unresolvable) and enforces it in both checkout actions after the subscription guard, skippable via bypassPendingGuard. Threads the bypass + forwards the pendingPayment block context through the relay route and edge gateway. A pending Pro payment never blocks an API checkout (different tier group); the subscription guard still wins when both apply. Claude-Session: https://claude.ai/code/session_01C2WR72ZeDg1vBQQpcVzaYi
…xonomy (#4438) Maps the backend PAYMENT_IN_PROGRESS 409 block to a typed, user-safe payment_in_progress code (non-retryable — recovery is a dialog confirmation, not a network retry), parallel to duplicate_subscription. Claude-Session: https://claude.ai/code/session_01C2WR72ZeDg1vBQQpcVzaYi
…4438) On a PAYMENT_IN_PROGRESS 409, both the dashboard and /pro surfaces now show a 'payment in progress — start a new checkout?' dialog instead of navigating. Confirm re-invokes startCheckout with bypassPendingGuard:true (skips the guard, proceeds to the hosted redirect); cancel is inert. Dashboard dialog mirrors checkout-duplicate-dialog (services layer); /pro uses an inline DOM dialog (separate build, no shared components). The attempt is preserved (recoverable). Claude-Session: https://claude.ai/code/session_01C2WR72ZeDg1vBQQpcVzaYi
) pro-test/ source changed (U4 /pro dialog); public/pro/ is the committed bundle Vercel ships (it does not rebuild pro-test on deploy), so the rebuild must land with the source change. Enforced by .husky/pre-push. Claude-Session: https://claude.ai/code/session_01C2WR72ZeDg1vBQQpcVzaYi
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 Tip: Enable Workflows to automatically generate PRs for you. |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Greptile SummaryThis PR extends the checkout dedup guard to also block new checkouts when a recent same-tier pending 3DS/SCA payment exists, addressing a gap that allowed customers to stack duplicate "Requires customer action" payments. The block surfaces as a confirm-to-proceed dialog (soft friction with a bypass), and the plan key is threaded through checkout-session metadata so the webhook can persist tier-group information on the
Confidence Score: 4/5The core dedup logic is correct — tier-group scoping, fail-open, bypass, and test coverage are all solid. The main risk is edge-case messaging: stale The guard, metadata bridge, schema change, and dialog wiring are all correct and well-tested. The only substantive concern is that convex/payments/billing.ts — the guard query and the stale-row edge case; pro-test/src/services/checkout.ts — accessibility attributes on the inline dialog. Important Files Changed
Sequence Diagram%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant U as User
participant C as Client (checkout.ts)
participant E as Edge (/api/create-checkout)
participant R as Relay (convex/http.ts)
participant A as internalCreateCheckout
participant SG as Subscription Guard
participant PG as Pending Guard (NEW)
participant D as Dodo Payments
U->>C: startCheckout(productId)
C->>E: POST /api/create-checkout
E->>R: relay to Convex
R->>A: internalCreateCheckout(userId, productId)
A->>SG: getCheckoutBlockingSubscription
alt Active subscription in same tier
SG-->>A: blocking sub
A-->>C: 409 ACTIVE_SUBSCRIPTION_EXISTS
C->>U: showDuplicateSubscriptionDialog
else No active subscription
SG-->>A: null
A->>PG: getBlockingPendingPayment
alt Recent pending 3DS, same tier, no bypass
PG-->>A: blocking pending payment
A-->>C: 409 PAYMENT_IN_PROGRESS
C->>U: showCheckoutPendingDialog
U->>C: onConfirm
C->>E: "POST bypassPendingGuard=true"
Note over E,D: Guard skipped
else No blocking pending payment
PG-->>A: null
A->>D: create session + wm_plan_key
D-->>C: checkout_url
C->>U: navigate to Dodo hosted checkout
end
end
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant U as User
participant C as Client (checkout.ts)
participant E as Edge (/api/create-checkout)
participant R as Relay (convex/http.ts)
participant A as internalCreateCheckout
participant SG as Subscription Guard
participant PG as Pending Guard (NEW)
participant D as Dodo Payments
U->>C: startCheckout(productId)
C->>E: POST /api/create-checkout
E->>R: relay to Convex
R->>A: internalCreateCheckout(userId, productId)
A->>SG: getCheckoutBlockingSubscription
alt Active subscription in same tier
SG-->>A: blocking sub
A-->>C: 409 ACTIVE_SUBSCRIPTION_EXISTS
C->>U: showDuplicateSubscriptionDialog
else No active subscription
SG-->>A: null
A->>PG: getBlockingPendingPayment
alt Recent pending 3DS, same tier, no bypass
PG-->>A: blocking pending payment
A-->>C: 409 PAYMENT_IN_PROGRESS
C->>U: showCheckoutPendingDialog
U->>C: onConfirm
C->>E: "POST bypassPendingGuard=true"
Note over E,D: Guard skipped
else No blocking pending payment
PG-->>A: null
A->>D: create session + wm_plan_key
D-->>C: checkout_url
C->>U: navigate to Dodo hosted checkout
end
end
|
…honest dialog copy (#4438) Addresses adversarial review of PR #4456: - BLOCKING: paymentEvents is append-only, so a 3DS payment that went processing -> failed/succeeded left its pending row behind and the guard falsely blocked the retry path for the whole 15-min window. Now dedup by dodoPaymentId: a payment only blocks if it has NO terminal (non-pending) charge row. Tests for processing+failed / processing+succeeded coexistence. - Bounded read: query the new by_userId_occurredAt index with a range on occurredAt instead of collecting the user's whole (unbounded, rawPayload- carrying) history — keeps the guard fail-open, not fail-closed. - Dialog copy: drop the unenforceable 'will not charge you twice' guarantee; offer honest refund recourse instead (both dashboard + /pro). Claude-Session: https://claude.ai/code/session_01C2WR72ZeDg1vBQQpcVzaYi
|
Thanks for the adversarial pass — the BLOCKING finding was a real bug. Addressed in ✅ Finding 1 (BLOCKING) — stale pending rows from terminal payments — fixedYou were exactly right:
✅ Finding 2 (SUGGESTION) — unbounded
|
…11y for /pro pending dialog (#4438) Greptile review cleanups (its #1/#2 were already fixed in 7d6ba08): - Declare subscription + pendingPayment on CheckoutErrorBody (the two 409 block shapes sharing the route) and drop the type-intersection casts at both call sites; remove the now-unused CheckoutErrorBody import. - Add aria-labelledby + titled heading to the /pro pending dialog, matching the dashboard counterpart. Claude-Session: https://claude.ai/code/session_01C2WR72ZeDg1vBQQpcVzaYi
|
Thanks @greptileai — your pass landed on ✅ #1 — unbounded
|
|
All four items resolved — thanks for the detailed rundown. The append-only dedup via This looks good to merge from my end. |
…t log, neutral 409 fallback (#4438) Multi-agent code review (11 reviewers) findings, all independently validated: - P1 fail-open: wrap getCheckoutBlockingPendingPayment's runQuery in try/catch -> null. A Convex infra throw was propagating to relay 500 -> edge 502, fail-CLOSING the checkout against the guard's documented fail-open contract. - P2 perf: run the subscription + pending guards via Promise.all (no shared data); subscription block still wins, bypassPendingGuard still skips the pending query. Saves a round-trip on every checkout. - Observability: console.info audit trail when the pending guard is bypassed (the original incident was undetected stacked payments). - P3: edge 409 fallback || 'ACTIVE_SUBSCRIPTION_EXISTS' -> ?? 'CHECKOUT_BLOCKED' so a missing block code can't misroute a PAYMENT_IN_PROGRESS to the wrong dialog. - Tests: anchor the staleness offset to PENDING_PAYMENT_BLOCK_WINDOW_MS; add api_business cross-tier non-block + catalog-miss fail-open coverage. Claude-Session: https://claude.ai/code/session_01C2WR72ZeDg1vBQQpcVzaYi
… dialogs (#4438 P2 review) Maintainability finding (conf 100): checkout-pending-dialog.ts and checkout-duplicate-dialog.ts shared ~130 lines of identical backdrop/card/ lifecycle DOM in the same src/services/ build. Extracted into checkout-dialog-factory.ts (showCheckoutConfirmDialog); both dialogs are now thin delegates passing only their id + copy + button labels. Public APIs (showDuplicateSubscriptionDialog / showCheckoutPendingDialog) and option interfaces unchanged — call sites and the stubbed dialog tests are unaffected. A future fix to keyboard/focus/listener-cleanup now lands in one place. (The /pro inline dialog stays duplicated — separate build, cannot import src/.) Claude-Session: https://claude.ai/code/session_01C2WR72ZeDg1vBQQpcVzaYi
The pre-push Sentry-coverage guard flagged the new fail-open catch (#4438 review): a catch that logs via console.error but neither calls captureSilentError nor re-throws. Re-throwing would defeat the fail-open contract (the whole point — a transient guard-query error must not block checkout), and Convex auto-Sentry forwards the structured console.error, so on-call still sees it. Added the sentry-coverage-ok marker with justification, matching the convex precedent in subscriptionHelpers.ts. Claude-Session: https://claude.ai/code/session_01C2WR72ZeDg1vBQQpcVzaYi
|
Code review pass complete. No actionable findings from the remote PR-head review.\n\nChecked the pending-payment guard wiring, |
What & why
Closes #4438 (epic #4440). The original 3DS "stuck payments" incident let a customer stack 4–5 duplicate payments all in "Requires customer action": the failure-retry path creates a brand-new Dodo checkout each time, and the existing dedup guard (
getCheckoutBlockingSubscription) only inspects thesubscriptionstable — which a pending 3DS payment never created. The redirect-mode fix (#4454) addressed the hang but left the stacking open.This extends checkout dedup to also block a new checkout when the user has a recent pending payment in the same tier group, surfacing a confirm-to-proceed dialog (the same UX family as the duplicate-subscription block) with an explicit escape hatch. Pro and API are different tier groups, so a pending Pro payment never blocks an API checkout.
How it works
The pending signal already lives in
paymentEvents(statusprocessing/requires_customer_action, persisted by #4436/#4446). The one missing piece was the tier group of a pending payment — threaded through the existing checkout-metadata bridge:Implementation (4 units, test-first)
metadata.wm_plan_keyat session create (resolveProductToPlan); optionalplanKeyonpaymentEvents; webhook persists it. Backward-compatible — legacy in-flight sessions simply carry noplanKey.getBlockingPendingPayment(15-min staleness window, tier-group scoped, fails open whenplanKeyis unresolvable). Enforced in both checkout actions after the subscription guard (which still wins), skippable viabypassPendingGuard. Bypass + thependingPaymentblock context are threaded through the relay route and edge gateway.payment_in_progresserror code (non-retryable — recovery is a dialog confirmation, not a network retry), mapping the backendPAYMENT_IN_PROGRESS409./pro(inline DOM dialog, separate build). Confirm re-invokes withbypassPendingGuard: true; cancel is inert; the attempt is preserved (recoverable).Tier-group acceptance matrix (the reviewer's case is pinned)
bypassPendingGuard:trueplanKeyDesign decisions
bypassPendingGuardkeeps the block as confirmation friction. The provider-side backstop (Dodo "Allow Multiple Subscriptions" + the subscription guard) is unchanged.occurredAt.Tests
convex/__tests__/billing.test.ts— the full guard matrix (tier-group scoping, staleness, status filter, fail-open, most-recent, monthly/annual parity).convex/__tests__/checkout.test.ts— action enforcement (PAYMENT_IN_PROGRESS block, subscription precedence, bypass, cross-tier).convex/__tests__/webhook.test.ts—planKeypersistence + backward-compat.tests/checkout-error-classification.test.mts— the new taxonomy code.tests/checkout-pending-dialog.test.mts— dashboard dialog wiring (show / confirm-with-bypass / cancel), end-to-end through the real taxonomy.npm run test:convex,npm run test:data,tsc(src + api + convex + pro-test) all green.Out of scope (follow-ups)
checkoutSessions.create(complementary API-layer mitigation)./proautomated test harness — its dialog is covered by parity/inspection until a runner exists.🤖 Generated with Claude Code
https://claude.ai/code/session_01C2WR72ZeDg1vBQQpcVzaYi