Payment Gateway

Payment Processing System

Design the core of a payment gateway: authorize, capture, void and refund through a processor per payment method, a strict payment state machine, idempotency keys that make retries safe, and an append-only audit trail.

Overview

A payment gateway sits between a merchant's code and the networks that move money. The merchant asks it to authorize an amount (check the card and hold the money), later to capture it (actually take the money), to void an authorization it no longer wants, or to refund some or all of a captured payment. Each payment method (card, wallet, bank transfer) talks to a different processor, but the merchant sees one interface.

Interviewers use this problem to test correctness under retries. Networks time out, and a merchant that did not see the reply will send the request again. The gateway must never charge twice for one intent, never refund more than it captured, and never capture a payment that was voided. A strong answer puts the rules in a state machine, makes every write carry an idempotency key, and keeps money in integer minor units (cents).

The design has three parts that change at different speeds: the payment lifecycle (almost never), the processors (a new one every few months), and the retry rules (once, carefully). Keeping them in separate classes is the point of the exercise.

Scoping the problem

  • One step (charge) or two (authorize, then capture)? — Two steps add states, void and expiry — Both: charge is authorize plus capture
  • Partial capture? Several captures? — Decides what capture stores — One capture, up to the authorized amount
  • Partial refunds? Several? — Refunds must be tracked as a running total — Yes, any number, never more than captured in total
  • Which payment methods? — Each is a processor behind one interface — Card and wallet to start
  • How do clients retry? — Decides how idempotency keys work — Every write carries a key the client chooses
  • Do we store card numbers? — Storing them brings heavy compliance duties — No: store the processor's token and the last four digits
  • Currencies? — Minor units differ by currency — One currency per payment, integer minor units

Core entities

A Receipt is a frozen snapshot of a payment at the moment an operation finished. It is what the idempotency store keeps, so a retry gets back exactly what the first call returned, not the payment's state now.

The payment lifecycle

The order inside each operation matters. The gateway first checks that the move is allowed, then calls the processor, and records the new state only after the processor succeeds. If the processor raises, nothing is recorded and the same key can be retried.

The trace below is the first payment in the demo, in cents.

  • 1 — authorize 5,000 (key k1) — Authorized — 0 — 0 — Network approves
  • 2 — authorize 5,000 again (key k1) — Authorized — 0 — 0 — Same key, same request: stored receipt, no network call
  • 3 — authorize 9,000 (key k1) — refused — Same key, different request
  • 4 — capture 4,000 — Captured — 4,000 — 0 — Partial capture
  • 5 — refund 1,500 — PartiallyRefunded — 4,000 — 1,500 — 2,500 left
  • 6 — refund 3,000 — refused — 4,000 — 1,500 — More than the 2,500 left
  • 7 — refund 2,500 — Refunded — 4,000 — 4,000 — Nothing left

Idempotency keys

A client that times out cannot know whether its request was processed. So it sends the same request again with the same key, and the gateway answers from its store instead of acting twice.

  • New key — runs the action, saves request and receipt — the normal path
  • Same key, same request — returns the saved receipt — a retry
  • Same key, different request — raises IdempotencyConflict — a client bug; acting would be a guess
  • Same key while the first call is still running — waits on the key's lock, then returns the saved receipt — two retries at once must not both act
  • The action raised (processor unreachable) — saves nothing — the client may retry safely
  • The network declined — saves the Declined receipt — a decline is an answer, not an error

Stripe's API works much the same way. It compares the parameters of a retry with the original and errors if they differ, and lets keys be pruned once they are at least 24 hours old. One difference: once a request has started running, Stripe saves its status code and body even when it failed, including 500 errors, so a retry gets the same error back; it saves nothing only when the parameters fail validation or another request with the same key is still running. The code saves nothing when the processor raises, which lets the client retry an outage but means the gateway must know the processor did not act (see the timeout question below).

Money and thread safety

Amounts are integers in the currency's smallest unit: 5,000 means 50.00 USD. Floating point cannot hold most decimal fractions exactly (0.1 + 0.2 is not 0.3 in binary floating point), and a payment system that adds up refunds must get exact totals.

  • One per idempotency key — the check-then-run of that key — 10 simultaneous retries of one key reach the network once
  • One per payment — its status, captured and refunded — two refunds cannot both pass the 'enough left' check
  • One gateway lock — the payment table, ids and audit list — short: a dictionary insert or a list append

The locks are always taken in the order key, payment, gateway, and no code waits for an earlier lock while holding a later one. The demo checks both races: 10 threads retrying one key produce one network call, and 10 threads each refunding 1,000 of a 5,000 capture with their own keys produce exactly 5 successes.

What you will be able to do

  • Draw the payment state machine and enforce it with one transitions table and an audit row per change.
  • Make every write idempotent with a client key, and say what happens for a retry, a conflicting reuse and a failure.
  • Add a payment method as a new Processor without touching the gateway.
  • Keep money in integer minor units and explain the refund invariant: total refunded never exceeds captured.
  • Show where the locks are and prove the refund race is closed with a test.

Functional requirements

  • authorize(key, amount, currency, method, token): the processor for the method approves and holds the amount, or declines
  • capture(key, payment_id, amount): take up to the authorized amount, once
  • void(key, payment_id): cancel an authorization that was not captured and release the hold
  • refund(key, payment_id, amount): any number of partial refunds; the total never exceeds the captured amount
  • Every write carries an idempotency key: a retry returns the first result; the same key with a different request is refused
  • Every state change is appended to an audit trail

Non-functional requirements

  • Correctness under retries: one intent never reaches the processor twice
  • Money is integer minor units; no floating point
  • Thread safe: a lock per key, a lock per payment, one short gateway lock, always in that order
  • A new payment method is a new Processor class; the gateway does not change
  • The gateway stores processor tokens, never card numbers

Interview questions

  • The gateway runs on many servers. How do idempotency keys still work?
  • The processor times out during capture. What state is the payment in?
  • Why authorize and capture separately? What if the merchant never captures?
  • How would you add a new payment method, say bank transfer?
  • Why integer cents? And how do you store card details?

Sign up free to read the full topic

More Low-Level Design topics

Start free on Cariara · Coding problems · System design studio