Most products start by tracking money the obvious way: a payments table with a status, a balance column on the user, perhaps a payouts table. It works until it doesn't. A balance drifts and nobody can explain why. A refund is applied twice. Finance asks how much the business owes sellers today and gets three answers. These aren't bugs in one query; they come from storing results instead of the movements that produced them.
A double-entry ledger records every movement of money as balanced entries between accounts, and derives every balance from those entries. It's the same idea accountants have used for centuries, and it suits software remarkably well. This guide explains it for engineers building wallets, marketplaces, payouts or anything else that holds money for others. It's a design guide, not accounting or tax advice; involve your finance team in the chart of accounts.
Why statuses and balance columns aren't enough
A balance column is a cached answer with no working shown. When it's wrong, there's nothing to check it against. A ledger turns that around:
- Every balance can be recomputed from entries, so a wrong balance can be traced to the entry that caused it.
- Nothing is overwritten. History is the record, which is what auditors, support teams and regulators need.
- Every movement has two sides, so money can't appear or vanish: if a seller's balance went up, some other account went down by the same amount.
- Questions like "how much do we owe sellers?" or "how much is the provider still to pay us?" become the balance of one account.
Double-entry in one paragraph
Money is held in accounts. Every movement is a transaction made of two or more entries, each moving an amount into or out of one account, and the entries of a transaction always sum to zero. Using signed amounts, a transfer of ₹100 from account A to account B is two entries: minus 100 on A, plus 100 on B. Accountants call the two sides debits and credits, and whether a debit increases or decreases an account depends on its type (assets and expenses increase with debits; liabilities and revenue increase with credits). Many engineering teams store signed amounts and let reports present them as debits and credits.
Accounts a payments business usually needs
Your chart of accounts will be specific to your business, but these appear in most payment flows:
| Account | Type | What its balance means |
|---|---|---|
| Bank (operating or nodal/escrow) | Asset | Money actually in the bank account |
| Provider receivable | Asset | Captured payments the provider hasn't settled yet |
| Seller or customer payable | Liability | Money you hold that belongs to sellers or customers (wallet balances) |
| Refunds payable | Liability | Refunds approved but not yet paid out |
| Commission or fee revenue | Revenue | What you've earned |
| Provider fees | Expense | What payment providers charged you |
| Suspense | Asset or liability | Money you can't yet attribute; should be worked to zero |
A worked example: one marketplace order
A customer pays ₹1,000 for a seller's product on a marketplace. The marketplace keeps a ₹100 commission. The payment provider charges a ₹20 fee and settles the rest. Then the seller is paid out. GST is left out to keep the arithmetic readable; in practice it gets its own accounts. Signed amounts: a positive number increases an asset or expense account, a negative number increases a liability or revenue account.
| Event | Account | Amount (₹) |
|---|---|---|
| 1. Payment captured | Provider receivable | +1,000 |
| 1. Payment captured | Seller payable | −1,000 |
| 2. Commission charged | Seller payable | +100 |
| 2. Commission charged | Commission revenue | −100 |
| 3. Provider settles, net of fee | Bank | +980 |
| 3. Provider settles, net of fee | Provider fees | +20 |
| 3. Provider settles, net of fee | Provider receivable | −1,000 |
| 4. Seller paid out | Seller payable | +900 |
| 4. Seller paid out | Bank | −900 |
Each event sums to zero. Afterwards, provider receivable is back to zero (the provider has paid everything it owed), seller payable is zero (the seller has been paid), the bank holds ₹80 more than before, commission revenue shows ₹100 earned, and provider fees show ₹20 spent. The ₹80 in the bank is exactly revenue minus fees. If any of those balances were different, you'd know precisely which step to look at.
A schema that enforces the rules
A minimal ledger has three tables: accounts, transactions (a journal of events) and entries. The important part is what the database guarantees.
CREATE TABLE ledger_accounts (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
code text NOT NULL UNIQUE, -- e.g. 'seller_payable:S123'
type text NOT NULL CHECK (type IN ('asset','liability','revenue','expense')),
currency char(3) NOT NULL
);
CREATE TABLE ledger_transactions (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
kind text NOT NULL, -- 'capture', 'settlement', 'payout', ...
external_ref text NOT NULL, -- provider or internal reference
effective_at timestamptz NOT NULL, -- when it happened in the real world
recorded_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (kind, external_ref) -- the same event can't be posted twice
);
CREATE TABLE ledger_entries (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
transaction_id bigint NOT NULL REFERENCES ledger_transactions (id),
account_id bigint NOT NULL REFERENCES ledger_accounts (id),
amount_minor bigint NOT NULL CHECK (amount_minor <> 0) -- signed, in paise
);
-- Post a transaction and its entries inside one database transaction, and
-- check before committing that its entries sum to zero per currency.
-- Never UPDATE or DELETE entries: grant the application INSERT and SELECT only.The UNIQUE constraint on kind and external reference makes posting idempotent: if the same settlement file or webhook is processed twice, the second insert fails instead of doubling the money. Checking that entries sum to zero can be done in the posting function, or in the database with a deferred constraint trigger, but it must happen in the same database transaction as the inserts.
Rules that keep a ledger trustworthy
The schema is the easy part. These rules are what make the numbers believable:
- Append-only. Never edit or delete an entry. Fix a mistake with a reversing transaction that cancels it, then post the correct one. The mistake and its correction both stay visible.
- Every transaction balances. No exceptions, no "temporary" one-sided entries.
- Idempotent posting. Each real-world event maps to exactly one transaction, keyed by its reference.
- One currency per account. Currency conversion is a transaction between accounts in different currencies, with the rate recorded, never a change of currency on an existing balance.
- Integers in minor units. Amounts in paise or cents as whole numbers, never floating point. Our guide to storing money in code explains why.
- Two dates. When something happened (effective) and when you recorded it. Reports for a period use the effective date; audits need both.
- Balances are derived. Cache them for speed if you must, but they're always recomputable from entries, and you should regularly check that they match.
Balances, concurrency and overdrafts
Reading a balance is a sum over an account's entries. For accounts with many entries, keep periodic balance snapshots and sum only entries after the latest one.
Wallets and seller balances add a constraint: a debit mustn't take a balance below zero. Two concurrent payouts can each see enough balance and both proceed. Prevent it by locking the account row (for example with SELECT ... FOR UPDATE in PostgreSQL) while you check the balance and post the debit, so the second payout waits and then sees the reduced balance. Keep these transactions short, and always lock accounts in a consistent order to avoid deadlocks.
Pending and posted money
Not every movement is final when you learn about it. A card authorisation reserves money that may never be captured; a payout may be sent but not yet confirmed. Model these explicitly, either as separate pending accounts or as a status on the transaction, so available balance (what can be spent or paid out) and ledger balance (everything posted) are both answerable. Moving from pending to posted is itself a transaction.
How the ledger helps reconciliation
A ledger makes reconciliation precise. In the example above, provider receivable should return to zero once a payment is settled; any amount left in it after the expected settlement date is a payment the provider hasn't paid or one you haven't matched. The same goes for refunds payable, payouts in transit and suspense. Reconciliation becomes a matter of explaining every non-zero balance in accounts that should be clear. Our payment reconciliation guide covers the matching itself.
Common mistakes
The ones we see most often:
- Storing a balance column and "also" keeping a ledger, then trusting the column when they disagree.
- Netting fees into the payment amount, so the gross amount and the fee can never be reported separately.
- Posting from webhooks without an idempotency key, so a retried event posts twice.
- Using floating-point amounts, or mixing currencies in one account.
- Editing entries to fix mistakes, which destroys the audit trail.
- Leaving suspense to grow; it should be worked down daily.
When you need one
You need a proper ledger once your product holds money on behalf of others or owes money to them: wallets and stored balances, marketplaces that split payments and pay out sellers, platforms that pay out from a balance, or any product that gives credits or refunds into a balance. Some of these, such as wallets that let customers store value, are regulated activities in India that need RBI authorisation, so check your model with counsel before building. If you're splitting payments between sellers, see our marketplace payments guide; for matching the provider side, LILPE's reconciliation page describes how settlements are matched to payments and bank credits.
Frequently asked questions
What is a double-entry ledger?
A way of recording money in which every movement is a transaction of two or more entries between accounts, and each transaction's entries sum to zero. Balances are calculated from the entries, so every balance can be explained and money can't appear or disappear without a matching entry elsewhere.
Why do fintech and payment products use double-entry ledgers?
Because they hold or move money for other people. A ledger gives a complete, append-only history, balances that can be recomputed and explained, protection against double posting, and clear answers to questions such as how much is owed to sellers or still due from a provider.
Should ledger amounts be positive and negative or debits and credits?
Either works if it's consistent. Many engineering teams store signed amounts that sum to zero per transaction and present them as debits and credits in reports. What matters is that every transaction balances and the sign convention per account type is documented.
How do you correct a mistake in a ledger?
Post a reversing transaction that cancels the wrong one, then post the correct transaction. Never edit or delete existing entries; the mistake and its correction both stay in the history.
How do you stop a wallet balance going negative?
Lock the account while checking its balance and posting the debit, in one database transaction, so concurrent debits are applied one after another. Without a lock, two payouts can both see enough balance and both go through.
Official sources
- PostgreSQL documentation — Explicit lockingRow-level locks such as SELECT ... FOR UPDATE, and avoiding deadlocks by acquiring locks in a consistent order.
- PostgreSQL documentation — Transaction isolation
Last reviewed . Examples, amounts and screens marked illustrative are not LILPE figures.




