🧠 Payment Processing LLD — Thought Process Guide¶
Goal: Learn how to think when designing a Low-Level Design. In a payments round, gateway strategies and validator chains are table stakes. What gets evaluated is whether you can say, precisely, why your design never charges a customer twice and never refunds more than was captured, including when the network lies to you.
📊 Class Diagram¶
classDiagram
direction LR
class PaymentService {
+outbox: Outbox
-_merchants: Dict~str, Merchant~
-_payments: Dict~str, Payment~
-_by_key: Dict~Tuple, Payment~
-_refund_keys: Dict~Tuple, Refund~
+add_merchant(merchant: Merchant) None
+pay(request: PaymentRequest) Payment
+refund(payment_id: str, amount: Money, idempotency_key: str, reason: str) Refund
+reconcile() List~Payment~
+get(payment_id: str) Payment
}
class PaymentGateway {
<<abstract>>
+charge(idempotency_key: str, amount: Money, payment_token: str) GatewayResult
+refund(idempotency_key: str, charge_reference: str, amount: Money) GatewayResult
}
class SimulatedGateway {
+money_moved: List~Tuple~
+inject(faults: str) None
}
class GatewayResult {
<<dataclass>>
+decision: GatewayDecision
+reference: str
+reason: str
}
class GatewayDecision {
<<enumeration>>
APPROVED
DECLINED
}
class RetryPolicy {
+max_attempts: int
+run(fn: Callable) GatewayResult
}
class RiskRule {
<<abstract>>
+evaluate(request: PaymentRequest) Optional~str~
}
class AmountLimitRule {
-_limit: Money
}
class VelocityRule {
-_attempts: Dict~str, Deque~
}
class Outbox {
+append(type_: str, aggregate_id: str, payload: str) None
+relay(publish: Callable) int
+pending() List~OutboxEvent~
}
class OutboxEvent {
<<dataclass>>
+event_id: int
+type: str
+aggregate_id: str
+payload: Dict~str, str~
}
class Merchant {
<<dataclass>>
+merchant_id: str
+name: str
+fee_bps: int
}
class PaymentRequest {
<<dataclass>>
+idempotency_key: str
+merchant_id: str
+customer_id: str
+amount: Money
+payment_token: str
}
class Payment {
<<dataclass>>
+payment_id: str
+status: PaymentStatus
+gateway_ref: Optional~str~
+fee: Optional~Money~
+refunds: List~Refund~
+history: List~PaymentStatus~
+lock: Lock
+refunded() Money
+held_for_refunds() Money
+refundable() Money
+transition(to: PaymentStatus) None
}
class Refund {
<<dataclass>>
+refund_id: str
+payment_id: str
+idempotency_key: str
+amount: Money
+status: RefundStatus
+gateway_ref: Optional~str~
}
class Money {
<<dataclass>>
+amount: Decimal
+currency: Currency
+of(amount: str, currency: Currency) Money
+zero(currency: Currency) Money
+fee(basis_points: int) Money
}
class Currency {
<<enumeration>>
USD
EUR
INR
JPY
+code: str
+exponent: int
}
class PaymentStatus {
<<enumeration>>
PROCESSING
SUCCEEDED
FAILED
UNKNOWN
PARTIALLY_REFUNDED
REFUNDED
}
class RefundStatus {
<<enumeration>>
PENDING
SUCCEEDED
FAILED
}
PaymentGateway <|-- SimulatedGateway
RiskRule <|-- AmountLimitRule
RiskRule <|-- VelocityRule
PaymentService --> PaymentGateway : charges via
PaymentService *-- RetryPolicy
PaymentService o-- "0..*" RiskRule : checks before charge
PaymentService *-- Outbox
PaymentService o-- "0..*" Merchant
PaymentService *-- "0..*" Payment
Outbox *-- "0..*" OutboxEvent
PaymentGateway ..> GatewayResult : returns
GatewayResult --> GatewayDecision
Payment *-- PaymentRequest
Payment *-- "0..*" Refund
Payment --> PaymentStatus
Refund --> RefundStatus
PaymentRequest --> Money
Money --> Currency
⏱️ How to run this in a 45–60 min interview¶
| Time | Step | What to say out loud |
|---|---|---|
| 0–7 min | Clarify | "Are we the merchant integrating with Stripe, or the payment processor itself? I'll assume we're a payment service in front of external gateways." Pin down auth/capture vs one-step charge, partial refunds, currencies. |
| 7–15 min | Entities, money, states | Money(Decimal, Currency) first, and say why not float. Draw the state machine including UNKNOWN. Name the interfaces: PaymentGateway, RiskRule. |
| 15–35 min | Core code | pay() with idempotency: claim the key, call the gateway with the key, record the outcome. Then refund() with the refundable-amount check. Typed errors: conflict, in-progress, illegal transition, refund error. |
| 35–45 min | Failure and concurrency | Walk the timeout-after-charge case. Distinguish "definitely not processed" from "unknown". Retries with backoff and jitter, decline never retried, UNKNOWN + reconcile. Two refunds racing: hold before calling out. |
| 45–60 min | Extension | Whatever they add (auth/capture, second gateway with failover, webhooks, ledger, outbox for events). Point to where it plugs in. Close with the DB view: unique constraint on the key, outbox table. |
Clarifying questions worth asking¶
- Are we the merchant or the processor? Changes whether we own card data (PCI scope) or just tokens.
- One-step charge or authorize then capture? E-commerce often authorizes at checkout and captures at shipment.
- Partial refunds? Multiple refunds per payment? → the refund invariant.
- Currencies? → minor units differ (JPY has none), no implicit FX.
- Who generates the idempotency key and how long is it valid? (Client, typically a UUID per logical operation; Stripe keeps keys for at least 24 hours.)
- What does the gateway guarantee? Does it accept an idempotency key? Does it send webhooks? Can we query a charge by our reference?
- Synchronous result required, or can the client poll / receive a webhook? Decides whether UNKNOWN is visible to the client.
- Fraud checks: blocking, or async review?
Phase 1: Identify the Nouns¶
| Noun | Decision | Why |
|---|---|---|
Money |
frozen dataclass | Decimal + currency; validation and arithmetic in one place |
Merchant |
frozen dataclass | Fee in basis points (int), no float percentages |
PaymentRequest |
frozen dataclass | Exactly the fields that define "the same request" for idempotency |
Payment |
dataclass + lock | Aggregate root: status, gateway reference, refunds, history |
Refund |
dataclass | Own key, own status; PENDING holds its amount |
PaymentGateway |
ABC | Adapter per provider; must forward the idempotency key |
RiskRule |
ABC | Pre-auth checks returning a rejection reason |
RetryPolicy |
class | Backoff with jitter, only for transient gateway errors |
Outbox |
class | Events written atomically with state changes |
PaymentService |
facade | Idempotency, locking, orchestration |
Phase 2: Enums and the state machine¶
class PaymentStatus(Enum): PROCESSING, SUCCEEDED, FAILED, UNKNOWN, PARTIALLY_REFUNDED, REFUNDED
class RefundStatus(Enum): PENDING, SUCCEEDED, FAILED
ALLOWED_TRANSITIONS = {
PROCESSING: {SUCCEEDED, FAILED, UNKNOWN},
UNKNOWN: {SUCCEEDED, FAILED},
SUCCEEDED: {PARTIALLY_REFUNDED, REFUNDED},
PARTIALLY_REFUNDED: {PARTIALLY_REFUNDED, REFUNDED},
FAILED: set(), REFUNDED: set(),
}
Say out loud: "A table, not a setter. FAILED and REFUNDED are terminal. UNKNOWN exists because a timeout is not a failure."
Phase 3: Money¶
Decimalbuilt from strings, quantized to the currency's exponent (USD 2, JPY 0).- Fees in basis points, rounded half-up once, at the minor unit.
- Same-currency arithmetic only. FX is a separate, explicit conversion with a recorded rate.
Phase 4: Idempotency, the core of the round¶
- Claim before acting. Insert the payment record keyed by
(merchant_id, key)before calling the gateway. In SQL:INSERT ... ON CONFLICT (merchant_id, idempotency_key) DO NOTHINGand check whether you won. - Compare the request. Same key + different body → reject.
- In flight → 409. The client retries later and gets the stored result.
- Forward the key to the gateway. This is the only thing that protects the timeout-after-charge case.
Phase 5: Retries vs double-charge¶
| Gateway outcome | Meaning | Action |
|---|---|---|
| Approved / Declined | Definite answer | Record it. Never retry a decline. |
| Unavailable (connection refused, 503 before acceptance) | Not processed | Retry with backoff |
| Timeout / connection reset after sending | Unknown | Retry with the same key; after the budget, mark UNKNOWN and reconcile |
Phase 6: Refund invariant¶
succeeded_refunds + pending_refunds <= captured_amount, checked and the PENDING refund recorded under the payment lock before the gateway call. Declined refunds release the hold; unknown ones keep it until reconciled.
Phase 7: Quick checklist¶
✅ Decimal money with currency exponent; no float anywhere ✅ Idempotency key claimed before the gateway call, compared against the request, forwarded to the gateway ✅ State machine as a transition table; UNKNOWN for timeouts; reconcile ✅ Retries only for transient errors, with backoff and jitter ✅ Refund holds make concurrent refunds safe ✅ Outbox for events; consumers dedupe by event id ✅ Tests for timeout-after-charge, duplicate concurrent requests, concurrent refunds