Design a Payment System

Difficulty: Advanced

Question

Design a payment system that handles credit card payments, refunds, and subscription billing. How do you ensure no double charges?

Answer

A payment system must be reliable, consistent, and auditable. Money is involved, so correctness is critical.

Core components: 1. Payment Gateway Integration: Stripe, Razorpay - handles card processing 2. Order Service: Creates payment intents with idempotency keys 3. Ledger: Double-entry bookkeeping for every transaction 4. Webhook Handler: Receives async payment confirmations 5. Reconciliation: Matches internal records with payment provider records

Key principles: - Idempotency: Same request processed only once (idempotency keys prevent double charges) - Double-entry: Every transaction has a debit and credit entry - Eventual consistency: Payment confirmation may arrive asynchronously via webhooks - Audit trail: Every state change is logged and immutable

Code examples

Idempotent Payment Processing

// Idempotency prevents double charges from retries
app.post('/api/payments', auth, async (req, res) => {
  const { orderId, amount, currency, idempotencyKey } = req.body;

  // 1. Check if this request was already processed
  const existing = await db.payments.findByIdempotencyKey(idempotencyKey);
  if (existing) {
    return res.json(existing); // Return same result
  }

  // 2. Create payment record with PENDING status
  const payment = await db.payments.create({
    orderId,
    userId: req.user.id,
    amount,
    currency,
    idempotencyKey,
    status: 'PENDING'
  });

  try {
    // 3. Call payment gateway
    const charge = await stripe.paymentIntents.create({
      amount: amount * 100, // cents
      currency,
      metadata: { paymentId: payment.id, orderId },
      idempotency_key: idempotencyKey // Stripe also supports idempotency
    });

    // 4. Update payment status
    await db.payments.update(payment.id, {
      status: 'PROCESSING',
      gatewayId: charge.id
    });

    res.json({ paymentId: payment.id, clientSecret: charge.client_secret });
  } catch (error) {
    await db.payments.update(payment.id, {
      status: 'FAILED',
      errorMessage: error.message
    });
    res.status(400).json({ error: 'Payment failed' });
  }
});

// Client generates idempotency key:
// const key = `pay_${orderId}_${Date.now()}`;

Idempotency keys ensure that retried requests (due to network timeouts, client retries) produce the same result. Both your system and the payment provider should use idempotency keys.

Webhook Handler for Payment Confirmation

// Payment gateway sends async confirmations via webhooks
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), async (req, res) => {
  // 1. Verify webhook signature
  const sig = req.headers['stripe-signature'];
  let event;
  try {
    event = stripe.webhooks.constructEvent(req.body, sig, WEBHOOK_SECRET);
  } catch (err) {
    return res.status(400).send('Invalid signature');
  }

  // 2. Process event idempotently
  const processed = await db.webhookEvents.findByEventId(event.id);
  if (processed) return res.json({ received: true }); // Already handled

  // 3. Handle different event types
  switch (event.type) {
    case 'payment_intent.succeeded': {
      const paymentId = event.data.object.metadata.paymentId;
      await db.payments.update(paymentId, { status: 'COMPLETED' });
      await ledger.record(paymentId, 'CREDIT');
      await orderService.fulfillOrder(event.data.object.metadata.orderId);
      break;
    }
    case 'payment_intent.payment_failed': {
      const paymentId = event.data.object.metadata.paymentId;
      await db.payments.update(paymentId, {
        status: 'FAILED',
        errorMessage: event.data.object.last_payment_error?.message
      });
      break;
    }
    case 'charge.refunded': {
      await handleRefund(event.data.object);
      break;
    }
  }

  // 4. Mark webhook event as processed
  await db.webhookEvents.create({ eventId: event.id, type: event.type });

  res.json({ received: true });
});

Webhooks handle async payment confirmations. Verify the signature to prevent fraud. Track processed events to handle duplicate webhook deliveries idempotently.

Double-Entry Ledger

-- Double-entry bookkeeping: every transaction has two entries
-- Total debits MUST equal total credits (always balanced)

CREATE TABLE ledger_entries (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  transaction_id UUID NOT NULL,
  account_id UUID NOT NULL,
  entry_type VARCHAR(10) NOT NULL CHECK (entry_type IN ('DEBIT', 'CREDIT')),
  amount DECIMAL(12,2) NOT NULL CHECK (amount > 0),
  currency VARCHAR(3) NOT NULL,
  description TEXT,
  created_at TIMESTAMP DEFAULT NOW()
);

-- Example: Customer pays $50 for a product
-- Entry 1: DEBIT  Customer Account  $50 (money leaves customer)
-- Entry 2: CREDIT Merchant Account  $50 (money enters merchant)

// Record a payment in the ledger
async function recordPayment(transactionId, customerId, merchantId, amount) {
  await db.$transaction([
    db.ledgerEntries.create({
      transactionId,
      accountId: customerId,
      entryType: 'DEBIT',
      amount,
      currency: 'USD'
    }),
    db.ledgerEntries.create({
      transactionId,
      accountId: merchantId,
      entryType: 'CREDIT',
      amount,
      currency: 'USD'
    })
  ]);
}

// Verify ledger is balanced
// SELECT SUM(CASE WHEN entry_type = 'DEBIT' THEN amount ELSE 0 END)
//      - SUM(CASE WHEN entry_type = 'CREDIT' THEN amount ELSE 0 END)
//   AS balance FROM ledger_entries;
// Result should always be 0

Double-entry bookkeeping is the foundation of financial systems. Every transaction creates exactly two entries (debit and credit) that must balance. This makes it impossible to lose track of money.

Key points

Concepts covered

Payment Processing, Idempotency, Double-Entry Bookkeeping, Reconciliation, Webhooks