Frontend Checkout UX & Dunning Recovery Flows

The checkout and recovery surface is where a SaaS billing system meets the messiest part of reality: flaky networks, issuer declines, expired cards, 3DS step-up challenges, and customers in a hundred jurisdictions paying in a dozen currencies. Get it wrong and you bleed revenue twice — once when a legitimate charge is declined and again when a customer abandons a confusing checkout. This subsystem spans the browser (Payment Element Integration), the vault (Secure Card Vaulting & Tokenization), the recovery engine (Grace Period & Retry Logic), the trust layer (Fraud Prevention & Dispute Management), the localization layer (Multi-Currency Checkout & Localization), and the account UI (Customer Portal & Self-Service).

The governing principle is that the frontend renders state but never owns it. The browser is hostile, lossy, and replayable; the only authoritative source of truth is the server-side ledger reconciled against gateway settlements. Every flow below is designed so that a dropped connection, a double-tapped button, or a retried webhook converges to exactly one correct financial outcome. This reference targets full-stack engineers, fintech developers, and product teams building revenue-critical checkout and recovery infrastructure.

Architecture Overview

A payment moves through six logical stages: capture in the browser, tokenization at the vault, authorization at the gateway, settlement, ledger posting, and — on failure — recovery. The frontend touches only the first stage and the read-only projection of the last. Everything financially meaningful happens server-side behind a signed webhook bus and an idempotency gate.

Checkout and recovery flow Card data is tokenized in the browser, authorized at the gateway, posted to the ledger, and failed charges route into a dunning recovery loop. Browser Payment Element Vault + tokenization Gateway authorization Webhook bus + idempotency Double-entry ledger Dunning + retry engine Self-service portal
Card data is tokenized before it leaves the browser; failed authorizations loop through the dunning engine and back to the customer via the self-service portal.

The data flow decouples synchronous user actions from asynchronous settlement. The table below summarizes which service owns each responsibility and what it must never do.

Service Owns Must never
Browser / Payment Element UI state, token request Hold raw PAN, decide charge outcome
Vault Token-to-PAN mapping, network tokens Expose PAN to app servers
Gateway Authorization, capture, SCA routing Be trusted without signature checks
Webhook bus Ordered, deduplicated delivery Mutate ledger directly
Ledger Authoritative balances, double-entry Accept un-reconciled writes
Dunning engine Retry scheduling, decline routing Retry hard declines

Core Data Model

The checkout and recovery domain centers on three durable entities: the tokenized payment method, the payment attempt (one row per authorization try), and the dunning campaign that sequences retries. Storing every attempt — not just the final outcome — is what makes recovery analytics and dispute defense possible later.

-- Tokenized payment instruments; no PAN ever stored here.
CREATE TABLE payment_methods (
  payment_method_id  UUID         PRIMARY KEY DEFAULT gen_random_uuid(),
  customer_id        UUID         NOT NULL REFERENCES customers(customer_id),
  gateway_token      TEXT         NOT NULL,          -- vault reference only
  brand              TEXT         NOT NULL,          -- visa, mastercard, amex
  last4              CHAR(4)      NOT NULL,
  exp_month          SMALLINT     NOT NULL,
  exp_year           SMALLINT     NOT NULL,
  is_default         BOOLEAN      NOT NULL DEFAULT false,
  network_token_id   TEXT,                           -- updater-maintained
  created_at         TIMESTAMPTZ  NOT NULL DEFAULT now()
);

-- One row per authorization attempt; the recovery audit trail.
CREATE TABLE payment_attempts (
  payment_attempt_id UUID         PRIMARY KEY DEFAULT gen_random_uuid(),
  invoice_id         UUID         NOT NULL REFERENCES invoices(invoice_id),
  payment_method_id  UUID         NOT NULL REFERENCES payment_methods(payment_method_id),
  idempotency_key    TEXT         NOT NULL UNIQUE,
  amount_minor       BIGINT       NOT NULL,          -- integer cents
  currency           CHAR(3)      NOT NULL,
  status             TEXT         NOT NULL,          -- pending|succeeded|failed
  decline_code       TEXT,                           -- e.g. insufficient_funds
  network_advice     TEXT,                           -- issuer retry hint
  attempted_at       TIMESTAMPTZ  NOT NULL DEFAULT now()
);

CREATE INDEX idx_attempts_invoice ON payment_attempts(invoice_id, attempted_at);

Money is always an integer minor unit (amount_minor BIGINT) — never a float. The idempotency_key unique constraint is the single hardest guarantee in the schema: it makes a double-submitted checkout a no-op at the database layer rather than a duplicate charge. The decline_code and network_advice columns feed the retry scheduler covered in Smart Retry Timing With Card Issuer Decline Codes.

Key Design Patterns

Idempotency keys on every charge

Every authorization carries a client-or-server-generated key persisted before the gateway call. A unique constraint turns retries into idempotent no-ops.

async function chargeOnce(invoiceId: string, key: string, amountMinor: number) {
  const inserted = await db.query(
    `INSERT INTO payment_attempts (invoice_id, idempotency_key, amount_minor, status)
     VALUES ($1, $2, $3, 'pending')
     ON CONFLICT (idempotency_key) DO NOTHING
     RETURNING payment_attempt_id`,
    [invoiceId, key, amountMinor]
  );
  if (inserted.rowCount === 0) return; // ✅ already in flight or done — no double charge
  await gateway.authorize({ idempotencyKey: key, amountMinor });
}

Use whenever a network round-trip to a payment gateway can be retried — which is always.

Finite state machine for recovery

Subscription and invoice recovery is a strict FSM: active → past_due → (retrying)* → recovered | canceled. Invalid jumps are rejected at the API edge before they reach the ledger.

const TRANSITIONS: Record<string, string[]> = {
  active:    ['past_due'],
  past_due:  ['retrying', 'recovered', 'canceled'],
  retrying:  ['retrying', 'recovered', 'past_due', 'canceled'],
};
function canTransition(from: string, to: string) {
  return TRANSITIONS[from]?.includes(to) ?? false; // ✗ reject anything else
}

Use whenever concurrent webhooks and portal actions can race on the same subscription.

Optimistic UI with server reconciliation

The portal reflects an action instantly, then reconciles against the authoritative result, rolling back the view on mismatch.

setView('updating');                                  // optimistic
const res = await api.updatePaymentMethod(token);
setView(res.confirmed ? 'saved' : 'error');           // ⚠️ reconcile, never trust the click

Use for portal interactions where perceived latency matters but correctness is server-owned.

Decline-code routing

Soft declines (insufficient_funds, try_again_later) schedule retries; hard declines (stolen_card, do_not_honor) suspend and notify without retry.

HARD = {"stolen_card", "lost_card", "fraudulent", "do_not_honor", "pickup_card"}
def should_retry(decline_code: str) -> bool:
    return decline_code not in HARD  # ✗ never retry a hard decline

Use to keep retry pressure off issuers and avoid fraud-flag escalation.

Compliance & Regulatory Boundaries

PCI-DSS scope is minimized by keeping raw PAN out of application servers entirely — hosted fields and tokenization keep most SaaS deployments at SAQ A. PSD2/SCA in the EEA requires 3DS2 step-up for many transactions; off-session retries rely on merchant-initiated-transaction (MIT) and credential-on-file (COF) exemptions so the dunning engine does not trigger a customer challenge it cannot satisfy. GDPR mandates data minimization on the stored card metadata (brand, last4, expiry only) and right-to-erasure on non-financial customer data, while financial records remain on an immutable audit trail for the statutory retention window. VAT/GST obligations attach at checkout based on the customer’s location, which is why localized pricing and tax must resolve before invoice finalization — see Multi-Currency Checkout & Localization.

Scalability & Failure Modes

At 10k active subscriptions a naive renewal job that fires all charges at midnight UTC creates a thundering herd against the gateway; spread renewals across the billing day and add jitter. At 100k subscriptions the dunning retry queue becomes the bottleneck — a synchronized retry wave can exceed issuer velocity limits and trip card-network fraud heuristics, so retries must carry per-issuer jitter and respect network advice codes. Gateway degradation is the classic cascade: a slow processor causes request pileup, exhausts connection pools, and starves unrelated checkout traffic. A circuit breaker that opens above a 15% failure rate in a rolling 5-minute window, combined with a secondary processor for fail-open routing, contains the blast radius. Out-of-order and duplicate webhook delivery is the steady-state condition, not the exception — sequence validation plus idempotency keys keep the ledger correct regardless of delivery order.

Operational Runbook

Monitor authorization success rate per gateway, per card brand, and per BIN — a sudden drop in one BIN range usually means an issuer-side problem, not your code. Alert when the rolling soft-decline rate exceeds its 7-day baseline by 3x, when the dunning recovery rate falls below its trailing 30-day average, and when webhook processing lag exceeds 60 seconds at p95. Run a daily reconciliation job that matches gateway settlement reports against payment_attempts and flags any settled transaction without a corresponding ledger entry (and vice versa) above a configurable tolerance. Keep a runbook entry for “circuit breaker open”: confirm the secondary processor is healthy, drain the retry backlog gradually, and never replay the entire failed queue at once.

Frequently Asked Questions

How do I guarantee a customer is never double-charged when checkout is retried? Persist a unique idempotency key before calling the gateway and pass the same key on every retry. The database unique constraint makes the second attempt a no-op, and the gateway’s own idempotency layer collapses duplicate authorizations. The browser never decides the outcome — it polls the server for the authoritative status.

Should I build my own dunning engine or use the gateway’s built-in retries? Start with the gateway’s smart retries for the common case, but own the scheduling once recovery rate becomes material to revenue. A custom engine lets you route on decline codes, coordinate retries with email touchpoints, and respect per-issuer velocity — see Grace Period & Retry Logic.

Where does fraud scoring fit in the checkout flow without hurting conversion? Score at authorization time and reserve step-up authentication for the risk tail rather than every transaction. Risk-based 3DS2 routing keeps friction off trusted customers — covered in Fraud Prevention & Dispute Management.

How do I support multiple currencies without corrupting the ledger? Store every amount in its transaction currency as integer minor units and never convert in the ledger. Display localized prices at checkout but post the settled currency exactly as the gateway reports it. See Multi-Currency Checkout & Localization.

What state should the frontend show during an SCA challenge? Transition to an explicit authentication_required state and hand control to the gateway’s 3DS flow. Do not optimistically mark the charge successful — wait for the webhook-confirmed result before updating the subscription.