Most accounting software treats double entry as a convention the application is trusted to honour. TaxChad treats it as a property of the database. If a journal does not balance, it does not get written, and no amount of application code, refactoring or future carelessness can change that.
These are not validations in a form. They are constraints and deferred constraint triggers inside PostgreSQL. They fire at commit time, they apply to every write path including imports, migrations and any future integration, and they cannot be turned off by application code.
| Guarantee | How it is enforced |
|---|---|
| A journal balances, in both currencies Debits equal credits in the transaction currency and in the home currency, to within half a penny | trg_journal_lines_balance deferred, runs at commit |
| The whole client ledger balances The entire trial balance is re-totalled at commit and rejected if it does not foot. This is the balance sheet equation, enforced. | trg_client_trial_balance deferred, deduped per transaction |
| A journal cannot be empty | trg_journal_has_lines |
| A line cannot be both a debit and a credit | journal_lines_single_side |
| No negative amounts Direction is expressed by side, never by sign | journal_lines_non_negative |
| The home-currency amount sits on the same side as the original | journal_lines_base_side_matches |
| A journal line cannot point at another client's nominal A tenancy guarantee expressed as an accounting one | trg_journal_lines_client |
| One nominal code per client | chart_of_accounts_client_code_key |
| Opening balance journals are dated before the period starts | trg_opening_journal_before_period |
A bookkeeping product's worst failure is not an outage. It is a set of books that quietly stops adding up, is filed, and is discovered months later by HMRC. Every guard above exists because that class of bug was caught in this codebase, understood, and then made impossible rather than merely fixed.
Every invariant in the system carries an enforcement level, and there is a written policy for deciding it: if a rule is a truth about a single row, it belongs in the database. If it is an emergent property of a whole ledger, or legitimate mid-workflow state, it belongs on a dashboard as a warning and must never be forced into a constraint.
Some client books carry a genuine, explainable trial balance difference from uncategorised bank movements. The invariant is not that the figure is zero. It is that the figure is pinned, reported and explainable. Ledger Health surfaces it; the test suite asserts it matches the recorded value exactly.
Before any insert, the posting core mirrors the database guards in plain TypeScript so the user gets a readable sentence rather than a raw Postgres error. The pre-check is a courtesy. The database is the safety net, and the code says so in as many words.
A blocked posting is written to the guard log even if the user simply closes the dialog, so an attempt that never reached the database is still visible.
The insert path is proxied so that a rejection raised by Postgres is recorded with the same rule code as the application-side block. One rule, one code, whichever layer caught it.
The guard log is insert-only: no edits, no deletions, visible to the firm. It is the difference between "the software stopped me" and "here is exactly what was attempted, by whom, when, and which rule refused it".
Multi-currency was not retrofitted. Every journal line carries both the transaction amount and the home-currency amount, and both must balance. That single decision is what makes period-end retranslation a report rather than a rescue operation.
A primary and a secondary rate source, a nightly ingest job and a separate freshness monitor that raises an alert if rates go stale. HMRC's own monthly rates are refreshed on the first of each month for the returns that require them.
Monetary items retranslate at the closing rate, non-monetary items do not. The close posts a reversing journal, splits realised and unrealised differences to separate nominals, and blocks items that are out of scope rather than silently including them.
An opening balance is a home-currency fact and is never FX-converted. Differences arising from multi-currency opening positions post to a dated adjustment, not into the opening figure.
The regression suite runs seeded, offline books that no real client has produced yet: a sterling-home client with dollar and euro bank accounts and a dollar payment gateway, a euro-home client, VAT registered and not. Each scenario is also deliberately corrupted to prove that every invariant still fires.
Company, sole trader and landlord share a single chart of accounts. VAT registration is a boolean on the business record, not a different chart. This is the decision that makes the sole trader and landlord market reachable without a second product.
Journal headers and lines, chart of accounts, bank accounts, bank transactions and multi-line splits, fiscal years and period locks.
Sales invoices and lines, recurring schedules, purchase invoices, customers and suppliers, payment allocations, credit notes, receipts and expenses.
VAT periods and submissions, Corporation Tax computations, Self Assessment sources and disposal events, CIS deductions, dividends, fixed assets and depreciation schedules.
Firms, firm members with scope, clients, client members with role, protected members, groups, invitations and onboarding progress.
Activity log with full snapshots and revert, deletion log, guard log, credential access log, assistant tool call log, HMRC call log, payments audit, protection log.
Backup runs, error events, content security policy reports, usage metering, subprocessor register, consent records, data requests, retention rules, incidents, support tickets.
Four views, 246 database functions and 595 indexes. Reporting is derived from the ledger on read rather than maintained as a second set of stored balances, which is the standard way for numbers to diverge.
Every schema change is a numbered migration in the repository, applied in order, recorded in the database. A backup manifest records the exact migration set a snapshot was taken at, so a restore rebuilds the schema before loading a single row.
The convention is that a migration creating an integrity guard also demonstrates the guard rejecting the thing it is meant to reject, inside the migration. A guard that has never refused anything is not a guard.
Ledger, VAT, HMRC, tax, FX, payroll and golden-fixture paths are listed in a protected paths file. Touching them requires a real commit message and an entry in an accounting change ledger, and both are checked in CI.
Captured real client books are replayed through the reporting core on every push. Regenerating or rebaselining them is forbidden, including to make a failing test pass. A sign-off manifest is checked by CI.