System Design Interview: Design a Digital Wallet and Payment System

What Is a Digital Wallet?

A digital wallet stores funds and enables transfers between users and merchants. Examples: PayPal, Venmo, Apple Pay, Google Pay, Cash App. Core challenges: exactly-once transactions (no double charges or double credits), consistent balance across concurrent operations, and preventing fraud while maintaining low latency.

  • Airbnb Interview Guide
  • Lyft Interview Guide
  • Uber Interview Guide
  • Shopify Interview Guide
  • Coinbase Interview Guide
  • Stripe Interview Guide
  • System Requirements

    Functional

    • Add funds via bank transfer or credit card
    • Send money to another user
    • Pay merchants
    • View transaction history
    • Link and manage bank accounts

    Non-Functional

    • 100M users, 10K transactions/second
    • No double charges, no lost transfers
    • Transaction finality within 5 seconds
    • Compliance: PCI-DSS for card data, AML for large transfers

    Core Data Model — Double-Entry Accounting

    Never update a balance directly. Use a ledger (journal) of immutable transactions:

    accounts: id, user_id, currency, balance (cached sum)
    transactions: id, idempotency_key, status, created_at
    ledger_entries: id, transaction_id, account_id, amount, entry_type(debit/credit)
    

    Every transaction creates exactly two ledger entries: one debit (balance decreases) and one credit (balance increases). The sum of all entries for an account equals its balance. This double-entry model means the ledger always balances — total debits = total credits across all accounts. Auditors can verify correctness by recomputing balances from the ledger.

    Transfer Implementation

    def transfer(sender_id, receiver_id, amount, idempotency_key):
        # Idempotency: check if this transfer already happened
        if Transaction.exists(idempotency_key):
            return Transaction.get(idempotency_key)
    
        with db.transaction():
            # Lock both accounts (order by ID to prevent deadlock)
            sender = Account.lock(min(sender_id, receiver_id))
            receiver = Account.lock(max(sender_id, receiver_id))
    
            if sender.balance < amount:
                raise InsufficientFunds()
    
            txn = Transaction.create(idempotency_key=idempotency_key)
            LedgerEntry.create(txn, sender, -amount, 'debit')
            LedgerEntry.create(txn, receiver, +amount, 'credit')
    
            sender.balance -= amount    # update cached balance
            receiver.balance += amount
    
            txn.status = 'completed'
        return txn
    

    Lock ordering (min account ID first) prevents deadlocks when two concurrent transfers involve the same two accounts in opposite directions.

    Idempotency

    The caller provides an idempotency_key (UUID) with each transfer. If the network times out and the caller retries, the second call sees the existing transaction and returns it — no double transfer. The idempotency check must be inside the same transaction as the transfer to prevent TOCTOU (time-of-check-time-of-use) race conditions.

    External Transfers (Bank Deposits/Withdrawals)

    Bank transfers via ACH take 1-3 business days and can be reversed (insufficient funds, fraudulent). Wallet credits are “pending” until ACH settles. Two-phase approach: credit the wallet with “pending” status immediately (good UX), set a hold on spending (cannot withdraw), lift the hold when ACH confirms settlement. On ACH reversal: debit the wallet and notify the user.

    Fraud Detection

    Rules evaluated in real-time before each transaction:

    • Velocity: more than 10 transfers in 1 hour → flag for review
    • Amount: single transfer over $10,000 → AML report (CTR)
    • Geo anomaly: transfer from country never seen for this user
    • ML model: predict fraud probability from 50+ features

    Flagged transactions are held for manual review. High-confidence fraud: auto-decline and freeze account.

    Scaling Balances

    The cached balance column (accounts.balance) is a hotspot for high-volume wallets (a merchant receiving thousands of payments per second). Solutions: (1) Optimistic locking: use a version column; retry on conflict — works for moderate contention. (2) Balance sharding: split the balance across N virtual sub-accounts; aggregate for read, distribute writes. (3) Asynchronous balance update: update ledger entries synchronously, update cached balance asynchronously from Kafka — accept slight staleness for display balance.

    Interview Tips

    • Double-entry ledger is the correct financial data model — mention it explicitly.
    • Lock ordering by account ID prevents deadlocks.
    • Idempotency key is the primary exactly-once mechanism.
    • Pending state for external transfers handles ACH reversal risk.

    {
    “@context”: “https://schema.org”,
    “@type”: “FAQPage”,
    “mainEntity”: [
    {
    “@type”: “Question”,
    “name”: “What is double-entry accounting and why is it the correct model for a digital wallet ledger?”,
    “acceptedAnswer”: { “@type”: “Answer”, “text”: “Double-entry accounting records every financial event as two equal and opposite entries: a debit on one account and a credit on another. For a $100 transfer from Alice to Bob: debit Alice's account $100 (balance decreases), credit Bob's account $100 (balance increases). The sum of all debits must equal the sum of all credits across the entire ledger — this is the accounting equation and serves as an internal consistency check. Why use it for a wallet: (1) Immutable audit trail — every balance change is backed by a ledger entry; you can recompute any account's balance from scratch by summing its entries. (2) Correctness verification — if total debits != total credits, something is wrong and can be detected automatically. (3) No lost money — money cannot appear or disappear; it only moves between accounts. (4) Regulatory compliance — double-entry is the international standard for financial record-keeping. The alternative (just updating a balance column) loses the history and makes debugging financial discrepancies nearly impossible.” }
    },
    {
    “@type”: “Question”,
    “name”: “How do you prevent deadlocks when locking two accounts for a transfer?”,
    “acceptedAnswer”: { “@type”: “Answer”, “text”: “Deadlock occurs when two concurrent transfers both lock the same two accounts in opposite orders. Transfer A-to-B: lock A, then try to lock B. Transfer B-to-A: lock B, then try to lock A. Both hold one lock and wait for the other — deadlock. Solution: always acquire locks in a canonical order — sort account IDs and always lock the lower ID first. Transfer A-to-B: lock min(A,B), then lock max(A,B). Transfer B-to-A: also locks min(A,B), then max(A,B). Both transfers lock in the same order; one proceeds while the other waits. No circular dependency, no deadlock. Implementation: sort the account IDs at the start of the transfer function, then issue SELECT … FOR UPDATE in ID order. This works because database row-level locks are acquired sequentially. Additional defense: set a lock timeout (SET lock_timeout = 5000 in PostgreSQL) so a transfer waiting too long fails with an error rather than hanging indefinitely. Retry on timeout with exponential backoff.” }
    },
    {
    “@type”: “Question”,
    “name”: “How do you handle the idempotency key to prevent double transfers on network retries?”,
    “acceptedAnswer”: { “@type”: “Answer”, “text”: “When a transfer request times out, the client does not know if the server processed it before the timeout. If the client retries without idempotency, the transfer executes twice — double-charging. Idempotency key solution: the client generates a unique UUID before the first attempt and sends it as a header: Idempotency-Key: {uuid}. The server stores the transfer result keyed by this UUID. On the first request: execute the transfer, store (uuid → result) in the DB (with a unique constraint on the idempotency_key column). Return the result. On a retry with the same UUID: the unique constraint triggers; the server fetches and returns the stored result without executing the transfer again. The idempotency check MUST happen inside the same database transaction as the transfer — not as a pre-check before the transaction. A pre-check (read → check → write) has a TOCTOU race: two concurrent requests both pass the pre-check, both proceed to execute. Only the DB-level unique constraint (inside the transaction) prevents duplicates atomically. TTL: idempotency keys expire after 24 hours — after which a retry is treated as a new transfer.” }
    }
    ]
    }

    Scroll to Top