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.
    Scroll to Top