Index/Payment System

SponsorGitHub
Design15 min

Payment System

Charges, captures, refunds and payouts across an unreliable external processor, with a ledger that has to balance.

Primary concepts and the hard part

Concepts
ACID transactionsdouble-entry ledgeridempotency keyssagas and compensating actionstransactional outboxexactly-once effectsreconciliationCP over APaudit trails
The hard part they’re probing
This is the one design where you must choose consistency over availability, and where "at-least-once + idempotent" stops being a slogan and becomes the actual mechanism preventing double-charges. They also want to see you treat the external payment processor as unreliable and asynchronous.

Requirements

Functionalwhat it must do
Charge a customer for an order
Support authorize → capture (hold funds now, take them on shipment)
Refunds, partial refunds, and chargebacks
Payouts to sellers/merchants
Transaction history and receipts
Non-functionalhow well it must do it
Never double-charge. Never lose a payment.
Every money movement must be auditable and reconcilable
Strong consistency for balances; eventual consistency acceptable for reporting
Availability target is high but subordinate to correctness — failing closed is correct here
PCI-DSS: never store raw card numbers
Out of scopedeliberately left out
Fraud scoring models
FX rate sourcing
Tax calculation
Scale
Transactions:   10,000/sec peak
Ledger entries: 2+ per transaction (double-entry) → 20,000 writes/sec
Retention:      7+ years, immutable (regulatory)
Conclusion
10k/sec is modest — this is not a scale problem. It is a correctness problem. Say that explicitly; candidates who launch into sharding strategies have misread the question.

API / Model

POST
/v1/payments
Idempotency-Key: <uuid>
{order_id, amount, currency, payment_method_token, capture: true|false}
the Idempotency-Key header is mandatory
201Createdpayment
POST
/v1/payments/{id}/capture
{amount}
200OK
POST
/v1/payments/{id}/refund
Idempotency-Key: <uuid> {amount, reason}
201Createdrefund
GET
/v1/payments/{id}
200OKpayment
POST
/v1/webhooks/psp
inbound from the processor, signed
200OK
primary keyforeign key → referenced columnnullableHover a table or column to trace its keys

Two details worth stating unprompted: amounts are integers in minor units (cents), never floats — 0.1 + 0.2 != 0.3 is a real bug that costs real money. And ledger entries are immutable; a correction is a new compensating entry, never an UPDATE.


High-level architecture

DatabaseQueue / streamExternal systemAPI gatewayFocusClick a node for details

Every payment is committed to the Primary DB before anything talks to the processor. From there, an outbox feeds Kafka, a PSP worker calls the PSP, and webhooks carry the result back into the same database.

  1. Checkout sends POST /payments with an Idempotency-Key through the API Gateway, and the Payment Service claims the key with an INSERT on (merchant, key). If the key has been seen and the payment completed, the service returns the stored response and does nothing else.
  2. For a new key, one ACID transaction inserts the payment as PENDING, its balanced ledger entries and an outbox row, and commits them to the Primary DB together.
  3. The outbox publisher polls for unpublished rows and publishes them to Kafka payment.events.
  4. The PSP worker consumes the event and calls the PSP to authorize or capture, with timeouts, backoff with jitter and a circuit breaker per PSP. It sends our idempotency key along, and after a timeout it queries the PSP for that key instead of guessing.
  5. The PSP reports the outcome to the webhook receiver, which verifies the HMAC signature and dedupes on psp_event_id before updating the Primary DB.

Card details never enter this flow. Checkout sends them straight to the PSP, and the payment request carries only payment_method_token. Other consumers of payment.events handle fulfilment, receipts and the warehouse, independently of the PSP worker. Once a day, reconciliation compares the PSP settlement file with the ledger and sends any discrepancy to an exceptions queue.


Trade-offs and deep dives

01
Idempotency is the core mechanism, not a nice-to-have

The client sends a key; the server claims it with a unique-constrained INSERT before doing any work. A retry finds the existing row and returns the stored response without re-charging. The unique constraint is what makes this concurrency-safe — two simultaneous retries race on the INSERT and exactly one wins. Also hash the request body and compare: the same key with a different amount is a client bug and should return an error, not silently replay.

02
The timeout problem, stated properly

You call the PSP and get no response. Three possibilities: the request never arrived, it arrived and succeeded but the response was lost, or it arrived and failed. You cannot distinguish them. Treating a timeout as a failure and retrying blindly is how double-charges happen. The correct handling: pass your idempotency key to the PSP as well (every major processor supports this), so a retry is safe on their side too, and reconcile the unknown state by querying the PSP for that key rather than guessing.

03
Double-entry ledger

Every transaction writes at least two entries that sum to zero — debit one account, credit another. This isn't accounting ceremony; it's an invariant you can check. If the sum of all ledger entries isn't zero, you have a bug, and you'll find it in seconds rather than discovering it in a quarterly audit. Balances are derived by summing entries (cached and periodically recomputed), never stored as a mutable field that can drift.

04
Why CP, not AP

During a partition, refusing the payment and returning an error is correct. The user retries; nothing is lost. Accepting the payment on both sides of a partition and reconciling later means double-charging real customers and real regulatory exposure. This is the clearest case in all of system design where availability loses, and stating it confidently is exactly what they want to hear.

05
Saga over 2PC

Two-phase commit across an inventory service, a payment service, and a shipping service would hold locks across network boundaries, and a coordinator crash strands everything. A saga runs local transactions with compensating actions — refund the charge, release the inventory. You give up isolation (there are observable intermediate states where money is taken but nothing has shipped) in exchange for availability and no distributed locking. Name the trade, don't hide it.

06
Transactional outbox

You must update the database and publish an event. Doing both directly is a dual write: either can fail independently. The outbox writes the event row inside the same transaction as the payment, and a separate publisher drains it. Now there is one transaction, atomicity holds, and publishing is at-least-once — hence idempotent consumers downstream. This is the standard answer to "what if the DB write succeeds but Kafka fails?" and it applies well beyond payments.

07
Authorize vs capture

Authorization places a hold on funds without moving them; capture moves them. Splitting the two lets you verify stock or ship before taking money, and it's the industry norm. Authorizations expire (typically ~7 days), so you need a job to capture or void before expiry — an easy detail to forget and a good one to mention.

08
PCI scope reduction

Card details go from the browser directly to the PSP's hosted fields or SDK, which returns a token. Your servers only ever see the token. This removes almost your entire codebase from PCI-DSS audit scope. If you accept raw card numbers anywhere, every service that touches them is in scope, and that is enormously expensive.

09
Reconciliation is mandatory

Every day, fetch the PSP's settlement report and compare it line by line against your ledger. Discrepancies go to an exceptions queue for human review. Distributed systems drift — webhooks get lost, retries land twice, the PSP has its own bugs. Reconciliation is the control that catches what the code missed, and mentioning it unprompted is a strong domain signal.

10
Webhooks are unreliable input

Verify the HMAC signature (otherwise anyone can mark payments as succeeded). Dedupe on the processor's event ID. Tolerate out-of-order arrival: model payment state as a state machine that ignores transitions that would move backwards, so a late authorized webhook doesn't undo a captured state.

11
Currency

Store minor units as integers with an explicit currency code. Never mix currencies in an arithmetic operation. For multi-currency, record the FX rate used on the transaction itself so the historical record is reproducible.


Possible follow-up questions

6 questions·try answering before you reveal