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.
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.