← System Design Simulator

Payment System System Design Interview Question

By Rahul Kumar · Senior Software Engineer · Updated · 10 components · 4 operations ·Source: Alex Xu, System Design Interview Vol 2, Chapter 11

Problem: Design a payment system that charges customers via external PSPs (Stripe/Braintree), keeps an auditable ledger, supports refunds, and reconciles with the PSP daily.

Overview

A payment system sits on the most unforgiving slice of the stack: money. Unlike a social feed where a stale read is an annoyance, a duplicate charge becomes a chargeback, a support ticket, and sometimes a regulator complaint. The job sounds simple on paper — accept a charge, call Stripe or Braintree, record what happened — but every interesting failure mode lives in the gaps between those three steps. Networks time out after the PSP captured the card but before the client saw a response. Retries turn one intent into two captures. Background jobs crash halfway through a refund and leave the ledger out of sync with the settlement file. The design below accepts that the external world (PSPs, mobile networks, flaky customers) is hostile and pushes correctness into two primitives: client-supplied idempotency keys stored in Redis, and a double-entry ledger where every debit has a matching credit. Everything else — webhooks, reconciliation, refunds — composes on top of those two invariants.

Payment System — Interactive Simulator

Runs fully client-side in your browser; no sign-up. Or open full screen →

Launch the interactive walkthrough for Payment System — animated architecture diagram, step-by-step flow with real payloads, component swap, and a discrete-event stress simulator.

Summary

An idempotent payment API that sits in front of an external PSP (Stripe, Braintree) and a double-entry ledger. The dominant design choices are (1) client-supplied idempotency keys stored in Redis with SETNX so a retried charge never double-bills; (2) double-entry bookkeeping in a SQL ledger (DECIMAL, never float) so every debit has a credit and the books always balance; (3) orchestrator pattern that calls the PSP, records the ledger entry, and updates the wallet — with a compensating refund if any step fails. A nightly reconciliation job reads the PSP's settlement report and diffs it against our ledger so any drift is caught within 24h.

Requirements

Functional

Non-functional

Capacity Assumptions

Back-of-Envelope Estimates

High-level architecture

Requests enter at an L7 load balancer that terminates public TLS and re-originates mTLS to a stateless Payment API tier. The API validates the request, extracts the Idempotency-Key header, and asks the Idempotency Store (Redis) whether this key has been seen. Redis SETNX with a 24h TTL atomically claims the key; if the key already exists and the request payload hash matches, we return the cached response and skip everything downstream. A Payment Orchestrator then drives the three-step dance: (1) call the PSP to capture the card via tokenized payment method, (2) write the double-entry ledger rows in a single SQL transaction, (3) update the Wallet Service or merchant balance. If step 1 succeeds but step 2 fails, the orchestrator issues a compensating PSP refund — the system never leaves money uncounted. Webhooks from the PSP arrive at a separate endpoint with signature verification; each webhook carries an event_id used as its own idempotency key so Stripe's at-least-once delivery doesn't produce duplicate ledger rows. A nightly Reconciliation Job pulls the PSP's settlement file, joins it against the ledger on external_charge_id, and files any mismatch into a manual-review queue. Storage is a partitioned Postgres cluster — partitioned on charge_date — because the ledger is append-only and time-ordered access dominates. Notifications are fire-and-forget over Kafka so a slow SMS provider never backs up the charge path.

Architecture Components (10)

Operations Walked Through (4)

Implementation

PaymentController — idempotency-key handling
@RestController
@RequestMapping("/v1/charges")
public class PaymentController {
  private final IdempotencyStore idempotency;
  private final PaymentOrchestrator orchestrator;

  @PostMapping
  public ResponseEntity<ChargeResponse> charge(
      @RequestHeader("Idempotency-Key") String key,
      @Valid @RequestBody ChargeRequest req) {
    String fingerprint = Hashing.sha256(req.canonicalBytes());
    IdempotencyStore.Slot slot = idempotency.claim(key, fingerprint, Duration.ofHours(24));
    if (slot.isReplay()) {
      if (!slot.fingerprint().equals(fingerprint)) {
        return ResponseEntity.status(422).body(ChargeResponse.keyReuseConflict());
      }
      return ResponseEntity.ok(slot.cachedResponse(ChargeResponse.class));
    }
    try {
      ChargeResponse resp = orchestrator.charge(req);
      idempotency.commit(key, resp);
      return ResponseEntity.ok(resp);
    } catch (PspException e) {
      idempotency.commit(key, ChargeResponse.failed(e.code(), e.getMessage()));
      return ResponseEntity.status(402).body(ChargeResponse.failed(e.code(), e.getMessage()));
    }
  }
}
Ledger — double-entry posting
@Service
public class Ledger {
  private final JdbcTemplate jdbc;

  @Transactional
  public void post(LedgerTxn txn) {
    if (txn.entries().stream().mapToLong(LedgerEntry::amountMinor).sum() != 0) {
      throw new IllegalStateException("unbalanced txn: debits != credits");
    }
    for (LedgerEntry e : txn.entries()) {
      jdbc.update(
        "INSERT INTO ledger_entries (txn_id, account_id, amount_minor, currency, direction, external_ref, posted_at) " +
        "VALUES (?, ?, ?, ?, ?::entry_dir, ?, now())",
        txn.id(), e.accountId(), Math.abs(e.amountMinor()), e.currency(),
        e.amountMinor() < 0 ? "DEBIT" : "CREDIT", txn.externalRef());
    }
  }

  public static LedgerTxn captureCharge(String txnId, long amountMinor, String currency,
                                        String customerAccount, String merchantAccount, String chargeId) {
    return new LedgerTxn(txnId, chargeId, List.of(
      new LedgerEntry(customerAccount, -amountMinor, currency),
      new LedgerEntry(merchantAccount,  amountMinor, currency)));
  }
}
Stripe webhook handler
@PostMapping("/v1/webhooks/stripe")
public ResponseEntity<Void> stripe(@RequestHeader("Stripe-Signature") String sig,
                                   @RequestBody byte[] raw) {
  if (!StripeSigs.verify(raw, sig, webhookSecret, Duration.ofMinutes(5))) {
    return ResponseEntity.status(400).build();
  }
  StripeEvent evt = StripeEvent.parse(raw);
  // event_id is our idempotency key for webhook delivery
  if (!webhookLog.recordIfNew(evt.id(), evt.type())) {
    return ResponseEntity.ok().build(); // already processed
  }
  switch (evt.type()) {
    case "charge.succeeded" -> chargeService.markCaptured(evt.chargeId(), evt.amountMinor());
    case "charge.refunded"  -> refundService.applyRefund(evt.chargeId(), evt.refundId(), evt.amountMinor());
    case "charge.dispute.created" -> disputeService.open(evt.chargeId(), evt.disputeId());
    default -> {}
  }
  return ResponseEntity.ok().build();
}
Nightly reconciliation job
@Scheduled(cron = "0 15 2 * * *", zone = "UTC")
public void reconcile() {
  LocalDate day = LocalDate.now(ZoneOffset.UTC).minusDays(1);
  try (var psp = pspClient.settlementReport(day);
       var ours = ledgerRepo.streamCapturedOn(day)) {
    Map<String, Money> pspByRef = psp.stream()
        .collect(Collectors.toMap(r -> r.externalChargeId, r -> r.amount));
    List<ReconDiff> diffs = new ArrayList<>();
    ours.forEach(row -> {
      Money pspAmt = pspByRef.remove(row.externalChargeId());
      if (pspAmt == null) diffs.add(ReconDiff.missingAtPsp(row));
      else if (!pspAmt.equals(row.amount())) diffs.add(ReconDiff.amountMismatch(row, pspAmt));
    });
    pspByRef.forEach((ref, amt) -> diffs.add(ReconDiff.missingInLedger(ref, amt)));
    if (!diffs.isEmpty()) reconQueue.fileForReview(day, diffs);
    metrics.gauge("recon.diffs", diffs.size());
  }
}

Key design decisions & trade-offs

Interview follow-ups

Related