Skip to content
PreviewThe Windows download and the public source are in preparation. Request early access.
9400How it works

How double-entry is enforced

Two locks, the Postgres triggers by name, why posted entries never change, and how exact money is stored.

Shipped8 min readChecked October 26, 2026

Most accounting software checks that an entry balances in the application. Analytical Ledger checks it there, and checks it again inside PostgreSQL, where a buggy import script, a migration or a direct SQL session cannot get around it.

Two locks

Every posting rule is enforced twice, by code that does not trust the other copy.

RuleIn the appIn Postgres
An entry has at least two lines, and debits equal creditssrc/lib/ledger/post.tstrg_je_balanced
A line's account belongs to the line's own entityassertAccountsMatchEntity in post.tstrg_jl_entity_account
You can only post into an open periodThe period check in postEntrytrg_je_period_open
A posted entry never changesThere is no edit or delete path for a posted entrytrg_jl_immutable, trg_je_guard

The app lock gives people a clear message at the point they make the mistake. The database lock covers everything that never touches the app: the importer, backfill scripts and a person typing SQL.

A line that crosses entities is the quiet one. It still balances, so nothing else would catch it, but it would silently misstate two entities' trial balances. That is why the entity rule has its own trigger.

The triggers, by name

These are the guards in scripts/sql/triggers.sql, installed by npm run db:extras after the migrations.

TriggerWhat it refuses
trg_je_balancedPosting an entry with fewer than two lines, or whose debits and credits differ.
trg_jl_immutableChanging the lines of a posted or void entry.
trg_je_guardDeleting or editing a posted or void entry. A posted entry can only be voided, and void is final.
trg_je_period_openPosting an entry into a period that is not open.
trg_je_actor_guardAttribution that is not in the canonical form, posting without both a creator and a poster, and system provenance that does not match the entry's source.
trg_jl_entity_accountA journal line whose account belongs to a different entity. Intercompany is not an exception: both sides post to their own accounts and link through the counterparty.
trg_recon_lock_guardDeleting or editing a locked reconciliation. It can only be reopened, and the reopening is audited.
trg_recon_line_guardClearing or un-clearing a line in a locked reconciliation.
trg_account_parent_guardA chart-of-accounts parent link that breaks the account hierarchy's rules.
trg_assistant_approval_guardAn agent's governed action running without a recorded human approval, and a finished approval moving again.
trg_attachment_entity_guardA document that crosses an entity boundary, or moves between entities.
trg_je_attachment_entity_guardLinking a document to a journal entry of another entity.
trg_entity_owner_pct_guardOwners of an entity who total more than 100 percent.

Import staging has its own guards for split transactions: a split is one root row with two or more legs, and its legs must add up exactly before either side can be approved or posted.

Schema and triggers are a pair. A change to posting or balance logic in the schema means re-checking the trigger file, and the tests run against a real Postgres with the real triggers applied.

Reversing entries

A posted entry is never edited or deleted. If it was wrong, you add to the record.

  1. Open the posted entry and choose Reverse.
  2. Ledger creates a new entry with every line flipped, debits to credits and credits to debits, and posts it as its own entry.
  3. The original is linked to its reversal. Both stay on the record, so the trail shows what happened and when.
  4. Post the correct entry.

An entry can be reversed once. Only a posted entry can be reversed. The reversal is dated like the original, so if that month is closed, post a correcting entry in an open month, or reopen the period first. Your first month-end close walks through the choice.

Money is never a float

Amounts are stored as NUMERIC(20,4) and travel through the app as decimal strings. All arithmetic goes through one module, src/lib/money.ts, built on decimal.js at precision 40 with banker's rounding. Nothing converts a money value to a JavaScript number, except a chart's display layer, which is the one sanctioned float boundary.

The period close adds up debits and credits in SQL as exact decimals and compares them with an exact equality, not a tolerance.

This site follows the same rule: the sample books on the home page are decimal strings summed in cents.

What agents cannot touch

An agent connected through MCP has two scopes. ledger.read reads. ledger.draft adds al_journal_create_draft, which creates an unposted draft. An agent can never call al_journal_post_draft, al_journal_approve or al_users_list, and trg_assistant_approval_guard stands behind that in the database.

Drafts wait for a person. See Connect your agent.

What this does not prove

These controls make an unbalanced, mis-owned, out-of-period or edited posting impossible. They do not prove that a person chose the economically correct account or amount.

Hence reconciliation to the bank, the close checklist and your own review. PostgreSQL records and uploaded documents are not encrypted by the application; use disk, database and backup encryption suited to where you run it. A truncate by a database administrator is outside what a row trigger can stop, and it is not a substitute for backups. See Backups and restore.

How double-entry is enforced | Analytical Ledger