Digital Wallet - Database Design
Reading Time: 20 minutes
Database Schema
1. Users Table
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
phone VARCHAR(20) UNIQUE NOT NULL,
phone_verified BOOLEAN DEFAULT FALSE,
email_verified BOOLEAN DEFAULT FALSE,
kyc_status VARCHAR(20) DEFAULT 'pending',
kyc_verified_at TIMESTAMP,
first_name VARCHAR(100),
last_name VARCHAR(100),
date_of_birth DATE,
country_code CHAR(2),
status VARCHAR(20) DEFAULT 'active',
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
INDEX idx_email (email),
INDEX idx_phone (phone),
INDEX idx_kyc_status (kyc_status)
);2. Devices Table
CREATE TABLE devices (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id),
device_token VARCHAR(255) UNIQUE NOT NULL,
device_type VARCHAR(20) NOT NULL, -- ios, android, web
device_model VARCHAR(100),
os_version VARCHAR(50),
app_version VARCHAR(50),
biometric_enabled BOOLEAN DEFAULT FALSE,
biometric_hash VARCHAR(255), -- Hashed biometric template
last_active_at TIMESTAMP,
status VARCHAR(20) DEFAULT 'active',
created_at TIMESTAMP DEFAULT NOW(),
INDEX idx_user_id (user_id),
INDEX idx_device_token (device_token),
INDEX idx_status (status)
);3. Payment Methods Table
CREATE TABLE payment_methods (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id),
type VARCHAR(20) NOT NULL, -- card, bank_account
token_id BIGINT NOT NULL,
card_network VARCHAR(20), -- visa, mastercard, amex
last4 CHAR(4) NOT NULL,
expiry_month SMALLINT,
expiry_year SMALLINT,
cardholder_name VARCHAR(100),
billing_address_id BIGINT,
is_default BOOLEAN DEFAULT FALSE,
status VARCHAR(20) DEFAULT 'active',
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
INDEX idx_user_id (user_id),
INDEX idx_token_id (token_id),
INDEX idx_status (status),
UNIQUE (user_id, is_default) WHERE is_default = TRUE
);4. Tokens Table (Encrypted)
CREATE TABLE tokens (
id BIGSERIAL PRIMARY KEY,
token_value VARCHAR(255) UNIQUE NOT NULL, -- Encrypted token
card_hash VARCHAR(64) NOT NULL, -- SHA-256 of PAN
token_type VARCHAR(20) DEFAULT 'payment',
expiry_date DATE,
status VARCHAR(20) DEFAULT 'active',
provisioned_at TIMESTAMP DEFAULT NOW(),
last_used_at TIMESTAMP,
use_count INT DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW(),
INDEX idx_token_value (token_value),
INDEX idx_card_hash (card_hash),
INDEX idx_status (status)
);
-- Encryption at rest using PostgreSQL pgcrypto
-- token_value stored as: pgp_sym_encrypt(value, key)5. Transactions Table (Partitioned)
CREATE TABLE transactions (
id BIGSERIAL,
user_id BIGINT NOT NULL,
payment_method_id BIGINT NOT NULL,
transaction_type VARCHAR(20) NOT NULL, -- payment, refund, p2p
amount DECIMAL(15, 2) NOT NULL,
currency CHAR(3) NOT NULL,
status VARCHAR(20) NOT NULL, -- pending, completed, failed
merchant_id VARCHAR(100),
merchant_name VARCHAR(255),
merchant_category VARCHAR(50),
description TEXT,
reference_id VARCHAR(100) UNIQUE,
idempotency_key VARCHAR(100) UNIQUE,
fraud_score DECIMAL(5, 2),
fraud_status VARCHAR(20),
payment_network_response JSONB,
error_code VARCHAR(50),
error_message TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
completed_at TIMESTAMP,
PRIMARY KEY (id, created_at)
) PARTITION BY RANGE (created_at);
-- Partitions by month
CREATE TABLE transactions_2026_01 PARTITION OF transactions
FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
CREATE TABLE transactions_2026_02 PARTITION OF transactions
FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');
-- Indexes
CREATE INDEX idx_transactions_user_id ON transactions(user_id, created_at DESC);
CREATE INDEX idx_transactions_status ON transactions(status);
CREATE INDEX idx_transactions_reference ON transactions(reference_id);
CREATE INDEX idx_transactions_idempotency ON transactions(idempotency_key);6. Transaction Details Table
CREATE TABLE transaction_details (
id BIGSERIAL PRIMARY KEY,
transaction_id BIGINT NOT NULL,
device_id BIGINT,
ip_address INET,
user_agent TEXT,
location_lat DECIMAL(10, 8),
location_lon DECIMAL(11, 8),
location_city VARCHAR(100),
location_country CHAR(2),
nfc_used BOOLEAN DEFAULT FALSE,
authentication_method VARCHAR(50), -- biometric, pin, none
metadata JSONB,
created_at TIMESTAMP DEFAULT NOW(),
INDEX idx_transaction_id (transaction_id)
);7. P2P Transfers Table
CREATE TABLE p2p_transfers (
id BIGSERIAL PRIMARY KEY,
sender_id BIGINT NOT NULL REFERENCES users(id),
receiver_id BIGINT NOT NULL REFERENCES users(id),
amount DECIMAL(15, 2) NOT NULL,
currency CHAR(3) NOT NULL,
status VARCHAR(20) NOT NULL, -- pending, completed, failed, cancelled
message TEXT,
reference_id VARCHAR(100) UNIQUE,
idempotency_key VARCHAR(100) UNIQUE,
sender_transaction_id BIGINT,
receiver_transaction_id BIGINT,
fee_amount DECIMAL(15, 2) DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW(),
completed_at TIMESTAMP,
INDEX idx_sender_id (sender_id, created_at DESC),
INDEX idx_receiver_id (receiver_id, created_at DESC),
INDEX idx_status (status),
INDEX idx_reference (reference_id)
);8. Balances Table
CREATE TABLE balances (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT UNIQUE NOT NULL REFERENCES users(id),
available_balance DECIMAL(15, 2) DEFAULT 0,
pending_balance DECIMAL(15, 2) DEFAULT 0,
currency CHAR(3) NOT NULL,
last_updated_at TIMESTAMP DEFAULT NOW(),
version INT DEFAULT 1, -- Optimistic locking
INDEX idx_user_id (user_id)
);
-- Optimistic locking for concurrent updates
-- UPDATE balances SET available_balance = available_balance - 100,
-- version = version + 1
-- WHERE user_id = ? AND version = ?9. Transfer Limits Table
CREATE TABLE transfer_limits (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT UNIQUE NOT NULL REFERENCES users(id),
daily_limit DECIMAL(15, 2) NOT NULL,
per_transaction_limit DECIMAL(15, 2) NOT NULL,
daily_used DECIMAL(15, 2) DEFAULT 0,
last_reset_date DATE DEFAULT CURRENT_DATE,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
INDEX idx_user_id (user_id)
);10. Fraud Rules Table
CREATE TABLE fraud_rules (
id BIGSERIAL PRIMARY KEY,
rule_name VARCHAR(100) UNIQUE NOT NULL,
rule_type VARCHAR(50) NOT NULL, -- velocity, amount, geo, device
rule_config JSONB NOT NULL,
severity VARCHAR(20) NOT NULL, -- low, medium, high, critical
action VARCHAR(20) NOT NULL, -- block, review, alert, 2fa
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
INDEX idx_rule_type (rule_type),
INDEX idx_is_active (is_active)
);
-- Example rule_config:
-- {
-- "max_transactions_per_hour": 10,
-- "max_amount_per_day": 5000,
-- "allowed_countries": ["US", "CA", "UK"]
-- }11. Fraud Events Table
CREATE TABLE fraud_events (
id BIGSERIAL PRIMARY KEY,
transaction_id BIGINT,
user_id BIGINT NOT NULL,
rule_id BIGINT REFERENCES fraud_rules(id),
fraud_score DECIMAL(5, 2) NOT NULL,
risk_level VARCHAR(20) NOT NULL, -- low, medium, high, critical
triggered_rules JSONB,
action_taken VARCHAR(20), -- blocked, flagged, allowed
reviewed_by BIGINT,
review_status VARCHAR(20),
review_notes TEXT,
created_at TIMESTAMP DEFAULT NOW(),
INDEX idx_transaction_id (transaction_id),
INDEX idx_user_id (user_id),
INDEX idx_risk_level (risk_level),
INDEX idx_created_at (created_at)
);12. Notifications Table
CREATE TABLE notifications (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id),
type VARCHAR(50) NOT NULL, -- transaction, security, promotional
channel VARCHAR(20) NOT NULL, -- push, email, sms, in_app
title VARCHAR(255),
message TEXT NOT NULL,
data JSONB,
status VARCHAR(20) DEFAULT 'pending', -- pending, sent, failed, read
sent_at TIMESTAMP,
read_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW(),
INDEX idx_user_id (user_id, created_at DESC),
INDEX idx_status (status),
INDEX idx_type (type)
);13. Receipts Table
CREATE TABLE receipts (
id BIGSERIAL PRIMARY KEY,
transaction_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
receipt_url VARCHAR(500), -- S3 URL
receipt_data JSONB,
format VARCHAR(20) DEFAULT 'pdf', -- pdf, json
created_at TIMESTAMP DEFAULT NOW(),
INDEX idx_transaction_id (transaction_id),
INDEX idx_user_id (user_id)
);14. Audit Logs Table
CREATE TABLE audit_logs (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT,
action VARCHAR(100) NOT NULL,
resource_type VARCHAR(50) NOT NULL,
resource_id BIGINT,
old_value JSONB,
new_value JSONB,
ip_address INET,
user_agent TEXT,
created_at TIMESTAMP DEFAULT NOW(),
INDEX idx_user_id (user_id, created_at DESC),
INDEX idx_resource (resource_type, resource_id),
INDEX idx_created_at (created_at)
);Database Sharding Strategy
Sharding by User ID
Shard Key: user_id
Number of Shards: 1000 (initially)
Shard Calculation:
shard_id = user_id % 1000
Shard Distribution:
- Shard 0-249: Region US-East
- Shard 250-499: Region US-West
- Shard 500-749: Region EU-West
- Shard 750-999: Region Asia-Pacific
Tables to Shard:
- users
- devices
- payment_methods
- transactions
- p2p_transfers
- balances
- transfer_limits
Global Tables (Not Sharded):
- tokens (accessed by token_id, not user_id)
- fraud_rules
- audit_logs (centralized)Read Replicas
Primary: 1 per shard (writes)
Replicas: 3 per shard (reads)
Read Distribution:
- Transaction history: Replicas (90% of reads)
- Balance checks: Replicas
- Payment authorization: Primary (consistency)
- P2P transfers: Primary (consistency)
Replication Lag: < 100msCaching Strategy
Redis Cache Layers
L1: Hot User Data (TTL: 5 minutes)
Key Pattern: user:{user_id}
Value: User profile JSON
Size: ~1 KB per user
Total: 20M active users × 1 KB = 20 GB
Key Pattern: balance:{user_id}
Value: Available balance
Size: ~100 bytes per user
Total: 20M users × 100 bytes = 2 GBL2: Payment Methods (TTL: 15 minutes)
Key Pattern: payment_methods:{user_id}
Value: List of payment methods JSON
Size: ~2 KB per user
Total: 20M users × 2 KB = 40 GBL3: Recent Transactions (TTL: 1 hour)
Key Pattern: transactions:{user_id}:recent
Value: Last 20 transactions JSON
Size: ~10 KB per user
Total: 20M users × 10 KB = 200 GBL4: Tokens (TTL: 1 hour)
Key Pattern: token:{token_id}
Value: Token details (encrypted)
Size: ~500 bytes per token
Total: 50M active tokens × 500 bytes = 25 GB
Total Redis Memory: ~287 GB
Redis Cluster: 6 nodes × 64 GB = 384 GBCache Invalidation
Write-Through:
- Update database first
- Then update cache
- If cache update fails, log and continue
Cache-Aside:
- Read from cache
- If miss, read from database
- Write to cache
- Return data
Invalidation Events:
- User profile update → Invalidate user:{user_id}
- Payment method added → Invalidate payment_methods:{user_id}
- Transaction completed → Invalidate transactions:{user_id}:recentData Consistency
ACID Transactions
P2P Transfer (Two-Phase Commit)
BEGIN;
-- Lock sender balance
SELECT available_balance FROM balances
WHERE user_id = :sender_id FOR UPDATE;
-- Debit sender
UPDATE balances
SET available_balance = available_balance - :amount,
version = version + 1
WHERE user_id = :sender_id AND version = :sender_version;
-- Credit receiver
UPDATE balances
SET available_balance = available_balance + :amount,
version = version + 1
WHERE user_id = :receiver_id AND version = :receiver_version;
-- Record transfer
INSERT INTO p2p_transfers (sender_id, receiver_id, amount, status)
VALUES (:sender_id, :receiver_id, :amount, 'completed');
COMMIT;Idempotency
-- Prevent duplicate transactions
INSERT INTO transactions (
user_id, amount, idempotency_key, status
) VALUES (
:user_id, :amount, :idempotency_key, 'pending'
)
ON CONFLICT (idempotency_key) DO NOTHING
RETURNING id;
-- If no rows returned, transaction already existsData Archival
Archival Strategy
Hot Data (0-90 days):
- Storage: PostgreSQL SSD
- Access: Real-time
- Retention: 90 days
Warm Data (90 days - 5 years):
- Storage: PostgreSQL HDD (partitioned)
- Access: Moderate (1-2 seconds)
- Retention: 5 years
Cold Data (5+ years):
- Storage: S3 Glacier
- Access: Rare (minutes to hours)
- Retention: 7 years (compliance)
Archival Process:
1. Daily job identifies old partitions
2. Export to Parquet format
3. Upload to S3 Glacier
4. Drop old partition
5. Update metadata tableInterview Discussion Points
Q: How handle high write throughput?
- Sharding by user_id (1000 shards)
- Batch inserts for audit logs
- Async writes for non-critical data
- Write-ahead logging
Q: How ensure data consistency in P2P?
- ACID transactions with row-level locks
- Optimistic locking (version field)
- Two-phase commit pattern
- Idempotency keys
Q: How optimize transaction history queries?
- Partition by month
- Index on (user_id, created_at DESC)
- Cache recent 20 transactions
- Elasticsearch for complex searches
Estimated Reading Time: 20 minutes