Core Entities
Wallet: (wallet_id, user_id, currency, balance_cents, status=ACTIVE|FROZEN|CLOSED, created_at). One user may have multiple wallets (different currencies). LedgerEntry: (entry_id, wallet_id, amount_cents, entry_type=CREDIT|DEBIT, reference_type=TRANSFER|TOPUP|WITHDRAWAL|FEE, reference_id, balance_after_cents, created_at). The ledger is the authoritative record — balance is derived from ledger entries, not stored directly (double-entry bookkeeping principle). Transfer: (transfer_id, from_wallet_id, to_wallet_id, amount_cents, currency, status=PENDING|COMPLETED|FAILED|REVERSED, idempotency_key, created_at).
Double-Entry Bookkeeping
Every transaction creates two ledger entries: a DEBIT on the sender’s wallet and a CREDIT on the receiver’s wallet. The sum of all CREDIT entries minus all DEBIT entries equals the current balance. This invariant detects data corruption: if it does not hold, something went wrong. Implementation: in a single database transaction: (1) INSERT LedgerEntry (sender, -amount, DEBIT). (2) UPDATE wallet SET balance = balance – amount WHERE wallet_id=sender AND balance >= amount (optimistic check). (3) INSERT LedgerEntry (receiver, +amount, CREDIT). (4) UPDATE wallet SET balance = balance + amount WHERE wallet_id=receiver. (5) INSERT Transfer record. All five operations are atomic. If any fails, the entire transaction rolls back.
Preventing Double Spend
Double spend occurs when the same funds are used twice (network retry causes two transfers). Prevention: idempotency key on every Transfer. Before processing: SELECT transfer WHERE idempotency_key=:key. If found: return existing result. If not: process and INSERT with idempotency_key (unique constraint). Optimistic locking for balance: UPDATE wallet SET balance=balance-amount, version=version+1 WHERE wallet_id=:id AND version=:expected_version AND balance >= amount. If 0 rows affected: concurrent modification — retry. The version check prevents two concurrent transfers from both seeing sufficient balance and both succeeding.
Transaction Limits and Controls
Daily limit: track daily_transferred_cents per wallet per date (Redis: INCR wallet:{id}:daily:{date} by amount; set TTL to midnight). Check before transfer: if daily_transferred + amount > daily_limit: reject. Single transaction limit: if amount > single_tx_limit: reject or require additional authentication (step-up auth). Velocity checks: 3+ failed transfers in 1 hour -> temporarily suspend wallet. Merchant category limits: block wallet from being used at certain merchant categories (gambling, adult content) — user-configurable controls. Store limit rules in a configurable table (not hardcoded) to allow per-user customization and regulatory overrides.
Top-up and Withdrawal
Top-up (load money into wallet): user initiates payment via bank transfer or card. Create a TopupRequest with status=PENDING. On payment confirmation from the payment provider (webhook): atomically credit the wallet and mark TopupRequest=COMPLETED. Never credit before payment confirms. Withdrawal (move money to bank account): debit the wallet immediately (hold funds), create a WithdrawalRequest. Process asynchronously via bank transfer (ACH, SEPA). On bank confirmation: mark COMPLETED. On failure: credit funds back to wallet and mark FAILED. Minimum withdrawal amount and withdrawal fee: apply fee as a separate DEBIT ledger entry (reference_type=FEE).
Interview Tips
- Balance should be computable from the ledger (sum of credits minus debits). Store it as a denormalized cache for performance, but the ledger is the source of truth.
- All balance changes must be inside a database transaction. Never modify two wallets in separate transactions — a crash between them leaves the system in an inconsistent state.
- Idempotency key is mandatory for any money movement. Clients must generate and store the key; servers must handle retries gracefully.
{
“@context”: “https://schema.org”,
“@type”: “FAQPage”,
“mainEntity”: [
{
“@type”: “Question”,
“name”: “How do you ensure atomic transfers between two wallets?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “A transfer between wallets must be atomic: either both the debit and credit happen, or neither does. Implementation using database transactions: BEGIN TRANSACTION; SELECT balance FROM wallets WHERE wallet_id=sender FOR UPDATE (lock sender row); IF balance daily_limit: reject. Per-transaction limit: if amount > single_tx_max: reject or require 2FA. Hourly failed attempts: if 3+ failed transfers (insufficient balance or rejected) in 1 hour: temporarily freeze wallet for 30 minutes. IP velocity: 10+ different wallets transferring from/to the same IP in 1 hour: flag for review. These Redis-based checks run before the database transaction for performance.”
}
},
{
“@type”: “Question”,
“name”: “How do you design the withdrawal flow to handle bank transfer delays?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Bank transfers (ACH, SEPA) are not instant — they take 1-3 business days. Withdrawal flow: (1) Validate: check balance, daily limit, destination bank account. (2) Debit wallet immediately (funds held). Insert PENDING withdrawal record. (3) Submit to bank API asynchronously (background job). (4) Bank responds with a transfer ID. Status becomes PROCESSING. (5) Bank webhook arrives (1-3 days later) with SUCCESS or FAILURE. On success: mark withdrawal COMPLETED. On failure: credit funds back to wallet, mark withdrawal FAILED, notify user. Idempotency on bank API calls: use the withdrawal_id as the idempotency key for the bank API. If the bank call times out: retry with the same key — the bank deduplicates. Never debit the wallet twice for the same withdrawal.”
}
},
{
“@type”: “Question”,
“name”: “How do you handle currency conversion in a multi-currency wallet?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “A user may hold balances in multiple currencies. Cross-currency transfers require conversion. Exchange rate source: fetch rates from a provider (XE, Open Exchange Rates) every minute. Cache in Redis: SET rate:USD:EUR 0.92 EX 60. For a transfer from USD wallet to EUR wallet: debit USD wallet by amount_usd. Apply exchange rate: amount_eur = round(amount_usd * rate_usd_eur * (1 – fx_fee_percent / 100)). Credit EUR wallet by amount_eur. Record both amounts and the rate used on the Transfer record. The FX fee is the wallet provider’s revenue. Snapshot the rate at transfer time — do not recalculate later. Display the effective rate and fee to the user before confirming (regulatory requirement in many jurisdictions).”
}
}
]
}
Asked at: Stripe Interview Guide
Asked at: Coinbase Interview Guide
Asked at: Shopify Interview Guide
Asked at: Snap Interview Guide
See also: Meta Interview Guide 2026: Facebook, Instagram, WhatsApp Engineering