The one design where being approximately right is unacceptable. Three things must appear in your answer or it will not be credible: **idempotency, an explicit state machine, and a double-entry ledger.**
Requirements
- Accept payments by card, UPI, netbanking and wallet.
- Authorise, capture, refund, and handle chargebacks.
- Integrate multiple downstream processors with failover.
- Webhooks to merchants; a full audit trail.
- Never double-charge; never lose a payment.
Idempotency
The network will fail after the charge succeeds but before the response arrives. The client will retry. Without protection, the customer is charged twice.
class IdempotencyRecord {
String key; // client-supplied, unique per logical attempt
String requestHash; // guards against key reuse with different params
PaymentStatus status;
String responseBody; // the ORIGINAL response, replayed verbatim
Instant expiresAt;
}1. Look up the key.
• Present + same request hash → return the stored response. Do NOT re-charge.
• Present + different hash → 422; the key is being reused incorrectly.
• Absent → INSERT it (unique constraint) and proceed.
2. Concurrent duplicate → the insert fails → wait and return the winner's result.The unique constraint is the concurrency control — two simultaneous retries race on the insert and exactly one proceeds. Say that explicitly; "we check if the key exists" without the constraint is a race, not a solution.
The state machine
┌──────────→ FAILED
│
CREATED → PROCESSING → AUTHORIZED → CAPTURED → REFUNDED
│ │ │
│ └→ VOIDED └→ PARTIALLY_REFUNDED
└──────────→ TIMEOUT (status unknown — must be reconciled)Model transitions explicitly and reject invalid ones at the domain level, not with
scattered ifs:
enum PaymentStatus {
CREATED { Set<PaymentStatus> next() { return Set.of(PROCESSING, FAILED); } },
PROCESSING { Set<PaymentStatus> next() { return Set.of(AUTHORIZED, FAILED, TIMEOUT); } },
AUTHORIZED { Set<PaymentStatus> next() { return Set.of(CAPTURED, VOIDED, FAILED); } },
CAPTURED { Set<PaymentStatus> next() { return Set.of(REFUNDED, PARTIALLY_REFUNDED); } },
// terminal states return an empty set
;
abstract Set<PaymentStatus> next();
boolean canTransitionTo(PaymentStatus s) { return next().contains(s); }
}`TIMEOUT` is the important state, and the one candidates omit. When a processor call
times out you do not know whether the charge happened. You cannot retry blindly and you cannot fail it — you must query the processor's status API or wait for reconciliation. Naming that state is a strong signal.
Authorise-then-capture matters too: authorisation reserves funds, capture takes them. Splitting them lets you validate the payment before shipping, and void instead of refunding if something goes wrong before capture.
Money and the ledger
record Money(long minorUnits, Currency currency) { // paise, cents — NEVER a double
Money add(Money other) {
if (!currency.equals(other.currency)) throw new CurrencyMismatch();
return new Money(minorUnits + other.minorUnits, currency);
}
}Integer minor units, never floating point. 0.1 + 0.2 != 0.3 in binary floating point,
and in a payment system that is a defect, not a curiosity. Currency belongs in the type so adding rupees to dollars is impossible to express.
Balances are derived from an append-only double-entry ledger, never stored as a mutable column:
entry_id | txn_id | account | debit | credit
1 | t1 | customer_card | 1000 |
2 | t1 | merchant_payable | | 980
3 | t1 | gateway_fees | | 20Every transaction's debits equal its credits — an invariant you can assert continuously, and which makes discrepancies detectable rather than silent. Ledger rows are immutable; a refund is a new compensating entry, never an edit or a delete. That is what makes the audit trail real.
Processor integration and failover
interface PaymentProcessor {
ProcessorResponse authorize(PaymentRequest r);
ProcessorResponse capture(String authId, Money amount);
ProcessorResponse refund(String captureId, Money amount);
boolean supports(PaymentMethod method, Currency c);
}Strategy again, plus a circuit breaker per processor: after repeated failures, stop sending traffic and fail over to a secondary rather than timing out every request. Route by method, currency, cost and current health.
Webhooks
Both directions, and both need care.
Inbound (from processors): verify the signature, treat delivery as at-least-once, and
process idempotently by event id. Return 200 immediately and process asynchronously — processors retry on a slow response, multiplying load exactly when you are struggling.
Outbound (to merchants): retry with exponential backoff over hours, sign the payload so
merchants can verify it, and expose a replay endpoint for events they missed.
Reconciliation
The safety net, and the thing that makes the system trustworthy. Every day, fetch the processor's settlement file and compare it against your ledger:
- In our ledger, not theirs → we recorded a charge that did not happen.
- In theirs, not ours → a payment we lost; typically a
TIMEOUTwhose outcome was
actually success.
- Amount mismatch → investigate immediately.
Every unresolved TIMEOUT is settled here. Volunteering reconciliation unprompted is
usually the strongest single signal in this interview.
Edge cases to handle
- Partial refunds summing beyond the captured amount — validate against the ledger.
- Refunding an authorised-but-uncaptured payment → void it instead.
- Currency mismatch between authorisation and capture.
- Chargebacks arriving months later, after the money is settled.
- A processor that reports success for a payment you marked failed.
Extensions to be ready for
- 3-D Secure and other authentication step-ups, which make the flow asynchronous.
- Recurring payments — stored mandates and scheduled charges.
- Split payments for marketplaces — one charge, several payees.
- Fraud scoring before authorisation.
- PCI DSS — never store raw card numbers; tokenise via the processor and keep the
cardholder data environment out of your systems entirely.
Track this problem on the System Design sheet.
Frequently asked
How does an idempotency key prevent double charging?
The client sends a unique key per logical payment attempt. The server inserts it under a unique constraint before charging; if the key already exists with the same request hash, the stored original response is replayed instead of charging again. The unique constraint is what makes concurrent retries safe — two simultaneous requests race on the insert and exactly one proceeds.
Why store money as integers instead of decimals?
Because binary floating point cannot represent decimal fractions exactly — 0.1 + 0.2 is not 0.3 — and those errors accumulate across millions of transactions. Store the amount in minor units (paise, cents) as a long, and carry the currency in the same value type so adding two different currencies cannot compile.
What happens when a payment processor call times out?
The payment enters an explicit TIMEOUT state, because the outcome is genuinely unknown — the charge may have succeeded. You cannot retry blindly, which risks a double charge, and you cannot mark it failed, which risks losing a real payment. Resolve it by querying the processor's status API, and settle any remainder in daily reconciliation against the settlement file.