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.
| Rule | In the app | In Postgres |
|---|---|---|
| An entry has at least two lines, and debits equal credits | src/lib/ledger/post.ts | trg_je_balanced |
| A line's account belongs to the line's own entity | assertAccountsMatchEntity in post.ts | trg_jl_entity_account |
| You can only post into an open period | The period check in postEntry | trg_je_period_open |
| A posted entry never changes | There is no edit or delete path for a posted entry | trg_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.
| Trigger | What it refuses |
|---|---|
trg_je_balanced | Posting an entry with fewer than two lines, or whose debits and credits differ. |
trg_jl_immutable | Changing the lines of a posted or void entry. |
trg_je_guard | Deleting or editing a posted or void entry. A posted entry can only be voided, and void is final. |
trg_je_period_open | Posting an entry into a period that is not open. |
trg_je_actor_guard | Attribution 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_account | A 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_guard | Deleting or editing a locked reconciliation. It can only be reopened, and the reopening is audited. |
trg_recon_line_guard | Clearing or un-clearing a line in a locked reconciliation. |
trg_account_parent_guard | A chart-of-accounts parent link that breaks the account hierarchy's rules. |
trg_assistant_approval_guard | An agent's governed action running without a recorded human approval, and a finished approval moving again. |
trg_attachment_entity_guard | A document that crosses an entity boundary, or moves between entities. |
trg_je_attachment_entity_guard | Linking a document to a journal entry of another entity. |
trg_entity_owner_pct_guard | Owners 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.
- Open the posted entry and choose Reverse.
- Ledger creates a new entry with every line flipped, debits to credits and credits to debits, and posts it as its own entry.
- The original is linked to its reversal. Both stay on the record, so the trail shows what happened and when.
- 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.