Skip to content

feat(payments): reconcile stuck pending payments#4795

Merged
koala73 merged 3 commits into
mainfrom
codex/payments-requires-action-reconcile
Jul 6, 2026
Merged

feat(payments): reconcile stuck pending payments#4795
koala73 merged 3 commits into
mainfrom
codex/payments-requires-action-reconcile

Conversation

@koala73

@koala73 koala73 commented Jul 4, 2026

Copy link
Copy Markdown
Owner

Summary

Stuck Dodo payments now have a scheduled recovery path instead of aging forever after abandoned 3DS or dropped webhook flows. The cron finds stale unresolved pending charge events, polls Dodo for the authoritative status, appends missing terminal payment events when the webhook was lost, and marks still-pending payments once after attempting a customer continuation email.

The still-pending path falls back to an ops-visible marker and structured warning when email delivery is unavailable, so the job remains idempotent and does not re-nag on every run. Poll failures are also surfaced through a scheduled throwing mutation so Convex auto-Sentry captures them without aborting the whole batch.

Fixes #4470.

Validation

  • ./node_modules/.bin/vitest run --config vitest.config.mts convex/tests/billing.test.ts
  • npm run test:convex
  • npm run typecheck
  • npm run typecheck:api
  • node scripts/check-sentry-coverage.mjs
  • git push pre-push hook: typecheck, API typecheck, Convex type check, CJS syntax, Unicode safety, architectural boundaries, safe HTML, Sentry coverage, rate-limit policy coverage, premium-fetch parity, edge bundle check, version sync

Compound Engineering
GPT-5 Codex

@vercel

vercel Bot commented Jul 4, 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 Jul 5, 2026 4:36am

Request Review

@greptile-apps

greptile-apps Bot commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds a daily cron job that finds Dodo payments stuck in a pending state (abandoned 3DS flows or dropped webhooks) older than a 6-hour threshold, polls Dodo for the authoritative status, back-fills terminal payment events when the webhook was lost, and sends a checkout-continuation email to customers still in a pending state. A new paymentReconciliationAttempts table and a by_occurredAt index on paymentEvents back the feature.

  • Email ordering hazard: sendStuckPaymentEmail is awaited before recordStuckPaymentReconciliationOutcome inserts the idempotency marker, so a transient mutation failure or action retry causes a duplicate email on the next cron run.
  • internal as any casts: both ctx.runQuery and ctx.runMutation calls bypass Convex's generated type-safe internal reference, removing compile-time protection against signature drift.
  • N+1 queries in candidate scan: each unique dodoPaymentId in the up-to-500-row scan issues two additional DB queries (marker check + full event history), which could exhaust Convex's per-transaction document-read budget on busy tables.

Confidence Score: 3/5

The cron's core booking logic is sound and well-tested, but the email-before-marker ordering means a mutation failure or action retry sends a duplicate checkout email to the customer with no record of the first send.

The email is sent before the idempotency marker is written; a transient mutation failure leaves no marker, so the next daily run re-selects the same candidate and sends another email. The internal as any casts and N+1 query pattern are additional quality concerns that affect correctness and scalability on the changed path. Schema and cron registration are clean.

convex/payments/billing.ts — specifically the ordering of sendStuckPaymentEmail relative to the marker-writing mutation, and the two internal as any casts in reconcileStuckPendingPayments.

Important Files Changed

Filename Overview
convex/payments/billing.ts Adds stuck-payment reconciliation logic (query, mutation, action); email is sent before the idempotency marker is written, allowing duplicate customer emails on retries; internal function references bypass TypeScript with as any casts; candidate scan performs N+1 DB queries for marker and history checks.
convex/schema.ts Adds paymentReconciliationAttempts table with by_dodoPaymentId and by_reconciledAt indexes, and a new by_occurredAt index on paymentEvents; schema changes look correct and well-indexed for the new access patterns.
convex/crons.ts Registers a new daily cron at 04:30 UTC for stuck-payment reconciliation; straightforward addition with no issues.
convex/tests/billing.test.ts Adds well-structured tests for candidate selection, batch-size bounding, terminal reconciliation idempotency, still-pending ops-marker idempotency, and customer-notified idempotency; cron registration is tested via source-file read.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Cron as Daily Cron (04:30 UTC)
    participant Action as reconcileStuckPendingPayments
    participant Query as listStuckPendingPaymentCandidates
    participant Dodo as Dodo Payments API
    participant Resend as Resend Email API
    participant Mutation as recordStuckPaymentReconciliationOutcome
    participant DB as Convex DB

    Cron->>Action: trigger()
    Action->>Query: runQuery(thresholdMs, lookbackMs, batchSize)
    Query->>DB: scan paymentEvents by_occurredAt [lookback..stale)
    loop per unique pending charge event
        Query->>DB: query paymentReconciliationAttempts by_dodoPaymentId
        Query->>DB: collect paymentEvents by_dodoPaymentId (terminal check)
    end
    Query-->>Action: StuckPaymentCandidate[]

    loop per candidate
        Action->>Dodo: payments.retrieve(dodoPaymentId)
        Dodo-->>Action: payment object
        alt status still pending
            Action->>Resend: POST /emails (before marker written)
            Resend-->>Action: 200 OK or error
        end
        Action->>Mutation: runMutation(observedStatus, staleAction)
        Mutation->>DB: check paymentReconciliationAttempts
        alt terminal status
            Mutation->>DB: insert paymentEvents
        end
        Mutation->>DB: insert paymentReconciliationAttempts (marker)
        Mutation-->>Action: action result
        alt poll failed
            Action->>DB: scheduler.runAfter(reportPollFailure)
        end
    end
    Action-->>Cron: ReconcileStuckPendingPaymentsSummary
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 Cron as Daily Cron (04:30 UTC)
    participant Action as reconcileStuckPendingPayments
    participant Query as listStuckPendingPaymentCandidates
    participant Dodo as Dodo Payments API
    participant Resend as Resend Email API
    participant Mutation as recordStuckPaymentReconciliationOutcome
    participant DB as Convex DB

    Cron->>Action: trigger()
    Action->>Query: runQuery(thresholdMs, lookbackMs, batchSize)
    Query->>DB: scan paymentEvents by_occurredAt [lookback..stale)
    loop per unique pending charge event
        Query->>DB: query paymentReconciliationAttempts by_dodoPaymentId
        Query->>DB: collect paymentEvents by_dodoPaymentId (terminal check)
    end
    Query-->>Action: StuckPaymentCandidate[]

    loop per candidate
        Action->>Dodo: payments.retrieve(dodoPaymentId)
        Dodo-->>Action: payment object
        alt status still pending
            Action->>Resend: POST /emails (before marker written)
            Resend-->>Action: 200 OK or error
        end
        Action->>Mutation: runMutation(observedStatus, staleAction)
        Mutation->>DB: check paymentReconciliationAttempts
        alt terminal status
            Mutation->>DB: insert paymentEvents
        end
        Mutation->>DB: insert paymentReconciliationAttempts (marker)
        Mutation-->>Action: action result
        alt poll failed
            Action->>DB: scheduler.runAfter(reportPollFailure)
        end
    end
    Action-->>Cron: ReconcileStuckPendingPaymentsSummary
Loading

Reviews (1): Last reviewed commit: "feat(payments): reconcile stuck pending ..." | Re-trigger Greptile

Comment thread convex/payments/billing.ts Outdated
Comment on lines +1228 to +1245
const staleAction = isPendingPaymentStatus(observedStatus)
? await sendStuckPaymentEmail(dodoPayment, candidate.planKey)
: undefined;
const outcome = await ctx.runMutation(
(internal as any).payments.billing.recordStuckPaymentReconciliationOutcome,
{
userId: candidate.userId,
dodoPaymentId: candidate.dodoPaymentId,
dodoSubscriptionId: readString(dodoPayment.subscription_id) ?? candidate.dodoSubscriptionId,
planKey: candidate.planKey,
amount: readNumber(dodoPayment.total_amount) ?? readNumber(dodoPayment.amount) ?? candidate.amount,
currency: readString(dodoPayment.currency) ?? candidate.currency,
pendingOccurredAt: candidate.pendingOccurredAt,
observedStatus,
staleAction,
rawPayload: payment,
},
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Customer email sent before idempotency marker is written

sendStuckPaymentEmail is awaited at line 1229 before recordStuckPaymentReconciliationOutcome inserts the paymentReconciliationAttempts marker. If the subsequent mutation throws (transient DB error, Convex document-size limit hit, or process crash before completion), the email has already been delivered but no marker is recorded. On the next daily cron run, listStuckPendingPaymentCandidates finds no marker for that payment, selects it as a fresh candidate, and sends another email. The customer receives duplicates with no actionable difference between them.

To preserve the "notify at most once" guarantee, the marker should be committed first — ideally as a two-phase approach: claim with ops_notified, then attempt the email outside the mutation and schedule a follow-up mutation to upgrade the action to customer_notified if delivery succeeds.

Comment on lines +1200 to +1203
const candidates = await ctx.runQuery(
(internal as any).payments.billing.listStuckPendingPaymentCandidates,
args,
) as StuckPaymentCandidate[];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 internal as any casts drop all call-site type checking

Both ctx.runQuery (line 1201) and ctx.runMutation (line 1232) use (internal as any) to reference internal functions. Convex generates strongly-typed internal references; casting to any silences the type checker so any future argument rename, addition, or removal in listStuckPendingPaymentCandidates or recordStuckPaymentReconciliationOutcome would compile successfully and fail at runtime. Using the generated internal.payments.billing.* references directly (without the cast) preserves the full type contract.

Comment on lines +1055 to +1107
const now = Date.now();
const staleBefore = now - thresholdMs;
const lookbackStart = now - lookbackMs;
const paymentEvents = await ctx.db
.query("paymentEvents")
.withIndex("by_occurredAt", (q) =>
q.gt("occurredAt", lookbackStart).lt("occurredAt", staleBefore),
)
.take(MAX_STUCK_PAYMENT_RECONCILIATION_SCAN_ROWS);

const candidates: StuckPaymentCandidate[] = [];
const seenPaymentIds = new Set<string>();
for (const ev of paymentEvents) {
if (candidates.length >= batchSize) break;
if (ev.type !== "charge") continue;
if (!isPendingPaymentStatus(ev.status)) continue;
if (seenPaymentIds.has(ev.dodoPaymentId)) continue;
seenPaymentIds.add(ev.dodoPaymentId);

const marker = await ctx.db
.query("paymentReconciliationAttempts")
.withIndex("by_dodoPaymentId", (q) => q.eq("dodoPaymentId", ev.dodoPaymentId))
.first();
if (marker) continue;

const history = await ctx.db
.query("paymentEvents")
.withIndex("by_dodoPaymentId", (q) => q.eq("dodoPaymentId", ev.dodoPaymentId))
.collect();
if (history.some((row) => row.type === "charge" && isTerminalPaymentStatus(row.status))) {
continue;
}

candidates.push({
userId: ev.userId,
dodoPaymentId: ev.dodoPaymentId,
dodoSubscriptionId: ev.dodoSubscriptionId,
planKey: ev.planKey,
amount: ev.amount,
currency: ev.currency,
pendingStatus: ev.status as "processing" | "requires_customer_action",
pendingOccurredAt: ev.occurredAt,
});
}

return candidates;
},
});

export const recordStuckPaymentReconciliationOutcome = internalMutation({
args: {
userId: v.string(),
dodoPaymentId: v.string(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 N+1 query pattern inside listStuckPendingPaymentCandidates

For each unique dodoPaymentId encountered in the up-to-500-row scan, the loop issues two additional queries: one against paymentReconciliationAttempts (marker check) and one collect() against paymentEvents (terminal-event check). In the worst case this is ~2×500 = 1000 round-trips inside a single Convex query. Convex queries have a maximum of 16 384 document reads per transaction and a wall-clock budget; a populated paymentEvents table with many non-charge events in the lookback window could push this toward the limit before the full batch of candidates is filled. A compound index on ["type", "occurredAt"] would let the scan skip non-charge rows at the index layer instead of in application code.

Address the adversarial review of the stuck-pending payment cron:

- F1: collapse unrecognised non-terminal Dodo IntentStatus (incl. null /
  requires_payment_method — the typical abandoned-3DS end-state) to
  still-pending, writing a marker that records the raw observed status in
  every fall-through. Previously these returned unknown_status with NO
  marker → daily re-poll for 14 days + batch-slot starvation.
- F2: page ops for real. The still-pending ops branch now console.errors
  (Convex auto-Sentry, refund-alert precedent) instead of a never-surfaced
  console.warn.
- F3: claim the reconciliation marker BEFORE sending the customer email
  (idempotency barrier — a post-send failure can't re-email next day),
  finalize after; split failure reporting by phase (poll / email / record).
- F4: gate the "continue checkout" email on link freshness (<24h). Older
  candidates route to ops rather than emailing a dead Dodo checkout link.
- F5: scan newest-first (.order desc) so freshly-stuck rows don't fall off
  the 500-row scan cap; log when the cap is hit.
- F6: guard ctx.scheduler.runAfter with its own try/catch; add
  AbortSignal.timeout to the Resend POST; stop logging the raw Resend
  response body (can echo the recipient email).
- F7: add reconciliation tests (unknown/null status marker, marker-before-
  email ordering, already_terminal race, Resend-throw-mid-batch isolation,
  scan-cap ordering).

Claude-Session: https://claude.ai/code/session_018FBmQG921WC4s3qLmuEKoQ
…round 2)

Fold in the reliability + test findings from the ce-code-review pass:

- Bound the Dodo poll (`payments.retrieve`) with `{ timeout: 10s,
  maxRetries: 1 }`. The SDK default is 60s x 2 retries (~180s); on a Dodo
  outage one degraded poll could otherwise stall the whole sequential
  batch — the exact failure this cron exists to survive. Mirrors the
  Resend timeout already added.
- Run the reconciliation cron every 6h instead of daily. A payment
  becomes a candidate at ~6h pending, so on a daily cadence its age at
  first scan is uniformly 6h-30h and anything in (24h, 30h] misses the
  24h customer-email freshness gate — ~25% of stuck payments silently
  dropped to ops-only. At 6h cadence first-scan age stays <=~12h. Safe:
  the action is idempotent and marker-gated.
- Dropped-webhook guard: when a reconciled SUCCEEDED payment carries a
  subscription id but has no subscriptions row, console.error (Sentry).
  A dropped payment webhook may travel with a dropped subscription.active
  webhook — charged customer, no entitlement, and #4794's renewal
  reconciler only scans active rows, so nobody else catches it.
- Correct the scan-cap comment/message: newest-first means over-cap OLD
  rows are deferred indefinitely (until the backlog clears), not "to a
  later run".
- Tests: action-level unrecognised-status (requires_payment_method) path,
  two-run at-most-once-email idempotency, and the dropped-subscription
  guard (pages / does not page).

Claude-Session: https://claude.ai/code/session_018FBmQG921WC4s3qLmuEKoQ
@koala73
koala73 merged commit ea3c213 into main Jul 6, 2026
24 checks passed
@koala73
koala73 deleted the codex/payments-requires-action-reconcile branch July 6, 2026 18:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant