Skip to content

Design a Payment System

A payment system processes financial transactions — the most critical system where correctness is everything.


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

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:#fff

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!

Every transaction is recorded as two entries (debit + credit):

Transaction: User A pays $10 to User B
Entry 1: User_A | DEBIT | $10 | Wallet
Entry 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.


// Daily reconciliation job
async 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)

-- Immutable audit log
CREATE 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 ledger
CREATE 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)
);

BottleneckSolution
Exactly-once processingIdempotency keys + idempotent PSP handling
PSP failuresRetry with exponential backoff, circuit breaker
FraudFraud detection service before processing
ReconciliationDaily automated reconciliation with alerts
Concurrent writes to balanceOptimistic locking (version column) or row-level locks

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.


  • 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.