SD-44 · Payment processing & double-entry ledger
A reference solution, stage by stage, for a 45–60-minute round. Cue sheet:
questions/F-transactions-payments.md · theory:
Correctness under failure,
Messaging & streams.
Run the round cold first:
Mock me: SD-44, Amazon, em, full round.Read this afterwards.
The prompt: “Design the system that takes a payment and records it — the ledger the company’s money is accounted for in.”
The one-sentence insight to land by minute 10
Exactly-once does not exist across a network boundary. So the design is at-least-once delivery + idempotent operations + an explicit pending state for unknown outcomes + reconciliation that finds the discrepancies anyway — on top of an immutable, append-only double-entry journal where balances are derived, never stored as the truth.
Stage 1 · Clarify requirements · 0:00–0:06
| I ask | Interviewer answers | Why it matters |
|---|---|---|
| Volume? | 10 k payments/s peak, 50 currencies | ledger write rate, partitioning |
| Latency? | authorisation decision within 2 s | the processor call dominates; budget the rest |
| External parties? | card networks, banks — time out, return ambiguous results, go down for hours | unknown outcomes are the core problem |
| Ledger rule? | must always balance — no exceptions | double entry, atomic posting |
| Audit? | every balance explainable from immutable records for 7 years | append-only journal, no UPDATE |
| Refunds / chargebacks? | yes, partial, days to months later | adjusting entries; state machine |
| Retries? | clients retry — “what do you require of them?” | idempotency keys |
| What must never happen, in order? | (ask) | 1 lose money/double-charge · 2 unbalanced ledger · 3 slow payment |
Out of scope (say it): fraud scoring (assume a service, SD-43), card-data vaulting (assume a PCI-scoped tokenisation service), FX pricing (SD-80), merchant onboarding/KYC (SD-78), payouts (SD-81).
Non-functional: zero double charges; ledger balanced at every commit; idempotent API; payment decision p99 < 2 s; every state change auditable; reconciliation daily with breaks owned.
Stage 2 · Back-of-the-envelope · 0:06–0:11
| Quantity | Arithmetic | Result | Consequence |
|---|---|---|---|
| Journal writes | 10 k payments/s × ~4 entries (auth hold + capture legs) | ~40 k entries/s peak | partition the journal by account |
| 7-year storage | ~3 k/s average (peak ÷ 3) × 10⁵ s × 365 × 7 years × 4 entries × ~200 B | ~500–600 TB | hot recent partitions + cold archive; immutable makes archiving easy |
| Hot account | a large merchant receiving 5 k payments/s | 5 k writes/s to one balance | a balance-row UPDATE serialises on a lock → append entries, derive balance |
| Processor latency | 300–1,500 ms typical, timeouts at ~10 s | most of the 2 s budget | everything we own must be < ~200 ms |
“The number that shapes the data model is the hot merchant: five thousand updates a second to one balance row would serialise on a row lock. So entries are appended and the balance is derived — which is also exactly what auditors want.”
Stage 3 · API and data model · 0:11–0:17
POST /v1/payments Idempotency-Key: <uuid>
{ merchantId, amount: { value: 1050, currency: "EUR" }, -- integer minor units, never floats
paymentMethodToken, capture: "auto" | "manual" }
-> 201 { paymentId, status: "authorized" | "captured" | "declined" | "pending" }
POST /v1/payments/{id}/capture { amount } Idempotency-Key
POST /v1/payments/{id}/refunds { amount } Idempotency-Key
GET /v1/payments/{id}
Webhooks to merchants: signed, with event IDs, retried with backoff (merchants dedupe by event ID)
Payment state machine:
stateDiagram-v2
[*] --> CREATED
CREATED --> AUTH_PENDING: send to processor
AUTH_PENDING --> AUTHORIZED: approved
AUTH_PENDING --> DECLINED: declined
AUTH_PENDING --> UNKNOWN: timeout / ambiguous
UNKNOWN --> AUTHORIZED: inquiry says approved
UNKNOWN --> DECLINED: inquiry says not found → safe
AUTHORIZED --> CAPTURED: capture
AUTHORIZED --> VOIDED: void / expiry
CAPTURED --> SETTLED: settlement file matched
CAPTURED --> PARTIALLY_REFUNDED: refund < remaining
PARTIALLY_REFUNDED --> REFUNDED: refunds = captured
SETTLED --> CHARGEBACK: dispute
The ledger (double entry):
| Table | Key | Content | Mutability |
|---|---|---|---|
accounts |
account_id |
owner, type (merchant payable, customer funds, fees revenue, processor receivable…), currency | rarely changes |
journal_transactions |
txn_id |
idempotency_key, payment_id, type, created_at, description |
immutable |
journal_entries |
(account_id, entry_id) |
txn_id, direction (debit/credit), amount_minor, currency |
immutable |
balances (cache/projection) |
account_id |
balance, as-of entry | derived; rebuildable |
idempotency |
(client_id, key) |
request hash, status, stored response | TTL e.g. 30 days |
outbox |
id |
event to publish | deleted after relay |
Invariant: for every txn_id, Σ debits = Σ credits per currency — enforced in the posting code
and checked by a database constraint or a deferred check, inside one transaction.
Example — €10.50 captured, €0.30 fee:
| Account | Debit | Credit |
|---|---|---|
| Processor receivable | 1,050 | |
| Merchant payable | 1,020 | |
| Fee revenue | 30 |
Stage 4 · High-level design · 0:17–0:27
flowchart LR
M[Merchant / client] -->|POST /payments + Idempotency-Key| API[Payments API]
API --> PS[Payment service<br/>state machine]
PS -->|txn: state + journal entries + outbox| DB[(Payments DB + Ledger<br/>partitioned by account)]
PS -->|authorise / capture| PG[Processor adapter] -->|ISO 8583 / API| PROC[Card network / processor]
DB -. outbox relay .-> LOG[[Payment events]]
LOG --> WH[Webhook sender]
LOG --> RISK[Fraud, analytics, notifications]
INQ[Inquiry & recovery job] -->|status lookups for UNKNOWN| PG
REC[Reconciliation] -->|daily settlement files| PROC
REC --> DB
Walk-through — one successful payment:
- API receives
POST /paymentswith an idempotency key; inserts anidempotencyrow as processing (unique on key) — a concurrent duplicate gets “in progress”. - Payment service creates the payment
CREATED → AUTH_PENDINGand commits before calling out (so a crash leaves evidence the call may have been made). - Processor adapter calls the network with our own idempotency/reference ID.
- Approved → in one DB transaction: state
AUTHORIZED, journal entries for the authorisation hold (if modelled), outbox event, idempotency row → done with the stored response. - Capture (auto or later) →
CAPTURED, posting the balanced entries above, outbox event. - Relay publishes events → webhooks to the merchant, downstream consumers.
- Next day: settlement file arrives → reconciliation matches it →
SETTLED, posting the settlement entries (receivable → bank cash, fees).
Stage 5 · Technology choices
| Component | Choice | Reason |
|---|---|---|
| Ledger + payment state | relational database, partitioned by account (or a distributed SQL store) | multi-row atomic postings, constraints, serializable where needed |
| Events | transactional outbox → log | no dual writes between DB and broker |
| Processor integration | adapter per processor, with retries only where the processor supports idempotent references | ambiguity differs per partner |
| Reconciliation | batch pipeline over immutable raw files + ledger expectations | external truth arrives in files |
| Money | integer minor units + ISO currency on every amount | floats lose cents |
Stage 6 · Deep dives · 0:27–0:45
6.1 The bank timed out — the next ten minutes and the next ten days
The crux. The authorisation call timed out; the customer may or may not have been charged.
- Do not mark it failed (the merchant retries → double charge) and do not mark it succeeded.
Move to
UNKNOWNand returnpendingto the client. - Inquiry job (seconds to minutes): ask the processor for the status by our reference ID. If
approved →
AUTHORIZEDand continue. If “not found” after the processor’s safe window →DECLINED(safe to retry with a new attempt). - If the processor supports it, send a reversal/void for an unknown auth we no longer want — reversals are idempotent by reference.
- Days later: the settlement file is the final word — reconciliation catches anything the inquiry missed (a capture we didn’t record → break → adjusting entry + merchant notification).
“A timeout is an unknown, not a failure. The payment goes to a pending state, an inquiry job asks the processor by our reference, and the settlement file is the final arbiter. At no point does a retry create a second charge, because every call carries the same reference.”
6.2 Idempotency — three concurrent retries, and the same key with a different amount
- Unique
(client_id, key)insert inside the same transaction that creates the payment: one wins; the others see processing →409 retry later, or, once done, get the stored response. - Same key, different payload (amount changed): request hash mismatch →
422. Silently returning the original would hide a client bug; executing would double-charge. - Downstream idempotency: our processor calls carry a deterministic reference
(
payment_id + attempt), so our own retries are deduplicated by the processor.
6.3 Crash between ledger write and event publish
Can’t happen inconsistently: state change, journal entries and the outbox row commit in one transaction; the relay publishes later, at least once; consumers dedupe by event ID. A crash before commit leaves nothing; after commit, the event will be published.
6.4 The hot merchant account
Appending entries scales (no lock on a balance row). For the balance itself:
- Derived on read from a projection updated asynchronously (seconds behind) — fine for dashboards.
- Where a check needs the balance (payouts can’t exceed available funds): split the account into N sub-accounts that each receive a share of writes, and sum them; or serialise payout checks through a single per-merchant worker. Payouts are rare compared to incoming payments.
6.5 Refunds, partial refunds, chargebacks
Each is a new journal transaction referencing the original payment — never an edit. A refund is
allowed only if Σ refunds + amount ≤ captured — checked under a per-payment lock or conditional
update on the payment row (refunded_minor + $amt <= captured_minor). A chargeback 90 days later posts
entries dated now (with the original reference), so closed periods are never rewritten.
6.6 Reconciliation as a permanent component
Daily: ingest the processor’s settlement file (immutable raw copy) → the ledger generates expectations (what we expect to be settled, net of fees) → match → breaks (missing, amount mismatch, unexpected) become an owned, aged workflow for finance ops. The unreconciled balance by age is a monitored metric. (Full design: SD-79.)
Stage 7 · Mid-round requirement — a bug double-posted 40,000 entries
- Stop the bleeding: kill-switch the faulty code path; halt payouts for affected merchants (money not yet sent can still be protected).
- Identify the duplicates precisely from the immutable journal (same payment, same type, twice).
- Correct with reversing entries — one correction transaction per duplicate, tagged with an
incident ID — never
DELETE/UPDATE. The audit trail shows the error and the correction. - For merchants already paid out the excess: a receivable from the merchant, recovered from future settlements — a business decision with communication, not a database operation.
- Postmortem on the class: why did idempotency not catch it, and add a reconciliation check that would have flagged it within the hour.
Stage 8 · Failure modes · 0:45–0:50
| Failure | Effect | Response |
|---|---|---|
| Processor outage | authorisations fail | route to a secondary processor where contracts allow; degrade gracefully; never “approve offline” without a risk policy |
| Processor slow | latency budget blown | timeouts → UNKNOWN + inquiry, not retries in a loop |
| DB primary failover | seconds of write unavailability | clients retry with the same idempotency key — safe |
| Outbox relay lag | webhooks late | alert on outbox age; merchants can poll GET /payments/{id} |
| Reconciliation breaks growing | financial risk | ageing SLO, owners, escalation |
Stage 9 · SLOs, observability, cost · 0:50–0:54
- SLIs: payment API success (excluding legitimate declines) · decision latency p99 < 2 s ·
UNKNOWNrate and age (spikes mean a processor problem) · ledger balance check (Σ = 0, must always hold — page immediately if not) · unreconciled amount by age. - The alert that fires before finance notices: auth approval rate per processor drops vs. baseline;
UNKNOWNcount rising; any imbalance. - Cost: processor fees dwarf infrastructure — the engineering lever on cost is routing and reducing false declines, not servers.
Stage 10 · The level layer
EM: the one-way doors — the ledger schema and the idempotency contract — get the most review; MVP is single processor, auto-capture, daily reconciliation; team seams payments API & state machine · ledger (owned centrally, a contract for everyone else) · processor integrations · reconciliation & finance tooling; on-call runbook for “payments failing at 3 a.m.”. Staff: posting invariants enforced in the database, the inquiry protocol per processor, sub-account sharding for hot accounts, replay/rebuild of projections. Director: multi-processor strategy (cost and resilience), regulatory and audit ownership, finance-ops headcount vs auto-match rate.
Stage 11 · The close
“Payments are an explicit state machine whose every transition commits together with balanced, immutable double-entry journal entries and an outbox event. Exactly-once across the bank boundary is impossible, so every call is idempotent by key, timeouts go to a pending state resolved by an inquiry job, and daily reconciliation against settlement files catches what’s left. Balances are derived, so the hot merchant account scales and every number is explainable for seven years. Next I’d harden the inquiry protocol per processor and make reconciliation breaks an owned, aged workflow.”
Stage 12 · Self-check
- Did I say “exactly-once doesn’t exist across the boundary” and design around it?
- Double entry with derived balances, integer minor units?
- Idempotency key in the same transaction; same key + different payload rejected?
- The timeout walk:
UNKNOWN→ inquiry → reversal → reconciliation? - Outbox for events; no dual writes?
- Refunds and chargebacks as new entries, never edits?
- Reconciliation as a permanent, owned component?
Cheat card
Insight: at-least-once + idempotency + pending state + reconciliation, on an immutable double-entry
journal. Numbers: ~40 k entries/s; ~500–600 TB over 7 years; hot merchant 5 k writes/s → append, don’t
update. API: idempotency key, integer minor units. Unknown outcome: UNKNOWN → inquiry by
our reference → reversal → settlement file. Events: outbox. Corrections: reversing entries.
Trap: distributed transaction with the bank; balance column as truth; floats; “timeout = failed”.