Design a merchant lending system

System design Medium Stripe
Reference
Start your answer

Remix this answer and make it yours, or start blank. Either way you work beside the reference, one chapter at a time, and it stays private until you publish it.

Problem statement

Offer business loans to merchants based on their payment history, then repay automatically by withholding a share of each day's sales.

Requirements

We are building merchant lending for a payments platform: pre-approve merchants for a fixed-fee business loan from their own processing history, pay the money into their balance, and collect it by withholding a fixed share of each day's sales before payout, all recorded in a double-entry ledger.

Clarifying questions worth asking first: Is this a flat-fee advance (fixed total owed) or interest-bearing? (Assume flat fee: borrow $10,000, owe $11,000.) Is withholding per charge or per daily settlement? (Per daily settlement batch.) Do refunds and disputes reduce what we withhold? (Withhold only on positive net sales; never push the merchant negative.) Does the platform hold the credit risk or a bank partner? (A bank partner originates; we service. Out of scope: underwriting model training, legal disclosures, collections agencies.)

Functional requirements

  • Nightly eligibility: score merchants on processing volume, tenure, growth and dispute rate; publish offers (amount, fee, withhold rate) that expire in 30 days.
  • Merchant accepts an offer (after KYC/terms); funds land in their platform balance the next business day.
  • Each day's settlement withholds withhold_rate × net_sales (capped at the remaining balance) before the payout, and posts it against the loan.
  • Merchant sees remaining balance, every repayment, and can prepay in full at any time (no discount on the fee).
  • Minimum progress: at least 1/9 of the total owed every 60 days, else the loan goes delinquent and collections start (ACH debit, higher withhold rate).
  • Daily reconciliation against the payout system, bank partner, and money movement; finance can explain every cent.

Non-functional requirements

  • Correctness over everything: the ledger balances to the cent; no double withholding, no double disbursement, even across retries and timeouts.
  • Balance reads are strongly consistent: a merchant who just prepaid must not see the old amount, and the payout job must read exactly what was posted.
  • Withholding must not delay payouts: the whole daily run finishes in under 2 hours.
  • Full audit trail: entries are append-only and never updated; corrections are new entries.
  • 99.95% availability for reads; offers and repayments can tolerate minutes of delay, money correctness cannot.

Back-of-the-envelope estimation

  • 5M merchants scored nightly; ~10% eligible → 500k live offers; ~100k active loans; average $20k, total book ≈ $2B.
  • Repayment postings: 100k loans × 1 settlement/day = 100k transactions/day, each 2–4 ledger lines → ~400k rows/day ≈ 150M rows/year. Trivial for one Postgres primary.
  • Card-level merchant-balance postings (the wider ledger the loan rides on): 50M charges/day ≈ 600 TPS average, 3k TPS peak; ~4 lines each → 200M rows/day. This is what forces partitioning, not the loans.
  • Daily payout window: 100k withholding calculations in < 2 h is < 15/s; the bottleneck is per-merchant locking, not throughput.
API design

All writes take an Idempotency-Key; replays return the original result.

GET  /v1/capital/offers?merchant_id=m_1
  → [{offer_id, amount: 1000000, fee: 100000, withhold_rate_bps: 1200, expires_at}]

POST /v1/capital/offers/{offer_id}/accept      Idempotency-Key: k1
  → 202 {loan_id, status: "pending_disbursal"}

GET  /v1/capital/loans/{loan_id}
  → {status, total_owed: 1100000, paid: 342150, remaining: 757850,
     withhold_rate_bps, next_minimum_due: {amount, by_date}, as_of_txn_id}

POST /v1/capital/loans/{loan_id}/prepayments   Idempotency-Key: k2
  {amount: 757850, source: "balance"} → 201 {txn_id, remaining: 0}

Internal ledger API (used by lending, payouts, disputes):

PostTransaction(idempotency_key, kind, effective_at, lines: [{account_id, amount, direction}])
  → {txn_id, status: "posted"}            # rejected if debits != credits
GetBalance(account_id, consistency: "strong" | "as_of", as_of_txn_id?)
  → {balance, last_txn_id}
GetTransaction(idempotency_key) → {txn_id, status} | NOT_FOUND

Recovery semantics: a caller that times out retries PostTransaction with the same key. The ledger inserts the idempotency row in the same DB transaction as the lines, so a retry either finds the committed result or commits fresh; there is no "half posted". If the key exists with a different payload hash, return 409.

Data model

PostgreSQL, one primary with synchronous replica: money needs serializable-enough transactions, foreign keys and unique constraints more than horizontal scale at 100k loans.

Table Key columns Notes
accounts id, owner_id, type (merchant_balance, loan_receivable, fee_receivable, platform_cash, bank_partner_payable), currency, normal_side One loan creates its own receivable accounts
ledger_transactions id, idempotency_key UNIQUE, kind, payload_hash, effective_at, created_at Header row per business event
ledger_entries id, txn_id, account_id, amount (bigint minor units, > 0), direction (debit/credit) Append-only; index (account_id, id); partition by month
account_balances account_id PK, balance, last_entry_id, version Materialized; updated in the same transaction as entries
offers id, merchant_id, amount, fee, withhold_rate_bps, model_version, features JSON, expires_at, status Index (merchant_id, status)
loans id, offer_id UNIQUE, merchant_id, total_owed, withhold_rate_bps, status, funded_at, last_minimum_check Index (status), (merchant_id); offer_id unique stops double acceptance
withholding_runs (loan_id, settlement_date) PK, net_sales, withheld, txn_id One row per loan per day: the natural idempotency key

Product events become ledger rows like this (amounts in cents):

Event Debit Credit
Disbursal of $10,000 + $1,000 fee loan_receivable 1,000,000; fee_receivable 100,000 merchant_balance 1,000,000; deferred_fee_revenue 100,000
Daily withholding $120 merchant_balance 12,000 loan_receivable 10,909; fee_receivable 1,091 (pro rata)
Fee recognized with repayment deferred_fee_revenue 1,091 fee_revenue 1,091

remaining = loan_receivable + fee_receivable, so the loan's balance is never a separate number that can drift.

One posting in SQL:

BEGIN;
INSERT INTO ledger_transactions (id, idempotency_key, kind, payload_hash)
  VALUES ($1, 'wh:loan_9:2026-10-11', 'withholding', $h);   -- unique violation => replay
SELECT balance FROM account_balances
  WHERE account_id IN ($merchant, $loan_rcv, $fee_rcv)
  ORDER BY account_id FOR UPDATE;                            -- fixed lock order
-- check: merchant balance >= amount, receivables >= amounts
INSERT INTO ledger_entries (txn_id, account_id, amount, direction) VALUES (...), (...), (...);
UPDATE account_balances SET balance = balance + delta, last_entry_id = $e, version = version + 1
  WHERE account_id = ...;                                    -- one per account
COMMIT;

A deferred constraint trigger asserts sum(debits) = sum(credits) per txn_id at commit.

High-level design
flowchart LR
  PAY[Payment processing] --> SET[Settlement batcher]
  FEAT[Feature pipeline] --> ELIG[Eligibility scorer]
  ELIG --> OFF[(Offers)]
  M[Merchant dashboard] --> API[Capital API]
  API --> OFF
  API --> LS[Loan service]
  LS --> LED[Ledger service]
  SET --> WH[Withholding job]
  WH --> LS
  WH --> PO[Payout service]
  LED --> PG[(Postgres ledger)]
  PO --> BANK[Bank rails]
  LS --> BP["Bank partner API"]
  REC[Reconciler] --> PG
  REC --> BANK
  REC --> BP
  1. Offer. Nightly, the feature pipeline aggregates 12 months of charges, refunds and disputes per merchant into the warehouse. The scorer outputs a predicted 12-month volume and default probability; offer amount ≈ 10–15% of annual volume, withhold rate chosen so expected payback is ~9 months. Offers are upserted with the model version and the exact features used.
  2. Accept and disburse. Accept inserts a loans row (unique on offer_id) in pending_disbursal, calls the bank partner to originate, then posts the disbursal transaction with key disburse:{loan_id}. Funds now sit in the merchant's balance and go out with the next payout.
  3. Post a repayment. The settlement batcher closes the day per merchant. Before payout, the withholding job computes min(rate × max(net_sales, 0), remaining, available_balance), inserts withholding_runs, and posts with key wh:{loan_id}:{date}. Only then does the payout service read the merchant balance and send the rest.
  4. Read a balance. Dashboard and payouts read account_balances on the primary (or a replica with as_of_txn_id that waits until it has replayed that far). When remaining hits 0, the loan moves to paid and withholding stops.
  5. Recover after a timeout. Any worker that dies mid-call retries with the same key; the unique key returns the committed transaction. A sweeper finds loans stuck in pending_disbursal > 1 h and asks the bank partner for status by our reference before retrying.
  6. Reconcile. Each morning the reconciler checks: entries balance per transaction; account_balances equals the sum of entries per account; total withheld equals what payouts held back; total disbursed equals bank partner funding reports.
Deep dives

Balanced posting under concurrency. The same merchant balance is touched by charges, refunds, disputes, payouts and withholding at once. Every posting locks its account_balances rows with SELECT … FOR UPDATE in ascending account_id order, so two transactions can't deadlock and can't both spend the same dollars. Overdraft checks run under the lock. READ COMMITTED plus explicit row locks is enough; SERIALIZABLE would add retry storms at 3k TPS. Withholding must be ordered against payout: the payout service takes the same lock and only pays balance after the withholding run for that date is recorded (it checks withholding_runs for every active loan of the merchant, else waits).

Strongly consistent balance reads.

Option Freshness Cost Use for
Sum ledger_entries on read Exact O(entries); slow after years Audits, investigations
Materialized account_balances on primary Exact 1 row read; adds load to primary Payouts, prepay quotes
Replica + as_of_txn_id wait Exact for that caller Wait up to replica lag (~50 ms) Dashboard after a write
Cache / replica without token Stale Cheapest Analytics only

The write returns last_txn_id; the client passes it on the next read, so a merchant who just prepaid never sees the old balance (read-your-writes) without routing every dashboard read to the primary.

Withholding and minimum-payment rules. Withholding happens on net settled sales, not gross, so a refund-heavy day withholds less; a negative day withholds 0 and never claws back past the balance. Partial rounding goes to the merchant (floor to the cent), and the last payment is capped so remaining lands exactly on 0. A nightly job checks each loan's 60-day window: if paid in the window < total_owed / 9, set delinquent, notify, and after a grace period raise the withhold rate or schedule an ACH debit (posted through the same ledger with key ach:{loan_id}:{n}; ACH returns post a reversal transaction 2–5 days later). Merchants that stop processing entirely stop repaying; that is the credit risk the pricing covers.

Reconciliation drift and operator recovery. Three checks, each with a different fix:

  • Internal: sum(entries) ≠ account_balances.balance for an account means a bug in the materialization path. Recompute from entries (the journal is the source of truth), page on-call, and freeze withholding for that merchant until fixed.
  • Ledger vs payouts: total withheld for a date must equal gross settlement minus amount paid out. A mismatch usually means a payout raced a withholding run; the fix is a correcting transaction, never an edit.
  • Ledger vs bank partner: daily funding and repayment files are matched on our reference IDs; unmatched items older than 2 days go to an operator queue with the transaction history attached.
    Operators fix with a kind = 'adjustment' transaction that requires two-person approval and a reason code, so the audit trail stays complete.

Hot merchant rows and when to shard. A large merchant can do 200+ charges/s, all locking one balance row. Options: batch their charge postings into one ledger transaction every 100 ms; split the balance into N sub-accounts (merchant_balance:shard_k) and sum them on read, with payouts locking all N; or move card postings to a dedicated high-throughput ledger and only post daily net into this one. Loans themselves (100k/day) never need sharding. When card-level volume outgrows one primary (~10k TPS), shard by merchant_id so every transaction stays single-shard; the platform-wide accounts (cash, fee revenue) become per-shard accounts rolled up in reconciliation.

Trade-offs
  • Flat-fee, sales-based repayment ties payback to the merchant's cash flow and is simple to explain, but repayment time is unpredictable and slow merchants hold capital longer for the same fee.
  • Withholding at daily settlement instead of per charge cuts postings 1000× and makes the step idempotent per day, but the merchant's balance briefly includes money owed to us and payouts must wait for the run.
  • One Postgres ledger with row locks gives real ACID and simple reconciliation, but caps throughput per hot account and per primary; sharding later costs cross-shard platform accounts.
  • Materialized balances make reads O(1) but create a second copy that can drift; the nightly recompute from entries is the price of that speed.
  • Never-update entries make audits trivial and fixes reversible, but every correction adds rows and support has to read a chain of adjustments.
  • Capping withholding at available balance avoids pushing merchants negative, at the cost of slower recovery after refund-heavy periods.
Interviewer expectations

Mid-level (L4)

  • Clear flow: offer, accept, disburse, daily withholding, payoff.
  • Stores amounts as integer cents and uses idempotency keys on accept and on each day's withholding.
  • A loans table plus repayments table with sensible indexes.

Senior (L5)

  • Double-entry ledger with the actual debit/credit lines for disbursal and repayment; balances derived from entries.
  • Explains lock ordering, overdraft checks under lock, and why withholding must commit before payout.
  • Handles refunds, negative days, final-payment capping, timeouts with retried keys, and daily reconciliation.

Staff (L6+)

  • Separates the loan product from a general ledger the whole payments platform shares, and sizes the ledger by card volume, not loan count.
  • Has a plan for hot merchant rows and a sharding boundary that keeps transactions single-shard.
  • Covers the business edges: minimum-payment delinquency, ACH returns, bank partner reconciliation, model versioning on offers, and two-person operator adjustments.