Lessons
1 · System Design Foundations
2 · APIs and Communication
3 · Capacity Estimation
4 · Core Building Blocks
5 · Databases
- 12Relational Databases and Data Modeling for System Design
- 13Database Indexes and Query Performance: How to Make Reads Fast
- 14Transactions, Isolation Levels and Concurrency Control: Preventing Race Conditions
- 15Database Replication: Primary-Replica, Read Replicas, Lag and Failover
- 16Database Sharding and Partitioning: How It Works and When You Need It
- 17SQL vs NoSQL: Data Models and How to Choose the Right Database
6 · Distributed Systems
- 18Why Distributed Systems Are Hard: Partial Failure, Timeouts and Clocks
- 19CAP Theorem and Consistency Models Explained Without the Myths
- 20Idempotency, Retries and Message Delivery Guarantees
- 21Distributed Transactions: Two-Phase Commit, the Outbox Pattern and Sagas
- 22Distributed Locks, Leases, Leader Election and Consensus
7 · Reliability and Operations
8 · Architecture and Security
9 · Interview Method and Foundation Case Studies
10 · Real-World Case Studies
- 33Design a Notification System: Email, SMS and Push at Scale
- 34Design a Chat System Like a Messaging App
- 35Design a News Feed: Fan-Out, Ranking and the Celebrity Problem
- 36Design Search Autocomplete (Typeahead)
- 37Design a Distributed Job Scheduler
- 38Design a Ticket Booking System Without Overselling
- 39Design a Payment System: Idempotency, Ledgers and Reconciliation
11 · Large-Scale Case Studies
- 40Design a Video Streaming Platform: Upload, Transcoding and Adaptive Streaming
- 41Design a Ride-Hailing System: Location, Matching and Real-Time Updates
- 42Design a Food Delivery Platform: Orders, Restaurants and Delivery Partners
- 43Design an E-Commerce System: Catalog, Cart, Checkout, Inventory and Flash Sales
- 44Design a Web Search Engine: Crawling, Inverted Indexes and Ranking
12 · Interview Readiness
Lesson 39 · Real-World Case Studies
Design a Payment System: Idempotency, Ledgers and Reconciliation
Design the payment platform behind checkout and refunds, where correctness matters more than speed: payment intents and attempts, an explicit state machine, idempotency at every hop, asynchronous confirmation through verified webhooks, a double-entry ledger with a runnable example, reconciliation against provider reports, refunds, provider failover, fraud and PCI scope, and an audit trail.
For
SDE-2 candidates preparing for payment-system questions, and backend engineers integrating payment providers where duplicate charges or lost money are unacceptable.
Level
Before you start
Read Transactions and Concurrency Control (article 14), Idempotency and Retries (article 20), Distributed Transactions and Sagas (article 21), Event Sourcing (article 27) and Security (article 28).
On this page
Key takeaways
- In payments, correctness beats latency: no double charges, no lost money, and every rupee explainable afterwards.
- Model payments as intents with attempts and an explicit state machine, and enforce transitions with conditional updates so late or duplicate events cannot move a payment backwards.
- Use idempotency keys from client to API and from API to provider, and treat timeouts as unknown outcomes to be resolved, never as failures.
- Record money movements in an append-only double-entry ledger where every entry balances; corrections are new entries, not edits.
- Reconcile your records against the provider's settlement reports every day, because webhooks get lost and bugs happen.
A checkout page shows a spinner for eight seconds, then an error. The customer tries again and pays successfully. The next morning their bank statement shows two charges. Or the opposite: the provider charged the card, our server never heard back, the order was cancelled, and the money sits in limbo until an angry support ticket arrives a week later. Neither is a dramatic outage; both destroy trust, and both come from the same root cause: treating a payment like an ordinary API call.
A payment system is one of the few designs where correctness clearly beats speed. This case study designs the payment platform behind checkout and refunds for an online marketplace, using a payment service provider (PSP) to talk to card networks, UPI and wallets. The examples are hypothetical, and nothing here describes any particular company's internal systems.
What you will learn
#- How to scope a payment platform and what its scale really is.
- Payment intents, attempts and an explicit state machine.
- Idempotency at each hop, and how to resolve unknown outcomes.
- Asynchronous confirmation with verified webhooks and status checks.
- A double-entry ledger, with a runnable example.
- Reconciliation, refunds, provider failover and the audit trail.
- Security: PCI scope, fraud and tampering.
1. The problem
#The marketplace's order service needs to collect money from customers at checkout, refund them when orders are cancelled or returned, and know at all times how much is owed to each merchant and how much the platform has earned. Payments go through one or more external PSPs. The payment platform must never charge twice for one order, never lose track of money, and be able to explain every movement to finance teams and auditors.
2. Clarify the requirements
#Question | Assumed answer | Impact |
|---|---|---|
Do we process cards ourselves? | No. A PSP handles cards, UPI, net banking and wallets; we never see card numbers | PCI scope reduction; PSP integration |
Payment methods? | Several; some complete instantly, some asynchronously (the customer approves in another app or on a bank page) | Asynchronous confirmation |
Currencies? | Indian rupees only | Integer paise everywhere |
Refunds? | Full and partial, possibly several per payment | Refund records, ledger reversals |
Merchant payouts? | Tracked in the ledger; the payout process itself is out of scope | Payable balances |
Scale? | See estimates; modest by request count | Correctness focus |
Records retention? | Years, per finance and regulatory requirements | Append-only storage |
Functional requirements: create a payment for an order; confirm it with a payment method; handle asynchronous outcomes; refund fully or partially; record every money movement; reconcile with the PSP; expose status to the order service.
Non-functional requirements
Quality | Target |
|---|---|
Correctness | No double charges; no payment without a recorded outcome; ledger always balanced |
Durability | Committed payment records are never lost (synchronous replication, backups) |
Latency | Checkout confirmation usually within a few seconds; asynchronous methods may take minutes |
Availability | 99.95% for creating and confirming payments; degraded but safe behaviour during PSP outages |
Auditability | Every state change and money movement is attributable and immutable |
Security | No card data in our systems; strict access control; signed provider communication |
3. Assumptions and scale estimation
#2 million payments/day -> about 23/s average; sale-day peaks of 5x -> about 120/sRefunds: 5% of payments -> 100,000/dayLedger: about 3 balanced entries per payment lifecycle (capture, provider fee, settlement) plus refunds -> about 7 million entries/day, each with 2 to 4 linesStorage: 7 million entries x 500 bytes = 3.5 GB/day -> about 1.3 TB/year, kept for yearsPSP webhooks: about 2 to 3 events per payment -> about 6 million/dayConclusions: the request rate is small; one well-run relational database (with a synchronous standby) can handle it, and gives us transactions, constraints and conditional updates, which are exactly what correctness needs. Storage grows steadily and is append-only, so partition ledger tables by time. Scale is not the hard part; failure handling is.
4. API design
#POST /v1/payment-intentsIdempotency-Key: checkout-ord_77{ "order_id": "ord_77", "amount": 149900, "currency": "INR", "customer_id": 42 }201 { "intent_id": "pi_51", "status": "requires_payment_method" }POST /v1/payment-intents/{intentId}/confirmIdempotency-Key: confirm-pi_51-1{ "payment_method_token": "pmt_..." } (a PSP token from the PSP's client-side SDK or hosted page)202 { "status": "processing" } or 200 { "status": "succeeded" } or { "status": "requires_action", "redirect_url": "..." }GET /v1/payment-intents/{intentId} -> current statusPOST /v1/refundsIdempotency-Key: refund-ord_77-item3{ "intent_id": "pi_51", "amount": 49900, "reason": "item_returned" }202 { "refund_id": "rf_9", "status": "pending" }POST /v1/psp/webhooks (PSP -> us; signature verified)- The amount comes from the order service (server to server), never from the browser, so a customer cannot change what they pay.
- Every write takes an idempotency key, and the payment platform passes its own keys to the PSP.
- Outcomes are asynchronous by design:
processingandrequires_actionare normal states, not errors. - Internally, the platform emits events such as
PaymentSucceeded,PaymentFailedandRefundSucceededthrough an outbox (article 21), which the order service consumes.
5. Data model
#Intents and attempts
#A payment intent is "we want to collect ₹1,499 for order 77". An attempt is one try with one payment method at one PSP. A customer whose card is declined may try UPI next: one intent, two attempts.
CREATE TABLE payment_intents ( intent_id VARCHAR(32) PRIMARY KEY, order_id VARCHAR(32) NOT NULL UNIQUE, -- one intent per order customer_id BIGINT NOT NULL, amount_paise BIGINT NOT NULL CHECK (amount_paise > 0), currency CHAR(3) NOT NULL, status VARCHAR(24) NOT NULL, version BIGINT NOT NULL DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT now());CREATE TABLE payment_attempts ( attempt_id VARCHAR(32) PRIMARY KEY, -- also our idempotency key at the PSP intent_id VARCHAR(32) NOT NULL REFERENCES payment_intents(intent_id), psp VARCHAR(16) NOT NULL, psp_reference VARCHAR(64) UNIQUE, -- the PSP's id for this attempt status VARCHAR(24) NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL);-- At most one successful attempt per intent:CREATE UNIQUE INDEX one_success_per_intent ON payment_attempts (intent_id) WHERE status = 'succeeded';CREATE TABLE refunds (refund_id PRIMARY KEY, intent_id, amount_paise, status, psp_reference UNIQUE, ...);CREATE TABLE psp_events (event_id VARCHAR(64) PRIMARY KEY, received_at, payload, processed_at);CREATE TABLE idempotency_keys (client_id, idem_key, request_hash, response, ... PRIMARY KEY (client_id, idem_key));The constraints carry much of the correctness: one intent per order, one success per intent, one record per PSP reference, one processed row per webhook event.
The state machine
#requires_payment_method --confirm--> processing --PSP success--> succeeded --refund(s)--> partially_refunded / refunded ^ | \--PSP failure--> failed (customer may try another method) | | +---- new attempt allowed ------+--requires 3-D Secure / app approval--> requires_action --> processing \--no answer for a long time--> unknown -> resolved by status queryEvery transition is a conditional update on the current state:
UPDATE payment_intents SET status = 'succeeded', version = version + 1WHERE intent_id = :id AND status IN ('processing', 'requires_action', 'unknown');A duplicate or late event that tries to move a succeeded payment back to processing, or a refunded payment to succeeded, changes zero rows. That one rule protects against most webhook ordering problems (article 20).
The ledger
#Money movements are recorded in a double-entry ledger: every journal entry has lines that debit and credit accounts, and the debits and credits of each entry must balance. Entries are append-only; mistakes are corrected with new, reversing entries. This is the classic accounting structure, and it fits event sourcing naturally: the journal is the history, balances are derived (article 27; Microsoft's Event Sourcing guidance mentions ledgers among the good fits for the pattern (Event Sourcing pattern)).
journal_entries (entry_id PK, kind, reference (intent, refund or settlement id), created_at)journal_lines (entry_id, line_no, account_id, amount_paise (debit positive, credit negative))accounts (account_id, type: provider_clearing, merchant_payable:{merchant}, commission_revenue, provider_fees_expense, bank, ...)6. Baseline architecture
#Order service --charge(card details)--> PSP API, wait for the answer, store "paid" or "failed"Simple, and wrong in several ways: card details pass through our servers (PCI scope), a timeout leaves us not knowing whether the card was charged, a retry may charge twice, asynchronous methods do not fit a single request, and there is no record of where money went.
7. What breaks first?
#- Timeouts: the PSP call times out; the charge may or may not have happened.
- Retries: clients, our services and networks retry; without idempotency, retries become duplicate charges.
- Asynchronous outcomes: many methods complete minutes later; webhooks arrive late, twice, or out of order, or not at all.
- Split brain between us and the PSP: our database says
processing, the PSP sayssucceeded. - No money trail: without a ledger, finance cannot answer "how much do we owe merchant X?".
- PSP outages during peak sales.
8. Scaling the design
#Idempotency at every hop
#Hop | Key | Effect |
|---|---|---|
Browser or app -> order service | Checkout idempotency key | One order per checkout |
Order service -> payment API |
| One intent per order (also |
Payment API -> PSP |
| A retried PSP call returns the original result instead of charging again |
PSP -> us (webhooks) | PSP | Each event processed once |
The AWS Builders' Library article on idempotent APIs describes the same discipline: unique caller-provided request identifiers, returning a semantically equivalent response for repeats, and rejecting reuse with different parameters (Making retries safe with idempotent APIs). Stripe's documentation describes how its idempotency keys work for retried requests (Idempotent requests).
Confirming a payment
#1. Confirm request arrives (idempotency key checked).2. One transaction: create attempt (status processing), intent -> processing.3. Call the PSP with attempt_id as the idempotency key, with a timeout.4a. PSP answers succeeded/failed: one transaction updates attempt and intent (conditional), writes ledger entries, writes outbox event; respond.4b. PSP answers requires_action: return the redirect or approval instructions; wait for a webhook.4c. PSP call times out: leave the attempt "processing" (outcome UNKNOWN, not failed); respond 202; a resolver job queries the PSP for the attempt's status after a short delay.The rule that prevents double charges: never treat a timeout as a failure and never start a new attempt while an earlier attempt's outcome is unknown. Resolve it first (by status query or webhook), then decide.
Webhooks
#PSPs report asynchronous outcomes by calling a webhook endpoint. Stripe's documentation, for example, describes receiving events at an endpoint, verifying signatures, returning a success response quickly, and handling duplicate events (Webhooks). Our handler:
- Verifies the signature (and timestamp, to resist replay).
- Stores the raw event in
psp_eventskeyed byevent_id(a duplicate insert means "already have it": return 200). - Returns 200 immediately.
- A processor applies the event: finds the attempt by
psp_reference, applies the transition conditionally, writes ledger entries and the outbox event in the same transaction. - If the event refers to an unknown reference or an impossible transition, it is parked for investigation, not dropped.
Webhooks can be lost, so a resolver job also polls the PSP for any attempt that has stayed processing or requires_action beyond a threshold.
The ledger in action
#This Java program posts three balanced journal entries for one order (a ₹1,000 payment with a 2% marketplace commission, the PSP's processing fee, and a ₹500 partial refund), rejects an unbalanced entry, and derives account balances from the journal. The account names and fee are illustrative.
// File: LedgerDemo.javaimport java.util.ArrayList;import java.util.List;import java.util.Locale;import java.util.Map;import java.util.TreeMap;public class LedgerDemo { // One line of a journal entry. Amounts are integers in paise; debits positive, credits negative. record Line(String account, long amount) {} record Entry(String id, String description, List<Line> lines) {} static final List<Entry> journal = new ArrayList<>(); // append-only static void post(String id, String description, Line... lines) { long sum = 0; for (Line line : lines) { sum += line.amount(); } if (sum != 0) { throw new IllegalArgumentException(id + " rejected: debits and credits differ by " + sum); } journal.add(new Entry(id, description, List.of(lines))); } static Map<String, Long> balances() { Map<String, Long> totals = new TreeMap<>(); for (Entry entry : journal) { for (Line line : entry.lines()) { totals.merge(line.account(), line.amount(), Long::sum); } } return totals; } public static void main(String[] args) { // A customer pays Rs 1,000 for an order; the platform keeps a 2% commission. post("e1", "payment pay_51 captured", new Line("provider_clearing", 100_000), // money the provider owes us new Line("merchant_payable", -98_000), // what we owe the merchant new Line("commission_revenue", -2_000)); // our earnings // The provider charges us Rs 23.60 for processing, deducted at settlement. post("e2", "provider fee for pay_51", new Line("provider_fees_expense", 2_360), new Line("provider_clearing", -2_360)); // Half the order is refunded: reverse half of the original split. post("e3", "refund ref_9 on pay_51 (Rs 500)", new Line("merchant_payable", 49_000), new Line("commission_revenue", 1_000), new Line("provider_clearing", -50_000)); try { post("e4", "buggy entry", new Line("merchant_payable", 10_000), new Line("provider_clearing", -9_000)); } catch (IllegalArgumentException e) { System.out.println(e.getMessage()); } long total = 0; for (Map.Entry<String, Long> b : balances().entrySet()) { System.out.printf(Locale.US, "%-22s %10.2f%n", b.getKey(), b.getValue() / 100.0); total += b.getValue(); } System.out.println("sum of all balances: " + total + " (always zero in double-entry)"); }}e4 rejected: debits and credits differ by 1000commission_revenue -10.00merchant_payable -490.00provider_clearing 476.40provider_fees_expense 23.60sum of all balances: 0 (always zero in double-entry)Read the balances: the provider owes us ₹476.40 (₹1,000 collected, less ₹500 refunded, less its ₹23.60 fee); we owe the merchant ₹490; our commission is ₹10 after the refund; and the fee is an expense. Every rupee is accounted for, and the buggy entry could never enter the journal. In a real system the journal lives in database tables, each entry is written in the same transaction as the state change that caused it, and money is never stored as floating point.
Reconciliation
#Even with all of the above, our records and the PSP's can drift: a webhook lost and a resolver bug, a refund the PSP processed twice, a fee that changed. Reconciliation compares them independently, every day:
- Download the PSP's settlement report (every transaction, fee and payout in a period).
- Match each line to our attempts and refunds by
psp_reference, comparing amounts and statuses. - Classify mismatches: present at the PSP but missing in our records, missing at the PSP, amount differs, status differs, fee differs.
- Fix known patterns automatically (for example, apply a missed success through the normal transition path), and send the rest to a finance operations queue with full context.
- Reconcile bank deposits against the PSP's payouts too.
A reconciliation that finds nothing for months is a good sign; one that is never run is a risk.
Refunds
#A refund is its own record with its own idempotency key and state machine (pending, succeeded, failed). Constraints ensure the total refunded never exceeds the captured amount (checked in the same transaction as creating the refund, with a row lock on the intent). The PSP call uses the refund_id as its idempotency key; outcomes arrive by response or webhook; ledger reversal entries are posted on success.
PSP outages and routing
#- Circuit breaker per PSP (article 23).
- Route new attempts to a secondary PSP when the primary is unavailable, if the business has integrated more than one.
- Never resend an in-flight attempt to a different PSP: if the first PSP's outcome is unknown, a second PSP could charge the same customer again. Resolve or expire the first attempt first.
- During a total PSP outage, the honest behaviour is to tell customers payments are temporarily unavailable, or to accept orders with "pay later" options if the business allows it.
The evolved architecture
#Customer app --(card/UPI details)--> PSP's SDK or hosted page --> PSP (card data never reaches us)Customer app --> Order service --(idempotent)--> Payment APIPayment API --> Payments DB (intents, attempts, refunds, idempotency keys, ledger, outbox) --> PSP API (attempt_id as idempotency key; timeouts; circuit breaker)PSP --webhooks--> Webhook receiver (verify, store event, 200) --> Event processor (conditional transitions, ledger entries, outbox)Resolver job --> PSP status API for stuck attemptsOutbox relay --> PaymentSucceeded / PaymentFailed / RefundSucceeded --> Order service, notificationsReconciliation (daily) --> PSP settlement reports vs ledger --> auto-fix or finance ops queuePayments DB: primary + synchronous standby; point-in-time backups; ledger tables partitioned by month9. Failure scenarios and reliability
#Failure | Effect | Response |
|---|---|---|
PSP call times out | Outcome unknown | Attempt stays |
Customer double-submits | Two confirm requests | Same idempotency key returns the same result |
Webhook duplicated or out of order | Repeated or stale events |
|
Webhook never arrives | Payment stuck | Resolver polls the PSP; daily reconciliation |
Our DB unavailable after the PSP charged | Success not recorded | PSP has the record; resolver and reconciliation recover it once the DB is back |
Order service misses | Order not fulfilled | Outbox redelivery; order service consumes idempotently |
Bug posts a wrong ledger entry | Balances wrong | Append a reversing entry; never edit history; reconciliation detects |
Primary PSP down at peak | Payments fail | Circuit breaker; new attempts to the secondary PSP |
Refund succeeds at the PSP but we time out | Refund state unknown | Same resolver pattern; refund idempotency key prevents double refunds |
10. Consistency
#- Inside the payments database: strong consistency. State transitions, ledger entries and outbox events commit together, so the payment state and the money trail never disagree.
- With the PSP: eventual. Our records converge to the PSP's truth through responses, webhooks, status queries and reconciliation.
- With the order service: eventual, through events and the order saga (article 21), with explicit intermediate states (
payment_pending). - No floating point, no edits: money is integer paise; history is append-only.
11. Security
#- Keep card data out: use the PSP's client-side SDK or hosted page so card details go straight to the PSP, and store only tokens and references. PCI DSS applies to entities that store, process or transmit cardholder data (PCI DSS); keeping that data out of your systems greatly reduces what falls in scope.
- Verify every webhook signature and timestamp; accept webhooks only on a dedicated endpoint.
- Server-side amounts: the amount is computed from the order on the server.
- Least privilege: only the payment service can write payment tables; finance tools read through audited paths; refunds above a threshold need approval.
- Secrets: PSP keys in a secrets manager, rotated, with separate keys per environment.
- Fraud: velocity limits (attempts per card, account and device), the PSP's risk signals, 3-D Secure or equivalent customer authentication where required, and review queues for suspicious refunds.
- Audit: every state change, refund and manual adjustment records who, what and when, in an append-only audit log.
12. Observability
#Signal | Why |
|---|---|
Payment success rate by method and PSP | Detect PSP problems and declines |
Confirmation latency p50 and p99 | Customer experience |
Attempts in | Stuck payments; resolver health |
Webhook receive-to-process lag; rejected signatures | Integration health and attacks |
Reconciliation mismatches by category | Correctness |
Ledger imbalance check | Must always be zero; page if not |
Refund volume and failures | Operations and fraud |
Duplicate-request hits per layer | Retry behaviour |
13. Trade-offs
#Decision | Chosen | Alternative | Why |
|---|---|---|---|
Card handling | PSP SDK or hosted page | Collect card details ourselves | PCI scope and risk |
Store | One relational database with strong consistency | Distributed or NoSQL store | Low request volume; transactions and constraints matter most |
Outcome handling | Asynchronous states plus resolver | Treat timeouts as failures | Avoid double charges and lost payments |
Money records | Append-only double-entry ledger | Balance columns updated in place | Auditability; errors corrected transparently |
Verification | Daily reconciliation | Trust webhooks | Webhooks and code both fail occasionally |
PSP strategy | Primary plus secondary for new attempts | One PSP | Availability, at integration cost |
14. The final interview answer
#"Payments here are about correctness, not scale: about 2 million payments a day, 120 a second at peak, on one relational database with a synchronous standby, because transactions and constraints are what keep money safe.
Each order gets one payment intent; each try with a method is an attempt. Card and UPI details go straight to the PSP through its SDK, so we only hold tokens. Every hop is idempotent: the order service calls us with a key per order, we call the PSP with the attempt id as its idempotency key, and webhooks are deduplicated by event id. A timeout is an unknown outcome, never a failure: the attempt stays processing until a resolver job or a webhook tells us the truth, and we never start a new attempt while one is unknown.
State changes are conditional updates on an explicit state machine, so late or duplicate events can't move a payment backwards. Each state change commits with double-entry ledger entries and an outbox event in one transaction, so the order service learns the outcome reliably and every rupee is accounted for. Refunds work the same way. Every day we reconcile the ledger against the PSP's settlement reports and investigate mismatches. Card data never enters our systems, webhooks are signature-verified, amounts are computed server-side and everything is audited."
Exercises
#Exercise 1
The state machine
#List which transitions are allowed among requires_payment_method, processing, requires_action, succeeded, failed, partially_refunded and refunded, and give two transitions that must be rejected, with the event that might try to cause each.
Exercise 2: The eight-second spinner
#Walk through the opening story with this design: the PSP call times out after 8 seconds, the customer taps "Pay" again. What does each component do, and how many charges result?
Exercise 3: Ledger entries
#Write the balanced journal entries (accounts and amounts) for: a ₹2,000 payment with a 5% marketplace commission; a PSP fee of ₹40; and a full refund after which the platform also refunds its commission and absorbs the PSP fee (which the PSP does not return). What are the final balances?
Exercise 4: Reconciliation mismatches
#The daily reconciliation finds: (a) a PSP transaction marked succeeded that we have as processing; (b) a refund we recorded as succeeded that does not appear in the PSP report; (c) a fee 10 paise different from what we recorded. What does each probably mean, and what should happen?
Show solutionHide solution
Allowed:
requires_payment_method -> processing(confirm).processing -> succeeded | failed | requires_action.requires_action -> processing | succeeded | failed(customer completes or abandons the step).failed -> processingonly through a new attempt on the same intent (the intent can return torequires_payment_methodfor a new method; the failed attempt stays failed).succeeded -> partially_refunded -> refunded, andsucceeded -> refunded.
Must be rejected:
succeeded -> failed: a late, out-of-order "payment failed" webhook for an earlier attempt, or a duplicate failure event. The conditional update requires the current status to beprocessingorrequires_action.refunded -> succeeded: a delayed "payment succeeded" webhook arriving after the refund completed. A success event can only apply toprocessing,requires_actionorunknown.
Exercise 2
Show solutionHide solution
- First tap: the order service calls
confirmwith keyconfirm-pi_51-1. The payment API creates attemptatt_1(processing) and calls the PSP withatt_1as the PSP idempotency key. The call times out. The API returns202 processing; the app shows "confirming your payment". - Second tap: if the app reuses the same confirm idempotency key (it should, for the same checkout), the API returns the stored
202 processing. No new attempt. - If a buggy app sends a new key, the API sees an attempt for this intent already
processing(outcome unknown) and refuses to start another (409, "payment in progress"). - The resolver queries the PSP for
att_1after a short delay. If it succeeded, the intent becomessucceeded, ledger entries are posted andPaymentSucceededis emitted; the app shows success. If it failed or never reached the PSP, the intent becomes available for a new attempt, and the customer can try again.
Result: exactly one charge (or none), never two.
Exercise 3
Show solutionHide solution
Amounts in rupees (stored as paise):
e1 payment captured: provider_clearing +2,000 ; merchant_payable -1,900 ; commission_revenue -100e2 provider fee: provider_fees_expense +40 ; provider_clearing -40e3 full refund: merchant_payable +1,900 ; commission_revenue +100 ; provider_clearing -2,000Each entry balances (+2,000 - 1,900 - 100 = 0; +40 - 40 = 0; +1,900 + 100 - 2,000 = 0).
Final balances: provider_clearing = 2,000 - 40 - 2,000 = -40 (we owe the PSP ₹40, since it refunded the full ₹2,000 but keeps its fee); merchant_payable 0; commission_revenue 0; provider_fees_expense +40 (our cost). Sum: -40 + 40 = 0. The ledger shows exactly what happened: the platform lost the ₹40 fee on this order.
Exercise 4
Show solutionHide solution
- (a) Succeeded at the PSP,
processingin our records: a missed webhook and a resolver that has not run or failed. Apply the success through the normal transition path (conditional update, ledger entries, outbox event) so the order is fulfilled; investigate why the resolver missed it. - (b) Refund we think succeeded, absent at the PSP: serious. Either our status update was wrong (a bug treated a timeout or an error as success) or the report period differs. Query the PSP for the refund by its reference. If it truly does not exist, the customer did not get their money: retry the refund with the same idempotency key, and fix the bug. Alert finance.
- (c) Fee differs by 10 paise: probably rounding or a changed fee rule. Post an adjusting ledger entry for the difference (never edit the original), and check whether our fee calculation or contract data needs updating.
Where to go next
#- Next: Design a Video Streaming Platform: Upload, Transcoding and Adaptive Streaming starts the Level 3 case studies.
- Later: the food delivery (42) and e-commerce (43) designs use this payment platform inside larger sagas.
Sources & further reading
- Technology
- Software Engineering
- #case study
- #distributed systems
- #idempotency
- #roadmap
- #scalability
- #system design
- #system design interview
Connected lessons
Linked from 1 guide
Other pages in the library that point here.
Keep reading
More in Technology →Guide · Technology
Start Here: Your Roadmap from Zero to Job-Ready DSA
Guide · Technology
Heaps and Priority Queues
Guide · Career
A LinkedIn Profile That Gets Recruiter Messages

