System Design · Fintech

Design a Digital Wallet like Paytm / PhonePe

How to build a wallet where money is never created or lost: a double-entry ledger, transfers that survive retries and crashes, KYC limits, sharding without breaking consistency, and the reconciliation that catches everything else.

✍️ Pranjal Jain, Ex-Microsoft · IIT Kanpur 📅 10 October 2026 ⏱ 18 min read
A digital wallet above a balanced scale with coins flowing between two ledger books
TL;DR
  • A wallet is a ledger first and an app second. Model every rupee movement as balanced double-entry postings in an append-only table; balances are a cached sum.
  • Transfers between two wallets on one shard are a single ACID transaction with rows locked in a fixed order. Across shards, use a saga with clearing accounts and a transactional outbox, never ad-hoc dual writes.
  • Every money-moving API takes an idempotency key stored under a unique constraint in the same transaction.
  • External money (UPI/card top-ups, bank withdrawals) is asynchronous: model pending states and never trust a single callback.
  • Reconciliation against banks and against your own invariants is not optional; it is how fintech teams sleep at night.

"Design Paytm wallet" or "design PhonePe" is one of the most common system design questions at Indian product companies, especially fintechs like PhonePe, Paytm, Razorpay, CRED, Cashfree and the payments teams at Flipkart and Amazon. It looks like a CRUD app with a balance column. It is not. The interviewer is testing whether you understand that in a money system, correctness beats availability, and "eventually consistent" balances are a bug report waiting to happen.

This post focuses on the closed-loop/semi-closed wallet itself (stored balance, P2P transfers, merchant payments from wallet, top-ups and withdrawals). UPI, where money moves between bank accounts through NPCI, is a different system covered in design a payment system: UPI and gateways.

What a Wallet Really Is (and Why That Shapes the Design)

In India, a wallet is legally a Prepaid Payment Instrument (PPI) regulated by the RBI. The money users "hold" in the wallet sits in an escrow account at a partner bank; the wallet company's ledger records who owns how much of that pool. That gives you the first invariant to state in the interview:

Sum of all user wallet balances + merchant balances + fees not yet withdrawn = balance in the escrow bank account (after accounting for in-flight settlements).

Everything in the design exists to keep that equation true and provable. If the ledger says users hold ₹500 crore and the bank says ₹499.9 crore, someone has to explain the ₹10 lakh, line by line.

Requirements

Functional

  • Create a wallet on signup; show balance and transaction history.
  • Add money from UPI, debit card, credit card or net banking.
  • P2P transfer to another wallet user.
  • Pay a merchant from wallet balance (QR or online checkout).
  • Withdraw to a linked bank account (full-KYC users only).
  • Refunds and reversals.
  • Enforce KYC-based limits on balance, monthly loads and transfers.

Non-functional

  • Strong consistency for balances. No double spend, no negative balance, no lost money.
  • Exactly-once effect for every money movement despite retries, timeouts and crashes.
  • Auditability: every balance change traceable to an immutable record; multi-year retention.
  • Availability around 99.95%+ with low latency (p99 under ~300 ms for a transfer), but when forced to choose, reject a transaction rather than risk an inconsistent one. In CAP terms the ledger is CP; see CAP theorem for interviews.
  • Peak handling during festive sales, IPL matches and month-start salary days.

Capacity Estimates

Illustrative assumptions, not any company's real figures:

  • 100M wallet users, 50M wallet transactions/day → ~580 TPS average.
  • Peak factor 10x during sales and festivals → ~6,000 TPS.
  • Each transfer writes 1 transaction row + 2 postings + 2 balance updates + 1 idempotency row + 1 outbox event ≈ 7 row operations → ~40k row writes/s at peak. Beyond a single primary database comfortably; plan to shard.
  • Storage: ~600 bytes per transfer across tables → 50M × 600 B ≈ 30 GB/day, ~11 TB/year before indexes and replication. Retain for years; move cold partitions to cheaper storage.
  • Reads: balance and history views are 5 to 10x writes, but are easily served from replicas and caches, except the balance check inside a transfer, which must hit the primary.

API Design

EndpointNotes
GET /v1/wallets/meBalance, KYC tier, remaining monthly limits
POST /v1/topups{amount, method, idempotency_key} → returns topup_id, status PENDING, and a payment intent for the gateway
POST /v1/transfers{to_wallet_id, amount, note, idempotency_key} → txn_id, status
POST /v1/paymentsMerchant payment: {merchant_id, order_ref, amount, idempotency_key}
POST /v1/withdrawalsTo linked bank; asynchronous, status PENDING → SUCCESS/FAILED
GET /v1/transactions?cursor=History, served from a read model
POST /internal/gateway/callbackSigned webhook from payment gateway; verify signature, then process idempotently

Two non-negotiables: amounts are integers in paise (never floats; 0.1 + 0.2 ≠ 0.3 in binary floating point), and every POST that moves money requires an idempotency key generated by the client once per user intent.

The Double-Entry Ledger

Double-entry bookkeeping is 500 years old for a reason: every transaction has at least two sides that sum to zero, so a bug in one place shows up as an imbalance instead of silently creating money.

PostgreSQL schema
CREATE TABLE accounts (
  id            BIGINT PRIMARY KEY,
  owner_type    TEXT NOT NULL,      -- USER | MERCHANT | SYSTEM
  owner_id      BIGINT,
  kind          TEXT NOT NULL,      -- WALLET, ESCROW_BANK, FEES, CLEARING, PENDING_TOPUP...
  balance       BIGINT NOT NULL DEFAULT 0,   -- paise, cached sum of postings
  allow_negative BOOLEAN NOT NULL DEFAULT FALSE,
  version       BIGINT NOT NULL DEFAULT 0,
  CHECK (allow_negative OR balance >= 0)
);

CREATE TABLE transactions (
  id          UUID PRIMARY KEY,
  type        TEXT NOT NULL,        -- P2P, TOPUP, MERCHANT_PAY, WITHDRAW, REFUND, REVERSAL
  status      TEXT NOT NULL,        -- PENDING, SUCCESS, FAILED, REVERSED
  amount      BIGINT NOT NULL CHECK (amount > 0),
  idem_key    TEXT UNIQUE,
  ref_id      TEXT,                 -- gateway / bank reference
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE postings (              -- append-only: no UPDATE, no DELETE
  id          BIGSERIAL PRIMARY KEY,
  txn_id      UUID NOT NULL REFERENCES transactions(id),
  account_id  BIGINT NOT NULL REFERENCES accounts(id),
  amount      BIGINT NOT NULL,      -- negative = debit, positive = credit
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX ON postings (account_id, id);

Example: a ₹250 P2P transfer with a ₹0 fee

txn_idaccountamount (paise)
t1wallet:Asha−25,000
t1wallet:Ravi+25,000
Sum0

Example: ₹1,000 merchant payment with a 1% MDR

txn_idaccountamount (paise)
t2wallet:Asha−100,000
t2merchant:ChaiPoint+99,000
t2system:fees+1,000
Sum0

Rules to state: postings are immutable; a mistake is fixed by a new reversal transaction with opposite postings, never by editing rows. The balance column is a cache that the transfer updates in the same transaction, and a nightly job verifies balance = SUM(postings.amount) per account.

System accounts make external money explicit

Money entering from outside appears as a posting against a system account that mirrors a real-world location: ESCROW_BANK (the escrow balance), PENDING_TOPUP (payment initiated but not settled), PAYOUT_IN_TRANSIT, FEES. These are allowed to go negative or positive as the mirror requires. This is what makes reconciliation mechanical later.

Atomic Transfers on a Single Shard

When both wallets live on the same database, a transfer is one ACID transaction. Two details separate good answers from great ones: lock rows in a deterministic order (lower account id first) so two opposite transfers cannot deadlock, and record the idempotency key inside the same transaction.

Java (JDBC, PostgreSQL)
import java.sql.*;
import java.util.UUID;
import javax.sql.DataSource;

public final class TransferService {
    private final DataSource ds;

    public TransferService(DataSource ds) { this.ds = ds; }

    /** Moves amountPaise from one wallet to another exactly once per idemKey. */
    public UUID transfer(String idemKey, long fromId, long toId, long amountPaise) throws SQLException {
        if (amountPaise <= 0 || fromId == toId) throw new IllegalArgumentException("invalid transfer");
        try (Connection c = ds.getConnection()) {
            c.setAutoCommit(false);
            c.setTransactionIsolation(Connection.TRANSACTION_READ_COMMITTED);
            try {
                UUID txnId = UUID.randomUUID();
                // 1. Claim the idempotency key. A concurrent duplicate blocks here until we finish.
                try (PreparedStatement ps = c.prepareStatement(
                        "INSERT INTO transactions(id, type, status, amount, idem_key) " +
                        "VALUES (?, 'P2P', 'SUCCESS', ?, ?) ON CONFLICT (idem_key) DO NOTHING")) {
                    ps.setObject(1, txnId);
                    ps.setLong(2, amountPaise);
                    ps.setString(3, idemKey);
                    if (ps.executeUpdate() == 0) {          // already processed: return original
                        c.rollback();
                        return findByIdemKey(c, idemKey);
                    }
                }
                // 2. Lock both wallets in id order to prevent deadlocks.
                long fromBalance = 0;
                for (long id : new long[]{Math.min(fromId, toId), Math.max(fromId, toId)}) {
                    try (PreparedStatement ps = c.prepareStatement(
                            "SELECT balance FROM accounts WHERE id = ? FOR UPDATE")) {
                        ps.setLong(1, id);
                        try (ResultSet rs = ps.executeQuery()) {
                            if (!rs.next()) throw new IllegalStateException("no account " + id);
                            if (id == fromId) fromBalance = rs.getLong(1);
                        }
                    }
                }
                if (fromBalance < amountPaise) throw new IllegalStateException("insufficient balance");

                // 3. Balanced postings + cached balances, all in this transaction.
                post(c, txnId, fromId, -amountPaise);
                post(c, txnId, toId, amountPaise);
                c.commit();
                return txnId;
            } catch (SQLException | RuntimeException e) {
                c.rollback();
                throw e;
            }
        }
    }

    private static void post(Connection c, UUID txnId, long accountId, long amount) throws SQLException {
        try (PreparedStatement ins = c.prepareStatement(
                 "INSERT INTO postings(txn_id, account_id, amount) VALUES (?, ?, ?)");
             PreparedStatement upd = c.prepareStatement(
                 "UPDATE accounts SET balance = balance + ?, version = version + 1 WHERE id = ?")) {
            ins.setObject(1, txnId); ins.setLong(2, accountId); ins.setLong(3, amount);
            ins.executeUpdate();
            upd.setLong(1, amount); upd.setLong(2, accountId);
            upd.executeUpdate();
        }
    }

    private static UUID findByIdemKey(Connection c, String idemKey) throws SQLException {
        try (PreparedStatement ps = c.prepareStatement(
                "SELECT id FROM transactions WHERE idem_key = ?")) {
            ps.setString(1, idemKey);
            try (ResultSet rs = ps.executeQuery()) {
                if (!rs.next()) throw new IllegalStateException("idempotency key vanished");
                return (UUID) rs.getObject(1);
            }
        }
    }
}

Why this is correct

  • No double spend: SELECT … FOR UPDATE serialises concurrent transfers from the same wallet. The second one waits, then reads the reduced balance. The CHECK (balance >= 0) constraint is a last line of defence if application logic is ever wrong.
  • No deadlock between A→B and B→A: both lock the lower id first.
  • No partial transfer: postings and balances commit or roll back together.
  • Exactly once per key: the unique constraint on idem_key plus ON CONFLICT DO NOTHING means a retry finds the existing row. Note that on "insufficient balance" the whole transaction, including the key, rolls back, so a retry is re-evaluated. If you want failed attempts to be sticky, write the failure under the key in a separate transaction.

Isolation-level subtleties (why READ COMMITTED plus explicit row locks is enough here, and when you would need SERIALIZABLE) are covered in ACID and isolation levels explained.

Idempotency Beyond the Database Row

The client taps "Pay", the request reaches the server, the transfer commits, and the response is lost because the user's phone drops from 4G to nothing in a Mumbai local train. The app retries. Without idempotency, Ravi gets paid twice.

  1. Client generates the key (UUID) when the user confirms the payment, persists it locally, and reuses it on every retry of that intent.
  2. Server scopes the key by user id so one user cannot collide with another's keys.
  3. Request fingerprint: store a hash of the request body with the key; if a retry arrives with the same key but a different amount, reject with 422 instead of silently returning the old result.
  4. Return the original response for a completed key, and a "still processing" status for an in-flight one.
  5. Propagate idempotency downstream: when calling a bank or gateway, pass your own transaction id as their idempotency/reference id so their retries are safe too.
  6. Consumers are idempotent: every Kafka consumer that applies money effects dedupes on txn_id, because delivery is at-least-once.

Sharding, Cross-Shard Transfers and Hot Wallets

At ~40k row writes/s peak you shard the ledger by account id (hash or range), keeping an account's postings and balance together. See database sharding strategies for choosing the key and resharding. Now two problems appear.

Problem 1: sender and receiver on different shards

You cannot run one local transaction across two databases. Options:

ApproachProsCons
Two-phase commit (XA)Atomic across shardsBlocking on coordinator failure, locks held across network round-trips, poor throughput
Distributed SQL (Spanner, CockroachDB, YugabyteDB)Database handles cross-shard transactionsHigher latency per transaction, operational and cost choices
Saga with clearing accounts + outboxEach step is a fast local ACID transaction; scales linearlyBrief in-transit state; needs compensations

The saga, step by step:

  1. Shard of A, local transaction: debit A ₹250, credit CLEARING@shardA ₹250, set transaction status IN_TRANSIT, and insert an outbox row "credit B ₹250 for txn t3". One commit.
  2. Outbox relay (or CDC like Debezium) publishes the outbox row to Kafka. Because the event was written in the same transaction as the debit, it can never be lost or published for a debit that did not happen.
  3. Shard of B, consumer: in one local transaction, check that t3 has not been applied (dedupe table), debit CLEARING@shardB ₹250, credit B ₹250, write a completion outbox event.
  4. Back on shard A: mark t3 SUCCESS.
  5. Compensation: if B's wallet is frozen or closed, shard B emits a failure event and shard A posts a reversal (credit A, debit clearing). The user sees "refunded".

Invariant: the sum of all clearing accounts equals the money currently in transit, and should drain to zero. Alert if a transfer stays in transit longer than a threshold. Note that money only ever leaves A after a successful local debit, so double spend is still impossible.

Problem 2: hot merchant accounts

During a flash sale, one large merchant may receive thousands of credits per second. Every credit taking a row lock on one accounts row serialises them all. Fixes:

  • Sub-accounts: split the merchant into N sub-accounts (merchant:42#0 … #15), credit a random one, report balance as the sum. Credits never need a balance check, so this is safe.
  • Deferred balance updates: append credit postings without touching the balance row, and have a background job fold them into the balance every few seconds. Debits from the merchant (payouts) read balance plus unfolded credits.

Add Money and Withdrawals: Dealing with the Outside World

Internal transfers are fully under your control. Top-ups and withdrawals involve banks, gateways and NPCI, which are slow, sometimes ambiguous, and occasionally wrong.

Top-up flow

  1. User requests ₹2,000 top-up via UPI. Create transaction TOPUP with status PENDING. No ledger postings yet, or postings into a pending account if you want visibility.
  2. Create a payment intent at the gateway with your transaction id as the merchant reference. The user approves in their UPI app.
  3. The gateway sends a signed webhook. Verify the signature, then do not trust the payload alone: call the gateway's status API to confirm.
  4. On confirmed success, in one local transaction: debit ESCROW_BANK (system account, mirroring incoming money), credit user wallet, mark SUCCESS. Dedupe on the gateway reference.
  5. If no webhook arrives, a status poller checks pending top-ups at increasing intervals (30 s, 2 min, 10 min…) and resolves them.
  6. If the bank debited the user but your system marked failure (or vice versa), reconciliation catches it and triggers an auto-refund or a credit.
⚠️
Timeouts are not failuresA timeout from a bank or gateway means "unknown", not "failed". Mark the transaction PENDING and resolve it with status checks. Marking it failed and letting the user retry is how people get charged twice.

Withdrawal flow

Withdraw is the mirror: first debit the wallet into PAYOUT_IN_TRANSIT (so the money cannot be spent twice while the bank transfer is in progress), then initiate an IMPS/NEFT/UPI payout through a banking partner, and on confirmation move from PAYOUT_IN_TRANSIT to ESCROW_BANK. On failure, reverse back to the wallet.

KYC Tiers and Limits

RBI's Master Directions on PPIs define categories such as small PPIs (opened with minimum details, with tight caps on balance and monthly loading, and no withdrawal to bank) and full-KYC PPIs (higher outstanding balance cap, commonly cited as ₹2 lakh, with bank transfers allowed). Exact limits and rules have been revised several times, so in an interview say "configurable limits per KYC tier, sourced from the current RBI directions" rather than hard-coding numbers. You can find the current text on the RBI website.

Designing the limits engine

  • Limits as configuration: a table keyed by tier: max balance, max load per month, max load per financial year, max P2P per month, withdrawals allowed (yes/no).
  • Counters per user per period: (user_id, period, metric) → amount, e.g. (u1, 2026-10, LOAD) → 7,50,000 paise. Update the counter in the same transaction as the ledger postings; a counter updated asynchronously can be raced past by two concurrent top-ups.
  • Max balance check happens on credit: a P2P transfer that would push the receiver above the cap must be rejected or held, which means the receiver's limits are also checked inside the transfer.
  • Tier upgrades (video KYC, Aadhaar-based e-KYC) change the config row the user points to; no data migration needed.
  • Fraud/risk rules (velocity checks, device fingerprint, new-device cooling period) run before the transaction, typically via a synchronous risk service with a tight timeout and a conservative default.

Event Sourcing, Read Models and Audit

A double-entry ledger is an event log: the postings are the facts, and the balance is a projection. That makes event sourcing and CQRS a natural fit (background in event sourcing and CQRS explained):

  • Write side: the ledger database, optimised for correct, small transactions.
  • Read side: transaction history, monthly statements, search ("payments to Swiggy last month"), analytics. Fed via CDC from the ledger into Kafka, then into Cassandra/Elasticsearch/a warehouse. A history view lagging by a second is fine; the balance on the transfer path never comes from here.
  • Rebuildability: if a read model is corrupted, replay postings to rebuild it.

Audit and tamper evidence

  • Postings are append-only; database roles for applications have no UPDATE/DELETE grant on the postings table.
  • Every manual adjustment (customer support credit, write-off) goes through a maker-checker workflow and is itself a transaction with a reason code and approver id.
  • Optionally hash-chain postings per account or per day (each block stores the hash of the previous one) and publish daily root hashes to separate storage, so tampering is detectable.
  • Ship ledger changes to a separate, access-controlled archive (object storage with retention locks).

Reconciliation: The Safety Net

Even with perfect code, money systems drift: a bank settles a transaction you never saw, a webhook is lost, a refund is processed twice by a partner. Reconciliation finds these.

Internal reconciliation (invariants)

  • For every transaction: SUM(postings.amount) = 0.
  • For every account: balance = SUM(postings).
  • Clearing and in-transit accounts drain to zero within SLA.
  • Sum of user + merchant + fee balances = escrow mirror account (adjusted for in-transit).

External reconciliation (against partners)

Each day, banks and gateways send settlement files. A reconciliation job matches them to internal transactions on reference ids and amounts:

Python: matching a settlement file against the ledger
def reconcile(ledger_rows, bank_rows):
    """Each row: dict with 'ref' and 'amount' (paise). Returns mismatch buckets."""
    ledger = {r["ref"]: r for r in ledger_rows}
    bank = {r["ref"]: r for r in bank_rows}
    result = {"matched": [], "amount_mismatch": [], "missing_in_bank": [], "missing_in_ledger": []}
    for ref, row in ledger.items():
        b = bank.get(ref)
        if b is None:
            result["missing_in_bank"].append(ref)
        elif b["amount"] != row["amount"]:
            result["amount_mismatch"].append((ref, row["amount"], b["amount"]))
        else:
            result["matched"].append(ref)
    result["missing_in_ledger"] = [ref for ref in bank if ref not in ledger]
    return result

ledger = [{"ref": "T1", "amount": 200000}, {"ref": "T2", "amount": 50000}]
bank = [{"ref": "T1", "amount": 200000}, {"ref": "T3", "amount": 10000}]
print(reconcile(ledger, bank))
# matched: T1, missing_in_bank: T2, missing_in_ledger: T3
BucketLikely causeTypical action
Missing in bank fileSettlement on next cycle (T+1), or our success was wrongCarry forward for N days, then investigate/reverse
Missing in ledgerLost webhook; bank debited user, we never creditedAuto-credit or auto-refund after status API confirmation
Amount mismatchFees/MDR handled differently, partial refunds, bugsManual ops queue with full context

In the real world matching is fuzzier (multiple partner reference formats, batched settlements, timezones around midnight), and a large share of fintech ops tooling exists for this. Mentioning a cut-off time and T+1 carry-forward shows you have thought about real settlement cycles.

Full architecture

App ──► API Gateway (auth, rate limit) ──► Wallet API │ ┌───────────────┬───────────────┼───────────────┐ ▼ ▼ ▼ ▼ Risk / Fraud Limits engine Ledger service Top-up / Payout (sync, tight (counters in (sharded orchestrator timeout) ledger txn) Postgres) (state machine) │ │ outbox / CDC ▼ ▼ Gateways, banks Kafka (webhooks + polling) ┌───────────┬───────┴─────┬────────────┐ ▼ ▼ ▼ ▼ History DB Notifications Recon jobs Warehouse (Cassandra) (SMS/push) (+ ops UI) (analytics)

Interview Tips and Common Mistakes

What interviewers probe

  • "Two requests spend the same ₹100 at the same instant. What happens?" (Row locks or conditional update; check constraint.)
  • "The response to a transfer is lost. The client retries. What happens?" (Idempotency key in the same transaction.)
  • "Sender and receiver are on different shards." (Saga with clearing accounts and outbox; explain compensation.)
  • "The bank times out on a withdrawal." (Pending state, status polling, never retry blindly.)
  • "How do you know the system has not lost money?" (Invariants plus external reconciliation.)

Mistakes that sink candidates

🚫
Balance-only designUPDATE wallets SET balance = balance - 100 with no ledger. You cannot audit it, cannot reconcile it, and cannot explain a customer complaint. Always lead with the ledger.
  • Read-then-write without a lock (check balance in one query, update in another).
  • Floats for money. Use integer paise or a decimal type.
  • Caching balances in Redis for the transfer path. Fine for display, fatal for authorisation.
  • Dual writes to the database and Kafka without an outbox; a crash between them loses or invents events.
  • Deleting or editing ledger rows to fix mistakes instead of posting reversals.
  • Ignoring regulation: not mentioning KYC tiers or limits in an Indian fintech interview misses easy points.
💡
A strong opening line"I'll model the wallet as a double-entry ledger in a relational database, with balances as a derived cache. Every money movement is an idempotent, balanced transaction, and anything involving banks is an asynchronous state machine with reconciliation." Then draw the tables.

For adjacent practice, the stock exchange design has a similar ledger at its core, and unique ID generation is useful for transaction references that must be sortable and globally unique.

Frequently Asked Questions

How does a digital wallet like Paytm store balances?▾
A well-designed wallet records every movement of money as balanced double-entry postings in an append-only ledger. The balance shown to the user is a materialised value updated in the same database transaction as the postings, and it can always be recomputed from the ledger.
What is double-entry bookkeeping in a wallet system?▾
Every transaction creates at least two postings, a debit on one account and a credit on another, that sum to zero. Money is therefore never created or destroyed by a bug in one place; if the postings do not balance, the transaction is rejected.
How do payment systems avoid charging twice on retries?▾
Clients send an idempotency key with every money-moving request. The server records the key with a unique constraint inside the same transaction as the transfer, so a retry with the same key returns the original result instead of executing a second transfer.
How do you handle wallet transfers across database shards?▾
Avoid distributed two-phase commit and use a saga: debit the sender into a clearing account on the sender's shard and publish an event through a transactional outbox, then credit the receiver from the clearing account on the receiver's shard. Each step is idempotent and has a compensating reversal.
What are the RBI KYC limits for wallets in India?▾
RBI's Master Directions on Prepaid Payment Instruments distinguish small PPIs opened with minimum details from full-KYC PPIs, with much lower balance and loading limits for small PPIs. The commonly cited caps are around Rs 10,000 for small PPIs and Rs 2 lakh outstanding for full-KYC PPIs, but limits change, so always check the current RBI directions.
What is reconciliation in payment systems?▾
Reconciliation compares your internal ledger with external records such as bank settlement files and payment gateway reports, and checks internal invariants such as all postings summing to zero. Mismatches are classified, auto-resolved where possible and escalated to operations otherwise.
Should a wallet use SQL or NoSQL?▾
The core ledger should use a relational database with ACID transactions, such as PostgreSQL or MySQL, sharded by account. Transaction history views, analytics and notifications can be served from NoSQL stores or search indexes fed from the ledger's event stream.

Master Fintech System Design

Prepflix's system design course works through payment, wallet and ledger designs used at PhonePe, Razorpay and Paytm, with mock interviews that probe consistency the way real panels do.

Explore the Prepflix Course →
Pranjal Jain
Pranjal Jain

Ex-Microsoft SDE · IIT Kanpur · Founder of Prepflix. Helps engineers crack startup and product company interviews across India.