Skip to the solution
HardSystem Design20 min read

How would you design a payment system?

0 views

The problem

Question presented to candidate: "Design the payment system for a marketplace like Etsy or Airbnb: buyers pay by card, the platform keeps a commission, and sellers are paid out to their bank accounts. We process about 10 million payments a day. Walk me through the components, how money is recorded, and how you make sure nothing is ever lost or paid twice."

What a strong answer should cover:

  • The two money flows: pay-in (buyer card to platform through a payment service provider, PSP) and payout (platform to seller bank), and why the platform never touches raw card numbers (tokenization, hosted fields, smaller PCI DSS scope)
  • A double-entry ledger as the source of truth: append-only entries that sum to zero, amounts in integer minor units, balances derived from entries
  • The payment lifecycle: authorize, capture, settle, refund, chargeback, with each state change written durably and driven by PSP webhooks
  • Correctness under concurrency and retries: idempotency keys end to end, conditional or versioned balance updates so payouts cannot overdraw
  • Reconciliation of the ledger against PSP settlement reports, and what to do with mismatches
  • Scale numbers: payment TPS is modest; the hard parts are correctness, auditability and integration with slow external systems

Clarifying questions expected:

  • Are we the merchant only, or a marketplace holding money for sellers (which changes the ledger and the regulation)?
  • Which payment methods, currencies and countries? Do we need multi-currency payouts?
  • Can we use a PSP such as Stripe or Adyen, or are we building card network connectivity ourselves?

Code / implementation expected: Optional โ€” the example runs a double-entry ledger for a marketplace order: an idempotent charge, a refund, a payout race and a reconciliation.

  • system-design
  • case-study

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

20 min read ยท 16 sections

Read the code

Marketplace payment on a double-entry ledger: integer cents, an idempotent charge, a refund, a payout race and daily reconciliation
// A marketplace payment flow on a double-entry ledger, in integer minor units (cents).
// Debits are positive, credits negative; every transaction must sum to zero.
const fmt = (c) => (c < 0 ? "-" : "") + Math.floor(Math.abs(c) / 100) + "." + String(Math.abs(c) % 100).padStart(2, "0");
const yn = (b) => (b ? "yes" : "no");
const pspFee = (amount) => Math.floor((amount * 29 + 500) / 1000) + 30; // 2.9% + 30c, rounded half up

console.log("== 1. Money is integers ==");
let f = 0;
for (let i = 0; i < 10; i++) f += 0.1;
let cents = 0;
for (let i = 0; i < 10; i++) cents += 10;
console.log("ten x 0.10 as float = " + f + ", equals 1? " + yn(f === 1));
console.log("ten x 10 cents      = " + fmt(cents) + ", equals 1.00? " + yn(cents === 100));
function allocate(total, parts) { // largest remainder: shares always add up to total
  const base = Math.floor(total / parts);
  const out = [];
  for (let i = 0; i < parts; i++) out.push(base + (i < total - base * parts ? 1 : 0));
  return out;
}
const split = allocate(10000, 3);
console.log("split 100.00 three ways: " + split.map(fmt).join(" + ") + " = " + fmt(split.reduce((a, b) => a + b, 0)));

class Ledger {
  constructor() { this.entries = []; this.bal = new Map(); this.ver = new Map(); this.seen = new Set(); }
  balance(acct) { return this.bal.get(acct) || 0; }
  version(acct) { return this.ver.get(acct) || 0; }
  post(txId, legs, expect) { // expect: optional {account, version, minCredit}
    if (this.seen.has(txId)) return "duplicate " + txId + " ignored";
    if (legs.reduce((s, l) => s + l[1], 0) !== 0) return "rejected " + txId + ": legs do not sum to zero";
    if (expect) {
      if (this.version(expect.account) !== expect.version) return "rejected " + txId + ": version conflict on " + expect.account;
      const after = this.balance(expect.account) + legs.filter((l) => l[0] === expect.account).reduce((s, l) => s + l[1], 0);
      if (after > 0) return "rejected " + txId + ": " + expect.account + " would go negative";
    }
    this.seen.add(txId);
    for (const [acct, amt] of legs) {
      this.entries.push([txId, acct, amt]);
      this.bal.set(acct, this.balance(acct) + amt);
      this.ver.set(acct, this.version(acct) + 1);
    }
    return "posted " + txId + " (" + legs.length + " entries)";
  }
}

console.log("\n== 2. Order ORD-1: buyer pays 100.00, platform keeps 10% ==");
const L = new Ledger();
const fee1 = pspFee(10000);
console.log("processor fee on 100.00 = " + fmt(fee1));
console.log(L.post("ORD-1:charge", [["psp_receivable", 10000], ["seller_payable:S1", -9000], ["revenue:commission", -1000]]));
console.log(L.post("ORD-1:charge", [["psp_receivable", 10000], ["seller_payable:S1", -9000], ["revenue:commission", -1000]]));
console.log(L.post("ORD-1:fee", [["expense:psp_fees", fee1], ["psp_receivable", -fee1]]));
console.log(L.post("ORD-1:settle", [["cash:bank", 10000 - fee1], ["psp_receivable", -(10000 - fee1)]]));
console.log(L.post("ORD-1:bad", [["cash:bank", 500], ["revenue:commission", -400]]));

console.log("\n== 3. Partial refund of 30.00 ==");
console.log(L.post("ORD-1:refund", [["seller_payable:S1", 2700], ["revenue:commission", 300], ["cash:bank", -3000]]));
console.log("seller S1 is owed " + fmt(-L.balance("seller_payable:S1")));

console.log("\n== 4. Two payout requests race for the same balance ==");
const v = L.version("seller_payable:S1");
const owed = -L.balance("seller_payable:S1");
const payout = (id, ver) => L.post(id, [["seller_payable:S1", owed], ["cash:bank", -owed]], { account: "seller_payable:S1", version: ver });
console.log("both read balance " + fmt(owed) + " at version " + v);
console.log("worker 1: " + payout("PAYOUT-1", v));
console.log("worker 2: " + payout("PAYOUT-2", v));
console.log("worker 2 re-reads version " + L.version("seller_payable:S1") + ": " + payout("PAYOUT-2", L.version("seller_payable:S1")));

console.log("\n== 5. Trial balance ==");
let total = 0;
for (const acct of [...L.bal.keys()].sort()) {
  total += L.balance(acct);
  console.log("  " + acct.padEnd(20) + fmt(L.balance(acct)).padStart(9));
}
console.log("  " + "sum".padEnd(20) + fmt(total).padStart(9) + "   entries: " + L.entries.length);

console.log("\n== 6. Daily reconciliation against the processor report ==");
L.post("ORD-3:charge", [["psp_receivable", 4000], ["seller_payable:S2", -3600], ["revenue:commission", -400]]);
const report = [["ORD-1", 10000, pspFee(10000)], ["ORD-2", 2500, pspFee(2500)]];
const ours = new Map();
for (const [tx, acct, amt] of L.entries) if (tx.endsWith(":charge") && acct === "psp_receivable") ours.set(tx.split(":")[0], amt);
const theirs = new Map(report.map((r) => [r[0], r[1]]));
for (const id of [...new Set([...ours.keys(), ...theirs.keys()])].sort()) {
  const a = ours.get(id), b = theirs.get(id);
  const verdict = a === undefined ? "MISSING IN LEDGER (lost webhook?)" : b === undefined ? "MISSING AT PROCESSOR" : a === b ? "matched" : "AMOUNT MISMATCH";
  console.log("  " + id + "  ledger " + (a === undefined ? "-" : fmt(a)).padStart(7) + "  report " + (b === undefined ? "-" : fmt(b)).padStart(7) + "  " + verdict);
}
console.log("processor fee on 25.00 = " + fmt(pspFee(2500)));

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 105 of 106 in System design

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

Up nextHard

How would you design YouTube?

Continue