Skip to main content
Finance9 min readUpdated

Double-Entry Ledger Design: Immutable Postings, Idempotency, and Balances That Always Reconcile

How to build the accounting core of a financial application: immutable double-entry postings, atomic balanced transactions, idempotency keys, currency handling, and reconciliation against external sources of truth.

Summary

If your application moves money, a balance column on a user row will eventually be wrong, and you will have no way to find out why. The correct core is a double-entry ledger: an append-only table of postings where every transaction's debits equal its credits, balances are derived rather than stored, and every write carries an idempotency key. It costs slightly more to build and it is the difference between a discrepancy you can explain in minutes and one you cannot explain at all.

PostgresTypeScriptNode.jsNext.js

Why the balance column fails

A mutable balance holds no history. When it is wrong — and concurrency bugs, partial failures, and duplicate webhooks guarantee it eventually will be — there is no record of how it got there. Worse, a mutable balance cannot represent money in flight: funds that have left one account and not yet arrived at another have nowhere to exist, so systems built this way tend to make money vanish and reappear during failures.

Double-entry solves both. Every movement is recorded twice, from and to, so the system is self-checking, and intermediate accounts give in-flight money a place to sit where it is visible and auditable.

The schema

CREATE TABLE accounts (
  id            BIGSERIAL PRIMARY KEY,
  name          TEXT NOT NULL,
  type          TEXT NOT NULL CHECK (type IN
                  ('asset','liability','equity','revenue','expense')),
  currency      CHAR(3) NOT NULL,
  UNIQUE (name, currency)
);

CREATE TABLE transactions (
  id              BIGSERIAL PRIMARY KEY,
  idempotency_key TEXT NOT NULL UNIQUE,   -- the whole safety story
  description     TEXT NOT NULL,
  occurred_at     TIMESTAMPTZ NOT NULL,   -- when it happened
  recorded_at     TIMESTAMPTZ NOT NULL DEFAULT now()  -- when we learned
);

CREATE TABLE postings (
  id             BIGSERIAL PRIMARY KEY,
  transaction_id BIGINT NOT NULL REFERENCES transactions(id),
  account_id     BIGINT NOT NULL REFERENCES accounts(id),
  -- Minor units. Positive = debit, negative = credit. Never a float.
  amount         BIGINT NOT NULL,
  currency       CHAR(3) NOT NULL
);

CREATE INDEX ON postings (account_id, transaction_id);

There is no UPDATE and no DELETE on postings, ever. A mistake is corrected by writing a reversing transaction, which leaves both the error and the correction in the record — that is a feature, and it is what an auditor or a regulator will ask you for.

The invariant, enforced by the database

Debits must equal credits within every transaction and every currency. Enforce that in the database rather than in application code, because application code is where the exception always turns out to be.

CREATE OR REPLACE FUNCTION assert_balanced() RETURNS TRIGGER AS $$
BEGIN
  IF EXISTS (
    SELECT 1 FROM postings
    WHERE transaction_id = NEW.transaction_id
    GROUP BY currency
    HAVING SUM(amount) <> 0
  ) THEN
    RAISE EXCEPTION 'Unbalanced transaction %', NEW.transaction_id;
  END IF;
  RETURN NULL;
END;
$$ LANGUAGE plpgsql;

CREATE CONSTRAINT TRIGGER postings_balanced
  AFTER INSERT ON postings
  DEFERRABLE INITIALLY DEFERRED   -- checked at COMMIT, after all legs land
  FOR EACH ROW EXECUTE FUNCTION assert_balanced();

The deferred constraint is what allows a multi-leg transaction to be inserted row by row inside one database transaction and still be validated as a whole at commit. Without DEFERRABLE, the check fires after the first leg and every transaction fails.

Idempotency and money in flight

Payment processors retry webhooks. Clients retry on timeout. Queues deliver at least once. Without an idempotency key on transactions, every one of those retries doubles a payment. The unique constraint above turns a duplicate into a harmless no-op: catch the violation, return the existing transaction, done.

Money in flight is the other half. An outbound payment is not a single movement from user to outside world — it is two: user account to a clearing account when submitted, and clearing account to external when settlement confirms. If settlement fails, you reverse the second leg only. At any moment, the clearing account's balance tells you exactly how much money is in transit, which is a number operations teams need and mutable-balance systems cannot produce.

  • Never post directly between a user account and an external account; always route through a clearing or suspense account.
  • Fees are their own posting to a revenue account, not a silent reduction of the transfer amount.
  • Foreign exchange creates two currency-balanced legs joined by an FX gain or loss account — never one posting with two currencies.
  • Keep occurred_at and recorded_at distinct so late-arriving information does not corrupt historical reporting.

Derived balances and reconciliation

Balance is a query: the sum of postings for an account. That is correct by construction but slow at volume, so add periodic snapshots — a materialized balance as of a checkpoint — and compute the current balance as the snapshot plus postings since. The snapshot is a cache that can always be rebuilt from postings, never a source of truth.

  • Run a daily job asserting that every snapshot equals a full recomputation. A divergence means a bug, and you want to find it that day, not that quarter.
  • Reconcile against external truth — bank statements, processor settlement reports, on-chain balances — and post any genuine difference to an explicit reconciliation account rather than adjusting a balance quietly.
  • Alert on the age and size of unreconciled items; a growing suspense balance is the earliest signal that something upstream broke.
  • The accounting equation across all accounts must sum to zero at all times. Monitor it as a single number — if it is ever non-zero, stop and investigate before anything else.

Frequently asked questions

Is double-entry overkill for a small product?
No — it is cheaper at the start than after a discrepancy. The schema is three tables and a trigger, roughly a day of work. Retrofitting a ledger onto a system that has been mutating balance columns for two years means reconstructing history that was never recorded, which is often not possible at all.
Should I use a ledger database instead of Postgres?
Postgres handles the large majority of fintech ledger workloads well, with the enormous advantage that your team already knows how to operate, back up, and query it. Purpose-built ledger engines are worth evaluating at very high sustained transaction rates or when you need built-in cryptographic history verification for compliance reasons.
How do I store monetary amounts?
As integers in the currency's minor unit — cents, satoshis — with the currency stored alongside, or as a fixed-precision NUMERIC. Never as a floating-point type. Floats cannot represent common decimal values exactly, and the error compounds through aggregation until reported totals disagree with reality.
How do you handle crypto and fiat in the same ledger?
The same way as any multi-currency system: balance each transaction per currency, hold on-chain assets in accounts denominated in that asset, and bridge between currencies through explicit conversion legs with an FX account. The only extra work is treating confirmation depth as the settlement event that moves funds out of the clearing account.

Building something like this?

I'm Harsh Mittal — I build production systems across Web3, AI, and financial infrastructure: smart contracts and DeFi protocols, RAG pipelines and LLM agents, market data infrastructure, and the interfaces on top of them. If this is the kind of problem you're working on, I can help you ship it.