Problem RestatementProblem
Design a system that lets merchants charge customers through external payment providers (card networks, banks, PayPal). A payment goes through several steps:
- Authorize: ask the card's bank to hold the money (e.g., a hotel holds $200).
- Capture: actually take the held money (maybe days later, when the order ships).
- Refund: give money back.
- Settle: the provider moves the real money to the merchant's bank, usually in daily batches.
The top requirement is correctness: never charge a customer twice, and never lose track of money, even when networks time out and servers crash. Target scale is about 10,000 transactions per second.
RequirementsRequirements
1.1 Functional
- Create a payment, authorize, capture (full or partial), cancel and refund.
- Get payment status.
- Notify merchants of status changes (webhooks).
- Reconcile our records with provider reports every day.
1.2 Non-Functional
- Exactly-once effect: retries must not create double charges.
- Durability and auditability: every change recorded, nothing deleted.
- High availability for authorization, which sits in the checkout path.
- Security: card numbers never stored in plain text (PCI rules).
1.3 Scale Estimates
- 10K transactions/sec at peak, about 200M per day.
- Each payment makes ~5 ledger entries plus state changes → ~50K DB writes/sec. That calls for sharding by merchant or payment ID.
- Provider calls take 200 ms–2 s, and some time out, so we must handle "unknown" results.
1.4 API Design
/v1/paymentswith header Idempotency-Key: 8f1c... and body { amount: 5000, currency: "USD", payment_method_token, capture: "manual" } → { payment_id, status: "authorized" }/v1/payments/{id}/capture{ amount }/v1/payments/{id}/refunds{ amount }/v1/payments/{id}High-Level ArchitectureArchitecture
2.1 Overview
- Payment API: checks the idempotency key and validates the request.
- Payment Service: runs the payment state machine (a fixed set of states and allowed moves between them).
- Tokenization vault: stores card numbers securely and returns tokens. The rest of the system only sees tokens.
- Provider adapters: one per provider (Visa/Mastercard acquirer, PayPal), with retries and timeouts.
- Ledger: a double-entry record of all money movement.
- Outbox + Kafka: reliably publishes events (for webhooks, analytics, settlement).
- Reconciliation jobs: compare our records with provider settlement files.
2.2 Architecture Diagram
%%{init: {"look":"handDrawn","handDrawnSeed":7,"theme":"base","fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","themeVariables":{"fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","fontSize":"16px","primaryColor":"#fff4e6","primaryBorderColor":"#1e1e1e","primaryTextColor":"#1e1e1e","secondaryColor":"#e7f5ff","tertiaryColor":"#ebfbee","lineColor":"#1e1e1e","textColor":"#1e1e1e","mainBkg":"#fff4e6","nodeBorder":"#1e1e1e","clusterBkg":"#f8f9fa","edgeLabelBackground":"#ffffff","classText":"#1e1e1e"}}}%%
flowchart LR
M["Merchant"] --> API["Payment API - idempotency check"]
API --> PS["Payment Service - state machine"]
PS --> DB[("Payments DB + Outbox")]
PS --> V["Token Vault"]
PS --> PA["Provider Adapters"]
PA --> EXT["Card networks / banks"]
PS --> L[("Ledger - double entry")]
DB -->|"outbox relay"| K[("Kafka")]
K --> WH["Webhook Sender"]
K --> ST["Settlement jobs"]
ST --> REC["Reconciliation vs provider files"]Data ModelData model
CREATE TABLE payments (
payment_id UUID PRIMARY KEY,
merchant_id UUID,
amount BIGINT, -- in cents
currency CHAR(3),
status TEXT, -- created, authorizing, authorized, capturing, captured, failed, refunded
provider TEXT,
provider_ref TEXT, -- the provider's ID for this payment
version INT
);
CREATE TABLE idempotency_keys (
merchant_id UUID, key TEXT, request_hash TEXT, response JSONB, created_at TIMESTAMP,
PRIMARY KEY (merchant_id, key)
);
CREATE TABLE ledger_entries ( -- never updated, only inserted
entry_id UUID, payment_id UUID, account TEXT, direction TEXT, -- debit / credit
amount BIGINT, created_at TIMESTAMP
);
CREATE TABLE outbox (event_id UUID, payload JSONB, published BOOLEAN);Key FlowsFlows
4.1 Authorize with idempotency
- The merchant sends
POST /paymentswith anIdempotency-Key. - We insert the key into
idempotency_keys. If it already exists, we return the saved response. The client retried, so we must not charge again. - We create the payment in state
authorizingbefore calling the provider. That way, if we crash, we know a call may have been made. - We call the provider, passing our
payment_idas their idempotency key too. - On success, set
authorized, write ledger entries, and in the same DB transaction write an outbox event. Save the response under the idempotency key.
4.2 Capture and settle
Capture moves the payment authorized → captured. Every night, the provider sends a settlement file. A job matches each line to our payments and writes ledger entries for fees and payouts.
Deep Dive A — The call to the bank timed outDeep dive
We asked the bank to authorize a card and the call timed out. The charge may have gone through, or it may not. This one unknown is where most payment bugs live.
Retry the charge
The call failed, so send it again. It is what every HTTP client does by default, and it is the answer that ends the interview badly.
%%{init: {"look":"handDrawn","handDrawnSeed":7,"theme":"base","fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","themeVariables":{"fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","fontSize":"16px","primaryColor":"#fff4e6","primaryBorderColor":"#1e1e1e","primaryTextColor":"#1e1e1e","secondaryColor":"#e7f5ff","tertiaryColor":"#ebfbee","lineColor":"#1e1e1e","textColor":"#1e1e1e","mainBkg":"#fff4e6","nodeBorder":"#1e1e1e","clusterBkg":"#f8f9fa","edgeLabelBackground":"#ffffff","classText":"#1e1e1e"}}}%%
sequenceDiagram
participant P as Payment service
participant B as Bank
P->>B: authorize $200
B-->>B: approves, holds $200
B--xP: response lost - timeout
P->>B: authorize $200 again
B-->>B: approves, holds $200 again
Note over B: customer is down $400The bank saw two unrelated requests and honoured both. The customer is charged twice, and we find out from a support ticket.
Treat the timeout as a failure
Mark the payment failed and let the customer try again. No double charge, which feels safer.
But if the bank did approve, the money is now held against a payment we have written off. The hold sits on the customer's card for days, our ledger disagrees with the bank's settlement file, and reconciliation has to chase every one of these by hand. We have swapped a visible bug for a silent one.
Same idempotency key, then ask until you know
Never guess the outcome. Record the attempt before the call, and give the provider a key that identifies this attempt:
%%{init: {"look":"handDrawn","handDrawnSeed":7,"theme":"base","fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","themeVariables":{"fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","fontSize":"16px","primaryColor":"#fff4e6","primaryBorderColor":"#1e1e1e","primaryTextColor":"#1e1e1e","secondaryColor":"#e7f5ff","tertiaryColor":"#ebfbee","lineColor":"#1e1e1e","textColor":"#1e1e1e","mainBkg":"#fff4e6","nodeBorder":"#1e1e1e","clusterBkg":"#f8f9fa","edgeLabelBackground":"#ffffff","classText":"#1e1e1e"}}}%%
sequenceDiagram
participant P as Payment service
participant B as Bank
P->>P: write payment - status authorizing, key pay_7f2
P->>B: authorize $200 - Idempotency-Key pay_7f2
B--xP: timeout
P->>B: authorize $200 - Idempotency-Key pay_7f2
B-->>P: same result as before - approved, auth_id 91
P->>P: status authorizedThe provider recognises the key and replays the original result instead of charging again. If it stays unreachable, the payment holds at authorizing and a background job polls "what happened to pay_7f2?" until there is an answer. The merchant sees "processing" the whole time — an honest unknown is better than a wrong answer in either direction.
This is why the state is written before the external call, and why every outbound call carries a key.
Deep Dive B — Ledger, outbox and reconciliationDeep dive
- Double-entry ledger: every movement is written as two entries that add to zero. For example, debit "customer_receivable" $50 and credit "merchant_payable" $50. If the books don't balance, there's a bug. Entries are append-only, so we never edit history, and corrections are new entries.
- Outbox pattern: writing to the DB and publishing to Kafka are two separate systems, so one can succeed while the other fails. Instead, write the event into an
outboxtable in the same transaction as the state change. A relay process reads the outbox and publishes to Kafka. Events are never lost, and duplicates are handled by consumers using event IDs. - Reconciliation: every day, compare (1) our ledger, (2) provider settlement files, and (3) bank deposits. Any mismatch (a missing capture, a wrong fee) goes to a review queue.
- Sharding: shard payments and ledger by
merchant_idso a payment's records live together and transactions stay on one shard.
Trade-offs & AlternativesTrade-offs
| Decision | Choice | Why | Alternative |
|---|---|---|---|
| Consistency | Strong (SQL, per-shard transactions) | Money must be exact | Eventual NoSQL: faster, risky for balances |
| Duplicate protection | Idempotency keys at API and provider | Safe retries everywhere | Dedup by amount/time: unreliable |
| Events | Transactional outbox | No lost or phantom events | Dual writes: can diverge |
| Multi-step flows | State machine + saga steps | Clear recovery after crashes | Distributed 2-phase commit: providers don't support it |
Common Follow-up QuestionsFollow-ups
- "How do you add a second provider?" Add an adapter and a router that picks a provider by cost, success rate or card type. Fail over to another provider only when you're sure the first attempt did not succeed.
- "Offline merchants that send payments later?" Accept batches with client-side IDs as idempotency keys, process them asynchronously, and reconcile the results.
- "How do you keep card data safe?" Keep card numbers only in a separate, locked-down vault (small PCI scope), and pass tokens everywhere else.
Wrap-UpWrap-up
Use a state machine that records "in progress" before calling providers, idempotency keys at every step, a double-entry append-only ledger, and a transactional outbox for events. Treat timeouts as "unknown" and resolve them by querying the provider, and reconcile daily with provider files so every cent is accounted for.