Problem Statement
Design a peer-to-peer payment service that lets users send money to one another, link external bank accounts, and manage wallet balances with fraud controls and regulatory limits.
Core Requirements
- User wallet with stored balance
- Transfer initiation between users
- Bank account linking and micro-deposit verification
- Hold and release flow for pending funds
- Daily and per-transaction limits enforcement
- Fraud detection and transaction review
Data Model
users
id BIGINT PK
email VARCHAR(255) UNIQUE NOT NULL
phone VARCHAR(20) UNIQUE
kyc_status ENUM('pending','verified','rejected')
created_at TIMESTAMP
wallets
id BIGINT PK
user_id BIGINT FK -> users.id UNIQUE
balance DECIMAL(18,2) NOT NULL DEFAULT 0
held_balance DECIMAL(18,2) NOT NULL DEFAULT 0
currency VARCHAR(3) DEFAULT 'USD'
updated_at TIMESTAMP
bank_accounts
id BIGINT PK
user_id BIGINT FK -> users.id
account_hash VARCHAR(64) NOT NULL
routing_hash VARCHAR(64) NOT NULL
status ENUM('pending','verified','removed')
verified_at TIMESTAMP
transfers
id BIGINT PK
sender_id BIGINT FK -> users.id
receiver_id BIGINT FK -> users.id
amount DECIMAL(18,2) NOT NULL
status ENUM('pending','held','completed','failed','reversed')
initiated_at TIMESTAMP
settled_at TIMESTAMP
idempotency_key VARCHAR(64) UNIQUE NOT NULL
note VARCHAR(255)
fraud_signals
id BIGINT PK
transfer_id BIGINT FK -> transfers.id
signal_type VARCHAR(64)
score FLOAT
action ENUM('allow','review','block')
created_at TIMESTAMP
API Design
POST /wallets/{userId}/transfer Initiate transfer
GET /wallets/{userId}/balance Get available and held balance
POST /bank-accounts Link a bank account
POST /bank-accounts/{id}/verify Verify via micro-deposits
GET /transfers/{transferId} Get transfer status
POST /transfers/{transferId}/cancel Cancel a pending transfer
GET /users/{userId}/transfers Transfer history with pagination
Transfer Initiation Flow
- Validate sender KYC status is verified
- Check daily and per-transaction limits against transfer amount
- Run fraud scoring — block or flag for manual review if score exceeds threshold
- Deduct amount from sender wallet balance and add to held_balance (atomic DB transaction)
- Insert transfer row with status
held - Publish
transfer.heldevent to queue for async settlement
Hold and Release Flow
Funds sit in held_balance until settlement completes. A background worker picks up transfer.held events and calls the payment network (ACH, card rails, or internal). On success the worker atomically decreases sender held_balance and increases receiver balance, then marks the transfer completed. On failure it returns the amount to sender balance and marks the transfer failed.
BEGIN TRANSACTION;
UPDATE wallets SET held_balance = held_balance - :amount WHERE user_id = :sender_id;
UPDATE wallets SET balance = balance + :amount WHERE user_id = :receiver_id;
UPDATE transfers SET status = 'completed', settled_at = NOW() WHERE id = :transfer_id;
COMMIT;
Bank Account Linking and Verification
The service stores only hashed account and routing numbers. Verification uses two micro-deposits of random cents sent to the external account. The user confirms both amounts within 72 hours. A token-based approach (Plaid, Stripe Financial Connections) can replace micro-deposits for faster verification.
Limits Enforcement
limits
user_id BIGINT FK -> users.id
daily_send_limit DECIMAL(18,2) DEFAULT 2500.00
per_tx_limit DECIMAL(18,2) DEFAULT 1000.00
daily_sent_today DECIMAL(18,2) DEFAULT 0
limit_reset_at TIMESTAMP
-- On each transfer attempt:
IF transfer.amount > limits.per_tx_limit THEN reject
IF limits.daily_sent_today + transfer.amount > limits.daily_send_limit THEN reject
UPDATE limits SET daily_sent_today = daily_sent_today + :amount WHERE user_id = :sender_id
Limits reset daily via a scheduled job or lazily when limit_reset_at is in the past. Use a Redis counter for high-throughput limit checks with a database fallback.
Fraud Detection
A fraud scoring service evaluates each transfer synchronously before funds are moved. Signals include: velocity (many transfers in short window), device fingerprint mismatch, new receiver, unusual amount, and geographic anomaly. Scores below a threshold allow; scores above block; middle range queues for manual review. Suspicious transfers hold funds until a human agent approves or rejects.
Idempotency
Clients send a unique idempotency_key with each transfer request. The server stores it in the transfers table with a UNIQUE constraint. Duplicate requests with the same key return the existing transfer record rather than creating a new debit.
Scaling Considerations
- Partition transfers table by
initiated_atdate for archival and query performance - Use optimistic locking or SELECT FOR UPDATE on wallet rows to prevent race conditions
- Async settlement workers scale independently from the API layer
- Separate read replicas for balance queries and transaction history
- Rate-limit transfer initiation per user at the API gateway
Interview Tips
- Start with the atomic hold-and-release flow — interviewers probe for double-spend awareness
- Clarify whether wallet-to-wallet is instant (internal ledger) or requires external rails (ACH = T+1)
- Discuss idempotency keys before the interviewer asks — it signals production experience
- Mention regulatory constraints: KYC/AML, reporting thresholds (USD 10k CTR), PCI scope if cards are involved
A peer-to-peer (P2P) payment service allows individuals to transfer money directly to one another through a digital platform without routing through a traditional bank branch. In system design, it involves a ledger of user accounts and balances, an orchestration layer that debits the sender and credits the receiver atomically, and integrations with external payment rails (ACH, card networks, or crypto) for funding and withdrawal. Key design goals include strong consistency for balance updates, idempotent transaction handling, and real-time fraud screening.
In a hold-and-release flow the system first places a temporary hold (authorization) on the sender’s funds, reducing their available balance without yet crediting the receiver. Once all validations pass — fraud checks, KYC limits, and external network confirmation — the hold is captured and the receiver’s balance is credited. If any step fails the hold is voided and the sender’s full balance is restored. This two-phase approach prevents double-spending while keeping funds recoverable during async settlement delays.
Idempotency in payments is achieved by associating each transaction request with a client-generated idempotency key (a UUID). The server stores the key and its outcome in a durable store before executing the transfer. On any retry — caused by network timeouts or client crashes — the server looks up the key and returns the previously recorded result without re-executing the transfer. The idempotency record should be written in the same database transaction as the balance update, and expired keys are purged after a safe retention window (typically 24–48 hours).
Daily transfer limits are enforced by querying a rolling-window counter (stored in Redis or a time-series table) that tracks the total amount sent by a user in the past 24 hours. If the requested transfer would exceed the limit the request is rejected before any funds are moved. Fraud checks run in parallel as a synchronous call to a risk-scoring service that evaluates signals such as device fingerprint, velocity patterns, and recipient history. A high risk score either blocks the transaction outright or routes it to a manual review queue. Both checks sit between request validation and the hold step so that no funds are ever touched for a blocked transaction.
See also: Stripe Interview Guide 2026: Process, Bug Bash Round, and Payment Systems
See also: Coinbase Interview Guide
See also: Scale AI Interview Guide 2026: Data Infrastructure, RLHF Pipelines, and ML Engineering