15. Payment System (Stripe-like) Advanced + Financial Correctness

A. Realistic Interview Prompt

"Design a payment processing system like Stripe: a merchant integrates an API to charge a customer's card, refund it partially or in full, and get notified when a payment's status changes — and no matter how a client's request is retried, or where in the pipeline a network call fails, a customer must never be charged twice for the same purchase, and every dollar the system has ever moved must be independently reconstructable and auditable after the fact, by someone who trusts nothing but the raw records."

Every other problem in this document treats "eventually correct" as an acceptable, even desirable, default — a stale feed, a slightly-late notification, a seat map that catches up a second later are all harmless. This problem is the one place in the set where that default is simply wrong for the core of the system: money that appears to move but didn't, or moves twice when it should have moved once, isn't a UX rough edge, it's an incorrect fact the business is now on the hook for. The load-bearing decision this design is built around is: treat an append-only, double-entry ledger as the single source of truth for every dollar that moves — balances are always computed from it, never stored and mutated directly; require a client-supplied idempotency key on every mutating request at the API boundary, non-negotiably, because "retry" is the normal behavior of every real network client; sequence a payment's multiple steps (charge the card, write the ledger, notify the merchant) as an explicit saga with a defined compensating action for any step whose side effect already happened when a later step fails; and run asynchronous reconciliation jobs that independently compare the internal ledger against the payment processor's own records, on the assumption that "should never happen" and "cannot happen" are different claims, and this domain is exactly the one where you build the safety net for the gap between them anyway.

B. Difficulty

Advanced — almost none of the individual mechanisms here are exotic (an idempotency key, a ledger table, a background reconciliation job are each independently simple to describe), but the domain has essentially zero tolerance for the small approximations that make every other problem in this document tractable. A candidate who reflexively reaches for "eventually consistent, we'll converge" without first identifying exactly which piece of this system cannot be allowed to converge after the fact — because by then real money has already moved incorrectly — is the most common way this problem goes wrong at the senior level.

C. What This Problem Tests

D. Requirements

Functional

  1. A merchant can charge a customer's stored payment method for a specified amount, in a specified currency
  2. A merchant can refund a charge, in full or in part, and can issue multiple partial refunds against the same original charge as long as their total doesn't exceed it
  3. A merchant (or the merchant's own system) can query the current status of any payment or refund at any time
  4. The system notifies a merchant asynchronously (via webhook) whenever a payment's status changes, including changes driven by the payment processor itself after the fact (e.g. a dispute)
  5. Every mutating request is safe to retry any number of times without repeating its real-world financial effect

Non-Functional

Out of scope: actual card-network/PCI-compliant tokenization internals (assumed handled by a compliant vendor or sub-processor); fraud/risk scoring models; full multi-entity accounting/tax reporting; building the payment processor itself — this system is the layer that sits in front of and orchestrates a processor, mirroring Stripe's own position in front of the underlying card networks.

E. Clarifying Questions

#QuestionWhy It MattersDesign Decision Affected
1Are we building the payment processor itself, or a layer that orchestrates calls to an existing processor (a Visa/Mastercard-connected acquirer)?Determines whether "the card network" is an external dependency this system treats as a black box with its own latency and failure modes, or something we're modeling internallyAPI Design (I), Deep Dive 3 (O)
2Does this need to support multi-party money movement — platform fee, marketplace split payouts to a sub-seller — or just simple merchant-charges-customer?Shapes how many ledger accounts a single transaction touches, and whether the ledger schema needs to support an arbitrary fan-out of legs per transaction from day oneCore Entities (H), Deep Dive 2 (N)
3What's the acceptable latency for a charge to be confirmed back to the merchant — synchronous within the checkout request, or can it be "pending" and confirmed asynchronously?Determines whether the saga's first step (authorization) must complete inline before responding, or can itself be deferredEnd-to-End Flow (L), Deep Dive 4 (P)
4Is multi-currency support required, or is everything settled in one reporting currency?A cross-currency transaction needs an explicit FX-conversion leg to keep the ledger balanced in a single reporting currency for consolidated viewsData Model (K), Interviewer Follow-Ups (AA)
5How long does an idempotency key need to remain valid — how long could a realistic client legitimately keep retrying the same logical request?Directly sets the idempotency-key TTL, trading protection window against unbounded key-store growthDeep Dive 1 (M)

F. Assumptions

G. Capacity Estimation

MetricValue / CalculationWhy It Matters
Active merchantsIllustratively, ~4MThe baseline scale driver for transaction volume below
Charges per day, platform-wide~250M charges/day → ~250M / 86,400 ≈ 2,900/sec averageThe write volume the charge/saga path (L, P) must sustain on an ordinary day
Peak-to-average ratioIllustratively ~15x during a major shopping event, ≈ 43,000 charges/sec sustained for the peak windowThe real design target — a system correct only at average load, but which corrupts state under a spike, has effectively failed at the one thing this problem cares about most
Ledger entries/day~250M charges × ≥2 balanced entries each (debit + credit, more with fees/splits, F) ≈ 550M-750M entries/daySets the write volume the ledger store (K) itself must sustain — an order of magnitude above the charge count because every transaction is, by construction, at least two rows, never one
Refunds/day~5% of charges ≈ 12.5M/day, each its own balanced ledger transaction (S)A meaningfully large secondary write stream, but a strictly smaller one than charges, and structurally identical to one (S)
Idempotency-key store, live keys~250M keys/day retained for a 24h TTL (F) ≈ ~250M live keys at any moment, each a small row (key, request hash, status, response snapshot) ≈ ~150 bytes ≈ ~38GB liveBounded and modest specifically because of the TTL (M) — without one, this store would grow without limit
Ledger storage growth~650M entries/day × ~250 bytes/entry ≈ ~160GB/day ≈ ~58TB/year, retained for years for audit/regulatory purposesLarge but comfortably within reach of a well-sharded relational store (K) — smaller by an order of magnitude than Dropbox's chunk-byte footprint (P14), because ledger rows are small, structured facts, not payloads
Webhook events, outbound to merchants~3-4 lifecycle events per charge (created, succeeded/failed, later refunded/disputed) ≈ ~1B events/day platform-wideThe volume the notification/webhook path (R) and its dedup/retry logic must handle reliably, at-least-once
Ledger-write latency budgetLow tens of milliseconds for the ACID commit itself — the strongly-consistent core (V) — versus low seconds acceptable for the full charge-to-merchant-notified round tripA deliberately narrow, fast, strongly-consistent island (patterns-j) inside an otherwise much more loosely-timed end-to-end flow

H. Core Entities

EntityPurposeKey Fields
MerchantThe party receiving funds; owns its own payable account in the ledgermerchant_id, payout_account_id, webhook_url
PaymentIntentThe customer-facing representation of one attempt to collect money — the unit a merchant actually integrates against (I, L)intent_id, merchant_id, amount, currency, status (created/processing/succeeded/failed), idempotency_key
AccountOne node in the internal chart of accounts the ledger moves money between — never itself a mutable balance, only an identity ledger entries reference (K, N)account_id, type (e.g. processor_clearing, merchant_payable, platform_revenue, refunds_payable), owner_id
LedgerTransactionOne balanced financial event grouping two or more entries that must sum to zero (K, N)transaction_id, related_intent_id or refund_id, created_at, reason
LedgerEntryOne immutable debit or credit line — the actual, append-only unit of financial truth (N)entry_id, transaction_id, account_id, direction (debit/credit), amount, currency
IdempotencyKeyRecordTracks which client-supplied keys have already been seen, and with what outcome (M)key (scoped per merchant), request_fingerprint, status (in_progress/completed), response_snapshot, expires_at
RefundA request to reverse some or all of a charge's amount — its own saga, its own ledger transaction (S)refund_id, payment_intent_id, amount, status, idempotency_key
WebhookEventOne inbound async event from the processor, or one outbound notification to a merchant — deduplicated independently of the API-boundary idempotency key (R)event_id (processor- or system-assigned), type, payload, received_at/delivered_at, processed
ReconciliationDiscrepancyA flagged mismatch found by comparing the internal ledger against the processor's settlement records (Q)discrepancy_id, internal_ref, processor_ref, type, resolved

I. API Design

-- merchant-facing (all mutating calls require an Idempotency-Key header):
POST /payment_intents { amount, currency, customer_payment_method_id }
     Idempotency-Key: <client-generated>  -> { intent_id, status: "processing" }        (M, L)
GET  /payment_intents/{intent_id}          -> { status, amount, ledger_summary }         (L)
POST /payment_intents/{intent_id}/refunds { amount (optional, partial) }
     Idempotency-Key: <client-generated>  -> { refund_id, status: "processing" }        (S)
GET  /refunds/{refund_id}                  -> { status, amount }                          (S)
-- outbound, system-to-merchant:
POST {merchant.webhook_url} { event_id, type: "payment_intent.succeeded" | ".failed" | ".refunded", ... }   (R)
-- inbound, processor-to-system:
POST /internal/webhooks/processor { event_id, type, payment_reference, ... }             (O, R)
-- internal, cluster-facing:
(saga orchestrator) drives: authorize -> ledger write -> notify, with compensations       (P)
(reconciliation worker) periodic: diff ledger vs processor settlement report              (Q)

Why every mutating endpoint requires Idempotency-Key, not just recommends it: this is the one API-design decision this entire problem is built around (Prerequisites O, Patterns Playbook E) — a request without a key isn't a convenience trade-off the way an optional header might be elsewhere, it's a request the system has no way to protect against a client's own retry, and is rejected outright rather than processed unprotected.

Why GET /payment_intents/{id} and the refund/status endpoints are safe without an idempotency key: they're pure reads with no side effect to protect against duplication — idempotency keys guard mutations specifically, the same distinction Prerequisites O draws generally.

J. Basic / Naive High-Level Design (V1)

The simplest thing that satisfies the functional requirement in isolation: a single accounts table with a balance column per merchant; charging a customer calls the payment processor directly, and on success runs UPDATE accounts SET balance = balance + amount WHERE merchant_id = ?. No idempotency key is required anywhere in this flow.

flowchart LR Client(["Merchant's Checkout"]) -- "charge request" --> API["Payment API"] API -- "charge card" --> Proc[("Payment Processor")] Proc -- "success" --> API API -- "UPDATE balance += amount" --> DB[("accounts table
(mutable balance column)")]

V1 — a direct, synchronous charge-then-mutate-balance flow, with nothing protecting against a retried request.

Why this breaks, in the two ways that matter most: first, and most dangerously, a client whose charge request times out — the processor charged the card, but the response never made it back — has no safe way to retry: calling the same endpoint again with no idempotency key charges the customer a second time, and there is nothing in this design that can tell the difference between "this is a genuinely new charge" and "this is the same charge, retried." Second, even setting retries aside, mutating a balance column directly destroys the one thing a financial system is obligated to preserve: a reconstructable history. If that balance is ever wrong — a bug in the update logic, a partially-applied migration, a race no one noticed — there is no way to determine how it became wrong, or what the correct value should have been, because the individual facts that produced it were never recorded, only their net effect. Both flaws point at the same root cause: this design has nothing durable and immutable underneath the number it reports, and nothing distinguishing a repeated request from a new one.

K. Data Model & Store Choice

The double-entry ledger schema, the central structural decision this entire design is built around: every financial event is recorded as a LedgerTransaction grouping two or more LedgerEntry rows, each tagged debit or credit against a specific Account (H) — and the one invariant enforced on every single write is that a transaction's entries must sum to zero (every debit balanced by an equal credit, the literal accounting definition of "double-entry"). A simple merchant charge touches at least two accounts — debit processor_clearing (money now sitting with the processor, not yet settled), credit merchant_payable:{merchant_id} (what the platform now owes that merchant) — and a transaction with a platform fee or a marketplace split (F, E's clarifying question 2) simply adds more balanced legs to the same transaction, crediting platform_revenue and debiting the merchant's payable proportionally, with the invariant holding regardless of how many accounts a single transaction ultimately touches.

Why this lives in a relational database with real ACID transactions (Prerequisites B), not a document store or a loosely-consistent key-value store: writing a LedgerTransaction's several balanced entries must be all-or-nothing — if the process crashes after writing the debit but before the credit, the ledger is left in a state that structurally cannot exist under the double-entry invariant, and any account balance derived from it (N) is now silently wrong. Wrapping every entry belonging to one transaction_id in a single database transaction, committed atomically, is what makes "the ledger can never be observed in an unbalanced state" a guarantee enforced by the storage layer itself, not a discipline hoped for at the application layer.

Sharding the ledger: partitioned by merchant_id (the same natural-ownership-key reasoning as Patterns Playbook G) so one merchant's transaction volume doesn't contend with another's, with the platform-wide accounts (platform_revenue, processor_clearing) either replicated read-heavy or handled by a small set of dedicated high-throughput shards, since nearly every transaction touches one of them. A single transaction that spans a merchant-owned account and a platform-wide account still commits atomically because both live inside the reach of one database transaction on the shard that owns that specific write.

Why Account itself carries no balance field at all: this is the direct, deliberate fix for V1's core flaw (J) — an account is purely an identity that ledger entries reference; its balance is never a column anyone writes to, only a value computed from the entries that reference it (N).

L. End-to-End Flow — A Customer Pays a Merchant: Charge, Ledger, Notification, All Idempotently

sequenceDiagram participant M as Merchant Checkout participant API as Payment API participant Idem as Idempotency Store (M) participant Saga as Saga Orchestrator (P) participant Proc as Payment Processor participant Ledger as Ledger DB (K, N) participant Notify as Webhook Sender (R) M->>API: POST /payment_intents (Idempotency-Key: K1) API->>Idem: has K1 been seen? Idem-->>API: no — record K1 as in_progress API->>Saga: start saga for new PaymentIntent Saga->>Proc: authorize + capture charge (passing K1 downstream too, O) Proc-->>Saga: succeeded Saga->>Ledger: write balanced LedgerTransaction (debit clearing, credit merchant_payable) Ledger-->>Saga: committed (ACID, K) Saga->>Notify: enqueue merchant webhook (payment_intent.succeeded) Saga->>API: mark PaymentIntent succeeded API->>Idem: store final response against K1 API-->>M: 200 { status: "processing" } (immediate) / webhook later confirms success Notify-->>M: webhook: payment_intent.succeeded

Every step downstream of the idempotency check is itself idempotent or safely retryable — a retried request with the same key at any point simply returns the already-recorded outcome instead of re-running any step.

M. Deep Dive 1 — Idempotency at the API Boundary

Requirement this addresses: the no-double-effect NFR (D) — the single most consequential mechanism in this entire design, directly fixing V1's fatal flaw (J).

Why this is non-negotiable here specifically, more than anywhere else this document uses the same mechanism: Prerequisites O establishes the general case — a client can't tell "my request never arrived" apart from "it arrived, succeeded, and the response was lost" — and Patterns Playbook E catalogs three prior instances of the fix (Chat's client_msg_id, Ticketmaster's payment-confirmation key, Job Scheduler's execution key). What's different here is the cost of getting it wrong: a duplicated chat message is a harmless annoyance a user can delete; a duplicated job execution is often reversible; a duplicated charge is money that has to be manually identified, refunded, and apologized for, and it erodes the one thing a payment provider is fundamentally selling — trust that the number it reports is correct. This is why idempotency keys are mandatory and rejected-if-absent here (I), not merely supported.

Storage and the request-fingerprint check: an IdempotencyKeyRecord (H) is keyed by (merchant_id, key) — scoped per merchant, since two unrelated merchants independently choosing the same key string must not collide — and stores a hash of the full request body alongside the key. A repeat request with the same key and the same request fingerprint returns the original cached response with no side effect repeated; a repeat request with the same key but a different body (a client bug reusing a key for a genuinely different charge) is rejected outright with a clear conflict error, rather than either silently executing a second charge or silently returning the wrong cached result for a different request.

The concurrent, still-in-flight case — a second race, one level up from Pattern 1: if a second request with the same key arrives while the first is still being processed (a client that fires a fast, automatic retry before the first attempt has even finished, not after a timeout), simply checking "has this key been seen" isn't enough — both requests could observe "not yet seen" and both proceed. The fix is the same check-then-act discipline as Patterns Playbook B, applied to the idempotency record itself: a unique constraint on (merchant_id, key) combined with an atomic insert-if-absent means the second request's insert fails immediately, and it's told to wait for or poll the first request's outcome rather than being allowed to also attempt the charge.

TTL, and why it's bounded rather than permanent: keys expire after roughly 24 hours (G, F) — long enough to cover a client that legitimately retries after being offline for hours, short enough that the key store's size stays proportional to a single day's request volume rather than growing forever. This mirrors the same trade Ticketmaster's reservation-hold TTL makes (P8, Deep Dive N) for a different resource entirely — a bounded window that's generous for the real, legitimate case and cheap to eventually reclaim.

N. Deep Dive 2 — The Double-Entry Ledger as the Single Source of Truth

Requirement this addresses: the auditable/reconstructable NFR (D) — the second half of V1's core flaw (J), independent of and complementary to idempotency (M): idempotency prevents a bad write from happening twice; the ledger design is what makes every write that does happen permanently, independently verifiable.

Why a balance is a computed value, never a stored, mutated field: an account's balance at any point in time is SUM(credits) - SUM(debits) over every LedgerEntry referencing that account (K) — not a column anyone ever runs UPDATE ... SET balance = balance + x against. This is the direct, structural fix for V1's second flaw: because every entry that ever affected an account is preserved, permanently and unmodified, that account's balance at any past moment is independently reconstructable by literally anyone with read access to the ledger — an internal auditor, an external regulator, a support engineer investigating a customer's complaint — without needing to trust that some other piece of code correctly maintained a running total. A cached, precomputed balance is a legitimate performance optimization (a materialized summary table, refreshed by a background job) but it is never the source of truth — it's always re-derivable from, and checkable against, the entries themselves.

Why "just add an audit log next to the mutable balance" doesn't get you the same guarantee: a common instinct is to keep V1's balance column for fast reads, and bolt on a separate log of "what changed" for audit purposes. This is subtly, importantly different from the ledger being the source of truth: if the balance column and the log can ever drift — a bug that updates one but not the other, a crash between the two writes — there is no way to know, after the fact, which one is correct, because neither is defined as authoritative over the other. The double-entry ledger design doesn't have this problem because there is only one write path and only one source of truth; a "current balance" is a query against it, not a second, independently-maintained fact that could disagree with it.

Why the double-entry invariant itself (debits = credits, always) is what makes reconciliation (Q) possible at all: because every transaction is mechanically checkable for internal balance at write time, and the sum of every account's net position across the whole ledger is mathematically guaranteed to be zero, a reconciliation job has a precise, checkable property to test against — "does this match the processor's records" is a well-posed question specifically because the internal side of the comparison is a rigorously balanced structure, not an arbitrary mutable number that could be wrong in ways nobody can even characterize.

O. Deep Dive 3 — Handling Processor Timeout Ambiguity

Requirement this addresses: the no-double-effect NFR (D), for the specific case where the ambiguity originates outside this system's own boundary — the call to an external payment processor times out.

The exact ambiguity, stated precisely: the saga (P) calls the processor to authorize and capture a charge; the call times out before a response arrives. Three things could actually be true: the processor never received the request at all; the processor received it and is still processing; or the processor received it, successfully charged the card, and the response confirming that was simply lost on the way back. From this system's side, these three cases are indistinguishable by the timeout alone — and blindly retrying the charge call is only safe in the first case. Retrying in the third case charges the customer's card twice, entirely outside the reach of this system's own idempotency-key store (M), because the duplication would happen on the processor's or card network's side, not at this system's API boundary.

The fix — query status, don't guess: the same idempotency key generated at this system's API boundary (M) is also passed downstream to the processor on the original charge call, so the processor itself can deduplicate a retried call carrying the same key — most real processors support exactly this (AD). But relying on that alone still leaves this system without a definite answer to act on. The correct move on an ambiguous timeout is not to immediately retry the charge call — it's to query the processor's own transaction-status endpoint for that specific reference, and read back a definite answer (succeeded, failed, or still processing) before the saga decides what to do next. Only once that answer is known does the saga either proceed down the success path (ledger write, notification) or safely retry the original call, now knowing it genuinely never landed.

Why this is the single most consequential deep dive for preventing an actual double charge: idempotency keys (M) protect against a client-initiated retry at this system's own boundary; this mechanism protects against the different, equally real failure mode of this system's own outbound call to the processor being ambiguous. Both are necessary; neither alone is sufficient — a design with perfect API-boundary idempotency but a "just retry on timeout" policy toward the processor is still exposed to exactly the failure this deep dive exists to close.

P. Deep Dive 4 — The Saga Pattern for Multi-Step Transactions

Requirement this addresses: the resilient-to-partial-failure NFR (D) — a payment is never really one atomic step; it's authorize-with-processor, then write-the-ledger, then notify-the-merchant, and each of those can fail independently, after the ones before it already produced a real, external side effect.

Orchestration, chosen over choreography: in a choreographed design, each service reacts to events published by the one before it (the processor-adapter service publishes "charge succeeded," the ledger service listens and writes, then publishes its own "ledger written" event, and so on) — appealingly decoupled, but the direct cost is that no single place holds the full picture of one specific payment's progress; reconstructing "what exactly happened to this $47.20 charge, step by step" means piecing it together from a scattered event trail across services. In an orchestrated design, a central saga coordinator explicitly issues each step in sequence and explicitly persists which step a given PaymentIntent is currently on — the same durable-workflow-orchestration concept named for Ride-Sharing's multi-step match/accept sequence (P5-Q), reused here for a financial chain instead of a matching retry loop. For a domain where an auditor, a support engineer, or a regulator will eventually ask exactly that "what happened, step by step" question, having one durable, queryable record of a transaction's progress is worth the coupling cost choreography avoids.

StepSide Effect if It SucceedsCompensating Action if a Later Step Fails
1. Authorize/capture charge with processorCustomer's card is actually charged — a real, external, hard-to-reverse effectIssue a refund/void call back to the processor (S) — the compensation for a real external side effect must itself be a real external action, not merely an internal state change
2. Write the balanced ledger transactionInternal accounts now reflect the money having moved (N)Never delete or mutate the entries just written (N) — write a new, reversing LedgerTransaction that cancels out the original's effect, itself balanced
3. Notify the merchant (webhook)Merchant's own system learns the payment succeededNone required — a failed or delayed notification is simply retried (R); notifying twice is harmless (protected by its own event-level idempotency), so there is nothing here that needs undoing, unlike steps 1 and 2

Why step 3 deliberately needs no compensation, and why that distinction matters: steps 1 and 2 move real money or record a real financial fact — failing to compensate them leaves an incorrect state that persists. Step 3 only moves information about a fact that's already correctly settled by steps 1-2; failing to notify delays visibility, it doesn't corrupt anything, so retrying without compensating is the right, cheaper answer specifically because the failure mode is fundamentally different in kind, not just in severity.

The direct relationship to Ticketmaster's payment sequencing (P8, Deep Dive Q): Ticketmaster deliberately sequences its own payment step strictly after the seat hold succeeds — and, notably, is the one explicit exception in this document to reflexively moving slow work off the hot path (Patterns Playbook C), because the user is actively waiting on a definitive yes/no. That two-step sequence (hold, then charge, with a single compensating action — release the seat — if the charge fails) is the minimal case of exactly this pattern: one point of failure, one compensation. This design generalizes the identical idea to an arbitrary-length chain, with its own explicit compensation defined per step, which is precisely the point at which an ad hoc "hold then charge" sequence stops being sufficient and a formally named saga, with an orchestrator that persists progress durably, becomes the right tool to reach for.

Q. Deep Dive 5 — Async Reconciliation Jobs

Requirement this addresses: auditability (D) — a deliberate second line of defense, built on the assumption that idempotency (M) and the saga's compensations (P) are already correct, but that "should never happen" and "cannot happen" are different claims, and this is exactly the domain where you build the safety net for the gap between them anyway.

The mechanism: a periodic batch job (daily, or more frequently for higher-value merchants) pulls the payment processor's own settlement report — a file or API listing every transaction the processor believes it processed and settled — and diffs it against the corresponding internal ledger transactions. Three categories of mismatch matter:

Auto-resolve narrowly, escalate everything else: a mismatch within an explicitly tolerated, well-understood bound (a sub-cent rounding difference from currency conversion) can be auto-corrected with a small, explicitly-tagged correcting LedgerTransaction (N) — never a silent mutation, always a new, attributable entry. Anything outside that narrow, well-understood category goes to a manual review queue rather than being auto-corrected on a guess, because guessing wrong here isn't a UX inconvenience, it's a real, incorrect movement of money layered on top of the first mistake.

Why this is explicitly a second line of defense, not the primary mechanism: reconciliation runs hours after the fact, at best — it is far too slow to be the thing that prevents a double charge in the moment (that's M and O's job) or that unwinds a failed multi-step transaction promptly (that's P's job). Its value is entirely in catching whatever slips past those first-line mechanisms anyway — a bug in the saga orchestrator, a webhook silently dropped, an edge case nobody anticipated — which is precisely why a mature financial system runs it unconditionally, not as an afterthought bolted on only once a discrepancy is first discovered the hard way.

R. Deep Dive 6 — Idempotent Webhooks from the Payment Processor

Requirement this addresses: the async-notification functional requirement (D) — but inbound this time: the processor telling this system that something changed, not this system telling a merchant.

At-least-once, out-of-order delivery is the expected default, not an edge case: the processor delivers webhook events for state changes (a charge settling, a later dispute, a refund confirming) the same way every real-world async delivery mechanism in this document works — at-least-once, per the same underlying admission named in Prerequisites O that exactly-once delivery isn't achievable over a network. A slow acknowledgment from this system triggers the processor's own redelivery, so the same event can legitimately arrive more than once; a network partition on the processor's side can deliver events out of their logical order (a dispute notification arriving before the succeeded event that logically precedes it).

Dedup by the processor's own event ID, a different key from the API-boundary idempotency key: each inbound WebhookEvent (H) carries an event_id assigned by the processor itself — a distinct identifier from the Idempotency-Key a merchant supplied when originally creating the charge (M). A WebhookEvent row keyed by that event_id, checked before any processing logic runs, means a redelivered event is acknowledged with a 200 OK immediately, with no side effect repeated, regardless of how many times the processor resends it.

Handling out-of-order arrival without assuming delivery order matches logical order: a PaymentIntent is modeled as a small, explicit state machine with a defined set of valid transitions (created → processing → succeeded → refunded, or created → processing → failed). An incoming event that doesn't represent a valid transition from the record's current state — a refund-confirmed event arriving before the succeeded event that logically precedes it — is not blindly applied; it's queued or retried briefly rather than forced through, on the reasonable expectation that the missing predecessor event is likely already in flight and will arrive shortly. This is a cheaper, more targeted fix than trying to impose a strict global order on webhook delivery itself, which the processor doesn't guarantee and this system can't control.

S. Deep Dive 7 — Handling Refunds & Partial Refunds

Requirement this addresses: refund and partial-refund functional requirements (D) — and a direct test of whether N's ledger discipline was actually internalized, not just applied to the happy-path charge.

A refund is a new, balanced LedgerTransaction, never a deletion or a mutation of the original: exactly the same principle N establishes for the original charge — the original charge's entries are permanent history and are never touched. A refund reverses the charge's economic effect by writing a fresh transaction whose entries move the same amount in the opposite direction (credit back toward the customer-facing side, debit out of merchant_payable), explicitly referencing the original transaction_id it reverses. The ledger after a refund shows both the original charge and its reversal, in full — the honest, complete history of what actually happened, rather than a record edited to look as if the charge never occurred.

Partial refunds are the identical mechanism at a lesser amount, with one derived value that matters: a charge's remaining refundable balance is computed, not stored, as original_amount − SUM(refund amounts already issued against it) — read live from the ledger each time it's needed, the exact same "derive, don't mutate" discipline N applies to account balances generally, reapplied here to a different number. A second partial refund attempt is validated against this freshly-computed remaining balance, which is what correctly prevents refunding more than was ever charged even across several independent partial refunds issued at different times.

Refunds get their own idempotency key and their own saga, not a shortcut through the charge's existing machinery: a refund is exactly as much a mutating financial side effect as the original charge (I) — it moves real money back toward the customer and deserves identical protection: a client-supplied Idempotency-Key (M) so a retried refund request never issues the refund twice, and its own saga sequence (call the processor to reverse the charge, write the reversing ledger transaction, notify the merchant) with the same per-step compensation reasoning as P, applied to the refund's own steps rather than assumed to be automatically covered by the original charge's machinery.

T. Full Solution at Scale — Final Architecture

flowchart TB Merchant["Merchant Checkout / API Client"] --> Gateway["Payment API / Gateway"] Gateway --> IdemStore[("Idempotency Key Store
durable, TTL'd, M")] Gateway --> Saga["Saga Orchestrator
(durable per-PaymentIntent state, P)"] Saga --> ProcAdapter["Processor Adapter"] ProcAdapter --> Processor[("External Payment Processor")] ProcAdapter -->|"ambiguous timeout -> query status, O"| Processor Saga --> LedgerRouter["Ledger Router
(shard by merchant_id, K)"] LedgerRouter --> LedgerShard1[("Ledger Shard 1
append-only, double-entry, N")] LedgerRouter --> LedgerShard2[("Ledger Shard 2")] LedgerRouter --> LedgerShard3[("Ledger Shard 3")] Saga --> WebhookSender["Outbound Webhook Sender
(retries, per-event idempotency, R)"] WebhookSender --> Merchant Processor -.->|"async status events"| WebhookReceiver["Inbound Webhook Receiver
(dedup by event_id, R)"] WebhookReceiver --> Saga subgraph Recon [" Async Reconciliation (Q) "] direction LR Puller["Pull processor settlement report"] --> Differ["Diff vs ledger"] Differ --> Review["Manual review queue
(unexplained drift)"] end LedgerShard1 --> Differ LedgerShard2 --> Differ LedgerShard3 --> Differ Processor -.-> Puller

The idempotency store and the ledger's ACID core are the two genuinely strongly-consistent pieces (V); the saga durably tracks progress across everything downstream of them, and reconciliation independently double-checks the whole picture after the fact.

U. Design Evolution

VersionWhat ChangedWhy
V1Direct charge, then mutate a merchant's balance column; no idempotency key anywhere (J)Simplest possible thing that satisfies the functional requirement in isolation
V2Mandatory client-supplied idempotency key at the API boundary, checked and stored before any charge attemptCloses the single most dangerous flaw in V1 — a retried request can no longer double-charge a customer (M)
V3The mutable balance column is replaced by an append-only, double-entry ledger; balances become a computed aggregateCloses V1's second flaw — every account balance is now independently reconstructable and auditable from immutable history, not trusted as a single cached number (N)
V4The charge → ledger-write → notify sequence is formalized as an explicit saga with a defined compensating action per stepA multi-step process with real, already-happened side effects needs more than an ad hoc "try, then roll back somehow" gesture once a later step can fail (P)
V5An ambiguous processor timeout triggers a status query, never a blind retry of the charge call itselfIdempotency at this system's own boundary (M) doesn't protect against ambiguity on the far side of an external call (O)
V6Inbound processor webhooks are deduplicated by the processor's own event ID and validated against a defined state-machine of valid transitionsAt-least-once, possibly out-of-order webhook delivery is the expected default, not a rare edge case (R)
V7Refunds and partial refunds are modeled as their own new, balanced ledger transactions, with their own idempotency key and sagaA refund is exactly as much a mutating financial side effect as the original charge, and gets identical protection rather than a shortcut (S)
V8An asynchronous reconciliation job independently diffs the internal ledger against the processor's own settlement recordsA second line of defense for whatever slips past every prior mechanism anyway — "should never happen" isn't "cannot happen" (Q)

What stays stable throughout: the small, idempotency-key-gated PaymentIntent/Refund contract from I — every later refinement changes how a request's side effects are sequenced, recorded, or double-checked, never the basic shape of what a merchant actually sends and receives.

V. Consistency Model

This is, alongside Ticketmaster (P8), one of the two most strongly-consistency-weighted designs in this document — but for a genuinely different underlying reason, worth stating precisely rather than lumping the two together. Ticketmaster's strong consistency (P8-V) protects a scarce, physically singular resource — a seat that cannot exist in two "sold" states, because only one buyer can ever actually sit in it. This system's strong consistency protects a mathematical invariant — a transaction's debits and credits must sum to zero, or the ledger's own record of reality is simply false, independent of any resource being contended for. Every LedgerTransaction commits as a single ACID unit (K, N) — there is no acceptable "partially written" or "eventually balances" state, which is a materially stronger requirement than Prerequisites L's general CAP/PACELC framing allows for the rest of this design: this is not a system choosing availability over consistency for its ledger core, full stop.

And yet — this is the detail worth making explicit in an interview — nearly everything surrounding that strongly-consistent core is deliberately asynchronous and only eventually consistent: outbound merchant notification (R) can lag by seconds without harm; the reconciliation job (Q) runs hours after the fact by design; even the idempotency key store (M), while itself requiring an atomic insert-if-absent, only needs to be consistent for the duration of its TTL, not forever. The strong-consistency requirement is scoped as narrowly as possible (Patterns Playbook J) — to the ledger commit itself, and nothing wider — precisely because delay in telling someone about a financial fact is harmless, while the fact itself ever being wrong, even briefly, is not. This narrow-island shape is the same general pattern Patterns Playbook J catalogs elsewhere, applied here to the single highest-stakes island in the entire document.

W. Failure Scenarios

X. Bottlenecks & Scaling Boundary

StageActual BottleneckFix Applied
V1 mutable balance columnNo durable, immutable history underneath the reported number — a bug or race corrupts it silently and irrecoverablyAppend-only, double-entry ledger; balances become a derived query, never a stored, mutated field (N)
A single, unsharded ledger table at global transaction volumeOne merchant's write volume contends with every other merchant's on the same rows/pagesShard the ledger by merchant_id (K), the natural ownership key, while still committing each single transaction's entries atomically within one shard's reach
Idempotency key store under a merchant's own traffic spikeA naive periodic-sweep expiry adds latency and unnecessary load scanning mostly-unexpired rowsNative TTL-driven expiry (the same principle as Ticketmaster's Redis-backed hold expiry, P8, Deep Dive N), not a scheduled scan
A single saga-orchestrator instance as a choke pointAll in-flight payments funnel through one process's throughput ceilingHorizontally scaled orchestrator workers, with each PaymentIntent's durable state partitioned by intent_id, so any worker can pick up any transaction's next step
Reconciliation re-diffing the entire ledger history on every runCost grows without bound as retained ledger history grows across yearsIncremental, windowed reconciliation — only the period since the last successful run is diffed each time (Q)

Y. Design Trade-Offs

DecisionOption AOption BTrade-off
Ledger schemaMutable balance column per accountAppend-only, double-entry ledger (chosen)A mutable column is simpler and faster to read, but destroys reconstructable history and has no mechanically checkable invariant; the ledger costs more storage and a computed-balance query, in exchange for genuine auditability (N)
Multi-step transaction coordinationChoreography (event-driven, decoupled)Orchestration (chosen)Choreography is more decoupled and scales each service independently, but no single place holds one transaction's full progress; orchestration adds a coordinating dependency in exchange for a durable, queryable record of exactly what happened, step by step (P)
Idempotency key durabilityIn-memory cache with TTLDurable relational row (chosen)An in-memory cache is faster, but a cache-node restart loses in-flight protection at the worst possible moment; a durable row survives that restart, at a modest added write cost (M)
Ambiguous processor timeoutBlindly retry the charge callQuery processor status first (chosen)Blind retry is simpler and faster in the common case, but risks a real double charge in the ambiguous case; querying first adds one extra round trip in exchange for a definite answer before acting (O)
Refund modelingMutate the original charge's recorded amountNew, separate reversing ledger transaction (chosen)Mutating the original destroys the historical record of what was actually charged; a new transaction preserves both facts in full, at the cost of one more row than the naive approach (S)

Z. Common Bad Designs

AA. Interviewer Follow-Ups

Follow-UpDesign ChangeComponents ChangedComponents Unchanged
"How would you support multi-currency transactions?"Every ledger entry carries a currency; a cross-currency transaction adds an explicit FX-conversion leg so the whole transaction still balances within a single reporting currency for consolidated viewsLedger schema, transaction construction (K)Idempotency, saga structure, reconciliation logic — none of it is currency-dependent
"What about disputes and chargebacks initiated by the card network, well after the original charge?"Modeled as another saga, triggered by an inbound processor webhook (R), that writes its own reversing ledger transaction and places the disputed amount into a hold account pending resolutionA new saga type, a new Account category for held/disputed fundsThe core idempotency, ledger, and reconciliation machinery — a dispute is just another financial event flowing through the same pipes
"How does this handle marketplace-style split payments — pay the merchant, platform takes a fee, remainder pays out to a sub-seller?"The ledger already models this without a new mechanism — a single transaction simply fans out across more accounts (customer-facing clearing, platform revenue, sub-seller payable), each leg still balanced against the othersNone structurally — just more legs on the same LedgerTransactionIdempotency keys, the saga's step sequence, reconciliation — genuinely nothing changes in kind, only in the width of one transaction
"What happens if the payment processor itself has an extended outage?"New charge attempts queue with backoff or fail fast per merchant-configured tolerance, but never bypass the idempotency-key or saga machinery to "work around" the outage — a slower correct answer beats a fast incorrect one hereRetry/backoff policy in the processor adapterEverything downstream of a successful charge — ledger, notification, reconciliation — is entirely unaffected by how the processor call itself was scheduled

AB. Interviewer Evaluation Rubric

AC. Interview Simulation

Interviewer: Design a payment processing system like Stripe — a merchant charges a customer's card, and needs that charge to be handled correctly no matter what goes wrong along the way.
Candidate: The first thing I want to nail down is idempotency, because it's the one thing this domain can't compromise on. Every mutating request — a charge, a refund — has to carry a client-generated idempotency key. If a client's request times out and it retries, the server needs to recognize "I've already handled this exact key" and just return the original result instead of charging the card again. Without that, a network retry — which is completely normal client behavior — becomes a real double charge.
Interviewer: Okay, and how do you actually store the money movement itself?
Candidate: Not as a balance column that gets updated in place — that's the naive version, and it has a real problem: if that number is ever wrong, there's no way to figure out how it got wrong, because you only kept the final total, not the history that produced it. Instead I'd use a double-entry ledger — every transaction is at least two balanced rows, a debit and a credit against specific accounts, and it's append-only, nothing ever gets updated or deleted. A balance is just a sum over those rows, computed on demand. That means an auditor, or even just an engineer debugging a customer complaint, can reconstruct exactly what happened from nothing but the raw entries.
Interviewer: What if the call to the actual card processor times out? How do you know whether the charge went through?
Candidate: That's genuinely the trickiest part. A timeout is ambiguous — the charge might have succeeded and the response just got lost. Retrying blindly is dangerous, because if it did succeed, I'd be charging the card a second time, and my own idempotency key doesn't help here since the duplication would happen on the processor's side, outside my system. So instead of retrying the charge call, I'd query the processor's own status endpoint for that transaction and get a definite answer — succeeded, failed, or still processing — before deciding what to do. I only retry once I actually know it never went through.
Interviewer: A payment isn't just one step, though — charge, then ledger, then notify the merchant. How do you handle one of those steps failing after an earlier one already succeeded?
Candidate: I'd treat that whole sequence as a saga, with a central orchestrator that persists which step a given payment is currently on, and a defined compensating action for any step that needs one. If the charge succeeds with the processor but the ledger write fails, the compensation is issuing an actual refund back through the processor — because that step's side effect was real and external, so undoing it has to be a real action too, not just flipping an internal flag. If the merchant notification step fails, though, there's no compensation needed at all — I just retry sending it, because notifying twice is harmless and failing to notify only delays visibility, it doesn't leave any incorrect state behind.
Interviewer: Given all that, would you say you're fully confident this system never has an incorrect ledger?
Candidate: I'd want to build like it might still have bugs, honestly — idempotency and the saga are the first line of defense, but I'd also run an async reconciliation job that periodically pulls the payment processor's own settlement report and diffs it against my ledger. If something doesn't match and I can't explain why, that goes to a manual review queue rather than getting auto-corrected on a guess. "Should never happen" and "cannot happen" aren't the same claim, and this is exactly the kind of system where you build the second line of defense anyway.

AD. Online Solution Comparison

SourceApproachStrengthWeaknessWorth Learning
Stripe — "Idempotent Requests" (official API documentation)Documents Stripe's own real, production idempotency-key mechanism: a client-supplied key attached to any mutating request, a defined retention window (24 hours), and explicit handling of a request replayed with the same key but a different bodyA direct, first-party confirmation that mandatory client-generated idempotency keys with a bounded TTL — not an interview simplification — is exactly how a real, at-scale payment API protects against duplicate chargesDocuments the client-facing contract thoroughly but not the internal storage/locking implementation behind itConfirms the 24-hour TTL and the "same key, different body → error" behavior this design's Deep Dive 1 (M) adopts almost exactly
Brandur Leach — "Idempotency Keys" (brandur.org)A widely-cited, detailed walkthrough of implementing idempotency keys correctly, including the specific race of two concurrent requests carrying the same key arriving before either has finished processingGoes deeper than most vendor docs specifically on the concurrent in-flight case (M) — the exact same check-then-act race named generally in Patterns Playbook B, applied here to the idempotency record itselfWritten from one engineer's implementation experience rather than as an official specification — worth treating as a strong, well-reasoned pattern rather than a standardConfirms that handling the in-flight concurrent-retry case is a real, non-obvious detail worth naming explicitly, not just the simpler "already-completed" case
Martin Kleppmann — "Accounting for Computer Scientists"Explains double-entry bookkeeping from first principles for a software-engineering audience, framing an append-only ledger of balanced debit/credit entries as the correct data structure for any system that needs to track money movement reliablyA rigorous, independent explanation of exactly why this design's central decision (N) — balances as a computed aggregate over immutable entries, never a mutated field — is the standard, correct answer in real accounting systems, not an invented interview constructWritten as a conceptual primer, not a distributed-systems-scale implementation guide — doesn't address sharding or multi-region concerns directlyThe clearest available explanation of why double-entry accounting's balance invariant is what makes a ledger auditable at all
Chris Richardson — "Saga" (microservices.io)Catalogs the saga pattern formally, including the explicit orchestration-vs-choreography distinction and the requirement for a compensating transaction per step with a real side effectThe standard, widely-referenced description of exactly the pattern this design's Deep Dive 4 (P) applies — confirms orchestration vs. choreography is a well-established trade-off, not a false dichotomy invented for this problemDescribes the pattern generally, across many possible domains — doesn't address payment-specific concerns like idempotency-key propagation to an external processorConfirms the vocabulary (saga, compensating transaction, orchestration vs. choreography) this design's Deep Dive 4 uses is the field's actual standard terminology

AE. Interview Cheat Sheet

ProblemPayment System (Stripe-like)
DifficultyAdvanced — individually simple mechanisms, but zero tolerance for the small approximations that make most other problems in this set tractable
Core requirementsCharge, refund (full/partial), status query, async merchant notification — with an absolute, non-negotiable prohibition on any duplicated financial side effect
Central decisionAppend-only double-entry ledger as sole source of truth; mandatory client-supplied idempotency keys on every mutation; a saga with per-step compensation for the multi-step charge/ledger/notify sequence; async reconciliation against the processor's own records as a second line of defense
Why not a mutable balance columnDestroys reconstructable history — a bug or race silently corrupts the number with no way to determine how or what the correct value should be
IdempotencyClient-generated key, scoped per merchant, checked via atomic insert-if-absent, TTL'd (~24h) — mandatory, not optional (P.O, Pl.E)
Processor timeoutQuery the processor's status endpoint on ambiguity — never blindly retry a charge call that might have already succeeded
Multi-step failure handlingOrchestrated saga, durable per-transaction state, explicit compensating action per step with a real side effect (refund the charge, reverse the ledger entry) — no compensation needed for a merely-informational step like notification
ConsistencyStrongly consistent, ACID ledger commits at the core (alongside Ticketmaster, P8-V, as the two strongest islands in the document) — everything wrapping that core (notification, reconciliation) is deliberately async
Common mistakesMutable balance columns; trusting client-side UI as duplicate protection; blind retries on processor timeout; mutating a charge's amount to represent a refund; treating reconciliation as optional
What NOT to over-engineerBuilding the payment processor itself, or a full risk/fraud-scoring engine — name this system's scope as orchestrating and recording around an external processor, not replacing one