Skip to content

fix(payments): persist pending/3DS payment webhook states (#4436)#4446

Merged
koala73 merged 3 commits into
mainfrom
fix/dodo-webhook-pending-payment-4436
Jun 26, 2026
Merged

fix(payments): persist pending/3DS payment webhook states (#4436)#4446
koala73 merged 3 commits into
mainfrom
fix/dodo-webhook-pending-payment-4436

Conversation

@koala73

@koala73 koala73 commented Jun 26, 2026

Copy link
Copy Markdown
Owner

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, and payment.cancelled previously fell through the webhook dispatcher's default branch 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 — extend paymentEventStatus with processing / requires_customer_action / cancelled. Union-widening only: existing rows keep valid values, and no consumer switches exhaustively on status (the one reader, billing.ts anon-claim, just reassigns userId).
  • convex/payments/subscriptionHelpers.ts — route via an explicit PAYMENT_EVENT_STATUS event→status map, replacing the binary endsWith(".succeeded") ? … : "failed" derivation that mislabeled every non-succeeded charge as failed.
  • convex/payments/webhookMutations.ts — add the three events to the handlePaymentOrRefundEvent case group so they persist a paymentEvents row instead of hitting default.

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 as processed" cleanup from #4436 is intentionally left out to keep this migration focused.

Tests (test-first)

  • Each new event persists a paymentEvents row with the correct status + type: charge.
  • A requires_customer_actionsucceeded sequence with distinct webhookIds persists both rows — proving dedup is by webhookId and later transitions still process.
  • Wrote red first (4 failing), then green. Full suite: 462 convex tests pass, tsc -p convex clean.

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

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
@vercel

vercel Bot commented Jun 26, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
worldmonitor Ready Ready Preview, Comment Jun 26, 2026 8:49am

Request Review

@koala73 koala73 added bug Something isn't working P0 Critical, blocks release or core functionality critical Critical issue requiring immediate attention area: API Backend API, sidecar, keys labels Jun 26, 2026
@greptile-apps

greptile-apps Bot commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a critical visibility gap in the Dodo Payments webhook pipeline: payment.processing, payment.requires_customer_action, and payment.cancelled events were silently dropped by the default branch of the dispatcher, leaving 3DS/SCA payments with no record in Convex. The fix routes all three through handlePaymentOrRefundEvent and replaces the old binary endsWith(".succeeded") ? … : "failed" status derivation with a lookup map.

  • convex/schema.ts: Additive union widening of paymentEventStatus with three new literals (processing, requires_customer_action, cancelled); fully backward-compatible, no backfill needed.
  • convex/payments/webhookMutations.ts + subscriptionHelpers.ts: Three new case labels route through the existing handlePaymentOrRefundEvent, and a PAYMENT_EVENT_STATUS map replaces the old binary derivation, correctly labeling all event types for both payment and refund events.
  • convex/__tests__/webhook.test.ts: Parameterized test for each new event type plus a two-event sequence test proving dedup is strictly by webhookId, not dodoPaymentId.

Confidence Score: 4/5

Safe to merge — the schema change is purely additive, no existing rows are affected, and the routing change only adds cases to a switch that previously dropped those events entirely.

The map lookup with a ?? "failed" fallback in subscriptionHelpers.ts is an improvement over the old binary derivation, but it can still silently mislabel any future event type added to the switch without a matching map entry. The four changed files are well-tested and all new paths are covered.

convex/payments/subscriptionHelpers.ts — the PAYMENT_EVENT_STATUS map's loose Record<string, …> key type means the map and switch can drift without a compile error.

Important Files Changed

Filename Overview
convex/schema.ts Additive union widening of paymentEventStatus with three new literals — backward-compatible, no existing rows need migration.
convex/payments/webhookMutations.ts Three new case labels correctly route payment.processing, payment.requires_customer_action, and payment.cancelled through handlePaymentOrRefundEvent; no fallthrough or break issues.
convex/payments/subscriptionHelpers.ts PAYMENT_EVENT_STATUS map correctly maps all routed event types; the ?? "failed" fallback silently mislabels any future unrecognized event added to the switch without a corresponding map entry.
convex/tests/webhook.test.ts Good coverage: parameterized tests for each new event type and a sequence test proving webhookId-only dedup allows distinct state-transition webhooks for the same payment to both persist.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Dodo as Dodo Payments
    participant HTTP as HTTP Action Handler
    participant WM as processWebhookEvent
    participant SH as handlePaymentOrRefundEvent
    participant DB as Convex DB

    Dodo->>HTTP: POST /webhook (payment.requires_customer_action)
    HTTP->>WM: internalMutation(webhookId, eventType, payload, ts)
    WM->>DB: query webhookEvents by webhookId
    DB-->>WM: null (not yet processed)
    WM->>SH: handlePaymentOrRefundEvent(ctx, data, eventType, ts)
    SH->>DB: resolveUserId(customer_id, metadata)
    DB-->>SH: userId
    Note over SH: PAYMENT_EVENT_STATUS[eventType] → "requires_customer_action"
    SH->>DB: insert paymentEvents row (status: requires_customer_action)
    WM->>DB: insert webhookEvents row (status: processed)

    Note over Dodo,DB: Later — customer completes 3DS

    Dodo->>HTTP: POST /webhook (payment.succeeded, new webhookId)
    HTTP->>WM: internalMutation(webhookId2, "payment.succeeded", payload, ts2)
    WM->>DB: query webhookEvents by webhookId2
    DB-->>WM: null (distinct webhookId — not a duplicate)
    WM->>SH: handlePaymentOrRefundEvent(ctx, data, "payment.succeeded", ts2)
    SH->>DB: insert paymentEvents row (status: succeeded)
    WM->>DB: insert webhookEvents row (status: processed)
Loading
%%{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 Dodo as Dodo Payments
    participant HTTP as HTTP Action Handler
    participant WM as processWebhookEvent
    participant SH as handlePaymentOrRefundEvent
    participant DB as Convex DB

    Dodo->>HTTP: POST /webhook (payment.requires_customer_action)
    HTTP->>WM: internalMutation(webhookId, eventType, payload, ts)
    WM->>DB: query webhookEvents by webhookId
    DB-->>WM: null (not yet processed)
    WM->>SH: handlePaymentOrRefundEvent(ctx, data, eventType, ts)
    SH->>DB: resolveUserId(customer_id, metadata)
    DB-->>SH: userId
    Note over SH: PAYMENT_EVENT_STATUS[eventType] → "requires_customer_action"
    SH->>DB: insert paymentEvents row (status: requires_customer_action)
    WM->>DB: insert webhookEvents row (status: processed)

    Note over Dodo,DB: Later — customer completes 3DS

    Dodo->>HTTP: POST /webhook (payment.succeeded, new webhookId)
    HTTP->>WM: internalMutation(webhookId2, "payment.succeeded", payload, ts2)
    WM->>DB: query webhookEvents by webhookId2
    DB-->>WM: null (distinct webhookId — not a duplicate)
    WM->>SH: handlePaymentOrRefundEvent(ctx, data, "payment.succeeded", ts2)
    SH->>DB: insert paymentEvents row (status: succeeded)
    WM->>DB: insert webhookEvents row (status: processed)
Loading

Reviews (1): Last reviewed commit: "fix(payments): persist pending/3DS payme..." | Re-trigger Greptile

Comment thread convex/payments/subscriptionHelpers.ts Outdated
…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
@koala73

koala73 commented Jun 26, 2026

Copy link
Copy Markdown
Owner Author

Both review points validated and fixed in ce16e87d8.

#1 (P1) — payment.requires_customer_action is not a real event type ✅ Confirmed, was dead code

Verified two independent ways:

  • Installed SDK node_modules/dodopayments/resources/webhook-events.d.ts:13WebhookEventType payment events are exactly payment.succeeded | payment.failed | payment.processing | payment.cancelled. No payment.requires_customer_action.
  • IntentStatus (payments.d.ts:93) — requires_customer_action is a payload data.status value, and Payment.status?: IntentStatus | null (payments.d.ts:254).
  • Official docs (Webhook Event Guide + Create-Webhook filter_types) agree: the 3DS/SCA-pending state rides inside the payment.processing event as data.status: "requires_customer_action"; there is no distinct event.

Fix: dropped the dead payment.requires_customer_action case; route the real payment.processing event and read data.status to distinguish plain processing from requires_customer_action. Tests now drive payment.processing + data.status (incl. the 3DS-pending → succeeded distinct-webhookId sequence).

#2 (P2) — Record<string> + ?? "failed" allows silent drift ✅ Fixed

Replaced the string-keyed map + silent fallback with a typed RoutedPaymentEvent union and an exhaustive derivePaymentEventStatus switch with a never default that throws. Now:

  • adding a routed event to the union without a switch case → compile error;
  • routing a new event in webhookMutations.ts without updating the union → the never default throws loudly (webhook 500 → Sentry), never a silent failed mislabel.

462 convex tests green, tsc -p convex clean.

…-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.
@koala73

koala73 commented Jun 26, 2026

Copy link
Copy Markdown
Owner Author

Good catch — valid precision nit. Fixed in 7e82d66be (comment-only).

You're right: the compile-time exhaustiveness guarantee is intra-file only (the RoutedPaymentEvent union ↔ the derivePaymentEventStatus switch). Cross-file drift — a new case in webhookMutations.ts — is laundered by the eventType as RoutedPaymentEvent cast at the call site, so it's caught at runtime by the never-default throw, not at compile time. The old comment conflated the two; it now spells out both guards and their different enforcement. No behavior change.

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

Labels

area: API Backend API, sidecar, keys bug Something isn't working critical Critical issue requiring immediate attention P0 Critical, blocks release or core functionality

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(payments): P0 — webhook drops requires_customer_action/processing → loses pending-payment signal

1 participant