Design a payment processor
Idempotent charges, transaction states, retries and refunds as commands.
- Idempotent operations — Making a repeated call harmless inside a single process too.
- State machines and the State pattern — Behaviour that depends on a mode, without a wall of conditionals.
- Command — Turning an action into an object you can queue, log and undo.
- Strategy — Swapping an algorithm at runtime by passing an object.
- Errors, exceptions and validation — Failing loudly at the boundary and never in the middle.
- Interface segregation — Small interfaces so classes are not forced to fake methods.
- Adapter — Making an existing class fit the interface you need.
01The design, in the six-step framework
1. Requirements and use cases
A payment processor takes a charge request from a merchant, moves it through a gateway, and keeps an honest record of what happened, even when the network drops halfway.
- A merchant charges a customer by card, wallet or bank transfer and gets a transaction id.
- A merchant retries the same charge after a timeout and must not charge twice.
- A merchant refunds all or part of a captured charge; refunds never exceed what was captured.
- The processor moves a transaction through created, authorised, captured, refunded, failed, and rejects illegal moves.
- An operator replays the history of a transaction.
Out of scope: PCI storage of card numbers, settlement files and currency conversion. Non-functional constraints: idempotency by merchant key under concurrent retries; a new payment method must not change the transaction logic; every state change is recorded for audit.
2. Core entities and relationships
- Transaction: the record with amount, state, captured and refunded totals and an event log.
- TransactionState: an enum with an explicit table of allowed transitions.
- PaymentMethod: an interface each rail implements: authorise, capture, refund against the external provider.
- Gateway adapters: CardGateway and WalletGateway wrap third-party clients behind
PaymentMethod. - Command: ChargeCommand and RefundCommand carry the intent, run it, and can be retried or compensated.
- IdempotencyStore: maps merchant key to the transaction id that key produced.
- PaymentProcessor: the orchestrator the merchant calls.
The processor composes transactions and the idempotency store because they are its state. Rails are aggregated: they are injected clients with their own lifetime, and a test substitutes a fake rail in one line.
3. Interfaces and key methods
class PaymentMethod(Protocol):
def authorize(self, amount: int) -> str:
"""Reserve funds; return the provider reference. Raises Declined."""
def capture(self, ref: str, amount: int) -> None: ...
def refund(self, ref: str, amount: int) -> None: ...
class Transaction:
def move(self, to: "TransactionState", note: str = "") -> None:
"""Apply a transition from ALLOWED; append an event; raise IllegalTransition otherwise."""
class IdempotencyStore:
def reserve(self, key: str) -> Optional[str]:
"""Return an existing transaction id for the key, or None after reserving it atomically."""
def complete(self, key: str, txn_id: str) -> None: ...
class Command(Protocol):
key: str
def execute(self, p: "PaymentProcessor") -> "Transaction": ...
class PaymentProcessor:
def run(self, cmd: Command) -> "Transaction":
"""Idempotent entry point: same key, same transaction, no second side effect."""
Invariants: refunded <= captured <= amount on every transaction; a transition appears in events if and only if it was applied; and one merchant key maps to at most one transaction id forever.
4. Design patterns and the choices behind them
An explicit state table
What varies is which moves are legal. A dictionary from state to allowed next states, checked in one move method, is the smallest form of a state machine; full State classes are warranted when each state has different behaviour, which here they do not. The rejected alternative is scattering if self.state == ... checks across charge and refund, which is where double refunds come from.
Commands for charge and refund
Charge and refund are requests that must be logged, retried and sometimes compensated. Making each a Command object with a key gives the processor one run method that handles idempotency and auditing for every operation, instead of repeating that plumbing per method.
Adapters per rail behind one interface
Card networks, wallets and bank transfers have different APIs and failure modes. Each is wrapped in an adapter that implements the narrow PaymentMethod interface, so the processor is closed to new rails and open to adding one. Keeping that interface to three methods follows interface segregation: a rail that cannot do partial capture still fits by raising, rather than by being forced to implement reporting methods it does not have.
Idempotency as a reservation
Idempotency is implemented as reserve-then-complete. The reservation is the atomic step; if two threads arrive with the same key, exactly one proceeds and the other waits for the result. Storing the result only after the charge succeeded is the classic bug: the second retry slips in during the gap.
5. Core flow and code
- The merchant builds a
ChargeCommand(key, amount, method)and callsprocessor.run. runasks theIdempotencyStoreto reserve the key; an existing id short-circuits to the stored transaction.ChargeCommand.executecreates a transaction, authorises on the rail, moves to AUTHORIZED, captures, moves to CAPTURED; a decline moves to FAILED.runcompletes the key with the transaction id so retries find it.- A
RefundCommandchecks the remaining capturable amount, calls the rail, and moves to PARTIALLY_REFUNDED or REFUNDED.
from __future__ import annotations
import itertools, threading, time
from dataclasses import dataclass, field
from enum import Enum
from typing import Optional, Protocol
class Declined(Exception): ...
class IllegalTransition(Exception): ...
class TransactionState(Enum):
CREATED = "created"
AUTHORIZED = "authorized"
CAPTURED = "captured"
PARTIALLY_REFUNDED = "partially_refunded"
REFUNDED = "refunded"
FAILED = "failed"
ALLOWED = {
TransactionState.CREATED: {TransactionState.AUTHORIZED, TransactionState.FAILED},
TransactionState.AUTHORIZED: {TransactionState.CAPTURED, TransactionState.FAILED},
TransactionState.CAPTURED: {TransactionState.PARTIALLY_REFUNDED, TransactionState.REFUNDED},
TransactionState.PARTIALLY_REFUNDED: {TransactionState.PARTIALLY_REFUNDED, TransactionState.REFUNDED},
TransactionState.REFUNDED: set(),
TransactionState.FAILED: set(),
}
@dataclass
class Transaction:
id: str
amount: int
method: str
state: TransactionState = TransactionState.CREATED
provider_ref: str = ""
captured: int = 0
refunded: int = 0
events: list[tuple[float, str]] = field(default_factory=list)
lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
def move(self, to: TransactionState, note: str = "") -> None:
if to not in ALLOWED[self.state]:
raise IllegalTransition(f"{self.state.value} -> {to.value}")
self.state = to
self.events.append((time.time(), f"{to.value} {note}".strip()))
class PaymentMethod(Protocol):
def authorize(self, amount: int) -> str: ...
def capture(self, ref: str, amount: int) -> None: ...
def refund(self, ref: str, amount: int) -> None: ...
class IdempotencyStore:
def __init__(self) -> None:
self._keys: dict[str, Optional[str]] = {}
self._lock = threading.Lock()
self._done = threading.Condition(self._lock)
def reserve(self, key: str) -> Optional[str]:
with self._lock:
if key not in self._keys:
self._keys[key] = None # reserved, in progress
return None
while self._keys[key] is None: # another thread is working on it
self._done.wait()
return self._keys[key]
def complete(self, key: str, txn_id: str) -> None:
with self._lock:
self._keys[key] = txn_id
self._done.notify_all()
class Command(Protocol):
key: str
def execute(self, p: PaymentProcessor) -> Transaction: ...
@dataclass
class ChargeCommand:
key: str
amount: int
method: str
def execute(self, p: PaymentProcessor) -> Transaction:
txn = p.new_transaction(self.amount, self.method)
rail = p.rails[self.method]
try:
txn.provider_ref = rail.authorize(self.amount)
txn.move(TransactionState.AUTHORIZED, txn.provider_ref)
rail.capture(txn.provider_ref, self.amount)
txn.captured = self.amount
txn.move(TransactionState.CAPTURED)
except Declined as e:
txn.move(TransactionState.FAILED, str(e))
return txn
@dataclass
class RefundCommand:
key: str
txn_id: str
amount: int
def execute(self, p: PaymentProcessor) -> Transaction:
txn = p.txns[self.txn_id]
with txn.lock:
if self.amount <= 0 or txn.refunded + self.amount > txn.captured:
raise ValueError("refund exceeds captured amount")
p.rails[txn.method].refund(txn.provider_ref, self.amount)
txn.refunded += self.amount
full = txn.refunded == txn.captured
txn.move(TransactionState.REFUNDED if full else TransactionState.PARTIALLY_REFUNDED, f"{self.amount}")
return txn
class PaymentProcessor:
def __init__(self, rails: dict[str, PaymentMethod]) -> None:
self.rails = rails
self.txns: dict[str, Transaction] = {}
self.idem = IdempotencyStore()
self._ids = itertools.count(1)
self._lock = threading.Lock()
def new_transaction(self, amount: int, method: str) -> Transaction:
with self._lock:
txn = Transaction(f"txn{next(self._ids)}", amount, method)
self.txns[txn.id] = txn
return txn
def run(self, cmd: Command) -> Transaction:
existing = self.idem.reserve(cmd.key)
if existing is not None:
return self.txns[existing]
txn = cmd.execute(self)
self.idem.complete(cmd.key, txn.id)
return txn
6. Extensions, concurrency and what interviewers probe
- A new rail (UPI, bank transfer) is one adapter class registered under a name; neither command nor processor changes. Interviewers tagged this question Hard because they push on what happens when a rail supports authorise but not separate capture: the adapter captures inside
authorizeand makescapturea no-op, and the transaction still passes through AUTHORIZED for the audit trail. - Retry during the gap: the second thread blocks in
reserveon the condition variable until the first completes, then returns the same transaction. If the first thread dies, the key stays reserved forever; a real system records the reservation with an expiry and a worker that resolves it against the provider. - Refund races: two refunds of the same transaction are serialised by
txn.lock, which is why the check and the increment ofrefundedsit inside it. - Timeouts from the provider are the hard case: you do not know whether the authorisation happened. The honest answer is a transaction stuck in CREATED with a pending flag and a reconciliation job; do not invent a success.
- Test first: same key twice yields one provider call; refund of more than captured raises; CAPTURED to AUTHORIZED is rejected; the event log has one line per transition.
- Common mistake: storing the idempotency result after success only. Reserve first, then act, then complete.
02Where this question was reported
- LLD Mastery · Google-tagged (Hard); idempotency, transaction state machine
- LLD Mastery · Google-tagged with Stripe/PayPal/Square/Visa
- Codemia · Codemia Hard
- Educative · Educative: Strategy + Factory to isolate payment methods