Idempotent fee posting: designing out double-charges
A double-entry primer, idempotency keys, race conditions, and why money never lives in a float.
Smart Ledger is designed so the class of bugs attributable to the ledger itself is engineered out — not because we're especially good at writing software, but because the design admits a smaller class of bugs in the first place — double-entry accounting, idempotency keys, and integer arithmetic combined push entire bug categories out of the realm of the writable.
Double-entry, in one paragraph
Every financial event is two entries that net to zero. A ₹15,000 fee payment from a parent
debits the bank-receivable account by ₹15,000 and credits the student-fee-revenue account
by ₹15,000. Both rows live in a single transaction, atomically, or neither does. Account
balances are projections — SUM(amount) WHERE account_id = ? — never stored, never
trusted, never updated in place. If you can't reconstruct the balance from the journal
alone, the journal is wrong.
That single rule makes "the ledger thinks the school received ₹47,000 but the bank says ₹50,000" structurally detectable. You query the journal against the gateway settlement file and any difference is a real, locatable, named transaction. Compare to a system where balances live in mutable rows and the only evidence of past state is a stack of audit logs nobody reads.
Idempotency keys are not a nice-to-have
Razorpay sometimes sends the same payment-success webhook twice within 60 seconds. PayU occasionally retries after timeouts even when the original succeeded. Our parent app posts the "I just paid" event from the client and from the server simultaneously. Without idempotency, every one of those duplicates becomes a double-credit.
The contract: every posting carries an idempotency key, derived from the source event's unique identifier (gateway payment ID, internal request hash, etc.). The ledger writer is upserts-only:
INSERT INTO ledger_entries (
idempotency_key, tenant_id, account_id, amount_paise, posted_at, ...
) VALUES (...)
ON CONFLICT (tenant_id, idempotency_key) DO NOTHING
RETURNING id;
If the insert returns no row, the event was already processed and the caller treats it as
success. The unique index on (tenant_id, idempotency_key) is the single piece of schema
that prevents the most common class of money bug.
Money software has exactly one rule: the same event, replayed, must produce the same result. Everything else is a consequence.
Race conditions hide where you don't look
The boring race — two concurrent inserts of the same idempotency key — is handled by the unique index. The interesting race is the partial-failure one: the application thinks the post succeeded, but the database connection died before the COMMIT acknowledged. The client retries with the same idempotency key. The server's second attempt sees the row already there, returns success — and now the application has to figure out whether the original attempt's downstream side-effects (receipt PDF generation, parent SMS, WhatsApp notification) actually fired.
We solve this by making the side-effects themselves idempotent and queue-driven. The ledger post enqueues a "post-receipt-effects" job with the same idempotency key. The job worker is upserts-only too — it claims the idempotency slot before sending the SMS, and won't re-send if the slot is taken. Belt and braces, but it's the only way that survives a data-centre flap.
Why we don't use floating-point for money
Every amount in the database is an integer in paise (1/100 of a rupee), stored as
bigint. ₹15,000.50 is 1500050. The application layer converts to display strings only
at render time. There is no DECIMAL(10,2) anywhere — we tried, found ourselves with
inconsistent rounding behaviour across PostgreSQL, the JVM, and Node's native arithmetic,
and standardised on integer paise.
The argument against floats in money is well-trodden, but the argument against decimals is subtler: every language and runtime treats decimals slightly differently, especially at edge cases like division for split-payment allocation. Integers behave identically everywhere. We do all rounding (e.g. for late-fee compounding) in a single function with explicit half-even semantics, and we test it against a fixture of 4,200 cases derived from historical school invoices.
Reconciliation is the only proof that any of this works
Every night at 02:30, a reconciliation job fetches the gateway's settlement report for T-1, joins it against the ledger's gateway-receivable account, and computes the difference per institution. The expected difference is zero. Any non-zero result opens a flagged ticket with the specific transactions that don't match. In the last eighteen months, the recon has flagged seventeen real issues — four were gateway side, twelve were race conditions in our retry logic that we then fixed, one was a school's accountant manually editing a row in our admin tool (we removed that capability the same day).
Without recon, "the ledger is correct" is a hope. With it, it's a measurement.
If you run a school group and your current fee module is a spreadsheet with formulas, the ledger sits inside Campus — see /campus or write to admin@airanexus.in.