arrow_backBACK TO PAYMENTS ENGINEERING IN INDIA — FROM UPI TO LEDGERS
Lesson 07Payments Engineering in India — From UPI to Ledgers9 min read

Designing a Double-Entry Ledger — Every Paisa Accounted For

October 27, 2026

TL;DR

Don't store money as a balance column you update. Record every money movement as an immutable journal entry with two or more postings whose amounts sum to zero (debits positive, credits negative). Accounts have types: assets and expenses grow with debits, liabilities and revenue with credits. Enforce balanced entries in the database, make posting idempotent with a unique source reference, and never update or delete entries; correct mistakes with reversal entries. Balances are the sum of postings, cached only for speed. Money you hold for customers or sellers (wallets, seller payables) is a liability, not revenue.

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:

  1. No history. You know the current number, not how it got there.
  2. Easy to corrupt. A race condition, a bug, or a manual UPDATE changes it, and nothing records that it happened.
  3. 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.

Three journal entries for a 1,000 rupee order. Entry 1 when payment is captured: debit gateway receivable 1,000, credit sales 1,000. Entry 2 when the gateway settles on T+1: debit bank 977, debit payment gateway fees 19.50, debit GST input credit 3.50, credit gateway receivable 1,000. Entry 3 for a 300 rupee refund: debit sales returns 300, credit gateway receivable 300. The gateway receivable balance goes from 1,000 to 0 to minus 300, meaning the gateway will deduct 300 from the next settlement. Golden rule: sum of debits equals sum of credits for every entry

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

Ledger schema with three tables: accounts with id, code, type, currency and normal side; journal entries with id, occurred at business time, recorded at insert time, description, and a unique source type and source id for dedupe; and postings with entry id, account id, signed amount in paise and order or payment references. Rules enforced by the database: postings of an entry sum to zero, no update or delete on entries or postings, unique source for idempotency. Balances are the sum of postings, cached in an account balances table updated in the same transaction or snapshotted daily, and recomputed nightly to verify
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, always

It’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                 -20000

The 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.

Ledger accounts for a marketplace: gateway receivable and bank as assets, seller payable per seller as a liability, commission revenue as revenue, payment gateway fees as expense and payouts in transit as an asset. A 1,000 rupee sale with 10 percent commission debits gateway receivable 1,000, credits seller payable 900 and commission revenue 100. A payout debits seller payable 900 and credits payouts in transit 900, and on bank confirmation debits payouts in transit and credits bank

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

  1. Balance columns updated in place. No history, no cross-check. Use a ledger.
  2. Floats for money. Integer paise (or exact decimals). Always.
  3. Unbalanced entries. Check in code and enforce in the database.
  4. Updating or deleting ledger rows. Revoke the permission. Use reversal entries.
  5. No idempotency on posting. Use a unique (source_type, source_id) so replays and duplicates are harmless.
  6. Only one timestamp. Keep business time and recorded time separately; year-end reports depend on it.
  7. Hot account rows. Busy accounts with cached balances become a bottleneck. Split, snapshot, or don’t cache them.
  8. Never verifying cached balances. Recompute nightly, and alert if the whole ledger doesn’t sum to zero.
  9. Treating customer or seller money as revenue. Wallets and seller payables are liabilities.
  10. 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.