Design a Payment System
hardA payment system is judged on what it does when something goes wrong. Any design can move money when everything works.
Key Concepts
payment.captured arriving before payment.authorized is normal.High-level design
API: idempotency-key → idempotency store check → execute or replay.
Charge flow: validate → fraud check → call PSP/network → record outcome → ledger entry.
Ledger: Postgres / Spanner; double-entry; append-only.
Multi-step: state machine in DB; saga for cross-service.
Settlement: nightly reconciliation against bank/network files.
Webhooks: at-least-once delivery to merchants with signature.
Components
- API gateway + idempotency store (Redis or Postgres).
- Fraud / risk service (rules + ML).
- Tokenization vault (PCI-scoped, isolated).
- PSP / network integration (Visa, Mastercard, ACH).
- Ledger (Postgres / Spanner / CockroachDB) with double-entry.
- State machine / saga orchestrator.
- Reconciliation jobs (Spark over ledger + statements).
- Webhook dispatcher (signed, retried).
- Settlement engine.
- Dispute / chargeback workflow.
The double-charge race, concretely
Timeline of the classic incident
t0 client POSTs /payments
t1 server calls the processor; card is charged
t2 network drops before the response returns
t3 client times out, retries
t4 without an idempotency key: second charge, same customer With an idempotency key
t0 client generates key K (UUID), POSTs /payments with K
t1 server INSERTs (K, status=in_progress) -- unique index on K
t2 server calls processor, stores the result against K
t3 response lost
t4 client retries with the SAME K
t5 server's INSERT fails on the unique index -> reads the stored
result -> returns the original outcome. One charge. What to store against the key
key, request fingerprint (hash of amount+currency+payee),
status, processor reference, response body, created_at. Why the fingerprint matters
If a retry arrives with the same key but a different amount, that is a
client bug or an attack. Reject with 422 rather than silently
returning the old result. In-progress retries
A retry that arrives while the first is still running should block
briefly and then return 409, not start a second processor call.Ledger model
Double-entry, append-only. Every payment writes balanced entries.
Customer pays 500.00 INR for an order:
entry 1 debit customer_receivable 50000 (paise)
entry 2 credit merchant_payable 50000
-------
sum 0 Refund of 200.00 INR later:
entry 3 debit merchant_payable 20000
entry 4 credit customer_receivable 20000Balance of any account = SUM(credits) - SUM(debits) over its entries. No row is ever updated. A mistake is corrected by a compensating pair, which leaves the error and its correction both visible.
Fields every entry needs
entry_id, transaction_id, account, direction, amount_minor, currency,
created_at, and the idempotency key or processor reference that caused
it. Why not a balance column
Two concurrent updates to a balance need a lock held across the
transaction, and a crash mid-update leaves a number nobody can audit.
Summing entries is slower and always explicable; snapshot periodically
if the sum gets expensive, keeping the entries as the source of truth.Failure modes in production
Retry without idempotency key
Symptom: duplicate charges, usually on mobile networks.
Cause: client library retries transparently on timeout.
Fix: key generated once per attempt and reused across retries -- and
make it required by the API, not optional.
Webhook replay
Symptom: a settled payment is credited twice.
Cause: handler not idempotent on event id.
Fix: unique index on the processor's event id; second delivery is a
no-op that still returns 200 so the processor stops retrying. Partial refund arithmetic
Symptom: refunds exceed the original charge by a few paise.
Cause: percentage splits rounded independently per line.
Fix: allocate rounding remainder to one line deterministically and
assert the split sums to the original amount. Reconciliation break nobody owns
Symptom: a small daily mismatch persists for weeks.
Cause: breaks are logged but not assigned.
Fix: treat a break like an incident -- owner, deadline, and a written
cause. The value is in the causes you find, not in the report.