Automating construction progress billing under ZATCA Phase 2
Advance recovery and retention generated automatically on every progress invoice, at the correct accounts and VAT treatment — after testing proved the original design never fired in the real workflow.
- Client
- Construction contracting firm, Saudi Arabia
- Sector
- ERP
- Stack
- Odoo 19 Enterprise · Python · XML-RPC · ZATCA Phase 2 · l10n_sa
- Year
- 2026
The problem
Construction contracts in Saudi Arabia do not bill like normal sales. A contract runs for months, and three streams of money move at once.
The customer pays an advance up front — a liability, not revenue. Work is billed in progress invoices as it completes. And each progress invoice recovers a slice of that advance while withholding a retention, typically 10% each, released only after the defect liability period ends.
Every movement lands in a different account, carries a different VAT treatment, and still has to produce an invoice ZATCA will clear. Retention is withheld at 0% VAT. Advance recovery is not revenue at all. Get the mapping wrong and the VAT return stops matching the ledger.
The client's team did this by hand: three lines typed per invoice, two percentages worked out on a calculator, and a rule to remember — advance recovery must stop once the advance is exhausted rather than running negative. It worked, because the accountant is good. It was also one distracted afternoon away from a wrong number reaching a tax authority.
What I did first
I did not start by writing automation. I started by proving the manual flow was correct.
I mapped the existing chart of accounts and product configuration against what the accounting actually required, then documented the end-to-end flow — advance, progress, retention release — as a step-by-step guide the accountant worked through by hand. That produced known-good figures: a 40,500 progress invoice nets to 37,867.50 with 5,467.50 VAT, and the journal entry balances across Receivable, Contract Liability, Retention Receivable, Revenue and VAT.
Those hand-verified numbers became the acceptance criteria for everything afterwards. Automation that produces a different total is not automation, it is a new bug.
That phase also surfaced a finding the client did not know about: a VAT misconfiguration in which sales tax had been booking to a purchase tax account. Invisible on the invoices, visible in the return.
What I built
Three custom fields and one automation rule, through Odoo's native automation layer over XML-RPC rather than as a bolt-on module.
| Field | Behaviour |
|---|---|
| Advance Recovery % | Per contract, defaults to 10 |
| Retention % | Per contract, defaults to 10 |
| Advance Outstanding | Computed, read-only — how much advance is left |
On a progress invoice the user now enters one product and one price. The recovery and retention lines generate automatically at that contract's own percentages, against the correct accounts and taxes. Recovery is capped at the outstanding advance, so the final progress invoice recovers only what remains and later invoices carry retention alone.
Same accounts, same taxes, same totals as the hand-verified flow. Only the typing is gone.
The part that was actually hard
The plan was wrong, and testing is what proved it.
The automation never fired in the real workflow. My design assumed progress invoices were created from the sale order, so the contract could be resolved through the order line link. But the documented workflow deliberately creates progress invoices standalone — because invoicing from the sale order deducts the whole advance at once and adds no retention. Standalone invoices carry no order link. The automation was silently skipping every real invoice.
An earlier automated suite had passed 15 out of 15 against that code. It passed because the fixtures wired the order link in by hand — a flow no actual user produces. Green tests, dead feature.
I rebuilt contract resolution as a three-step chain: order link, then the invoice origin stamp, then the customer's single open construction contract — with a deliberate refusal to guess when two contracts are candidates.
The advance always calculated as zero. Odoo's down-payment wizard writes an invoice line with no product and an is_downpayment flag. My code counted advances by product ID, so it found nothing, every time.
And the one that mattered most. An invoice posted with line subtotals of −3,500 and general ledger balances of +2,200. The invoice looked correct on screen; the journal entry behind it disagreed with it. That is the worst class of accounting bug — silently wrong, and downstream of the thing anyone would check.
The cause is architectural. The automation fires inside Odoo's dynamic-line synchronisation during invoice write, and inside that context four separate approaches to modifying an existing line each fail differently: updating the price leaves the GL balance stale, writing the balance explicitly raises "entry is not balanced," and unlinking crashes Odoo's own recompute mid-write.
The conclusion, reached by exhausting the alternatives rather than by guessing: this layer can safely insert lines at invoice creation, but cannot safely update or delete them. I set the rule to insert-only, moved the trigger to creation time, and documented the constraint instead of papering over it.
The reason the corrupt entry was caught at all is an assertion the earlier tests did not have: every product line's GL balance must mirror its subtotal. Asserting the invoice total is not enough. The total was right. The ledger was not.
How it was verified
- Clean-room reinstall — every artefact reverted to zero, reinstalled from the installer script, full walkthrough re-run: 13/13.
- Real workflow only — a real contract, the real down-payment wizard, genuine standalone progress invoices. No synthetic fixtures.
- GL consistency asserted per line, not just on invoice totals.
- The advance cap exercised, not assumed — a deliberately oversized 400,000 progress invoice capped recovery at exactly the remaining advance.
- Live system provably untouched — credentials scoped to the test database and rejected by production, and every script asserts its target host before connecting.
What the client got
A working automation on the test database, a one-command revert, an installer that reproduces the state from scratch, and two documents: an internal record of what was touched and why, and a plain-English testing guide for the accountant.
That guide states the insert-only limitation openly, in its own section, with the workaround — if you change a progress price, delete the draft and redo it. Better the client learns that from me in week one than finds it themselves in month two.
What is deliberately not done yet
Live deployment. The automation is proven on test. Moving it to production is a separate scheduled change with its own verification pass, not something to bundle into the build.
Removing the insert-only limitation. This is the honest ceiling of a no-code automation layer. Recalculating deduction lines when a progress price changes needs a real Python module with an onchange handler, which means moving off Odoo Online to a platform that permits custom code. That is a genuine cost, and whether the limitation justifies it is the client's call. I scoped it rather than sold it.
Retention release. Retention accumulates in a dedicated receivable account; the release invoice at the end of the defect liability period is still manual, and is the natural next automation.
Down-payment tax configuration. Testing surfaced that the Down Payment product carries no tax while the system refuses to post an untaxed line. I worked around it in test scripts only and left the live configuration alone, pending the client confirming how they handle it today. Fixing a live configuration you do not yet understand is how you create the next incident.
Why it mattered
Automation in accounting is not a scripting problem. It is a correctness problem wearing a scripting problem's clothes.
The interesting work was not writing the rule. It was establishing what the platform could and could not guarantee, proving the original design was dead in the real workflow, and writing a test that asserted the thing that mattered rather than the thing that was easy to check.
The deliverable includes the limitation in writing. A client who knows the boundary can work inside it. A client who does not will eventually post a wrong invoice to a tax authority.
- Clean-room walkthrough, after rebuild
- 13/13
- Earlier suite passing on a dead feature
- 15/15
Have a system that needs to work in production?
Tell me what's breaking — or what you're building.