Pakarti pakarti.id
All posts

A trial balance that balances but is wrong

The dangerous accounting bug is not the one that throws an error. It is the one that reconciles.

Every accounting system checks that debits equal credits. It is the first thing anyone implements and the easiest thing to test. It is also close to worthless as a safety property, because the failures that actually hurt a business all pass it.

An entry posted to the wrong account balances. An entry posted in the wrong period balances. An invoice quietly dropped from an export leaves books that still balance. A rounding remainder pushed into an adjustment account balances — that is what plugging is for. In every one of those cases the trial balance is fine, the reports render, and the figure that reaches the tax return is wrong.

So the question we designed around was not how to make the ledger balance. That is table stakes. It was: what can we make impossible?

The ledger is append-only, and the database enforces it

Triggers refuse any update or delete on a posted entry or line, from any code path, whoever is asking.

That distinction matters more than it sounds. Plenty of systems have an append-only ledger in the sense that no screen offers a delete button. That is a property of the interface, and it survives exactly as long as nobody writes a migration script, a bulk correction, or an admin tool. Append-only as a property of storage means the database says no.

There is a break-glass switch that lifts the trigger. It is deliberately not granted to the application’s database role, so reaching it requires a superuser and a decision. Using it is an incident with a written record, not a workaround.

Balance is a deferred constraint, not an application check

An entry is assembled line by line. If the balance check ran after every statement, no multi-line entry could ever be written at all. So the constraint is deferred to commit: the intermediate states are permitted, and the final state is not optional.

An application-level check is a check that one code path performs. A deferred constraint is one the database performs on every transaction — including the ones written next year by someone who never read this article.

Corrections are entries, not edits

There is no unpost. A posted document that turns out to be wrong is corrected by a reversing entry dated in an open period — never on the original entry’s date, which would rewrite a period that may already have been filed.

The interface can still offer “edit” on a posted invoice. Behind it, the service writes a reversal and a new entry. The user gets the affordance they expect; the ledger gets the history it requires. Those are not in conflict, as long as nobody confuses the two.

No posting rule may plug a difference

This is the rule that takes the most discipline to hold. When an entry will not balance, the pull toward routing the remainder somewhere — rounding, suspense, anywhere — is enormous, because it makes the operation complete and the screen turn green.

Plugging always looks like a fallback. It is always a defect: it converts a loud failure into a silent one that someone will unpick from a year of transactions.

If an entry will not balance, the rule or the configuration is wrong. It fails.

One posting engine, and every report is a projection

No subledger writes a journal line. Receivables, payables, inventory and fixed assets emit documents; a single posting engine turns a document into a balanced entry. That is what keeps “which code path produced this entry” an answerable question.

And every report is a projection over journal lines. Nothing recomputes a figure the engine already computed. When a report disagrees with the ledger, the report is wrong — which sounds obvious until you meet a system where the balance sheet and the general ledger are maintained by different code and nobody can say which to believe.

Money never becomes a float

Fixed-point decimal, end to end. Not mostly: a decimal that turns into a floating-point number in a comparison, a sort, a query filter or an export path is a defect and is treated as one. Rounding points are statutory, applied at the edge, and never moved without a citation.

What this actually buys

Not correctness. No architecture gives you that. What it buys is that the remaining ways to be wrong are loud. A wrong mapping produces a visibly wrong report. A missing tax rate throws. An unbalanced rule fails at commit.

The class of bug that survives all the way to an audit — reconciling, plausible, quietly wrong — has had its main habitats removed.

The system this came from Akunta — double-entry accounting and tax Next Postgres was hiding it, not Django