Your Consultants Are Busy. Your Billing Says Otherwise.
Every professional services firm we've onboarded shares the same complaint: the team is working at full capacity, but revenue doesn't reflect it. Timesheets are submitted late (or not at all), project managers don't know who's available next week, and invoices go out weeks after the work was delivered — often missing billable hours that were never logged.
The gap between work performed and revenue captured is called revenue leakage, and in professional services it typically runs between 5% and 15% of total billable hours. For a 50-person consultancy billing at $150/hour, that's $1.5M–$4.5M in annual revenue that simply evaporates because the operational systems can't keep up with the delivery team.
Odoo 19 addresses this with a tightly integrated Project + Timesheet + Invoicing + Planning stack that eliminates the manual handoffs where billable hours get lost. This guide walks through the complete configuration — from setting up billing models (T&M, fixed-price, retainer) to building profitability dashboards that show margin by project, client, and consultant in real time.
How to Configure Time & Materials, Fixed-Price, and Retainer Billing in Odoo 19
Professional services firms rarely use a single billing model. The same company might bill implementation projects on T&M, support contracts on retainer, and compliance audits at a fixed price. Odoo 19 handles all three through the Service Invoicing Policy on each product, combined with the project's billing configuration.
| Billing Model | Invoicing Policy | Revenue Recognition | Best For |
|---|---|---|---|
| Time & Materials | Based on Timesheets | As hours are logged | Consulting, development, ad-hoc work |
| Fixed Price | Based on Milestones | At milestone completion | Audits, implementations, deliverable-based |
| Retainer | Prepaid (Deferred Revenue) | As hours are consumed | Managed services, ongoing support |
Time & Materials Setup
For T&M billing, the service product must be configured with "Based on Timesheets" as the invoicing policy. When a consultant logs time against a task linked to a sales order, Odoo automatically creates a draft invoice line for that time at the rate defined on the sales order line.
# In your custom module's data or migration script
product_tm_consulting = self.env['product.product'].create({
'name': 'Senior Consultant — T&M',
'type': 'service',
'service_type': 'timesheet',
'service_tracking': 'task_in_project',
'invoice_policy': 'delivery', # "Based on Timesheets"
'list_price': 175.00,
'uom_id': self.env.ref('uom.product_uom_hour').id,
'project_id': False, # Create new project per SO
'property_account_income_id': account_revenue.id,
})
# Create a sale order with T&M line
sale_order = self.env['sale.order'].create({
'partner_id': client.id,
'order_line': [(0, 0, {
'product_id': product_tm_consulting.id,
'product_uom_qty': 0, # No fixed qty for T&M
'price_unit': 175.00,
})],
})
sale_order.action_confirm() The critical setting is product_uom_qty: 0 on the sale order line. This tells Odoo there's no fixed quantity — the delivered quantity will be driven entirely by timesheet entries. The invoice amount grows as hours are logged.
Fixed-Price with Milestones
For fixed-price engagements, you want to invoice at specific milestones rather than by hours logged. Odoo 19's milestone feature lets you define billing triggers on tasks:
# Service product for fixed-price work
product_fixed = self.env['product.product'].create({
'name': 'ERP Implementation — Fixed Price',
'type': 'service',
'service_type': 'milestones',
'invoice_policy': 'delivery',
'list_price': 45000.00,
'uom_id': self.env.ref('uom.product_uom_unit').id,
})
# Create milestones on the project
project = self.env['project.project'].create({
'name': 'Acme Corp ERP Implementation',
'allow_milestones': True,
})
milestones = [
('Discovery & Gap Analysis', 30), # 30% of total
('Configuration Complete', 30), # 30%
('UAT Sign-Off', 25), # 25%
('Go-Live & Hypercare', 15), # 15%
]
for name, pct in milestones:
self.env['project.milestone'].create({
'name': name,
'project_id': project.id,
'sale_line_id': so_line.id, # Link to SO line
'quantity_percentage': pct / 100,
})When a project manager marks a milestone as reached, Odoo makes that percentage of the sale order line's amount available for invoicing. This gives you clear billing triggers tied to deliverables rather than arbitrary calendar dates.
Retainer / Prepaid Hours
Retainers require a different approach: the client pays upfront, and hours are consumed against the prepaid balance. Odoo 19 handles this through prepaid service products with deferred revenue accounting. When the client purchases a 40-hour support block, the revenue is deferred. As consultants log time, the deferred revenue is recognized proportionally.
A common scenario: the implementation phase is fixed-price, but change requests during the project are billed T&M. Odoo supports this by linking multiple sale order lines (each with a different billing policy) to the same project. Assign T&M tasks to the T&M SO line and milestone tasks to the fixed-price line. The invoicing engine handles both correctly.
Timesheet Entry, Approval Workflows, and Billing Rate Overrides in Odoo 19
Timesheets are the revenue engine of a professional services firm. Every hour that isn't logged is an hour that can't be billed. Odoo 19's timesheet module has improved significantly — with grid view for weekly entry, timer-based tracking, manager approval workflows, and automatic billing rate computation.
Configuring the Approval Workflow
Enable timesheet approval under Timesheets → Configuration → Settings → Timesheet Approval. Once enabled, submitted timesheets require manager validation before they can be invoiced. This prevents billing errors from reaching the client.
from odoo import models, fields, api
from datetime import timedelta
class TimesheetReminder(models.TransientModel):
_name = 'hr.timesheet.reminder'
_description = 'Weekly Timesheet Reminder'
@api.model
def _send_missing_timesheet_reminders(self):
"""Cron job: remind employees who logged < 32h this week."""
today = fields.Date.today()
week_start = today - timedelta(days=today.weekday())
employees = self.env['hr.employee'].search([
('company_id', '=', self.env.company.id),
('employee_type', '=', 'employee'),
])
for emp in employees:
logged = sum(
self.env['account.analytic.line'].search([
('employee_id', '=', emp.id),
('date', '>=', week_start),
('date', '<=', today),
('project_id', '!=', False),
]).mapped('unit_amount')
)
if logged < 32.0: # Less than 4 days logged
template = self.env.ref(
'my_module.email_timesheet_reminder'
)
template.send_mail(emp.id, force_send=True)
return TrueBilling Rate Overrides by Employee or Role
Not every consultant bills at the same rate. A senior architect at $250/hour and a junior analyst at $95/hour may both work on the same project. Odoo 19 supports employee-level billing rate overrides through the timesheet_cost field and sale order line pricing. Configure rate tables under Project → Configuration → Settings → Pricing to set different rates per employee, per project, or per task type.
| Rate Strategy | Configuration | Use Case |
|---|---|---|
| Flat project rate | Single SO line, one price | Small projects, blended rates |
| Per-role rate | Multiple SO lines by seniority | Enterprise clients, rate card contracts |
| Per-employee rate | Employee mapping on project | Staff augmentation, named resources |
Odoo 19's built-in timer lets consultants start/stop tracking from the task view. In our experience, timer-based tracking captures 15–20% more billable hours than manual end-of-day entry. The reason is simple: when you fill in timesheets from memory at 5 PM, you forget the 30-minute client call and the 45-minute code review. Timers capture it as it happens.
Resource Planning and Allocation: Scheduling Consultants Across Projects in Odoo 19
The Planning module is where professional services firms gain the most operational visibility. It answers the question every delivery manager asks on Monday morning: "Who is available, and where should I put them?"
Odoo 19's Planning module provides a Gantt-style view of resource allocation across projects, with drag-and-drop scheduling, conflict detection, and capacity forecasting. Shifts (allocations) are tied to projects and tasks, so when a consultant is assigned to a project phase, it shows up in their personal schedule and the project's resource plan simultaneously.
from odoo import models, api
from datetime import timedelta
class SaleOrder(models.Model):
_inherit = 'sale.order'
def action_confirm(self):
res = super().action_confirm()
self._create_planning_shifts()
return res
def _create_planning_shifts(self):
"""Auto-create planning shifts when an SO is confirmed."""
Planning = self.env['planning.slot']
for line in self.order_line.filtered(
lambda l: l.product_id.service_tracking
in ('task_in_project', 'project_only')
):
project = line.project_id or line.task_id.project_id
if not project:
continue
# Calculate shift duration from estimated hours
hours = line.product_uom_qty
days_needed = max(1, int(hours / 8))
Planning.create({
'project_id': project.id,
'task_id': line.task_id.id if line.task_id else False,
'resource_id': False, # Unassigned — PM allocates
'allocated_hours': hours,
'start_datetime': fields.Datetime.now(),
'end_datetime': fields.Datetime.now()
+ timedelta(days=days_needed),
'role_id': self._get_default_role(line).id,
'state': 'draft',
})The workflow above creates unassigned planning shifts when a sale order is confirmed. The project manager then drags these into specific consultant calendars using the Planning Gantt view. This ensures no confirmed project falls through the cracks — every sale immediately creates a resource demand that needs to be fulfilled.
Capacity vs. Demand Dashboard
Navigate to Planning → Planning by Resource to see each consultant's allocation. Odoo 19 color-codes capacity: green (< 80% allocated), yellow (80–100%), red (> 100% overbooked). A delivery manager can spot overallocation instantly and redistribute work before it causes burnout or deadline slippage.
| Consultant | This Week | Next Week | Week +2 | Status |
|---|---|---|---|---|
| Sarah K. (Sr. Consultant) | 40h / 40h | 32h / 40h | 16h / 40h | Bench risk in 2 weeks |
| Marco R. (Technical Lead) | 44h / 40h | 40h / 40h | 40h / 40h | Overbooked |
| Leila M. (Jr. Analyst) | 24h / 40h | 8h / 40h | 0h / 40h | Available |
Planning shows where people should be working. Timesheets show where they actually worked. Comparing the two surfaces a critical metric: schedule adherence. If a consultant was planned for 30 hours on Project A but timesheets show 18 hours on Project A and 12 hours on "internal meetings," you have a delivery risk that won't show up in either system alone.
Expense Management: From Receipt to Client Reinvoicing in Odoo 19
Professional services projects often include reimbursable expenses — travel, software licenses, subcontractor fees. Odoo 19 connects expenses to projects and sales orders so that client-billable expenses flow directly into the project invoice alongside timesheet-based charges.
The configuration chain is: Expense Product → Analytic Account (project) → Sale Order Line → Invoice. When a consultant submits a travel expense tagged to a project, and that project is linked to a sale order with a "reinvoice expenses" line, Odoo automatically adds the expense to the next invoice at cost or with a markup.
| Reinvoicing Policy | Configuration | Invoice Amount |
|---|---|---|
| At cost | expense_policy = 'cost' | Exact amount paid |
| At sales price | expense_policy = 'sales_price' | Product list price (markup built in) |
| No reinvoicing | expense_policy = 'no' | Internal cost only |
Enable OCR receipt scanning under Expenses settings. Odoo 19's built-in OCR extracts the vendor, amount, date, and category from uploaded receipt photos — reducing manual data entry by 80%. The consultant snaps a photo of a dinner receipt, Odoo pre-fills the expense form, and the consultant just needs to tag the project and submit.
Approval Workflow for Expenses
Configure multi-level expense approval under Expenses → Configuration → Settings. For professional services, we recommend a two-tier flow: expenses under $500 require only the project manager's approval, while expenses above $500 also require finance team sign-off. This balances speed (consultants aren't blocked on small travel claims) with control (large expenses get financial oversight).
Once an expense report is approved, it creates a journal entry that debits the appropriate analytic account (the project). If the expense product is configured for reinvoicing, Odoo adds the amount to the project's "Costs to Re-invoice" section, making it available on the next client invoice. The entire chain — receipt upload to client billing — happens without a single spreadsheet.
For consultants who travel frequently, configure mileage and per-diem expense products with fixed unit costs. Instead of uploading individual gas receipts, the consultant enters the distance traveled, and Odoo calculates the reimbursable amount at your company's rate (e.g., $0.67/km). This dramatically reduces expense report volume while maintaining accurate project costing.
Client Portal: Giving Clients Real-Time Project Visibility Without Email Chains
The number one cause of client dissatisfaction in professional services isn't poor work — it's lack of visibility. Clients want to know: How many hours have been used? Are we on track? What's the remaining budget? Without a portal, they ask via email, the PM compiles a spreadsheet, and the answer arrives 48 hours later.
Odoo 19's customer portal gives clients self-service access to:
- Project tasks — status, assignee, and deadlines for every deliverable.
- Timesheet entries — who worked on what, when, and for how long (if you enable timesheet visibility on the project).
- Invoices and payments — current billing status and payment history.
- Support tickets — if using Helpdesk, clients can submit and track tickets directly.
- Documents — shared project files and deliverables.
To enable timesheet visibility for portal users, navigate to Project → Configuration → Settings and enable "Timesheets on Customer Portal". Then on each project, toggle the "Timesheets" portal visibility option. Clients will see a summary of hours logged per task without needing to request a status report.
You don't have to show everything. Some firms show task status but hide timesheet details. Others show invoices but not individual time entries. Configure portal visibility per project — an implementation project might have full transparency while an internal R&D project shows only milestones. The settings are on the project form under the "Settings" tab.
Profitability Analysis and Revenue Forecasting for Services Firms in Odoo 19
Revenue is vanity, margin is sanity. A project that bills $200,000 but consumes $190,000 in consultant time and expenses isn't a success — it's a warning sign. Odoo 19's Project Profitability view aggregates revenue (invoiced + uninvoiced timesheets) against costs (employee cost rate x hours + expenses) to show margin at the project, client, and portfolio level.
Navigate to Project → All Projects and click the Profitability button on any project. Odoo displays:
- Revenues — invoiced amount, amount to invoice (uninvoiced timesheets), and other revenue.
- Costs — employee timesheet costs (internal rate x hours), expense costs, purchase order costs, and other costs.
- Margin — revenue minus costs, shown as both an absolute amount and a percentage.
from odoo import models, fields, api
class ClientProfitabilityReport(models.TransientModel):
_name = 'ps.client.profitability.report'
_description = 'Client Profitability Report'
date_from = fields.Date(required=True)
date_to = fields.Date(required=True)
partner_ids = fields.Many2many('res.partner')
def action_generate(self):
domain = [
('date', '>=', self.date_from),
('date', '<=', self.date_to),
]
if self.partner_ids:
domain.append(
('partner_id', 'in', self.partner_ids.ids)
)
timesheets = self.env['account.analytic.line'].search(
domain + [('project_id', '!=', False)]
)
report_data = {}
for ts in timesheets:
partner = ts.project_id.partner_id
if not partner:
continue
key = partner.id
if key not in report_data:
report_data[key] = {
'partner': partner.name,
'hours': 0.0,
'revenue': 0.0,
'cost': 0.0,
}
report_data[key]['hours'] += ts.unit_amount
report_data[key]['revenue'] += (
ts.unit_amount * ts.so_line.price_unit
if ts.so_line else 0.0
)
report_data[key]['cost'] += (
ts.unit_amount * ts.employee_id.timesheet_cost
)
# Calculate margin for each client
for data in report_data.values():
data['margin'] = data['revenue'] - data['cost']
data['margin_pct'] = (
(data['margin'] / data['revenue'] * 100)
if data['revenue'] > 0 else 0.0
)
return report_dataForecasting Revenue from the Pipeline
Combine CRM pipeline data with resource planning to forecast revenue. Every qualified opportunity with an estimated value and expected close date represents future resource demand. When that opportunity converts to a sale order, the planning shifts created at confirmation (from section 03) immediately show the impact on capacity. This creates a closed-loop forecast: sales pipeline → resource demand → capacity constraint → hiring decision.
Key metrics to track on your executive dashboard:
- Utilization rate — billable hours / total available hours. Target: 70–80%.
- Realization rate — invoiced revenue / (billable hours x standard rate). Measures discounting and write-offs.
- Revenue per FTE — total revenue / headcount. Industry benchmark: $150K–$250K for mid-market firms.
- Backlog — contracted but undelivered revenue. Shows how many weeks of work are secured.
class UtilizationCompute(models.Model):
_inherit = 'hr.employee'
utilization_rate = fields.Float(
string='Utilization Rate (%)',
compute='_compute_utilization',
store=True,
)
@api.depends('timesheet_ids', 'resource_calendar_id')
def _compute_utilization(self):
today = fields.Date.today()
month_start = today.replace(day=1)
for emp in self:
# Total available hours from work schedule
calendar = emp.resource_calendar_id
if not calendar:
emp.utilization_rate = 0.0
continue
available = calendar.get_work_hours_count(
datetime.combine(month_start, time.min),
datetime.combine(today, time.max),
resource=emp.resource_id,
)
# Billable hours from timesheets
billable = sum(
self.env['account.analytic.line'].search([
('employee_id', '=', emp.id),
('date', '>=', month_start),
('date', '<=', today),
('project_id.allow_timesheets', '=', True),
('so_line', '!=', False), # Linked to SO = billable
]).mapped('unit_amount')
)
emp.utilization_rate = (
(billable / available * 100)
if available > 0 else 0.0
) This computed field gives you a live utilization percentage on every employee record. Build a Pivot or Dashboard view on hr.employee grouped by department to see which teams are running hot and which have capacity. Combine this with the Planning module's forward-looking allocation data to distinguish between "utilized now" and "booked for next month."
3 Configuration Mistakes That Cause Revenue Leakage in Odoo Professional Services
Timesheets Not Linked to a Sale Order Line
Consultants log time against a project, the project manager sees the hours, but when it's time to invoice — the hours don't appear on the invoice. The root cause: the project task isn't linked to a sale order line. This happens when tasks are created manually on the project instead of being auto-generated from the sale order confirmation. The timesheets exist, but Odoo's invoicing engine doesn't know they're billable.
Always create projects and tasks from the sale order, not manually. Set service_tracking = 'task_in_project' on your service products so that confirming the SO automatically generates the project and tasks with the correct SO line linkage. For ad-hoc tasks, use the "Create on Tasks" mapping on the project to assign them to an existing SO line.
Employee Cost Rate Set to Zero (or Never Set)
The profitability report shows 100% margin on every project. That sounds great until you realize the employee cost field is $0 for every consultant. Odoo calculates project cost as hours x employee_cost_rate. If the cost rate was never configured, every project looks infinitely profitable — and your margin reporting is useless. You'll make staffing decisions based on fictional data.
Set the Timesheet Cost field on every employee record (HR → Employees → employee form → Settings tab). This should reflect the fully-loaded cost: salary + benefits + overhead, divided by available hours per year. For a consultant earning $120K/year with 30% overhead, that's approximately ($120K x 1.3) / 1,800 hours = $86.67/hour.
Retainer Hours Consumed but Deferred Revenue Not Recognized
A client buys a 100-hour prepaid block. Your team logs 40 hours against it. But on the P&L, all 100 hours of revenue appear as recognized income in the month of sale. The issue: deferred revenue recognition wasn't configured on the retainer product. This creates artificially high revenue in the sale month and artificially low revenue in delivery months — distorting monthly financial reporting and potentially causing compliance issues under ASC 606 / IFRS 15.
On the retainer service product, set the Deferred Revenue Account (under the Accounting tab). Then configure a Revenue Recognition rule that ties recognition to timesheet consumption. As consultants log hours against the retainer, revenue moves from the deferred account to the income account proportionally. This matches revenue to the period in which the service was delivered.
What Integrated Project Billing Saves Your Services Firm
The financial impact of connecting timesheets, billing, and resource planning in a single system:
Linking timesheets directly to sale orders eliminates the "forgot to log it" and "task wasn't billable" leakage. Firms typically recover 8–15% of previously lost billable hours.
Invoices generated from approved timesheets in two clicks instead of compiled from spreadsheets over a week. Faster billing means faster payment — improving cash flow by 15–20 days on average.
Real-time resource planning shows who's available before the PM sends a Slack message asking. Bench time becomes visible and actionable, reducing unbillable gaps between projects.
The compounding effect is what matters most. When timesheets are submitted on time because reminders are automated, invoices go out faster because they're generated from approved timesheets, and resource gaps are filled sooner because planning shows availability in real time — the entire revenue cycle accelerates. One firm we migrated to this setup reduced their average days-sales-outstanding (DSO) from 52 days to 31 days within two quarters.
Optimization Metadata
Configure Odoo 19 for professional services: T&M, fixed-price, and retainer billing. Timesheet approval, resource planning, expense reinvoicing, and profitability analysis.
1. "How to Configure Time & Materials, Fixed-Price, and Retainer Billing in Odoo 19"
2. "Timesheet Entry, Approval Workflows, and Billing Rate Overrides in Odoo 19"
3. "Resource Planning and Allocation: Scheduling Consultants Across Projects in Odoo 19"
4. "3 Configuration Mistakes That Cause Revenue Leakage in Odoo Professional Services"