Construction Companies Don’t Fail Because of Bad Builds—They Fail Because of Bad Numbers
A mid-size general contractor in Ontario completed 47 projects in 2024. Thirty-nine were profitable. Eight lost money. But they didn’t discover which eight until four months after project closeout—when the accountant finished reconciling subcontractor invoices against budgets in a spreadsheet that had grown to 14 tabs and 23,000 rows.
The root cause wasn’t incompetence. It was architecture. Their estimating tool didn’t talk to their accounting system. Purchase orders for materials lived in email threads. Subcontractor certificates of insurance were tracked in a shared drive folder that nobody maintained. Progress claims were calculated manually by the project manager, who used a different margin formula than finance.
Odoo 19 solves this by connecting Project, Accounting, Purchase, Inventory, and Timesheets into a single data model. Analytic accounts tie every dollar—labor, material, subcontractor, equipment—to the project and cost category that generated it. Progress billing creates invoices against milestones, not calendar dates. Subcontractor management tracks holdbacks, lien waivers, and payment certification in the same system that issues the purchase order.
This guide walks through the complete construction setup: project costing with analytic accounts, subcontractor lifecycle management, milestone-based progress billing, material requisitions, equipment cost allocation, change order workflows, WIP reporting, retention handling, and multi-project dashboards.
Project Costing with Analytic Accounts: Every Dollar Traced to a Job
Construction accounting lives and dies on job costing. Every expense—a concrete pour, a plumber’s invoice, a crane rental—must land on the correct project and the correct cost category within that project. Odoo 19’s analytic accounting engine handles this natively, but only if you structure it correctly from the start.
Step 1 — Create Analytic Plans for Construction
Navigate to Accounting → Configuration → Analytic Plans. Create two plans: one for Projects (each job site gets its own analytic account) and one for Cost Categories (labor, materials, subcontractors, equipment, overhead). This two-axis structure lets you answer both “how much did Project Alpha cost?” and “how much did we spend on subcontractors across all projects?”
# Create the "Projects" analytic plan
project_plan_id = models.execute_kw(db, uid, password,
'account.analytic.plan', 'create', [{
'name': 'Construction Projects',
'company_id': company_id,
}])
# Create the "Cost Categories" analytic plan
cost_plan_id = models.execute_kw(db, uid, password,
'account.analytic.plan', 'create', [{
'name': 'Cost Categories',
'company_id': company_id,
}])
# Create project-level analytic accounts
for project in ['PRJ-2026-001 Riverside Condos',
'PRJ-2026-002 Maple St Renovation',
'PRJ-2026-003 Industrial Park Ph2']:
models.execute_kw(db, uid, password,
'account.analytic.account', 'create', [{
'name': project,
'plan_id': project_plan_id,
'company_id': company_id,
}])
# Create cost category analytic accounts
for category in ['Labor', 'Materials', 'Subcontractors',
'Equipment', 'Permits & Fees', 'Overhead']:
models.execute_kw(db, uid, password,
'account.analytic.account', 'create', [{
'name': category,
'plan_id': cost_plan_id,
'company_id': company_id,
}])Step 2 — Link Analytic Accounts to Projects
Open each project in Project → Configuration → Projects and set the Analytic Account field to the corresponding job account. When employees log timesheets or when purchase orders reference this project, the costs automatically flow to the correct analytic account. This is the single most important configuration step—skip it and your job costing is meaningless.
Step 3 — Budget Lines per Cost Category
With analytic accounts in place, create budget lines that map your estimate to Odoo. Each line combines a project analytic account, a cost category analytic account, and a dollar amount. As actuals flow in, the budget vs. actual comparison happens in real time—not in a spreadsheet four months later.
| Cost Category | Budget | Actual | Variance | Source Documents |
|---|---|---|---|---|
| Labor | $185,000 | $172,400 | -$12,600 | Timesheets, payroll journal entries |
| Materials | $420,000 | $438,200 | +$18,200 | Purchase orders, vendor bills |
| Subcontractors | $310,000 | $295,000 | -$15,000 | Subcontract POs, progress certificates |
| Equipment | $75,000 | $81,300 | +$6,300 | Rental invoices, internal allocation entries |
| Permits & Fees | $22,000 | $22,000 | $0 | Vendor bills |
For recurring expenses that always split the same way (e.g., site office rent shared 60/40 between two concurrent projects), create an analytic distribution template. Apply it to the vendor bill line and Odoo splits the cost automatically. This eliminates the monthly journal entry dance that construction accountants dread.
Subcontractor Lifecycle: From RFQ to Holdback Release
Subcontractors represent 40–60% of a typical construction project’s cost. Managing them means tracking more than invoices—you need certificates of insurance, WorkSafeBC/WSIB clearances, lien waivers, holdback retentions, and payment certifications. Odoo 19’s Purchase module, combined with custom fields and approval workflows, handles the full lifecycle.
Subcontract Purchase Orders with Holdback Terms
Create a purchase order type specifically for subcontract agreements. Use Odoo’s payment terms feature to encode holdback retention—typically 10% in most Canadian jurisdictions, held for 45–60 days after substantial completion. The holdback amount appears as a separate payable line, preventing accidental early release.
# Navigate to: Accounting > Configuration > Payment Terms
# Create: "Net 30 + 10% Holdback 60 Days"
Payment Term Lines:
Line 1: 90% of balance, due 30 days after invoice date
Line 2: 10% of balance, due 60 days after completion date
# Assign to subcontractor purchase orders:
# Purchase > Orders > Select subcontract PO
# Set Payment Terms = "Net 30 + 10% Holdback 60 Days"
# The resulting vendor bill splits into two due dates:
# $45,000 due April 15 (90% of $50,000 progress claim)
# $5,000 due June 14 (10% holdback)Tracking Compliance Documents
Use Odoo Studio or custom fields on the res.partner model to track subcontractor compliance: insurance expiry dates, safety certifications, WSIB/WorkSafeBC clearance letters, and bonding capacity. Create an automated action that blocks purchase order confirmation if any required document is expired or missing.
| Compliance Document | Field Type | Validation Rule | Consequence if Missing |
|---|---|---|---|
| General Liability Insurance | Date (expiry) | Must be > today + 30 days | Block PO confirmation |
| WSIB/WorkSafeBC Clearance | Binary (upload) | Must exist and be < 90 days old | Block PO confirmation |
| Safety Certification (COR) | Date (expiry) | Must be current | Warning on PO, block site access |
| Bonding Capacity Letter | Monetary + Date | Capacity >= PO value | Require management override |
| Statutory Declaration / Lien Waiver | Binary (per invoice) | Must exist before payment | Block payment registration |
Set up a scheduled action that runs weekly, scanning all active subcontractor partners for documents expiring within 30 days. The action sends an automated email to the subcontractor requesting renewal and flags the partner record with an internal note visible to procurement. This prevents the scenario where a sub’s insurance lapses mid-project and you discover it only when a worker is injured on site.
In construction, you should never release payment without a corresponding lien waiver (or statutory declaration in Canadian jurisdictions). Add a binary field on vendor bills called "Lien Waiver Received" and create an approval rule that prevents payment registration until the checkbox is ticked. This one field has saved our clients from six-figure lien claims.
Progress Billing & Milestone Invoicing: Bill for Work Completed, Not Time Elapsed
Construction projects don’t bill monthly like SaaS subscriptions. They bill against milestones—foundation complete, framing inspected, drywall finished, final walkthrough accepted. Odoo 19’s Sales and Project modules support milestone-based invoicing natively, but the setup requires careful alignment between the sales order, project tasks, and accounting.
Step 1 — Configure the Sales Order with Milestone Lines
Create a sales order where each line represents a billing milestone. Set the invoicing policy to “Based on Milestones” on the product used for each line. Link each sales order line to a project task that represents the milestone. When the project manager marks the task as done, the line becomes invoiceable.
# Product setup: "Construction Milestone" service product
# Invoicing Policy = "Based on Milestones"
# Service Tracking = "Task"
milestone_product_id = models.execute_kw(db, uid, password,
'product.product', 'create', [{
'name': 'Construction Milestone',
'type': 'service',
'service_policy': 'delivered_milestones',
'service_tracking': 'task_global_project',
'project_id': riverside_project_id,
}])
# Sales order with progress billing schedule
so_id = models.execute_kw(db, uid, password,
'sale.order', 'create', [{
'partner_id': client_id,
'analytic_account_id': riverside_analytic_id,
'order_line': [
(0, 0, {
'product_id': milestone_product_id,
'name': 'M1 - Foundation Complete',
'price_unit': 180000.00,
'product_uom_qty': 1.0,
}),
(0, 0, {
'product_id': milestone_product_id,
'name': 'M2 - Structural Framing',
'price_unit': 320000.00,
'product_uom_qty': 1.0,
}),
(0, 0, {
'product_id': milestone_product_id,
'name': 'M3 - Mechanical/Electrical Rough-In',
'price_unit': 275000.00,
'product_uom_qty': 1.0,
}),
(0, 0, {
'product_id': milestone_product_id,
'name': 'M4 - Interior Finishes',
'price_unit': 210000.00,
'product_uom_qty': 1.0,
}),
(0, 0, {
'product_id': milestone_product_id,
'name': 'M5 - Final Completion & Handover',
'price_unit': 115000.00,
'product_uom_qty': 1.0,
}),
],
}])| Milestone | Claim Amount | Less 10% Holdback | Net Payable | Cumulative Holdback |
|---|---|---|---|---|
| M1 — Foundation Complete | $180,000 | $18,000 | $162,000 | $18,000 |
| M2 — Structural Framing | $320,000 | $32,000 | $288,000 | $50,000 |
| M3 — Mechanical/Electrical | $275,000 | $27,500 | $247,500 | $77,500 |
| M4 — Interior Finishes | $210,000 | $21,000 | $189,000 | $98,500 |
| M5 — Final Completion | $115,000 | $11,500 | $103,500 | $110,000 |
Step 2 — Retention on Client Invoices
Most construction contracts include a 10% holdback on progress claims, released after the lien period expires. Configure a payment term on the client’s sales order that withholds 10% until a specified date. Each milestone invoice will automatically split into the net payable amount and the retained portion.
Step 3 — Certificate of Payment Workflow
In CCDC (Canadian Construction Documents Committee) or AIA-style contracts, the consultant/architect certifies each progress claim before the owner pays. Model this in Odoo by adding an approval stage to the invoicing workflow: draft invoice → consultant review → certified → sent to client. The consultant can review directly in the Odoo portal, reducing email back-and-forth by weeks.
Material Requisitions & Equipment Cost Tracking
Material procurement on construction sites follows a different rhythm than manufacturing. A site foreman needs 200 bags of concrete mix tomorrow morning, not in two weeks when the MRP scheduler runs. Odoo 19’s Purchase module handles urgent procurement, but the key is ensuring every purchase order carries the correct analytic distribution so costs land on the right job.
Material Requisitions from Site
Create a simplified purchase request workflow where site supervisors submit material needs through Odoo’s Purchase Agreements or a custom requisition form. The procurement team reviews, sources from approved vendors (using blanket orders for commonly purchased materials like lumber, concrete, and steel), and issues purchase orders with the project analytic account pre-filled.
Equipment Cost Allocation
Construction equipment—excavators, cranes, scaffolding—gets allocated to projects either as external rentals (vendor bills with analytic accounts) or internal equipment charges (journal entries allocating ownership costs to projects based on utilization). For owned equipment, create a recurring journal entry template that distributes monthly depreciation plus operating costs across active projects based on equipment logs.
| Equipment | Type | Daily Rate | Riverside Condos | Maple St Reno |
|---|---|---|---|---|
| Tower Crane TC-200 | External Rental | $1,850/day | 22 days — $40,700 | — |
| CAT 320 Excavator | Owned | $480/day | 15 days — $7,200 | 8 days — $3,840 |
| Scaffolding Package | Owned | $120/day | 45 days — $5,400 | 30 days — $3,600 |
| Concrete Pump Truck | External Rental | $2,200/day | 6 days — $13,200 | 3 days — $6,600 |
For owned equipment, don’t forget to allocate fuel, maintenance, and insurance costs to projects. Create a dedicated analytic account under the Equipment cost category for each major asset. Monthly, run an allocation journal entry that distributes these costs based on actual utilization days. A $480/day internal rate should cover depreciation, fuel, insurance, and a maintenance reserve—calculate it annually and adjust quarterly.
Change Order Management: Scope Changes Without Budget Chaos
Change orders are the single largest source of margin erosion in construction. A client requests an upgrade to the electrical panel. The foreman approves it verbally on site. The electrician does the work. Three months later, the PM discovers the $14,000 change was never documented, never invoiced, and the cost is buried in the general electrical budget line. Multiply this by 15–20 changes per project and you’ve just lost your profit margin.
Modeling Change Orders in Odoo
There is no native “change order” object in Odoo, but the pattern is clean: each change order becomes a new sales order line on the original contract’s sales order, tagged with a change order reference. This keeps the original contract value intact while accumulating approved changes as separate billable lines. The project’s analytic account captures the additional costs, and the milestone invoice includes the change order amounts.
# Change Order Process:
# 1. Site identifies scope change
# 2. PM creates a new SO line tagged "CO-###"
# 3. Client approves via portal signature
# 4. PM adds corresponding budget line
# 5. Work proceeds; costs land on analytic account
# 6. CO amount included in next progress claim
# Sales Order Line for Change Order:
Product: Construction Milestone
Description: CO-007 - Upgrade to 400A electrical panel
Quantity: 1
Unit Price: $14,200
Analytic: PRJ-2026-001 Riverside Condos + Materials
# Budget adjustment (new analytic budget line):
Analytic Account: PRJ-2026-001 Riverside Condos
Cost Category: Materials
Amount: $8,900 (cost of 400A panel + labor)
Revenue Expected: $14,200 (billed to client)
Margin: 37.3%Implement a policy: no change order work begins until the SO line exists in Odoo and the client has approved it via portal signature. This sounds obvious, but construction culture defaults to “just get it done and we’ll sort the paperwork later.” The paperwork never gets sorted. Odoo’s portal approval with electronic signature removes the friction—clients can approve on their phone from the job site in under 60 seconds.
WIP Reporting, Retention Tracking & Multi-Project Dashboards
Work-in-Progress (WIP) reporting is the heartbeat of construction accounting. It tells you whether a project is overbilled (you’ve invoiced more than the work completed—a liability) or underbilled (you’ve completed more work than you’ve invoiced—an asset). Getting this wrong means your financial statements misrepresent your company’s actual financial position, which can trigger bonding problems, bank covenant violations, and audit findings.
WIP Calculation in Odoo
Odoo doesn’t have a native WIP report for construction, but all the data exists in the analytic accounts. The formula is straightforward:
# WIP Report Logic per Project
# ────────────────────────────
# Contract Value: Total SO amount (original + change orders)
# Costs to Date: Sum of all analytic lines (debit)
# Estimated Total Cost: Budget amount (updated monthly)
# % Complete: Costs to Date / Estimated Total Cost
# Earned Revenue: Contract Value * % Complete
# Billed to Date: Sum of posted invoices
# Over/Under Billing: Billed to Date - Earned Revenue
projects = models.execute_kw(db, uid, password,
'account.analytic.account', 'search_read',
[[('plan_id', '=', project_plan_id)]],
{'fields': ['name', 'debit', 'credit', 'balance']})
for proj in projects:
costs_to_date = proj['debit']
estimated_total = get_budget_amount(proj['id'])
contract_value = get_so_total(proj['id'])
if estimated_total > 0:
pct_complete = costs_to_date / estimated_total
else:
pct_complete = 0
earned_revenue = contract_value * pct_complete
billed_to_date = get_invoiced_amount(proj['id'])
wip_position = billed_to_date - earned_revenue
# Positive = overbilled (liability)
# Negative = underbilled (asset)
print(f"{{proj['name']}}: {{pct_complete:.1%}} complete, "
f"WIP: ${{wip_position:,.0f}}")Retention Tracking
Both client-side holdbacks (money your client owes you after the lien period) and subcontractor-side holdbacks (money you owe your subs) need separate tracking. Create dedicated receivable and payable accounts for retention: “Holdback Receivable” and “Holdback Payable.” When progress invoices are posted with holdback payment terms, the retained portion lands in these accounts automatically. At lien expiry, reclassify to standard AR/AP with a journal entry.
Retention Journal Entries
When a progress invoice posts with holdback payment terms, Odoo creates two receivable lines: one for the net amount (due in 30 days) and one for the holdback (due after the lien period). Map the holdback line to a dedicated “Holdback Receivable” account (e.g., account 1152) rather than the standard trade receivable. This separation is critical for your balance sheet—auditors and bonding companies need to see holdbacks as a distinct asset class, not lumped into standard AR.
On the subcontractor side, mirror this with a “Holdback Payable” account (e.g., account 2152). When the lien period expires and you release the holdback, create a journal entry that reclassifies the amount from Holdback Payable to Accounts Payable, then process the payment normally. This creates a clean audit trail showing exactly when the holdback was released and why.
Multi-Project Dashboard
Build a custom dashboard using Odoo’s spreadsheet module (or a BI connector to your preferred tool) that shows all active projects on a single screen: budget vs. actual, % complete, WIP position, outstanding receivables, holdback balances, and next milestone due date. The project plan analytic report is your starting point—it already aggregates costs and revenues per analytic account.
| Project | Contract | % Complete | Billed | WIP Position | Holdback Owing |
|---|---|---|---|---|---|
| Riverside Condos | $1,100,000 | 62% | $682,000 | Underbilled $6,200 | $68,200 |
| Maple St Renovation | $485,000 | 88% | $436,500 | Overbilled $9,300 | $43,650 |
| Industrial Park Ph2 | $2,350,000 | 31% | $705,000 | Overbilled $22,150 | $70,500 |
3 Construction-Specific Mistakes That Wreck Your Job Costing in Odoo
Purchase Orders Without Analytic Accounts Default to “Unallocated”
A procurement clerk creates a PO for $28,000 of structural steel but forgets to set the analytic account on the order line. The vendor bill posts to the materials expense account with no project attribution. Your P&L looks correct in aggregate, but job costing for every active project is now wrong—one project is understated by $28,000 and you won’t discover it until the project closeout reconciliation.
Make the analytic distribution mandatory on purchase order lines. In Accounting > Configuration > Settings, enable “Analytic Distribution Required” for purchase journals. Odoo will block PO confirmation if any line is missing an analytic account. Also, run a weekly report filtering journal items where the analytic account is blank—catch strays before month-end.
Mixing Percentage-of-Completion with Completed-Contract Recognition
Your accountant uses percentage-of-completion (POC) for large multi-year projects but completed-contract for smaller jobs under $200,000. Odoo doesn’t enforce revenue recognition methods per project—it’s up to your team to apply the correct treatment. When a $180,000 project gets a $35,000 change order, it crosses the threshold but nobody reclassifies it. Your revenue recognition is now inconsistent across projects, which is exactly what auditors look for.
Document your revenue recognition policy in Odoo using project tags: “POC” and “Completed Contract.” Create a scheduled action that checks if any Completed Contract project’s total SO value (original + change orders) exceeds your threshold, and flags it for reclassification. Review these flags monthly during close.
Releasing Subcontractor Holdback Before Lien Period Expiry
A project coordinator processes a subcontractor’s final invoice and pays the full amount—including the 10% holdback—because the payment terms weren’t configured correctly on the original PO. The lien period hasn’t expired. The sub subsequently files a lien for disputed extra work. You’ve now lost your holdback leverage and face a lien on the property.
Configure holdback payment terms on every subcontractor purchase order, not just the ones over a certain value. Create a user group called “Holdback Release Approver” with exclusive rights to post payments against holdback payable accounts. Add a checklist field on the payment form: lien waiver received, lien period expired, project manager sign-off. No payment posts without all three boxes checked.
What Integrated Construction Management in Odoo Delivers
Construction companies that move from spreadsheet job costing to Odoo’s integrated analytic accounting see measurable improvements within the first quarter:
Change orders that previously went unbilled, cost overruns that went undetected, and subcontractor holdbacks that were released early — fixing these three leaks alone recovers 3–5% of project margin on average.
When every cost hits the correct analytic account in real time, month-end close becomes a review process instead of a reconstruction exercise. No more chasing POs, reclassifying expenses, or rebuilding WIP in spreadsheets.
Project managers see budget vs. actual at any moment — not 30 days later when accounting catches up. Early warnings on cost overruns mean corrective action while there’s still margin to protect, not post-mortem analysis on a project that already lost money.
1. “Project Costing with Analytic Accounts in Odoo 19 for Construction”
2. “Subcontractor Holdback and Retention Management in Odoo”
3. “Milestone-Based Progress Billing Configuration in Odoo 19”
4. “WIP Reporting and Over/Under Billing in Odoo Construction”
5. “Change Order Tracking and Invoicing in Odoo 19”