Case study
Design a Payment Processing System
Process card and wallet payments with exactly-once semantics, audit trails, and PCI-compliant isolation.
Requirements
- Functional: charge, refund, partial refund; support cards and wallet balance
- Idempotent payment API; webhook callbacks to merchants
- Strong consistency for money movement; full audit ledger
- Non-functional: p99 authorization < 500ms; 99.99% correctness (no lost/duplicate funds)
- Scale: 50M users; 100M transactions/month; avg $25
PCI scope
Never store raw PAN/CVV in application DB. Tokenize via Stripe/Adyen vault; our system handles tokens only.
Back-of-envelope Estimation
| Metric | Calculation | Result |
|---|---|---|
| Transactions/month | 100M | Given |
| TPS avg | 100M / (30 × 86,400) | ~40/sec |
| TPS peak (Black Friday) | 50× avg | ~2K/sec |
| Ledger rows/month | 100M × 2 entries (debit/credit) | 200M rows/mo |
| Storage/year (ledger) | 200M × 12 × 300 B | ~720 GB |
API Design
| Endpoint | Description |
|---|---|
| POST /v1/payments | { amount, currency, paymentMethodToken, idempotencyKey } → paymentId |
| GET /v1/payments/{id} | Status: pending | authorized | captured | failed | refunded |
| POST /v1/payments/{id}/refund | { amount?, idempotencyKey } |
| POST /v1/webhooks/stripe | Internal: PSP callback handler |
Data Model
| Entity | Fields |
|---|---|
| payments | payment_id, merchant_id, amount, currency, status, idempotency_key, psp_ref |
| ledger_entries | entry_id, account_id, payment_id, debit, credit, ts — append-only |
| accounts | account_id, type (user|merchant|platform), balance — derived from ledger |
| idempotency_store | key, request_hash, response, expires_at — 24h TTL |
High-level Design
Merchant ──► Payment API ──► Idempotency check
│
Payment Orchestrator (saga state machine)
│
┌───────────────┼───────────────┐
▼ ▼ ▼
Ledger Service PSP (Stripe) Notification
(double-entry) authorize/capture webhooksDeep Dive: Idempotency & Saga
- Check idempotency_key in store; return cached response if duplicate
- Create payment row status=pending; write ledger hold entry
- Call PSP authorize; on success status=authorized
- Capture (sync or async); ledger debit user credit merchant
- On failure: compensating ledger entry releases hold
Deep Dive: Double-entry Ledger
Every payment creates balanced entries: debit customer account, credit merchant escrow. Platform fees as separate line. Ledger is append-only; balances computed or materialized via nightly reconciliation. Discrepancy alert triggers finance freeze.
Exactly-once
At-least-once PSP webhooks + idempotent handler keyed by psp_event_id. Never mutate ledger rows — only append corrections.
Failure Modes & Monitoring
| SLO | Target |
|---|---|
| Authorization p99 | < 500ms |
| Ledger balance accuracy | 100% reconciled daily |
| Duplicate charge rate | 0 |
- Alert: PSP timeout spike, saga stuck in pending > 5 min, reconciliation mismatch
- Immutable audit log retained 7 years for compliance