SD System Design In Depth
0%
design system-designpaymentsworkflows

Design: a payment system

Module 6.2: Interactive Product System Designs Updated Sep 18, 2026
📄 Cheat Sheet
Design a Payment System - System Design Interview
Code with Lucian Design a Payment System - System Design Interview Watch on YouTube ↗

Kept: a well-regarded walkthrough of payment service, PSP integration, ledger, wallet and reconciliation, closely matching the lesson.

0:00 / --:--

Foundational Requirements: Idempotent Operations & Double-Entry

Financial systems operate under zero-tolerance constraints for data loss or incorrect balances. A production payment system must guarantee:

  • Idempotency: Retries of one intended payment use the same operation key and payload. Local uniqueness prevents duplicate local operations; preventing duplicate external charges additionally depends on the PSP's idempotency contract and reconciliation.
  • Double-Entry Bookkeeping: Every financial transaction must record equal and offsetting debits and credits across balance sheet accounts. Money is never created or destroyed; money only moves between accounts.
  • Auditability & Immutability: Ledger entries are append-only. Mistakes are corrected by recording reversing journal entries, never by mutating or deleting past records.

Payment Flow Architecture

flowchart LR Buyer["Buyer App"] --> PayAPI["Payment Service"] PayAPI --> Idemp["Idempotency Check"] Idemp --> PSP["Payment Service Provider: Stripe / Adyen"] PSP --> PayAPI PayAPI --> Ledger[("Double-Entry Ledger DB")] PayAPI --> Rec["Reconciliation Engine"] BankSettlement["Bank Settlement Files"] --> Rec

Idempotency Execution Pattern

Clients generate a cryptographically unique Idempotency-Key (UUID v4) for every checkout attempt. When the request reaches the payment gateway:

  1. An atomic database transaction creates a payment record in PENDING status indexed by the idempotency key. If a row already exists, the gateway returns the existing status without charging again.
  2. After committing the local operation, the service calls the PSP with the same stable key and frozen payload. It does not hold a database transaction open across the network call.
  3. If authorization is confirmed, a local transaction records SUCCEEDED and the corresponding ledger rows. If the call times out, retain UNKNOWN: the PSP may have charged the customer even though no reply arrived. Recover by repeating the identical provider operation where its contract permits, querying provider state, processing signed webhooks idempotently, and reconciling settlement data.

Nightly Reconciliation & Settlement

External PSPs and acquiring banks transmit end-of-day settlement batch files. The internal Reconciliation Engine matches internal ledger journal entries against the bank statement transactions. Any discrepancy (missed charge, chargeback, foreign exchange difference) triggers automated alerting and dispute queues for accounting review.

Worked example: ledger entries for one card payment

A merchant sells a 100.00 USD order. The PSP fee is 2.9% + 0.30, so 3.20 USD, and the PSP pays out the net amount. Amounts are stored in integer cents.

Journal entryAccountDebitCredit
CapturePSP receivable (asset)9680
Processing fees (expense)320
Sales revenue10000
Payout (T+2)Bank cash9680
PSP receivable9680
Refund of 20.00Sales returns (contra-revenue)2000
PSP receivable (netted from the next payout)2000

In every journal entry, debits equal credits. A nightly check that each entry balances, plus matching the PSP receivable balance against the payout and settlement files, catches most bugs. Whether the original fee is returned on a refund depends on the PSP's pricing, which is one more line the reconciliation must check.

Payment state machine

flowchart LR C["CREATED"] --> P["PENDING at PSP"] P --> A["AUTHORIZED"] P --> F["FAILED"] P --> U["UNKNOWN after timeout"] U -->|"query PSP or webhook"| A U -->|"query PSP or webhook"| F A --> K["CAPTURED"] A -->|"expires or voided"| V["VOIDED"] K --> S["SETTLED"] K --> R["REFUNDED or partially refunded"]

Card authorisations usually expire after about 7 days (this varies by network and merchant category), so a capture must happen before then or the hold on the customer's funds lapses. Only move between states along allowed transitions, using a conditional update, the same pattern as seat booking.

Reconciliation example

FindingLikely causeAction
In the PSP file, missing from the ledgerTimeout left the payment UNKNOWN, and the webhook was lostCreate ledger entries from the PSP record, and mark the order paid
In the ledger, missing from the PSP fileCapture never actually happened, or will settle in the next batchRecheck the next day, then investigate
Amount differsFX conversion, fee change, partial capturePost an adjustment entry
Chargeback lineCustomer disputed the payment with their bankOpen a dispute case, and reverse the merchant payable

Stripe keeps idempotency keys for at least 24 hours and returns the original response for any repeated request with the same key. Your own idempotency table should keep keys at least as long as the longest client retry window.

Check yourself Question 1 of 3
Timeout is not failure staff
From: Idempotency Execution Pattern

The call to the PSP times out with no reply. The client asks the payment service to try again. What should the service do?

Architecture Challenge: Payment Idempotency & Two-Phase Commit
Staff Level
Context / Scenario: Mobile network timeout on credit card payment submission.
Problem: A customer clicks 'Pay
00'. The network connection drops before the browser receives the HTTP response. The customer clicks 'Pay 00' again. How do you ensure they are only charged once?
A Block all requests coming from the same IP address for 60 seconds.
B Prompt the customer with a JavaScript alert asking if they really meant to pay again.
C Refund all duplicate charges automatically at midnight.
D Require a client-generated UUID Idempotency-Key header; for a repeated key within the retention window (e.g. 24 hours) the server returns the original result instead of charging again.
Deep Dive Rationale: Idempotency keys (popularized by Stripe; being standardized in an IETF draft) map each transaction uniquely. If a request with an existing key is received, the server returns the cached result without charging the card again.

Key Takeaways

Every financial mutation must use double-entry ledger mechanics: Sum(Debits) == Sum(Credits).
Use stable operation identities end to end, but verify each external provider's idempotency window and changed-payload behavior.
Never mutate ledger records; use compensating transactions to correct errors.
Asynchronous nightly reconciliation is required to verify internal records against external bank settlement files.

Further Reading

Stripe Engineering: Designing Robust and Idempotent APIs Double-Entry Bookkeeping Principles for Engineers
Was this lesson helpful? Spot an issue or have a suggestion? Let us know on GitHub.
Report an issue
← Previous Design: a ticket booking system Next → Design: a search service