Glossary term

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_balances materialized 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_rate field 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 documented compensating_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

Double-Entry Ledger FAQ

Common questions

What is a double-entry ledger in fintech?
A double-entry ledger is a data model where every value movement is stored as two balanced entries — one debit and one credit — against two different accounts. The sum of debits equals the sum of credits for every transaction, and the sum of all entries across the ledger reconciles to zero. In fintech, it is the foundational structure that makes balances trustworthy, audits possible, and reconciliation deterministic.
Why do fintech platforms need double-entry instead of single-entry?
Single-entry tracks balances. Double-entry tracks movement. When a regulator asks where a specific dollar went, double-entry answers with the debit account that lost it and the credit account that received it. Single-entry cannot. Once you have to support reversals, fees, partial captures, or multi-party settlements, double-entry is the only structure that holds up.
How is a double-entry ledger different from append-only?
Double-entry is about the shape of each transaction (paired debits and credits). Append-only is about how entries are stored (insert only, never update or delete). Production ledgers are typically both: every transaction is double-entry, and every entry is appended forever. The combination is what makes balance reconstruction at any historical timestamp possible.
How do reversals work in a double-entry ledger?
Reversals are recorded as a new pair of compensating entries that exactly negate the original transaction. The original entries are never updated or deleted. This means the historical record always shows both the original event and its reversal, with timestamps and reason codes attached, which is exactly what auditors and regulators want to see.
Can I use Postgres or do I need a specialized ledger database?
Postgres is enough for most fintechs through Series B. The right schema is a transactions table plus an entries table with a constraint that pairs sum to zero per transaction, plus triggers or service-layer guards preventing UPDATE and DELETE. Specialized ledger databases (TigerBeetle, Modern Treasury, Formance) earn their keep above ~10K transactions per second or in multi-party marketplace settlement, but Postgres covers the vast majority of cases cleanly.

Let's build it together

Building something that depends on this?

The glossary is the short version. The custom analysis happens on the strategy call.