The first version of a wallet is easy to imagine: a user table has a balance field, a transfer subtracts from one balance and adds to another, and the interface shows the result.
That model survives until real money behavior arrives. A payment can be pending. A provider can time out after accepting the request. A refund can happen weeks later. A transfer can have a fee. Money can sit in an omnibus bank account while the product owes different amounts to thousands of users. Foreign exchange can move two currencies at different rates. A bank statement can disagree with the application. An operator can make a correction.
At that point the question is no longer, "What is the balance?" It is, "What sequence of financial events makes this balance true?"
This is an engineering article about product ledgers and systems of record. It is not accounting, legal or regulatory advice, and a production financial institution still needs qualified accounting and compliance design around the ledger.
A mutable balance is a cache, not a history
Suppose an account has a balance of 48,500. That number does not tell you whether the user deposited 50,000 and paid a 1,500 fee, received two transfers, reversed a purchase or had an operator adjustment. It tells you the current result but not the path.
Stripe's customer credit-balance API illustrates the distinction. Creating a balance transaction produces an immutable transaction that updates the customer's credit balance; most transaction fields cannot later be edited.5 The transaction history is the evidence. The ending balance is a consequence of it.
This is the first ledger principle I care about: balances should be explainable from entries. A cached balance is useful for fast reads, but it should not be the only surviving record of money movement.
If support asks why a user had a particular balance at 14:03 yesterday, can the system reconstruct the answer from durable financial events without relying on application logs?
Double-entry turns money movement into an invariant
Double-entry bookkeeping is not only for accountants. It is a useful data model for software because every movement of value has a source and a destination.
TigerBeetle's financial-accounting documentation summarizes the central property: every transaction has at least one debit and one credit, and double-entry ensures funds come from somewhere and go somewhere.6 Modern Treasury describes the same guarantee as requiring debits and credits to balance per currency.3
The terminology depends on whose books are being represented. Customer wallet balances often represent a liability of the platform, not an asset of the user inside the platform's own ledger. That distinction matters because it keeps the accounting model coherent.
The software benefit is stronger than a convention. If every posted transaction must balance, a class of impossible states becomes harder to create. You cannot "just add 10,000" without specifying the other side of the movement.
Posted financial history should be immutable
When a completed transaction is wrong, the instinct in normal CRUD software is to edit it. In a ledger, editing history destroys evidence.
Modern Treasury argues that posted ledger transactions should remain immutable, with corrections represented through reversals or additional correcting transactions. Its engineering explanation is straightforward: once historical data is mutated, reconstructing what happened becomes difficult or impossible.2
TigerBeetle similarly defines transfers as immutable records that cannot be modified or deleted after creation; errors are handled with correcting transfers.8
That does not mean nothing in a financial system can ever change. A payment can move from pending to posted. Metadata can be enriched. A business object such as an order can be edited. The rule is narrower: once value has been posted to the ledger, preserve the financial history and represent corrections as new history.
Pending money and posted money are not the same state
External payment rails do not behave like an in-memory function call. A bank transfer can be initiated but not settled. A card authorization can reserve funds and later be captured or released. A payout can be submitted and later rejected.
A useful ledger therefore distinguishes at least available, pending and posted effects, even if the implementation uses different names. Modern Treasury's ledger model uses pending transactions before final posting, explicitly because bank payments can spend time in progress and can still fail.2
The product consequence is important. "Current balance" is often not one number. A wallet can have:
Collapsing these states into a single mutable balance is where many double-spend and customer-support problems begin.
Retries must not create duplicate money
Distributed systems retry. A client times out. A queue redelivers. A worker crashes after committing but before acknowledging. If retrying a financial request can post value twice, the system is not safe.
TigerBeetle's reliable-submission guidance uses client-generated IDs as idempotency keys so the same transfer is not created twice across retries.7 The exact mechanism can differ in another system, but the invariant should not.
I prefer idempotency at the financial-event boundary, not only at the HTTP route. If a provider webhook is delivered five times, all five deliveries may be valid HTTP requests, but only one financial consequence should be posted for the same provider event.
Give the business event a stable identity
Provider transaction ID, transfer ID or internally generated financial event ID should survive retries.
Enforce uniqueness where money is posted
Do not rely only on a request-layer cache that can expire or be bypassed.
Return the prior result
A replay should resolve to the original financial outcome rather than inventing a second one.
Separate retry from correction
If the business genuinely wants another transfer, create a new financial event with a new identity.
The ledger is internal truth. Reconciliation connects it to external truth.
A perfect internal ledger can still disagree with the bank or payment provider. That is not always a ledger bug. Fees, settlement timing, provider reversals, unrecognized deposits, late webhooks and manual bank actions can all create variance.
Modern Treasury's reconciliation documentation describes comparing a ledger-account balance against the balance reported by a bank or vendor to identify and resolve variance.4 That is a useful separation of concerns: the ledger explains what the application believes happened; reconciliation determines whether the outside world agrees.
I would never treat provider webhooks as reconciliation. Webhooks are events. Reconciliation is comparison.
A practical architecture for a product ledger
A ledger does not need to be a separate database product on day one. A relational database can implement a strong ledger if the schema, invariants and transaction boundaries are designed correctly. The more important decision is the model.
I would separate five concepts:
Accounts
Typed stores of value such as settlement cash, customer liabilities, fees receivable, platform income and suspense.
Transactions
The business-level financial event that groups entries atomically.
Entries
The debits and credits that change account balances while preserving per-currency equality.
References
Links to domain objects and external provider IDs so money movement can be traced to its cause.
Audit context
Who or what initiated a change, which service handled it, and which correction relates to which original transaction.
Then enforce the invariants at the database or ledger service boundary: balanced entries, one currency per balanced transaction group unless an explicit FX design bridges currencies, unique financial-event identifiers, atomic posting and controlled account overdraft rules.
Uber has publicly described using double-entry bookkeeping, immutable orders and asynchronous stream processing in its payments platform to balance credits and debits while scaling collections and disbursements.9 The exact architecture of a smaller platform should be simpler, but the invariants are the useful part to borrow.
Multi-currency systems make the need for explicit entries even stronger
A USD-to-NGN conversion is not "change the currency field and multiply the balance." It is at least two movements of value, often with a spread or fee and an external liquidity provider somewhere in the chain.
A good ledger preserves the original amounts, currencies, rate reference, fee and counterparties. TigerBeetle's modelling guidance keeps transfers within a ledger and represents currency exchange with multiple linked transfers, which is useful because the economics of the exchange remain explicit rather than being hidden inside a mutated balance.6
For a product platform, I would also keep provider settlement state separate from customer accounting state. A customer can be owed value before the external provider has settled cash, but the system must know that those are two different truths with different risks.
The ledger should not become the entire application database
An immutable financial history is powerful, but it is not where every product field belongs. User profiles, KYC workflow state, marketing preferences, support notes and UI configuration are not ledger entries.
Modern Treasury makes a similar distinction between mutable business objects and accounting-level objects that represent trackable money movement.2 I think that boundary is important. The ledger should answer financial questions. The domain model should answer product questions.
That separation also reduces accidental coupling. You can redesign an order workflow without rewriting financial history, and you can audit money movement without replaying every unrelated application event.
When does a platform actually need one?
Not every application needs a double-entry ledger. A content site with a single subscription provider probably does not. A marketplace, wallet, card platform, lending product, stored-value system, exchange, payroll system or product that owes money to several parties probably does.
I would treat these as warning signs:
You show users balances
Especially when those balances can be spent, withdrawn or transferred.
You hold money on behalf of multiple parties
Omnibus or pooled settlement structures create internal allocation obligations.
One transaction can produce several financial consequences
Principal, platform fee, tax, commission, reserve and payout should not be hidden in one amount.
Providers can disagree with you
If external settlement files or bank balances need comparison, you need an internal system of record worth reconciling against.
Corrections matter
If an operator can change money state, you need durable evidence of the original event and the correcting event.
You cannot explain yesterday's balance
That is usually the clearest signal that the current model has outgrown itself.
The point of a ledger is not accounting theatre. It is to make financial state explainable, constrained and recoverable.
Balances are answers. The ledger is the proof.
Engineering reference basis
- Modern Treasury, Ledgers Overview. Immutable double-entry product-ledger concepts and system-of-record design.
- Modern Treasury, 2021. Immutability, reversals, mutable business objects and pending versus posted ledger state.
- Modern Treasury, updated 2025. Double-entry and immutable-log guarantees.
- Modern Treasury, Account Reconciliation. Comparing ledger balances with bank or vendor-reported balances.
- Stripe API Reference. Immutable customer balance transactions and ending balances.
- TigerBeetle, Financial Accounting. Debits, credits, account types and double-entry invariants.
- TigerBeetle, Reliable Transaction Submission. Stable IDs and idempotent transfer submission.
- TigerBeetle, Safety. Financial atomicity, consistency and immutable transfer history.
- Uber Engineering. Double-entry bookkeeping, immutable orders and asynchronous payment processing at scale.
