1. Requirements Clarification

Functional Requirements

  • Accept payments from users (credit/debit card, digital wallets)
  • Process payouts to sellers/merchants
  • Support refunds (full and partial)
  • Maintain a ledger of all financial movements
  • Provide payment history to users
  • Handle currency conversion for international payments

Non-Functional Requirements

  • Correctness: Zero tolerance for double charges or lost money — this is the top priority above performance
  • Scale: 1 million transactions per day; peak 100 transactions per second
  • Availability: 99.99% (52 min downtime/year)
  • Compliance: PCI DSS for card data; GDPR for personal data
  • Auditability: Every state change in a transaction must be logged immutably

2. High-Level Architecture

  User / Mobile App
       │
       ▼
┌─────────────────┐
│  Payment API    │  ← validates, deduplicates (idempotency key check)
│  Service        │
└────────┬────────┘
         │
    ┌────▼─────────────────────────────────────────┐
    │          MySQL (ACID transactions)            │
    │  accounts │ transactions │ ledger_entries     │
    └────┬──────────────────────────────────────────┘
         │
    ┌────▼────────┐       ┌─────────────────────┐
    │  PSP Layer  │──────▶│  Stripe / PayPal     │
    │  (adapter)  │       │  (external provider) │
    └────┬────────┘       └──────────┬────────────┘
         │                          │ webhook
    ┌────▼────────┐       ┌──────────▼────────────┐
    │  Webhook    │◀──────│  Webhook Endpoint      │
    │  Processor  │       │  /api/webhooks/stripe  │
    └────┬────────┘       └───────────────────────┘
         │
    ┌────▼──────────┐
    │ Downstream    │  (order fulfillment, email receipt, fraud scoring)
    │ Services (MQ) │
    └───────────────┘

3. Idempotency — Preventing Double Charges

The most important correctness mechanism in a payment system. Every payment API call must be idempotent.

Payment API — idempotency key implementation POST /api/payments Headers: Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000 Body: { "amount": 9999, "currency": "USD", "payment_method": "pm_xxx" } -- Server logic: BEGIN TRANSACTION; -- 1. Check if this idempotency key was already processed SELECT id, status, response FROM idempotency_keys WHERE key_id = '550e8400...' FOR UPDATE; IF FOUND: -- Return the original response, do NOT charge again RETURN original_response; -- 2. Reserve the idempotency key (claim it) INSERT INTO idempotency_keys (key_id, status, created_at) VALUES ('550e8400...', 'PROCESSING', NOW()); COMMIT; -- 3. Process the payment with PSP result = stripe.charge(amount=9999, payment_method='pm_xxx') -- 4. Update idempotency key with result UPDATE idempotency_keys SET status = result.status, response = result.json WHERE key_id = '550e8400...';

Interview Tip — Idempotency Key TTL

Store idempotency keys in a separate table with a 24-hour retention policy (or 7 days for payment reconciliation). After expiry, the same key can be reused — but that should be rare since UUIDs are unique by design. The client should generate a fresh UUID for each new payment intent, reusing the same UUID only for retries of the same payment.

4. ACID Transactions for Money Movement

Moving money requires ACID properties — anything less risks creating or destroying money.

SQL — atomic money transfer (double-entry) BEGIN TRANSACTION; -- Debit sender's account UPDATE accounts SET balance = balance - 100.00 WHERE user_id = 'alice' AND balance >= 100.00; -- Check if update affected 1 row (balance was sufficient) IF ROW_COUNT() = 0: ROLLBACK; RAISE InsufficientFundsError; -- Credit receiver's account UPDATE accounts SET balance = balance + 100.00 WHERE user_id = 'bob'; -- Record both ledger entries (double-entry bookkeeping) INSERT INTO ledger_entries (account_id, type, amount, ref_id, created_at) VALUES ('alice_account', 'DEBIT', 100.00, 'txn_xyz', NOW()), ('bob_account', 'CREDIT', 100.00, 'txn_xyz', NOW()); -- Record the transaction INSERT INTO transactions (id, from_account, to_account, amount, status) VALUES ('txn_xyz', 'alice', 'bob', 100.00, 'COMPLETED'); COMMIT; -- If any step fails, everything is rolled back atomically

5. Double-Entry Bookkeeping

Double-entry bookkeeping is the accounting standard that underpins every financial system. The invariant is simple: every debit must have a corresponding equal credit.

TransactionDebit AccountDebit AmountCredit AccountCredit Amount
User tops up walletPayment Gateway (external)$100User Wallet$100
User buys productUser Wallet$50Merchant Wallet$50
Platform takes feeMerchant Wallet$2.50Platform Revenue$2.50
Merchant payoutMerchant Wallet$47.50Bank Account (external)$47.50
RefundMerchant Wallet$50User Wallet$50

To verify ledger integrity at any time: SUM(debit entries) = SUM(credit entries). A discrepancy means money was either created or destroyed — a critical bug.

6. PSP Integration and Async Webhooks

A Payment Service Provider (PSP) like Stripe handles the complex parts: card network communication, 3D Secure authentication, bank authorization, and settlement. Your system integrates with the PSP via API and webhooks.

Payment Flow with Stripe:

1. Client: create PaymentIntent (your backend → Stripe API)
   Returns: client_secret for frontend

2. Client: collect card details → Stripe.js tokenizes in browser
   → Card data never touches your servers (PCI scope reduction)

3. Client: confirmPayment(client_secret) → Stripe processes
   → Stripe communicates with card network (Visa/MC)

4. Stripe: async result via webhook to your server
   POST /webhook/stripe
   { "type": "payment_intent.succeeded", "data": { "id": "pi_xxx" } }

5. Your server: update transaction status → release order → send receipt

Timeline: steps 1–3 take 0.5–3 seconds. Step 4 may take 1–60 seconds.
Never block the user's request waiting for step 4 — return "payment processing"
and update when the webhook arrives.

7. Database Schema

SQL — accounts, transactions, ledger CREATE TABLE accounts ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, user_id BIGINT UNSIGNED NOT NULL, type ENUM('wallet','revenue','gateway','payout') NOT NULL, currency CHAR(3) NOT NULL DEFAULT 'USD', balance DECIMAL(19,4) NOT NULL DEFAULT 0.0000, -- balance enforced ≥ 0 at application level, not DB CHECK (for performance) created_at DATETIME NOT NULL, PRIMARY KEY (id), UNIQUE KEY uk_user_type_currency (user_id, type, currency) ); CREATE TABLE transactions ( id VARCHAR(64) NOT NULL, -- PSP reference or internal UUID idempotency_key VARCHAR(64) NOT NULL, from_account_id BIGINT UNSIGNED NULL, to_account_id BIGINT UNSIGNED NULL, amount DECIMAL(19,4) NOT NULL, currency CHAR(3) NOT NULL, status ENUM('PENDING','PROCESSING','COMPLETED','FAILED','REFUNDED') NOT NULL, psp VARCHAR(30) NULL, -- 'stripe', 'paypal' psp_ref VARCHAR(200) NULL, -- PSP's transaction ID metadata JSON NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, PRIMARY KEY (id), UNIQUE KEY uk_idempotency (idempotency_key), KEY idx_psp_ref (psp_ref), KEY idx_user_created (from_account_id, created_at) ); CREATE TABLE ledger_entries ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, transaction_id VARCHAR(64) NOT NULL, account_id BIGINT UNSIGNED NOT NULL, type ENUM('DEBIT','CREDIT') NOT NULL, amount DECIMAL(19,4) NOT NULL, balance_after DECIMAL(19,4) NOT NULL, -- for audit trail created_at DATETIME NOT NULL, PRIMARY KEY (id), KEY idx_account_created (account_id, created_at), KEY idx_transaction_id (transaction_id) ); -- Ledger entries are APPEND-ONLY — never updated or deleted

8. Reconciliation

Reconciliation catches cases where your internal records diverge from what the PSP reports. This happens more often than expected — network timeouts, race conditions, and webhook failures all create discrepancies.

  • Run a daily reconciliation job at 2am UTC (after PSP settlement window closes)
  • Download PSP settlement report for the previous day
  • Match each PSP transaction ID to your internal transactions.psp_ref
  • Flag: PSP shows success but your DB shows PENDING → likely missed webhook → mark complete
  • Flag: your DB shows success but PSP shows failure → refund the user immediately
  • Flag: amount mismatch → investigate potential fee calculation bug

Never Trust Client-Side Payment Confirmation

The frontend will call your API saying "payment succeeded" based on the Stripe.js response. Always verify payment status server-side via the PSP API or webhook before releasing goods/services. Fraudsters regularly modify client-side responses to claim payment success without actually paying.

How We Research and Update This Guide

We test the underlying formula or workflow, compare outputs with reliable references, and revise examples whenever the page content changes.

  • The workflow or formula is tested directly in the tool and compared against independent reference examples.
  • Examples are kept practical so readers can verify the result without hidden assumptions.
  • Pages are revised whenever the interface, calculation flow, or surrounding guidance materially changes.

Frequently Asked Questions — Payment System Design