← All Posts
High Level Design Series · Real-World Designs· Post 62 of 70

Design: Payment System

Why Payment Systems Are Hard

Moving money between parties seems simple on the surface — debit one account, credit another. In practice, payment systems are among the most challenging distributed systems to build. Money cannot be lost, duplicated, or silently dropped. A single bug can mean millions in financial losses, regulatory fines, or permanent loss of customer trust.

The challenges stem from three fundamental truths:

Scale context: Stripe processes over $1 trillion in payment volume annually. Visa handles 65,000 transactions per second at peak. Our design targets 1 million transactions/day (~12 TPS average, ~100 TPS peak) — a mid-sized payment platform.

Requirements

Functional Requirements

RequirementDetails
Process paymentsAccept credit/debit card payments. Customer pays merchant through our platform. Support authorization & capture (hold funds then collect) and direct charge flows.
Handle refundsFull and partial refunds. Reverse the original transaction, credit the customer, debit the merchant. Refunds must reference the original payment.
Multi-currency supportAccept payments in USD, EUR, GBP, JPY, etc. Handle currency conversion at point of capture. Store amounts in smallest currency unit (cents, pence).
Exactly-once processingEvery payment must be processed exactly once. Retries must not cause duplicate charges. Idempotency keys ensure this.
Webhook notificationsNotify merchants asynchronously of payment outcomes: succeeded, failed, disputed, refunded.
ReconciliationCompare internal ledger with PSP settlement reports daily. Flag and resolve any discrepancies.

Non-Functional Requirements

RequirementTarget
Throughput1 million transactions/day (~12 TPS average, ~100 TPS peak)
Latency< 2 seconds for payment authorization (p99). Most latency is from PSP/bank.
Availability99.99% uptime. Payment downtime directly costs revenue.
ConsistencyStrong consistency for ledger entries. Every debit has a matching credit. Ledger must always balance.
DurabilityZero data loss for financial records. Synchronous replication to at least 2 replicas.
PCI DSS complianceNever store raw card numbers (PAN). Use tokenization via PSP. All card data encrypted in transit (TLS 1.2+).

The Payment Flow — End to End

A card payment involves six parties communicating in a precise sequence. Understanding this flow is critical before designing the system.

The Six Parties

Three Phases: Authorization, Capture, Settlement

Every card payment goes through three distinct phases:

Phase 1: AUTHORIZATION (real-time, ~200-500ms)
═══════════════════════════════════════════════
Customer → Merchant → Payment Service → PSP → Card Network → Issuing Bank
                                                                    │
"Can this customer pay $100?"                                       │
                                                                    ▼
                                                         Check: sufficient funds?
                                                         Check: card not blocked?
                                                         Check: fraud score OK?
                                                                    │
                                                                    ▼
Issuing Bank → Card Network → PSP → Payment Service → Merchant → Customer
                                                                    
"Yes — authorization code: AUTH-7X92. Funds held (not yet moved)."


Phase 2: CAPTURE (async, within 1-7 days of auth)
═══════════════════════════════════════════════════
Payment Service → PSP → Card Network → Issuing Bank

"Transfer the $100 we authorized under AUTH-7X92."

The issuing bank moves the funds from "held" to "captured."
Auth without capture expires after 7 days (Visa) or 30 days (Mastercard).


Phase 3: SETTLEMENT (batch, T+1 to T+3 business days)
══════════════════════════════════════════════════════
Issuing Bank → Card Network → Acquiring Bank → PSP → Payment Service → Merchant

Card network batches all captured transactions.
Issuing bank sends money to acquiring bank.
PSP deposits into merchant's bank account.
Minus interchange fees (1.5-3.5%) and PSP fees (2.9% + $0.30 for Stripe).

Timeline: Authorization happens in real-time. Settlement takes 2-3 business days.
Auth vs Capture: Hotels and car rentals authorize (hold) a large amount at check-in, then capture (charge) the actual amount at check-out. If they only need $150 of a $500 hold, they capture $150 and the remaining $350 hold is released. This is why you sometimes see a "pending charge" on your statement that later changes.

Interactive: Payment Transaction Flow

System Architecture

Our payment service sits between merchants and PSPs. It must be reliable, auditable, and idempotent.

┌─────────────────────────────────────────────────────────────────────────┐
│                          PAYMENT PLATFORM                               │
│                                                                         │
│  ┌──────────┐    ┌──────────────────┐    ┌──────────────────────────┐   │
│  │ API      │    │ Payment Service   │    │ PSP Integration Layer   │   │
│  │ Gateway  │───→│                  │───→│                          │   │
│  │          │    │ • Validate req    │    │ • Stripe adapter         │   │
│  │ • Auth   │    │ • Check idempot.  │    │ • PayPal adapter         │   │
│  │ • Rate   │    │ • Route to PSP    │    │ • Adyen adapter          │   │
│  │   limit  │    │ • Update ledger   │    │ • Retry logic            │   │
│  │ • TLS    │    │ • Fire webhooks   │    │ • Circuit breaker        │   │
│  └──────────┘    └────────┬─────────┘    └────────────┬─────────────┘   │
│                           │                            │                 │
│                    ┌──────▼──────┐              ┌──────▼──────┐         │
│                    │   Ledger    │              │  Webhook    │         │
│                    │   Service   │              │  Dispatcher │         │
│                    │             │              │             │         │
│                    │ Double-entry│              │ • Queue     │         │
│                    │ bookkeeping │              │ • Retry     │         │
│                    │ Immutable   │              │ • Signing   │         │
│                    └──────┬──────┘              └─────────────┘         │
│                           │                                             │
│                    ┌──────▼──────┐     ┌───────────────────┐           │
│                    │  PostgreSQL │     │  Reconciliation   │           │
│                    │  (Primary)  │────→│  Service          │           │
│                    │             │     │                   │           │
│                    │ Sync repli- │     │ • Daily matching  │           │
│                    │ cation to   │     │ • Discrepancy     │           │
│                    │ 2 replicas  │     │   alerting        │           │
│                    └─────────────┘     └───────────────────┘           │
└─────────────────────────────────────────────────────────────────────────┘

External:
  ┌─────────┐     ┌──────────┐     ┌─────────────┐     ┌──────────────┐
  │ Merchant│────→│ Our API  │────→│ PSP (Stripe) │────→│ Card Network │
  │ (Client)│     │ Gateway  │     │              │     │ (Visa, MC)   │
  └─────────┘     └──────────┘     └──────────────┘     └──────┬───────┘
                                                                │
                                                         ┌──────▼───────┐
                                                         │ Issuing Bank │
                                                         └──────────────┘

Component Responsibilities

API Gateway: TLS termination, authentication (API keys), rate limiting (per-merchant), request validation, and routing. Every request is logged with a correlation ID for tracing.

Payment Service: The core orchestrator. It receives payment requests, checks idempotency, selects the appropriate PSP, coordinates the authorization/capture flow, updates the ledger, and dispatches webhooks. This is a stateless service — all state lives in the database.

PSP Integration Layer: An adapter pattern that abstracts away PSP-specific APIs. Each PSP (Stripe, PayPal, Adyen) has its own adapter implementing a common interface. This layer handles retries, circuit breaking, and timeout management.

Ledger Service: Maintains the double-entry bookkeeping system. Every financial operation results in balanced debit/credit entries. The ledger is append-only and immutable — corrections are made with new reversing entries, never by modifying existing ones.

Webhook Dispatcher: Sends asynchronous notifications to merchants about payment events. Uses a queue (SQS/Kafka) with exponential backoff retries. Signs payloads with HMAC for verification.

Reconciliation Service: Runs daily, comparing our internal ledger against PSP settlement reports. Flags discrepancies for manual review. This is the financial safety net.

Database Schema

-- Payments table: one row per payment intent
CREATE TABLE payments (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    idempotency_key VARCHAR(255) UNIQUE NOT NULL,  -- client-provided, ensures exactly-once
    merchant_id     UUID NOT NULL REFERENCES merchants(id),
    amount          BIGINT NOT NULL,                -- in smallest currency unit (cents)
    currency        CHAR(3) NOT NULL,               -- ISO 4217: USD, EUR, GBP
    status          VARCHAR(20) NOT NULL DEFAULT 'CREATED',
                    -- CREATED → AUTHORIZED → CAPTURED → SETTLED
                    -- CREATED → AUTHORIZED → VOIDED
                    -- CREATED → DECLINED
                    -- CAPTURED → REFUNDED / PARTIALLY_REFUNDED
    psp_provider    VARCHAR(50),                    -- stripe, paypal, adyen
    psp_payment_id  VARCHAR(255),                   -- external ID from PSP
    auth_code       VARCHAR(100),                   -- authorization code from issuing bank
    description     TEXT,
    metadata        JSONB,                          -- arbitrary merchant-provided data
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    captured_at     TIMESTAMPTZ,
    settled_at      TIMESTAMPTZ,

    CONSTRAINT valid_status CHECK (status IN (
        'CREATED', 'AUTHORIZED', 'CAPTURED', 'SETTLED',
        'DECLINED', 'VOIDED', 'REFUNDED', 'PARTIALLY_REFUNDED', 'FAILED'
    )),
    CONSTRAINT positive_amount CHECK (amount > 0)
);

CREATE INDEX idx_payments_merchant ON payments(merchant_id);
CREATE INDEX idx_payments_status ON payments(status);
CREATE INDEX idx_payments_created ON payments(created_at);
CREATE INDEX idx_payments_psp ON payments(psp_provider, psp_payment_id);


-- Idempotency table: prevents duplicate processing
CREATE TABLE idempotency_keys (
    key             VARCHAR(255) PRIMARY KEY,
    merchant_id     UUID NOT NULL,
    payment_id      UUID REFERENCES payments(id),
    request_hash    VARCHAR(64) NOT NULL,   -- SHA-256 of request body
    response_code   INT,
    response_body   JSONB,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    expires_at      TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '24 hours'
);


-- Refunds table: tracks refund operations
CREATE TABLE refunds (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    payment_id      UUID NOT NULL REFERENCES payments(id),
    amount          BIGINT NOT NULL,        -- refund amount in cents
    reason          TEXT,
    status          VARCHAR(20) NOT NULL DEFAULT 'PENDING',
                    -- PENDING → PROCESSING → SUCCEEDED → FAILED
    psp_refund_id   VARCHAR(255),
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    completed_at    TIMESTAMPTZ,

    CONSTRAINT valid_refund_status CHECK (status IN (
        'PENDING', 'PROCESSING', 'SUCCEEDED', 'FAILED'
    ))
);


-- Double-entry ledger: the financial heart of the system
CREATE TABLE ledger_entries (
    id              BIGSERIAL PRIMARY KEY,
    transaction_id  UUID NOT NULL,          -- groups debit + credit pair
    payment_id      UUID REFERENCES payments(id),
    account_id      UUID NOT NULL REFERENCES accounts(id),
    entry_type      CHAR(1) NOT NULL,       -- 'D' = debit, 'C' = credit
    amount          BIGINT NOT NULL,        -- always positive
    currency        CHAR(3) NOT NULL,
    description     TEXT,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),

    CONSTRAINT valid_entry_type CHECK (entry_type IN ('D', 'C')),
    CONSTRAINT positive_ledger_amount CHECK (amount > 0)
);

CREATE INDEX idx_ledger_transaction ON ledger_entries(transaction_id);
CREATE INDEX idx_ledger_payment ON ledger_entries(payment_id);
CREATE INDEX idx_ledger_account ON ledger_entries(account_id);


-- Accounts: represents ledger accounts
CREATE TABLE accounts (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name            VARCHAR(255) NOT NULL,
    type            VARCHAR(20) NOT NULL,   -- ASSET, LIABILITY, EQUITY, REVENUE, EXPENSE
    currency        CHAR(3) NOT NULL,
    balance         BIGINT NOT NULL DEFAULT 0,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),

    CONSTRAINT valid_account_type CHECK (type IN (
        'ASSET', 'LIABILITY', 'EQUITY', 'REVENUE', 'EXPENSE'
    ))
);

-- Example accounts:
-- 'merchant_receivable' (ASSET)     — money owed to us by PSPs
-- 'merchant_payable' (LIABILITY)    — money we owe to merchants
-- 'platform_revenue' (REVENUE)      — our fee income
-- 'customer_refund' (LIABILITY)     — refunds owed to customers

Idempotency — The Most Critical Concept

In a payment system, idempotency is not optional — it is the single most important correctness guarantee. Without it, a network timeout during payment processing could result in the customer being charged twice. This is the nightmare scenario.

How Idempotency Works

Every payment request includes a client-generated idempotency key — a unique identifier (usually a UUID) that represents "this specific payment attempt." The server uses this key to detect and deduplicate retries.

// Client sends payment with idempotency key in header
POST /v1/payments
Idempotency-Key: pay_8f14e45f-ceea-467f-a830-5c1e3b4a0a72
Content-Type: application/json

{
  "amount": 10000,       // $100.00 in cents
  "currency": "usd",
  "merchant_id": "merch_abc123",
  "payment_method": "tok_visa_4242",
  "description": "Order #ORD-9901"
}

Server-Side Idempotency Flow

async function processPayment(req: PaymentRequest): PaymentResponse {
    const { idempotencyKey, merchantId } = req;

    // Step 1: Check if we've seen this idempotency key before
    const existing = await db.query(
        'SELECT payment_id, response_code, response_body FROM idempotency_keys WHERE key = $1',
        [idempotencyKey]
    );

    if (existing.rows.length > 0) {
        const row = existing.rows[0];

        // Step 2a: Verify request body matches (prevent key reuse with different params)
        const requestHash = sha256(JSON.stringify(req.body));
        if (row.request_hash !== requestHash) {
            throw new Error(
                'Idempotency key already used with different parameters. '
                + 'Generate a new key for a different payment.'
            );
        }

        // Step 2b: Return the cached response — DO NOT reprocess
        console.log(`Idempotent replay for key=${idempotencyKey}`);
        return { statusCode: row.response_code, body: row.response_body };
    }

    // Step 3: New request — acquire a lock on the idempotency key
    // This prevents two concurrent requests with the same key from both proceeding
    const lock = await db.query(
        `INSERT INTO idempotency_keys (key, merchant_id, request_hash, created_at)
         VALUES ($1, $2, $3, NOW())
         ON CONFLICT (key) DO NOTHING
         RETURNING key`,
        [idempotencyKey, merchantId, sha256(JSON.stringify(req.body))]
    );

    if (lock.rows.length === 0) {
        // Another concurrent request already claimed this key
        // Wait briefly and retry — it will hit the cache path above
        await sleep(500);
        return processPayment(req);  // recursive retry hits cached response
    }

    try {
        // Step 4: Process the payment (call PSP, update ledger, etc.)
        const result = await executePayment(req);

        // Step 5: Store the response for future idempotent replays
        await db.query(
            `UPDATE idempotency_keys
             SET payment_id = $1, response_code = $2, response_body = $3
             WHERE key = $4`,
            [result.paymentId, 200, JSON.stringify(result), idempotencyKey]
        );

        return result;
    } catch (error) {
        // If payment fails, store the error response too
        // so retries get the same error (don't retry a declined card)
        if (error instanceof DeclinedError) {
            await db.query(
                `UPDATE idempotency_keys SET response_code = $1, response_body = $2 WHERE key = $3`,
                [400, JSON.stringify({ error: error.message }), idempotencyKey]
            );
        } else {
            // For transient errors (timeout, 5xx), DELETE the idempotency key
            // so the client can safely retry
            await db.query('DELETE FROM idempotency_keys WHERE key = $1', [idempotencyKey]);
        }
        throw error;
    }
}
Key insight: The idempotency key + INSERT ON CONFLICT pattern is a distributed lock. If two identical requests arrive simultaneously, only one will successfully insert. The other will see the conflict, wait, and return the cached response. This guarantees exactly-once processing even under concurrent retries.

Idempotency Key Best Practices

Double-Entry Ledger

The double-entry bookkeeping system is the foundation of every financial system since 15th-century Venice. The core rule is simple but inviolable:

The Accounting Equation: Assets = Liabilities + Equity
Every transaction creates exactly two entries: a debit and a credit of equal amount. The ledger must always balance. If it doesn't, money has been lost or created from nothing — both are catastrophic.

Debit and Credit Rules

Account TypeDebit (DR) IncreasesCredit (CR) Increases
Asset (cash, receivables)✅ Yes❌ (decreases)
Liability (payables, refunds owed)❌ (decreases)✅ Yes
Revenue (fees earned)❌ (decreases)✅ Yes
Expense (PSP fees, costs)✅ Yes❌ (decreases)

Payment Example: Customer Pays $100

When a customer pays $100 through our platform (with a 2.9% platform fee):

Transaction: Customer pays $100 to Merchant
Platform fee: 2.9% = $2.90
Merchant receives: $97.10

Ledger Entries (all within a single DB transaction):
┌──────────────────────────┬───────┬────────┬────────┐
│ Account                  │ Type  │ Debit  │ Credit │
├──────────────────────────┼───────┼────────┼────────┤
│ PSP Receivable (Asset)   │ ASSET │ $100.00│        │  ← PSP owes us $100
│ Merchant Payable (Liab.) │ LIAB  │        │ $97.10 │  ← We owe merchant $97.10
│ Platform Revenue (Rev.)  │ REV   │        │  $2.90 │  ← We earned $2.90
├──────────────────────────┼───────┼────────┼────────┤
│ TOTALS                   │       │ $100.00│ $100.00│  ← BALANCED ✓
└──────────────────────────┴───────┴────────┴────────┘

SQL:
BEGIN;
  -- Debit: PSP owes us the full amount
  INSERT INTO ledger_entries (transaction_id, payment_id, account_id, entry_type, amount, currency, description)
  VALUES ('txn-001', 'pay-001', 'acct_psp_receivable', 'D', 10000, 'USD', 'Payment received via Stripe');

  -- Credit: We owe the merchant their share
  INSERT INTO ledger_entries (transaction_id, payment_id, account_id, entry_type, amount, currency, description)
  VALUES ('txn-001', 'pay-001', 'acct_merchant_payable', 'C', 9710, 'USD', 'Merchant payout for order #ORD-9901');

  -- Credit: Our platform fee revenue
  INSERT INTO ledger_entries (transaction_id, payment_id, account_id, entry_type, amount, currency, description)
  VALUES ('txn-001', 'pay-001', 'acct_platform_revenue', 'C', 290, 'USD', 'Platform fee 2.9%');

  -- Verify balance: total debits must equal total credits
  -- This check runs as a DB constraint or application-level assertion
COMMIT;

Balance Verification

-- This query must ALWAYS return 0. If it doesn't, you have a bug.
SELECT
    SUM(CASE WHEN entry_type = 'D' THEN amount ELSE 0 END) -
    SUM(CASE WHEN entry_type = 'C' THEN amount ELSE 0 END) AS imbalance
FROM ledger_entries
WHERE transaction_id = 'txn-001';

-- Result: 0 ✓ (10000 debits - 9710 - 290 credits = 0)


-- Global balance check (run daily in reconciliation):
SELECT
    SUM(CASE WHEN entry_type = 'D' THEN amount ELSE 0 END) AS total_debits,
    SUM(CASE WHEN entry_type = 'C' THEN amount ELSE 0 END) AS total_credits,
    SUM(CASE WHEN entry_type = 'D' THEN amount ELSE 0 END) -
    SUM(CASE WHEN entry_type = 'C' THEN amount ELSE 0 END) AS imbalance
FROM ledger_entries;

-- If imbalance ≠ 0, ALERT IMMEDIATELY. Money is missing or duplicated.

Interactive: Double-Entry Ledger

PSP Integration — Stripe API Examples

Our system integrates with PSPs through an adapter pattern. Here is the Stripe integration in detail, including error handling and retry logic.

Creating a Payment Intent (Stripe)

// Stripe uses the PaymentIntent API for the authorize → capture flow

// Step 1: Create a PaymentIntent (authorization)
const stripe = require('stripe')('sk_live_...');

async function createPaymentIntent(req: InternalPaymentRequest) {
    try {
        const paymentIntent = await stripe.paymentIntents.create({
            amount: req.amount,                    // 10000 = $100.00
            currency: req.currency,                // 'usd'
            payment_method: req.paymentMethodToken, // 'pm_card_visa' (tokenized)
            confirm: true,                         // authorize immediately
            capture_method: 'manual',              // hold funds, capture later
            metadata: {
                internal_payment_id: req.paymentId,
                merchant_id: req.merchantId,
                order_id: req.orderId,
            },
            idempotency_key: req.idempotencyKey,  // Stripe's built-in idempotency
        }, {
            idempotencyKey: req.idempotencyKey,    // header-level idempotency
        });

        return {
            pspPaymentId: paymentIntent.id,        // 'pi_3N7vJ2...'
            status: mapStripeStatus(paymentIntent.status),
            authCode: paymentIntent.charges?.data[0]?.authorization_code,
        };
    } catch (error) {
        if (error.type === 'StripeCardError') {
            // Card declined — permanent failure
            return { status: 'DECLINED', declineCode: error.decline_code };
            // decline_code: 'insufficient_funds', 'stolen_card', 'expired_card'
        }
        if (error.type === 'StripeAPIError' || error.type === 'StripeConnectionError') {
            // Transient — safe to retry with same idempotency key
            throw new RetryableError(error.message);
        }
        throw error;
    }
}

function mapStripeStatus(stripeStatus: string): string {
    const mapping = {
        'requires_payment_method': 'FAILED',
        'requires_confirmation':   'CREATED',
        'requires_action':         'PENDING_3DS',   // 3D Secure needed
        'processing':              'PROCESSING',
        'requires_capture':        'AUTHORIZED',     // funds held, ready to capture
        'canceled':                'VOIDED',
        'succeeded':               'CAPTURED',       // auto-captured payments
    };
    return mapping[stripeStatus] || 'UNKNOWN';
}

Capturing a Payment

// Step 2: Capture — transfer the held funds (called when order ships)

async function capturePayment(pspPaymentId: string, amount?: number) {
    try {
        const captured = await stripe.paymentIntents.capture(pspPaymentId, {
            amount_to_capture: amount,  // optional: partial capture
        });

        // Update our ledger
        await ledger.recordCapture({
            paymentId: captured.metadata.internal_payment_id,
            amount: captured.amount_received,
            currency: captured.currency,
        });

        return { status: 'CAPTURED', amountCaptured: captured.amount_received };
    } catch (error) {
        if (error.code === 'payment_intent_unexpected_state') {
            // Already captured, expired, or canceled
            // Check current state and reconcile
            const current = await stripe.paymentIntents.retrieve(pspPaymentId);
            return { status: mapStripeStatus(current.status) };
        }
        throw error;
    }
}


// Step 3: Refund

async function refundPayment(pspPaymentId: string, amount: number, reason: string) {
    const refund = await stripe.refunds.create({
        payment_intent: pspPaymentId,
        amount: amount,             // partial refund amount in cents
        reason: reason,             // 'requested_by_customer', 'duplicate', 'fraudulent'
    });

    // Record refund in ledger (reverse entries)
    await ledger.recordRefund({
        paymentId: refund.payment_intent,
        refundId: refund.id,
        amount: refund.amount,
        currency: refund.currency,
    });

    return { refundId: refund.id, status: refund.status }; // 'succeeded' or 'pending'
}

PSP Adapter Pattern

// Common interface — all PSP adapters implement this
interface PSPAdapter {
    createPayment(req: PaymentRequest): Promise<PSPPaymentResult>;
    capturePayment(pspId: string, amount?: number): Promise<CaptureResult>;
    refundPayment(pspId: string, amount: number): Promise<RefundResult>;
    getPaymentStatus(pspId: string): Promise<PaymentStatus>;
}

// Stripe adapter
class StripeAdapter implements PSPAdapter { /* ... as above ... */ }

// PayPal adapter
class PayPalAdapter implements PSPAdapter {
    async createPayment(req: PaymentRequest): Promise<PSPPaymentResult> {
        // PayPal uses Orders API instead of PaymentIntents
        const order = await paypal.orders.create({
            intent: 'AUTHORIZE',
            purchase_units: [{
                amount: { currency_code: req.currency.toUpperCase(), value: (req.amount / 100).toFixed(2) },
                reference_id: req.paymentId,
            }],
        });
        return { pspPaymentId: order.id, status: mapPayPalStatus(order.status) };
    }
    // ... capture, refund, getStatus
}

// PSP router — selects the right adapter based on merchant config or payment method
class PSPRouter {
    private adapters: Map<string, PSPAdapter>;

    async route(req: PaymentRequest): Promise<PSPAdapter> {
        // Routing logic:
        // 1. Check merchant preference (some merchants prefer specific PSPs)
        // 2. Check payment method (PayPal for PayPal payments, Stripe for cards)
        // 3. Check geographic optimization (Adyen for EU, Stripe for US)
        // 4. Check PSP health (circuit breaker status)
        const preferredPSP = req.merchantConfig.preferredPSP || 'stripe';
        const adapter = this.adapters.get(preferredPSP);

        if (!adapter || this.circuitBreaker.isOpen(preferredPSP)) {
            return this.adapters.get(this.getFallbackPSP(preferredPSP));
        }
        return adapter;
    }
}

Handling Failures

In payment systems, the failure modes are more important than the happy path. Every failure must be handled without losing money or double-charging customers.

Failure 1: PSP Timeout

The most dangerous failure. You sent a payment request to Stripe. The network timed out after 30 seconds. Did the payment go through or not?

// WRONG: Don't just retry blindly!
// If Stripe processed the first request, retrying without idempotency = double charge!

// CORRECT: Check status first, then retry with idempotency key
async function handlePSPTimeout(paymentId: string, idempotencyKey: string) {
    // Step 1: Check if the PSP actually processed the payment
    try {
        const status = await pspAdapter.getPaymentStatus(paymentId);
        if (status === 'AUTHORIZED' || status === 'CAPTURED') {
            // Payment went through despite timeout — update our records
            await updatePaymentStatus(paymentId, status);
            return { status, message: 'Payment succeeded (recovered from timeout)' };
        }
        if (status === 'DECLINED') {
            await updatePaymentStatus(paymentId, 'DECLINED');
            return { status: 'DECLINED' };
        }
    } catch (checkError) {
        // Can't even check status — PSP is completely down
        console.error('PSP status check failed:', checkError);
    }

    // Step 2: If status unknown or PSP unreachable, retry with the SAME idempotency key
    // The idempotency key guarantees the PSP won't process it twice
    try {
        const result = await pspAdapter.createPayment({
            ...originalRequest,
            idempotencyKey: idempotencyKey,  // SAME key as original attempt
        });
        return result;
    } catch (retryError) {
        // Step 3: If retry also fails, mark payment as PENDING_REVIEW
        // A human or reconciliation process will resolve it
        await updatePaymentStatus(paymentId, 'PENDING_REVIEW');
        await alertOpsTeam({
            type: 'PSP_TIMEOUT_UNRESOLVED',
            paymentId,
            message: 'Payment in unknown state after PSP timeout and retry failure',
        });
        return { status: 'PENDING_REVIEW' };
    }
}

Failure 2: Network Failure (Between Client and Our Service)

// The merchant's server sends us a payment request.
// Our service processes it and sends back the response.
// The response is lost due to network failure.
// The merchant never gets the response.

// Without idempotency:
// Merchant retries → we process AGAIN → customer charged TWICE!

// With idempotency:
// Merchant retries with SAME idempotency key →
// We find the cached response → return it immediately →
// Customer charged ONCE ✓

// This is why EVERY payment API must support idempotency keys.
// It's not a nice-to-have. It's a correctness requirement.

Failure 3: Bank Declines the Transaction

// The issuing bank declines the transaction.
// Common decline codes (Stripe):

const DECLINE_CODES = {
    'insufficient_funds':     'Card has insufficient funds',
    'lost_card':              'Card reported lost',
    'stolen_card':            'Card reported stolen',
    'expired_card':           'Card has expired',
    'incorrect_cvc':          'CVC verification failed',
    'processing_error':       'Bank processing error — may be retried',
    'do_not_honor':           'Bank refuses (generic) — do not retry',
    'fraudulent':             'Bank suspects fraud',
    'withdrawal_count_limit': 'Daily transaction limit exceeded',
};

async function handleDecline(paymentId: string, declineCode: string) {
    // Update payment status
    await db.query(
        'UPDATE payments SET status = $1, updated_at = NOW() WHERE id = $2',
        ['DECLINED', paymentId]
    );

    // Store the decline in idempotency cache
    // so retries with same key get the same decline (not a new attempt)
    await cacheDeclineResponse(paymentId, declineCode);

    // Notify the merchant via webhook
    await webhookDispatcher.send(merchantId, {
        type: 'payment.declined',
        payment_id: paymentId,
        decline_code: declineCode,
        message: DECLINE_CODES[declineCode] || 'Payment declined by bank',
        retryable: declineCode === 'processing_error',  // only this one is retryable
    });

    // For 'processing_error' ONLY: schedule an automatic retry after 1 hour
    if (declineCode === 'processing_error') {
        await retryQueue.enqueue({
            paymentId,
            retryAt: Date.now() + 3600000,  // 1 hour
            maxRetries: 3,
            currentRetry: 0,
        });
    }
}

Retry Strategy with Exponential Backoff

class PaymentRetryPolicy {
    // Only retry on transient failures. NEVER retry on:
    // - Card declined (insufficient funds, stolen, expired)
    // - Invalid request (bad amount, missing fields)
    // - Authentication failure (bad API key)

    private static RETRYABLE_ERRORS = new Set([
        'StripeAPIError',           // 500 from Stripe
        'StripeConnectionError',    // Network timeout
        'ECONNRESET',               // TCP connection reset
        'ETIMEDOUT',                // Connection timeout
    ]);

    static shouldRetry(error: Error, attempt: number): boolean {
        if (attempt >= 3) return false;  // max 3 retries
        return this.RETRYABLE_ERRORS.has(error.name || error.code);
    }

    static getDelay(attempt: number): number {
        // Exponential backoff with jitter
        const baseDelay = 1000;  // 1 second
        const maxDelay = 30000;  // 30 seconds
        const exponential = baseDelay * Math.pow(2, attempt);
        const jitter = Math.random() * 1000;
        return Math.min(exponential + jitter, maxDelay);
        // Attempt 0: ~1s, Attempt 1: ~2s, Attempt 2: ~4s
    }
}

Webhook Handling

PSPs send asynchronous notifications (webhooks) for events that happen outside of direct API calls: successful settlements, disputes, chargebacks, and refund completions. Reliable webhook handling is critical.

Receiving Webhooks from Stripe

const express = require('express');
const stripe = require('stripe')('sk_live_...');

// Webhook endpoint — receives POST from Stripe
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), async (req, res) => {
    const sig = req.headers['stripe-signature'];
    const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;  // 'whsec_...'

    let event;
    try {
        // Step 1: VERIFY the webhook signature (CRITICAL for security)
        // Prevents attackers from sending fake webhook events
        event = stripe.webhooks.constructEvent(req.body, sig, webhookSecret);
    } catch (err) {
        console.error('Webhook signature verification failed:', err.message);
        return res.status(400).send('Invalid signature');
    }

    // Step 2: Deduplicate — check if we've already processed this event
    const alreadyProcessed = await db.query(
        'SELECT id FROM processed_webhooks WHERE event_id = $1',
        [event.id]
    );
    if (alreadyProcessed.rows.length > 0) {
        console.log(`Webhook ${event.id} already processed, skipping`);
        return res.status(200).json({ received: true });  // Return 200 to stop retries
    }

    // Step 3: Process the event based on type
    try {
        switch (event.type) {
            case 'payment_intent.succeeded':
                await handlePaymentSucceeded(event.data.object);
                break;
            case 'payment_intent.payment_failed':
                await handlePaymentFailed(event.data.object);
                break;
            case 'charge.dispute.created':
                await handleDisputeCreated(event.data.object);
                break;
            case 'charge.refunded':
                await handleRefundCompleted(event.data.object);
                break;
            case 'payout.paid':
                await handleSettlementReceived(event.data.object);
                break;
            default:
                console.log(`Unhandled webhook type: ${event.type}`);
        }

        // Step 4: Mark as processed
        await db.query(
            'INSERT INTO processed_webhooks (event_id, event_type, processed_at) VALUES ($1, $2, NOW())',
            [event.id, event.type]
        );

        res.status(200).json({ received: true });
    } catch (processingError) {
        console.error(`Error processing webhook ${event.id}:`, processingError);
        // Return 500 so Stripe retries the webhook
        // Stripe retries up to 3 days with exponential backoff
        res.status(500).json({ error: 'Processing failed, will retry' });
    }
});


async function handlePaymentSucceeded(paymentIntent) {
    const internalPaymentId = paymentIntent.metadata.internal_payment_id;

    await db.query(
        'UPDATE payments SET status = $1, captured_at = NOW(), updated_at = NOW() WHERE id = $2',
        ['CAPTURED', internalPaymentId]
    );

    // Notify our merchant via our own webhook system
    await webhookDispatcher.send(paymentIntent.metadata.merchant_id, {
        type: 'payment.succeeded',
        payment_id: internalPaymentId,
        amount: paymentIntent.amount_received,
        currency: paymentIntent.currency,
    });
}

async function handleDisputeCreated(dispute) {
    // A customer disputed (chargeback) a payment.
    // The merchant has ~7 days to respond with evidence.
    const paymentId = dispute.payment_intent;

    await db.query(
        'UPDATE payments SET status = $1, updated_at = NOW() WHERE psp_payment_id = $2',
        ['DISPUTED', paymentId]
    );

    // Create a ledger hold — the disputed amount is frozen
    await ledger.recordDispute({
        paymentId,
        amount: dispute.amount,
        currency: dispute.currency,
    });

    // URGENT notification to merchant
    await webhookDispatcher.send(merchantId, {
        type: 'payment.disputed',
        dispute_id: dispute.id,
        amount: dispute.amount,
        reason: dispute.reason,          // 'fraudulent', 'product_not_received', etc.
        evidence_due_by: dispute.evidence_details.due_by,
    });
}

Sending Webhooks to Merchants

class WebhookDispatcher {
    // Our merchants register webhook URLs: https://merchant.com/webhooks/payments

    async send(merchantId: string, payload: WebhookPayload): Promise<void> {
        const merchant = await db.query(
            'SELECT webhook_url, webhook_secret FROM merchants WHERE id = $1',
            [merchantId]
        );

        if (!merchant.rows[0]?.webhook_url) return;

        const { webhook_url, webhook_secret } = merchant.rows[0];

        // Sign the payload with HMAC-SHA256
        const timestamp = Math.floor(Date.now() / 1000);
        const payloadStr = JSON.stringify(payload);
        const signature = crypto
            .createHmac('sha256', webhook_secret)
            .update(`${timestamp}.${payloadStr}`)
            .digest('hex');

        // Enqueue for reliable delivery
        await webhookQueue.enqueue({
            id: uuidv4(),
            merchantId,
            url: webhook_url,
            payload: payloadStr,
            signature: `t=${timestamp},v1=${signature}`,
            attempts: 0,
            maxAttempts: 5,
            nextRetryAt: Date.now(),
        });
    }
}

// Webhook delivery worker (processes the queue)
async function deliverWebhook(job: WebhookJob): Promise<void> {
    try {
        const response = await fetch(job.url, {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
                'X-Webhook-Signature': job.signature,
                'X-Webhook-Id': job.id,
            },
            body: job.payload,
            signal: AbortSignal.timeout(10000),  // 10s timeout
        });

        if (response.status >= 200 && response.status < 300) {
            await markWebhookDelivered(job.id);
        } else {
            throw new Error(`Webhook returned ${response.status}`);
        }
    } catch (error) {
        job.attempts++;
        if (job.attempts >= job.maxAttempts) {
            await markWebhookFailed(job.id);
            await alertOpsTeam({ type: 'WEBHOOK_DELIVERY_FAILED', jobId: job.id });
        } else {
            // Exponential backoff: 1min, 5min, 25min, 2hrs, 10hrs
            const delays = [60, 300, 1500, 7200, 36000];
            job.nextRetryAt = Date.now() + delays[job.attempts - 1] * 1000;
            await webhookQueue.reschedule(job);
        }
    }
}

Refund Flow

Refunds reverse the original payment. In a double-entry ledger, a refund creates new reversing entries — we never delete or modify the original entries.

async function processRefund(paymentId: string, amount: number, reason: string) {
    // Step 1: Validate the refund
    const payment = await db.query('SELECT * FROM payments WHERE id = $1', [paymentId]);
    if (!payment.rows[0]) throw new Error('Payment not found');

    const pay = payment.rows[0];
    if (pay.status !== 'CAPTURED' && pay.status !== 'PARTIALLY_REFUNDED') {
        throw new Error(`Cannot refund payment in status: ${pay.status}`);
    }

    // Check total refunds don't exceed original amount
    const totalRefunded = await db.query(
        "SELECT COALESCE(SUM(amount), 0) as total FROM refunds WHERE payment_id = $1 AND status = 'SUCCEEDED'",
        [paymentId]
    );
    if (totalRefunded.rows[0].total + amount > pay.amount) {
        throw new Error('Refund amount exceeds original payment');
    }

    // Step 2: Create refund record
    const refundId = uuidv4();
    await db.query(
        'INSERT INTO refunds (id, payment_id, amount, reason, status) VALUES ($1, $2, $3, $4, $5)',
        [refundId, paymentId, amount, reason, 'PROCESSING']
    );

    // Step 3: Call PSP to process refund
    const pspResult = await pspAdapter.refundPayment(pay.psp_payment_id, amount);

    // Step 4: Record REVERSING ledger entries
    await ledger.recordRefund(paymentId, refundId, amount, pay.currency);

    // Step 5: Update statuses
    const newStatus = (totalRefunded.rows[0].total + amount === pay.amount)
        ? 'REFUNDED' : 'PARTIALLY_REFUNDED';

    await db.query(
        'UPDATE payments SET status = $1, updated_at = NOW() WHERE id = $2',
        [newStatus, paymentId]
    );
    await db.query(
        'UPDATE refunds SET status = $1, psp_refund_id = $2, completed_at = NOW() WHERE id = $3',
        ['SUCCEEDED', pspResult.refundId, refundId]
    );

    return { refundId, status: 'SUCCEEDED', amount };
}

Refund Ledger Entries

Refund $100: Reverse the original payment entries

Original payment entries:
  DR  PSP Receivable     $100.00
  CR  Merchant Payable    $97.10
  CR  Platform Revenue     $2.90

Refund reversal entries:
┌──────────────────────────┬───────┬────────┬────────┐
│ Account                  │ Type  │ Debit  │ Credit │
├──────────────────────────┼───────┼────────┼────────┤
│ Merchant Payable (Liab.) │ LIAB  │ $97.10 │        │  ← We no longer owe merchant
│ Platform Revenue (Rev.)  │ REV   │  $2.90 │        │  ← Reverse our fee
│ PSP Receivable (Asset)   │ ASSET │        │ $100.00│  ← PSP no longer owes us
├──────────────────────────┼───────┼────────┼────────┤
│ TOTALS                   │       │ $100.00│ $100.00│  ← BALANCED ✓
└──────────────────────────┴───────┴────────┴────────┘

After refund, the net effect across both transactions is $0.00 on every account.
This is the beauty of double-entry: refunds are just mirror-image entries.

Reconciliation

Reconciliation is the process of comparing our internal ledger against PSP settlement reports to ensure they match. It's the financial safety net that catches bugs, fraud, and integration errors.

The Reconciliation Process

// Reconciliation runs daily at 2 AM UTC

class ReconciliationService {
    async runDailyReconciliation(date: string): Promise<ReconReport> {
        const report: ReconReport = {
            date,
            matched: 0,
            mismatched: 0,
            missingInLedger: 0,
            missingInPSP: 0,
            discrepancies: [],
        };

        // Step 1: Fetch PSP settlement report for the day
        // Stripe provides this via the Balance Transactions API
        const pspTransactions = await stripe.balanceTransactions.list({
            created: {
                gte: Math.floor(new Date(`${date}T00:00:00Z`).getTime() / 1000),
                lt: Math.floor(new Date(`${date}T23:59:59Z`).getTime() / 1000),
            },
            limit: 100,  // paginate for more
            type: 'charge',
        });

        // Step 2: Fetch our internal ledger entries for the same day
        const internalTransactions = await db.query(
            `SELECT p.id, p.amount, p.currency, p.psp_payment_id, p.status,
                    SUM(CASE WHEN le.entry_type = 'D' THEN le.amount ELSE 0 END) as total_debits,
                    SUM(CASE WHEN le.entry_type = 'C' THEN le.amount ELSE 0 END) as total_credits
             FROM payments p
             LEFT JOIN ledger_entries le ON le.payment_id = p.id
             WHERE p.created_at >= $1 AND p.created_at < $2
             GROUP BY p.id`,
            [`${date}T00:00:00Z`, `${date}T23:59:59Z`]
        );

        // Step 3: Match PSP transactions with internal records
        const internalMap = new Map(internalTransactions.rows.map(r => [r.psp_payment_id, r]));
        const pspMap = new Map(pspTransactions.data.map(t => [t.source, t]));

        // Check every PSP transaction has a matching internal record
        for (const pspTxn of pspTransactions.data) {
            const internal = internalMap.get(pspTxn.source);

            if (!internal) {
                report.missingInLedger++;
                report.discrepancies.push({
                    type: 'MISSING_IN_LEDGER',
                    pspId: pspTxn.source,
                    pspAmount: pspTxn.amount,
                    severity: 'HIGH',
                    // PSP charged the customer but we have no record!
                });
                continue;
            }

            // Compare amounts
            if (internal.amount !== pspTxn.amount) {
                report.mismatched++;
                report.discrepancies.push({
                    type: 'AMOUNT_MISMATCH',
                    paymentId: internal.id,
                    pspId: pspTxn.source,
                    internalAmount: internal.amount,
                    pspAmount: pspTxn.amount,
                    difference: internal.amount - pspTxn.amount,
                    severity: 'CRITICAL',
                });
                continue;
            }

            // Verify ledger balances (debits = credits for this payment)
            if (internal.total_debits !== internal.total_credits) {
                report.discrepancies.push({
                    type: 'LEDGER_IMBALANCE',
                    paymentId: internal.id,
                    debits: internal.total_debits,
                    credits: internal.total_credits,
                    severity: 'CRITICAL',
                });
                continue;
            }

            report.matched++;
        }

        // Check for internal records with no PSP match
        for (const [pspId, internal] of internalMap) {
            if (!pspMap.has(pspId) && internal.status === 'CAPTURED') {
                report.missingInPSP++;
                report.discrepancies.push({
                    type: 'MISSING_IN_PSP',
                    paymentId: internal.id,
                    internalAmount: internal.amount,
                    severity: 'HIGH',
                    // We think we captured, but PSP has no record!
                });
            }
        }

        // Step 4: Alert on discrepancies
        if (report.discrepancies.length > 0) {
            await alertFinanceTeam(report);
        }

        // Step 5: Store the report
        await db.query(
            'INSERT INTO reconciliation_reports (date, report_json) VALUES ($1, $2)',
            [date, JSON.stringify(report)]
        );

        return report;
    }
}
Real-world insight: Even well-built payment systems have a small discrepancy rate (0.01–0.1%). Common causes include: PSP settlement timing differences (T+2 vs your T+1 cutoff), currency conversion rounding, partial captures, and race conditions between webhooks and batch settlement files. The reconciliation system must handle these gracefully — not all discrepancies are bugs.

PCI DSS Compliance

PCI DSS (Payment Card Industry Data Security Standard) is a mandatory security standard for any system that handles credit card data. Non-compliance can result in fines of $5,000–$100,000 per month and the inability to process card payments.

PCI Compliance Levels

LevelCriteriaValidation
Level 1> 6 million transactions/yearAnnual on-site audit by QSA
Level 21–6 million transactions/yearSelf-Assessment Questionnaire (SAQ)
Level 320,000–1 million e-commerceSAQ + quarterly network scan
Level 4< 20,000 e-commerceSAQ (simplified)

Tokenization: Never Touch Raw Card Numbers

// THE GOLDEN RULE: Raw card numbers (PAN) never touch our servers.
// The customer's browser sends card data DIRECTLY to the PSP (Stripe.js / Stripe Elements).
// We receive a token that represents the card.

// Frontend (customer's browser):
// Stripe Elements collects card data and returns a token
const { paymentMethod } = await stripe.createPaymentMethod({
    type: 'card',
    card: cardElement,  // Stripe Elements UI component
    billing_details: { name: 'Alice Johnson' },
});
// paymentMethod.id = 'pm_1N7vJ2...' (this is a TOKEN, not a card number)

// Our server ONLY receives the token — never the actual card number
// 'pm_1N7vJ2...' → represents card 4242 **** **** 4242 inside Stripe's vault

// Backend (our server):
app.post('/api/payments', async (req, res) => {
    const { paymentMethodToken, amount, currency } = req.body;

    // paymentMethodToken = 'pm_1N7vJ2...' — a safe, non-sensitive token
    // We NEVER see: card number, CVV, expiry date

    const payment = await stripe.paymentIntents.create({
        amount: amount,
        currency: currency,
        payment_method: paymentMethodToken,  // pass the token to Stripe
        confirm: true,
    });
});

// Data flow:
//
//  Customer Browser                    Our Server           Stripe
//  ─────────────────                   ──────────           ──────
//  Card: 4242424242424242     ────────────────────────────→ Stores card
//  CVV: 123, Exp: 12/28                                    Returns token
//  ←───────────────────────────────────────────────────────
//  Token: pm_1N7vJ2...       ─────→ Receives token ──────→ Charges card
//                                   (no card data!)         using token
//
// Our server NEVER sees the raw card number. PCI scope = minimal (SAQ-A).

Key PCI DSS Requirements

Pro tip: Use Stripe Elements or Stripe.js to collect card data on the frontend. This means card numbers never touch your servers, reducing your PCI scope from SAQ-D (300+ requirements) to SAQ-A (22 requirements). This is by far the easiest path to PCI compliance.

Multi-Currency Handling

// Rule 1: Always store amounts in the SMALLEST currency unit (cents, pence, yen)
// This avoids floating-point precision errors

// WRONG:
amount = 19.99;   // floating-point: 19.99 * 100 = 1998.9999999999998

// CORRECT:
amount = 1999;    // $19.99 stored as 1999 cents — integer, precise


// Rule 2: Currency-specific smallest units
const CURRENCY_DECIMALS = {
    'USD': 2,   // $1.00 = 100 cents
    'EUR': 2,   // €1.00 = 100 cents
    'GBP': 2,   // £1.00 = 100 pence
    'JPY': 0,   // ¥1 = 1 (no decimal subdivision)
    'BHD': 3,   // 1 BHD = 1000 fils (3 decimal places!)
};

function toSmallestUnit(amount: number, currency: string): number {
    const decimals = CURRENCY_DECIMALS[currency.toUpperCase()] ?? 2;
    return Math.round(amount * Math.pow(10, decimals));
}
// toSmallestUnit(19.99, 'USD') = 1999
// toSmallestUnit(1000, 'JPY') = 1000 (already smallest unit)


// Rule 3: Currency conversion at capture time
// Authorization may be in EUR, but settlement is in USD
// Use the exchange rate at the moment of capture, not authorization

async function convertCurrency(amount: number, from: string, to: string): Promise<number> {
    const rate = await exchangeRateService.getRate(from, to);
    // rate = 1.08 for EUR → USD

    // Convert and round to smallest unit of target currency
    const converted = Math.round(amount * rate);

    // Store the conversion for audit trail
    await db.query(
        `INSERT INTO currency_conversions (original_amount, original_currency,
         converted_amount, converted_currency, exchange_rate, converted_at)
         VALUES ($1, $2, $3, $4, $5, NOW())`,
        [amount, from, converted, to, rate]
    );

    return converted;
}

// Rule 4: Separate ledger accounts per currency
// Don't mix USD and EUR in the same account!
// Account: 'merchant_payable_USD', 'merchant_payable_EUR', etc.

Payment State Machine

Payment Status Transitions:

  ┌─────────┐
  │ CREATED │─────────────────────────────────┐
  └────┬────┘                                 │
       │ authorize()                          │ decline
       ▼                                      ▼
  ┌────────────┐                        ┌──────────┐
  │ AUTHORIZED │                        │ DECLINED │ (terminal)
  └─────┬──────┘                        └──────────┘
        │
   ┌────┴─────┐
   │          │
   │ capture()│ void()
   ▼          ▼
┌──────────┐  ┌────────┐
│ CAPTURED │  │ VOIDED │ (terminal: auth expired or cancelled)
└────┬─────┘  └────────┘
     │
     │ settle()                    refund()
     ▼                          ┌─────────────────┐
┌─────────┐    full refund      │                 │
│ SETTLED │───────────────────→ │    REFUNDED     │ (terminal)
└────┬────┘                     └─────────────────┘
     │         partial refund
     └───────────────────────→ PARTIALLY_REFUNDED ──→ REFUNDED


Valid transitions (enforced in code):
  CREATED     → AUTHORIZED, DECLINED, FAILED
  AUTHORIZED  → CAPTURED, VOIDED
  CAPTURED    → SETTLED, REFUNDED, PARTIALLY_REFUNDED, DISPUTED
  SETTLED     → REFUNDED, PARTIALLY_REFUNDED
  DISPUTED    → CAPTURED (dispute won), REFUNDED (dispute lost)

Invalid transitions (throw error):
  DECLINED    → anything (terminal state)
  VOIDED      → anything (terminal state)
  REFUNDED    → anything (terminal state)

Key Takeaways