The 100 problems, solved / Commerce and money / 078 · Design a payment processor

Design a payment processor

Idempotent charges, transaction states, retries and refunds as commands.

Concepts this design leans on

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.
CommandsCoreRails*by nameCommandexecute(processor)ChargeCommandkey: stramount: intmethod: strRefundCommandkey: strtxn_id: stramount: intPaymentProcessortxns: dictidem: IdempotencyStorerails: dictrun(command)Transactionid: strstate: TransactionStatecaptured: intrefunded: intevents: listmove(to, note)IdempotencyStorereserve(key)complete(key, txn_id)PaymentMethodauthorize(amount)capture(ref, amount)refund(ref, amount)CardGatewayWalletGateway

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

  1. The merchant builds a ChargeCommand(key, amount, method) and calls processor.run.
  2. run asks the IdempotencyStore to reserve the key; an existing id short-circuits to the stored transaction.
  3. ChargeCommand.execute creates a transaction, authorises on the rail, moves to AUTHORIZED, captures, moves to CAPTURED; a decline moves to FAILED.
  4. run completes the key with the transaction id so retries find it.
  5. A RefundCommand checks 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 authorize and makes capture a no-op, and the transaction still passes through AUTHORIZED for the audit trail.
  • Retry during the gap: the second thread blocks in reserve on 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 of refunded sit 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