Data Guard
Protect data, not block operations — ORM-level document protection for Odoo 19
The Problem
Odoo lets users freely edit Sales Orders after delivery, modify Purchase Orders after receipt, reset posted invoices to draft, and reset payments to draft even within a locked fiscal period — creating silent data mismatches between your orders, warehouse, and books.
You need protection that guards the data without locking down your workflow.
Two Anchors, One Guard
Data Guard uses two different anchoring strategies tailored to how each business area actually operates:
- Sales & Purchase Orders — anchored on actual delivered / received quantity per line.
- Accounting documents — anchored on the fiscal lock date.
Daily operations stay free and unobstructed, while improper edits to completed data are blocked at the ORM level — not just hidden in the UI.
Settings
One toggle in General Settings. Enable to activate all protections. Disable temporarily for administrator-level corrections, then re-enable. No module reinstall required.
Sales & Purchase Order Protection
Protection is per-line, based on actual stock movement quantity (qty_delivered / qty_received). Only lines with qty > 0 are locked. Cancel, reset to draft, adding new lines, and editing undelivered lines remain fully available. If a warehouse return brings the qty back to zero, protection lifts automatically.
Per-line protection matrix
| Action | Line with qty > 0 | Line with qty = 0 |
|---|---|---|
| Edit product, UoM, unit price | Blocked | Allowed |
| Reduce quantity | Cannot go below delivered/received | Allowed |
| Increase quantity | Allowed | Allowed |
| Delete line | Blocked | Allowed |
| Add new lines | Always allowed | |
| Cancel / Reset to Draft | Always allowed | |
| Edit partner, currency (header) | Blocked when ANY line has qty > 0 | |
Stock Picking Source Enforcement
Receipts and deliveries containing storable products must originate from a Purchase Order, Sales Order, or a valid return. Manual pickings without a source document are blocked at validation — so every physical movement stays anchored to an order, which is what makes the invoiced-vs-physical gap below meaningful. Consignment receipts (with an Owner assigned) and internal transfers are exempt.
Return Limitation
Repeated returns on the same document are a common source of phantom stock and cascading data errors. Data Guard enforces two strict rules to prevent this:
| Rule | Behavior |
|---|---|
| One return per document | Each delivery or receipt can only be returned once. A second return attempt is blocked. |
| No return of return | Return documents cannot be returned again. If goods need to go back out, create a new delivery or receipt instead. |
| "Update quantities on SO/PO" required | A return with to_refund unticked does not decrement received/delivered qty, so the returned goods vanish from the physical quantity and silence the gap. Validating such a return is blocked. |
| Manager override | Users in the Data Guard: Manager group can bypass all three rules (partial & repeat returns, or a deliberate non-refund return) when needed. |
Journal Entry & Invoice Protection
Posted invoices, bills, and credit notes within the fiscal lock period cannot be reset to draft. Invoices and bills also show a warning banner and list highlight when a line links to an order carrying a quantity gap (see below).
Invoiced-vs-Physical Quantity Gap
Every physical movement is anchored on an order; every anchored quantity
should have a posted invoiced counterpart. The number in
between is suite_qty_gap — a stored, signed quantity gap
on each order line, anchored on the stock side:
purchase.order.line:posted billed qty − qty_receivedsale.order.line:qty_invoiced_posted − qty_delivered
computed for stock-tracked products only. Posted invoices only:
under Odoo 19 perpetual valuation the inventory GL entry is created
“at invoicing” (when the bill is posted, not when goods move), so
a draft bill posts no GL and must not close the gap — otherwise
“goods shipped, bill sitting in draft” would silently read zero.
The Sales side reuses Odoo’s native qty_invoiced_posted;
the Purchase side has no native posted-only field, so the module builds one.
Apart from that single documented exception it subtracts native fields and
never re-aggregates, and never branches on the billing-control policy. The
gap is a raw signed number, never summed or absolute-valued.
Severity keys on physical completion, not on quantity. A line is complete when no stock move on it is still pending (every move done or cancelled) — exactly how Odoo’s own receipt/delivery status works. Until then a gap is normal “in transit”; once complete it is structural, because only a document can still close it.
| Status | Condition | Meaning |
|---|---|---|
| Open exposure 🟡 | gap ≠ 0, line not yet complete | Normal in-transit gap — will self-resolve as goods / documents catch up |
| Over-invoiced 🔴 | gap > 0, line complete | Billed / invoiced beyond physical — act now |
| Received not billed 🔴 | PO, gap < 0, line complete | Goods consumed before the bill lands — permanent stock-account residue |
| Delivered not invoiced 🔴 | SO, gap < 0, line complete | Revenue leakage — no external self-healing mechanism |
Order lists colour only the structural (red) statuses — open exposure is left uncoloured so red keeps its signal value. The order line shows the signed gap so you can pinpoint the offending line. Saved filters drive a month-end worklist: over-invoiced, received-not-billed / delivered-not-invoiced, plus an “open exposure > 30 days” ageing filter for in-transit gaps that have sat too long. The invoice or bill shows a single uniform yellow heads-up banner that fires only for a structural gap on the linked order — which another invoice on the same order may have caused — so always open the order to locate the exact line and direction. Because the invoice-side flag is non-stored, it is read on demand per displayed row and triggers no whole-database recompute on receipts, deliveries or returns. Price differences (bill price vs order price) are intentionally not checked — under AVCO/FIFO the bill price becomes cost and the stock move is re-valued, so GL and subledger stay in sync.
Payment Protection (Native Gap Fix)
Odoo 19 decoupled payments from journal entries — native lock dates do not prevent resetting a payment to draft. This module closes that gap: any non-draft, non-canceled payment within the lock period cannot be reset to draft (covers in_process, paid, and rejected states).
Bank Statement Reconciliation Guard
Deleting a reconciled bank statement transaction with the ordinary delete action only removes the line’s own journal entry — it does not undo the reconciliation. The matched payment or invoice keeps its reconciled state while its counterpart entry vanishes: the payment reverts to “in process” and is orphaned, and the invoice stays flagged as paid against a move that no longer exists. Native Odoo only blocks deleting a line inside a valid, complete statement, so a bare reconciled line slips through.
Data Guard blocks deletion of any reconciled transaction (full or partial, or one carrying an auto-generated payment) and asks you to undo the reconciliation first — the Reset button cleanly reverses the match and removes auto-generated payments. Un-reconciled lines can still be deleted freely.
Lock Date Protection & Dashboard
All five lock date fields are protected while Data Guard is enabled: fiscalyear_lock_date, tax_lock_date, sale_lock_date, purchase_lock_date, hard_lock_date.
Only users in the dedicated "Data Guard: Manager" group can modify these fields. This group is domain-neutral and independent — it is NOT inherited by any accounting or inventory role and must be assigned explicitly. A Lock Date Dashboard under Accounting > Configuration shows all current lock dates in one place; its menu is restricted to the group and nested under Accounting, so a user without accounting access never sees it.
Full Protection Summary
| Document | Trigger | What Is Protected | What Is NOT Blocked |
|---|---|---|---|
| Sales Order | qty_delivered > 0 | Header: partner, currency, company. Line: product, UoM, price. Qty floor. No delete. | Cancel, reset to draft, add lines, edit undelivered lines, increase qty |
| Purchase Order | qty_received > 0 | Header: partner, currency, company. Line: product, UoM, price. Qty floor. No delete. | Cancel, reset to draft, add lines, edit unreceived lines, increase qty |
| Stock Picking | Validation | Storable products require PO / SO source or valid return | Consignment (with Owner), internal transfers |
| Returns | Return wizard | One return per document. Return of return (and exchange of return) forbidden. | Data Guard: Manager can bypass both rules |
| Invoice / Bill | Fiscal lock date | Cannot reset to draft within locked period. Warning banner when a line's order carries a quantity gap. | Current-period invoices are free. Standalone (no SO/PO) not flagged. |
| SO / PO line | invoiced ≠ physical qty | Signed stock-anchored gap + status (over-invoiced / delivered-not-invoiced / received-not-billed), colour-coded, filterable. | Non-storable lines; price differences (handled by AVCO/FIFO) |
| Payment | Fiscal lock date | Cannot reset to draft within locked period (native gap fix) | Current-period payments are free |
| Bank Statement | Delete a reconciled line | Blocked while reconciled — undo the reconciliation (Reset) first | Un-reconciled lines delete freely; lock-period handled natively |
| Lock Dates (x5) | Guard enabled | Only Data Guard: Manager group can modify | Data Guard: Manager users can always adjust |
Key Features
- ORM-level protection — while enabled, applies to every user through UI, API calls, and imports alike; only an administrator can turn it off
- Per-line granularity — only lines with actual stock moves are locked
- Auto-unlock on return — qty back to zero lifts all protection
- Stock Picking source enforcement for storable products
- Return limitation — one return per document, no return of return
- Invoiced-vs-physical quantity gap — signed, stock-anchored, colour-coded worklist on SO/PO orders
- Bank statement reconciliation guard — can’t delete a reconciled line and orphan its payment/invoice
- Multi-company aware — each company uses its own lock date
- All 5 lock date fields protected with dedicated manager group
- Lock Date Dashboard for at-a-glance status
- Global on / off toggle in Settings
- Zero dependencies beyond native Odoo modules
Why Not Just Hide the Buttons?
Many "protection" modules simply hide UI elements with CSS or view
inheritance. Users can still modify data via API calls, imports,
automated actions, or developer mode. Data Guard operates at the
ORM level — write(),
unlink(), and action methods are intercepted before any
change reaches the database, so while protection is enabled the rules
hold for every user across every channel.
Turning protection off is itself an administrator action — the
Settings toggle, or the suite_force_unlock context. Like any
ORM-level control it can be disabled by someone with system-administrator
rights; that is a deliberate, auditable escape hatch, not a loophole a
regular user, an import, or an automated action can reach on its own.
Technical Notes
- No new models — pure
_inheriton existing Odoo models. - No scheduled actions, no background jobs, no performance overhead.
- Protection is synchronous and immediate on every write / unlink / action.
- Dependencies:
sale_stock,purchase_stock,account,account_payment,stock(all native Odoo). - Compatible with Odoo 19.0 Community and Enterprise.
- Emergency bypass: pass
suite_force_unlock: Truein ORM context.
License & support
Licensed under OPL-1 (Odoo Proprietary License v1).
Maintained by SuiteState (ElectroState FZCO).
For questions, bug reports, or feature requests, visit
suitestate.com or contact
hello@suitestate.com.
| Availability |
Odoo Online
Odoo.sh
On Premise
|
| Odoo Apps Dependencies |
•
Inventory (stock)
• Invoicing (account) • Discuss (mail) • Purchase (purchase) |
| Lines of code | 730 |
| Technical Name |
suite_data_guard |
| License | OPL-1 |
| Website | https://suitestate.com |
Odoo Proprietary License v1.0 This software and associated files (the "Software") may only be used (executed, modified, executed after modifications) if you have purchased a valid license from the authors, typically via Odoo Apps, or if you have received a written agreement from the authors of the Software (see the COPYRIGHT file). You may develop Odoo modules that use the Software as a library (typically by depending on it, importing it and using its resources), but without copying any source code or material from the Software. You may distribute those modules under the license of your choice, provided that this license is compatible with the terms of the Odoo Proprietary License (For example: LGPL, MIT, or proprietary licenses similar to this one). It is forbidden to publish, distribute, sublicense, or sell copies of the Software or modified copies of the Software. The above copyright notice and this permission notice must be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
Please log in to comment on this module