Frontend Labs
⋯

39. Payments & Ledger (Stripe)

Business context. Money movement. The business outcomes are trust, regulatory compliance, and authorization rate (every failed legitimate payment is lost revenue for the merchant). Correctness beats latency, and no double charges, ever.

Requirements. Accept payment intents from merchants; authorize and capture via card networks/PSPs; refunds; payouts; a double-entry ledger; reconciliation with banks; webhooks to merchants. Scale: ~10k payments/s at peak (Black Friday), p99 < 1–2 s (dominated by external networks). Availability 99.99%+ on the API, and exactly-once financial effects.

Core concepts.

  • Idempotency keys on every mutating API (§5.2). Clients retry on timeouts, and the server must return the original result.
  • State machine per payment: created → requires_auth → authorized → captured → (refunded | disputed). Transitions are persisted atomically with their outbox events.
  • Double-entry ledger: every movement is a balanced set of immutable entries (debits = credits). Balances are derived (or cached) from entries and never updated in place.
ledger_entries(entry_id, txn_id, account_id, direction DEBIT|CREDIT, amount, currency, created_at)
Invariant per txn_id: SUM(debits) = SUM(credits)
Capture $100 card payment, 2.9% + 30¢ fee:
  DEBIT  customer_funds_receivable   100.00
  CREDIT merchant_balance             96.80
  CREDIT platform_fee_revenue          3.20

Design.

Merchant ─▶ API (authN, idempotency store) ─▶ Payment orchestrator (durable workflow / state machine)
             ├─▶ Risk/fraud scoring (ML, rules) — with timeout + fallback policy
             ├─▶ PSP/acquirer adapters (Visa/MC via processors) — retries ONLY with network-level idempotency
             ├─▶ Ledger service (append-only, strongly consistent DB, e.g. sharded Postgres/Spanner)
             └─▶ Outbox ─▶ Kafka ─▶ webhooks to merchant (signed, retried with backoff), notifications, analytics
Batch: daily settlement files from banks ─▶ reconciliation jobs ─▶ discrepancies queue ─▶ ops tooling

Deep dives.

  • The unknown outcome: the PSP call times out. Did the charge happen? Never blindly retry with a new request. Retry with the same PSP idempotency reference, or query the PSP's status endpoint, and mark the payment pending_unknown until resolved. Reconciliation is the backstop.
  • Consistency: ledger writes use serializable transactions within an account's shard. Cross-shard transfers use a two-step pattern with a suspense account (TCC-like, §12.3), so each step stays a local ACID transaction.
  • Hot accounts: a huge merchant's balance account receives thousands of credits per second. Split it into sub-accounts and roll up asynchronously.
  • Auditability: immutable history, event sourcing, and corrections only as new reversing entries. Regulators and auditors require it.
  • Security and compliance: PCI DSS scope reduction through tokenization. Card numbers live only in an isolated vault; everything else uses tokens.

Strategic lens. Authorization rate is a revenue lever: smart routing across multiple acquirers by approval rate and cost, retries on soft declines, and network tokens. A 1% authorization uplift on $10B of volume is $100M of merchant revenue. Correctness first, then optimize routing. Build vs buy: the vault and PSPs are bought or partnered; the ledger and orchestration are core and built.

Follow-ups: multi-currency (FX rates locked at authorization, rounding rules); payouts (batching, bank cutoffs); disputes and chargebacks (long-running workflows); designing for a PSP outage (failover routing).

🏗️ Flow: authorize and capture with the unknown-outcome path.

 Merchant ─POST /payment_intents/confirm (Idem-Key K)─▶ API
   ─▶ idempotency(K) new → state CREATED
   ─▶ Risk score (≤ 150 ms; timeout → rules-only fallback)
   ─▶ state AUTH_PENDING (persisted BEFORE calling out)  ◀── recovery point
   ─▶ Acquirer/PSP authorize (network ref R = hash(K))
        ├─ approved → state AUTHORIZED + ledger hold entries + outbox "payment.authorized" (1 txn)
        ├─ declined → state FAILED (+ decline code for merchant / retry advice)
        └─ timeout  → state AUTH_UNKNOWN → status query by R every 30 s (bounded)
                        └─ still unknown after N min → void by R (idempotent) / reconciliation resolves
   ─▶ later: capture (same pattern) → ledger: receivable ↔ merchant balance ↔ fee revenue
 Daily: acquirer settlement file ─▶ reconciliation (match by R, amount, currency) ─▶ breaks queue

🌍 How it's built in production.

  • Stripe idempotency (§5.6) and its Ledger system (described on the Stripe blog): an immutable, double-entry model of money movement used to verify that every flow balances, with automated detection of discrepancies.
  • Airbnb Orpheus (§12.6): every payment workflow is a DAG of idempotent, retryable steps. Airbnb reports "five nines of consistency".
  • Uber, Square, Adyen and others describe the same core: double-entry ledgers, idempotent APIs, and reconciliation as the final safety net. Correctness is enforced by independent verification, not trust in the happy path.

⚠️ Corner cases.

  • Double-click on "Pay": the client must generate the idempotency key once per checkout attempt (not per click), and the server dedupes.
  • The PSP returned 500 but actually captured: reconciliation finds a capture with no matching payment in your system. You need automated matching plus a refund or link-up workflow.
  • Partial captures, multiple captures, and refunds greater than the remaining amount: the state machine must track amounts (authorized, captured, refunded) and enforce invariants inside a transaction.
  • Currency minor units: JPY has 0 decimals, KWD has 3. Store integer minor units plus the currency, and round per the network's rules.
  • Chargeback after payout: the merchant balance goes negative. Use reserve policies and a debt-collection flow, which is product and risk policy encoded in ledger accounts.
  • Webhook ordering: payment.captured can arrive before payment.authorized. Merchants must use the state in the payload or fetch current state, and docs must say so.
  • Settlement cutoffs and time zones: bank cutoffs are in local time, and DST shifts batch boundaries. Reconciliation must use the bank's business date.

Generated from real-life-system-design-guide.md · 8 Oct 2026 16:36