Design a Payment System
- Design a general payment system end to end before adding any card-network flavor
- Name the invariants money forces before you draw a single box
- Layer the issuer, acquirer and clearing flow of a card network on top of the general design
Understanding the prompt
When a Mastercard panel says "design a payment system," the trap is to hear "design the Mastercard network" and reach straight for issuers and acquirers. Do that and you have skipped the part the round grades: whether you can build a general transaction-processing system that moves value correctly. The card-network shape is a flavor you add on top of the general core. A candidate who can only recite the Mastercard answer fails the moment the interviewer swaps the nouns. You need to design a double-entry ledger, make a retry idempotent, and say why a payment splits into authorization and settlement. So build the reusable machine first, then dress it in the network.
The reusable machine is any system that debits one account and credits another and must never get the total wrong. Strip the branding away and the same core shows up behind a wallet transfer, a marketplace payout, a subscription charge, and a card transaction. A request arrives and you check it is allowed. You record the movement of money in a ledger that always balances, and you make the whole thing survive a retry when the network drops your response. Get that core clean and honest and the Mastercard prompt becomes a short final step, because the issuer, the acquirer, and the authorize-then-clear rhythm all map onto pieces the general design already has.
Pinning the requirements first
Resist naming services for a minute and bound the problem out loud, because the prompt is deliberately open and the interviewer wants to watch you scope it. State the functional surface plainly. A client asks to charge a payer and pay a payee some amount. The system decides whether to allow it, records the movement of money durably, and returns a definite outcome. It later finalizes the movement so funds settle, and at all times it can answer what a given account's balance is and how any transaction reached its current state. That is the whole core, and it is enough to model something real without drowning in features.
Then decide what you handle now and what you name and defer. Try to design fraud scoring, multi-currency conversion, chargebacks, and network certification all in the first pass, and you will run out of time before the ledger is clean. The right move is to model the skeleton so each of those has an obvious home, then say so. A design where "add real fraud scoring later" means dropping a model behind the existing fraud-check seam is a good design. A design where it means rewriting the charge path is not. Name the functional non-goals for this pass, put a hook where each deferred piece will attach, and you have shown the judgment the round is set to find.
- What the round grades: correctness, then scale. Clean ledger invariants, exactly-once processing, and an auditable trail come first. Throughput and sharding matter, but a design that scales beautifully and can double-charge a card fails the part that counts. Lead with money being right.
- Money is not a float: integer minor units. Represent amounts as integer minor units, cents rather than dollars, so rounding never invents or destroys a fraction of a currency. Say this early; reaching for a floating-point balance is a fast way to look like you have never shipped a ledger.
- The ledger is the source of truth: double-entry, always balanced. Every movement of money is two entries that sum to zero: a debit and an equal credit. The invariant that the whole ledger nets to zero is what lets you prove no money was created or lost, and it is the spine of the design.
- Retries are the normal case: idempotent by key. Networks drop responses, so clients resend. Every mutating request carries an idempotency key, and a repeat of a key returns the first outcome without applying the money again. This is how one charge stays one charge.
The money invariants before the boxes
Spend the first real minutes on the invariants, because the components fall out of them cleanly once the rules are straight. Three ideas carry the whole design. The first is double-entry accounting. You never store a balance as a number you edit in place. You store an append-only sequence of postings, where every posting moves an amount out of one account and into another as a matched debit and credit. A balance is then a derived quantity, the sum of an account's postings, and the global invariant is that all postings across the ledger sum to zero. That structure is what makes the system auditable: you can replay the log and prove exactly how every balance came to be, which is precisely what reconciliation and a dispute both need.
The second idea is the non-negative invariant on real accounts. A customer or merchant account must never drop below zero, because that would mean the system let money out that was not there. So a debit against a real account is conditional: it only posts if the balance covers it, and otherwise the transaction is declined rather than forced through. The exception is a designated clearing or settlement account that represents value entering and leaving the system from the outside world. It may run negative because it is the counterweight that keeps every posting balanced. Naming that one privileged account explicitly is what lets funding and payouts stay double-entry without breaking the rule everywhere else.
The third idea is exactly-once through idempotency, which is the invariant most candidates state weakly. On an unreliable network a client cannot tell a lost request from a lost response, so it retries, and a naive system applies the money twice. The fix is a client-chosen idempotency key on every mutating call. The system records a key's outcome the first time it processes it. Any later request with the same key gets that stored outcome back without touching the ledger again. The subtle part is that you cache the decision, not just the success, so a retried request that was declined stays declined. State that out loud and the exactly-once story stops being a slogan and becomes a mechanism.
Finding the components
With the invariants straight, surface the components by underlining the responsibilities and keeping the ones that carry state or a single clear job. These pieces survive that test. A payment API accepts and validates requests, an idempotency store dedupes retries, and a fraud check can veto. An authorization service decides the outcome, and a ledger service owns the double-entry postings beside a durable transaction record. A capture and settlement path finalizes funds, and a reconciliation pass proves the books agree with the outside world. Give each one a single responsibility and check that no two are secretly doing the same job.
The seam that matters most is keeping the ledger service the only thing that writes postings. Everything else decides whether money should move; only the ledger records that it did, and it does so as balanced double-entry against the idempotency guarantee. That single rule keeps the design honest. The fraud check can grow from a stub into a real model, the settlement worker can change how it batches, and the API can change how it authenticates. None of them reaches into the balances directly. The transaction record sits beside the ledger as the immutable audit trail of how each payment moved through its states, so a dispute or a reconciliation break can be reconstructed after the fact.
How the pieces fit together
With the components named, the design is really the wiring between them and the order of one charge, so draw it. Read the diagram by role. Blue is an external party the system does not control: the merchant that starts the charge, and the acquirer and issuer that make up the card network beyond your edge. Violet is a service the system owns and runs. Amber is the queue that hands the finalize step to a worker. Green is the durable state the design is built to protect: the idempotency store, the transaction record, and the double-entry ledger. Follow the edges to read one payment. Dedupe it, screen it, authorize it against the network, and post the hold in the ledger. Then capture and settle it, with reconciliation checking the books against what the network cleared.
The lifecycle: authorize, capture, settle
The whole design converges on the order of one charge, and the order is the design. First the API dedupes the request against its idempotency key, so a retry short-circuits to the stored outcome before anything else runs. Then it screens the request with the fraud check, because a blocked charge should never reach the network or the ledger. Then authorization asks the issuer, through the acquirer, for a real-time approve or decline. On approval the system does two durable things together: it records the authorized state in the transaction record and posts an authorization hold in the ledger as balanced double-entry. Only later, on capture, does the settlement worker finalize the funds and write the settling posting. Splitting authorize from capture is what lets the decision be instant while the money moves in its own time.
// One charge is one lifecycle. The ORDER is the design: dedupe, screen,// authorize, then post the hold durably. Capture and settle run later.async function charge(req: ChargeRequest): Promise<Outcome> { // 1. IDEMPOTENCY: a retried key returns the first outcome, no double-charge. const prior = await idem.lookup(req.idempotencyKey) if (prior) return prior.outcome // exactly-once: replay, do not re-apply // 2. FRAUD HOOK: a blocked charge never reaches the network or the ledger. if (!(await fraud.allow(req))) { return idem.record(req.idempotencyKey, { status: 'BLOCKED' }) } // 3. AUTHORIZE: real-time approve/decline from the issuer via the acquirer. const auth = await network.authorize(req.card, req.amount) if (!auth.approved) { return idem.record(req.idempotencyKey, { status: 'DECLINED' }) } // 4. POST THE HOLD durably: transaction state + balanced double-entry, together. await txns.append(req.id, 'AUTHORIZED') await ledger.post({ // debit and credit sum to zero key: req.idempotencyKey, debit: 'CLEARING', credit: req.payee, amount: req.amount, }) // Capture is enqueued, not done inline: settlement finalizes funds later. await captureQueue.enqueue(req.id) return idem.record(req.idempotencyKey, { status: 'AUTHORIZED' })}Two things about this order are worth saying to the interviewer directly. First, the idempotency lookup comes before every side effect on purpose. Screen or authorize before you dedupe and a retry re-runs the fraud call and re-hits the network, which at best wastes work and at worst double-authorizes. Deduping first makes the entire charge exactly-once, beyond the ledger write alone. Second, the transaction-state append and the ledger post have to be durable together, so a crash cannot leave the books saying a payment authorized while the ledger holds no posting, or the reverse. You might reach for a transaction across one store, an outbox that the settlement worker drains, or a saga with compensations. Whichever you pick, the round wants to hear that you noticed the two writes must not diverge.
Idempotency, exactly-once, and reconciliation
A payment system that works in a clean demo and drifts on a real rail is a failing answer. Spend real time on the guarantees that keep it correct under retries and partial failure. Exactly-once is not a delivery mode you can buy from a queue; it is an effect you engineer. The network delivers at least once, clients resend, and workers crash mid-batch. Build the money layer so that applying the same logical operation twice lands the same as applying it once. Idempotency keys give you that on the write path, the append-only ledger gives you a replayable history, and reconciliation gives you the after-the-fact check that the two sides agree. Together they are how the design survives the bad day.
Reconciliation deserves its own beat because it is the safety net the happy path cannot provide. Even with idempotent writes, the outside world and your books can disagree: the network reports a settlement your ledger never recorded, an authorization ages out without a capture, or an amount comes back different. The reconciliation pass reads the ledger postings and the transaction record and matches them against what the network cleared, then flags every break for repair. It is only possible because the ledger is append-only and every transaction state was recorded immutably, which is the concrete payoff of designing for auditability from the start rather than bolting it on.
Authorization (real time)
- A hold: the issuer reserves funds but no settled money has moved yet
- Must feel instant, so it returns a decision without waiting on settlement
- Recorded as an authorized state plus a hold posting in the ledger
- Can be reversed cheaply if the capture never comes
- Answers the question "is this allowed and are the funds there?"
Clearing and settlement (later)
- The real movement: settled funds change hands
- Runs asynchronously, often batched, off the capture queue
- Recorded as a settled state plus the final balanced posting
- Is where reconciliation matches your books to the network file
- Answers the question "did the money move, exactly once?"
The card-network flavor
Only now, with the general system standing, add the Mastercard shape, because it maps cleanly onto pieces the design already has. A card transaction has four parties: the cardholder, the merchant, the acquirer that banks the merchant, and the issuer that issued the card. The network sits in the middle, routing messages and setting the rules between them. In the diagram the acquirer and issuer are the external edge the authorization service talks to; nothing else in the core changes. The most important thing to say is that a card payment is authorize-now, clear-and-settle-later by design. That is the same authorize-then-capture split already in the lifecycle, so there is no special case to graft on.
Keep the auditability thread running through the card framing too. A card payment is exactly the kind of transaction that gets disputed weeks later through a chargeback. The only way to answer a dispute is an immutable record of how the payment moved from authorization to settlement, tied to the network's own identifiers. That is why the transaction record and the append-only ledger are not optional polish here; they are what make a chargeback answerable and a settlement break traceable. Say that the card-network flavor raises the stakes on the audit trail rather than replacing the design, and you have connected the general system to the Mastercard reality without losing the reusable core.
The runnable core: an idempotent double-entry ledger
The interviewer often compresses the whole design into one implementable class, and for a payment system the natural one is the ledger itself. It is an idempotent, double-entry poster that moves money between accounts, refuses to overdraw a real account, and makes a retried posting land exactly once. It is the same skill as the full design in miniature, because the entire correctness posture lives in that one class. The double-entry balance, the non-negative invariant, and the idempotency cache are exactly what keep the system honest, and they are exactly where a candidate either states a clean invariant or hand-waves.
The problem below strips the network away and keeps the money math, because that is what runs. Amounts are integer minor units so nothing rounds. One designated clearing account may go negative because it represents value entering and leaving from outside. Every posting carries an idempotency key so a resend replays the first outcome. Handle the overdraw decline and the retried-key replay cleanly. They carry this problem the way the duplicate case carries a min-stack.
Idempotent double-entry ledger: post, decline, replay
Problem
-
PaymentLedger() creates an empty ledger. Every account starts at balance 0 and is created on first reference.-
post(key, debit, credit, amount) moves amount (an integer number of minor units, such as cents) out of the debit account and into the credit account as a balanced double-entry, governed by an idempotency key:- If
key has been seen before, return the same status string recorded for it and change no balances. This is exactly-once: a retry replays the first outcome.- Otherwise, if
amount <= 0, record and return "INVALID".- Otherwise, apply the non-negative invariant: if
debit is any account other than the special "CLEARING" account and its balance is less than amount, record and return "DECLINED" and change no balances. The "CLEARING" account is the external settlement account and is allowed to go negative.- Otherwise, subtract
amount from debit, add amount to credit, record and return "APPLIED".-
balance(account) returns the current integer balance of account (0 if never referenced).The overdraw decline and the retried-key replay carry the problem: a real account must never go negative, and a repeated key must return its first outcome without moving money a second time.
Input encoding. A test case is a JSON array of operations. The first entry is
["PaymentLedger", []]; each later entry is [method, [args]]. The expected array holds each operation's return, with null for the constructor.Input: PaymentLedger(); post("k1","CLEARING","alice",1000); balance("alice"); post("k1","CLEARING","alice",1000); balance("alice")
Output: the second post returns "APPLIED"; alice stays 1000
Funding alice from the clearing account applies once. The second post reuses key "k1", so it replays the stored "APPLIED" and does not credit alice again; her balance is still 1000, not 2000.
Input: PaymentLedger(); post("t","alice","bob",500); balance("bob")
Output: post returns "DECLINED"; bob is 0
alice has a balance of 0 and is not the CLEARING account, so debiting 500 would overdraw her. The posting is declined, no balance changes, and bob stays at 0.
Hold two maps: account to integer balance, and idempotency key to recorded status. post is a key lookup first, then a validate-and-apply with three outcomes. balance is a map lookup defaulting to 0.
Idempotent double-entry ledger: post, decline, replay
Click "Run" to execute your code
Test Cases
[["PaymentLedger",[]],["post",["k1","CLEARING","alice",1000]],["balance",["alice"]],["post",["k1","CLEARING","alice",1000]],["balance",["alice"]],["balance",["CLEARING"]]]Expected: [null,"APPLIED",1000,"APPLIED",1000,-1000][["PaymentLedger",[]],["post",["f","CLEARING","alice",1000]],["post",["t","alice","bob",300]],["balance",["alice"]],["balance",["bob"]]]Expected: [null,"APPLIED","APPLIED",700,300][["PaymentLedger",[]],["post",["t","alice","bob",500]],["balance",["bob"]]]Expected: [null,"DECLINED",0][["PaymentLedger",[]],["post",["t","alice","bob",500]],["post",["t","alice","bob",500]],["balance",["alice"]]]Expected: [null,"DECLINED","DECLINED",0][["PaymentLedger",[]],["post",["z","CLEARING","alice",0]],["balance",["alice"]]]Expected: [null,"INVALID",0][["PaymentLedger",[]],["post",["f","CLEARING","alice",700]],["post",["t","alice","bob",700]],["balance",["alice"]],["balance",["bob"]]]Expected: [null,"APPLIED","APPLIED",0,700]What the round rewards
The bar on this design is correctness of money first, then a clean path to scale, and never the reverse. Whichever way the prompt is phrased, a distributed payment system, transaction handling, or the Mastercard network itself, the moves are the same. Build the general machine before the branding: a double-entry ledger as the source of truth, integer minor units so nothing rounds, and one component that owns every balance write. Make processing exactly-once with idempotency keys that cache the decision, and say plainly that this is how a retried request stays a single charge. Split authorize from capture so the decision is fast and settlement moves in its own time, and keep an immutable transaction record and an append-only ledger so the system is auditable and reconcilable. Put a hook where fraud, currency, and disputes will attach rather than solving them all now. Then, and only then, layer the card network on top, mapping the issuer and acquirer onto the edge and pointing at idempotency as the piece that keeps a retried auth from charging twice. Show that order of priorities and you read as someone who could own a piece of a real payments platform, not someone who memorized the network diagram.
Key Takeaways
- ✓Design the general payment system before the card network. The reusable core is a double-entry ledger as the source of truth, integer minor units, a single component that owns every balance write, and a clean authorize-then-capture split. The Mastercard shape is a flavor you add on top of that foundation.
- ✓Money forces the invariants. Every posting balances to zero, real accounts never go negative while one designated clearing account may, and amounts are integer minor units rather than floats. State these before you draw a box, because they are what the round is really grading.
- ✓Exactly-once is something you engineer. Every mutating request carries an idempotency key, the system caches the decision and not just the success, and a repeat replays the first outcome without moving money again. Dedupe before every side effect so the whole charge is exactly-once, along with the ledger write.
- ✓Split authorization from settlement and keep the trail immutable. Authorization is a real-time hold; clearing and settlement move the money later off a queue; reconciliation matches the append-only ledger against what the network cleared. The transaction record and the ledger are what make a chargeback answerable.
- ✓The runnable core is an idempotent double-entry ledger, where the overdraw decline and the retried-key replay are what you're graded on. Build it cleanly and the same reasoning carries to the card-network and transaction-state prompts a Mastercard round favors.
Mastercard interview guideKnow what Mastercard asks before you walk in.
10 coding · 2 system design · 15 behavioral questions
Get their questions first.
InterviewLegend is independent and not affiliated with or endorsed by any company it covers.