Back to System Design Problems
Q7Question 7 of 13

Design a Payment System

Key Concepts
  • Idempotency keys make retries safe: the server stores results by key so a replay returns the same outcome.
  • Double-spend prevention uses atomic conditional updates or optimistic locking so two requests cannot both spend the last rupee.
  • Two-phase authorize-then-capture reserves funds first, then settles later, matching card network flows.
  • Refunds are new ledger entries, not edits to old ones, preserving the append-only audit trail.
  • The ledger is append-only double-entry: every transaction writes a debit and matching credit so the sum is always zero.
  • PCI DSS compliance means never storing raw card numbers; tokenize through a compliant vault.
  • A reconciliation job detects mismatches between the gateway and the local ledger and repairs them.

Definition

Make payments idempotent, prevent double-spend with atomic checks, authorize before capture, and record every movement in an append-only double-entry ledger.
  • Idempotency key : a client-generated unique id per payment so retries return the same result instead of double-charging.
  • Double-spend prevention : atomic conditional update or optimistic locking ensuring the same balance is not committed twice.
  • Two-phase (authorize-capture) : authorize reserves funds; capture settles them. Uncaptured auths can be voided.
  • Double-entry ledger : every transaction writes a debit and a matching credit; the signed sum is always zero.
  • PCI DSS : the system never stores raw card numbers; it tokenizes through a compliant vault.

When Rajesh pays Priya for rent through National Bank of India, the bank first checks and reserves the money (authorize), then actually moves it (capture). If Rajesh's phone retries because the network blinked, the bank recognizes the same payment and does not charge twice. Every rupee movement is written in a permanent ledger with two entries : money out and money in.

Understanding the Concepts

How does idempotency work?

The client generates a unique key per payment. The server stores results by key, so replays return the same outcome. If Rajesh's phone retries because the network timed out, the server finds the previous result for that key and returns it without charging again.

JAVA
package com.bank.payment;

public class IdempotentPaymentService {
    public PaymentResult pay(String idemKey, PaymentRequest req) {
        PaymentResult prior = idempotencyStore.get(idemKey);
        if (prior != null) return prior;  // safe retry
        PaymentResult result = execute(req);
        idempotencyStore.put(idemKey, result);
        return result;
    }
}

How do you prevent double-spend?

Atomic conditional update or optimistic locking ensures the balance check and debit happen as one step. If two concurrent requests try to spend the last 500 rupees, only one succeeds because the version check fails for the second.

JAVA
package com.bank.payment;

import java.math.BigDecimal;

public class DoubleSpendPrevention {
    public boolean debit(String account, BigDecimal amount, long expectedVersion) {
        int rows = jdbc.update(
            "UPDATE accounts SET balance = balance - ?, version = version + 1 " +
            "WHERE id = ? AND version = ? AND balance >= ?",
            amount, account, expectedVersion, amount);
        return rows == 1;  // 0 = concurrent update, retry
    }
}

How does authorize-then-capture work?

Authorize reserves funds by reducing available balance without actually moving money. Capture settles the reserved amount. This matches card network flows and allows clean void of uncaptured auths. At National Bank of India, when Rajesh books a hotel, the bank authorizes the amount immediately but captures only at checkout.

JAVA
package com.bank.payment;

import java.math.BigDecimal;

public class TwoPhasePayment {
    public String authorize(String account, BigDecimal amount) {
        accounts.reserve(account, amount);  // available -= amount, held += amount
        return ledger.record("AUTH", account, amount);
    }
    public void capture(String authId) {
        var r = ledger.find(authId);
        accounts.settle(r.account(), r.amount());  // held -> debited
        ledger.record("CAPTURE", r.account(), r.amount());
    }
    public void voidAuth(String authId) {
        var r = ledger.find(authId);
        accounts.release(r.account(), r.amount()); // held -> available again
        ledger.record("VOID", r.account(), r.amount());
    }
}

What is a double-entry ledger?

Every transaction writes a debit entry and a matching credit entry, so the signed sum is always zero. Refunds are new entries, not edits. This makes the ledger an immutable audit trail. Auditors at National Bank of India can trace every rupee movement without worrying about tampered records.

JAVA
package com.bank.payment;

import java.math.BigDecimal;
import java.time.Instant;

public class LedgerService {
    public void recordTransfer(String fromAccount, String toAccount, BigDecimal amount) {
        String txnId = generateTxnId();
        ledgerStore.append(new LedgerEntry(txnId, fromAccount, amount.negate(), Instant.now()));
        ledgerStore.append(new LedgerEntry(txnId, toAccount, amount, Instant.now()));
        // sum of all entries for txnId = 0
    }
}

What is PCI DSS compliance?

The system never stores raw card numbers. When Rajesh enters his card, the number goes to a PCI-compliant vault that returns a token. The payment service uses only the token for authorization and capture. A breach of the payment database exposes tokens, not real card numbers.

JAVA
package com.bank.payment;

public class CardTokenizer {
    public String tokenize(String rawCardNumber) {
        return pciVault.store(rawCardNumber);  // returns "tok_abc123"
    }
    public String detokenize(String token) {
        return pciVault.retrieve(token);  // only called inside PCI boundary
    }
}

How do you handle partial failures?

If the gateway succeeds but the local write fails, the bank has charged the customer without recording it. A reconciliation job compares gateway settlement files against the local ledger every night and creates correcting entries for any mismatches.

JAVA
package com.bank.payment;

import java.util.List;

public class ReconciliationJob {
    public void reconcile() {
        List<GatewayRecord> gatewayRecords = gateway.fetchSettlement();
        for (GatewayRecord gr : gatewayRecords) {
            if (!ledger.exists(gr.transactionId())) {
                ledger.createCorrectingEntry(gr);  // repair the mismatch
                alertOps(gr);  // notify operations team
            }
        }
    }
}

How do you handle refunds?

Refunds are new ledger entries crediting the payer and debiting the merchant, never edits to existing entries. This preserves the append-only audit trail. The original payment and the refund are both visible in the ledger, linked by a reference id.

What should a senior engineer know?

A senior engineer designs the reconciliation job as a first-class component, not an afterthought, and knows that the ledger, not the balance column, is the source of truth. They also plan for multi-currency support using a currency field on every ledger entry and exchange-rate snapshots at transaction time.

Visual Overview

DIAGRAM
  Payment System Architecture

  ┌──────────┐  pay request  ┌──────────────────┐
  │  Client  │──────────────►│  Payment Service │
  │  (idem   │               │                  │
  │   key)   │               │  1. Check idem   │
  └──────────┘               │  2. Authorize    │
                             │  3. Capture      │
                             │  4. Ledger entry │
                             └────────┬─────────┘
                                      │
                    ┌─────────────────┼─────────────────┐
                    │                 │                 │
             ┌──────▼──────┐  ┌──────▼──────┐  ┌──────▼──────┐
             │  Accounts   │  │  Ledger     │  │  Gateway    │
             │  (balance,  │  │  (append-   │  │  (card      │
             │   version)  │  │   only,     │  │   network)  │
             └─────────────┘  │   double-   │  └─────────────┘
                              │   entry)    │
                              └─────────────┘

  Ledger entries for a transfer:
  ┌──────────┬───────────┬──────────┐
  │ Entry    │ Account   │ Amount   │
  ├──────────┼───────────┼──────────┤
  │ DEBIT    │ SAV2001   │ -50000   │
  │ CREDIT   │ CUR3001   │ +50000   │
  │ Sum      │           │ 0        │
  └──────────┴───────────┴──────────┘

Key Takeaway

A payment system moves money with correctness, durability, and auditability as its highest priorities. Idempotency keys make retries safe. Double-spend prevention uses atomic conditional updates. Two-phase authorize-then-capture matches card network flows. The append-only double-entry ledger ensures every debit has a matching credit and the books always balance.

PCI compliance means never storing raw card numbers. A reconciliation job repairs mismatches between the gateway and local ledger. At National Bank of India, this combination of mechanisms made the payment system trustworthy enough to move real money.

Remember one sentence: make payments idempotent, prevent double-spend atomically, use authorize-capture for funds reservation, and record everything in an append-only double-entry ledger.

---

Share
Use ← → arrow keys to navigate