← System Design
Large-Scale Products

Design a Payment System

In simple words

Like a careful accountant: every rupee that moves is written twice (from where and to where) in a book that is never erased, every request carries a receipt number so it can't be paid twice, and at the end of the day the book is checked against the bank's statement.

Problem Statement

Design the payment system of an e-commerce company: when a customer checks out, charge them through external payment providers (cards, UPI, wallets), record the money movement correctly, and later pay out to sellers. The bar is correctness, not throughput: no double charges, no lost payments, and books that always balance.

Functional Requirements

Non-Functional Requirements

Core Entities

API Design

POST /v1/payments
  Idempotency-Key: order-9812-attempt-1
  { "order_id": "9812", "amount": 49900, "currency": "INR", "method_token": "tok_..." }
  -> 201 { payment_id, status: "PROCESSING" | "REQUIRES_ACTION", next_action_url? }

POST /v1/payments/{id}/refunds  { amount }   Idempotency-Key: ...
GET  /v1/payments/{id}          -> { status, amount, psp_reference }
POST /webhooks/psp              (signed by the PSP; verified before use)

Amounts are integers in the smallest currency unit (paise, cents), never floating point.

High-Level Design (HLD)

Payment flow with idempotency and a ledgerCheckoutPayment servicePayments DB+ idempotencykeysPayment providerWebhook handlerLedger(double entry)PaymentSucceededpay(key)createchargeresult
Every hop is idempotent, the outcome arrives asynchronously, and every money movement goes into an append-only ledger.
checkout -> Payment service
              1. idempotency check (key -> existing payment? return it)
              2. create payment row: status=CREATED  (same txn as key)
              3. call PSP with OUR payment id as the PSP idempotency key
              4. status=PROCESSING / REQUIRES_ACTION (redirect for 3DS/UPI)
PSP --> webhook --> verify signature --> update payment status (state machine)
                                     --> write ledger entries (double entry)
                                     --> publish PaymentSucceeded -> order service
Reconciliation job (daily): PSP settlement file vs our ledger -> mismatches -> alerts/queue
Status poller: payments stuck in PROCESSING too long -> query PSP for the truth

Low-Level Design (LLD)

Payment state machine and ledgerCREATEDPROCESSINGREQUIRES_ACTION(3-D Secure / UPI)SUCCEEDEDFAILEDledger_entriestxn_id, accountdebit / creditamount (paise)webhookdouble entry
Transitions are conditional (late or duplicate webhooks change nothing); success writes balanced ledger entries.

Payment state machine

CREATED -> PROCESSING -> SUCCEEDED -> (PARTIALLY_)REFUNDED
               |    \-> REQUIRES_ACTION -> PROCESSING
               \-> FAILED

UPDATE payments SET status = 'SUCCEEDED', psp_reference = :ref
 WHERE id = :id AND status IN ('PROCESSING','REQUIRES_ACTION');
-- transitions are conditional: a duplicate or late webhook changes nothing

Idempotency end to end

Double-entry ledger

ledger_entries(id, txn_id, account, direction, amount, currency, created_at)  -- append-only

Customer pays 499.00, platform fee 50.00:
  txn 77:  DEBIT  psp_clearing        49900
           CREDIT seller_payable      44900
           CREDIT platform_revenue     5000
  invariant: sum(debits) = sum(credits) for every txn (checked in the same DB transaction)
Refunds are new reversing entries, never edits or deletes.

Handling the unknown outcome

If the call to the PSP times out, we don't know whether the customer was charged. Never assume failure. Mark the payment PROCESSING, and let the webhook or a status poll (querying the PSP by our payment id) decide; the PSP-side idempotency key makes a retry safe.

Follow-up Questions

The order service must mark the order paid exactly when the payment succeeds. How?
Write the status change and an outbox event in the same database transaction; a relay publishes the outbox to the message bus. The order service consumes it idempotently. No dual-write gap.
Why reconcile daily if everything is idempotent?
Bugs, missed webhooks, manual actions at the PSP and bank-side reversals happen. Reconciliation compares independent records (PSP settlements vs our ledger) and catches what the online path missed.
How do you avoid storing card numbers?
The card form comes from the PSP (hosted fields/SDK), which returns a token; our systems only ever see tokens, keeping PCI scope minimal.

Related