By the Boongon team
Most business apps implement approvals in the UI. The "Post" button is disabled until the document is approved, and that's that. It works until the day someone posts the same document through an import, an API call, a background job or a new screen that forgot the check.
In accounting software that's a control failure, not a cosmetic bug. Here are the design principles we think matter when approvals must be enforced, not just displayed.
Principle 1: A hidden button is not a control
If the rule lives in a front-end component, it only binds people who use that component. The rule needs to sit at the narrowest point every write passes through: as close to the data as you can get.
In practice that means the "can this document be posted?" check runs in the same transaction that changes the document's status, and it runs no matter which code path initiated the change. A useful mental test:
If a new developer adds a new endpoint next year and forgets about approvals entirely, does the rule still hold?
If the answer is "no", the control lives in the wrong layer.
Principle 2: Model the approval as state on the document
Treat approval as part of the document's lifecycle, not a side table someone might consult:
draft -> submitted -> under_approval -> approved -> posted
|
v
rejected -> draft
Two invariants do most of the work:
- A document can only move to
postedfromapproved. - A document that is
under_approvalor later cannot have its financial fields changed.
The second one is easy to miss. If the amount can be edited after the last sign-off, the approval refers to a document that no longer exists. Locking the fields once approval starts means "who approved what" stays meaningful.
Principle 3: Rules are data, matched at submission
Approval rules usually look like this:
document_type = purchase_invoice
AND amount > threshold
AND cost_centre = X
=> steps: [site_manager, commercial, finance_director]
Store rules as data, and when a document is submitted, resolve which rule applies and snapshot the resulting steps onto that document. Snapshotting matters: if someone changes the rule tomorrow, documents already in flight should finish under the rule they started with, and your history should show which rule version applied.
Assign steps to roles, not people.
Principle 4: One ledger, so there's only one door
Approvals get much harder when the business event and its accounting consequence live in different systems. If the purchase invoice lives in one app and the journal in another, you need the approval rule in both, or a sync that can't race.
A single ledger, where posting the invoice is creating its journal in the same transaction, means there's one door to guard. It also gives you traceability for free: a profit and loss line opens to the journal, which opens to the invoice, which opens to its approval steps.
Principle 5: Never edit posted data, reverse it
Once something is posted, updates should be refused. Corrections are new, linked entries that reverse the original, and both stay visible. This keeps the approved record intact and makes the correction itself subject to the same rules.
Principle 6: Make the history tamper-evident
An audit log in an ordinary table can be edited by anyone with enough privileges. A simple improvement is to chain entries: each row stores a hash of its own content plus the hash of the previous row.
row_hash = H(actor, action, record_id, timestamp, payload, prev_row_hash)
Altering or deleting a row breaks every hash after it, and a periodic verification job can report "chain intact" or point to where the break is. It won't stop someone rewriting the whole chain, but it makes silent edits detectable.
Principle 7: Automation is just another caller
Imports, integrations, scheduled jobs and now AI assistants are all just callers. They should hit exactly the same enforcement as a person clicking a button. A clean pattern for AI in particular:
- It can read only what the requesting user is permitted to read.
- It can draft documents, which enter the normal lifecycle as
draft. - It can never post. Drafts go through the same approval rules as anything a human raised.
If your enforcement lives at the data layer, you get this almost for free. If it lives in the UI, every new automation is a new hole.
Testing it
The test suite that gives us the most confidence isn't a UI test. It's a set of tests that try to post an unapproved document through every write path you have (each endpoint, the importer, each job) and assert that every one is refused with the same error. When a new path is added, it goes into that list.
Wrapping up
Enforced approvals come down to a few decisions: enforce at the data, lock what was approved, snapshot the rule, keep one ledger, reverse instead of edit, chain the history, and treat every automation as an ordinary caller.
We build Boongon, a cloud ERP where approvals are enforced where the data lives, on one ledger. We'd be interested to hear how others handle approval enforcement, especially across imports and integrations, in the comments.
The Boongon team
Top comments (0)