Double-Entry Ledger
A double-entry ledger records every value movement as two balanced entries — a debit and a credit — so the books never drift, balances reconstruct deterministically, and auditors can trace any number to its source.
What a double-entry ledger is
A double-entry ledger is the foundational data model behind every audit-ready fintech platform. It treats every value movement as a pair of balanced entries — one debit and one credit — recorded against two accounts in the same transaction. The key property is that the sum of debits equals the sum of credits for any transaction, and the sum of balances across the ledger always reconciles to zero. That property is what makes the books trustworthy.
Single-entry systems track totals on one side of a transaction. Double-entry systems track movement, which is the only structure that survives an audit. When a regulator asks “where did this $100 go,” a double-entry ledger system answers in one query: it shows the debit account that lost the $100 and the credit account that received it, with timestamps, source references, and the audit trail attached.
Why fintech platforms depend on it
Every modern payment platform — payouts, wallets, escrow, marketplace settlement — needs a record of money movement that survives reconciliation, regulator inspection, and edge cases like reversals, partial captures, and fees deducted at different intervals. A double-entry ledger encodes the rules so reversal is just another pair of entries (a “compensating transaction”), not an in-place mutation that erases history.
Engineers who build payment systems without a double-entry ledger usually ship the rest of the platform fast and then discover, six months in, that they cannot answer questions like “what was the balance on March 3 at 11:42 UTC” or “which transactions are causing the discrepancy in the daily reconciliation report.” Those failures are nearly always solved by adopting double-entry semantics, often by retrofitting a ledger service into the existing platform.
How engineers model double-entry in production
The minimum viable production schema is two tables: transactions (one row per business event) and entries (two or more rows per transaction). Each entry references an account and an amount, signed positive (debit) or negative (credit), and a transaction id. A foreign key constraint and a balanced-pair invariant ensure no transaction can be inserted without paired entries. Some teams encode this as a database trigger, others as a service-layer guarantee with strict tests.
A few production decisions matter. First, accounts are not user-facing concepts — they are internal buckets, often nested by type (asset, liability, revenue, expense, equity). User-facing balances are computed by summing entries against a chosen account. Second, ledgers should be append-only: never update an entry, never delete a transaction, always insert a compensating one. Third, idempotent ingestion via idempotency keys is essential — retries must not double-write entries.
Code-shape example
A minimal production-grade schema in Postgres:
CREATE TABLE accounts (
id BIGSERIAL PRIMARY KEY,
type TEXT NOT NULL CHECK (type IN ('asset','liability','revenue','expense','equity')),
name TEXT NOT NULL,
parent_id BIGINT REFERENCES accounts(id),
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE transactions (
id UUID PRIMARY KEY,
idempotency_key TEXT UNIQUE NOT NULL,
description TEXT NOT NULL,
occurred_at TIMESTAMPTZ NOT NULL,
reason_code TEXT,
source_ref TEXT,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE entries (
id BIGSERIAL PRIMARY KEY,
transaction_id UUID NOT NULL REFERENCES transactions(id),
account_id BIGINT NOT NULL REFERENCES accounts(id),
amount_minor BIGINT NOT NULL, -- positive = debit, negative = credit
currency CHAR(3) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Pairs sum to zero per transaction per currency.
CREATE OR REPLACE FUNCTION assert_balanced_transaction()
RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN
IF EXISTS (
SELECT 1 FROM entries
WHERE transaction_id = NEW.transaction_id
GROUP BY currency
HAVING SUM(amount_minor) <> 0
) THEN
RAISE EXCEPTION 'transaction % is unbalanced', NEW.transaction_id;
END IF;
RETURN NEW;
END;
$$;
A typical write looks like this:
// Money in to a customer wallet from an external processor settlement.
await ledger.post({
idempotencyKey: `payout-${event.id}`,
description: "Customer wallet topup from Stripe",
occurredAt: event.timestamp,
entries: [
{ account: "stripe-clearing", amountMinor: -10_00, currency: "USD" },
{ account: `wallet-${userId}`, amountMinor: +10_00, currency: "USD" },
],
});
The service rejects the write if the entries do not balance. The idempotencyKey makes retries safe.
How we implement it at Dashhold
In every fintech engagement, the ledger is the first service we design. A typical Dashhold implementation has these properties:
- Postgres + Drizzle or Prisma + a thin ledger SDK. No heroic ORM tricks. The ledger SDK exposes one method,
post(), that takes an idempotency key and the entries array. Every other write path in the application goes through it. - Account hierarchy modeled as adjacency-list with materialized roll-ups. A
view_account_balancesmaterialized view recomputes per-account totals every minute, so balance reads are O(1) and consistent with last-completed-minute state. - Reconciliation as a scheduled job, not an on-demand query. Daily we run a job that compares the ledger’s settlement-account totals against processor (Stripe, Adyen, Modern Treasury) settlement reports. Discrepancies open a ticket automatically.
- Multi-currency by default. Every entry has a currency. Cross-currency entries use a
transaction_fx_ratefield captured at the moment of conversion; balances roll up per currency, not converted. - Append-only enforced at three layers. Postgres triggers prevent UPDATE/DELETE on
entries, the SDK does not expose update methods, and admin scripts must use a documentedcompensating_entry()helper that produces a new pair, never mutates.
The combination of these patterns is what keeps the books explainable through audits and regulator reviews. We have shipped this shape in production at platforms processing $200M+/year, and the same pattern scales down cleanly to a Series A neobank doing $5M/year.
Common pitfalls
- Storing amounts in floating-point. Always use integer minor units (cents, paise, kobo) per currency. Floating point causes off-by-one accounting drift that shows up at the worst possible moment.
- Mixing the ledger with the business event log. The ledger is a record of money movement, not a record of orders. Keep them in separate services even if they share a database.
- Updating entries in place during data backfills. Never. Backfills emit compensating entries and a reason code. Mutating entries silently breaks every audit story.
- Not enforcing balanced-pair invariants in code AND the database. Belt-and-braces. If only the application enforces it, an admin script will eventually bypass it.
- Putting reporting queries on the live ledger. Reporting reads should hit a replica or a derived warehouse. The ledger primary should only see write traffic and short-lived balance reads.
- Allowing decimals in account ids or transaction ids. UUIDs everywhere. Sequential ids leak volume to anyone who sees them.
Where it sits in a fintech platform
A double-entry ledger lives behind the orchestration layer that talks to payment processors, the wallet that surfaces balances, and the reconciliation jobs that compare ledger totals to processor settlements. It is the source of truth for accounting, regulatory reporting, and product features that depend on accurate balances. Teams that get the ledger right early save months of cleanup work later — and the studio’s fintech development practice ships ledgers as the structural foundation of every payment platform engagement.
See also
- Append-only ledger — why every entry is an insert
- Idempotency key — the primitive that keeps writes retry-safe
- Designing fintech ledgers — the studio’s longer field guide
- Payment platform — where the ledger sits in the broader stack