File: payment-system/payment_system.py

Date: 2026-06-05

Time: 13:18

payment-system/payment_system.py

Purpose

This file implements a payment processing system — one of the canonical system design interview problems. It owns the entire payment lifecycle: account creation, payment processing with external processor integration, refunds (full and partial), and balance tracking. The implementation demonstrates three core concepts that come up in payment system interviews: idempotency (preventing duplicate charges), double-entry bookkeeping (every money movement has a matching debit and credit), and retry with exponential backoff (handling transient processor failures).

This is a single-process in-memory simulation — there's no database or network — but the abstractions are faithful to how a real payment system is structured.

Key Components

Data Classes

PaymentSystem Class

The main orchestrator. Internal state is organized into five dictionaries/lists:

| Field | Type | Role |

|-------|------|------|

| accounts | dict[str, str] | accountid → currency mapping |

| payments | dict[str, Payment] | paymentid → Payment lookup |

| _ledger | list[LedgerEntry] | append-only transaction log |

| idempotency | dict[str, str] | idempotencykey → payment_id dedup map |

| _webhooks | dict[str, list[callable]] | event → callback list |

Key methods:

Patterns

Idempotency via key mapping. The _idempotency dict maps client-provided keys to payment IDs. If a key is seen again, the existing payment is returned immediately — no re-processing. This is the standard pattern for payment APIs where network failures can cause clients to retry.

Double-entry bookkeeping. Every money movement produces exactly two ledger entries: a debit on one account and a matching credit on another. This is enforced in createaccount, processpayment, and refund. The verifyledgerintegrity method audits this invariant.

Strategy pattern for the processor. The external payment processor is injected via set_processor() — a callable that returns a status dict. This decouples processing logic from the payment orchestration and makes testing easy (inject a fake that returns "timeout" or "failure").

Exponential backoff retry. callprocessorwithretry retries up to maxretries times on "timeout", with delays of 0.1s, 0.2s, 0.4s (base × 2^attempt). Non-timeout results (success or failure) return immediately.

Webhook / observer pattern. Events (payment.created, payment.completed, payment.failed, payment.refunded) fire registered callbacks. Exceptions in callbacks are silently swallowed — webhooks are fire-and-forget.

Dependencies

Imports: Only stdlib — time (timestamps, retry delays), uuid (ID generation), dataclasses (data modeling). No external dependencies.

Imported by: testpaymentsystem.py — the test suite is the sole consumer.

Flow

A typical successful payment flows through process_payment like this:

1. Idempotency check — return cached payment if key exists

2. Validation — account existence, currency compatibility, sufficient balance

3. Payment creation — status CREATED, registered in payments and idempotency

4. Processing — status transitions to PROCESSING, external processor called with retry

5. Settlement — on success, two ledger entries (debit payer, credit payee), status COMPLETED

6. Notification — webhook fired at each state transition

A refund reverses the ledger entries — debit the payee's account, credit the payer's — and transitions through REFUND_PENDINGREFUNDED.

Balance is always derived, never cached: get_balance does a full ledger scan, summing credits and subtracting debits for the given account.

Invariants

Error Handling

Two error strategies are used:

1. Raise ValueError — for programming errors / invalid requests: unknown account, duplicate account, invalid refund state, refund exceeding payment amount. These are caller bugs.

2. Return FAILED payment — for business-rule failures: insufficient balance, currency mismatch, processor failure/timeout. The caller gets back a Payment with status="FAILED" and can inspect it.

Webhook callbacks are wrapped in a bare except Exception: pass — a deliberate choice to prevent observer failures from breaking the payment flow. This means webhook errors are invisible, which is a tradeoff worth noting.

The retry logic in callprocessorwithretry converts exhausted retries (all timeouts) into {"status": "failure"} — the caller never sees "timeout" as a final result.

Topics to Explore

Beliefs