Designing a Digital Wallet System: Double-Entry Ledgers, Transaction Atomicity, and Balance Consistency at Scale
How to architect a digital wallet from first principles: double-entry ledger schema, ACID atomicity for transfers, concurrent balance consistency, idempotency, reconciliation, and multi-currency support with TypeScript examples and honest tradeoffs.
A digital wallet sits at the intersection of two demanding requirements: it needs to be fast enough to handle high-frequency balance checks, and it needs to be correct enough that no amount of concurrent traffic, network partition, or retried request ever produces a wrong balance. These two requirements pull in opposite directions, and the design decisions you make in the first week determine how painful the next three years are.
Most wallet implementations start with a single balance column on a users table. That works until you need to answer “show me every transaction that produced this balance”, debug a $0.01 discrepancy at end of month, or support multiple currencies per user. This article starts from the ledger model and works outward to the operational concerns that break naive implementations at scale.
Why Double-Entry Bookkeeping Is Not Optional
Accountants solved this problem in the 15th century. Every financial event generates exactly two entries: a debit to one account and a credit to another. The invariant is simple: the sum of all debits must equal the sum of all credits at any point in time. If your ledger sum is non-zero, something is wrong, and you know immediately.
Compare this to the mutable-balance approach:
// Naive approach: mutable balance
await db.query(
"UPDATE wallets SET balance = balance - $1 WHERE user_id = $2",
[amount, senderId]
);
await db.query(
"UPDATE wallets SET balance = balance + $1 WHERE user_id = $2",
[amount, receiverId]
);
This gives you no audit trail without a separate event table, no way to detect corruption other than manual reconciliation, and no model-level invariant to test against. The ledger approach gives you all three for free.
The data model starts with accounts and entries:
type AccountType = "asset" | "liability" | "equity" | "revenue" | "expense";
interface LedgerAccount {
id: string;
userId: string | null; // null for system accounts
type: AccountType;
currencyCode: string; // ISO 4217
createdAt: Date;
}
interface LedgerEntry {
id: string;
transactionId: string; // ties the two sides of one event together
accountId: string;
amount: bigint; // always positive, in minor units (cents, pence, fils)
direction: "debit" | "credit";
createdAt: Date;
idempotencyKey: string;
metadata: Record<string, string>;
}
interface LedgerTransaction {
id: string;
description: string;
createdAt: Date;
entries: LedgerEntry[]; // always at least two
}
A few decisions embedded here that are worth making explicit.
Amount is always bigint, always positive, always in minor units. Never store monetary amounts as floats. IEEE 754 doubles cannot represent many decimal fractions exactly, and the errors compound across millions of transactions. bigint with minor-unit integers sidesteps this entirely.
Direction is a separate field, not a signed amount. You could store debits as negative and credits as positive. Some teams do. The problem is that the semantics of “positive” and “negative” depend on account type: a positive balance in a liability account means you owe that money, not that you have it. Explicit direction fields are less ambiguous when reading entries six months later.
transactionId groups the two (or more) entries for one business event. A transfer from user A to user B produces two entries under one transaction ID. A fee deducted during a transfer produces three or four. You can enforce the debit/credit invariant per transaction at insert time.
Ledger Schema and Balance Queries
The schema in PostgreSQL looks like this:
CREATE TABLE ledger_accounts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id),
type TEXT NOT NULL CHECK (type IN ('asset','liability','equity','revenue','expense')),
currency CHAR(3) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE ledger_transactions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
description TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE ledger_entries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
transaction_id UUID NOT NULL REFERENCES ledger_transactions(id),
account_id UUID NOT NULL REFERENCES ledger_accounts(id),
amount BIGINT NOT NULL CHECK (amount > 0),
direction TEXT NOT NULL CHECK (direction IN ('debit','credit')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
idempotency_key TEXT NOT NULL,
metadata JSONB,
UNIQUE(idempotency_key)
);
CREATE INDEX idx_ledger_entries_account ON ledger_entries(account_id, created_at);
Balance for a user’s asset account is the sum of credits minus the sum of debits:
async function getBalance(
db: Pool,
accountId: string
): Promise<bigint> {
const result = await db.query<{ balance: string }>(
`SELECT
COALESCE(
SUM(CASE WHEN direction = 'credit' THEN amount ELSE -amount END),
0
) AS balance
FROM ledger_entries
WHERE account_id = $1`,
[accountId]
);
return BigInt(result.rows[0].balance);
}
This query works but becomes slow as entry count grows. The common solution is a balance snapshot table: a materialized running balance per account, updated within the same transaction that inserts new entries. Reads go to the snapshot; the raw entries remain for audit and reconciliation.
CREATE TABLE account_balances (
account_id UUID PRIMARY KEY REFERENCES ledger_accounts(id),
balance BIGINT NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
The snapshot must be updated atomically with the entries. If either write fails, both roll back. The snapshot is never more than one committed transaction out of date.
Transaction Atomicity: Transfers Under ACID Guarantees
A funds transfer between two users is the canonical test for your atomicity model. It must satisfy four properties:
- Atomic: both entries commit or neither does
- Consistent: the debit/credit invariant holds after every transaction
- Isolated: concurrent transfers on the same accounts produce the same result as serial execution
- Durable: once committed, the transfer survives a crash
In PostgreSQL, you get all four within a single database transaction using SERIALIZABLE isolation for the balance check:
async function transfer(
db: Pool,
params: {
fromAccountId: string;
toAccountId: string;
amount: bigint;
idempotencyKey: string;
description: string;
}
): Promise<string> {
const client = await db.connect();
try {
await client.query("BEGIN");
// Check for duplicate — idempotency at the DB level
const existing = await client.query<{ transaction_id: string }>(
`SELECT transaction_id FROM ledger_entries
WHERE idempotency_key = $1 LIMIT 1`,
[params.idempotencyKey]
);
if (existing.rows.length > 0) {
await client.query("ROLLBACK");
return existing.rows[0].transaction_id;
}
// Lock both balance rows in consistent order to prevent deadlocks
const lockOrder = [params.fromAccountId, params.toAccountId].sort();
await client.query(
`SELECT balance FROM account_balances
WHERE account_id = ANY($1::uuid[])
FOR UPDATE`,
[lockOrder]
);
// Verify sender has sufficient funds
const balanceResult = await client.query<{ balance: string }>(
`SELECT balance FROM account_balances WHERE account_id = $1`,
[params.fromAccountId]
);
const currentBalance = BigInt(balanceResult.rows[0]?.balance ?? "0");
if (currentBalance < params.amount) {
await client.query("ROLLBACK");
throw new Error("Insufficient funds");
}
// Create transaction record
const txResult = await client.query<{ id: string }>(
`INSERT INTO ledger_transactions (description)
VALUES ($1) RETURNING id`,
[params.description]
);
const transactionId = txResult.rows[0].id;
// Insert both ledger entries
await client.query(
`INSERT INTO ledger_entries
(transaction_id, account_id, amount, direction, idempotency_key)
VALUES
($1, $2, $3, 'debit', $4),
($1, $5, $3, 'credit', $6)`,
[
transactionId,
params.fromAccountId,
params.amount,
`${params.idempotencyKey}:debit`,
params.toAccountId,
`${params.idempotencyKey}:credit`,
]
);
// Update snapshot balances
await client.query(
`UPDATE account_balances
SET balance = balance - $1, updated_at = now()
WHERE account_id = $2`,
[params.amount, params.fromAccountId]
);
await client.query(
`UPDATE account_balances
SET balance = balance + $1, updated_at = now()
WHERE account_id = $2`,
[params.amount, params.toAccountId]
);
await client.query("COMMIT");
return transactionId;
} catch (err) {
await client.query("ROLLBACK");
throw err;
} finally {
client.release();
}
}
A few production details worth noting. The FOR UPDATE lock acquisition uses sorted account IDs. Without a consistent lock ordering, two concurrent transfers between the same two accounts deadlock: transaction A locks account 1 then waits for account 2, while transaction B locks account 2 and waits for account 1. Sorting eliminates the cycle.
The idempotency key check at the start of the transaction handles the case where a client retries a request that already succeeded. The second call returns the original transaction ID without creating a duplicate transfer.
Balance Consistency Under Concurrent Writes
The balance snapshot approach works well under moderate write concurrency. Under heavy load, the FOR UPDATE lock on account_balances becomes a bottleneck: every concurrent transfer to or from the same account queues up.
There are three practical strategies:
| Strategy | Consistency | Throughput | Complexity |
|---|---|---|---|
Serialized row lock (FOR UPDATE) | Strong | Limited by lock contention | Low |
| Optimistic locking with retry | Strong (eventually) | Better under low conflict | Medium |
| Append-only with periodic snapshot | Eventual (configurable lag) | High | High |
Serialized row lock is correct and simple. It hits a ceiling around a few hundred transactions per second per account when each transaction takes 10-20ms.
Optimistic locking reads the current balance, computes the new value, and updates only if the version column has not changed. On conflict, retry. Works well when most transfers touch different accounts (low contention). Degrades badly on hot accounts like a platform’s fee collection account.
Append-only with periodic snapshots never updates the balance row. Instead, it inserts entries and updates the snapshot asynchronously at configurable intervals. Balance reads go to the snapshot and tolerate some lag. This enables very high write throughput at the cost of a window of inconsistency: a balance read during the snapshot interval might be slightly stale.
For most wallet systems, serialized locks with read replicas for balance queries is the right default. Reserve append-only for accounts that receive thousands of small credits per second, such as a platform revenue account or a promotional cashback pool.
Idempotency for Financial Operations
Every financial operation exposed over an API or processed from a queue must be idempotent. The failure scenario is always the same: the operation succeeds at the database but the response never reaches the caller, who retries. Without idempotency, the retry creates a second transaction.
The pattern shown in the transfer function above (check for existing entry by idempotency key, inside the transaction) handles the database layer. At the API layer, the idempotency key should come from the caller, be scoped to the operation type, and be stored with the response so that a repeated request returns the original response:
interface IdempotentResponse<T> {
idempotencyKey: string;
requestHash: string; // SHA-256 of the request body
response: T;
createdAt: Date;
expiresAt: Date; // typically 24-48 hours
}
async function createTransfer(
req: TransferRequest,
idempotencyKey: string
): Promise<TransferResponse> {
const cached = await cache.get<IdempotentResponse<TransferResponse>>(
`idempotent:transfer:${idempotencyKey}`
);
if (cached) {
// Verify the request body matches the original
if (cached.requestHash !== hashRequest(req)) {
throw new Error(
"Idempotency key reuse with different request body"
);
}
return cached.response;
}
const response = await executeTransfer(req);
await cache.set(
`idempotent:transfer:${idempotencyKey}`,
{
idempotencyKey,
requestHash: hashRequest(req),
response,
createdAt: new Date(),
expiresAt: addHours(new Date(), 48),
},
{ ttl: 48 * 3600 }
);
return response;
}
The request hash check is important. If a client sends the same idempotency key with different amounts (a bug on their side), you want to return an error rather than silently applying the first request’s semantics to the second call.
Reconciliation Patterns
A wallet ledger that reconciles perfectly with external systems (bank accounts, payment rails, custody providers) is auditable and compliant. One that does not produces growing discrepancies that become difficult to trace back to their source.
Reconciliation runs as a periodic job comparing two sources of truth:
interface ReconciliationResult {
periodStart: Date;
periodEnd: Date;
internalTotal: bigint;
externalTotal: bigint;
discrepancy: bigint;
unmatchedInternal: LedgerTransaction[];
unmatchedExternal: ExternalTransaction[];
}
async function reconcile(
db: Pool,
externalSource: ExternalStatementFetcher,
periodStart: Date,
periodEnd: Date,
currencyCode: string
): Promise<ReconciliationResult> {
const [internalTxns, externalTxns] = await Promise.all([
fetchInternalTransactions(db, periodStart, periodEnd, currencyCode),
externalSource.fetch(periodStart, periodEnd, currencyCode),
]);
const externalById = new Map(
externalTxns.map((t) => [t.externalReference, t])
);
const matched: string[] = [];
const unmatchedInternal: LedgerTransaction[] = [];
for (const internal of internalTxns) {
const external = externalById.get(internal.externalReference);
if (external && external.amount === internal.amount) {
matched.push(internal.id);
} else {
unmatchedInternal.push(internal);
}
}
const matchedExternalRefs = new Set(
internalTxns
.filter((t) => matched.includes(t.id))
.map((t) => t.externalReference)
);
const unmatchedExternal = externalTxns.filter(
(t) => !matchedExternalRefs.has(t.externalReference)
);
const internalTotal = internalTxns.reduce((sum, t) => sum + t.amount, 0n);
const externalTotal = externalTxns.reduce((sum, t) => sum + t.amount, 0n);
return {
periodStart,
periodEnd,
internalTotal,
externalTotal,
discrepancy: internalTotal - externalTotal,
unmatchedInternal,
unmatchedExternal,
};
}
Unmatched transactions need a human decision: were they a legitimate error, a timing difference (transaction posted on the external side after the reconciliation window closed), or a bug? The reconciliation report should be stored with a status workflow: open, under review, resolved, accepted variance.
Run reconciliation at end of day. For high-risk systems, run it hourly. Discrepancies that are caught within hours are vastly easier to investigate than ones discovered at month end.
Multi-Currency Support
The ledger schema above includes a currency field on accounts. Each account is denominated in one currency. A user with a USD wallet and a EUR wallet has two accounts.
A cross-currency transfer introduces an exchange rate and a third system account for the spread (the margin between the buy and sell rate that the platform retains):
interface CurrencyConversion {
fromAmount: bigint;
fromCurrency: string;
toAmount: bigint;
toCurrency: string;
exchangeRate: string; // stored as a decimal string, never float
rateTimestamp: Date;
spreadAmount: bigint; // platform margin, in fromCurrency
spreadAccountId: string;
}
async function crossCurrencyTransfer(
db: Pool,
params: {
fromAccountId: string;
toAccountId: string;
fromAmount: bigint;
conversion: CurrencyConversion;
idempotencyKey: string;
}
): Promise<string> {
const client = await db.connect();
try {
await client.query("BEGIN");
// ... idempotency check and lock acquisition (same as above)
const txResult = await client.query<{ id: string }>(
`INSERT INTO ledger_transactions (description)
VALUES ($1) RETURNING id`,
[`Cross-currency transfer ${params.conversion.fromCurrency} to ${params.conversion.toCurrency}`]
);
const transactionId = txResult.rows[0].id;
// Debit sender in source currency
await insertEntry(client, transactionId, params.fromAccountId, params.fromAmount, "debit", `${params.idempotencyKey}:sender-debit`);
// Credit spread account in source currency
if (params.conversion.spreadAmount > 0n) {
await insertEntry(client, transactionId, params.conversion.spreadAccountId, params.conversion.spreadAmount, "credit", `${params.idempotencyKey}:spread-credit`);
}
// Credit receiver in target currency
await insertEntry(client, transactionId, params.toAccountId, params.conversion.toAmount, "credit", `${params.idempotencyKey}:receiver-credit`);
// Store conversion record for audit
await client.query(
`INSERT INTO currency_conversions
(transaction_id, from_currency, to_currency, exchange_rate, rate_timestamp, spread_amount)
VALUES ($1, $2, $3, $4, $5, $6)`,
[
transactionId,
params.conversion.fromCurrency,
params.conversion.toCurrency,
params.conversion.exchangeRate,
params.conversion.rateTimestamp,
params.conversion.spreadAmount,
]
);
await client.query("COMMIT");
return transactionId;
} catch (err) {
await client.query("ROLLBACK");
throw err;
} finally {
client.release();
}
}
Note that the double-entry invariant holds per-currency, not across currencies. You cannot sum debits and credits across USD and EUR entries and expect them to equal zero. Each currency’s books balance independently.
Store the exchange rate with the transaction, not just the converted amounts. The rate is a historical fact: if a dispute arises six months later about why a user received X EUR for Y USD, you need the rate that was applied at the time, not the rate today.
Synchronous vs Eventual Consistency for Balance Updates
The snapshot balance pattern I described earlier is synchronous: the balance row updates within the same transaction as the entries. A balance read immediately after a committed transfer will always see the updated value.
Some systems trade this off for throughput. The append-only pattern skips the synchronous balance update and instead runs a background job to recompute or advance the snapshot. The tradeoff table:
| Approach | Balance read latency | Write throughput | Correctness guarantee |
|---|---|---|---|
| Synchronous snapshot | Low (index scan) | Bounded by row lock | Strong: never stale |
| Eventual snapshot (async) | Low (snapshot) | High (no lock) | Eventual: configurable lag |
| Computed on read | High (full scan) | Very high | Strong: always accurate |
| Read replicas + sync write | Low | Moderate | Strong writes, eventual reads |
For user-facing balance displays, strong consistency is almost always the right choice. A user who sends money and then immediately checks their balance expects the balance to reflect the transfer. Showing a stale balance that has not processed the transfer yet erodes trust in ways that are hard to recover from.
For platform-internal accounts (fee collection, float, reserves), eventual consistency is acceptable. No user is waiting to see the platform’s fee account update in real time.
For overdraft prevention, you cannot use eventual consistency. The balance check before a debit must reflect all previously committed debits. Use SELECT ... FOR UPDATE or an optimistic lock with retry. A stale balance read that allows a debit that should have been rejected is the most expensive bug a wallet system can have.
Production Considerations
Snapshot drift detection. Run a periodic job that computes the balance from raw entries for a sample of accounts and compares it to the snapshot. Any discrepancy means the snapshot update logic has a bug. Catch this in staging before it compounds in production.
Entry ordering within a transaction. Insert entries in a deterministic order within each transaction. This makes the reconciliation logic simpler and makes entry logs human-readable.
Soft deletes, not hard deletes. Ledger entries are never deleted. Corrections are new entries, not modifications to existing ones. If a transaction was entered incorrectly, create a reversal transaction that mirrors the original entries with directions swapped, then create the corrected transaction.
Indexes on high-cardinality columns. The ledger_entries(account_id, created_at) index covers the most common queries: “give me all entries for this account in this time range.” A covering index that includes amount and direction avoids the heap read for balance computations.
Audit log vs ledger. The ledger is the audit log. Do not create a separate audit table that mirrors ledger writes. You will eventually have a divergence between the two and no canonical source of truth.
Currency rounding policy. Define it once and enforce it everywhere. When converting 1 USD at an exchange rate of 0.9234 EUR, do you round half-up, half-even, or truncate? The choice matters less than consistency. A mismatch between the rounding policy in your application layer and in your reconciliation job produces penny discrepancies that are genuinely confusing to debug.
The Fundamental Insight
A digital wallet is, at its core, an accounting system. The engineering decisions that matter most are not the ones about frameworks or infrastructure. They are the accounting primitives: immutable entries, grouped by transaction, per-currency accounts, snapshots that are always consistent with the entries that produced them, and idempotency enforced at both the application and database levels. Get those four right and the rest of the system can change without threatening correctness.
More in System Design
How Amazon Aurora Works Internally: The Log-Is-the-Database Architecture, Quorum Writes, and Storage-Compute Separation Behind Cloud-Native SQL
A deep dive into Aurora's storage-compute separation, the log-is-the-database design that pushes redo log processing to storage nodes, quorum-based replication across 6 copies in 3 AZs, protection group architecture, fast cloning, Serverless v2 scaling mechanics, and honest tradeoffs versus RDS, self-managed PostgreSQL, CockroachDB, and Cloud Spanner.
How CockroachDB Works Internally: Distributed SQL, Raft Consensus Per Range, and the Architecture Behind Serializable Transactions at Global Scale
A deep dive into CockroachDB's internals: the 512MB range-based data model with automatic splitting, per-range Raft replication, MVCC timestamp ordering with hybrid logical clocks, DistSQL physical planning, serializable transactions with timestamp refreshes, closed timestamps for follower reads, online schema changes using multi-version schema descriptors, and production considerations for hotspots and write amplification.
How Container Runtimes Work Internally: Namespaces, Cgroups, and the OCI Stack From Docker Run to Process Isolation
Containers are not virtual machines and they are not magic. They are a thin composition of Linux kernel primitives: namespaces, cgroups, and a layered filesystem. This article traces exactly what happens between docker run and a running process, covering the OCI spec, containerd, runc, overlay filesystems, and container networking at the kernel level.
How YugabyteDB Works Internally: DocDB Storage, Tablet Splitting, and the Dual API Architecture That Scales PostgreSQL Horizontally
A deep dive into YugabyteDB internals covering the DocDB storage engine built on RocksDB LSM trees, per-tablet Raft consensus, YSQL and YCQL dual query layers, distributed MVCC with hybrid logical clocks, automatic tablet splitting and rebalancing, xCluster multi-region replication, and production considerations for hotspots, connection pooling, and schema design.