A wallet startup I advised had a simple design. The users table had a wallet_balance column. Top-up? UPDATE users SET wallet_balance = wallet_balance + 500. Payment from wallet? Subtract. Cashback? Add. It worked fine for a year.
Then their finance team did the first proper audit. The total of all users’ wallet balances was ₹2.3 lakh more than the money actually sitting in their bank and escrow accounts. Where did ₹2.3 lakh of wallet balance come from? Nobody could say. The balance column showed the current number, but not how it got there. Some cashback bug, some race condition, some manual fix by ops, maybe all three. With only a current balance, there was no way to find out.
Every finance team in the world solved this problem centuries ago with double-entry bookkeeping. This lesson shows how to build it into your system, in engineering terms.
The Problem With a Balance Column
A balance column has three problems:
- No history. You know the current number, not how it got there.
- Easy to corrupt. A race condition, a bug, or a manual
UPDATEchanges it, and nothing records that it happened. - No cross-check. There’s nothing to compare it against. If it’s wrong, it looks just as valid as if it’s right.
A ledger fixes all three. Instead of storing the balance, you store every movement, and the balance is simply the sum of movements. And because each movement is recorded from two sides, the books always have to add up, which gives you a built-in cross-check.
Double-Entry in Plain Words
Every movement of money has a source and a destination. Money doesn’t appear or disappear; it moves from one account to another. So each movement is recorded as a journal entry with at least two lines (called postings):
- One or more debits
- One or more credits
- And the total of debits always equals the total of credits.
What do “debit” and “credit” mean? Forget the everyday meanings. In accounting, they’re just the two sides:
| Account type | Examples | Increases with | Decreases with |
|---|---|---|---|
| Asset | bank, gateway receivable | debit | credit |
| Expense | gateway fees, cashback cost | debit | credit |
| Liability | customer wallets, seller payables, GST payable | credit | debit |
| Revenue | sales, commission | credit | debit |
| Equity | owner’s capital | credit | debit |
Let’s see it with a real example: a customer buys something for ₹1,000.
Entry 1, payment captured. The gateway now owes you ₹1,000 (an asset called gateway_receivable), and you’ve made a sale (revenue). Debit the asset, credit revenue.
Entry 2, settlement. The gateway pays ₹977 into your bank, after keeping ₹19.50 fee and ₹3.50 GST on the fee. Your bank goes up by ₹977, you record ₹19.50 of fee expense and ₹3.50 of GST you can claim back as input credit (check with your CA how your company handles this), and the gateway no longer owes you anything for this payment.
Entry 3, partial refund. You refund ₹300. Sales returns go up (a contra-revenue account), and the gateway will take ₹300 from your next settlement, so gateway_receivable goes down by ₹300.
Look at gateway_receivable across the three entries: +1,000, then 0, then −300. That −300 says “the gateway will deduct ₹300 from your next settlement”. The ledger is telling you exactly what to expect from the next settlement file. That’s what makes reconciliation (Lesson 9) possible.
(This is a simplified example to show the mechanics. GST on your sales, TDS and TCS rules for e-commerce, and how your company books these is a conversation with your CA and finance team. The engineering is the same; only the accounts change.)
The engineer’s sign convention
To keep the code simple, store each posting as one signed integer:
- Debit = positive
- Credit = negative
Then the rule “debits equal credits” becomes simply: the postings of every entry sum to zero. Easy to check in code and in the database.
The Schema
CREATE TABLE ledger_accounts (
id BIGSERIAL PRIMARY KEY,
code TEXT NOT NULL UNIQUE, -- 'gateway_receivable:razorpay', 'wallet:user:4471'
type TEXT NOT NULL CHECK (type IN ('asset','liability','revenue','expense','equity')),
currency CHAR(3) NOT NULL DEFAULT 'INR',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE journal_entries (
id BIGSERIAL PRIMARY KEY,
source_type TEXT NOT NULL, -- 'payment.captured', 'settlement', 'refund.created'
source_id TEXT NOT NULL, -- gateway payment id, settlement id, refund id ...
description TEXT,
occurred_at TIMESTAMPTZ NOT NULL, -- when it happened in the real world
recorded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (source_type, source_id) -- the same event can never be posted twice
);
CREATE TABLE postings (
id BIGSERIAL PRIMARY KEY,
entry_id BIGINT NOT NULL REFERENCES journal_entries(id),
account_id BIGINT NOT NULL REFERENCES ledger_accounts(id),
amount_paise BIGINT NOT NULL CHECK (amount_paise <> 0), -- + debit, - credit
order_id TEXT,
payment_id TEXT
);
CREATE INDEX ON postings (account_id, entry_id);
CREATE INDEX ON postings (order_id);Enforcing “every entry balances” in the database
Your code will check this, but code has bugs. Let the database refuse unbalanced entries too. In Postgres, a deferred constraint trigger checks the sum at commit time, after all postings of the entry are inserted:
CREATE FUNCTION check_entry_balanced() RETURNS trigger AS $$
BEGIN
IF (SELECT COALESCE(SUM(amount_paise), 0) FROM postings WHERE entry_id = NEW.entry_id) <> 0 THEN
RAISE EXCEPTION 'journal entry % does not balance', NEW.entry_id;
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
CREATE CONSTRAINT TRIGGER postings_must_balance
AFTER INSERT ON postings
DEFERRABLE INITIALLY DEFERRED
FOR EACH ROW EXECUTE FUNCTION check_entry_balanced();Making the ledger append-only
Entries and postings are never updated or deleted. Enforce it with permissions: the application’s database user gets INSERT and SELECT on these tables, nothing else.
REVOKE UPDATE, DELETE, TRUNCATE ON journal_entries, postings FROM app_user;If something was posted wrongly, you post a reversal: a new entry with the same lines and opposite signs, referring to the original. Then you post the correct entry. The history shows exactly what happened, including the mistake and its correction. Auditors expect this.
Posting Entries From Code
One function writes to the ledger. Everything goes through it.
from dataclasses import dataclass
from datetime import datetime
@dataclass(frozen=True)
class Line:
account: str # account code
amount_paise: int # + debit, - credit
order_id: str | None = None
payment_id: str | None = None
class UnbalancedEntry(Exception): ...
def post_entry(conn, *, source_type: str, source_id: str, occurred_at: datetime,
lines: list[Line], description: str = "") -> int | None:
if sum(l.amount_paise for l in lines) != 0:
raise UnbalancedEntry(f"{source_type}:{source_id} sums to "
f"{sum(l.amount_paise for l in lines)}")
if any(l.amount_paise == 0 for l in lines):
raise ValueError("zero-amount posting")
with conn.transaction():
entry = conn.fetchrow("""
INSERT INTO journal_entries (source_type, source_id, description, occurred_at)
VALUES ($1, $2, $3, $4)
ON CONFLICT (source_type, source_id) DO NOTHING
RETURNING id
""", source_type, source_id, description, occurred_at)
if entry is None:
return None # already posted: idempotent no-op
for l in lines:
account_id = get_or_create_account_id(conn, l.account)
conn.execute("""
INSERT INTO postings (entry_id, account_id, amount_paise, order_id, payment_id)
VALUES ($1, $2, $3, $4, $5)
""", entry["id"], account_id, l.amount_paise, l.order_id, l.payment_id)
return entry["id"]Now each business event becomes a small, readable function:
def on_payment_captured(conn, p: dict, order_id: str):
post_entry(conn,
source_type="payment.captured", source_id=p["id"],
occurred_at=from_unix(p["captured_at"]),
description=f"Payment for order {order_id}",
lines=[
Line("gateway_receivable:razorpay", +p["amount"], order_id, p["id"]),
Line("sales", -p["amount"], order_id, p["id"]),
])The source_type + source_id pair is the idempotency key. When the outbox relay (Lesson 5) delivers order.paid twice, the second post_entry hits the unique constraint and quietly does nothing. When you replay a day’s events after a bug fix, nothing is double-counted.
Business time vs recorded time
Notice two timestamps on each entry:
occurred_at: when it happened in the real world (the payment was captured at 11:58 PM on 31st March).recorded_at: when your system wrote it (12:03 AM on 1st April, because the webhook was late).
Finance reports go by occurred_at, which decides which day, month and financial year a transaction belongs to. Your debugging goes by recorded_at. Mixing them up puts transactions in the wrong financial year, which matters a lot at the 31st March year end.
Balances
The balance of an account is the sum of its postings:
SELECT SUM(amount_paise) FROM postings WHERE account_id = $1;(For a liability or revenue account, the “natural” balance is negative in our sign convention. Display it with the sign flipped.)
For small systems, computing this on the fly is fine. As you grow, you need faster reads. Two common approaches:
Cached balance updated in the same transaction. Keep an account_balances table and update it inside post_entry, with a row lock per account. Reads are instant. The catch: a very busy account (like gateway_receivable or sales, which every payment touches) becomes a hot row, and all postings queue up behind its lock. You can split hot accounts by day or by shard (gateway_receivable:razorpay:2026-10-27), or not cache them at all.
Snapshots plus recent postings. Each night, save every account’s balance as of midnight. A balance read is the snapshot plus the sum of postings since then. No hot rows, still fast.
Either way, run a nightly check that recomputes balances from postings and compares with the cache. If they ever differ, something wrote around your ledger function, and you want to know immediately.
The ultimate check
Because every entry sums to zero, the sum of all postings in the whole ledger is always zero. If this query ever returns anything else, your ledger is corrupted:
SELECT SUM(amount_paise) FROM postings; -- must be 0, alwaysIt’s a cheap query to put in your monitoring.
Wallets: Money That Isn’t Yours
Back to the wallet startup. In ledger terms, when a customer tops up ₹500:
Dr gateway_receivable +50000
Cr wallet:user:4471 -50000 (liability: you owe the customer this money)When they spend ₹200 of it on an order:
Dr wallet:user:4471 +20000
Cr sales -20000The wallet balance is a liability: money you hold that belongs to the customer. It’s not your revenue until they spend it. The total of all wallet accounts is how much you owe all customers together, and it must be backed by real money in your bank and gateway accounts. That’s the check the wallet startup couldn’t do.
To stop a wallet going negative, check and post in one transaction with a lock on that wallet’s balance row:
def spend_from_wallet(conn, user_id: int, amount: int, order_id: str):
acct = f"wallet:user:{user_id}"
with conn.transaction():
bal = conn.fetchval("""
SELECT balance_paise FROM account_balances
WHERE account_code = $1 FOR UPDATE
""", acct)
available = -bal # liability: stored negative
if available < amount:
raise InsufficientBalance(available)
post_entry(conn, source_type="wallet.spend", source_id=order_id,
occurred_at=now(), lines=[
Line(acct, +amount, order_id),
Line("sales", -amount, order_id)])(If post_entry also updates account_balances, the lock you hold here keeps it consistent. Note that prepaid wallets in India are regulated as PPIs by RBI. If you’re building a real wallet that holds customer money, read the PPI rules first. A “store credit” that can only be used in your own app is treated differently from a wallet that can pay elsewhere.)
Marketplaces: Seller Money Is a Liability Too
If you run a marketplace, most of the money you collect belongs to sellers. Only your commission is revenue.
Each seller gets their own payable account. When a sale happens, the seller’s share goes to their payable, your commission to revenue. When you pay the seller, their payable goes down. The seller’s statement is just the postings on their account, which is exactly what sellers want to see when they ask “why did I get ₹8,412 this week?”
Notice payouts_in_transit. A payout sent via IMPS or NEFT isn’t confirmed immediately. You move the money to “in transit” when you send it, and to “bank” when the payout succeeds. If the payout fails (wrong account number), you reverse it back to the seller’s payable. At any moment, the ledger shows how much money is on its way out.
Marketplace payments in India also come with specific RBI rules on how a marketplace may collect and hold money for sellers, plus tax rules (TCS for e-commerce operators, for example). Involve finance and legal early.
Pitfalls to Avoid
- Balance columns updated in place. No history, no cross-check. Use a ledger.
- Floats for money. Integer paise (or exact decimals). Always.
- Unbalanced entries. Check in code and enforce in the database.
- Updating or deleting ledger rows. Revoke the permission. Use reversal entries.
- No idempotency on posting. Use a unique
(source_type, source_id)so replays and duplicates are harmless. - Only one timestamp. Keep business time and recorded time separately; year-end reports depend on it.
- Hot account rows. Busy accounts with cached balances become a bottleneck. Split, snapshot, or don’t cache them.
- Never verifying cached balances. Recompute nightly, and alert if the whole ledger doesn’t sum to zero.
- Treating customer or seller money as revenue. Wallets and seller payables are liabilities.
- Designing accounts without finance. Show your account list to your CA or finance team before you build it. They’ll catch tax and reporting needs you’d miss.
Quick Recap
- Store movements, not balances. Each movement is a journal entry whose postings sum to zero.
- Assets and expenses grow with debits; liabilities and revenue with credits. Debit positive, credit negative.
- Enforce balanced entries, idempotent posting, and append-only tables in the database.
- Balances are sums of postings; cache carefully and verify nightly.
- Wallet balances and seller money are liabilities.
The ledger tells you what should have happened. Next, we’ll look at refunds, chargebacks and disputes: the ways money flows back out, and how to record and handle each one.