Skip to content

Design a payment gateway and digital wallet

TL;DR

  • You build wallets, a gateway to card, UPI and netbanking processors, and a double-entry ledger that every cent passes through.
  • Three decisions carry the interview: an idempotency key on every money-moving call, debits equal credits or nothing is written, and wallet locks taken in id order so two transfers in opposite directions queue instead of deadlocking.
  • Adapter hides three incompatible vendor SDKs, Chain of Responsibility holds the fraud and limit rules, Unit of Work makes balance plus ledger plus transaction one commit, and the webhook handler survives duplicate, reordered and early delivery.

Problem statement

"Design a digital wallet with a payment gateway in front of it. Users add money from a card, UPI or bank account, send money to each other, pay merchants and get refunds. Each payment goes through fraud and limit checks, is authorized and captured with an external processor, and shows up in a history. The processor calls us back with webhooks that are not always in order and not always once. Focus on the classes, the money invariants, and what happens when a client retries a payment it is not sure went through."

Requirements

Functional

  • Wallets with a balance and reservations; opening balance funded from the clearing account so the book starts balanced.
  • Add money through a payment method (card, UPI, netbanking) via a processor adapter; withdraw back to a bank.
  • Wallet-to-wallet transfer; merchant payment with a platform fee; full and partial refunds.
  • A payment lifecycle: initiated, authorized, captured, failed, partially refunded, refunded.
  • An idempotency key on every money-moving call. A repeat returns the stored result; the same key with a different payload is an error.
  • A double-entry ledger: every posting has debits equal to credits, and the ledger balance of a wallet account equals the wallet balance.
  • Fraud and daily-limit rules evaluated before any money moves.
  • Processor webhooks that may be duplicated, reordered, or arrive before the transaction row is committed.

Non-functional and constraints

  • Money is common.Money (integer cents). Fees are integer basis points with floor division; no float, ever.
  • Effectively-once money movement: at-least-once delivery plus idempotent handlers.
  • No deadlock. Multi-wallet operations acquire locks in a fixed order.
  • No processor call while a lock is held.
  • A wallet can never go negative.

Out of scope: KYC, currency conversion, chargeback dispute workflows, card tokenisation and PCI scope, settlement files and reconciliation (that is the HLD version).

Clarifying questions and assumptions

Question to ask Assumption taken here
Who supplies the idempotency key? The client, one per logical request. We store the request fingerprint with it and reject the key if the payload differs.
What does a retry get back while the first call is still running? A conflict, not a second payment. In-flight is a real state in the idempotency store.
Is a top-up settled when the processor approves it? No. Approval is AUTHORIZED; the money lands when the capture webhook arrives. That is what makes the webhook path worth designing.
Can webhooks arrive out of order? Yes. Each status has a rank; an event that does not move the transaction forward is ignored.
What if a webhook arrives before we committed the transaction? It is parked by processor reference and replayed the moment the row exists.
How is the merchant fee rounded? amount x 200 / 10000 with floor division, so the platform never rounds in its own favour, and the merchant gets the remainder.
One currency or many? One per store. Multi-currency needs a rate snapshot per transaction, which is a follow-up.

Core entities and relationships

  • Wallet — balance, reservations, and the only negative-balance guard in the system. available() is balance - reserved.
  • Transaction — one money-moving intent: type, status, amount, source and destination ledger accounts, idempotency key, processor reference, refunded total. transition_to consults TRANSACTION_TRANSITIONS.
  • LedgerEntry and Ledger — the book. An entry is one side of a posting; Ledger.check_balanced refuses anything whose signed cents do not sum to zero.
  • PaymentMethod and the PaymentProcessor protocol, with CardProcessorAdapter, UpiProcessorAdapter and NetbankingProcessorAdapter over three deliberately incompatible vendor clients; PaymentProcessorFactory picks one by method type.
  • FraudRule chain — AmountCeilingRule, DenylistRule, VelocityRule, DailyLimitRule, each returning a FraudDecision or passing the request on.
  • IdempotencyStore — claim, complete, release. PaymentStore owns wallets, transactions, refunds, the ledger and one reentrant lock per wallet. PaymentUnitOfWork is the transaction boundary.
  • MoneyService is the shared skeleton; WalletService and PaymentService fill in the middle. WebhookHandler applies processor events.

Multiplicities: wallet 1 -> * transactions, transaction 1 -> * ledger entries (always at least two), transaction 1 -> * refunds, store 1 -> 1 ledger, method type 1 -> 1 processor adapter.

Class diagram

The domain: a wallet, a transaction, and the ledger both of them answer to.

classDiagram
    class Wallet {
        +id: str
        +balance: Money
        +reserved: Money
        +available() Money
        +reserve(amount)
        +release(amount)
        +debit(amount)
        +credit(amount)
    }
    class Transaction {
        +id: str
        +type: TransactionType
        +status: TransactionStatus
        +amount: Money
        +source: str
        +destination: str
        +idempotency_key: str
        +psp_reference: str
        +refunded: Money
        +transition_to(status)
        +refundable() Money
    }
    class Refund {
        +transaction_id: str
        +amount: Money
        +idempotency_key: str
    }
    class PaymentMethod {
        +type: PaymentMethodType
        +token: str
    }
    class WebhookEvent {
        +event_id: str
        +psp_reference: str
        +status: TransactionStatus
    }
    class LedgerEntry {
        +transaction_id: str
        +account: str
        +direction: EntryDirection
        +amount: Money
        +signed_cents() int
    }
    class Ledger {
        -_lock: Lock
        +check_balanced(entries)
        +post(entries)
        +balance(account) Money
        +entries_for(transaction_id) List~LedgerEntry~
        +is_balanced() bool
    }
    class TransactionStatus {
        <<enumeration>>
        INITIATED
        AUTHORIZED
        CAPTURED
        FAILED
        PARTIALLY_REFUNDED
        REFUNDED
    }
    class TransactionType {
        <<enumeration>>
        TOP_UP
        TRANSFER
        WITHDRAWAL
        MERCHANT_PAYMENT
        REFUND
    }
    class EntryDirection {
        <<enumeration>>
        DEBIT
        CREDIT
    }
    Transaction --> TransactionStatus
    Transaction --> TransactionType
    Transaction "1" --> "many" LedgerEntry : posts
    Transaction "1" o-- "many" Refund
    LedgerEntry --> EntryDirection
    Ledger "1" *-- "many" LedgerEntry
    Wallet ..> Ledger : mirrored by
    Transaction ..> PaymentMethod
    WebhookEvent ..> Transaction : advances

The services: adapters, the fraud chain, the transaction boundary and the webhook handler.

classDiagram
    class PaymentProcessor {
        <<interface>>
        +authorize(method, amount, reference) PspResult
        +capture(authorization_id, amount) PspResult
        +refund(capture_id, amount) PspResult
    }
    class CardProcessorAdapter
    class UpiProcessorAdapter
    class NetbankingProcessorAdapter
    class PaymentProcessorFactory {
        +with_stubs(declined_tokens) PaymentProcessorFactory
        +for_method(method) PaymentProcessor
    }
    class PspResult {
        +approved: bool
        +reference: str
        +code: str
    }
    class FraudRule {
        <<abstract>>
        +set_next(rule) FraudRule
        +check(context) FraudDecision
        +evaluate(context) FraudDecision
    }
    class AmountCeilingRule
    class DenylistRule
    class VelocityRule
    class DailyLimitRule
    class MoneyService {
        +_new(...) Transaction
        +_replay(record) Transaction
        +_finish(key, transaction_id, failure) Transaction
    }
    class WalletService {
        +open_wallet(owner_id, opening_balance) Wallet
        +top_up(key, wallet_id, method, amount) Transaction
        +withdraw(key, wallet_id, method, amount) Transaction
        +transfer(key, source_id, target_id, amount) Transaction
    }
    class PaymentService {
        +fee_for(amount) Money
        +pay_merchant(key, wallet_id, merchant_id, amount) Transaction
        +refund(key, transaction_id, amount) Refund
    }
    class WebhookHandler {
        +handle(event) str
        +replay(psp_reference) List~str~
        +parked() int
    }
    class PaymentStore {
        +locked(wallet_ids)
        +wallet(wallet_id) Wallet
        +apply(wallets, transactions, entries, refunds)
    }
    class IdempotencyStore {
        +claim(key, fingerprint) IdempotencyRecord
        +complete(key, transaction_id)
        +release(key)
    }
    class UnitOfWork {
        <<interface>>
        +commit()
        +rollback()
    }
    class PaymentUnitOfWork {
        +post(entries)
        +track(transaction) Transaction
        +commit()
    }
    class TransactionListener {
        <<interface>>
        +on_transaction(transaction)
    }
    class TransactionLog
    PaymentProcessor <|.. CardProcessorAdapter
    PaymentProcessor <|.. UpiProcessorAdapter
    PaymentProcessor <|.. NetbankingProcessorAdapter
    PaymentProcessorFactory ..> PaymentProcessor : selects
    PaymentProcessor ..> PspResult
    FraudRule <|-- AmountCeilingRule
    FraudRule <|-- DenylistRule
    FraudRule <|-- VelocityRule
    FraudRule <|-- DailyLimitRule
    FraudRule --> "0..1" FraudRule : next
    MoneyService <|-- WalletService
    MoneyService <|-- PaymentService
    WalletService --> PaymentProcessorFactory
    PaymentService --> FraudRule
    MoneyService --> PaymentUnitOfWork
    MoneyService --> "many" TransactionListener : notifies
    UnitOfWork <|.. PaymentUnitOfWork
    PaymentUnitOfWork --> PaymentStore
    PaymentStore "1" *-- "1" IdempotencyStore
    WebhookHandler --> PaymentStore
    TransactionListener <|.. TransactionLog

Design patterns applied

Pattern Where Why it earns its place
Adapter CardProcessorAdapter, UpiProcessorAdapter, NetbankingProcessorAdapter The three stub clients return a dict, a tuple and a colon-separated string, which is exactly how real vendor SDKs differ. Each adapter normalises one into PspResult; nothing upstream knows a vendor exists.
Chain of Responsibility FraudRule and its four subclasses Rules are added, reordered and switched off per market. Cheap checks sit at the front, history scans at the back, and the first rule with an opinion wins.
Unit of Work PaymentUnitOfWork + PaymentStore.apply Balance, transaction row and ledger entries become visible together, and apply validates the posting before it writes. An imbalanced posting cannot move a balance.
Template Method MoneyService Claim key, lock, transact, commit, complete, notify — the same six steps in five operations. The subclasses only supply the middle.
State (transition table) TRANSACTION_TRANSITIONS + Transaction.transition_to Six statuses with an explicit legal-move table. CAPTURED can only become a refund state; FAILED and REFUNDED are terminal.
Factory PaymentProcessorFactory.for_method Method type to adapter, in one dict. Adding a wallet-to-wallet rail is one entry.
Observer TransactionListener / TransactionLog Receipts, notifications and analytics hang off settled transactions and are notified outside every lock.
Repository (light) PaymentStore wallet, transaction, apply, locked. Backing it with SQL means implementing four methods, and locked becomes SELECT ... FOR UPDATE in id order.

What was deliberately not used: two-phase commit between the wallet and the processor. You cannot enrol a card network in your transaction; the honest answer is a local transaction plus an idempotent, reconcilable webhook — which is what this design is. Also no Saga orchestrator class: with one local resource and one remote call, a reservation and a compensating release are enough.

Key flows

A merchant payment: fraud chain, then one transaction that debits, credits and splits the fee.

sequenceDiagram
    autonumber
    actor U as User
    participant S as PaymentService
    participant I as IdempotencyStore
    participant C as FraudRule
    participant K as PaymentStore
    participant W as PaymentUnitOfWork
    participant L as Ledger
    U->>S: pay_merchant(key, wallet, cafe, 60.00)
    S->>I: claim(key, fingerprint)
    alt key already completed
        I-->>S: stored record
        S-->>U: the stored transaction (or its stored failure)
    else new key
        I-->>S: claimed
        S->>C: evaluate(amount, destination, history)
        C-->>S: allow or block with a reason
        S->>K: locked(wallet)
        S->>W: open working copy
        S->>W: wallet.debit(60.00), 3 entries (wallet, merchant, fees)
        S->>W: commit
        W->>L: check_balanced then post
        S->>I: complete(key, transaction_id)
        S-->>U: Transaction(CAPTURED)
    end

Transaction lifecycle. A fraud block and a processor decline both land on FAILED, which is what makes the replay path uniform.

stateDiagram-v2
    [*] --> INITIATED : idempotency key claimed
    INITIATED --> AUTHORIZED : processor approved
    INITIATED --> CAPTURED : internal move, no processor
    INITIATED --> FAILED : fraud block or decline
    AUTHORIZED --> CAPTURED : capture webhook
    AUTHORIZED --> FAILED : capture refused
    CAPTURED --> PARTIALLY_REFUNDED : partial refund
    CAPTURED --> REFUNDED : full refund
    PARTIALLY_REFUNDED --> PARTIALLY_REFUNDED : another partial refund
    PARTIALLY_REFUNDED --> REFUNDED : the remainder
    FAILED --> [*]
    REFUNDED --> [*]

Implementation

The order to write it in: vocabulary, wallet, transaction, ledger, adapters, fraud chain, idempotency, transaction boundary, services.

The enums carry two tables. TRANSACTION_TRANSITIONS is the state machine; STATUS_RANK is what makes an out-of-order webhook detectable in one comparison.

code/lld/payment_gateway_wallet/models.py — enums and tables
class TransactionType(StrEnum):
    TOP_UP = "top_up"  # money in, from a card, UPI or bank
    TRANSFER = "transfer"  # wallet to wallet, no external rail
    WITHDRAWAL = "withdrawal"  # money out, to a bank account
    MERCHANT_PAYMENT = "merchant_payment"  # wallet to merchant, minus a fee
    REFUND = "refund"  # merchant back to wallet


class TransactionStatus(StrEnum):
    INITIATED = "initiated"  # created, funds reserved, nothing settled
    AUTHORIZED = "authorized"  # the processor holds the money, we do not yet
    CAPTURED = "captured"  # settled, ledger posted, balances moved
    FAILED = "failed"  # declined or refused; every reservation released
    PARTIALLY_REFUNDED = "partially_refunded"
    REFUNDED = "refunded"


class PaymentMethodType(StrEnum):
    CARD = "card"
    UPI = "upi"
    NETBANKING = "netbanking"


class EntryDirection(StrEnum):
    DEBIT = "debit"
    CREDIT = "credit"


TERMINAL_STATUSES = frozenset({TransactionStatus.FAILED, TransactionStatus.REFUNDED})

TRANSACTION_TRANSITIONS: Mapping[TransactionStatus, frozenset[TransactionStatus]] = {
    TransactionStatus.INITIATED: frozenset(
        {TransactionStatus.AUTHORIZED, TransactionStatus.CAPTURED, TransactionStatus.FAILED}
    ),
    TransactionStatus.AUTHORIZED: frozenset({TransactionStatus.CAPTURED, TransactionStatus.FAILED}),
    TransactionStatus.CAPTURED: frozenset(
        {TransactionStatus.PARTIALLY_REFUNDED, TransactionStatus.REFUNDED}
    ),
    TransactionStatus.PARTIALLY_REFUNDED: frozenset(
        {TransactionStatus.PARTIALLY_REFUNDED, TransactionStatus.REFUNDED}
    ),
    TransactionStatus.FAILED: frozenset(),
    TransactionStatus.REFUNDED: frozenset(),
}

# How far along a transaction a processor event claims to be. A webhook whose
# rank is not higher than the transaction's current rank is late or duplicate.
STATUS_RANK: Mapping[TransactionStatus, int] = {
    TransactionStatus.INITIATED: 0,
    TransactionStatus.AUTHORIZED: 1,
    TransactionStatus.FAILED: 2,
    TransactionStatus.CAPTURED: 2,
    TransactionStatus.PARTIALLY_REFUNDED: 3,
    TransactionStatus.REFUNDED: 3,
}
code/lld/payment_gateway_wallet/models.py — errors
class InsufficientBalanceError(ConflictError):
    """Available balance (balance minus reservations) cannot cover the amount."""


class LedgerImbalanceError(ConflictError):
    """A posting whose debits and credits do not cancel. Never let this reach production."""


class IdempotencyConflictError(ConflictError):
    """The key was reused with a different request, or the first request is still running."""


class FraudRejectedError(ConflictError):
    """A fraud rule refused the payment before any money moved."""


class PaymentDeclinedError(ConflictError):
    """The processor refused the authorization; reservations are released."""


class TransactionStateError(InvalidStateError):
    """The transaction is not in a state that allows this transition."""


class UnknownEntityError(NotFoundError):
    """No such wallet, transaction or payment method."""

The wallet is four methods and one guard. Say out loud that debit is the only place a balance goes down, so "can a wallet go negative?" has a one-line answer.

code/lld/payment_gateway_wallet/models.py — wallet
@dataclass(slots=True)
class Wallet:
    """Balance plus reservations. ``debit`` is the only negative-balance guard you need."""

    id: str
    owner_id: str
    balance: Money
    reserved: Money = Money(0)

    def available(self) -> Money:
        return self.balance - self.reserved

    def reserve(self, amount: Money) -> None:
        _require_positive(amount)
        if amount > self.available():
            raise InsufficientBalanceError(f"{self.id}: {self.available()} available, {amount} requested")
        self.reserved = self.reserved + amount

    def release(self, amount: Money) -> None:
        self.reserved = Money(max(0, (self.reserved - amount).cents), amount.currency)

    def debit(self, amount: Money) -> None:
        _require_positive(amount)
        if amount > self.balance:
            raise InsufficientBalanceError(f"{self.id}: balance {self.balance} cannot cover {amount}")
        self.balance = self.balance - amount

    def credit(self, amount: Money) -> None:
        _require_positive(amount)
        self.balance = self.balance + amount

    def copy(self) -> Wallet:
        return Wallet(self.id, self.owner_id, self.balance, self.reserved)


def _require_positive(amount: Money) -> None:
    if amount.cents <= 0:
        raise ValidationError(f"amount must be positive, got {amount}")
code/lld/payment_gateway_wallet/models.py — transaction and refund
@dataclass(frozen=True, slots=True)
class PaymentMethod:
    """A tokenised instrument. The token is what the processor understands, not a card number."""

    id: str
    owner_id: str
    type: PaymentMethodType
    token: str
    label: str


@dataclass(slots=True)
class Transaction:
    """One money-moving intent, from creation to settlement or refund."""

    id: str
    type: TransactionType
    status: TransactionStatus
    amount: Money
    source: str  # ledger account money leaves
    destination: str  # ledger account money arrives in
    idempotency_key: str
    created_at: float
    psp_reference: str | None = None
    failure_reason: str | None = None
    refunded: Money = Money(0)

    def transition_to(self, status: TransactionStatus) -> None:
        if status not in TRANSACTION_TRANSITIONS[self.status]:
            raise TransactionStateError(f"transaction {self.id}: {self.status} cannot become {status}")
        self.status = status

    def is_final(self) -> bool:
        return self.status in TERMINAL_STATUSES

    def refundable(self) -> Money:
        if self.status not in (
            TransactionStatus.CAPTURED,
            TransactionStatus.PARTIALLY_REFUNDED,
        ):
            return Money(0, self.amount.currency)
        return self.amount - self.refunded

    def copy(self) -> Transaction:
        return Transaction(
            self.id, self.type, self.status, self.amount, self.source, self.destination,
            self.idempotency_key, self.created_at, self.psp_reference, self.failure_reason, self.refunded,
        )


@dataclass(frozen=True, slots=True)
class Refund:
    id: str
    transaction_id: str
    amount: Money
    idempotency_key: str
    at: float


@dataclass(frozen=True, slots=True)
class WebhookEvent:
    """What the processor posts back to us. ``event_id`` is its delivery key."""

    event_id: str
    psp_reference: str
    status: TransactionStatus
    at: float

The ledger is the shortest and most important file. signed_cents turns a posting into a sum that must be zero, and check_balanced is called before anything is written.

code/lld/payment_gateway_wallet/ledger.py
@dataclass(frozen=True, slots=True)
class LedgerEntry:
    """One side of a posting. Frozen and append-only: the ledger is never edited."""

    id: str
    transaction_id: str
    account: str
    direction: EntryDirection
    amount: Money
    at: float

    def signed_cents(self) -> int:
        """Credit is positive, debit is negative, so a balanced posting sums to zero."""
        return self.amount.cents if self.direction is EntryDirection.CREDIT else -self.amount.cents


class Ledger:
    """Append-only book of entries, plus the balance of any account.

    ``_lock`` guards the entry list and the per-account totals. Reads are cheap
    because the totals are maintained on write rather than recomputed.
    """

    def __init__(self, currency: str = "USD") -> None:
        self._currency = currency
        self._lock = threading.Lock()
        self._entries: list[LedgerEntry] = []
        self._totals: dict[str, int] = {}

    @staticmethod
    def check_balanced(entries: Sequence[LedgerEntry]) -> None:
        """Debits must equal credits. This is the invariant the whole design protects."""
        if not entries:
            raise LedgerImbalanceError("a posting needs at least two entries")
        residual = sum(entry.signed_cents() for entry in entries)
        if residual != 0:
            raise LedgerImbalanceError(f"posting is out by {residual} cents; debits must equal credits")

    def post(self, entries: Sequence[LedgerEntry]) -> None:
        self.check_balanced(entries)
        with self._lock:
            for entry in entries:
                self._entries.append(entry)
                self._totals[entry.account] = self._totals.get(entry.account, 0) + entry.signed_cents()

    def balance(self, account: str) -> Money:
        """Credits minus debits: positive means the account holds money."""
        with self._lock:
            return Money(self._totals.get(account, 0), self._currency)

    def entries_for(self, transaction_id: str) -> list[LedgerEntry]:
        with self._lock:
            return [entry for entry in self._entries if entry.transaction_id == transaction_id]

    def is_balanced(self) -> bool:
        """The whole book: every cent debited somewhere was credited somewhere else."""
        with self._lock:
            return sum(self._totals.values()) == 0

    def size(self) -> int:
        with self._lock:
            return len(self._entries)

The three stub clients are intentionally inconsistent. Read them together with their adapters: this is what Adapter is actually for, and it is worth 30 seconds of narration in the room.

code/lld/payment_gateway_wallet/psp.py
@dataclass(frozen=True, slots=True)
class PspResult:
    """The single shape the gateway understands, whatever the vendor returned."""

    approved: bool
    reference: str
    code: str

    @classmethod
    def declined(cls, code: str) -> PspResult:
        return cls(False, "", code)


class PaymentProcessor(Protocol):
    """Authorize, capture, refund. Everything below this line is someone else's system."""

    def authorize(self, method: PaymentMethod, amount: Money, reference: str) -> PspResult: ...

    def capture(self, authorization_id: str, amount: Money) -> PspResult: ...

    def refund(self, capture_id: str, amount: Money) -> PspResult: ...


class CardNetworkClient:
    """Vendor A: returns dictionaries and thinks in minor units."""

    def __init__(self, declined_tokens: Iterable[str] = ()) -> None:
        self._declined = frozenset(declined_tokens)
        self._counter = 0

    def _next(self, prefix: str) -> str:
        self._counter += 1
        return f"{prefix}_{self._counter:04d}"

    def authorize_charge(self, card_token: str, minor_units: int, currency: str) -> dict[str, str]:
        if card_token in self._declined:
            return {"status": "declined", "decline_code": "do_not_honor"}
        return {"status": "authorized", "auth_id": self._next("auth"), "currency": currency}

    def capture_charge(self, auth_id: str, minor_units: int) -> dict[str, str]:
        return {"status": "captured", "charge_id": self._next("ch")}

    def refund_charge(self, charge_id: str, minor_units: int) -> dict[str, str]:
        return {"status": "refunded", "refund_id": self._next("re")}


class UpiSwitchClient:
    """Vendor B: returns ``(ok, reference)`` tuples and calls the reference an RRN."""

    def __init__(self, declined_vpas: Iterable[str] = ()) -> None:
        self._declined = frozenset(declined_vpas)
        self._counter = 0

    def collect(self, vpa: str, paise: int) -> tuple[bool, str]:
        if vpa in self._declined:
            return False, "USER_DECLINED"
        self._counter += 1
        return True, f"RRN{self._counter:06d}"

    def reverse(self, rrn: str, paise: int) -> tuple[bool, str]:
        return True, f"REV{rrn}"


class NetbankingClient:
    """Vendor C: one method, a colon-separated status string, no capture step."""

    def __init__(self, declined_accounts: Iterable[str] = ()) -> None:
        self._declined = frozenset(declined_accounts)
        self._counter = 0

    def initiate(self, account_ref: str, amount_cents: int) -> str:
        if account_ref in self._declined:
            return "FAILED:INSUFFICIENT_FUNDS"
        self._counter += 1
        return f"OK:NB{self._counter:06d}"

    def initiate_refund(self, bank_ref: str, amount_cents: int) -> str:
        return f"OK:RF{bank_ref}"


class CardProcessorAdapter:
    """Adapter: two-step card flow (authorize then capture) behind ``PaymentProcessor``."""

    def __init__(self, client: CardNetworkClient) -> None:
        self._client = client

    def authorize(self, method: PaymentMethod, amount: Money, reference: str) -> PspResult:
        response = self._client.authorize_charge(method.token, amount.cents, amount.currency)
        if response["status"] != "authorized":
            return PspResult.declined(response["decline_code"])
        return PspResult(True, response["auth_id"], "approved")

    def capture(self, authorization_id: str, amount: Money) -> PspResult:
        response = self._client.capture_charge(authorization_id, amount.cents)
        return PspResult(True, response["charge_id"], "captured")

    def refund(self, capture_id: str, amount: Money) -> PspResult:
        response = self._client.refund_charge(capture_id, amount.cents)
        return PspResult(True, response["refund_id"], "refunded")


class UpiProcessorAdapter:
    """Adapter: UPI authorizes and captures in one collect call, so capture is a no-op."""

    def __init__(self, client: UpiSwitchClient) -> None:
        self._client = client

    def authorize(self, method: PaymentMethod, amount: Money, reference: str) -> PspResult:
        ok, value = self._client.collect(method.token, amount.cents)
        return PspResult(True, value, "approved") if ok else PspResult.declined(value)

    def capture(self, authorization_id: str, amount: Money) -> PspResult:
        return PspResult(True, authorization_id, "captured")

    def refund(self, capture_id: str, amount: Money) -> PspResult:
        ok, value = self._client.reverse(capture_id, amount.cents)
        return PspResult(ok, value, "refunded" if ok else "reversal_failed")


class NetbankingProcessorAdapter:
    """Adapter: parses the vendor's status string into the shared result type."""

    def __init__(self, client: NetbankingClient) -> None:
        self._client = client

    @staticmethod
    def _parse(raw: str) -> PspResult:
        status, _, value = raw.partition(":")
        return PspResult(True, value, "approved") if status == "OK" else PspResult.declined(value)

    def authorize(self, method: PaymentMethod, amount: Money, reference: str) -> PspResult:
        return self._parse(self._client.initiate(method.token, amount.cents))

    def capture(self, authorization_id: str, amount: Money) -> PspResult:
        return PspResult(True, authorization_id, "captured")

    def refund(self, capture_id: str, amount: Money) -> PspResult:
        return self._parse(self._client.initiate_refund(capture_id, amount.cents))


class PaymentProcessorFactory:
    """Maps a payment method type to the adapter that speaks its rail."""

    def __init__(self, processors: dict[PaymentMethodType, PaymentProcessor]) -> None:
        self._processors = dict(processors)

    @classmethod
    def with_stubs(cls, declined_tokens: Iterable[str] = ()) -> PaymentProcessorFactory:
        tokens = frozenset(declined_tokens)
        return cls(
            {
                PaymentMethodType.CARD: CardProcessorAdapter(CardNetworkClient(tokens)),
                PaymentMethodType.UPI: UpiProcessorAdapter(UpiSwitchClient(tokens)),
                PaymentMethodType.NETBANKING: NetbankingProcessorAdapter(NetbankingClient(tokens)),
            }
        )

    def for_method(self, method: PaymentMethod) -> PaymentProcessor:
        try:
            return self._processors[method.type]
        except KeyError:
            raise ValidationError(f"no processor configured for {method.type}") from None

Fraud rules are a chain because the ordering is a business decision that changes. check returns None to pass the request on; evaluate walks the chain.

code/lld/payment_gateway_wallet/fraud.py
@dataclass(frozen=True, slots=True)
class FraudContext:
    """Everything a rule may look at. Built once per payment, never mutated."""

    wallet_id: str
    amount: Money
    destination: str
    at: float
    history: tuple[Transaction, ...]  # this wallet's transactions, newest last


@dataclass(frozen=True, slots=True)
class FraudDecision:
    allowed: bool
    rule: str = ""
    reason: str = ""

    @classmethod
    def allow(cls) -> FraudDecision:
        return cls(True)

    @classmethod
    def block(cls, rule: str, reason: str) -> FraudDecision:
        return cls(False, rule, reason)


class FraudRule(ABC):
    """One link. ``check`` returns a decision to stop on, or None to pass along."""

    def __init__(self) -> None:
        self._next: FraudRule | None = None

    def set_next(self, rule: FraudRule) -> FraudRule:
        self._next = rule
        return rule  # so a chain reads a.set_next(b).set_next(c)

    @abstractmethod
    def check(self, context: FraudContext) -> FraudDecision | None: ...

    def evaluate(self, context: FraudContext) -> FraudDecision:
        decision = self.check(context)
        if decision is not None:
            return decision
        if self._next is None:
            return FraudDecision.allow()
        return self._next.evaluate(context)


class AmountCeilingRule(FraudRule):
    """The cheapest check: one transaction may not exceed a hard ceiling."""

    def __init__(self, ceiling: Money) -> None:
        super().__init__()
        self._ceiling = ceiling

    def check(self, context: FraudContext) -> FraudDecision | None:
        if context.amount > self._ceiling:
            return FraudDecision.block("amount_ceiling", f"{context.amount} exceeds the {self._ceiling} ceiling")
        return None


class DenylistRule(FraudRule):
    """Destinations the platform refuses outright, whatever the amount."""

    def __init__(self, denied: Sequence[str]) -> None:
        super().__init__()
        self._denied = frozenset(denied)

    def check(self, context: FraudContext) -> FraudDecision | None:
        if context.destination in self._denied:
            return FraudDecision.block("denylist", f"{context.destination} is on the denylist")
        return None


class VelocityRule(FraudRule):
    """Too many payments in a short window is the classic stolen-credentials signal."""

    def __init__(self, max_count: int, window_seconds: float) -> None:
        super().__init__()
        self._max_count = max_count
        self._window = window_seconds

    def check(self, context: FraudContext) -> FraudDecision | None:
        recent = [t for t in context.history if context.at - t.created_at <= self._window]
        if len(recent) >= self._max_count:
            return FraudDecision.block(
                "velocity", f"{len(recent)} payments in the last {int(self._window)}s"
            )
        return None


class DailyLimitRule(FraudRule):
    """The limit policy: the sum of today's settled outgoings plus this one has a cap."""

    def __init__(self, daily_cap: Money, day_seconds: float = 86_400) -> None:
        super().__init__()
        self._cap = daily_cap
        self._day = day_seconds

    def check(self, context: FraudContext) -> FraudDecision | None:
        spent = Money(0, context.amount.currency)
        for transaction in context.history:
            settled = transaction.status in (TransactionStatus.CAPTURED, TransactionStatus.AUTHORIZED)
            if settled and context.at - transaction.created_at <= self._day:
                spent = spent + transaction.amount
        if spent + context.amount > self._cap:
            return FraudDecision.block("daily_limit", f"{spent} already spent today, cap is {self._cap}")
        return None


def build_default_chain(
    ceiling: Money, daily_cap: Money, denied: Sequence[str] = (), max_per_minute: int = 5
) -> FraudRule:
    """Cheap checks first, history scans last."""
    head = AmountCeilingRule(ceiling)
    head.set_next(DenylistRule(denied)).set_next(VelocityRule(max_per_minute, 60.0)).set_next(
        DailyLimitRule(daily_cap)
    )
    return head

Three verbs make money movement effectively-once. claim is the one to talk through: a completed key replays, an in-flight key conflicts, a mismatched fingerprint conflicts.

code/lld/payment_gateway_wallet/store.py — idempotency
@dataclass(slots=True)
class IdempotencyRecord:
    """One client request. ``transaction_id`` is None while the request is in flight."""

    key: str
    fingerprint: str
    transaction_id: str | None = None


class IdempotencyStore:
    """Claim, complete, release. The three verbs that make money movement effectively-once.

    ``claim`` returns the finished record for a replay, raises for a key reused
    with a different payload or still running, and otherwise reserves the key.
    """

    def __init__(self) -> None:
        self._lock = threading.Lock()
        self._records: dict[str, IdempotencyRecord] = {}

    def claim(self, key: str, fingerprint: str) -> IdempotencyRecord | None:
        with self._lock:
            record = self._records.get(key)
            if record is None:
                self._records[key] = IdempotencyRecord(key, fingerprint)
                return None
            if record.fingerprint != fingerprint:
                raise IdempotencyConflictError(f"key {key} was already used for a different request")
            if record.transaction_id is None:
                raise IdempotencyConflictError(f"key {key} is still in flight")
            return record

    def complete(self, key: str, transaction_id: str) -> None:
        with self._lock:
            self._records[key].transaction_id = transaction_id

    def release(self, key: str) -> None:
        """Only for unexpected failures: a business decline keeps its stored result."""
        with self._lock:
            record = self._records.get(key)
            if record is not None and record.transaction_id is None:
                del self._records[key]

locked is the deadlock answer: sort the ids, take the locks, release in reverse. The Unit of Work validates before it writes.

code/lld/payment_gateway_wallet/store.py — store and lock ordering
class PaymentStore:
    """Wallets, transactions, refunds, the ledger and one reentrant lock per wallet."""

    def __init__(self, currency: str = "USD") -> None:
        self._registry_lock = threading.Lock()
        self._wallets: dict[str, Wallet] = {}
        self._wallet_locks: dict[str, threading.RLock] = {}
        self._transactions: dict[str, Transaction] = {}
        self._by_reference: dict[str, str] = {}  # psp reference -> transaction id
        self._refunds: list[Refund] = []
        self.ledger = Ledger(currency)
        self.idempotency = IdempotencyStore()

    def open_wallet(self, wallet: Wallet) -> Wallet:
        with self._registry_lock:
            self._wallets[wallet.id] = wallet
            self._wallet_locks[wallet.id] = threading.RLock()
        return wallet

    def wallet(self, wallet_id: str) -> Wallet:
        """A private copy: callers mutate it inside a Unit of Work, never in place."""
        with self._registry_lock:
            try:
                return self._wallets[wallet_id].copy()
            except KeyError:
                raise UnknownEntityError(f"unknown wallet {wallet_id}") from None

    def transaction(self, transaction_id: str) -> Transaction:
        with self._registry_lock:
            try:
                return self._transactions[transaction_id].copy()
            except KeyError:
                raise UnknownEntityError(f"unknown transaction {transaction_id}") from None

    def transaction_for_reference(self, psp_reference: str) -> Transaction | None:
        with self._registry_lock:
            transaction_id = self._by_reference.get(psp_reference)
            return self._transactions[transaction_id].copy() if transaction_id else None

    def history(self, wallet_id: str) -> list[Transaction]:
        account = f"wallet:{wallet_id}"
        with self._registry_lock:
            return [
                t.copy()
                for t in self._transactions.values()
                if account in (t.source, t.destination)
            ]

    def refunds(self) -> list[Refund]:
        with self._registry_lock:
            return list(self._refunds)

    def refund_for_key(self, idempotency_key: str) -> Refund:
        with self._registry_lock:
            for refund in self._refunds:
                if refund.idempotency_key == idempotency_key:
                    return refund
        raise UnknownEntityError(f"no refund recorded for key {idempotency_key}")

    @contextmanager
    def locked(self, *wallet_ids: str) -> Iterator[None]:
        """Acquire every wallet lock in id order. Fixed order, no deadlock, no exceptions."""
        with self._registry_lock:
            missing = [w for w in wallet_ids if w not in self._wallet_locks]
            if missing:
                raise UnknownEntityError(f"unknown wallet {missing[0]}")
            locks = [self._wallet_locks[w] for w in sorted(set(wallet_ids))]
        for lock in locks:
            lock.acquire()
        try:
            yield
        finally:
            for lock in reversed(locks):
                lock.release()

    def apply(
        self,
        wallets: Sequence[Wallet],
        transactions: Sequence[Transaction],
        entries: Sequence[LedgerEntry],
        refunds: Sequence[Refund],
    ) -> None:
        """Validate first, then write. Nothing here can fail once the check passes."""
        if entries:
            Ledger.check_balanced(entries)
        with self._registry_lock:
            for wallet in wallets:
                self._wallets[wallet.id] = wallet
            for transaction in transactions:
                self._transactions[transaction.id] = transaction
                if transaction.psp_reference:
                    self._by_reference[transaction.psp_reference] = transaction.id
            self._refunds.extend(refunds)
        if entries:
            self.ledger.post(entries)
code/lld/payment_gateway_wallet/store.py — unit of work
class UnitOfWork(Protocol):
    """Balances, transaction rows and ledger entries become visible together."""

    def commit(self) -> None: ...

    def rollback(self) -> None: ...


class PaymentUnitOfWork:
    """Working copies of the wallets and transactions one operation touches.

    ``commit`` hands the whole batch to ``PaymentStore.apply``, which checks the
    ledger posting *before* it writes anything. A transfer that would leave the
    books out by a cent therefore never moves a balance at all.
    """

    def __init__(
        self,
        store: PaymentStore,
        wallet_ids: Sequence[str] = (),
        transaction_ids: Sequence[str] = (),
    ) -> None:
        self._store = store
        self._committed = False
        self.wallets: dict[str, Wallet] = {w: store.wallet(w) for w in sorted(set(wallet_ids))}
        self.transactions: dict[str, Transaction] = {
            t: store.transaction(t) for t in sorted(set(transaction_ids))
        }
        self.entries: list[LedgerEntry] = []
        self.refunds: list[Refund] = []

    def __enter__(self) -> Self:
        return self

    def __exit__(
        self,
        exc_type: type[BaseException] | None,
        exc: BaseException | None,
        tb: TracebackType | None,
    ) -> None:
        if not self._committed:
            self.rollback()

    def wallet(self, wallet_id: str) -> Wallet:
        try:
            return self.wallets[wallet_id]
        except KeyError:
            raise UnknownEntityError(f"wallet {wallet_id} was not opened in this transaction") from None

    def transaction(self, transaction_id: str) -> Transaction:
        try:
            return self.transactions[transaction_id]
        except KeyError:
            raise UnknownEntityError(f"transaction {transaction_id} is not in this transaction") from None

    def track(self, transaction: Transaction) -> Transaction:
        self.transactions[transaction.id] = transaction
        return transaction

    def post(self, *entries: LedgerEntry) -> None:
        self.entries.extend(entries)

    def commit(self) -> None:
        self._store.apply(
            list(self.wallets.values()), list(self.transactions.values()), self.entries, self.refunds
        )
        self._committed = True

    def rollback(self) -> None:
        self.entries.clear()
        self.refunds.clear()
        self.wallets = {w: self._store.wallet(w) for w in self.wallets}

MoneyService holds the shape every operation repeats; WalletService and PaymentService supply the middle. top_up is the interesting one: authorize, commit, and let the webhook settle.

code/lld/payment_gateway_wallet/services.py — shared skeleton
class MoneyService:
    """Template Method: the steps every money-moving operation shares.

    Subclasses supply the middle of the sandwich; this class owns id and clock
    injection, ledger-entry construction, idempotent replay and notification.
    """

    def __init__(
        self,
        store: PaymentStore,
        clock: Clock | None = None,
        ids: IdGenerator | None = None,
        listeners: Iterable[TransactionListener] = (),
    ) -> None:
        self._store = store
        self._clock = clock or SystemClock()
        self._ids = ids or SequentialIdGenerator("T")
        self._listeners = list(listeners)

    def _debit(self, transaction_id: str, account: str, amount: Money) -> LedgerEntry:
        return LedgerEntry(
            self._ids.next_id(), transaction_id, account, EntryDirection.DEBIT, amount, self._clock.now()
        )

    def _credit(self, transaction_id: str, account: str, amount: Money) -> LedgerEntry:
        return LedgerEntry(
            self._ids.next_id(), transaction_id, account, EntryDirection.CREDIT, amount, self._clock.now()
        )

    def _new(
        self,
        kind: TransactionType,
        amount: Money,
        source: str,
        destination: str,
        idempotency_key: str,
        status: TransactionStatus = TransactionStatus.INITIATED,
    ) -> Transaction:
        if amount.cents <= 0:
            raise ValidationError(f"amount must be positive, got {amount}")
        return Transaction(
            self._ids.next_id(), kind, status, amount, source, destination, idempotency_key, self._clock.now()
        )

    def _replay(self, record: IdempotencyRecord) -> Transaction:
        """A repeated key returns the stored outcome, and re-raises a stored failure."""
        transaction = self._store.transaction(record.transaction_id or "")
        if transaction.status is TransactionStatus.FAILED:
            reason = transaction.failure_reason or "declined"
            error = FraudRejectedError if reason.startswith("fraud:") else PaymentDeclinedError
            raise error(f"{transaction.id} already failed: {reason}")
        return transaction

    def _finish(self, idempotency_key: str, transaction_id: str, failure: type[Exception]) -> Transaction:
        """Complete the key, then surface a business failure the same way a replay does."""
        self._store.idempotency.complete(idempotency_key, transaction_id)
        settled = self._store.transaction(transaction_id)
        if settled.status is TransactionStatus.FAILED:
            raise failure(f"{settled.id} failed: {settled.failure_reason}")
        self._notify(settled)
        return settled

    def _notify(self, transaction: Transaction) -> None:
        for listener in self._listeners:
            listener.on_transaction(transaction)  # outside every lock
code/lld/payment_gateway_wallet/services.py — wallet service
class WalletService(MoneyService):
    """Opening, funding, withdrawing and wallet-to-wallet transfers."""

    def __init__(
        self,
        store: PaymentStore,
        processors: PaymentProcessorFactory,
        clock: Clock | None = None,
        ids: IdGenerator | None = None,
        listeners: Iterable[TransactionListener] = (),
        on_reference: Callable[[str], object] | None = None,
    ) -> None:
        super().__init__(store, clock, ids, listeners)
        self._processors = processors
        # Wired to ``WebhookHandler.replay``: drains events that arrived before
        # this transaction's processor reference was committed.
        self._on_reference = on_reference

    def open_wallet(self, owner_id: str, opening_balance: Money) -> Wallet:
        """The opening balance is funded from the clearing account, so the book balances."""
        wallet = Wallet(self._ids.next_id(), owner_id, Money(0, opening_balance.currency))
        self._store.open_wallet(wallet)
        if opening_balance.cents == 0:
            return wallet
        transaction = self._new(
            TransactionType.TOP_UP, opening_balance, PSP_CLEARING, wallet_account(wallet.id),
            f"open:{wallet.id}", TransactionStatus.CAPTURED,
        )
        with self._store.locked(wallet.id), PaymentUnitOfWork(self._store, [wallet.id]) as uow:
            uow.wallet(wallet.id).credit(opening_balance)
            uow.track(transaction)
            uow.post(
                self._debit(transaction.id, PSP_CLEARING, opening_balance),
                self._credit(transaction.id, wallet_account(wallet.id), opening_balance),
            )
            uow.commit()
        return self._store.wallet(wallet.id)

    def top_up(self, idempotency_key: str, wallet_id: str, method: PaymentMethod, amount: Money) -> Transaction:
        """Authorize with the processor; the money lands when the capture webhook arrives."""
        claimed = self._store.idempotency.claim(
            idempotency_key, f"top_up:{wallet_id}:{amount.cents}:{method.id}"
        )
        if claimed is not None:
            return self._replay(claimed)
        try:
            transaction = self._new(
                TransactionType.TOP_UP, amount, PSP_CLEARING, wallet_account(wallet_id), idempotency_key
            )
            with self._store.locked(wallet_id), PaymentUnitOfWork(self._store, [wallet_id]) as uow:
                uow.track(transaction)
                uow.commit()
            result = self._processors.for_method(method).authorize(method, amount, transaction.id)
            with self._store.locked(wallet_id), PaymentUnitOfWork(self._store, [wallet_id], [transaction.id]) as uow:
                current = uow.transaction(transaction.id)
                if result.approved:
                    current.psp_reference = result.reference
                    current.transition_to(TransactionStatus.AUTHORIZED)
                else:
                    current.failure_reason = f"psp:{result.code}"
                    current.transition_to(TransactionStatus.FAILED)
                uow.commit()
        except Exception:
            self._store.idempotency.release(idempotency_key)
            raise
        settled = self._finish(idempotency_key, transaction.id, PaymentDeclinedError)
        if self._on_reference is not None and settled.psp_reference:
            self._on_reference(settled.psp_reference)
        return self._store.transaction(transaction.id)

    def withdraw(self, idempotency_key: str, wallet_id: str, method: PaymentMethod, amount: Money) -> Transaction:
        """Reserve, ask the processor, then either settle or hand the reservation back."""
        claimed = self._store.idempotency.claim(
            idempotency_key, f"withdraw:{wallet_id}:{amount.cents}:{method.id}"
        )
        if claimed is not None:
            return self._replay(claimed)
        held = False  # True only while a committed reservation has no matching settlement
        try:
            transaction = self._new(
                TransactionType.WITHDRAWAL, amount, wallet_account(wallet_id), PSP_CLEARING, idempotency_key
            )
            with self._store.locked(wallet_id), PaymentUnitOfWork(self._store, [wallet_id]) as uow:
                uow.wallet(wallet_id).reserve(amount)  # raises before the key is completed
                uow.track(transaction)
                uow.commit()
            held = True
            result = self._processors.for_method(method).authorize(method, amount, transaction.id)
            with self._store.locked(wallet_id), PaymentUnitOfWork(self._store, [wallet_id], [transaction.id]) as uow:
                wallet, current = uow.wallet(wallet_id), uow.transaction(transaction.id)
                wallet.release(amount)
                if result.approved:
                    wallet.debit(amount)
                    current.psp_reference = result.reference
                    current.transition_to(TransactionStatus.CAPTURED)
                    uow.post(
                        self._debit(current.id, wallet_account(wallet_id), amount),
                        self._credit(current.id, PSP_CLEARING, amount),
                    )
                else:
                    current.failure_reason = f"psp:{result.code}"
                    current.transition_to(TransactionStatus.FAILED)
                uow.commit()
            held = False
        except Exception:
            # A rail that raises instead of declining is the path that strands money:
            # the reservation is already committed, so compensate before re-raising.
            if held:
                self._release_reservation(wallet_id, amount)
            self._store.idempotency.release(idempotency_key)
            raise
        return self._finish(idempotency_key, transaction.id, PaymentDeclinedError)

    def _release_reservation(self, wallet_id: str, amount: Money) -> None:
        """Compensating action: give back a hold whose processor call never answered."""
        with self._store.locked(wallet_id), PaymentUnitOfWork(self._store, [wallet_id]) as uow:
            uow.wallet(wallet_id).release(amount)
            uow.commit()

    def transfer(self, idempotency_key: str, source_id: str, target_id: str, amount: Money) -> Transaction:
        """Wallet to wallet. Both locks, always in id order, one transaction, no processor."""
        if source_id == target_id:
            raise ValidationError("cannot transfer to the same wallet")
        claimed = self._store.idempotency.claim(
            idempotency_key, f"transfer:{source_id}:{target_id}:{amount.cents}"
        )
        if claimed is not None:
            return self._replay(claimed)
        try:
            transaction = self._new(
                TransactionType.TRANSFER, amount, wallet_account(source_id), wallet_account(target_id),
                idempotency_key, TransactionStatus.CAPTURED,
            )
            with self._store.locked(source_id, target_id), PaymentUnitOfWork(self._store, [source_id, target_id]) as uow:
                uow.wallet(source_id).debit(amount)
                uow.wallet(target_id).credit(amount)
                uow.track(transaction)
                uow.post(
                    self._debit(transaction.id, wallet_account(source_id), amount),
                    self._credit(transaction.id, wallet_account(target_id), amount),
                )
                uow.commit()
        except Exception:
            self._store.idempotency.release(idempotency_key)
            raise
        return self._finish(idempotency_key, transaction.id, PaymentDeclinedError)

    def balance(self, wallet_id: str) -> Money:
        return self._store.wallet(wallet_id).balance

    def history(self, wallet_id: str) -> list[Transaction]:
        return self._store.history(wallet_id)
code/lld/payment_gateway_wallet/services.py — payment service
class PaymentService(MoneyService):
    """Merchant payments and refunds: the fraud chain, the fee split and the reversal."""

    def __init__(
        self,
        store: PaymentStore,
        fraud_chain: FraudRule,
        clock: Clock | None = None,
        ids: IdGenerator | None = None,
        listeners: Iterable[TransactionListener] = (),
    ) -> None:
        super().__init__(store, clock, ids, listeners)
        self._fraud = fraud_chain

    @staticmethod
    def fee_for(amount: Money) -> Money:
        """Integer basis points and floor division: the platform never rounds in its own favour."""
        return Money(amount.cents * MERCHANT_FEE_BASIS_POINTS // 10_000, amount.currency)

    def pay_merchant(self, idempotency_key: str, wallet_id: str, merchant_id: str, amount: Money) -> Transaction:
        """Fraud chain first, then one transaction that debits the wallet and splits the fee."""
        claimed = self._store.idempotency.claim(
            idempotency_key, f"pay:{wallet_id}:{merchant_id}:{amount.cents}"
        )
        if claimed is not None:
            return self._replay(claimed)
        destination = merchant_account(merchant_id)
        try:
            transaction = self._new(
                TransactionType.MERCHANT_PAYMENT, amount, wallet_account(wallet_id), destination, idempotency_key
            )
            decision = self._fraud.evaluate(
                FraudContext(wallet_id, amount, destination, self._clock.now(), tuple(self._store.history(wallet_id)))
            )
            with self._store.locked(wallet_id), PaymentUnitOfWork(self._store, [wallet_id]) as uow:
                uow.track(transaction)
                if not decision.allowed:
                    transaction.failure_reason = f"fraud:{decision.rule}:{decision.reason}"
                    transaction.transition_to(TransactionStatus.FAILED)
                else:
                    fee = self.fee_for(amount)
                    uow.wallet(wallet_id).debit(amount)  # the negative-balance guard lives here
                    transaction.transition_to(TransactionStatus.CAPTURED)
                    uow.post(
                        self._debit(transaction.id, wallet_account(wallet_id), amount),
                        self._credit(transaction.id, destination, amount - fee),
                        self._credit(transaction.id, FEES, fee),
                    )
                uow.commit()
        except Exception:
            self._store.idempotency.release(idempotency_key)
            raise
        return self._finish(idempotency_key, transaction.id, FraudRejectedError)

    def refund(self, idempotency_key: str, transaction_id: str, amount: Money | None = None) -> Refund:
        """Full or partial. The fee is reversed pro rata so the books stay symmetrical."""
        original = self._store.transaction(transaction_id)
        refund_amount = amount or original.refundable()
        claimed = self._store.idempotency.claim(
            idempotency_key, f"refund:{transaction_id}:{refund_amount.cents}"
        )
        if claimed is not None:
            return self._store.refund_for_key(idempotency_key)
        try:
            if refund_amount.cents <= 0 or refund_amount > original.refundable():
                raise ValidationError(
                    f"{original.id} can refund at most {original.refundable()}, asked for {refund_amount}"
                )
            wallet_id = wallet_id_of(original.source)
            refund = Refund(self._ids.next_id(), original.id, refund_amount, idempotency_key, self._clock.now())
            with self._store.locked(wallet_id), PaymentUnitOfWork(self._store, [wallet_id], [original.id]) as uow:
                current = uow.transaction(original.id)
                # Reverse the *incremental* fee, not the fee on this slice alone: floor
                # division on each slice would otherwise strand a cent in FEES when a
                # payment is refunded in parts. Full refunds always return exactly what
                # was charged, whatever the split.
                fee = self.fee_for(current.refunded + refund_amount) - self.fee_for(current.refunded)
                uow.wallet(wallet_id).credit(refund_amount)
                current.refunded = current.refunded + refund_amount
                current.transition_to(
                    TransactionStatus.REFUNDED
                    if current.refunded == current.amount
                    else TransactionStatus.PARTIALLY_REFUNDED
                )
                uow.refunds.append(refund)
                uow.post(
                    self._debit(current.id, current.destination, refund_amount - fee),
                    self._debit(current.id, FEES, fee),
                    self._credit(current.id, wallet_account(wallet_id), refund_amount),
                )
                uow.commit()
            self._store.idempotency.complete(idempotency_key, original.id)
        except Exception:
            self._store.idempotency.release(idempotency_key)
            raise
        self._notify(self._store.transaction(original.id))
        return refund

The webhook handler is three defences in fifteen lines: duplicate by event id, out of order by rank, early by parking and replay.

code/lld/payment_gateway_wallet/webhooks.py
class WebhookHandler:
    """Applies processor events to transactions, idempotently and in rank order."""

    def __init__(
        self,
        store: PaymentStore,
        clock: Clock | None = None,
        ids: IdGenerator | None = None,
        listeners: Iterable[TransactionListener] = (),
    ) -> None:
        self._store = store
        self._clock = clock or SystemClock()
        self._ids = ids or SequentialIdGenerator("L")
        self._listeners = list(listeners)
        self._lock = threading.Lock()
        self._seen: set[str] = set()
        self._parked: dict[str, list[WebhookEvent]] = {}

    def handle(self, event: WebhookEvent) -> str:
        """Returns applied, ignored, duplicate or deferred -- the four honest outcomes."""
        transaction = self._store.transaction_for_reference(event.psp_reference)
        if transaction is None:
            with self._lock:
                if event.event_id in self._seen:
                    return "duplicate"
                self._parked.setdefault(event.psp_reference, []).append(event)
            return "deferred"
        with self._lock:
            if event.event_id in self._seen:
                return "duplicate"
            self._seen.add(event.event_id)
        if STATUS_RANK[event.status] <= STATUS_RANK[transaction.status]:
            return "ignored"  # a late authorization after a capture, for example
        self._apply(transaction, event)
        return "applied"

    def replay(self, psp_reference: str) -> list[str]:
        """Drain the events that arrived before we had committed the transaction row."""
        with self._lock:
            events = self._parked.pop(psp_reference, [])
        return [self.handle(event) for event in sorted(events, key=lambda e: STATUS_RANK[e.status])]

    def parked(self) -> int:
        with self._lock:
            return sum(len(events) for events in self._parked.values())

    def _apply(self, transaction: Transaction, event: WebhookEvent) -> None:
        wallet_ids = [wallet_id_of(a) for a in (transaction.source, transaction.destination) if a.startswith("wallet:")]
        with self._store.locked(*wallet_ids), PaymentUnitOfWork(
            self._store, wallet_ids, [transaction.id]
        ) as uow:
            current = uow.transaction(transaction.id)
            if STATUS_RANK[event.status] <= STATUS_RANK[current.status]:
                return  # someone else applied it while we were taking the lock
            if event.status is TransactionStatus.CAPTURED and current.type is TransactionType.TOP_UP:
                wallet_id = wallet_id_of(current.destination)
                uow.wallet(wallet_id).credit(current.amount)
                uow.post(
                    LedgerEntry(self._ids.next_id(), current.id, PSP_CLEARING, EntryDirection.DEBIT, current.amount, event.at),
                    LedgerEntry(self._ids.next_id(), current.id, wallet_account(wallet_id), EntryDirection.CREDIT, current.amount, event.at),
                )
            current.transition_to(event.status)
            uow.commit()
        settled = self._store.transaction(transaction.id)
        for listener in self._listeners:
            listener.on_transaction(settled)

The demo runs a full day: a card top-up settled by webhook, a duplicate and a late webhook, an early webhook, a retried transfer, a merchant payment with its fee, a partial refund, and a blocked payment.

code/lld/payment_gateway_wallet/demo.py
"""One day of a wallet: top-up by card, webhooks, a transfer, a payment, a refund."""

from common import FakeClock, Money, SequentialIdGenerator
from lld.payment_gateway_wallet.fraud import build_default_chain
from lld.payment_gateway_wallet.ledger import FEES, merchant_account, wallet_account
from lld.payment_gateway_wallet.models import (
    FraudRejectedError,
    IdempotencyConflictError,
    PaymentMethod,
    PaymentMethodType,
    TransactionStatus,
    WebhookEvent,
)
from lld.payment_gateway_wallet.psp import PaymentProcessorFactory
from lld.payment_gateway_wallet.services import PaymentService, WalletService
from lld.payment_gateway_wallet.store import PaymentStore
from lld.payment_gateway_wallet.webhooks import WebhookHandler


def main() -> None:
    clock = FakeClock(start=1_700_000_000)
    store = PaymentStore()
    webhooks = WebhookHandler(store, clock=clock, ids=SequentialIdGenerator("L"))
    wallets = WalletService(
        store, PaymentProcessorFactory.with_stubs(declined_tokens={"tok_dead"}),
        clock=clock, ids=SequentialIdGenerator("T"), on_reference=webhooks.replay,
    )
    payments = PaymentService(
        store, build_default_chain(ceiling=Money.of("500.00"), daily_cap=Money.of("800.00")),
        clock=clock, ids=SequentialIdGenerator("P"),
    )
    card = PaymentMethod("pm-1", "ada", PaymentMethodType.CARD, "tok_live", "Visa 4242")
    ada = wallets.open_wallet("ada", Money.of("200.00"))
    bob = wallets.open_wallet("bob", Money.of("50.00"))
    print(f"opened {ada.id} with {ada.balance} and {bob.id} with {bob.balance}")

    top_up = wallets.top_up("idem-topup-1", ada.id, card, Money.of("300.00"))
    print(f"top-up {top_up.id} {top_up.status} at the card network, balance still {wallets.balance(ada.id)}")
    print(f"capture webhook: {webhooks.handle(WebhookEvent('evt-1', top_up.psp_reference or '', TransactionStatus.CAPTURED, clock.now()))}, balance {wallets.balance(ada.id)}")
    print(f"same webhook again: {webhooks.handle(WebhookEvent('evt-1', top_up.psp_reference or '', TransactionStatus.CAPTURED, clock.now()))}")
    print(f"late authorized webhook: {webhooks.handle(WebhookEvent('evt-2', top_up.psp_reference or '', TransactionStatus.AUTHORIZED, clock.now()))}")

    early = WebhookEvent("evt-3", "auth_0002", TransactionStatus.CAPTURED, clock.now())
    print(f"webhook for the next authorization arrives first: {webhooks.handle(early)}, parked {webhooks.parked()}")
    second = wallets.top_up("idem-topup-2", ada.id, card, Money.of("100.00"))
    print(f"top-up {second.id} committed, parked drained to {webhooks.parked()}, balance {wallets.balance(ada.id)}")

    transfer = wallets.transfer("idem-xfer-1", ada.id, bob.id, Money.of("120.00"))
    print(f"transfer {transfer.id}: ada {wallets.balance(ada.id)}, bob {wallets.balance(bob.id)}")
    print(f"retry of the same key: {wallets.transfer('idem-xfer-1', ada.id, bob.id, Money.of('120.00')).id}, ada still {wallets.balance(ada.id)}")
    try:
        wallets.transfer("idem-xfer-1", ada.id, bob.id, Money.of("5.00"))
    except IdempotencyConflictError as exc:
        print(f"same key, different amount: {exc}")

    payment = payments.pay_merchant("idem-pay-1", ada.id, "cafe", Money.of("60.00"))
    fee = PaymentService.fee_for(payment.amount)
    print(f"paid cafe {payment.amount}: merchant {store.ledger.balance(merchant_account('cafe'))}, fees {store.ledger.balance(FEES)} (2% of {payment.amount} is {fee})")
    refund = payments.refund("idem-ref-1", payment.id, Money.of("25.00"))
    print(f"refund {refund.amount}: transaction now {store.transaction(payment.id).status}, ada {wallets.balance(ada.id)}")
    try:
        payments.pay_merchant("idem-pay-2", ada.id, "cafe", Money.of("600.00"))
    except FraudRejectedError as exc:
        print(f"fraud chain: {exc}")
    print(f"ledger balanced: {store.ledger.is_balanced()} over {store.ledger.size()} entries")
    print(f"wallet ledger account matches the wallet: {store.ledger.balance(wallet_account(ada.id))} == {wallets.balance(ada.id)}")


if __name__ == "__main__":
    main()

Running python -m lld.payment_gateway_wallet.demo prints:

opened T-1 with 200.00 USD and T-5 with 50.00 USD
top-up T-9 authorized at the card network, balance still 200.00 USD
capture webhook: applied, balance 500.00 USD
same webhook again: duplicate
late authorized webhook: ignored
webhook for the next authorization arrives first: deferred, parked 1
top-up T-10 committed, parked drained to 0, balance 600.00 USD
transfer T-11: ada 480.00 USD, bob 170.00 USD
retry of the same key: T-11, ada still 480.00 USD
same key, different amount: key idem-xfer-1 was already used for a different request
paid cafe 60.00 USD: merchant 58.80 USD, fees 1.20 USD (2% of 60.00 USD is 1.20 USD)
refund 25.00 USD: transaction now partially_refunded, ada 445.00 USD
fraud chain: P-9 failed: fraud:amount_ceiling:600.00 USD exceeds the 500.00 USD ceiling
ledger balanced: True over 16 entries
wallet ledger account matches the wallet: 445.00 USD == 445.00 USD

The last two lines are the ones to point at. The whole book nets to zero, and the ledger's view of the wallet agrees with the wallet's own balance — two independent statements of the same truth.

Concurrency and edge cases

Which lock protects what, and in what order.

  1. PaymentStore._wallet_locks[wallet_id] is a reentrant lock guarding one wallet's balance, reservations and the transactions that touch it. It is held across the whole read-modify-write.
  2. PaymentStore.locked(*wallet_ids) sorts the ids before acquiring. A transfer A to B and a simultaneous B to A therefore request the same lock first and queue; without the sort they would take one lock each and wait forever. That single sorted call is the deadlock answer, and interviewers ask for it by name.
  3. PaymentStore._registry_lock guards the dictionaries and is held for a lookup or a write batch. It is a leaf: nothing else is acquired underneath it.
  4. IdempotencyStore._lock and Ledger._lock are likewise leaves.

No processor call under a lock. top_up and withdraw commit, release, call the processor, then open a second transaction. An uncontended mutex is around 17 ns and a same-datacenter round trip around 500 µs (see the latency cheatsheet) — about 30 000 times longer, and a real card network is tens of milliseconds further away. Holding a wallet lock across that would make one slow processor look like a broken wallet.

Exactly-once, honestly. Delivery is at-least-once; the handlers are idempotent; together that is effectively-once. Three separate keys do the work: the client's idempotency key for outbound calls, the processor's event_id for inbound webhooks, and STATUS_RANK for ordering. A retry after a timeout is safe because the key is claimed before the processor is called.

Webhook arriving before the commit. The processor can call back faster than we can finish our own transaction. handle finds no transaction for that reference, parks the event, and returns deferred; WalletService calls replay right after committing the authorization. Nothing is lost, nothing is applied twice.

Rounding. The fee is amount x 200 / 10000 with floor division on integer cents, so a 60.00 payment yields exactly 1.20 in fees and 58.80 to the merchant. A refund reverses the incremental fee — fee_for(refunded_so_far + this_slice) - fee_for(refunded_so_far) — not the fee on the slice alone. The difference only shows up on awkward amounts, and it is the kind of thing an interviewer probes: 0.51 at 2% is a 1-cent fee, and floor division on two 25-and-26-cent refunds would reverse 0 both times and strand that cent in the fee account forever. Note that is_balanced() would not have caught it — the book still nets to zero when a cent sits in the wrong account, so you need the stronger assertion that a fully refunded payment leaves the merchant and the fee account at zero.

Common mistake

Treating the idempotency key as a cache of the response. It is a claim on the operation: you take it before you call the processor, you keep it if the payment fails for business reasons, and you release it only when nothing happened. Candidates who look the key up after the charge have built a system that double-charges on exactly the retry the key was supposed to protect.

Other edge cases handled: the same key with a different payload (conflict); a key whose first request is still running (conflict); a wallet debited below zero (refused before any entry is written); a refund larger than the refundable remainder; a second refund that completes the total and flips the status to REFUNDED; a fraud block recorded as a FAILED transaction so the replay re-raises the same error; a declined withdrawal releasing its reservation, and a withdrawal whose rail raises rather than declining releasing it too, because a hold with no settlement behind it is money the user cannot spend; an imbalanced posting rejected with the exact residual in the message.

Extensibility and follow-ups

  • A new rail (bank transfer, buy-now-pay-later): one adapter and one registry entry. The stub clients show the shape a vendor SDK arrives in; nothing above PaymentProcessor changes.
  • A new fraud rule (device fingerprint, geo velocity): one FraudRule subclass and one set_next call. Rule order becomes configuration, and a scoring rule that combines signals is just a rule that reads more of the context.
  • Chargebacks: a new transaction type whose posting reverses the original and debits a chargeback_losses account. The ledger already supports it; only the state table grows.
  • Processor timeout with an unknown outcome: the worst case in payments. Keep the key claimed, mark the transaction INITIATED, and reconcile from the processor's report rather than guessing. Never auto-retry an unknown authorization without the same key.
  • Multi-currency: a currency on every account, an FX rate snapshot on the transaction, and postings that stay within one currency per leg.
  • Scale: a million payments a day is 1M / 10^5 = 12 writes per second on average, peak roughly three times that — trivial for one primary, which handles 5k to 20k writes per second. The real limit is contention on a hot merchant wallet, so shard the merchant balance into buckets and sum them on read.

Interview tip

Write assert sum(debits) == sum(credits) on the board early and say "this is the invariant; everything else in my design exists to protect it." It converts a sprawling problem into one you can reason about out loud, and it gives every later decision — the Unit of Work, the ordering of the fraud check, the webhook rank — an obvious justification.

Tests

tests/test_payment_gateway_wallet.py has 21 cases, and every one that moves money ends by asserting ledger.is_balanced(). The three to walk through are idempotent replay, the two-directional concurrency test, and the webhook triple:

code/lld/payment_gateway_wallet/tests/test_payment_gateway_wallet.py — idempotency
def test_a_repeated_idempotency_key_returns_the_stored_result(clock: FakeClock) -> None:
    rig = Rig(clock)
    ada = rig.wallets.open_wallet("ada", Money.of("200.00"))
    bob = rig.wallets.open_wallet("bob", Money.of("0.00"))

    first = rig.wallets.transfer("k1", ada.id, bob.id, Money.of("30.00"))
    replayed = rig.wallets.transfer("k1", ada.id, bob.id, Money.of("30.00"))

    assert replayed.id == first.id  # the stored transaction, not a second one
    assert rig.wallets.balance(ada.id) == Money.of("170.00")  # debited exactly once
    assert rig.store.ledger.size() == 4  # two opening entries plus one transfer posting

    with pytest.raises(IdempotencyConflictError, match="different request"):
        rig.wallets.transfer("k1", ada.id, bob.id, Money.of("31.00"))
code/lld/payment_gateway_wallet/tests/test_payment_gateway_wallet.py — concurrency
def test_concurrent_transfers_in_both_directions_conserve_money_and_never_deadlock(clock: FakeClock) -> None:
    rig = Rig(clock)
    ada = rig.wallets.open_wallet("ada", Money.of("100.00"))
    bob = rig.wallets.open_wallet("bob", Money.of("100.00"))
    total_before = rig.wallets.balance(ada.id) + rig.wallets.balance(bob.id)

    def move(i: int) -> bool:
        source, target = (ada.id, bob.id) if i % 2 == 0 else (bob.id, ada.id)
        try:
            rig.wallets.transfer(f"k{i}", source, target, Money.of("7.00"))
        except InsufficientBalanceError:
            return False
        return True

    with ThreadPoolExecutor(max_workers=8) as pool:
        results = list(pool.map(move, range(40)))

    ada_balance, bob_balance = rig.wallets.balance(ada.id), rig.wallets.balance(bob.id)
    assert ada_balance + bob_balance == total_before  # no cent created or destroyed
    assert ada_balance.cents >= 0 and bob_balance.cents >= 0
    assert rig.store.ledger.is_balanced()
    assert rig.store.ledger.size() == 4 + 2 * results.count(True)  # 2 openings + 2 per transfer
code/lld/payment_gateway_wallet/tests/test_payment_gateway_wallet.py — webhooks
def test_webhooks_are_deduplicated_ordered_and_replayed(clock: FakeClock) -> None:
    rig = Rig(clock)
    ada = rig.wallets.open_wallet("ada", Money.of("0.00"))

    early = WebhookEvent("evt-early", "auth_0002", TransactionStatus.CAPTURED, clock.now())
    assert rig.webhooks.handle(early) == "deferred"  # we have not authorized this yet

    top_up = rig.wallets.top_up("k1", ada.id, CARD, Money.of("100.00"))
    reference = top_up.psp_reference or ""
    assert top_up.status is TransactionStatus.AUTHORIZED
    assert rig.wallets.balance(ada.id) == Money(0)  # authorization is not settlement

    assert rig.webhooks.handle(WebhookEvent("evt-1", reference, TransactionStatus.CAPTURED, clock.now())) == "applied"
    assert rig.wallets.balance(ada.id) == Money.of("100.00")
    assert rig.webhooks.handle(WebhookEvent("evt-1", reference, TransactionStatus.CAPTURED, clock.now())) == "duplicate"
    assert rig.webhooks.handle(WebhookEvent("evt-2", reference, TransactionStatus.AUTHORIZED, clock.now())) == "ignored"
    assert rig.wallets.balance(ada.id) == Money.of("100.00")  # neither moved money again

    second = rig.wallets.top_up("k2", ada.id, CARD, Money.of("60.00"))  # reference auth_0002
    assert rig.webhooks.parked() == 0  # the early event was replayed on commit
    assert rig.store.transaction(second.id).status is TransactionStatus.CAPTURED
    assert rig.wallets.balance(ada.id) == Money.of("160.00")
    assert rig.store.ledger.is_balanced()

The rest cover: a transfer keeping the wallet balance and its ledger account in agreement; a wallet refusing to go negative and releasing the key so a retry works; each of the four fraud rules blocking with its own reason via parametrize; a fraud block recorded as a failed transaction that replays as the same error; partial then full refund walking the state machine and returning the fee; a fee of one cent surviving a refund split into two slices that each round to zero; a declined processor releasing the reservation and a rail that raises doing the same; the ledger rejecting a posting that is out by one cent; and all three adapters normalising their vendor responses. Run them with uv run pytest code/lld/payment_gateway_wallet -q.

45-minute pacing

Minutes What to do What to say or write
0-5 Clarify Who supplies idempotency keys? Are webhooks ordered? Is authorize the same as capture? Out of scope: KYC, FX, chargeback workflow.
5-10 Invariants Write "debits equal credits" and "a wallet never goes negative" on the board. Everything else follows from those two lines.
10-17 Entities and lifecycle Wallet, Transaction, LedgerEntry, Ledger, PaymentMethod. Draw the six-state diagram.
17-32 Code Ledger.check_balanced, Wallet.debit, IdempotencyStore.claim, then pay_merchant end to end inside the Unit of Work.
32-39 Concurrency Lock ordering by wallet id, no processor call under a lock, and the webhook triple: duplicate, out of order, early.
39-45 Extensions A new rail as an adapter, a new fraud rule as a chain link, chargebacks, and the unknown-outcome timeout.