Payment Processing System — Implementation¶
A payment core that cannot double-charge and cannot over-refund:
Decimalmoney, client idempotency keys forwarded to the gateway, an explicit payment state machine with anUNKNOWNstate for timeouts, retries that are only safe because of the key, refund holds, and a transactional outbox. Single file, stdlib only, Python 3.10+.
🧭 The flow in one picture¶
pay(request with idempotency_key)
│ (merchant_id, key) seen before? same request → return stored payment
│ different request → IdempotencyConflictError
│ still PROCESSING → RequestInProgressError (409)
▼
PROCESSING ──risk rule rejects──────────────────────────▶ FAILED
│
│ gateway.charge(key, amount, token) retried with backoff on Unavailable / Timeout
├── APPROVED ─────────────────────────────────────────▶ SUCCEEDED ──refund──▶ PARTIALLY_REFUNDED ──▶ REFUNDED
├── DECLINED (never retried) ─────────────────────────▶ FAILED
└── still timing out after retries ───────────────────▶ UNKNOWN ──reconcile()──▶ SUCCEEDED | FAILED
Every transition appends an OutboxEvent in the same critical section (same DB transaction in production).
🔑 Key design decisions¶
| Decision | Why |
|---|---|
Money(Decimal, Currency), quantized to the minor unit |
0.1 + 0.2 != 0.3 in float. Money rejects floats, NaN and excess precision (10.001 USD, 1.5 JPY), refuses cross-currency arithmetic, and rounds fees half-up to the minor unit. |
Idempotency key scoped to (merchant_id, key) and compared against the full request |
A retry returns the stored payment. Reusing a key for a different amount is a client bug and gets IdempotencyConflictError, never a silent second payment. |
| Claim the key before calling the gateway | The payment record is inserted (status PROCESSING) under the service lock, so a concurrent duplicate sees it and gets RequestInProgressError. The naive "check Redis → charge → store result" lets two concurrent requests both miss and both charge. |
| The key is forwarded to the gateway | Our own dedup cannot help when our request timed out after the gateway charged the card. Forwarding the key means a retry (or reconcile()) gets the original outcome back instead of a second charge. That is what makes retrying a timeout safe. |
| Two gateway error types | GatewayUnavailableError = definitely not processed. GatewayTimeoutError = outcome unknown. Both are retried only because the key travels with the request. A decline is a business answer and is never retried. |
UNKNOWN state + reconcile() |
After retries are exhausted we do not guess. The payment is UNKNOWN (not refundable, not shown as failed to the merchant) until reconcile re-sends with the same key. Marking it FAILED would invite the customer to pay again while the first charge may have gone through. |
Transition table (ALLOWED_TRANSITIONS) |
One place to read the lifecycle; Payment.transition raises on anything else, e.g. FAILED → SUCCEEDED. The original code had a status setter that accepted any value. |
| Refund holds | Under the payment's lock we check refundable = amount − succeeded − pending and append a PENDING refund before calling the gateway. Concurrent refunds therefore cannot sum past the captured amount. A declined refund releases the hold; a timed-out refund keeps it until reconciled. |
| Transactional outbox | State change and event are written atomically, then Outbox.relay publishes in order, at-least-once. No "DB committed but Kafka publish failed" gap, and no event for a change that rolled back. |
| No lock held across gateway I/O | PaymentService._lock guards the maps only; Payment.lock guards one payment's state. Gateway calls happen outside both, so a slow provider does not serialise unrelated payments. |
🔒 Concurrency walkthrough¶
| Lock | Guards | Order |
|---|---|---|
PaymentService._lock |
payment / key / refund-key maps | innermost; never held across I/O |
Payment.lock |
one payment's status, refunds list | taken before _lock when both are needed |
SimulatedGateway._lock, VelocityRule._lock, Outbox._lock |
their own state | leaf locks |
Races handled and tested: concurrent duplicate pay() with the same key (exactly one gateway charge), 25 concurrent 10.00 refunds against a 100.00 payment (exactly 10 succeed), concurrent reconcile() and a late gateway answer (_attempt_charge re-checks the status under the lock and drops a stale result).
🧩 Where to extend¶
| Extension point | Built-in | Typical "now add X" |
|---|---|---|
PaymentGateway |
SimulatedGateway (scriptable faults: unavailable, timeout_before, timeout_after) |
Stripe / Razorpay adapters; a GatewayRouter choosing by currency or cost; a circuit breaker wrapper that fails over to a second provider only for requests that never reached the first one |
RiskRule.evaluate(request) -> reason \| None |
AmountLimitRule, VelocityRule (per customer sliding window) |
Device fingerprint, BIN country mismatch, ML score with a manual-review band |
RetryPolicy |
Exponential backoff, full jitter, injectable sleep |
Per-gateway budgets, retry only idempotent operations |
PaymentStatus |
— | Auth/capture split (AUTHORIZED → CAPTURED, VOIDED), DISPUTED for chargebacks |
Not built, deliberately: authorization vs capture, multi-currency FX, a double-entry ledger, chargebacks. Each is discussed in the interview questions.
💻 Full source¶
"""
Payment Processing System - Low Level Design
============================================
What this models (and what interviewers actually probe):
* Money is Decimal, quantized to the currency's minor unit. Never float.
* Every charge and refund carries a client idempotency key. A retry with the
same key returns the original result; the same key with a different request
is rejected; a duplicate that arrives while the first is in flight gets 409.
* The idempotency key is forwarded to the gateway, which is what makes retrying
a *timeout* safe: the gateway dedupes, so a retry can never double-charge.
* Payment state machine with an explicit transition table, including UNKNOWN
(gateway timed out, outcome not known) which is resolved by reconcile().
* Refund invariant: succeeded + in-flight refunds <= captured amount, enforced
under the payment's lock *before* calling the gateway.
* Transactional outbox: each state change and its event are written under the
same lock (one DB transaction in production); a relay publishes them
at-least-once.
Stdlib only, Python 3.10+.
"""
from __future__ import annotations
import itertools
import random
import threading
import time
from abc import ABC, abstractmethod
from collections import deque
from dataclasses import dataclass, field
from decimal import ROUND_HALF_UP, Decimal
from enum import Enum
from typing import Callable, Deque, Dict, List, Optional, Tuple
# ---------------------------------------------------------------- errors
class PaymentError(Exception):
pass
class ValidationError(PaymentError):
pass
class IdempotencyConflictError(PaymentError):
"""Same idempotency key reused with a different request (HTTP 422 at Stripe)."""
class RequestInProgressError(PaymentError):
"""A request with this idempotency key is still being processed (HTTP 409)."""
class IllegalTransitionError(PaymentError):
pass
class RefundError(PaymentError):
pass
class NotFoundError(PaymentError):
pass
# Gateway-side failures. The distinction matters: one is safe to retry blindly,
# the other is only safe to retry because the idempotency key travels with it.
class GatewayUnavailableError(Exception):
"""Request definitely not processed (connection refused, 503 before acceptance)."""
class GatewayTimeoutError(Exception):
"""Request may or may not have been processed. Outcome unknown."""
# ---------------------------------------------------------------- money
class Currency(Enum):
USD = ("USD", 2)
EUR = ("EUR", 2)
INR = ("INR", 2)
JPY = ("JPY", 0) # no minor unit: 1.5 JPY is not a valid amount
@property
def code(self) -> str:
return self.value[0]
@property
def exponent(self) -> int:
return self.value[1]
@dataclass(frozen=True)
class Money:
amount: Decimal
currency: Currency
def __post_init__(self) -> None:
if not isinstance(self.amount, Decimal):
raise ValidationError("amount must be a Decimal (construct from a string, never a float)")
if not self.amount.is_finite():
raise ValidationError("amount must be finite")
quantum = Decimal(1).scaleb(-self.currency.exponent)
if self.amount != self.amount.quantize(quantum):
raise ValidationError(f"{self.amount} has more precision than {self.currency.code} allows")
@classmethod
def of(cls, amount: str, currency: Currency) -> "Money":
return cls(Decimal(amount), currency)
@classmethod
def zero(cls, currency: Currency) -> "Money":
return cls(Decimal(0).quantize(Decimal(1).scaleb(-currency.exponent)), currency)
def _same(self, other: "Money") -> None:
if self.currency is not other.currency:
raise ValidationError(f"currency mismatch: {self.currency.code} vs {other.currency.code}")
def __add__(self, other: "Money") -> "Money":
self._same(other)
return Money(self.amount + other.amount, self.currency)
def __sub__(self, other: "Money") -> "Money":
self._same(other)
return Money(self.amount - other.amount, self.currency)
def __lt__(self, other: "Money") -> bool:
self._same(other)
return self.amount < other.amount
def __le__(self, other: "Money") -> bool:
self._same(other)
return self.amount <= other.amount
def fee(self, basis_points: int) -> "Money":
quantum = Decimal(1).scaleb(-self.currency.exponent)
return Money((self.amount * basis_points / Decimal(10_000)).quantize(quantum, ROUND_HALF_UP),
self.currency)
def __str__(self) -> str:
return f"{self.amount} {self.currency.code}"
# ---------------------------------------------------------------- domain
class PaymentStatus(Enum):
PROCESSING = "processing" # record created, gateway call in progress
SUCCEEDED = "succeeded"
FAILED = "failed" # declined, or rejected by risk rules
UNKNOWN = "unknown" # gateway timed out; reconcile() decides
PARTIALLY_REFUNDED = "partially_refunded"
REFUNDED = "refunded"
ALLOWED_TRANSITIONS: Dict[PaymentStatus, set] = {
PaymentStatus.PROCESSING: {PaymentStatus.SUCCEEDED, PaymentStatus.FAILED, PaymentStatus.UNKNOWN},
PaymentStatus.UNKNOWN: {PaymentStatus.SUCCEEDED, PaymentStatus.FAILED},
PaymentStatus.SUCCEEDED: {PaymentStatus.PARTIALLY_REFUNDED, PaymentStatus.REFUNDED},
PaymentStatus.PARTIALLY_REFUNDED: {PaymentStatus.PARTIALLY_REFUNDED, PaymentStatus.REFUNDED},
PaymentStatus.FAILED: set(),
PaymentStatus.REFUNDED: set(),
}
REFUNDABLE = {PaymentStatus.SUCCEEDED, PaymentStatus.PARTIALLY_REFUNDED}
class RefundStatus(Enum):
PENDING = "pending" # amount is held against the payment; gateway call in flight or unknown
SUCCEEDED = "succeeded"
FAILED = "failed"
@dataclass(frozen=True)
class Merchant:
merchant_id: str
name: str
fee_bps: int = 250 # 2.50%
@dataclass(frozen=True)
class PaymentRequest:
"""Everything that defines 'the same request' for idempotency purposes."""
idempotency_key: str
merchant_id: str
customer_id: str
amount: Money
payment_token: str # tokenized card/UPI handle; raw PAN never reaches us (PCI scope)
@dataclass
class Refund:
refund_id: str
payment_id: str
idempotency_key: str
amount: Money
reason: str
status: RefundStatus = RefundStatus.PENDING
gateway_ref: Optional[str] = None
@dataclass
class Payment:
payment_id: str
request: PaymentRequest
status: PaymentStatus = PaymentStatus.PROCESSING
gateway_ref: Optional[str] = None
failure_reason: Optional[str] = None
fee: Optional[Money] = None
refunds: List[Refund] = field(default_factory=list)
history: List[PaymentStatus] = field(default_factory=lambda: [PaymentStatus.PROCESSING])
lock: threading.Lock = field(default_factory=threading.Lock, repr=False, compare=False)
@property
def amount(self) -> Money:
return self.request.amount
def refunded(self) -> Money:
return _sum(self.amount.currency, (r.amount for r in self.refunds if r.status is RefundStatus.SUCCEEDED))
def held_for_refunds(self) -> Money:
return _sum(self.amount.currency, (r.amount for r in self.refunds if r.status is RefundStatus.PENDING))
def refundable(self) -> Money:
if self.status not in REFUNDABLE:
return Money.zero(self.amount.currency)
return self.amount - self.refunded() - self.held_for_refunds()
def transition(self, to: PaymentStatus) -> None:
"""Caller holds self.lock."""
if to not in ALLOWED_TRANSITIONS[self.status]:
raise IllegalTransitionError(f"{self.payment_id}: {self.status.value} -> {to.value}")
self.status = to
self.history.append(to)
def _sum(currency: Currency, amounts) -> Money:
total = Money.zero(currency)
for a in amounts:
total = total + a
return total
# ---------------------------------------------------------------- gateway
class GatewayDecision(Enum):
APPROVED = "approved"
DECLINED = "declined"
@dataclass(frozen=True)
class GatewayResult:
decision: GatewayDecision
reference: str
reason: str = ""
class PaymentGateway(ABC):
"""Adapter over Stripe / Adyen / Razorpay. Implementations MUST forward the idempotency
key to the provider so that a retried request is deduplicated on their side."""
@abstractmethod
def charge(self, idempotency_key: str, amount: Money, payment_token: str) -> GatewayResult: ...
@abstractmethod
def refund(self, idempotency_key: str, charge_reference: str, amount: Money) -> GatewayResult: ...
class SimulatedGateway(PaymentGateway):
"""Deterministic in-memory gateway with scriptable faults.
Faults (consumed one per call): "unavailable" (not processed), "timeout_before"
(not processed, caller sees a timeout), "timeout_after" (processed, caller still sees
a timeout: the case that causes double charges without idempotency keys).
Tokens starting with "tok_decline" are declined.
"""
def __init__(self) -> None:
self._lock = threading.Lock()
self._results: Dict[str, GatewayResult] = {}
self._faults: Deque[str] = deque()
self._refs = itertools.count(1)
self.money_moved: List[Tuple[str, Money]] = [] # what actually hit the card network
def inject(self, *faults: str) -> None:
with self._lock:
self._faults.extend(faults)
def charge(self, idempotency_key: str, amount: Money, payment_token: str) -> GatewayResult:
def decide() -> GatewayResult:
if payment_token.startswith("tok_decline"):
return GatewayResult(GatewayDecision.DECLINED, f"ch_{next(self._refs)}", "card_declined")
self.money_moved.append(("charge", amount))
return GatewayResult(GatewayDecision.APPROVED, f"ch_{next(self._refs)}")
return self._call(f"charge:{idempotency_key}", decide)
def refund(self, idempotency_key: str, charge_reference: str, amount: Money) -> GatewayResult:
def decide() -> GatewayResult:
self.money_moved.append(("refund", amount))
return GatewayResult(GatewayDecision.APPROVED, f"re_{next(self._refs)}")
return self._call(f"refund:{idempotency_key}", decide)
def _call(self, key: str, decide: Callable[[], GatewayResult]) -> GatewayResult:
with self._lock:
fault = self._faults.popleft() if self._faults else None
if fault == "unavailable":
raise GatewayUnavailableError("connection refused")
if fault == "timeout_before":
raise GatewayTimeoutError("read timeout")
if key not in self._results: # provider-side idempotency
self._results[key] = decide()
if fault == "timeout_after":
raise GatewayTimeoutError("read timeout (charge went through)")
return self._results[key]
# ---------------------------------------------------------------- retries
class RetryPolicy:
"""Exponential backoff with full jitter. Only for errors the caller says are retryable."""
def __init__(self, max_attempts: int = 3, base_delay_s: float = 0.2, max_delay_s: float = 5.0,
sleep: Callable[[float], None] = time.sleep, rng: Optional[random.Random] = None):
if max_attempts < 1:
raise ValueError("max_attempts must be >= 1")
self.max_attempts = max_attempts
self._base, self._cap = base_delay_s, max_delay_s
self._sleep = sleep
self._rng = rng or random.Random()
def run(self, fn: Callable[[], GatewayResult]) -> GatewayResult:
for attempt in range(self.max_attempts):
try:
return fn()
except (GatewayUnavailableError, GatewayTimeoutError):
if attempt == self.max_attempts - 1:
raise
self._sleep(self._rng.uniform(0, min(self._cap, self._base * 2 ** attempt)))
raise AssertionError("unreachable")
# ---------------------------------------------------------------- risk rules
class RiskRule(ABC):
"""Pre-authorization check. Return a rejection reason, or None to allow."""
@abstractmethod
def evaluate(self, request: PaymentRequest) -> Optional[str]: ...
class AmountLimitRule(RiskRule):
def __init__(self, limit: Money):
self._limit = limit
def evaluate(self, request: PaymentRequest) -> Optional[str]:
if request.amount.currency is self._limit.currency and self._limit < request.amount:
return f"amount exceeds {self._limit}"
return None
class VelocityRule(RiskRule):
"""At most `max_attempts` payment attempts per customer per sliding window."""
def __init__(self, max_attempts: int, window_s: float, clock: Callable[[], float] = time.monotonic):
self._max, self._window, self._clock = max_attempts, window_s, clock
self._lock = threading.Lock()
self._attempts: Dict[str, Deque[float]] = {}
def evaluate(self, request: PaymentRequest) -> Optional[str]:
now = self._clock()
with self._lock:
q = self._attempts.setdefault(request.customer_id, deque())
while q and q[0] <= now - self._window:
q.popleft()
if len(q) >= self._max:
return f"more than {self._max} attempts in {self._window:.0f}s"
q.append(now) # counts attempts, not successes: card-testing bots mostly fail
return None
# ---------------------------------------------------------------- outbox
@dataclass(frozen=True)
class OutboxEvent:
event_id: int
type: str
aggregate_id: str
payload: Dict[str, str]
class Outbox:
"""Stands in for an `outbox` table written in the same transaction as the state change."""
def __init__(self) -> None:
self._lock = threading.Lock()
self._pending: Dict[int, OutboxEvent] = {}
self._ids = itertools.count(1)
def append(self, type_: str, aggregate_id: str, **payload: str) -> None:
with self._lock:
event = OutboxEvent(next(self._ids), type_, aggregate_id, payload)
self._pending[event.event_id] = event
def relay(self, publish: Callable[[OutboxEvent], None]) -> int:
"""Publish pending events in order; stop at the first failure. At-least-once:
a crash after publish but before removal re-sends, so consumers dedupe on event_id."""
with self._lock:
batch = sorted(self._pending.values(), key=lambda e: e.event_id)
sent = 0
for event in batch:
try:
publish(event)
except Exception:
break
with self._lock:
self._pending.pop(event.event_id, None)
sent += 1
return sent
def pending(self) -> List[OutboxEvent]:
with self._lock:
return sorted(self._pending.values(), key=lambda e: e.event_id)
# ---------------------------------------------------------------- service
class PaymentService:
def __init__(self, gateway: PaymentGateway, retry: Optional[RetryPolicy] = None,
risk_rules: Optional[List[RiskRule]] = None, outbox: Optional[Outbox] = None):
self._gateway = gateway
self._retry = retry or RetryPolicy()
self._rules = list(risk_rules or [])
self.outbox = outbox or Outbox()
self._lock = threading.Lock() # guards the maps below, never held across I/O
self._merchants: Dict[str, Merchant] = {}
self._payments: Dict[str, Payment] = {}
self._by_key: Dict[Tuple[str, str], Payment] = {} # (merchant_id, key) -> payment
self._refund_keys: Dict[Tuple[str, str], Refund] = {} # (payment_id, key) -> refund
self._ids = itertools.count(1)
def add_merchant(self, merchant: Merchant) -> None:
with self._lock:
self._merchants[merchant.merchant_id] = merchant
# ---- charge ------------------------------------------------------------
def pay(self, request: PaymentRequest) -> Payment:
"""Create-and-charge, idempotent on (merchant_id, idempotency_key)."""
if request.amount.amount <= 0:
raise ValidationError("amount must be positive")
if not request.idempotency_key:
raise ValidationError("idempotency_key is required")
with self._lock:
merchant = self._merchants.get(request.merchant_id)
if merchant is None:
raise NotFoundError(f"merchant {request.merchant_id}")
existing = self._by_key.get((request.merchant_id, request.idempotency_key))
if existing is not None:
if existing.request != request:
raise IdempotencyConflictError(f"key {request.idempotency_key} reused with a different request")
if existing.status is PaymentStatus.PROCESSING:
raise RequestInProgressError(request.idempotency_key)
return existing
payment = Payment(f"pay_{next(self._ids)}", request)
self._payments[payment.payment_id] = payment
self._by_key[(request.merchant_id, request.idempotency_key)] = payment
# From here on, a concurrent duplicate sees PROCESSING and gets 409.
with payment.lock:
for rule in self._rules:
reason = rule.evaluate(request)
if reason:
self._finish_charge(payment, PaymentStatus.FAILED, reason=f"risk: {reason}")
return payment
self._attempt_charge(payment, merchant)
return payment
def reconcile(self) -> List[Payment]:
"""Resolve UNKNOWN payments and PENDING refunds by re-sending with the *same* key.
The gateway either returns the original outcome or processes it once now."""
with self._lock:
unknown = [p for p in self._payments.values() if p.status is PaymentStatus.UNKNOWN]
pending = [(p, r) for p in self._payments.values() for r in p.refunds
if r.status is RefundStatus.PENDING]
for p in unknown:
self._attempt_charge(p, self._merchants[p.request.merchant_id])
for p, r in pending:
self._attempt_refund(p, r)
return unknown
def _attempt_charge(self, payment: Payment, merchant: Merchant) -> None:
req = payment.request
try:
result = self._retry.run(lambda: self._gateway.charge(req.idempotency_key, req.amount,
req.payment_token))
except (GatewayUnavailableError, GatewayTimeoutError) as e:
with payment.lock:
if payment.status is PaymentStatus.PROCESSING:
self._finish_charge(payment, PaymentStatus.UNKNOWN, reason=str(e))
return
with payment.lock:
if payment.status not in (PaymentStatus.PROCESSING, PaymentStatus.UNKNOWN):
return # someone else already resolved it
payment.gateway_ref = result.reference
if result.decision is GatewayDecision.APPROVED:
payment.fee = req.amount.fee(merchant.fee_bps)
self._finish_charge(payment, PaymentStatus.SUCCEEDED)
else:
self._finish_charge(payment, PaymentStatus.FAILED, reason=result.reason)
def _finish_charge(self, payment: Payment, to: PaymentStatus, reason: Optional[str] = None) -> None:
"""Caller holds payment.lock. State change and outbox event are one atomic step."""
payment.transition(to)
payment.failure_reason = reason
self.outbox.append(f"payment.{to.value}", payment.payment_id,
amount=str(payment.amount), reason=reason or "")
# ---- refund ------------------------------------------------------------
def refund(self, payment_id: str, amount: Money, idempotency_key: str, reason: str = "") -> Refund:
"""Partial or full refund, idempotent on (payment_id, idempotency_key)."""
if amount.amount <= 0:
raise ValidationError("refund amount must be positive")
payment = self.get(payment_id)
with payment.lock:
with self._lock:
existing = self._refund_keys.get((payment_id, idempotency_key))
if existing is not None:
if existing.amount != amount:
raise IdempotencyConflictError(f"refund key {idempotency_key} reused with a different amount")
return existing
if payment.status not in REFUNDABLE:
raise RefundError(f"{payment_id} is {payment.status.value}; only settled charges can be refunded")
if payment.refundable() < amount:
raise RefundError(f"refund {amount} exceeds refundable {payment.refundable()}")
# Hold the amount before calling out, so concurrent refunds cannot over-refund.
refund = Refund(f"re_{next(self._ids)}", payment_id, idempotency_key, amount, reason)
payment.refunds.append(refund)
with self._lock:
self._refund_keys[(payment_id, idempotency_key)] = refund
self._attempt_refund(payment, refund)
return refund
def _attempt_refund(self, payment: Payment, refund: Refund) -> None:
try:
result = self._retry.run(lambda: self._gateway.refund(refund.idempotency_key,
payment.gateway_ref or "", refund.amount))
except (GatewayUnavailableError, GatewayTimeoutError):
return # stays PENDING with the amount held; reconcile() retries
with payment.lock:
if refund.status is not RefundStatus.PENDING:
return
refund.gateway_ref = result.reference
if result.decision is GatewayDecision.APPROVED:
refund.status = RefundStatus.SUCCEEDED
fully = payment.refunded() == payment.amount
payment.transition(PaymentStatus.REFUNDED if fully else PaymentStatus.PARTIALLY_REFUNDED)
self.outbox.append("refund.succeeded", refund.refund_id,
payment_id=payment.payment_id, amount=str(refund.amount))
else:
refund.status = RefundStatus.FAILED # releases the hold
self.outbox.append("refund.failed", refund.refund_id,
payment_id=payment.payment_id, reason=result.reason)
# ---- queries -----------------------------------------------------------
def get(self, payment_id: str) -> Payment:
with self._lock:
payment = self._payments.get(payment_id)
if payment is None:
raise NotFoundError(payment_id)
return payment
# ---------------------------------------------------------------- demo
def demo() -> None:
gateway = SimulatedGateway()
service = PaymentService(
gateway,
retry=RetryPolicy(max_attempts=3, sleep=lambda s: None),
risk_rules=[AmountLimitRule(Money.of("10000.00", Currency.USD))],
)
service.add_merchant(Merchant("m_1", "TechStore", fee_bps=290))
usd = lambda s: Money.of(s, Currency.USD)
# 1. The dangerous case: the gateway charges the card, then our read times out.
gateway.inject("timeout_after")
req = PaymentRequest("order-42", "m_1", "cust_1", usd("1500.00"), "tok_visa")
p = service.pay(req)
print(f"1. {p.payment_id}: {p.status.value}, fee {p.fee}, charges at gateway: {len(gateway.money_moved)}")
# 2. Client retries after its own timeout: same key -> same payment, no new charge.
assert service.pay(req) is p
print(f"2. retry returned {p.payment_id}; charges at gateway still {len(gateway.money_moved)}")
# 3. Gateway stays down past all retries -> UNKNOWN, then reconcile resolves it.
gateway.inject("timeout_before", "timeout_before", "timeout_before")
p2 = service.pay(PaymentRequest("order-43", "m_1", "cust_1", usd("20.00"), "tok_visa"))
print(f"3. {p2.payment_id}: {p2.status.value}", end=" -> ")
service.reconcile()
print(p2.status.value)
# 4. Partial refunds; a retried refund is not applied twice; over-refund is rejected.
service.refund(p.payment_id, usd("500.00"), "rf-1", "damaged")
service.refund(p.payment_id, usd("500.00"), "rf-1", "damaged")
try:
service.refund(p.payment_id, usd("1000.01"), "rf-2")
except RefundError as e:
print("4. rejected:", e)
print(f" {p.payment_id}: {p.status.value}, refunded {p.refunded()}, refundable {p.refundable()}")
# 5. Declines and risk rejections are terminal FAILED states.
d = service.pay(PaymentRequest("order-44", "m_1", "cust_2", usd("10.00"), "tok_decline_nsf"))
r = service.pay(PaymentRequest("order-45", "m_1", "cust_2", usd("50000.00"), "tok_visa"))
print(f"5. {d.status.value} ({d.failure_reason}); {r.status.value} ({r.failure_reason})")
# 6. Outbox relay: events leave in commit order.
published: List[str] = []
service.outbox.relay(lambda e: published.append(f"{e.type}:{e.aggregate_id}"))
print("6. published:", published)
if __name__ == "__main__":
demo()
▶️ How to Run¶
cd python-low-level-design/payment-processing-system
python3 payment_processing.py # deterministic demo
python3 -m unittest test_payment_processing -v # 23 tests, including concurrency
The demo walks through the cases interviewers ask about: a gateway timeout after the card was charged (one charge, not two), a client retry with the same key, a payment stuck in UNKNOWN that reconcile() resolves, partial and retried refunds, an over-refund rejection, a decline, a risk rejection, and the outbox relay.