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:
- Exactly-once semantics — A customer must never be charged twice for the same purchase, and a merchant must never receive double payment. In a distributed system where networks fail, services crash, and retries happen, achieving exactly-once is extraordinarily difficult.
- Multi-party coordination — A single payment touches your API, your payment service, a Payment Service Provider (Stripe, PayPal), a card network (Visa, Mastercard), and an issuing bank. Each of these can fail independently.
- Regulatory compliance — PCI DSS mandates that raw card numbers never touch your servers. Financial regulations require an auditable ledger where every cent is accounted for. Getting this wrong is not just a bug — it is a legal violation.
Requirements
Functional Requirements
| Requirement | Details |
|---|---|
| Process payments | Accept credit/debit card payments. Customer pays merchant through our platform. Support authorization & capture (hold funds then collect) and direct charge flows. |
| Handle refunds | Full and partial refunds. Reverse the original transaction, credit the customer, debit the merchant. Refunds must reference the original payment. |
| Multi-currency support | Accept payments in USD, EUR, GBP, JPY, etc. Handle currency conversion at point of capture. Store amounts in smallest currency unit (cents, pence). |
| Exactly-once processing | Every payment must be processed exactly once. Retries must not cause duplicate charges. Idempotency keys ensure this. |
| Webhook notifications | Notify merchants asynchronously of payment outcomes: succeeded, failed, disputed, refunded. |
| Reconciliation | Compare internal ledger with PSP settlement reports daily. Flag and resolve any discrepancies. |
Non-Functional Requirements
| Requirement | Target |
|---|---|
| Throughput | 1 million transactions/day (~12 TPS average, ~100 TPS peak) |
| Latency | < 2 seconds for payment authorization (p99). Most latency is from PSP/bank. |
| Availability | 99.99% uptime. Payment downtime directly costs revenue. |
| Consistency | Strong consistency for ledger entries. Every debit has a matching credit. Ledger must always balance. |
| Durability | Zero data loss for financial records. Synchronous replication to at least 2 replicas. |
| PCI DSS compliance | Never 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
- Customer (Cardholder) — The person paying. They enter card details on the merchant's checkout page.
- Merchant — The business receiving payment. They integrate with our payment service via API.
- Payment Service (Us) — Our platform. Accepts payment requests from merchants, routes to PSPs, maintains the ledger, handles retries and idempotency.
- PSP (Payment Service Provider) — Stripe, PayPal, Adyen, Square. They connect to card networks and handle the low-level card processing. We never touch raw card data — the PSP does.
- Card Network — Visa, Mastercard, American Express. They route authorization requests between the PSP (acquiring bank) and the issuing bank. They set interchange fees.
- Issuing Bank — The customer's bank that issued the card. They approve or decline the transaction based on the customer's available funds, fraud rules, and spending limits.
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.
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;
}
}
Idempotency Key Best Practices
- Client generates the key — Typically a UUID v4. The client must store this key and reuse it for retries of the same payment.
- Keys expire — 24 hours is typical. After expiry, the key can be reused (though it shouldn't be for the same payment).
- Hash the request body — Prevent misuse where the same key is sent with different payment amounts.
- Store the full response — Retries return the exact same response, including error responses for declined cards.
- Transient vs permanent failures — Delete the key on transient failures (timeout) so the client can retry. Keep the key on permanent failures (declined) so the client gets a consistent error.
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:
Assets = Liabilities + EquityEvery 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 Type | Debit (DR) Increases | Credit (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;
}
}
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
| Level | Criteria | Validation |
|---|---|---|
| Level 1 | > 6 million transactions/year | Annual on-site audit by QSA |
| Level 2 | 1–6 million transactions/year | Self-Assessment Questionnaire (SAQ) |
| Level 3 | 20,000–1 million e-commerce | SAQ + quarterly network scan |
| Level 4 | < 20,000 e-commerce | SAQ (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
- Requirement 3: Protect stored cardholder data — With tokenization, we don't store any. Our PCI scope is minimal (SAQ-A or SAQ A-EP).
- Requirement 4: Encrypt transmission — All communication uses TLS 1.2+. No card data over HTTP. Certificate pinning for PSP connections.
- Requirement 6: Develop secure systems — Code reviews, dependency scanning, no storing tokens in logs, parameterized SQL queries.
- Requirement 8: Identify and authenticate access — MFA for all admin access, unique user IDs, strong passwords, API key rotation every 90 days.
- Requirement 10: Track and monitor all access — Log every access to cardholder data environment. Centralized logging with 1-year retention. Real-time alerting on suspicious activity.
- Requirement 11: Regular security testing — Quarterly vulnerability scans, annual penetration tests, WAF in front of payment APIs.
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
- Idempotency is non-negotiable. Every payment operation must use idempotency keys. Without it, network failures cause double charges. This is the #1 correctness requirement.
- Double-entry ledger is the source of truth. Every cent must be accounted for with balanced debit/credit entries. The ledger is append-only and immutable. Run balance checks constantly.
- Three phases: Authorization (hold funds) → Capture (transfer funds) → Settlement (money moves between banks, T+2 days). Each phase can fail independently.
- Never store raw card numbers. Use PSP tokenization (Stripe Elements). This reduces PCI scope from 300+ requirements to ~22. Let the PSP handle the hard security.
- Reconciliation is your safety net. Compare internal ledger vs PSP settlement daily. Flag discrepancies. Even perfect systems have small mismatches due to timing and rounding.
- Handle every failure mode explicitly. PSP timeout? Check status, then retry with idempotency key. Bank decline? Cache the decline, don't retry. Network failure? Client retries with same idempotency key.
- Webhooks need deduplication and signature verification. PSPs send events asynchronously. Verify signatures to prevent fake events. Deduplicate by event ID. Retry on failure with exponential backoff.
- Multi-currency: use smallest unit integers. Never use floating-point for money. Store in cents/pence. Convert at capture time, not authorization time. Separate ledger accounts per currency.