System Design Problems
24 min read

Design a Payment System

Building a payment processing platform that handles card transactions, bank transfers, and digital wallets with PCI DSS compliance, idempotent processing, and real-time fraud detection. Payment systems operate under unique constraints: zero tolerance for duplicate charges, regulatory mandates (PCI DSS), and sub-second fraud decisions. This design covers the complete payment lifecycle—authorization, capture, settlement—plus reconciliation, refunds, and multi-gateway routing.

Data Layer

External Networks

Processing Layer

Payment Gateway

Client Layer

Web App

Mobile App

Point of Sale

Payment API

Idempotent

Smart Router

Failover + Optimization

Fraud Engine

ML Scoring

Authorization

Service

Capture

Service

Refund

Service

Visa / VisaNet

Mastercard / Banknet

ACH Network

Apple Pay / Google Pay

Double-Entry

Ledger

Token Vault

PCI Scope

Event Stream

Kafka

Payment system architecture: Client apps submit payments through an idempotent API, fraud engine scores in real-time, smart router selects optimal processor, authorization flows through card networks, and all movements are recorded in a double-entry ledger.

Payment system design revolves around four competing constraints:

  1. Exactly-once processing — Network failures and retries must never result in duplicate charges. Idempotency keys + request fingerprinting make every operation safely retriable.

  2. PCI compliance scope reduction — Cardholder data (PAN, CVV) must never touch your servers if avoidable. Tokenization at the edge (via Stripe Elements, Adyen Web Components) keeps sensitive data out of your environment.

  3. Latency under fraud scrutiny — Fraud decisions must complete in <100ms to avoid checkout abandonment, while evaluating 1000+ signals per transaction.

  4. Financial accuracy — Every fund movement (authorization hold, capture, refund, chargeback) must be recorded in a double-entry ledger. Reconciliation ensures external settlements match internal records.

The mental model: tokenize → authorize → capture → settle → reconcile. Each stage has distinct timing, failure modes, and rollback procedures.

Design DecisionTrade-off
Edge tokenizationRemoves PAN from scope; adds client SDK complexity
Idempotency keysSafe retries; requires key management and storage
Smart routingHigher auth rates; multi-processor operational overhead
Async settlementHandles scale; delayed confirmation visibility
Double-entry ledgerAudit-ready; write amplification
FeatureScopeNotes
Card paymentsCoreVisa, Mastercard, Amex via card networks
Bank transfersCoreACH (US), SEPA (EU), wire transfers
Digital walletsCoreApple Pay, Google Pay (tokenized)
Authorization + CaptureCoreSeparate or combined (auth-capture)
RefundsCoreFull and partial, with reason codes
Recurring paymentsCoreSubscription billing with retry logic
Multi-currencyExtendedFX conversion at capture time
Split paymentsExtendedMarketplace payouts
Disputes/ChargebacksExtendedEvidence submission, representment
3D SecureCoreSCA compliance for EU/PSD2
RequirementTargetRationale
Availability99.99%Revenue-critical; Stripe maintains 99.999%
Authorization latencyp99 < 2sCard network round-trip + fraud scoring
Fraud decision latencyp99 < 100msInline with authorization; cannot delay checkout
Duplicate charge rate0%Non-negotiable; idempotency required
Data consistencyStrongFinancial data requires ACID guarantees
PCI DSS complianceLevel 1Required for >6M transactions/year
Settlement accuracy100%Reconciliation must match external records

Traffic Profile:

MetricTypicalPeak (Black Friday)
Transactions/day10M50M
TPS (average)115 TPS580 TPS
TPS (peak)500 TPS2,000 TPS
Auth requests/sec1,000 RPS5,000 RPS

Reference: Visa processes 1,700-8,500 TPS average, with peak capacity of 65,000+ TPS.

Storage:

Transactions: 10M/day × 2KB = 20GB/day
Yearly: 7.3TB
With 7-year retention: ~50TB
Ledger entries: 10M × 4 entries (avg) × 500B = 20GB/day
Event stream: 10M × 1KB = 10GB/day

Latency Budget:

Total authorization: 2000ms budget
├── API processing: 50ms
├── Fraud scoring: 100ms
├── Tokenization lookup: 20ms
├── Network to processor: 50ms
├── Processor to card network: 500ms
├── Issuer decision: 800ms
├── Response path: 480ms

Best when:

  • High transaction volume (>$1B annually)—interchange savings justify engineering cost
  • Regulatory requirements demand data residency
  • Unique payment flows that don’t fit third-party APIs

Architecture:

Card Networks

In-House

Payment Gateway

Processor Integration

Risk Engine

Ledger

Acquiring Bank

VisaNet

Banknet

Client

Key characteristics:

  • Direct acquiring bank relationships
  • Full control over routing decisions
  • In-house tokenization and vault
  • Custom fraud rules and ML models

Trade-offs:

  • Lower per-transaction cost at scale (save 0.1-0.3%)
  • Full customization of payment flows
  • Data residency control
  • PCI DSS Level 1 scope (audit, penetration testing, quarterly scans)
  • 12-18 month build time minimum
  • Requires dedicated security and compliance team

Real-world example: Shopify built Shop Pay in-house, processing $12B+ in GMV (2023). Justified by volume and unique merchant financing features. Required dedicated payments engineering team of 50+.

Best when:

  • Speed to market is critical
  • Transaction volume <$500M annually
  • Engineering focus should be on product, not payments infrastructure

Architecture:

Payment Platform

Your Application

Stripe.js

Events

Payment API

Webhook Handler

Stripe API

Fraud Detection

Token Vault

Client

Key characteristics:

  • PCI scope reduced to SAQ-A (minimal questionnaire)
  • Built-in fraud detection (Stripe Radar)
  • Payment method coverage (cards, wallets, BNPL)
  • Automatic card network compliance updates

Trade-offs:

  • Days to integrate, not months
  • PCI compliance handled by provider
  • Built-in fraud detection
  • Global payment method coverage
  • Higher per-transaction fees (2.9% + $0.30 typical)
  • Less control over routing and failover
  • Vendor lock-in risk

Real-world example: Figma uses Stripe for all payments. At their scale (~$600M ARR), the 2.9% fee is acceptable given engineering leverage—zero payment engineers needed on staff.

Best when:

  • Multiple payment providers needed (regional coverage, redundancy)
  • Authorization rate optimization is critical
  • Gradual migration from one provider to another

Architecture:

Payment Providers

Orchestration Layer

Your Application

Payment API

Smart Router

Routing Rules

Stripe

Adyen

PayPal

Key characteristics:

  • Single API, multiple backend processors
  • Intelligent routing based on card type, geography, cost
  • Automatic failover on processor outage
  • A/B testing payment flows

Trade-offs:

  • Redundancy and failover
  • Route optimization (Adyen reports 26% cost savings with smart routing)
  • Gradual provider migration
  • Additional integration layer complexity
  • Token portability challenges between providers
  • Orchestration platform cost

Real-world example: eBay uses Adyen’s intelligent payment routing, achieving 26% average cost savings on US debit transactions and 0.22% uplift in authorization rates.

FactorPath A (Build)Path B (Third-Party)Path C (Orchestration)
Time to market12-18 monthsDays-weeks1-3 months
PCI scopeLevel 1 (full)SAQ-A (minimal)SAQ-A (minimal)
Per-transaction costLowest at scaleHighest (2.9%+)Middle
Engineering effortHigh (50+ FTEs)Low (1-2 FTEs)Medium (5-10 FTEs)
CustomizationFullLimitedMedium
Best forHigh-volume, unique needsStartups, SMBsEnterprise, multi-region

This article focuses on Path B (Third-Party) with elements of Path C (Smart Routing) because:

  1. Most engineering teams should not build payment infrastructure
  2. Third-party platforms handle PCI compliance, fraud, and card network changes
  3. Smart routing concepts apply regardless of implementation

The architecture sections show how to integrate third-party providers while maintaining control over critical concerns like idempotency, reconciliation, and ledger accuracy.

ComponentResponsibilityTechnology
Payment APIIdempotent payment operationsREST API + Idempotency keys
Token ServiceMap payment methods to tokensStripe.js / Adyen Web Components
Smart RouterSelect optimal processorRule engine + ML routing
Fraud EngineReal-time risk scoringML model (Stripe Radar)
Authorization ServiceCard network communicationProcessor SDK
Capture ServiceSettlement initiationAsync job processor
Ledger ServiceDouble-entry bookkeepingPostgreSQL + event sourcing
Reconciliation ServiceMatch internal vs externalBatch jobs + anomaly detection
Webhook HandlerProcess async eventsIdempotent consumer
LedgerIssuing BankCard NetworkProcessor (Stripe)Fraud EnginePayment APIClientLedgerIssuing BankCard NetworkProcessor (Stripe)Fraud EnginePayment APIClientLater: CaptureT+2 days: SettlementPOST /payments (idempotency_key)Check idempotency cacheScore transactionrisk_score: 25, decision: allowCreate PaymentIntentAuthorization requestVerify funds, fraud checkApproved (auth_code)Authorization responsePaymentIntent created (auth hold)Record authorization (debit: receivables, credit: auth_hold)201 Created {payment_id, status: authorized}POST /payments/{id}/captureCapture PaymentIntentCapture requestCapturedRecord capture (debit: auth_hold, credit: revenue)200 OK {status: captured}Webhook: payout.createdRecord settlement (debit: cash, credit: receivables)
PatternUse CaseAuth Window
Auth + immediate captureDigital goods, subscriptionsN/A (single request)
Auth then captureE-commerce (ship then charge)7 days (Visa), 30 days (others)
Auth with delayed captureHotels, car rentalsUp to 31 days
Incremental authHotels (room service additions)Within original auth window

Design note: Visa shortened online Merchant-Initiated Transaction (MIT) windows from 7 to 5 days as of April 2024. Always capture within the network’s window or risk auth expiration.

POST /api/v1/payments
Idempotency-Key: pay_abc123_user_456
Authorization: Bearer {api_key}
Content-Type: application/json
{
"amount": 9999,
"currency": "usd",
"payment_method_token": "pm_tok_visa_4242",
"capture_method": "automatic",
"description": "Order #12345",
"metadata": {
"order_id": "ord_789",
"customer_email": "user@example.com"
},
"idempotency_key": "pay_abc123_user_456"
}

Response (201 Created):

{
"id": "pay_xyz789",
"object": "payment",
"amount": 9999,
"currency": "usd",
"status": "succeeded",
"payment_method": {
"id": "pm_tok_visa_4242",
"type": "card",
"card": {
"brand": "visa",
"last4": "4242",
"exp_month": 12,
"exp_year": 2025
}
},
"captured": true,
"receipt_url": "https://pay.example.com/receipts/pay_xyz789",
"created_at": "2024-03-15T10:00:00Z",
"metadata": {
"order_id": "ord_789"
}
}

Error Responses:

CodeConditionResponse
400 Bad RequestInvalid amount, currency{"error": {"code": "invalid_amount"}}
402 Payment RequiredCard declined{"error": {"code": "card_declined", "decline_code": "insufficient_funds"}}
409 ConflictIdempotency key reused with different params{"error": {"code": "idempotency_conflict"}}
429 Too Many RequestsRate limit exceeded{"error": {"code": "rate_limited"}}
POST /api/v1/payments
Idempotency-Key: auth_abc123
{
"amount": 9999,
"currency": "usd",
"payment_method_token": "pm_tok_visa_4242",
"capture_method": "manual"
}

Response:

{
"id": "pay_xyz789",
"status": "requires_capture",
"amount_capturable": 9999,
"capture_before": "2024-03-22T10:00:00Z"
}
POST /api/v1/payments/{payment_id}/capture
Idempotency-Key: cap_abc123
{
"amount_to_capture": 9999
}

Partial capture: Capture less than authorized amount. Remaining authorization is automatically released.

POST /api/v1/payments/{payment_id}/refunds
Idempotency-Key: ref_abc123
{
"amount": 2500,
"reason": "customer_request",
"metadata": {
"support_ticket": "TKT-456"
}
}

Response:

{
"id": "ref_abc456",
"payment_id": "pay_xyz789",
"amount": 2500,
"status": "pending",
"reason": "customer_request",
"estimated_arrival": "2024-03-20"
}

Refund timing: Card refunds take 5-10 business days to appear on customer statement. ACH refunds take 3-5 business days.

POST /webhooks/payments
Stripe-Signature: t=1234567890,v1=abc123...
{
"id": "evt_123",
"type": "payment_intent.succeeded",
"data": {
"object": {
"id": "pi_xyz",
"amount": 9999,
"status": "succeeded"
}
},
"created": 1234567890
}

Critical webhook events:

EventAction Required
payment_intent.succeededMark order as paid, trigger fulfillment
payment_intent.payment_failedNotify customer, retry logic
charge.refundedUpdate order status, adjust inventory
charge.dispute.createdAlert fraud team, gather evidence
payout.paidReconcile settlement
5 collapsed lines
-- Core payment record
CREATE TABLE payments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
external_id VARCHAR(100) UNIQUE NOT NULL,
idempotency_key VARCHAR(255) UNIQUE NOT NULL,
-- Amount
amount_cents BIGINT NOT NULL CHECK (amount_cents > 0),
currency VARCHAR(3) NOT NULL,
amount_captured_cents BIGINT DEFAULT 0,
amount_refunded_cents BIGINT DEFAULT 0,
-- Status
status VARCHAR(30) NOT NULL DEFAULT 'pending',
capture_method VARCHAR(20) NOT NULL,
-- Payment method (tokenized reference)
payment_method_id UUID REFERENCES payment_methods(id),
payment_method_type VARCHAR(20) NOT NULL,
-- Customer
customer_id UUID REFERENCES customers(id),
-- Processor details
processor VARCHAR(30) NOT NULL,
processor_payment_id VARCHAR(100),
auth_code VARCHAR(20),
decline_code VARCHAR(50),
-- Risk
risk_score INTEGER,
risk_level VARCHAR(20),
-- Metadata
description TEXT,
metadata JSONB DEFAULT '{}',
-- Timestamps
created_at TIMESTAMPTZ DEFAULT NOW(),
authorized_at TIMESTAMPTZ,
captured_at TIMESTAMPTZ,
canceled_at TIMESTAMPTZ,
-- Constraints
11 collapsed lines
CONSTRAINT valid_status CHECK (status IN (
'pending', 'requires_action', 'requires_capture',
'processing', 'succeeded', 'failed', 'canceled'
))
);
-- Indexes for common queries
CREATE INDEX idx_payments_customer ON payments(customer_id, created_at DESC);
CREATE INDEX idx_payments_status ON payments(status, created_at DESC);
CREATE INDEX idx_payments_processor ON payments(processor_payment_id);
CREATE INDEX idx_payments_idempotency ON payments(idempotency_key);

Double-Entry Ledger Schema

-- Accounts in the chart of accounts
CREATE TABLE accounts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code VARCHAR(20) UNIQUE NOT NULL,
name VARCHAR(100) NOT NULL,
type VARCHAR(20) NOT NULL, -- asset, liability, revenue, expense
currency VARCHAR(3) NOT NULL,
is_active BOOLEAN DEFAULT true
);
-- Ledger entries (immutable)
CREATE TABLE ledger_entries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
transaction_id UUID NOT NULL,
account_id UUID NOT NULL REFERENCES accounts(id),
entry_type VARCHAR(10) NOT NULL, -- debit or credit
amount_cents BIGINT NOT NULL CHECK (amount_cents > 0),
currency VARCHAR(3) NOT NULL,
description TEXT,
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW(),
-- Reference to source
payment_id UUID REFERENCES payments(id),
refund_id UUID REFERENCES refunds(id),
payout_id UUID REFERENCES payouts(id)
);
CREATE INDEX idx_ledger_transaction ON ledger_entries(transaction_id);
CREATE INDEX idx_ledger_account ON ledger_entries(account_id, created_at DESC);
CREATE INDEX idx_ledger_payment ON ledger_entries(payment_id);

Ledger Entry Examples

Authorization (hold funds):

Transaction: AUTH-001
├── DEBIT accounts_receivable $100.00
└── CREDIT authorization_hold $100.00

Capture (recognize revenue):

Transaction: CAP-001
├── DEBIT authorization_hold $100.00
└── CREDIT revenue $100.00

Settlement (receive cash):

Transaction: SET-001
├── DEBIT cash $97.10 (after fees)
├── DEBIT processing_fees $2.90
└── CREDIT accounts_receivable $100.00

Refund:

Transaction: REF-001
├── DEBIT revenue $50.00
└── CREDIT accounts_receivable $50.00
-- Minimal schema for tokenized payment methods
-- Actual PAN/CVV stored in PCI-compliant vault (Stripe, external)
CREATE TABLE payment_methods (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
customer_id UUID NOT NULL REFERENCES customers(id),
processor VARCHAR(30) NOT NULL,
processor_token VARCHAR(100) NOT NULL, -- Stripe pm_xxx
-- Non-sensitive metadata
type VARCHAR(20) NOT NULL, -- card, bank_account, wallet
card_brand VARCHAR(20), -- visa, mastercard, amex
card_last4 VARCHAR(4),
card_exp_month INTEGER,
card_exp_year INTEGER,
card_funding VARCHAR(20), -- credit, debit, prepaid
-- Billing
billing_name VARCHAR(100),
billing_country VARCHAR(2),
billing_postal_code VARCHAR(20),
-- Status
is_default BOOLEAN DEFAULT false,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(customer_id, processor_token)
);

PCI scope note: This schema stores only tokens and non-sensitive metadata. The actual card numbers (PAN) are stored by Stripe/Adyen in their PCI-compliant vaults. Your database never sees or stores raw card data.

DataStoreRationale
PaymentsPostgreSQLACID, complex queries, audit requirements
Ledger entriesPostgreSQLStrong consistency, immutable append-only
Idempotency keysRedis + PostgreSQLFast lookup (Redis), durable record (PG)
Payment method tokensPostgreSQLReferential integrity with customers
Event streamKafkaHigh throughput, replay capability
Reconciliation snapshotsS3 + ParquetCost-effective analytics storage
Rate limitingRedisSub-ms counters

Idempotency prevents duplicate charges when clients retry failed requests. Stripe’s implementation serves as the industry reference.

Request flow:

Idempotency Cache (Redis + PG)

No

Yes

Yes

No

Incoming Request

Idempotency Key

Exists?

Process Request

Parameters

Match?

Return Cached Response

409 Conflict

Store Response

with Key

Return Response

Implementation:

idempotency-service.ts
14 collapsed lines
import { Redis } from "ioredis"
import { createHash } from "crypto"
interface IdempotencyRecord {
key: string
request_hash: string
response: any
status: "processing" | "complete" | "error"
created_at: Date
expires_at: Date
}
const redis = new Redis(process.env.REDIS_URL)
const IDEMPOTENCY_TTL = 24 * 60 * 60 // 24 hours (Stripe's window)
export async function checkIdempotency(
key: string,
requestBody: object,
): Promise<{ exists: boolean; response?: any; conflict?: boolean }> {
const requestHash = hashRequest(requestBody)
// Check Redis first (fast path)
const cached = await redis.get(`idem:${key}`)
if (!cached) {
// Key doesn't exist, allow processing
return { exists: false }
}
const record: IdempotencyRecord = JSON.parse(cached)
// Key exists, check if parameters match
if (record.request_hash !== requestHash) {
// Same key, different request = conflict
return { exists: true, conflict: true }
}
// Same key, same request
if (record.status === "processing") {
// Request in flight, return 409 to trigger retry
return { exists: true, conflict: true }
}
// Return cached response
return { exists: true, response: record.response }
}
export async function startIdempotentRequest(key: string, requestBody: object): Promise<boolean> {
const requestHash = hashRequest(requestBody)
// Atomic set-if-not-exists
const result = await redis.set(
`idem:${key}`,
JSON.stringify({
key,
request_hash: requestHash,
status: "processing",
created_at: new Date(),
}),
"EX",
IDEMPOTENCY_TTL,
"NX", // Only set if not exists
)
16 collapsed lines
return result === "OK"
}
export async function completeIdempotentRequest(
key: string,
response: any,
status: "complete" | "error",
): Promise<void> {
const cached = await redis.get(`idem:${key}`)
if (!cached) return
const record: IdempotencyRecord = JSON.parse(cached)
record.response = response
record.status = status
await redis.setex(`idem:${key}`, IDEMPOTENCY_TTL, JSON.stringify(record))
// Also persist to PostgreSQL for durability
await db.idempotency_records.upsert({
key,
request_hash: record.request_hash,
response: JSON.stringify(response),
status,
expires_at: new Date(Date.now() + IDEMPOTENCY_TTL * 1000),
})
}
function hashRequest(body: object): string {
return createHash("sha256").update(JSON.stringify(body)).digest("hex")
}

Payment controller with idempotency:

payment-controller.ts
7 collapsed lines
import { checkIdempotency, startIdempotentRequest, completeIdempotentRequest } from "./idempotency-service"
export async function createPayment(req: Request): Promise<Response> {
const idempotencyKey = req.headers.get("Idempotency-Key")
if (!idempotencyKey) {
return errorResponse(400, "idempotency_key_required")
}
// Check for existing request
const check = await checkIdempotency(idempotencyKey, req.body)
if (check.conflict) {
return errorResponse(409, "idempotency_conflict")
}
if (check.exists && check.response) {
// Return cached response (including errors)
return new Response(JSON.stringify(check.response), {
status: check.response.status_code || 200,
headers: { "Idempotent-Replayed": "true" },
})
}
// Start processing (atomic lock)
const acquired = await startIdempotentRequest(idempotencyKey, req.body)
if (!acquired) {
// Another request is processing, retry later
return errorResponse(409, "request_in_progress")
}
try {
// Process payment
const payment = await processPayment(req.body)
// Cache successful response
await completeIdempotentRequest(idempotencyKey, payment, "complete")
return successResponse(201, payment)
} catch (error) {
// Cache error response (prevents retry storms)
await completeIdempotentRequest(idempotencyKey, { error: error.message }, "error")
throw error
}
}

Design decisions:

DecisionRationale
24-hour TTLMatches Stripe; long enough for debugging, short enough to not accumulate
Hash request bodyDetects different requests with same key
Cache errors tooPrevents retry storms for permanent failures
Redis + PostgreSQLRedis for speed, PostgreSQL for durability and audit

Real-time fraud scoring must complete within 100ms to avoid impacting checkout latency.

Scoring architecture:

Decision

ML Scoring

Feature Engineering

Transaction Data

Yes

65-74

No

Transaction

Card Fingerprint

Device Fingerprint

Behavioral Signals

Velocity Features

Historical Features

Geolocation Features

Card Features

Fraud Model

DNN

Rule Engine

Risk Score

0-99

Score >= 75?

Block

Review Queue

Allow

Feature examples (1000+ per transaction):

CategoryFeatures
VelocityTransactions/hour from this card, IP, device
HistoricalDays since first transaction, avg transaction amount
GeolocationDistance from billing address, IP geolocation mismatch
CardBIN country, funding type (credit/debit/prepaid)
DeviceBrowser fingerprint, screen resolution, timezone
BehavioralTime on page before purchase, mouse movement patterns

Stripe Radar reference:

  • 0-99 risk score (0 = lowest risk)
  • Default block threshold: 75
  • Elevated risk threshold: 65
  • Decision time: <100ms
  • False positive rate: ~0.1%
  • Evaluates 1000+ characteristics per transaction
  • Models retrained monthly (0.5% recall improvement per retraining)

3D Secure integration:

3ds-service.ts
9 collapsed lines
interface ThreeDSResult {
authentication_status: "success" | "failed" | "attempted" | "not_supported"
liability_shift: boolean
eci: string // Electronic Commerce Indicator
}
export async function handle3DSChallenge(
paymentIntentId: string,
returnUrl: string,
): Promise<{ requires_action: boolean; redirect_url?: string }> {
const pi = await stripe.paymentIntents.retrieve(paymentIntentId)
if (pi.status === "requires_action") {
// 3DS challenge required
return {
requires_action: true,
redirect_url: pi.next_action?.redirect_to_url?.url,
}
}
return { requires_action: false }
}

3D Secure 2.0 flows:

FlowDescriptionUser Experience
FrictionlessRisk-based auth, no user inputInstant (invisible)
ChallengeBiometrics, OTP, push notification10-30 seconds

3DS benefits: Visa research shows 85% reduction in checkout times and 75% reduction in cart abandonment compared to 3DS 1.0. Required for PSD2 SCA compliance in EU.

Reconciliation ensures internal ledger matches external settlements. Discrepancies indicate bugs, fraud, or processor errors.

Reconciliation process:

Output

Reconciliation

Data Sources

Internal Ledger

Processor Reports

Stripe, Adyen

Bank Statements

Fetch Settlement Files

Parse & Normalize

Three-Way Match

Calculate Differences

Matched Transactions

Exceptions/Breaks

Alerts to Finance

Implementation:

reconciliation-service.ts
14 collapsed lines
interface SettlementRecord {
external_id: string
amount_cents: number
currency: string
type: "charge" | "refund" | "chargeback"
settled_at: Date
fees_cents: number
}
interface ReconciliationResult {
matched: number
unmatched_internal: SettlementRecord[]
unmatched_external: SettlementRecord[]
amount_discrepancy_cents: number
}
export async function reconcileSettlement(date: Date, processor: string): Promise<ReconciliationResult> {
// 1. Fetch processor settlement report
const externalRecords = await fetchSettlementReport(processor, date)
// 2. Fetch internal ledger entries
const internalRecords = await fetchLedgerEntries(date, processor)
// 3. Match by external ID
const matched: string[] = []
const unmatchedExternal: SettlementRecord[] = []
const unmatchedInternal: SettlementRecord[] = []
const internalMap = new Map(internalRecords.map((r) => [r.external_id, r]))
for (const ext of externalRecords) {
const internal = internalMap.get(ext.external_id)
if (!internal) {
// Transaction in processor report but not in our ledger
unmatchedExternal.push(ext)
continue
}
// Verify amounts match
if (internal.amount_cents !== ext.amount_cents) {
unmatchedExternal.push(ext)
unmatchedInternal.push(internal)
continue
}
matched.push(ext.external_id)
internalMap.delete(ext.external_id)
}
// Remaining internal records not in processor report
for (const [, record] of internalMap) {
unmatchedInternal.push(record)
}
// Calculate total discrepancy
const externalTotal = externalRecords.reduce((sum, r) => sum + r.amount_cents, 0)
const internalTotal = internalRecords.reduce((sum, r) => sum + r.amount_cents, 0)
return {
matched: matched.length,
unmatched_internal: unmatchedInternal,
unmatched_external: unmatchedExternal,
amount_discrepancy_cents: externalTotal - internalTotal,
}
}
// Alert on discrepancies
16 collapsed lines
export async function handleReconciliationBreaks(result: ReconciliationResult): Promise<void> {
if (result.unmatched_external.length > 0) {
await alertFinanceTeam({
type: "unmatched_external",
count: result.unmatched_external.length,
transactions: result.unmatched_external,
})
}
if (Math.abs(result.amount_discrepancy_cents) > 100) {
// $1 threshold
await alertFinanceTeam({
type: "amount_discrepancy",
amount_cents: result.amount_discrepancy_cents,
})
}
}

Common reconciliation breaks:

Break TypeCauseResolution
Missing in ledgerWebhook missed, race conditionReplay webhook, manual entry
Missing in processorAuth expired, voidedClose internal record
Amount mismatchPartial capture, FXVerify capture amount
DuplicateIdempotency failureRefund duplicate
TimingSettlement date rolloverVerify dates

Smart routing optimizes for authorization rate, cost, or both by selecting the best processor per transaction.

smart-router.ts
11 collapsed lines
interface RoutingDecision {
processor: "stripe" | "adyen" | "paypal"
reason: string
expected_auth_rate: number
expected_cost_bps: number // basis points
}
interface TransactionContext {
card_brand: string
card_country: string
card_funding: "credit" | "debit" | "prepaid"
amount_cents: number
currency: string
merchant_country: string
}
export function routeTransaction(ctx: TransactionContext): RoutingDecision {
// Rule 1: US debit cards - route to lowest-cost network
if (ctx.card_country === "US" && ctx.card_funding === "debit") {
return {
processor: "adyen", // Intelligent routing for US debit
reason: "us_debit_cost_optimization",
expected_auth_rate: 0.96,
expected_cost_bps: 50, // vs 150+ for credit
}
}
// Rule 2: European cards - route to Adyen for local acquiring
if (["DE", "FR", "GB", "NL", "ES", "IT"].includes(ctx.card_country)) {
return {
processor: "adyen",
reason: "eu_local_acquiring",
expected_auth_rate: 0.94,
expected_cost_bps: 180, // Lower cross-border fees
}
}
// Rule 3: High-value transactions - route to processor with best auth rate
if (ctx.amount_cents > 100000) {
// > $1000
return {
processor: "stripe",
reason: "high_value_auth_optimization",
expected_auth_rate: 0.92,
expected_cost_bps: 290,
}
}
// Default: Stripe
return {
processor: "stripe",
reason: "default",
expected_auth_rate: 0.9,
16 collapsed lines
expected_cost_bps: 290,
}
}
// Failover on processor error
export async function processWithFailover(ctx: TransactionContext, paymentData: PaymentData): Promise<PaymentResult> {
const primary = routeTransaction(ctx)
const processors = [primary.processor, ...getFailoverProcessors(primary.processor)]
for (const processor of processors) {
try {
return await processPayment(processor, paymentData)
} catch (error) {
if (isRetryableError(error)) {
continue // Try next processor
}
throw error // Non-retryable (e.g., card declined)
}
}
throw new Error("All processors failed")
}

Routing optimization results (Adyen reference):

  • US debit routing: 26% average cost savings
  • Authorization rate uplift: 0.22%
  • Some merchants: 55% cost savings

The primary frontend concern is keeping card data out of your servers entirely.

Tokenization at edge:

8 collapsed lines
// payment-form.tsx (React example)
import { loadStripe } from "@stripe/stripe-js";
import { Elements, CardElement, useStripe, useElements } from "@stripe/react-stripe-js";
const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_KEY);
function CheckoutForm() {
const stripe = useStripe();
const elements = useElements();
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
// Card data goes directly to Stripe, never touches your server
const { error, paymentMethod } = await stripe.createPaymentMethod({
type: "card",
card: elements.getElement(CardElement),
billing_details: {
name: "Customer Name",
},
});
if (error) {
setError(error.message);
return;
}
// Only the token (pm_xxx) goes to your backend
const response = await fetch("/api/payments", {
method: "POST",
body: JSON.stringify({
payment_method_token: paymentMethod.id,
amount: 9999,
}),
});
};
return (
<form onSubmit={handleSubmit}>
11 collapsed lines
<CardElement />
<button type="submit">Pay $99.99</button>
</form>
);
}
export function CheckoutPage() {
return (
<Elements stripe={stripePromise}>
<CheckoutForm />
</Elements>
);
}

PCI scope impact:

ApproachPCI LevelEffort
Direct card handlingSAQ-D (400+ questions)Months of compliance work
Stripe.js iframeSAQ-A (22 questions)Hours
Redirect to hosted pageSAQ-AMinimal
3ds-handler.ts
9 collapsed lines
import { loadStripe } from "@stripe/stripe-js"
export async function handle3DSChallenge(clientSecret: string): Promise<{ success: boolean; error?: string }> {
const stripe = await loadStripe(process.env.NEXT_PUBLIC_STRIPE_KEY)
// This opens the 3DS challenge in a modal/redirect
const { error, paymentIntent } = await stripe.confirmCardPayment(clientSecret, {
payment_method: paymentMethodId,
})
if (error) {
// 3DS failed or user canceled
return { success: false, error: error.message }
}
if (paymentIntent.status === "succeeded") {
return { success: true }
}
if (paymentIntent.status === "requires_action") {
// Additional action needed (rare edge case)
return await handle3DSChallenge(paymentIntent.client_secret)
}
return { success: false, error: "Unexpected payment status" }
}
payment-errors.ts
const ERROR_MESSAGES: Record<string, string> = {
card_declined: "Your card was declined. Please try a different card.",
insufficient_funds: "Insufficient funds. Please try a different card.",
expired_card: "Your card has expired. Please update your payment method.",
incorrect_cvc: "The CVC code is incorrect. Please check and try again.",
processing_error: "A processing error occurred. Please try again.",
rate_limited: "Too many attempts. Please wait a moment and try again.",
}
export function getErrorMessage(declineCode: string): string {
return ERROR_MESSAGES[declineCode] || "Payment failed. Please try again."
}
ComponentPurposeRequirements
API GatewayRate limiting, authHigh availability, DDoS protection
Application serversPayment processingHorizontal scaling, idempotent
Primary databasePayments, ledgerACID, strong consistency
CacheIdempotency, sessionsSub-ms latency
Message queueAsync processingExactly-once, durable
Event streamAudit, analyticsHigh throughput, retention
Secrets managerAPI keys, encryption keysHSM-backed, audit logging

Security

Data Layer

Compute Layer

Edge Layer

CloudFront

AWS WAF

AWS Shield

Application Load Balancer

ECS Fargate

Payment API

Lambda

Webhooks

RDS PostgreSQL

Multi-AZ

ElastiCache Redis

SQS FIFO

Kinesis Data Streams

AWS KMS

Secrets Manager

Users

Service configuration:

ServiceConfigurationRationale
RDS PostgreSQLdb.r6g.xlarge, Multi-AZ, encryptedACID, HA, compliance
ElastiCache Redisr6g.large, cluster mode, 3 nodesIdempotency, low latency
ECS Fargate2 vCPU, 4GB, auto-scaling 2-20Predictable performance
SQS FIFO3000 msg/secExactly-once webhooks
KMSCustomer-managed keysEncryption key control
CloudWatch1-minute metrics, alarmsObservability
Managed ServiceSelf-HostedTrade-off
RDS PostgreSQLPostgreSQL on EC2More control, operational burden
ElastiCacheRedis Cluster on EC2Cost at scale
SQS FIFOKafkaHigher throughput, complexity
Secrets ManagerHashiCorp VaultFull control, operational overhead

ACH has fundamentally different timing and failure modes than cards.

ach-payment.ts
interface ACHPayment {
routing_number: string
account_number: string // tokenized
account_type: "checking" | "savings"
}
export async function initiateACHPayment(
payment: ACHPayment,
amount: number,
): Promise<{ status: string; estimated_settlement: Date }> {
// ACH is batch-processed, not real-time
const transfer = await stripe.paymentIntents.create({
amount,
currency: "usd",
payment_method_types: ["us_bank_account"],
payment_method_data: {
type: "us_bank_account",
us_bank_account: {
routing_number: payment.routing_number,
account_number: payment.account_number,
account_holder_type: "individual",
},
},
})
return {
status: "processing",
estimated_settlement: addBusinessDays(new Date(), 3), // Same-day ACH: same day
}
}

ACH timing:

TypeSubmission DeadlineSettlement
Same-Day ACH4:45 PM ETSame day by 5 PM
Next-Day ACH2:15 PM ETNext business day
Standard ACHVaries2-3 business days

ACH failure modes:

  • NSF (Non-Sufficient Funds): Returns after 2-3 days
  • Account closed: Returns after settlement attempt
  • Invalid account: Returns within 24 hours
subscription-service.ts
11 collapsed lines
interface Subscription {
id: string
customer_id: string
plan_id: string
status: "active" | "past_due" | "canceled"
current_period_end: Date
payment_method_id: string
}
const RETRY_SCHEDULE = [1, 3, 5, 7] // Days after failure
export async function processSubscriptionRenewal(subscription: Subscription): Promise<void> {
const plan = await getPlan(subscription.plan_id)
try {
const payment = await createPayment({
amount: plan.amount_cents,
currency: plan.currency,
customer_id: subscription.customer_id,
payment_method_id: subscription.payment_method_id,
idempotency_key: `sub_${subscription.id}_${subscription.current_period_end.toISOString()}`,
})
if (payment.status === "succeeded") {
await extendSubscription(subscription.id)
}
} catch (error) {
if (isDeclinedError(error)) {
await markSubscriptionPastDue(subscription.id)
await scheduleRetry(subscription.id, RETRY_SCHEDULE[0])
await notifyCustomerPaymentFailed(subscription.customer_id)
}
}
}
async function handleRetry(subscriptionId: string, attemptNumber: number): Promise<void> {
const subscription = await getSubscription(subscriptionId)
try {
await processSubscriptionRenewal(subscription)
} catch (error) {
if (attemptNumber < RETRY_SCHEDULE.length) {
await scheduleRetry(subscriptionId, RETRY_SCHEDULE[attemptNumber])
} else {
// Final attempt failed
await cancelSubscription(subscriptionId, "payment_failed")
await notifyCustomerCanceled(subscription.customer_id)
}
2 collapsed lines
}
}
fx-service.ts
interface FXQuote {
from_currency: string
to_currency: string
rate: number
expires_at: Date
}
export async function getQuote(fromCurrency: string, toCurrency: string, amount: number): Promise<FXQuote> {
// FX rates are volatile, quote expires quickly
const rate = await fetchCurrentRate(fromCurrency, toCurrency)
return {
from_currency: fromCurrency,
to_currency: toCurrency,
rate,
expires_at: new Date(Date.now() + 60 * 1000), // 60 second quote
}
}
export async function captureWithFX(paymentId: string, quote: FXQuote): Promise<void> {
if (new Date() > quote.expires_at) {
throw new Error("FX quote expired")
}
// Lock in rate at capture time
await capturePayment(paymentId, {
fx_rate: quote.rate,
settlement_currency: quote.to_currency,
})
}

Payment system design requires balancing competing concerns across multiple dimensions:

  1. Idempotency is non-negotiable — Every payment operation must be safely retriable. Idempotency keys with request hashing prevent duplicate charges during network failures or client retries.

  2. PCI scope reduction saves engineering effort — Edge tokenization (Stripe.js, Adyen Web Components) keeps card data off your servers, reducing PCI questionnaire from 400+ questions to 22.

  3. Fraud decisions must be fast — Sub-100ms scoring using ML models (Stripe Radar processes 1000+ features per transaction) balances security with checkout conversion.

  4. Double-entry ledger ensures audit readiness — Every fund movement (auth, capture, refund, settlement) recorded with debits equaling credits enables reconciliation and compliance.

  5. Smart routing optimizes cost and auth rates — Multi-processor setups with intelligent routing can achieve 26%+ cost savings (Adyen US debit routing) while improving authorization rates.

What this design optimizes for:

  • Zero duplicate charges (idempotency)
  • PCI scope minimization (edge tokenization)
  • High authorization rates (smart routing)
  • Financial accuracy (double-entry ledger)
  • Operational resilience (failover, reconciliation)

What it sacrifices:

  • Simplicity (multi-processor complexity)
  • Latency (fraud scoring adds ~100ms)
  • Cost (third-party processor fees vs in-house)

Known limitations:

  • Webhook reliability depends on processor—implement webhook replay mechanisms
  • FX rates are volatile—quote expiration must be enforced
  • Chargeback handling requires manual evidence gathering
  • ACH failures surface days after initiation
  • RESTful API design principles
  • Database transactions and ACID guarantees
  • Distributed systems basics (idempotency, exactly-once delivery)
  • Basic understanding of card payment networks
  • PAN (Primary Account Number): The 16-digit card number
  • PCI DSS (Payment Card Industry Data Security Standard): Security standard for card data handling
  • SAQ (Self-Assessment Questionnaire): PCI compliance verification form
  • Interchange: Fee paid by acquirer to issuer on each transaction (1.4-2.6% typical)
  • Authorization: Hold placed on cardholder’s available credit
  • Capture: Finalization of authorized transaction for settlement
  • Settlement: Transfer of funds from issuer to acquirer to merchant
  • 3D Secure: Protocol for authenticating card-not-present transactions
  • SCA (Strong Customer Authentication): EU PSD2 requirement for two-factor auth
  • ACH (Automated Clearing House): US bank-to-bank transfer network
  • Chargeback: Disputed transaction reversed by card network
  • Payment systems require idempotent operations with 24-hour key retention (Stripe’s standard)
  • Edge tokenization (Stripe.js/Adyen Web Components) reduces PCI scope from SAQ-D to SAQ-A
  • Fraud scoring must complete in <100ms, evaluating 1000+ features per transaction
  • Double-entry ledger tracks every fund movement: authorization holds, captures, refunds, settlements
  • Smart routing across multiple processors can achieve 26% cost savings on US debit
  • Reconciliation matches internal ledger against processor settlements daily
  • 3D Secure 2.0 enables frictionless authentication with 85% checkout time reduction

Read more

  • Previous

    Design an Issue Tracker (Jira/Linear)

    System Design / System Design Problems 27 min read

    A comprehensive system design for an issue tracking and project management tool covering API design for dynamic workflows, efficient kanban board pagination, drag-and-drop ordering without full row updates, concurrent edit handling, and real-time synchronization. This design addresses the challenges of project-specific column configurations while maintaining consistent user-defined ordering across views.

  • Next

    Design a Flash Sale System

    System Design / System Design Problems 23 min read

    Building a system to handle millions of concurrent users competing for limited inventory during time-bounded sales events. Flash sales present a unique challenge: extreme traffic spikes (10-100x normal) concentrated in seconds, with zero tolerance for inventory errors. This design covers virtual waiting rooms, atomic inventory management, and asynchronous order processing.