Skip to content

feat(payments): P2 — daily reconciliation cron for payments stuck in requires_customer_action #4470

Description

@koala73

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):

  1. 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.
  2. 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.
  3. Idempotent + bounded — stamp a reconciliation marker so the same payment is not re-notified on every run; cap the batch.

Acceptance criteria

  • A cron in convex/crons.ts runs on a schedule and processes stuck-pending payments.
  • Reconciles dropped-webhook cases by re-persisting the authoritative Dodo status.
  • Notifies / cancels genuinely-stuck payments past the threshold, idempotently (no repeat nags).
  • Emits an ops signal (Sentry) with counts.
  • Tests: stuck-past-threshold → action; recently-pending (within window) → skipped; already-terminal (append-only coexistence) → skipped; idempotent re-run.

Scope / notes

Context: epic #4440 · root cause #4449 · pending-signal #4436 · dedup guard #4438 (PR #4456) · retired watchdog #4439.

Metadata

Metadata

Assignees

No one assigned

    Labels

    paymentPayment, billing, subscription, checkout, and entitlement lifecycle

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions