TechLead
Lesson 21 of 30
7 min read
System Design

System Design: Payment System

Learn how to design a scalable payment system covering processing flows, idempotency, PCI-DSS security, and double-entry ledger accounting.

Designing a Payment System

Payment systems are among the most critical and complex components in any business. A single bug can result in financial loss, regulatory violations, or loss of customer trust. Designing a reliable payment system requires deep understanding of distributed systems, security, and financial accounting principles.

Key Design Goals

  • Correctness: Money must never be lost, duplicated, or misrouted
  • Reliability: 99.999% uptime for payment processing
  • Security: Full PCI-DSS compliance and fraud prevention
  • Auditability: Every transaction must be traceable end-to-end
  • Scalability: Handle peak loads (e.g., Black Friday, flash sales)

Payment Processing Flow

A typical payment flow involves multiple stages and external systems. Understanding this flow is essential before diving into the architecture.

// Simplified payment processing flow
enum PaymentStatus {
  INITIATED = "INITIATED",
  AUTHORIZED = "AUTHORIZED",
  CAPTURED = "CAPTURED",
  SETTLED = "SETTLED",
  FAILED = "FAILED",
  REFUNDED = "REFUNDED",
}

interface PaymentRequest {
  idempotencyKey: string;
  amount: number;
  currency: string;
  merchantId: string;
  customerId: string;
  paymentMethod: PaymentMethodDetails;
  metadata?: Record<string, string>;
}

interface PaymentRecord {
  id: string;
  idempotencyKey: string;
  status: PaymentStatus;
  amount: number;
  currency: string;
  merchantId: string;
  customerId: string;
  gatewayTransactionId?: string;
  createdAt: Date;
  updatedAt: Date;
  statusHistory: StatusTransition[];
}

interface StatusTransition {
  from: PaymentStatus;
  to: PaymentStatus;
  timestamp: Date;
  reason?: string;
}

async function processPayment(request: PaymentRequest): Promise<PaymentRecord> {
  // Step 1: Idempotency check
  const existing = await findByIdempotencyKey(request.idempotencyKey);
  if (existing) return existing;

  // Step 2: Create payment record
  const payment = await createPaymentRecord(request);

  // Step 3: Risk assessment / fraud check
  const riskResult = await assessRisk(payment);
  if (riskResult.blocked) {
    return await updateStatus(payment, PaymentStatus.FAILED, "Risk check failed");
  }

  // Step 4: Authorize with payment gateway
  const authResult = await authorizePayment(payment);
  if (!authResult.success) {
    return await updateStatus(payment, PaymentStatus.FAILED, authResult.reason);
  }

  // Step 5: Capture the payment
  const captureResult = await capturePayment(payment, authResult.transactionId);
  if (!captureResult.success) {
    await voidAuthorization(authResult.transactionId);
    return await updateStatus(payment, PaymentStatus.FAILED, "Capture failed");
  }

  // Step 6: Update ledger entries
  await createLedgerEntries(payment);

  return await updateStatus(payment, PaymentStatus.CAPTURED);
}

The flow above is simplified. In production, each step involves retries, timeouts, circuit breakers, and careful error handling. The two-phase process of authorization followed by capture is standard in credit card processing and allows merchants to verify funds before completing the transaction.

Idempotency in Payments

Idempotency is the single most important concept in payment system design. Network failures, timeouts, and retries mean the same request can arrive multiple times. Without idempotency, customers can be charged twice.

Critical Rule

Every payment API must accept an idempotency key. If a request with the same key arrives again, the system must return the original result without processing the payment again. Store the idempotency key alongside the payment record and check it before any processing begins.

async function handlePaymentWithIdempotency(
  idempotencyKey: string,
  request: PaymentRequest
): Promise<PaymentRecord> {
  // Use database-level locking to prevent race conditions
  return await withDistributedLock(`payment:${idempotencyKey}`, async () => {
    // Check if this request was already processed
    const existing = await db.payments.findOne({ idempotencyKey });

    if (existing) {
      // Return the cached result - do NOT reprocess
      return existing;
    }

    // Process the new payment
    const result = await processPayment(request);

    return result;
  });
}

// Client-side: generate idempotency key before sending
function generateIdempotencyKey(
  userId: string,
  orderId: string
): string {
  // Deterministic key ensures retries use the same key
  return crypto
    .createHash("sha256")
    .update(`${userId}:${orderId}:${Date.now()}`)
    .digest("hex");
}

Payment Gateway Integration

Payment gateways (Stripe, Adyen, Braintree) abstract the complexity of communicating with card networks and banks. Your system should support multiple gateways for redundancy and cost optimization.

Component Role Examples
Payment Gateway Routes transactions to acquirers Stripe, Adyen, Braintree
Acquiring Bank Processes transactions on behalf of merchants Chase, Wells Fargo
Card Network Facilitates communication between acquirers and issuers Visa, Mastercard, Amex
Issuing Bank Customer's bank that issued the card Capital One, Citi
// Gateway abstraction for multi-gateway support
interface PaymentGateway {
  name: string;
  authorize(payment: PaymentRecord): Promise<GatewayResponse>;
  capture(transactionId: string, amount: number): Promise<GatewayResponse>;
  refund(transactionId: string, amount: number): Promise<GatewayResponse>;
  void(transactionId: string): Promise<GatewayResponse>;
}

class GatewayRouter {
  private gateways: Map<string, PaymentGateway> = new Map();
  private routingRules: RoutingRule[];

  selectGateway(payment: PaymentRecord): PaymentGateway {
    // Route based on currency, country, card type, cost
    for (const rule of this.routingRules) {
      if (rule.matches(payment)) {
        const gw = this.gateways.get(rule.gatewayName);
        if (gw && this.isHealthy(rule.gatewayName)) return gw;
      }
    }
    // Fallback to primary gateway
    return this.gateways.get("stripe")!;
  }
}

Handling Failures and Retries

Payment processing involves multiple external services, each of which can fail. The system must handle failures gracefully without double-charging customers.

  • Timeout handling: If a gateway call times out, query the gateway for the transaction status before retrying
  • Exponential backoff: Retry failed calls with increasing delays (1s, 2s, 4s, 8s)
  • Circuit breaker: If a gateway fails repeatedly, route traffic to a backup gateway
  • Compensating transactions: If capture fails after authorization, void the authorization
  • Dead letter queue: Move unresolvable failures to a queue for manual review

Security Considerations (PCI-DSS)

PCI-DSS (Payment Card Industry Data Security Standard) compliance is mandatory for any system handling cardholder data. The most common approach is to minimize your PCI scope by using tokenization.

PCI-DSS Scope Reduction Strategies

  • Tokenization: Never store raw card numbers. Use gateway-provided tokens
  • Client-side encryption: Collect card details in the gateway's iframe or SDK
  • Network segmentation: Isolate payment services in a separate network zone
  • Encryption at rest: Encrypt all sensitive data stored in databases
  • Access logging: Log all access to payment data with immutable audit logs
  • Key management: Rotate encryption keys regularly using a dedicated KMS

Reconciliation

Reconciliation is the process of verifying that your internal records match external records (bank statements, gateway reports). This is essential for detecting discrepancies, fraud, and system bugs.

interface ReconciliationResult {
  matched: number;
  mismatched: ReconciliationMismatch[];
  missingInternal: string[]; // In gateway but not in our system
  missingExternal: string[]; // In our system but not in gateway
}

async function reconcile(
  date: string
): Promise<ReconciliationResult> {
  const internalTxns = await db.payments.find({
    date,
    status: { $in: ["CAPTURED", "SETTLED", "REFUNDED"] },
  });

  const gatewayReport = await gateway.getSettlementReport(date);

  const result: ReconciliationResult = {
    matched: 0,
    mismatched: [],
    missingInternal: [],
    missingExternal: [],
  };

  const gatewayMap = new Map(
    gatewayReport.map((t) => [t.transactionId, t])
  );

  for (const internal of internalTxns) {
    const external = gatewayMap.get(internal.gatewayTransactionId);
    if (!external) {
      result.missingExternal.push(internal.id);
    } else if (internal.amount !== external.amount) {
      result.mismatched.push({
        internalId: internal.id,
        externalId: external.transactionId,
        internalAmount: internal.amount,
        externalAmount: external.amount,
      });
    } else {
      result.matched++;
    }
    gatewayMap.delete(internal.gatewayTransactionId);
  }

  result.missingInternal = Array.from(gatewayMap.keys());
  return result;
}

Ledger and Double-Entry Accounting

A proper payment system uses double-entry bookkeeping: every transaction creates at least two ledger entries that must balance. This ensures that money is never lost or created out of thin air.

interface LedgerEntry {
  id: string;
  transactionId: string;
  accountId: string;
  amount: number; // Positive = debit, Negative = credit
  currency: string;
  entryType: "DEBIT" | "CREDIT";
  createdAt: Date;
}

// For a $100 payment from customer to merchant:
// Customer account:  DEBIT  -$100  (money leaves customer)
// Merchant account:  CREDIT +$97   (merchant receives minus fees)
// Revenue account:   CREDIT +$3    (platform fee)
// Sum of all entries = 0 (the fundamental invariant)

async function createLedgerEntries(payment: PaymentRecord): Promise<void> {
  const fee = calculatePlatformFee(payment.amount);

  const entries: LedgerEntry[] = [
    {
      id: generateId(),
      transactionId: payment.id,
      accountId: payment.customerId,
      amount: -payment.amount,
      currency: payment.currency,
      entryType: "DEBIT",
      createdAt: new Date(),
    },
    {
      id: generateId(),
      transactionId: payment.id,
      accountId: payment.merchantId,
      amount: payment.amount - fee,
      currency: payment.currency,
      entryType: "CREDIT",
      createdAt: new Date(),
    },
    {
      id: generateId(),
      transactionId: payment.id,
      accountId: "PLATFORM_REVENUE",
      amount: fee,
      currency: payment.currency,
      entryType: "CREDIT",
      createdAt: new Date(),
    },
  ];

  // All entries must be inserted atomically
  await db.ledger.insertMany(entries, { session: transaction });
}

Ledger Immutability

Ledger entries must never be updated or deleted. To correct a mistake, you create a new reversing entry. This ensures a complete audit trail and prevents tampering. Treat your ledger as an append-only log.

Continue Learning