Design a Payment System
Case Study: Design a Payment System
Section titled “Case Study: Design a Payment System”A payment system processes financial transactions — the most critical system where correctness is everything.
Requirements
Section titled “Requirements”Functional:
- Process a payment from buyer to seller
- Handle multiple payment methods (credit card, wallet, bank transfer)
- Generate receipts
- Support refunds and chargebacks
Non-functional:
- Exactly-once processing — no double charges!
- Idempotent retries
- Audit trail of all transactions
- 99.999% uptime during business hours
High-Level Design
Section titled “High-Level Design”flowchart LR Client["📱 Client"] --> API["Payment API"] API --> Auth["Auth & Fraud Check"] Auth --> Ledger["Ledger Service"] Ledger --> Wallet["Wallet Service"] Ledger --> PSP["Payment Processor<br/>(Stripe, PayPal)"]
API --> Queue["Message Queue"] Queue --> Reconciliation["Reconciliation Service"] Queue --> Notification["Notification"]
Ledger --> DB[("Ledger DB<br/>PostgreSQL")]
style Client fill:#7c3aed,color:#fff style API fill:#4f46e5,color:#fff style Auth fill:#6366f1,color:#fff style Ledger fill:#8b5cf6,color:#fff style PSP fill:#059669,color:#fff style DB fill:#059669,color:#fffDeep Dive: Payment Flow with Idempotency
Section titled “Deep Dive: Payment Flow with Idempotency”sequenceDiagram participant Client as 📱 Client participant API as 🖥️ Payment API participant PSP as 🏦 Payment Processor participant Ledger as 📒 Ledger
Client->>API: POST /charge<br/>{ idempotency_key: "abc", amount: 1000 } API->>API: Check idempotency — key "abc" seen before? Note over API: First time → process API->>PSP: Process payment (idem_key: "abc") PSP-->>API: ✅ Success (transaction_id: "txn_456") API->>Ledger: Record debit user_A, credit user_B Ledger-->>API: ✅ Recorded API-->>Client: ✅ 200 { status: "success", txn_id: "txn_456" }
Client->>API: POST /charge<br/>{ idempotency_key: "abc", amount: 1000 } (RETRY) API->>API: Check idempotency — key "abc" found! API-->>Client: ✅ 200 { status: "success", txn_id: "txn_456" } Note over API: Same response — no double charge!Deep Dive: Double-Entry Ledger
Section titled “Deep Dive: Double-Entry Ledger”Every transaction is recorded as two entries (debit + credit):
Transaction: User A pays $10 to User B
Entry 1: User_A | DEBIT | $10 | WalletEntry 2: User_B | CREDIT | $10 | Wallet
Sum of all entries = 0 (always!)Why double-entry? You can detect errors: if sum(debits) ≠ sum(credits), something is wrong.
Deep Dive: Reconciliation
Section titled “Deep Dive: Reconciliation”// Daily reconciliation jobasync function reconcile() { // 1. Get all payments from ledger for today const payments = await ledger.getPayments(Date.range('today'));
// 2. Get all settlements from payment processor const settlements = await psp.getSettlements(Date.range('today'));
// 3. Match them for (const payment of payments) { const match = settlements.find(s => s.id === payment.psp_transaction_id);
if (!match) { // Payment processor didn't settle this! await alertTeam(`Unreconciled payment: ${payment.id}`); }
if (match.amount !== payment.amount) { // Amount mismatch! await alertTeam(`Amount mismatch: ${payment.id}`); } }}Reconciliation catches:
- Payments that succeeded in the ledger but failed at the processor
- Processor settled a payment that the ledger doesn’t know about
- Amount mismatches (currency conversion, fees)
Data Model
Section titled “Data Model”-- Immutable audit logCREATE TABLE transactions ( id BIGINT PRIMARY KEY, idempotency_key VARCHAR(64) UNIQUE NOT NULL, user_id BIGINT NOT NULL, amount_cents BIGINT NOT NULL, currency VARCHAR(3) DEFAULT 'USD', status VARCHAR(20), -- pending, succeeded, failed, refunded psp_transaction_id VARCHAR(64), created_at TIMESTAMP DEFAULT NOW());
-- Double-entry ledgerCREATE TABLE ledger_entries ( id BIGINT PRIMARY KEY, transaction_id BIGINT NOT NULL, account_id BIGINT NOT NULL, entry_type VARCHAR(10), -- DEBIT, CREDIT amount_cents BIGINT NOT NULL, currency VARCHAR(3), FOREIGN KEY (transaction_id) REFERENCES transactions(id));Bottlenecks & Trade-offs
Section titled “Bottlenecks & Trade-offs”| Bottleneck | Solution |
|---|---|
| Exactly-once processing | Idempotency keys + idempotent PSP handling |
| PSP failures | Retry with exponential backoff, circuit breaker |
| Fraud | Fraud detection service before processing |
| Reconciliation | Daily automated reconciliation with alerts |
| Concurrent writes to balance | Optimistic locking (version column) or row-level locks |
Follow-up Questions
Section titled “Follow-up Questions”Q: Your API call to the payment processor times out, but their webhook confirming success arrives 30 seconds later — what state is the transaction in, and how do you avoid a double charge?
Mark the transaction pending on timeout rather than assuming failure. The webhook is the source of truth: when it arrives, it transitions pending → succeeded using the same idempotency key, so if the client also retries the original request, the idempotency check returns the already-recorded result instead of charging again.
Q: With multi-currency payments, how do you keep the conversion rate consistent if a request is retried? Lock the exchange rate at the moment the idempotency key is first created and store it with the transaction record. Retries reuse the stored rate instead of re-fetching the live rate, otherwise a retry minutes later could settle at a different amount than the client confirmed.
Q: How do you support a partial refund on an order with multiple line items?
Track a refunded_amount per line item (or per ledger sub-entry) rather than only at the order level, and write each partial refund as its own double-entry pair. Validate that the sum of all partial refunds for a line item never exceeds its original charged amount.
Q: What if the same idempotency key is replayed but with a different amount? Treat it as a conflict, not a valid retry — reject with an error (e.g., 409) instead of silently returning the original result. A changed amount on a reused key usually means a client bug or a replay/tampering attempt, and honoring it would break the exactly-once guarantee.
Q: A chargeback comes in three weeks after the transaction settled — how does that flow through an immutable ledger?
You never mutate the original transaction row; you record a new reversing transaction (debit the merchant, credit back the processor/buyer side) that references the original transaction_id, so the audit trail shows both the original charge and its reversal instead of rewriting history.
In Simple Words
Section titled “In Simple Words”- Payment system = the most correctness-critical system. No double charges allowed!
- Idempotency keys prevent double charges from retries.
- Double-entry ledger ensures every debit has a matching credit.
- Daily reconciliation catches mismatches between your records and the payment processor.