
Software Architect
Pós-graduado em arquitetura de software e soluções. Conecto profundidade técnica com resultados de negócio para entregar produtos que as pessoas realmente usam. Também mentoro desenvolvedores e criadores em programas ao vivo, podcasts e iniciativas de comunidade focadas em tecnologia inclusiva.
Continue explorando tópicos similares

Um design de sistema de nível produção para editores colaborativos, cobrindo sessões em tempo real, trade-offs entre OT e CRDT, sincronização offline, logs de operações, snapshots, permissões, hist...

Um guia prático e senior para desenhar um motor de busca em escala de internet, cobrindo crawler frontier, robots.txt, sitemaps, canonicalização, deduplicação, índice invertido, ranking, snippets.

Um guia completo de system design para cache distribuído com Redis e Memcached, cobrindo cache-aside, read-through, write-through, invalidação, TTL, eviction, sharding, replicação, hot keys, preven...
Checklist de 47 pontos para encontrar bugs, riscos de segurança e problemas de performance antes do lançamento.
Templates testados em produção, usados por desenvolvedores. Economize semanas de setup no seu próximo projeto.

Summaey: A comprehensive system design guide for building a payment processing platform like Stripe, handling millions of transactions with exactly-once semantics, double-entry ledger, idempotency, fraud detection, and PCI-DSS compliance.
Published: February 2026 Reading time: 50 minutes Keywords: #SystemDesign #PaymentSystem #Stripe #Fintech #Idempotency #ACID #FraudDetection #Scalability #TechInterview #Pix #DoubleEntryLedger
Every second, Stripe processes over $1 million in payments. A customer in Tokyo clicks "Buy Now," and within 800 milliseconds, the system has validated the card, checked for fraud across 300+ signals, routed the transaction through the optimal payment processor, authorized the charge with the issuing bank, recorded the double-entry ledger entries, and returned a success response. All while maintaining exactly-once semantics across a distributed system handling 500 million API calls per day.
This is not a CRUD application. This is a financial system where a single bug can mean losing real money. Where "eventual consistency" is not an acceptable answer for your ledger. Where you cannot simply retry a failed request without risking double-charging a customer. Where regulatory compliance spans dozens of jurisdictions, and a security breach could end the company.
Payment systems are arguably the most challenging distributed systems to design correctly. They sit at the intersection of distributed computing, financial accounting, security engineering, and regulatory compliance. Getting the happy path right is table stakes. What separates a production payment system from a toy project is how it handles the unhappy paths: network timeouts mid-transaction, partial failures in multi-step processes, currency conversion edge cases, and reconciliation across dozens of payment processors.
In this deep dive, we will build a payment platform from the ground up. You will understand why idempotency is non-negotiable, how double-entry bookkeeping prevents money from vanishing into thin air, why the payment state machine has more states than you think, and how companies like Stripe and Square achieve five-nines reliability on the most critical path in their infrastructure.
Let's design the system that moves the world's money.
Payment Processing:
Merchant Platform:
Financial Operations:
Compliance:
| Requirement | Target | Rationale |
|---|---|---|
| Payment Latency | < 2 seconds (p99) | Customer abandonment increases 7% per second of delay |
| Availability | 99.999% (payment path) | Downtime directly loses revenue for merchants |
| Consistency | Strong (ACID) for ledger | Money cannot appear or disappear |
| Idempotency | Exactly-once semantics | Double-charging destroys trust |
| Throughput | 10,000+ TPS | Handle flash sales, Black Friday |
| Data Retention | 7+ years | Financial regulatory requirements |
| Recovery Point | Zero data loss (RPO=0) | No financial transaction can be lost |
| Encryption | AES-256 at rest, TLS 1.3 in transit | PCI-DSS and security best practices |
Merchants: 4 million active businesses
Monthly transactions: 1 billion
Peak TPS: 15,000 (Black Friday, Singles Day)
Average TPS: ~400
API calls per day: 500 million
Payment methods supported: 50+
Currencies: 135+
Countries: 46
Webhooks delivered per day: 2 billion
Transaction records:
1B transactions/month x 3KB per transaction = 3 TB/month
Annual: 36 TB/year (before replication)
7-year retention: ~250 TB
Ledger entries (double-entry, ~4 entries per transaction):
4B entries/month x 500 bytes = 2 TB/month
Annual: 24 TB/year
Event store (audit log):
~10 events per transaction lifecycle
10B events/month x 1KB = 10 TB/month
Card tokens (PCI vault):
500M stored cards x 200 bytes = 100 GB
(Small but extremely sensitive)
Idempotency keys:
500M API calls/day x 200 bytes = 100 GB/day
TTL: 24-72 hours, so ~300 GB active
API requests (inbound):
500M/day = ~5,800 RPS average
Average request: 2KB -> 11.6 MB/sec
Peak (5x): 58 MB/sec
Webhook delivery (outbound):
2B/day = ~23,000/sec
Average payload: 1KB -> 23 MB/sec
PSP communication (outbound):
1B/month = ~385 RPS
Each PSP call: ~2KB -> 770 KB/sec
Database write throughput:
~400 TPS x 4 ledger entries x 500 bytes = 800 KB/sec
Peak: ~6 MB/sec
Payment processing:
400 TPS average x 200ms processing = 80 concurrent requests
Peak (15K TPS) x 200ms = 3,000 concurrent requests
With overhead: ~500 pods at peak
Fraud detection:
Real-time scoring: ~50ms per transaction
400 TPS x 50ms = 20 concurrent scoring jobs
Webhook delivery:
23K/sec x 500ms avg delivery = 11,500 concurrent connections
~200 webhook worker pods
The payment system follows a layered architecture with clear separation between the API layer, payment orchestration, financial accounting, and external integrations.
Key architectural decisions:
The API follows REST conventions with strong idempotency guarantees. Every mutating endpoint requires an Idempotency-Key header.
The payment flow starts with creating a Payment Intent, which represents the merchant's desire to collect a payment.
// POST /v1/payment_intents
// Headers:
// Authorization: Bearer sk_live_xxx
// Idempotency-Key: pi_unique_order_12345
// Content-Type: application/json
interface CreatePaymentIntentRequest {
amount: number; // Amount in smallest currency unit (cents)
currency: string; // ISO 4217 (e.g., "usd", "brl", "eur")
payment_method_types: string[]; // ["card", "pix", "boleto"]
capture_method?: "automatic" | "manual"; // Default: automatic
description?: string;
metadata?: Record<string, string>; // Merchant custom data
customer_id?: string; // Optional: link to saved customer
receipt_email?: string;
statement_descriptor?: string; // Appears on bank statement (22 chars max)
transfer_data?: {
destination: string; // Connected account ID for marketplace
amount?: number; // Amount to transfer (platform keeps rest)
};
}
interface PaymentIntent {
id: string; // "pi_3MtwBwLkdIwH..."
object: "payment_intent";
amount: number;
amount_capturable: number;
amount_received: number;
currency: string;
status: PaymentIntentStatus;
client_secret: string; // Used by frontend SDK to confirm
payment_method?: string; // "pm_1MtwBwLkdIwH..."
capture_method: "automatic" | "manual";
confirmation_method: "automatic" | "manual";
created: number; // Unix timestamp
description?: string;
last_payment_error?: PaymentError;
metadata: Record<string, string>;
charges: {
data: Charge[];
total_count: number;
};
next_action?: {
type: "redirect_to_url" | "use_stripe_sdk" | "pix_display_qr_code";
redirect_to_url?: { url: string; return_url: string };
pix_display_qr_code?: { data: string; expires_at: number };
};
livemode: boolean;
}
type PaymentIntentStatus =
| "requires_payment_method"
| "requires_confirmation"
| "requires_action" // 3D Secure, Pix QR code, etc.
| "processing"
| "requires_capture" // Auth succeeded, waiting for capture
| "canceled"
| "succeeded";
// POST /v1/payment_intents/:id/confirm
// Headers:
// Authorization: Bearer sk_live_xxx
// Idempotency-Key: confirm_pi_12345
interface ConfirmPaymentIntentRequest {
payment_method: string; // "pm_card_visa"
return_url?: string; // For 3D Secure redirect
mandate_data?: { // For SEPA, ACH
customer_acceptance: {
type: "online";
online: {
ip_address: string;
user_agent: string;
};
};
};
}
// Response: PaymentIntent with updated status
// POST /v1/payment_intents/:id/capture
// Used for auth-then-capture flow (hotels, car rentals)
interface CapturePaymentIntentRequest {
amount_to_capture?: number; // Can capture less than authorized
statement_descriptor?: string;
metadata?: Record<string, string>;
}
// POST /v1/refunds
// Headers:
// Idempotency-Key: refund_ch_xxx_full
interface CreateRefundRequest {
payment_intent: string; // "pi_3MtwBwLkdIwH..."
amount?: number; // Partial refund amount (omit for full)
reason?: "duplicate" | "fraudulent" | "requested_by_customer";
metadata?: Record<string, string>;
}
interface Refund {
id: string; // "re_3MtwBwLkdIwH..."
object: "refund";
amount: number;
charge: string;
created: number;
currency: string;
payment_intent: string;
reason?: string;
status: "pending" | "succeeded" | "failed" | "canceled";
receipt_number?: string;
}
// Webhook payload structure
interface WebhookEvent {
id: string; // "evt_1MtwBwLkdIwH..."
object: "event";
api_version: string; // "2026-01-15"
created: number;
type: WebhookEventType;
data: {
object: PaymentIntent | Charge | Refund | Dispute;
previous_attributes?: Record<string, unknown>;
};
livemode: boolean;
pending_webhooks: number;
request: {
id: string; // Request that triggered this event
idempotency_key?: string;
};
}
type WebhookEventType =
| "payment_intent.created"
| "payment_intent.succeeded"
| "payment_intent.payment_failed"
| "payment_intent.canceled"
| "payment_intent.requires_action"
| "charge.succeeded"
| "charge.failed"
| "charge.refunded"
| "charge.dispute.created"
| "charge.dispute.closed"
| "customer.subscription.created"
| "customer.subscription.updated"
| "customer.subscription.deleted"
| "invoice.paid"
| "invoice.payment_failed"
| "payout.paid"
| "payout.failed";
import crypto from "crypto";
function verifyWebhookSignature(
payload: string,
signature: string,
secret: string,
tolerance: number = 300 // 5 minutes
): boolean {
const elements = signature.split(",");
const timestamp = elements
.find((e) => e.startsWith("t="))
?.substring(2);
const signatures = elements
.filter((e) => e.startsWith("v1="))
.map((e) => e.substring(3));
if (!timestamp || signatures.length === 0) {
throw new Error("Invalid signature format");
}
// Protect against replay attacks
const timestampAge = Math.floor(Date.now() / 1000) - parseInt(timestamp);
if (timestampAge > tolerance) {
throw new Error("Timestamp outside tolerance zone");
}
// Compute expected signature
const signedPayload = `${timestamp}.${payload}`;
const expectedSignature = crypto
.createHmac("sha256", secret)
.update(signedPayload)
.digest("hex");
// Constant-time comparison to prevent timing attacks
const isValid = signatures.some((sig) =>
crypto.timingSafeEqual(
Buffer.from(sig, "hex"),
Buffer.from(expectedSignature, "hex")
)
);
return isValid;
}
The data model is designed around immutability and auditability. Financial records are never updated in place. Every change creates a new record or a compensating entry.
-- All monetary amounts stored as BIGINT in smallest currency unit (cents, centavos)
-- NEVER use FLOAT or DECIMAL for money in the transaction path
CREATE TABLE merchants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
business_name VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL UNIQUE,
country CHAR(2) NOT NULL, -- ISO 3166-1 alpha-2
default_currency CHAR(3) NOT NULL, -- ISO 4217
kyc_status VARCHAR(20) NOT NULL DEFAULT 'pending'
CHECK (kyc_status IN ('pending', 'verified', 'rejected')),
payout_schedule VARCHAR(20) NOT NULL DEFAULT 'T+2',
settings JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE payment_intents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
merchant_id UUID NOT NULL REFERENCES merchants(id),
customer_id UUID REFERENCES customers(id),
amount BIGINT NOT NULL CHECK (amount > 0),
currency CHAR(3) NOT NULL,
status VARCHAR(30) NOT NULL DEFAULT 'requires_payment_method'
CHECK (status IN (
'requires_payment_method',
'requires_confirmation',
'requires_action',
'processing',
'requires_capture',
'canceled',
'succeeded'
)),
capture_method VARCHAR(10) NOT NULL DEFAULT 'automatic'
CHECK (capture_method IN ('automatic', 'manual')),
client_secret VARCHAR(255) NOT NULL UNIQUE,
description TEXT,
metadata JSONB NOT NULL DEFAULT '{}',
cancellation_reason VARCHAR(50),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- Partitioning-friendly index
CONSTRAINT payment_intents_amount_positive CHECK (amount > 0)
);
-- Critical: index for merchant dashboard queries
CREATE INDEX idx_payment_intents_merchant_status
ON payment_intents (merchant_id, status, created_at DESC);
-- Index for the client-facing lookup
CREATE UNIQUE INDEX idx_payment_intents_client_secret
ON payment_intents (client_secret);
CREATE TABLE charges (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
payment_intent_id UUID NOT NULL REFERENCES payment_intents(id),
payment_method_id UUID NOT NULL REFERENCES payment_methods(id),
amount BIGINT NOT NULL CHECK (amount > 0),
amount_captured BIGINT NOT NULL DEFAULT 0,
amount_refunded BIGINT NOT NULL DEFAULT 0,
currency CHAR(3) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending'
CHECK (status IN (
'pending', 'succeeded', 'failed', 'expired'
)),
failure_code VARCHAR(50),
failure_message TEXT,
statement_descriptor VARCHAR(22),
disputed BOOLEAN NOT NULL DEFAULT false,
fraud_details JSONB NOT NULL DEFAULT '{}',
psp_reference VARCHAR(255), -- External PSP transaction ID
psp_name VARCHAR(50), -- Which PSP processed this
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- Ensure refunded amount never exceeds captured amount
CONSTRAINT charges_refund_check
CHECK (amount_refunded <= amount_captured)
);
CREATE INDEX idx_charges_payment_intent
ON charges (payment_intent_id);
CREATE INDEX idx_charges_psp_reference
ON charges (psp_reference) WHERE psp_reference IS NOT NULL;
CREATE TABLE refunds (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
charge_id UUID NOT NULL REFERENCES charges(id),
amount BIGINT NOT NULL CHECK (amount > 0),
currency CHAR(3) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'succeeded', 'failed', 'canceled')),
reason VARCHAR(50),
psp_reference VARCHAR(255),
failure_reason TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_refunds_charge ON refunds (charge_id);
-- Idempotency keys with automatic expiration
CREATE TABLE idempotency_keys (
key VARCHAR(255) NOT NULL,
merchant_id UUID NOT NULL,
api_path VARCHAR(255) NOT NULL,
api_method VARCHAR(10) NOT NULL,
request_hash VARCHAR(64) NOT NULL, -- SHA-256 of request body
response_code INTEGER,
response_body JSONB,
locked_at TIMESTAMPTZ, -- For distributed locking
recovery_point VARCHAR(50), -- For crash recovery
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL DEFAULT now() + INTERVAL '24 hours',
PRIMARY KEY (key, merchant_id)
);
CREATE INDEX idx_idempotency_expires
ON idempotency_keys (expires_at);
-- Payment events for event sourcing / audit trail
CREATE TABLE payment_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
payment_intent_id UUID NOT NULL,
event_type VARCHAR(50) NOT NULL,
data JSONB NOT NULL,
sequence_number INTEGER NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- Ensure ordered events per payment
UNIQUE (payment_intent_id, sequence_number)
);
CREATE INDEX idx_payment_events_intent_seq
ON payment_events (payment_intent_id, sequence_number);
The schema enforces several important invariants at the database level:
charges_refund_check constraint prevents over-refunding.The payment processing flow is the heart of the system. Understanding the state machine and the auth-capture lifecycle is essential.
A payment moves through well-defined states. The state machine is strict: only specific transitions are allowed, and each transition produces an event.
There are three distinct phases in processing a card payment, and understanding why they are separate is critical for system design.
Authorization is a real-time check: "Does this card have sufficient funds, and does the issuing bank approve this transaction?" The bank places a hold on the funds but does not move money. Think of it as a reservation.
Capture tells the bank: "We are ready to actually take the money." For e-commerce, this often happens immediately (automatic capture). For hotels and car rentals, the merchant authorizes at check-in and captures at check-out, possibly for a different amount.
Settlement is when money actually moves between banks. This happens in batches, typically overnight. The acquiring bank collects from issuing banks and deposits funds (minus fees) into the merchant's account.
The payment orchestrator is the central coordinator. It manages the payment lifecycle while ensuring idempotency and crash recovery.
interface PaymentOrchestrator {
confirmPayment(
paymentIntentId: string,
request: ConfirmPaymentIntentRequest,
idempotencyKey: string
): Promise<PaymentIntent>;
}
class PaymentOrchestratorImpl implements PaymentOrchestrator {
constructor(
private readonly db: DatabasePool,
private readonly fraudEngine: FraudEngine,
private readonly pspRouter: PSPRouter,
private readonly ledger: LedgerService,
private readonly eventBus: EventBus,
private readonly idempotencyStore: IdempotencyStore
) {}
async confirmPayment(
paymentIntentId: string,
request: ConfirmPaymentIntentRequest,
idempotencyKey: string
): Promise<PaymentIntent> {
// Step 1: Check idempotency (returns cached response if exists)
const cached = await this.idempotencyStore.get(idempotencyKey);
if (cached?.response) {
return cached.response as PaymentIntent;
}
// Step 2: Acquire idempotency lock (prevents concurrent processing)
const lock = await this.idempotencyStore.acquireLock(
idempotencyKey,
request
);
try {
// Step 3: Load and validate payment intent
const pi = await this.loadPaymentIntent(paymentIntentId);
this.validateStateTransition(pi.status, "processing");
// Step 4: Fraud check
const fraudResult = await this.fraudEngine.score({
amount: pi.amount,
currency: pi.currency,
paymentMethod: request.payment_method,
merchantId: pi.merchant_id,
metadata: pi.metadata,
});
if (fraudResult.action === "block") {
return this.handleFraudBlock(pi, fraudResult, lock);
}
if (fraudResult.action === "challenge") {
return this.initiate3DSecure(pi, request, lock);
}
// Step 5: Route to optimal PSP and authorize
const psp = await this.pspRouter.selectPSP(pi);
const authResult = await psp.authorize({
amount: pi.amount,
currency: pi.currency,
paymentMethodToken: request.payment_method,
merchantRef: pi.id,
statementDescriptor: pi.statement_descriptor,
});
// Step 6: Process result in a single database transaction
const result = await this.db.transaction(async (tx) => {
if (authResult.status === "approved") {
// Update payment intent status
const updatedPI = await tx.query(
`UPDATE payment_intents
SET status = $1, updated_at = now()
WHERE id = $2 AND status = 'requires_confirmation'
RETURNING *`,
[
pi.capture_method === "automatic"
? "succeeded"
: "requires_capture",
pi.id,
]
);
if (updatedPI.rowCount === 0) {
throw new ConcurrentModificationError(
"Payment intent was modified concurrently"
);
}
// Create charge record
await tx.query(
`INSERT INTO charges
(payment_intent_id, payment_method_id, amount,
amount_captured, currency, status,
psp_reference, psp_name, statement_descriptor)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
[
pi.id,
request.payment_method,
pi.amount,
pi.capture_method === "automatic" ? pi.amount : 0,
pi.currency,
"succeeded",
authResult.psp_reference,
psp.name,
pi.statement_descriptor,
]
);
// Record ledger entries (within same transaction)
await this.ledger.recordPayment(tx, {
paymentIntentId: pi.id,
merchantId: pi.merchant_id,
amount: pi.amount,
currency: pi.currency,
captured:
pi.capture_method === "automatic",
});
// Record event
await tx.query(
`INSERT INTO payment_events
(payment_intent_id, event_type, data, sequence_number)
VALUES ($1, $2, $3,
(SELECT COALESCE(MAX(sequence_number), 0) + 1
FROM payment_events WHERE payment_intent_id = $1))`,
[
pi.id,
"payment_intent.succeeded",
JSON.stringify({
auth_code: authResult.auth_code,
psp: psp.name,
}),
]
);
return updatedPI.rows[0];
} else {
// Handle decline
return this.handleDecline(tx, pi, authResult);
}
});
// Step 7: Cache response for idempotency
await this.idempotencyStore.saveResponse(lock, 200, result);
// Step 8: Emit async events (outside transaction)
await this.eventBus.publish("payment_intent.succeeded", {
paymentIntentId: pi.id,
merchantId: pi.merchant_id,
});
return result;
} catch (error) {
await this.idempotencyStore.releaseLock(lock);
throw error;
}
}
private validateStateTransition(
currentStatus: string,
targetStatus: string
): void {
const validTransitions: Record<string, string[]> = {
requires_payment_method: ["requires_confirmation", "canceled"],
requires_confirmation: [
"requires_action",
"processing",
"canceled",
],
requires_action: [
"processing",
"requires_payment_method",
"canceled",
],
processing: [
"succeeded",
"requires_capture",
"requires_payment_method",
"canceled",
],
requires_capture: ["succeeded", "canceled"],
};
const allowed = validTransitions[currentStatus] || [];
if (!allowed.includes(targetStatus)) {
throw new InvalidStateTransitionError(
`Cannot transition from '${currentStatus}' to '${targetStatus}'`
);
}
}
}
Why this design matters for interviews:
UPDATE ... WHERE status = 'requires_confirmation' ensures that concurrent requests cannot both succeed. Only one will match the WHERE clause.If the payment state machine is the heart of the system, the double-entry ledger is its soul. Every financial system that handles real money must implement double-entry bookkeeping. There is no shortcut. There is no "we'll add it later." If you skip this, you will eventually lose track of money, and no one will be able to explain where it went.
Double-entry bookkeeping was invented in 15th-century Italy, and we still use it because no one has found anything better for ensuring financial integrity. The core principle is simple: every transaction must have at least two entries that sum to zero. Money cannot appear from nowhere or vanish into thin air.
There are five types of accounts:
Each account has a "normal balance" -- the side (debit or credit) that increases it:
| Account Type | Normal Balance | Debit Effect | Credit Effect |
|---|---|---|---|
| Asset | Debit | Increases | Decreases |
| Liability | Credit | Decreases | Increases |
| Equity | Credit | Decreases | Increases |
| Revenue | Credit | Decreases | Increases |
| Expense | Debit | Increases | Decreases |
The golden rule: For every transaction, total debits must equal total credits.
-- Chart of Accounts for a Payment Platform
CREATE TABLE ledger_accounts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code VARCHAR(20) NOT NULL UNIQUE, -- e.g., '1001', '2001'
name VARCHAR(100) NOT NULL,
account_type VARCHAR(20) NOT NULL
CHECK (account_type IN (
'asset', 'liability', 'equity', 'revenue', 'expense'
)),
normal_balance VARCHAR(6) NOT NULL
CHECK (normal_balance IN ('debit', 'credit')),
currency CHAR(3) NOT NULL,
balance BIGINT NOT NULL DEFAULT 0, -- Running balance in cents
is_system BOOLEAN NOT NULL DEFAULT false, -- Platform-level accounts
merchant_id UUID REFERENCES merchants(id), -- NULL for system accounts
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Key accounts for the payment platform:
INSERT INTO ledger_accounts (code, name, account_type, normal_balance, currency, is_system) VALUES
-- Assets
('1001', 'Cash - Stripe Treasury', 'asset', 'debit', 'usd', true),
('1002', 'Accounts Receivable - PSP', 'asset', 'debit', 'usd', true),
('1003', 'Funds in Transit', 'asset', 'debit', 'usd', true),
('1004', 'Reserve Account', 'asset', 'debit', 'usd', true),
-- Liabilities
('2001', 'Merchant Payable - Pending', 'liability', 'credit', 'usd', true),
('2002', 'Merchant Payable - Available', 'liability', 'credit', 'usd', true),
('2003', 'Refunds Pending', 'liability', 'credit', 'usd', true),
('2004', 'Customer Deposits', 'liability', 'credit', 'usd', true),
-- Revenue
('4001', 'Processing Fee Revenue', 'revenue', 'credit', 'usd', true),
('4002', 'International Fee Revenue', 'revenue', 'credit', 'usd', true),
('4003', 'Subscription Revenue', 'revenue', 'credit', 'usd', true),
-- Expenses
('5001', 'PSP Processing Costs', 'expense', 'debit', 'usd', true),
('5002', 'Card Network Fees', 'expense', 'debit', 'usd', true),
('5003', 'Chargeback Losses', 'expense', 'debit', 'usd', true),
('5004', 'Currency Conversion Costs', 'expense', 'debit', 'usd', true);
-- Ledger transactions (immutable grouping)
CREATE TABLE ledger_transactions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
reference_type VARCHAR(30) NOT NULL, -- 'charge', 'refund', 'payout', etc.
reference_id UUID NOT NULL, -- FK to the originating record
description TEXT NOT NULL,
currency CHAR(3) NOT NULL,
posted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_ledger_tx_reference
ON ledger_transactions (reference_type, reference_id);
-- Individual ledger entries (immutable, append-only)
CREATE TABLE ledger_entries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
transaction_id UUID NOT NULL REFERENCES ledger_transactions(id),
account_id UUID NOT NULL REFERENCES ledger_accounts(id),
entry_type VARCHAR(6) NOT NULL CHECK (entry_type IN ('debit', 'credit')),
amount BIGINT NOT NULL CHECK (amount > 0), -- Always positive
currency CHAR(3) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_ledger_entries_account
ON ledger_entries (account_id, created_at);
CREATE INDEX idx_ledger_entries_transaction
ON ledger_entries (transaction_id);
-- Critical constraint: debits must equal credits for every transaction
-- Enforced via trigger or application code
CREATE OR REPLACE FUNCTION verify_balanced_transaction()
RETURNS TRIGGER AS $$
DECLARE
debit_sum BIGINT;
credit_sum BIGINT;
BEGIN
SELECT
COALESCE(SUM(CASE WHEN entry_type = 'debit' THEN amount END), 0),
COALESCE(SUM(CASE WHEN entry_type = 'credit' THEN amount END), 0)
INTO debit_sum, credit_sum
FROM ledger_entries
WHERE transaction_id = NEW.transaction_id;
-- Only verify when we have both sides
-- (entries are inserted one at a time within a transaction)
IF debit_sum > 0 AND credit_sum > 0 AND debit_sum != credit_sum THEN
RAISE EXCEPTION 'Unbalanced transaction: debits=% credits=%',
debit_sum, credit_sum;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
When a customer pays $100 to a merchant through our platform (with a 2.9% + $0.30 fee), here is what happens in the ledger:
Step 1: Authorization (no ledger entry yet) Authorization is just a hold. No money moves, so we do not create ledger entries. We record the event for audit purposes only.
Step 2: Capture When the payment is captured, money is "in transit" from the card network to us.
// When a $100 charge is captured (2.9% + $0.30 fee = $3.20)
async function recordPaymentCapture(
tx: DatabaseTransaction,
chargeId: string,
merchantId: string,
amount: number, // 10000 (cents)
currency: string,
feeAmount: number // 320 (cents)
): Promise<void> {
const netAmount = amount - feeAmount; // 9680 cents = $96.80
// Create the ledger transaction
const { id: txnId } = await tx.queryOne(
`INSERT INTO ledger_transactions
(reference_type, reference_id, description, currency)
VALUES ('charge', $1, $2, $3)
RETURNING id`,
[chargeId, `Payment capture for charge ${chargeId}`, currency]
);
// Entry 1: DEBIT Accounts Receivable (we are owed money by the PSP)
await tx.query(
`INSERT INTO ledger_entries
(transaction_id, account_id, entry_type, amount, currency)
VALUES ($1,
(SELECT id FROM ledger_accounts WHERE code = '1002'),
'debit', $2, $3)`,
[txnId, amount, currency]
);
// Entry 2: CREDIT Merchant Payable (we owe money to the merchant)
await tx.query(
`INSERT INTO ledger_entries
(transaction_id, account_id, entry_type, amount, currency)
VALUES ($1,
(SELECT id FROM ledger_accounts WHERE code = '2001'),
'credit', $2, $3)`,
[txnId, netAmount, currency]
);
// Entry 3: CREDIT Processing Fee Revenue (our fee)
await tx.query(
`INSERT INTO ledger_entries
(transaction_id, account_id, entry_type, amount, currency)
VALUES ($1,
(SELECT id FROM ledger_accounts WHERE code = '4001'),
'credit', $2, $3)`,
[txnId, feeAmount, currency]
);
// Verify: Debit $100.00 = Credit $96.80 + Credit $3.20 = $100.00 ✓
// Update running balances
await tx.query(
`UPDATE ledger_accounts SET balance = balance + $1
WHERE code = '1002'`, [amount] // AR increases
);
await tx.query(
`UPDATE ledger_accounts SET balance = balance + $1
WHERE code = '2001'`, [netAmount] // Merchant payable increases
);
await tx.query(
`UPDATE ledger_accounts SET balance = balance + $1
WHERE code = '4001'`, [feeAmount] // Revenue increases
);
}
Step 3: Settlement (money arrives from card network)
async function recordSettlement(
tx: DatabaseTransaction,
settlementId: string,
totalAmount: number,
networkFees: number,
currency: string
): Promise<void> {
const netCash = totalAmount - networkFees;
const { id: txnId } = await tx.queryOne(
`INSERT INTO ledger_transactions
(reference_type, reference_id, description, currency)
VALUES ('settlement', $1, $2, $3)
RETURNING id`,
[settlementId, `Settlement ${settlementId}`, currency]
);
// DEBIT Cash (money arrived in our bank)
await insertEntry(tx, txnId, "1001", "debit", netCash, currency);
// DEBIT Network Fees Expense (cost of doing business)
await insertEntry(tx, txnId, "5002", "debit", networkFees, currency);
// CREDIT Accounts Receivable (PSP no longer owes us)
await insertEntry(tx, txnId, "1002", "credit", totalAmount, currency);
// Debit: netCash + networkFees = totalAmount = Credit: totalAmount ✓
}
Step 4: Payout (we pay the merchant)
async function recordPayout(
tx: DatabaseTransaction,
payoutId: string,
merchantId: string,
amount: number,
currency: string
): Promise<void> {
const { id: txnId } = await tx.queryOne(
`INSERT INTO ledger_transactions
(reference_type, reference_id, description, currency)
VALUES ('payout', $1, $2, $3)
RETURNING id`,
[payoutId, `Payout to merchant ${merchantId}`, currency]
);
// DEBIT Merchant Payable (we no longer owe the merchant)
await insertEntry(tx, txnId, "2001", "debit", amount, currency);
// CREDIT Cash (money left our bank)
await insertEntry(tx, txnId, "1001", "credit", amount, currency);
// Debit: amount = Credit: amount ✓
}
Refunds are the reverse of captures. The merchant gives back money, and we reduce what we owe them.
async function recordRefund(
tx: DatabaseTransaction,
refundId: string,
chargeId: string,
refundAmount: number,
originalFee: number,
originalAmount: number,
currency: string
): Promise<void> {
// Calculate proportional fee refund
// If original charge was $100 with $3.20 fee, and refund is $50,
// refund $1.60 of the fee (50% of fee)
const feeRefund = Math.round(
(refundAmount / originalAmount) * originalFee
);
const merchantRefund = refundAmount - feeRefund;
const { id: txnId } = await tx.queryOne(
`INSERT INTO ledger_transactions
(reference_type, reference_id, description, currency)
VALUES ('refund', $1, $2, $3)
RETURNING id`,
[refundId, `Refund for charge ${chargeId}`, currency]
);
// DEBIT Merchant Payable (reduce what we owe merchant)
await insertEntry(tx, txnId, "2001", "debit", merchantRefund, currency);
// DEBIT Processing Fee Revenue (reverse our fee)
await insertEntry(tx, txnId, "4001", "debit", feeRefund, currency);
// CREDIT Accounts Receivable (we now owe back to the PSP/network)
await insertEntry(tx, txnId, "1002", "credit", refundAmount, currency);
// Debit: merchantRefund + feeRefund = refundAmount = Credit ✓
}
At any point in time, we can verify the entire ledger is balanced:
-- Trial balance: must always sum to zero
SELECT
la.account_type,
la.name,
la.code,
la.balance,
la.normal_balance,
CASE
WHEN la.normal_balance = 'debit' THEN la.balance
ELSE 0
END AS debit_balance,
CASE
WHEN la.normal_balance = 'credit' THEN la.balance
ELSE 0
END AS credit_balance
FROM ledger_accounts la
WHERE la.is_system = true
ORDER BY la.code;
-- The sum of all debit balances must equal the sum of all credit balances
-- If it doesn't, you have a bug. Stop everything and find it.
SELECT
SUM(CASE WHEN normal_balance = 'debit' THEN balance ELSE 0 END)
AS total_debits,
SUM(CASE WHEN normal_balance = 'credit' THEN balance ELSE 0 END)
AS total_credits,
SUM(CASE WHEN normal_balance = 'debit' THEN balance ELSE 0 END) -
SUM(CASE WHEN normal_balance = 'credit' THEN balance ELSE 0 END)
AS imbalance
FROM ledger_accounts
WHERE is_system = true;
-- imbalance MUST be 0. Always. No exceptions.
Why double-entry is non-negotiable:
Idempotency is the property that performing an operation multiple times produces the same result as performing it once. In a payment system, this is not a nice-to-have feature. It is a fundamental safety mechanism that prevents double-charging customers.
Consider this scenario: A merchant sends a request to charge $500. The charge succeeds, but the network drops before the response reaches the merchant. The merchant, not knowing if the charge went through, retries the request. Without idempotency, the customer is charged $1,000. Trust is destroyed. The merchant loses a customer. You lose a merchant.
This is not a theoretical scenario. Network failures, timeouts, load balancer restarts, and mobile connectivity issues happen constantly. In a system processing millions of transactions, "rare" events happen hundreds of times per day.
Every mutating API request must include an Idempotency-Key header. The system guarantees that requests with the same key produce the same result, regardless of how many times they are sent.
interface IdempotencyRecord {
key: string;
merchantId: string;
apiPath: string;
requestHash: string; // SHA-256 of the request body
responseCode: number | null;
responseBody: object | null;
recoveryPoint: string | null; // Where to resume if we crashed
lockedAt: Date | null;
createdAt: Date;
expiresAt: Date;
}
class IdempotencyService {
private readonly lockTTL = 30_000; // 30 seconds
async processWithIdempotency<T>(
key: string,
merchantId: string,
apiPath: string,
requestBody: object,
operation: () => Promise<{ statusCode: number; body: T }>
): Promise<{ statusCode: number; body: T; fromCache: boolean }> {
const requestHash = this.hashRequest(requestBody);
// Step 1: Try to find existing record
const existing = await this.findRecord(key, merchantId);
if (existing) {
// Case A: Completed request - return cached response
if (existing.responseCode !== null) {
// Verify the request body matches (same key, different request = error)
if (existing.requestHash !== requestHash) {
throw new IdempotencyKeyConflictError(
"Idempotency key already used with a different request body"
);
}
return {
statusCode: existing.responseCode,
body: existing.responseBody as T,
fromCache: true,
};
}
// Case B: In-progress (locked) - another request is processing
if (existing.lockedAt) {
const lockAge =
Date.now() - existing.lockedAt.getTime();
if (lockAge < this.lockTTL) {
throw new IdempotencyKeyInProgressError(
"A request with this idempotency key is currently being processed"
);
}
// Lock expired - previous request likely crashed
// Fall through to re-acquire lock and resume
}
}
// Step 2: Create or re-acquire lock
const lockAcquired = await this.acquireLock(
key,
merchantId,
apiPath,
requestHash
);
if (!lockAcquired) {
throw new IdempotencyKeyInProgressError(
"Could not acquire idempotency lock"
);
}
try {
// Step 3: Execute the operation
const result = await operation();
// Step 4: Save the response for future replays
await this.saveResponse(
key,
merchantId,
result.statusCode,
result.body
);
return { ...result, fromCache: false };
} catch (error) {
// If the operation fails with a client error (4xx),
// save it so retries get the same error
if (error instanceof ClientError) {
await this.saveResponse(
key,
merchantId,
error.statusCode,
{ error: error.message }
);
} else {
// For server errors (5xx), release the lock
// so the client can retry with the same key
await this.releaseLock(key, merchantId);
}
throw error;
}
}
private async acquireLock(
key: string,
merchantId: string,
apiPath: string,
requestHash: string
): Promise<boolean> {
// Atomic upsert with lock acquisition
const result = await this.db.query(
`INSERT INTO idempotency_keys
(key, merchant_id, api_path, request_hash, locked_at)
VALUES ($1, $2, $3, $4, now())
ON CONFLICT (key, merchant_id)
DO UPDATE SET
locked_at = now()
WHERE idempotency_keys.locked_at IS NULL
OR idempotency_keys.locked_at < now() - INTERVAL '30 seconds'
RETURNING key`,
[key, merchantId, apiPath, requestHash]
);
return result.rowCount > 0;
}
private hashRequest(body: object): string {
return crypto
.createHash("sha256")
.update(JSON.stringify(body))
.digest("hex");
}
}
What happens if the server crashes halfway through processing a payment? The idempotency record tells us where we left off, allowing us to resume from the last known good state.
enum RecoveryPoint {
STARTED = "started",
FRAUD_CHECKED = "fraud_checked",
PSP_AUTHORIZED = "psp_authorized",
LEDGER_RECORDED = "ledger_recorded",
COMPLETED = "completed",
}
class RecoverablePaymentProcessor {
async processPayment(
paymentIntentId: string,
idempotencyKey: string
): Promise<PaymentResult> {
const record = await this.idempotencyStore.get(idempotencyKey);
let recoveryPoint = record?.recoveryPoint || RecoveryPoint.STARTED;
// Resume from where we left off
let fraudResult: FraudResult | undefined;
let authResult: AuthorizationResult | undefined;
if (recoveryPoint === RecoveryPoint.STARTED) {
// Step 1: Fraud check
fraudResult = await this.fraudEngine.score(paymentIntentId);
await this.idempotencyStore.updateRecoveryPoint(
idempotencyKey,
RecoveryPoint.FRAUD_CHECKED
);
recoveryPoint = RecoveryPoint.FRAUD_CHECKED;
}
if (recoveryPoint === RecoveryPoint.FRAUD_CHECKED) {
// Step 2: PSP authorization
authResult = await this.psp.authorize(paymentIntentId);
await this.idempotencyStore.updateRecoveryPoint(
idempotencyKey,
RecoveryPoint.PSP_AUTHORIZED
);
recoveryPoint = RecoveryPoint.PSP_AUTHORIZED;
}
if (recoveryPoint === RecoveryPoint.PSP_AUTHORIZED) {
// Step 3: Record in ledger (idempotent via unique constraint)
await this.ledger.recordPayment(paymentIntentId);
await this.idempotencyStore.updateRecoveryPoint(
idempotencyKey,
RecoveryPoint.LEDGER_RECORDED
);
recoveryPoint = RecoveryPoint.LEDGER_RECORDED;
}
if (recoveryPoint === RecoveryPoint.LEDGER_RECORDED) {
// Step 4: Update status and complete
await this.updatePaymentStatus(paymentIntentId, "succeeded");
await this.idempotencyStore.updateRecoveryPoint(
idempotencyKey,
RecoveryPoint.COMPLETED
);
}
return this.buildResult(paymentIntentId);
}
}
In a distributed system with multiple API servers, idempotency requires careful coordination:
Use the database as the lock. Do not rely on in-memory locks across multiple servers. PostgreSQL's INSERT ... ON CONFLICT provides atomic upsert behavior that works across all application instances.
Lock timeouts prevent deadlocks. If a server dies while holding a lock, the lock expires after 30 seconds, allowing another server to pick up the work.
Request hash prevents misuse. If a merchant reuses an idempotency key with different parameters, we reject the request instead of returning stale data.
TTL prevents unbounded growth. Idempotency keys expire after 24-72 hours. After that, the same key can be reused (which is fine, because by then the original request's effects are permanent).
-- Clean up expired idempotency keys (run via cron every hour)
DELETE FROM idempotency_keys
WHERE expires_at < now()
AND response_code IS NOT NULL;
-- Monitor stuck locks (should be zero in steady state)
SELECT COUNT(*) AS stuck_locks
FROM idempotency_keys
WHERE locked_at IS NOT NULL
AND response_code IS NULL
AND locked_at < now() - INTERVAL '5 minutes';
A production payment system never relies on a single Payment Service Provider (PSP). Multi-PSP routing is essential for reliability, cost optimization, and geographic coverage.
When a customer pays with a credit card, the transaction flows through multiple entities. Understanding this chain is critical for debugging and optimization.
The PSP router decides which processor handles each transaction, based on success rates, cost, latency, and capabilities.
interface PSPConfig {
name: string;
priority: number;
supportedCurrencies: Set<string>;
supportedCardBrands: Set<string>;
supportedCountries: Set<string>;
supportedMethods: Set<string>; // 'card', 'pix', 'sepa'
maxAmountCents: number;
isHealthy: boolean;
successRate: number; // Rolling 5-minute window
avgLatencyMs: number; // Rolling 5-minute window
costBasisPoints: number; // Fee in basis points (100 = 1%)
weight: number; // For weighted routing
}
class PSPRouter {
private readonly psps: Map<string, PSPConfig>;
private readonly circuitBreakers: Map<string, CircuitBreaker>;
async selectPSP(
paymentIntent: PaymentIntent,
paymentMethod: PaymentMethod
): Promise<PSPConfig> {
// Step 1: Filter to eligible PSPs
const eligible = Array.from(this.psps.values()).filter((psp) => {
if (!psp.isHealthy) return false;
if (!psp.supportedCurrencies.has(paymentIntent.currency))
return false;
if (
paymentMethod.type === "card" &&
!psp.supportedCardBrands.has(paymentMethod.brand)
)
return false;
if (
!psp.supportedMethods.has(paymentMethod.type)
)
return false;
if (paymentIntent.amount > psp.maxAmountCents) return false;
if (this.circuitBreakers.get(psp.name)?.isOpen()) return false;
return true;
});
if (eligible.length === 0) {
throw new NoPSPAvailableError(
"No payment processor available for this transaction"
);
}
// Step 2: Score each PSP
const scored = eligible.map((psp) => ({
psp,
score: this.calculateScore(psp, paymentIntent),
}));
// Step 3: Select highest score (with some randomization for load balancing)
scored.sort((a, b) => b.score - a.score);
// Use weighted random among top 2 to avoid thundering herd
if (scored.length >= 2) {
const totalWeight =
scored[0].score + scored[1].score;
const random = Math.random() * totalWeight;
return random < scored[0].score
? scored[0].psp
: scored[1].psp;
}
return scored[0].psp;
}
private calculateScore(
psp: PSPConfig,
pi: PaymentIntent
): number {
// Higher is better
let score = 0;
// Success rate is most important (70% weight)
score += psp.successRate * 70;
// Lower cost is better (20% weight)
score += (1 - psp.costBasisPoints / 300) * 20;
// Lower latency is better (10% weight)
score += (1 - psp.avgLatencyMs / 5000) * 10;
// Boost for local PSP (same country as card issuer)
// Local processing often has higher auth rates
if (this.isLocalProcessor(psp, pi.currency)) {
score *= 1.15;
}
return score;
}
// Automatic failover when primary PSP fails
async authorizeWithFailover(
paymentIntent: PaymentIntent,
paymentMethod: PaymentMethod
): Promise<AuthorizationResult> {
const primaryPSP = await this.selectPSP(
paymentIntent,
paymentMethod
);
try {
const result = await this.callPSP(
primaryPSP,
paymentIntent,
paymentMethod
);
this.recordSuccess(primaryPSP.name);
return result;
} catch (error) {
this.recordFailure(primaryPSP.name);
// Only retry on network/server errors, NOT on declines
if (!this.isRetryableError(error)) {
throw error;
}
// Try fallback PSP
const fallbackPSP = await this.selectPSP(
paymentIntent,
paymentMethod
// Exclude the failed PSP
);
if (fallbackPSP.name === primaryPSP.name) {
throw error; // No alternative available
}
return this.callPSP(
fallbackPSP,
paymentIntent,
paymentMethod
);
}
}
private isRetryableError(error: unknown): boolean {
// PSP timeouts and 5xx errors are retryable
// Card declines (insufficient funds, stolen card) are NOT retryable
if (error instanceof PSPDeclineError) return false;
if (error instanceof PSPTimeoutError) return true;
if (error instanceof PSPServerError) return true;
return false;
}
}
Pix is Brazil's instant payment system, processing over 150 million transactions per day. It is a real-time push payment, fundamentally different from card payments.
interface PixPaymentFlow {
// Step 1: Generate QR code with payment details
createPixCharge(
amount: number,
merchantPixKey: string,
description: string,
expiresInSeconds: number
): Promise<PixCharge>;
// Step 2: Customer scans QR code in their banking app
// Step 3: Bank sends instant confirmation via webhook
handlePixWebhook(payload: PixNotification): Promise<void>;
}
interface PixCharge {
txid: string; // Transaction ID (up to 35 chars)
qrCode: string; // EMV QR code data
qrCodeBase64: string; // QR code image
pixCopiaECola: string; // Copy-paste Pix code
amount: number;
expiresAt: Date;
status: "ACTIVE" | "COMPLETED" | "EXPIRED";
}
class PixIntegration {
async createPixCharge(
paymentIntent: PaymentIntent
): Promise<PixCharge> {
// Generate a unique txid (required by BCB - Brazil Central Bank)
const txid = this.generateTxid(paymentIntent.id);
// Call PSP's Pix API (e.g., via Pix API from BCB standard)
const response = await this.pspClient.post(
`/v2/cob/${txid}`,
{
calendario: {
expiracao: 3600, // 1 hour to pay
},
devedor: {
cpf: paymentIntent.metadata.customer_cpf,
nome: paymentIntent.metadata.customer_name,
},
valor: {
original: this.formatBRL(paymentIntent.amount),
},
chave: this.merchantPixKey,
solicitacaoPagador: paymentIntent.description,
infoAdicionais: [
{
nome: "Payment Intent",
valor: paymentIntent.id,
},
],
}
);
// Update payment intent to show QR code to customer
await this.db.query(
`UPDATE payment_intents
SET status = 'requires_action', updated_at = now()
WHERE id = $1`,
[paymentIntent.id]
);
return {
txid,
qrCode: response.pixCopiaECola,
qrCodeBase64: await this.generateQRImage(
response.pixCopiaECola
),
pixCopiaECola: response.pixCopiaECola,
amount: paymentIntent.amount,
expiresAt: new Date(
Date.now() + 3600 * 1000
),
status: "ACTIVE",
};
}
async handlePixWebhook(
notification: PixNotification
): Promise<void> {
for (const pix of notification.pix) {
// Find the payment intent linked to this Pix transaction
const paymentIntent = await this.findByTxid(pix.txid);
if (!paymentIntent) {
// Unknown transaction - log and skip
this.logger.warn("Unknown Pix txid", { txid: pix.txid });
continue;
}
// Verify amount matches (important: prevent partial payment attacks)
if (pix.valor !== this.formatBRL(paymentIntent.amount)) {
this.logger.error("Pix amount mismatch", {
expected: paymentIntent.amount,
received: pix.valor,
});
continue;
}
// Complete the payment (idempotent via status check)
await this.db.transaction(async (tx) => {
const updated = await tx.query(
`UPDATE payment_intents
SET status = 'succeeded', updated_at = now()
WHERE id = $1 AND status = 'requires_action'
RETURNING *`,
[paymentIntent.id]
);
if (updated.rowCount === 0) {
return; // Already processed (idempotent)
}
// Create charge and ledger entries
await this.createCharge(tx, paymentIntent, pix);
await this.ledger.recordPayment(tx, {
paymentIntentId: paymentIntent.id,
merchantId: paymentIntent.merchant_id,
amount: paymentIntent.amount,
currency: "brl",
captured: true,
});
});
// Emit event for webhook delivery
await this.eventBus.publish(
"payment_intent.succeeded",
{ paymentIntentId: paymentIntent.id }
);
}
}
}
ACH payments are fundamentally different from card payments: they are slow (2-5 business days for settlement), batch-processed, and have a much longer dispute window (60 days). The system must handle this asynchronous nature.
interface ACHPaymentTimeline {
day0: "Payment initiated, mandate verified";
day1: "Originator submits to ODFI (Originating Bank)";
day2: "ACH operator processes and routes to RDFI";
day3: "RDFI posts to customer account";
day4_to_day5: "Settlement between banks";
day60: "Return window closes";
}
// ACH requires explicit mandate/authorization from the customer
interface ACHMandate {
customerId: string;
bankAccountId: string;
ipAddress: string;
userAgent: string;
acceptedAt: Date;
mandateText: string; // The actual authorization text shown to customer
}
Fraud is an arms race. Every payment system must balance two competing goals: blocking fraudulent transactions and approving legitimate ones. Block too aggressively, and you lose revenue. Block too little, and you lose money to chargebacks.
A production fraud detection system uses multiple layers, from simple rules to sophisticated machine learning models.
interface FraudSignals {
// Transaction signals
amount: number;
currency: string;
merchantCategory: string;
isFirstPurchase: boolean;
isRecurring: boolean;
// Card signals
cardCountry: string;
cardBrand: string;
isPrePaid: boolean;
binRiskLevel: string;
// Device signals
ipAddress: string;
ipCountry: string;
deviceFingerprint: string;
userAgent: string;
isKnownProxy: boolean;
isKnownVPN: boolean;
isTorExit: boolean;
// Behavioral signals
sessionDuration: number;
pageViewsBeforePurchase: number;
hasChangedEmail: boolean;
accountAge: number;
// Velocity signals (computed)
txCountLast1h: number;
txCountLast24h: number;
txAmountLast24h: number;
distinctCardsLast24h: number;
distinctIPsLast24h: number;
failedAttemptsLast1h: number;
}
interface FraudResult {
score: number; // 0-100 (0 = safe, 100 = fraud)
action: "allow" | "challenge" | "block";
reasons: string[]; // Human-readable reasons
ruleMatches: string[]; // Which rules triggered
modelVersion: string;
}
class FraudEngine {
async score(signals: FraudSignals): Promise<FraudResult> {
const reasons: string[] = [];
let score = 0;
// Layer 1: Hard blocks (immediate rejection)
if (await this.isBlocklisted(signals)) {
return {
score: 100,
action: "block",
reasons: ["Card/IP/device is blocklisted"],
ruleMatches: ["blocklist"],
modelVersion: this.modelVersion,
};
}
// Layer 2: Velocity checks
if (signals.txCountLast1h > 10) {
score += 30;
reasons.push(
`High velocity: ${signals.txCountLast1h} txns in last hour`
);
}
if (signals.failedAttemptsLast1h > 3) {
score += 25;
reasons.push(
`${signals.failedAttemptsLast1h} failed attempts in last hour`
);
}
if (signals.distinctCardsLast24h > 3) {
score += 20;
reasons.push(
`${signals.distinctCardsLast24h} different cards in 24h`
);
}
// Layer 3: Geographic mismatch
if (
signals.cardCountry !== signals.ipCountry &&
!this.isCommonTravelPair(
signals.cardCountry,
signals.ipCountry
)
) {
score += 15;
reasons.push(
`Card country (${signals.cardCountry}) != IP country (${signals.ipCountry})`
);
}
// Layer 4: Proxy/VPN detection
if (signals.isKnownProxy || signals.isTorExit) {
score += 20;
reasons.push("Traffic from known proxy or Tor exit node");
}
// Layer 5: ML model (most sophisticated)
const mlScore = await this.mlModel.predict(signals);
score = Math.round(score * 0.4 + mlScore * 0.6);
// Determine action
let action: "allow" | "challenge" | "block";
if (score < 30) {
action = "allow";
} else if (score < 70) {
action = "challenge"; // Trigger 3D Secure
} else {
action = "block";
}
return {
score,
action,
reasons,
ruleMatches: reasons.map((r) => r.split(":")[0]),
modelVersion: this.modelVersion,
};
}
}
3D Secure (3DS) adds a cardholder authentication step, shifting liability for fraud from the merchant to the card issuer. Version 2.0 uses a frictionless flow for low-risk transactions and a challenge flow (OTP, biometric) for higher-risk ones.
class ThreeDSecureService {
async initiate3DS(
paymentIntent: PaymentIntent,
cardToken: string
): Promise<ThreeDSecureResult> {
// Send transaction data to card network's directory server
const authRequest = {
messageVersion: "2.2.0",
threeDSRequestorID: this.requestorId,
purchaseAmount: paymentIntent.amount.toString(),
purchaseCurrency: this.getCurrencyCode(
paymentIntent.currency
),
cardholderName: paymentIntent.metadata.cardholder_name,
email: paymentIntent.receipt_email,
// Device data for risk-based authentication
browserInfo: {
browserAcceptHeader: "text/html",
browserIP: paymentIntent.metadata.ip_address,
browserLanguage: "en-US",
browserScreenHeight: "1080",
browserScreenWidth: "1920",
browserTZ: "-180",
},
};
const response = await this.directoryServer.authenticate(
authRequest
);
if (response.transStatus === "Y") {
// Frictionless: issuer approved without challenge
return {
authenticated: true,
eci: response.eci, // Electronic Commerce Indicator
cavv: response.authValue, // Cardholder Auth Verification Value
version: "2.2.0",
challengeRequired: false,
};
}
if (response.transStatus === "C") {
// Challenge required: redirect customer to issuer
return {
authenticated: false,
challengeRequired: true,
redirectUrl: response.acsURL,
creq: response.challengeRequest,
};
}
// Rejected or error
return {
authenticated: false,
challengeRequired: false,
reason: response.transStatusReason,
};
}
}
Subscriptions are one of the most complex features in a payment system. The billing engine must handle trials, plan changes, proration, failed payments, and dunning (retry logic for failed charges).
interface Subscription {
id: string;
customerId: string;
merchantId: string;
planId: string;
status: SubscriptionStatus;
currentPeriodStart: Date;
currentPeriodEnd: Date;
cancelAtPeriodEnd: boolean;
canceledAt: Date | null;
trialStart: Date | null;
trialEnd: Date | null;
defaultPaymentMethod: string;
latestInvoice: string;
metadata: Record<string, string>;
createdAt: Date;
}
type SubscriptionStatus =
| "trialing"
| "active"
| "past_due" // Payment failed, retrying
| "unpaid" // All retries exhausted
| "canceled"
| "incomplete" // First payment failed
| "incomplete_expired"; // First payment not completed in time
interface BillingPlan {
id: string;
name: string;
amount: number;
currency: string;
interval: "day" | "week" | "month" | "year";
intervalCount: number; // e.g., 2 for "every 2 months"
trialDays: number;
metadata: Record<string, string>;
}
class BillingEngine {
/**
* Runs every hour to find subscriptions that need billing.
* Uses database-level locking to prevent double-billing
* in multi-instance deployments.
*/
async processBillingCycle(): Promise<void> {
// Find subscriptions ready to bill
// Use SELECT FOR UPDATE SKIP LOCKED for concurrent processing
const subscriptions = await this.db.query(
`SELECT s.* FROM subscriptions s
WHERE s.status IN ('active', 'trialing')
AND s.current_period_end <= now()
AND NOT EXISTS (
SELECT 1 FROM invoices i
WHERE i.subscription_id = s.id
AND i.period_start = s.current_period_end
)
ORDER BY s.current_period_end ASC
LIMIT 100
FOR UPDATE SKIP LOCKED`,
[]
);
for (const sub of subscriptions.rows) {
try {
await this.billSubscription(sub);
} catch (error) {
this.logger.error("Billing failed", {
subscriptionId: sub.id,
error: error.message,
});
// Continue with next subscription
// Failed ones will be picked up by dunning
}
}
}
private async billSubscription(
sub: Subscription
): Promise<void> {
const plan = await this.getPlan(sub.planId);
// Calculate next period
const nextPeriodStart = sub.currentPeriodEnd;
const nextPeriodEnd = this.calculateNextPeriod(
nextPeriodStart,
plan.interval,
plan.intervalCount
);
await this.db.transaction(async (tx) => {
// Create invoice
const invoice = await tx.queryOne(
`INSERT INTO invoices
(subscription_id, customer_id, merchant_id,
amount, currency, status,
period_start, period_end, due_date)
VALUES ($1, $2, $3, $4, $5, 'open', $6, $7, $8)
RETURNING *`,
[
sub.id,
sub.customerId,
sub.merchantId,
plan.amount,
plan.currency,
nextPeriodStart,
nextPeriodEnd,
nextPeriodStart, // Due immediately
]
);
// Attempt payment
try {
const paymentIntent = await this.createPaymentIntent({
amount: plan.amount,
currency: plan.currency,
customerId: sub.customerId,
merchantId: sub.merchantId,
paymentMethod: sub.defaultPaymentMethod,
description: `Subscription ${sub.id} - ${plan.name}`,
metadata: {
subscription_id: sub.id,
invoice_id: invoice.id,
},
});
// Confirm the payment immediately
await this.paymentOrchestrator.confirmPayment(
paymentIntent.id,
{
payment_method: sub.defaultPaymentMethod,
},
`sub_${sub.id}_${nextPeriodStart.toISOString()}`
);
// Payment succeeded - advance the subscription
await tx.query(
`UPDATE subscriptions
SET current_period_start = $1,
current_period_end = $2,
status = 'active',
updated_at = now()
WHERE id = $3`,
[nextPeriodStart, nextPeriodEnd, sub.id]
);
await tx.query(
`UPDATE invoices SET status = 'paid' WHERE id = $1`,
[invoice.id]
);
} catch (error) {
// Payment failed - enter dunning
await tx.query(
`UPDATE subscriptions
SET status = 'past_due', updated_at = now()
WHERE id = $1`,
[sub.id]
);
await tx.query(
`UPDATE invoices
SET status = 'open',
next_payment_attempt = now() + INTERVAL '3 days'
WHERE id = $1`,
[invoice.id]
);
}
});
}
}
When a subscription payment fails, you do not immediately cancel. Instead, you enter a "dunning" process: a carefully timed series of retries with customer notifications.
class DunningEngine {
// Retry schedule: attempt at these intervals after initial failure
private readonly retrySchedule = [
{ daysAfter: 3, notifyCustomer: true },
{ daysAfter: 5, notifyCustomer: true },
{ daysAfter: 7, notifyCustomer: true },
{ daysAfter: 14, notifyCustomer: true, finalNotice: true },
];
async processDunning(): Promise<void> {
const pastDueInvoices = await this.db.query(
`SELECT i.*, s.default_payment_method, s.customer_id
FROM invoices i
JOIN subscriptions s ON s.id = i.subscription_id
WHERE i.status = 'open'
AND s.status = 'past_due'
AND i.next_payment_attempt <= now()
AND i.attempt_count < $1
ORDER BY i.next_payment_attempt ASC
LIMIT 50
FOR UPDATE SKIP LOCKED`,
[this.retrySchedule.length]
);
for (const invoice of pastDueInvoices.rows) {
const attempt = invoice.attempt_count;
const schedule = this.retrySchedule[attempt];
try {
// Retry the payment
await this.retryPayment(invoice);
// Success! Reactivate subscription
await this.db.query(
`UPDATE subscriptions
SET status = 'active', updated_at = now()
WHERE id = $1`,
[invoice.subscription_id]
);
} catch (error) {
// Still failing
const isLastAttempt =
attempt >= this.retrySchedule.length - 1;
if (isLastAttempt) {
// All retries exhausted - mark as unpaid
await this.db.query(
`UPDATE subscriptions
SET status = 'unpaid', updated_at = now()
WHERE id = $1`,
[invoice.subscription_id]
);
await this.db.query(
`UPDATE invoices
SET status = 'uncollectible'
WHERE id = $1`,
[invoice.id]
);
} else {
// Schedule next retry
const nextSchedule = this.retrySchedule[attempt + 1];
await this.db.query(
`UPDATE invoices
SET attempt_count = attempt_count + 1,
next_payment_attempt = now()
+ INTERVAL '1 day' * $1
WHERE id = $2`,
[nextSchedule.daysAfter, invoice.id]
);
}
if (schedule.notifyCustomer) {
await this.sendDunningEmail(invoice, {
isFinalNotice: schedule.finalNotice || false,
nextRetryDate: isLastAttempt
? null
: this.retrySchedule[attempt + 1],
});
}
}
}
}
/**
* Smart retry timing optimization:
* - Retry on the 1st and 15th of month (common paydays)
* - Retry in the morning (higher approval rates)
* - If card expired, wait for new card before retrying
*/
private optimizeRetryTiming(invoice: Invoice): Date {
const now = new Date();
const dayOfMonth = now.getDate();
// If close to a common payday, schedule for that day
if (dayOfMonth < 15) {
return this.setToMorning(new Date(now.setDate(15)));
}
const nextMonth = new Date(now);
nextMonth.setMonth(nextMonth.getMonth() + 1, 1);
return this.setToMorning(nextMonth);
}
}
Supporting multiple currencies introduces significant complexity. The key challenges are exchange rate management, settlement currency conversion, and handling currencies with different decimal places.
// Different currencies have different minor unit sizes
// JPY has no minor units (1 JPY = 1 JPY, no cents)
// BHD has 3 decimal places (1 BHD = 1000 fils)
// Most others have 2 (1 USD = 100 cents)
const CURRENCY_DECIMALS: Record<string, number> = {
USD: 2, EUR: 2, GBP: 2, BRL: 2,
JPY: 0, // Zero-decimal currency
BHD: 3, KWD: 3, OMR: 3, // Three-decimal currencies
};
function toSmallestUnit(
amount: number,
currency: string
): number {
const decimals = CURRENCY_DECIMALS[currency.toUpperCase()] ?? 2;
return Math.round(amount * Math.pow(10, decimals));
}
function fromSmallestUnit(
amountSmallest: number,
currency: string
): number {
const decimals = CURRENCY_DECIMALS[currency.toUpperCase()] ?? 2;
return amountSmallest / Math.pow(10, decimals);
}
// Examples:
// toSmallestUnit(99.99, 'USD') = 9999 (cents)
// toSmallestUnit(1000, 'JPY') = 1000 (yen, no conversion)
// toSmallestUnit(10.500, 'BHD') = 10500 (fils)
interface ExchangeRate {
sourceCurrency: string;
targetCurrency: string;
rate: number; // Mid-market rate
buyRate: number; // Rate when we buy target currency
sellRate: number; // Rate when we sell target currency
spread: number; // Our margin (typically 1-2%)
timestamp: Date;
source: string; // "ecb", "reuters", "openexchange"
}
class CurrencyExchangeService {
private rates: Map<string, ExchangeRate> = new Map();
// Refresh rates every 60 seconds from multiple sources
async refreshRates(): Promise<void> {
const sources = [
this.fetchECBRates(),
this.fetchReutersRates(),
];
const results = await Promise.allSettled(sources);
const validRates = results
.filter(
(r): r is PromiseFulfilledResult<ExchangeRate[]> =>
r.status === "fulfilled"
)
.flatMap((r) => r.value);
// Use median of sources for each pair (reduces manipulation risk)
for (const rate of validRates) {
const key = `${rate.sourceCurrency}_${rate.targetCurrency}`;
this.rates.set(key, rate);
}
}
convert(
amount: number,
fromCurrency: string,
toCurrency: string
): ConversionResult {
if (fromCurrency === toCurrency) {
return { amount, rate: 1, fee: 0 };
}
const rate = this.getRate(fromCurrency, toCurrency);
if (!rate) {
throw new UnsupportedCurrencyPairError(
`No exchange rate for ${fromCurrency} -> ${toCurrency}`
);
}
// Apply our spread
const effectiveRate = rate.sellRate; // Customer is "buying" target currency
const convertedAmount = Math.round(amount * effectiveRate);
const fee = Math.round(
amount * (effectiveRate - rate.rate)
);
return {
amount: convertedAmount,
rate: effectiveRate,
fee,
midMarketRate: rate.rate,
timestamp: rate.timestamp,
};
}
}
Reconciliation is the process of verifying that your internal records match external records (bank statements, PSP reports, card network files). In a high-volume payment system, discrepancies are inevitable. The reconciliation engine must find them, classify them, and resolve them.
interface ReconciliationResult {
date: string;
totalInternalTransactions: number;
totalExternalTransactions: number;
matched: number;
timingDifferences: number; // Expected, will resolve next day
amountDiscrepancies: number; // Need investigation
missingInternal: number; // In PSP but not in our system
missingExternal: number; // In our system but not in PSP
netDiscrepancy: number; // Total unexplained difference in cents
}
class ReconciliationEngine {
async reconcile(
date: string,
pspName: string
): Promise<ReconciliationResult> {
// Step 1: Load internal transactions for the date
const internal = await this.db.query(
`SELECT c.id, c.psp_reference, c.amount, c.currency,
c.status, c.created_at
FROM charges c
WHERE c.psp_name = $1
AND c.created_at::date = $2::date
AND c.status IN ('succeeded', 'failed')
ORDER BY c.psp_reference`,
[pspName, date]
);
// Step 2: Load PSP settlement report
const external = await this.loadPSPReport(pspName, date);
// Step 3: Match by PSP reference ID
const result: ReconciliationResult = {
date,
totalInternalTransactions: internal.rows.length,
totalExternalTransactions: external.length,
matched: 0,
timingDifferences: 0,
amountDiscrepancies: 0,
missingInternal: 0,
missingExternal: 0,
netDiscrepancy: 0,
};
const internalMap = new Map(
internal.rows.map((r) => [r.psp_reference, r])
);
const externalMap = new Map(
external.map((r) => [r.reference, r])
);
// Find matches and discrepancies
for (const [ref, ext] of externalMap) {
const int = internalMap.get(ref);
if (!int) {
result.missingInternal++;
result.netDiscrepancy += ext.amount;
await this.recordDiscrepancy({
type: "missing_internal",
pspReference: ref,
externalAmount: ext.amount,
date,
});
continue;
}
if (int.amount !== ext.amount) {
result.amountDiscrepancies++;
result.netDiscrepancy += ext.amount - int.amount;
await this.recordDiscrepancy({
type: "amount_mismatch",
pspReference: ref,
internalAmount: int.amount,
externalAmount: ext.amount,
difference: ext.amount - int.amount,
date,
});
continue;
}
result.matched++;
internalMap.delete(ref);
}
// Remaining internal transactions not found in PSP report
for (const [ref] of internalMap) {
result.missingExternal++;
await this.recordDiscrepancy({
type: "missing_external",
pspReference: ref,
date,
});
}
// Alert if discrepancy exceeds threshold
if (
Math.abs(result.netDiscrepancy) > 10000 || // > $100
result.amountDiscrepancies > 10
) {
await this.alertFinanceTeam(result);
}
return result;
}
}
-- Daily reconciliation summary query
SELECT
DATE(c.created_at) AS transaction_date,
c.psp_name,
COUNT(*) AS total_charges,
SUM(c.amount) AS total_amount,
SUM(CASE WHEN c.status = 'succeeded' THEN c.amount ELSE 0 END)
AS succeeded_amount,
SUM(CASE WHEN c.status = 'failed' THEN 1 ELSE 0 END)
AS failed_count,
SUM(c.amount_refunded) AS total_refunded
FROM charges c
WHERE c.created_at >= CURRENT_DATE - INTERVAL '7 days'
GROUP BY DATE(c.created_at), c.psp_name
ORDER BY transaction_date DESC, c.psp_name;
-- Unreconciled transactions (should trend toward zero)
SELECT COUNT(*) AS unreconciled,
SUM(amount) AS unreconciled_amount
FROM charges
WHERE status = 'succeeded'
AND reconciled_at IS NULL
AND created_at < CURRENT_DATE - INTERVAL '3 days';
Payment systems are prime targets for attackers. A breach can result in massive fines, loss of processing ability, and destruction of customer trust. Security is not a feature you add later -- it is baked into every architectural decision.
PCI-DSS (Payment Card Industry Data Security Standard) Level 1 is the highest level of compliance, required for any entity processing over 6 million card transactions per year. The key principle is minimizing the "cardholder data environment" (CDE) -- the set of systems that touch raw card numbers.
// The Token Engine runs in an isolated PCI-scoped environment
// It is the ONLY service that ever sees raw card numbers
class TokenEngine {
// Runs in isolated VPC with separate access controls
// All communication encrypted with mutual TLS
// Hardware Security Module (HSM) for encryption keys
async tokenize(cardData: RawCardData): Promise<string> {
// Step 1: Validate card number (Luhn check)
if (!this.luhnCheck(cardData.number)) {
throw new InvalidCardError("Card number fails Luhn check");
}
// Step 2: Check if we already have a token for this card
// (uses fingerprint, not raw number)
const fingerprint = await this.hsm.generateFingerprint(
cardData.number
);
const existing = await this.vaultDB.query(
`SELECT token FROM card_vault
WHERE fingerprint = $1 AND merchant_id = $2`,
[fingerprint, cardData.merchantId]
);
if (existing.rows.length > 0) {
return existing.rows[0].token;
}
// Step 3: Encrypt card data with HSM-managed key
const encryptedPAN = await this.hsm.encrypt(
cardData.number,
{
algorithm: "AES-256-GCM",
keyId: this.currentKeyId,
context: `card:${fingerprint}`,
}
);
// Step 4: Generate token (non-reversible without vault access)
const token = `tok_${this.generateSecureToken()}`;
// Step 5: Store in vault
await this.vaultDB.query(
`INSERT INTO card_vault
(token, fingerprint, encrypted_pan, last_four, brand,
exp_month, exp_year, merchant_id, key_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
[
token,
fingerprint,
encryptedPAN,
cardData.number.slice(-4),
this.detectBrand(cardData.number),
cardData.expMonth,
cardData.expYear,
cardData.merchantId,
this.currentKeyId,
]
);
// Step 6: Audit log (never includes PAN)
await this.auditLog.record({
action: "card_tokenized",
token,
fingerprint,
brand: this.detectBrand(cardData.number),
lastFour: cardData.number.slice(-4),
});
return token;
}
// Detokenize is only called by the PSP integration layer
// Never exposed to merchant-facing APIs
async detokenize(token: string): Promise<string> {
const vault = await this.vaultDB.query(
`SELECT encrypted_pan, key_id FROM card_vault
WHERE token = $1`,
[token]
);
if (vault.rows.length === 0) {
throw new TokenNotFoundError();
}
const pan = await this.hsm.decrypt(
vault.rows[0].encrypted_pan,
vault.rows[0].key_id
);
// Audit every detokenization
await this.auditLog.record({
action: "card_detokenized",
token,
reason: "psp_authorization",
requestedBy: this.getCallerIdentity(),
});
return pan;
}
}
-- Enable Transparent Data Encryption (TDE) for PostgreSQL
-- In practice, use managed services (AWS RDS, GCP Cloud SQL)
-- that handle this automatically
-- Column-level encryption for extra-sensitive fields
-- Uses pgcrypto extension
CREATE EXTENSION IF NOT EXISTS pgcrypto;
-- Merchant bank account details (encrypted column)
CREATE TABLE merchant_bank_accounts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
merchant_id UUID NOT NULL REFERENCES merchants(id),
-- Encrypted with application-level key (HSM-managed)
encrypted_account_number BYTEA NOT NULL,
encrypted_routing_number BYTEA NOT NULL,
-- These are safe to store in plain text
bank_name VARCHAR(100),
account_type VARCHAR(20),
country CHAR(2),
-- Masked version for display
last_four CHAR(4),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- API key storage (never store raw keys)
CREATE TABLE api_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
merchant_id UUID NOT NULL REFERENCES merchants(id),
key_prefix VARCHAR(12) NOT NULL, -- "sk_live_xxxx" (visible prefix)
key_hash VARCHAR(64) NOT NULL, -- SHA-256 of full key
mode VARCHAR(4) NOT NULL CHECK (mode IN ('test', 'live')),
name VARCHAR(100),
active BOOLEAN NOT NULL DEFAULT true,
last_used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (key_hash)
);
-- When authenticating: hash the provided key and look up by hash
-- NEVER store or log the raw API key after initial creation
Every access to sensitive data, every state change, and every administrative action must be logged immutably.
interface AuditEntry {
id: string;
timestamp: Date;
actor: {
type: "system" | "merchant" | "admin" | "api";
id: string;
ipAddress: string;
};
action: string;
resource: {
type: string; // "payment_intent", "refund", "merchant"
id: string;
};
changes?: {
field: string;
oldValue: string; // Sensitive values are masked
newValue: string;
}[];
metadata?: Record<string, string>;
}
class AuditService {
// Audit log is append-only. Write to a separate database
// that application code cannot modify or delete.
async record(entry: AuditEntry): Promise<void> {
// Write to immutable audit store
// (e.g., separate PostgreSQL with restricted permissions,
// or AWS CloudTrail / GCP Cloud Audit Logs)
await this.auditDB.query(
`INSERT INTO audit_log
(timestamp, actor_type, actor_id, actor_ip,
action, resource_type, resource_id,
changes, metadata)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
[
entry.timestamp,
entry.actor.type,
entry.actor.id,
entry.actor.ipAddress,
entry.action,
entry.resource.type,
entry.resource.id,
JSON.stringify(entry.changes),
JSON.stringify(entry.metadata),
]
);
// The audit database user has INSERT-only permissions:
// GRANT INSERT ON audit_log TO audit_writer;
// No UPDATE, DELETE, or TRUNCATE permissions
}
}
| Category | Requirement | Implementation |
|---|---|---|
| Data | Never store raw PAN | Tokenize at edge, store tokens only |
| Data | Never store CVV/CVC | Not stored anywhere, ever, under any circumstances |
| Data | Mask PAN in logs | Show only last 4 digits: ****1234 |
| Transport | TLS 1.3 everywhere | Mutual TLS between internal services |
| Transport | Certificate pinning | Mobile SDKs pin to known certificates |
| Auth | API key rotation | Support multiple active keys, grace period for rotation |
| Auth | Rate limiting | Per-merchant, per-endpoint rate limits |
| Auth | IP allowlisting | Optional for merchant API access |
| Encryption | AES-256-GCM at rest | HSM-managed keys with annual rotation |
| Access | Least privilege | No human access to production card vault |
| Access | Break-glass procedure | Emergency access requires dual approval |
| Monitoring | Intrusion detection | Real-time alerts on anomalous access patterns |
| Compliance | PCI-DSS Level 1 audit | Annual on-site assessment by QSA |
| Compliance | SOC 2 Type II | Annual audit of security controls |
The database architecture for a payment system prioritizes correctness over performance. We use PostgreSQL as the primary data store because its ACID guarantees are non-negotiable for financial data.
At 1 billion transactions per month, a single table will not scale. We partition by time.
-- Partition payment_intents by month
CREATE TABLE payment_intents (
id UUID NOT NULL DEFAULT gen_random_uuid(),
merchant_id UUID NOT NULL,
amount BIGINT NOT NULL,
currency CHAR(3) NOT NULL,
status VARCHAR(30) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- ... other columns
PRIMARY KEY (id, created_at)
) PARTITION BY RANGE (created_at);
-- Create partitions for each month
CREATE TABLE payment_intents_2026_01
PARTITION OF payment_intents
FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
CREATE TABLE payment_intents_2026_02
PARTITION OF payment_intents
FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');
CREATE TABLE payment_intents_2026_03
PARTITION OF payment_intents
FOR VALUES FROM ('2026-03-01') TO ('2026-04-01');
-- Automate partition creation with pg_partman or a cron job
-- that creates partitions 3 months ahead
-- Partition ledger_entries the same way
CREATE TABLE ledger_entries (
id UUID NOT NULL DEFAULT gen_random_uuid(),
transaction_id UUID NOT NULL,
account_id UUID NOT NULL,
entry_type VARCHAR(6) NOT NULL,
amount BIGINT NOT NULL,
currency CHAR(3) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (id, created_at)
) PARTITION BY RANGE (created_at);
-- Older partitions can be moved to cold storage
-- NEVER delete financial data; move to cheaper storage
ALTER TABLE payment_intents_2024_01
SET TABLESPACE cold_storage;
// Connection pool configuration for payment workloads
const poolConfig = {
// Primary pool: payment processing (write-heavy)
primary: {
host: process.env.PG_PRIMARY_HOST,
port: 5432,
database: "payments",
max: 50, // Max connections per pod
min: 10, // Keep warm connections
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 5000,
statement_timeout: "10s", // Kill long-running queries
// Use prepared statements for frequent queries
preparedStatements: true,
},
// Read replica pool: dashboards and reporting
replica: {
host: process.env.PG_READ_HOST,
port: 5432,
database: "payments",
max: 100, // More connections for read-heavy
min: 5,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 5000,
statement_timeout: "60s", // Reports can take longer
},
};
// Critical: use advisory locks for subscription billing
// to prevent double-billing across multiple workers
async function acquireBillingLock(
subscriptionId: string
): Promise<boolean> {
const lockKey = hashToInt(subscriptionId);
const result = await db.query(
`SELECT pg_try_advisory_xact_lock($1) AS acquired`,
[lockKey]
);
return result.rows[0].acquired;
}
Payment systems are inherently event-driven. A single payment generates a cascade of downstream effects: webhook delivery, settlement processing, reporting updates, analytics, and more.
Every state change in the payment lifecycle produces an immutable event. These events serve multiple purposes:
interface PaymentEvent {
id: string;
aggregateId: string; // payment_intent_id
aggregateType: "payment_intent";
eventType: string;
eventVersion: number; // For schema evolution
sequenceNumber: number; // Ordering within aggregate
data: Record<string, unknown>;
metadata: {
correlationId: string; // Trace across services
causationId: string; // What caused this event
userId?: string;
merchantId: string;
timestamp: Date;
source: string; // Which service produced it
};
}
class EventStore {
async append(event: PaymentEvent): Promise<void> {
// Append with optimistic concurrency control
try {
await this.db.query(
`INSERT INTO payment_events
(id, payment_intent_id, event_type, data,
sequence_number, metadata, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[
event.id,
event.aggregateId,
event.eventType,
event.data,
event.sequenceNumber,
event.metadata,
new Date(),
]
);
} catch (error) {
if (this.isUniqueViolation(error)) {
throw new ConcurrentModificationError(
`Event ${event.sequenceNumber} already exists for ${event.aggregateId}`
);
}
throw error;
}
// Publish to Kafka for downstream consumers
await this.kafka.produce({
topic: `payment.events.${event.eventType}`,
key: event.aggregateId,
value: JSON.stringify(event),
headers: {
"event-type": event.eventType,
"correlation-id": event.metadata.correlationId,
},
});
}
// Reconstruct payment state from events
async getPaymentHistory(
paymentIntentId: string
): Promise<PaymentEvent[]> {
const result = await this.db.query(
`SELECT * FROM payment_events
WHERE payment_intent_id = $1
ORDER BY sequence_number ASC`,
[paymentIntentId]
);
return result.rows;
}
}
Reliable webhook delivery is surprisingly difficult. You must handle retries, ordering, circuit breaking for unhealthy endpoints, and signature verification.
class WebhookDeliveryService {
private readonly maxRetries = 8;
// Exponential backoff: 1min, 5min, 30min, 2h, 8h, 24h, 48h, 72h
private readonly retryDelays = [
60, 300, 1800, 7200, 28800, 86400, 172800, 259200,
];
async deliverEvent(event: PaymentEvent): Promise<void> {
// Find all webhook endpoints subscribed to this event type
const endpoints = await this.db.query(
`SELECT * FROM webhook_endpoints
WHERE merchant_id = $1
AND status = 'active'
AND enabled_events @> $2::jsonb`,
[
event.metadata.merchantId,
JSON.stringify([event.eventType]),
]
);
for (const endpoint of endpoints.rows) {
await this.deliverToEndpoint(event, endpoint);
}
}
private async deliverToEndpoint(
event: PaymentEvent,
endpoint: WebhookEndpoint
): Promise<void> {
const payload = JSON.stringify({
id: `evt_${event.id}`,
object: "event",
type: event.eventType,
created: Math.floor(event.metadata.timestamp.getTime() / 1000),
data: { object: event.data },
livemode: true,
});
// Generate signature
const timestamp = Math.floor(Date.now() / 1000);
const signedPayload = `${timestamp}.${payload}`;
const signature = crypto
.createHmac("sha256", endpoint.secret)
.update(signedPayload)
.digest("hex");
const signatureHeader = `t=${timestamp},v1=${signature}`;
try {
const response = await fetch(endpoint.url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Stripe-Signature": signatureHeader,
"Webhook-Id": event.id,
"Webhook-Timestamp": timestamp.toString(),
},
body: payload,
signal: AbortSignal.timeout(30000), // 30s timeout
});
if (response.status >= 200 && response.status < 300) {
// Success
await this.recordDelivery(event.id, endpoint.id, "delivered");
return;
}
// Non-2xx response - schedule retry
throw new WebhookDeliveryError(
`HTTP ${response.status}`
);
} catch (error) {
await this.scheduleRetry(event, endpoint, 0);
}
}
private async scheduleRetry(
event: PaymentEvent,
endpoint: WebhookEndpoint,
attempt: number
): Promise<void> {
if (attempt >= this.maxRetries) {
await this.recordDelivery(
event.id,
endpoint.id,
"failed"
);
// Disable endpoint after too many consecutive failures
await this.checkEndpointHealth(endpoint.id);
return;
}
const delaySeconds = this.retryDelays[attempt];
await this.db.query(
`INSERT INTO webhook_delivery_queue
(event_id, endpoint_id, attempt, scheduled_at)
VALUES ($1, $2, $3, now() + INTERVAL '1 second' * $4)`,
[event.id, endpoint.id, attempt + 1, delaySeconds]
);
}
}
A payment system must be the most reliable service in your infrastructure. When the payment system is down, your merchants are losing revenue. Here are the patterns that enable five-nines availability on the payment path.
class CircuitBreaker {
private failures = 0;
private successes = 0;
private state: "closed" | "open" | "half-open" = "closed";
private lastFailureTime: Date | null = null;
constructor(
private readonly threshold: number = 5,
private readonly resetTimeMs: number = 30000,
private readonly halfOpenMaxCalls: number = 3
) {}
async execute<T>(operation: () => Promise<T>): Promise<T> {
if (this.state === "open") {
// Check if enough time has passed to try again
if (
Date.now() - (this.lastFailureTime?.getTime() || 0) >
this.resetTimeMs
) {
this.state = "half-open";
this.successes = 0;
} else {
throw new CircuitOpenError(
"Circuit breaker is open, failing fast"
);
}
}
try {
const result = await operation();
this.onSuccess();
return result;
} catch (error) {
this.onFailure();
throw error;
}
}
private onSuccess(): void {
if (this.state === "half-open") {
this.successes++;
if (this.successes >= this.halfOpenMaxCalls) {
// Enough successful calls, close the circuit
this.state = "closed";
this.failures = 0;
}
} else {
this.failures = 0;
}
}
private onFailure(): void {
this.failures++;
this.lastFailureTime = new Date();
if (this.failures >= this.threshold) {
this.state = "open";
}
}
isOpen(): boolean {
return this.state === "open";
}
}
Payment systems cannot have maintenance windows. Deployments must be zero-downtime with instant rollback capability.
// Database migrations must be backward-compatible
// Follow the "expand and contract" pattern:
// Step 1: EXPAND - Add new column (nullable, with default)
// Deploy: Both old and new code work
// ALTER TABLE charges ADD COLUMN new_status VARCHAR(30);
// Step 2: MIGRATE - Backfill data
// UPDATE charges SET new_status = status WHERE new_status IS NULL;
// Step 3: CODE - Deploy new code that writes to both columns
// Application writes to both old and new columns
// Step 4: CONTRACT - Remove old column (after all instances updated)
// ALTER TABLE charges DROP COLUMN old_status;
// NEVER rename a column or change its type in a single deploy
// NEVER add a NOT NULL constraint without a default value
// NEVER drop a column that old code still reads from
The critical payment path has multiple levels of redundancy.
class ResilientPaymentPath {
async processPayment(
paymentIntent: PaymentIntent
): Promise<PaymentResult> {
// Level 1: Try primary PSP
try {
return await this.withTimeout(
this.primaryPSP.authorize(paymentIntent),
5000 // 5s timeout
);
} catch (primaryError) {
this.metrics.increment("psp.primary.failure");
// Level 2: Try secondary PSP (only for network errors)
if (this.isNetworkError(primaryError)) {
try {
return await this.withTimeout(
this.secondaryPSP.authorize(paymentIntent),
5000
);
} catch (secondaryError) {
this.metrics.increment("psp.secondary.failure");
// Level 3: Queue for async processing
if (this.isNetworkError(secondaryError)) {
return this.queueForRetry(paymentIntent);
}
throw secondaryError;
}
}
throw primaryError; // Card decline, not a network issue
}
}
private queueForRetry(
paymentIntent: PaymentIntent
): PaymentResult {
// Store in local durable queue (Redis with persistence)
// Return "processing" status to merchant
// Background worker will retry when PSP is available
this.retryQueue.enqueue({
paymentIntentId: paymentIntent.id,
enqueuedAt: new Date(),
maxRetryUntil: new Date(Date.now() + 15 * 60 * 1000),
});
return {
status: "processing",
message:
"Payment is being processed. You will be notified via webhook.",
};
}
}