Every Unapproved Purchase Order Is a Financial Risk You Haven't Measured
A procurement manager clicks "Confirm" on a $45,000 vendor order. No second pair of eyes. No budget check. No audit trail beyond a timestamp in mail.message. Three weeks later, finance discovers the department already exceeded its quarterly budget by 20% — but the goods are on a container ship. This happens more often than anyone in leadership wants to admit.
The Approvals module (approvals) in Odoo 19 Enterprise exists to close exactly this gap. It provides a configurable, multi-level approval engine that works across purchase requests, expense reports, leave requests, and any custom workflow you define. Unlike ad-hoc email chains or Slack messages that say "can you approve this?", the module creates a structured, auditable, enforceable approval process with clear ownership at every stage.
But the module ships with sensible defaults that cover about 40% of real-world needs. The other 60% — multi-level chains with conditional routing, minimum approver thresholds, delegation rules for vacations, integration with purchase and expense workflows, and portal access for external stakeholders — requires configuration that isn't obvious from the UI. This guide covers all of it.
Installing and Configuring the Approvals Module in Odoo 19 Enterprise
The Approvals module is an Enterprise-only feature. It's not available in the Community edition, and no OCA alternative provides the same approval chain engine. Navigate to Apps → search "Approvals" and install the approvals module. This pulls in the dependencies mail, hr, and product automatically.
After installation, the module creates a new top-level menu: Approvals. The key menu items are:
- Approvals → My Requests — where employees submit and track their own requests
- Approvals → Requests to Approve — the approver's queue, showing all pending requests assigned to them
- Approvals → All Requests — admin view with every request across the company
- Approvals → Configuration → Approval Types — where you define categories, approval rules, and field visibility
Before creating approval types, configure the global settings under Settings → Approvals. The two critical options are:
| Setting | Field Name | Default | Recommendation |
|---|---|---|---|
| Minimum Approval | approval_minimum | 1 | Set to 2 for financial requests above a threshold. This enforces dual-control for high-value transactions. |
| Portal Access | use_portal | Disabled | Enable if external stakeholders (board members, external auditors) need to approve requests without an internal Odoo user license. |
# In a custom module's __manifest__.py
{
'name': 'Custom Approval Configuration',
'version': '19.0.1.0.0',
'depends': ['approvals', 'approvals_purchase', 'hr_expense_approvals'],
'data': [
'data/approval_category_data.xml',
'security/ir.model.access.csv',
],
'license': 'OEEL-1',
}
# In a post_init_hook or data file, enable portal access
# via res.config.settings
def post_init_hook(env):
config = env['res.config.settings'].create({
'module_approvals_purchase': True,
'module_hr_expense_approvals': True,
})
config.execute()Defining Approval Types and Categories: Purchase, Expense, Leave, and Custom
Approval types (model approval.category) are the backbone of the module. Each type defines what gets approved, who approves it, how many approvals are required, and which fields the requester must fill in. Navigate to Approvals → Configuration → Approval Types to create or modify them.
Odoo 19 ships with four pre-configured approval types after installing the sub-modules:
| Approval Type | Sub-Module | Integrates With | Key Fields |
|---|---|---|---|
| Purchase Request | approvals_purchase | Purchase Orders (purchase.order) | product_line_ids, amount_total, vendor_id |
| Expense Report | hr_expense_approvals | Expenses (hr.expense.sheet) | expense_sheet_id, total_amount, employee_id |
| Time Off | approvals_purchase (HR bridge) | Time Off (hr.leave) | date_start, date_end, holiday_status_id |
| General Approval | approvals (base) | Standalone | reason, amount, reference |
Field Visibility per Approval Type
Each approval type controls which fields appear on the request form. The approval.category model uses selection fields with three options for each configurable field: None (hidden), Optional (visible but not required), and Required (must be filled before submission). The key field visibility controls are:
has_date— show/require start and end date fieldshas_period— show/require period selectionhas_quantity— show/require quantity fieldhas_amount— show/require monetary amounthas_reference— show/require external reference fieldhas_partner— show/require contact (vendor/customer) selectionhas_payment_method— show/require payment methodhas_location— show/require location fieldhas_product— show/require product lines
<odoo>
<data noupdate="1">
<!-- Purchase Request: requires vendor, products, and amount -->
<record id="approval_category_purchase_request" model="approval.category">
<field name="name">Purchase Request</field>
<field name="sequence">10</field>
<field name="description">
Request approval before creating a purchase order.
Required for all orders above $5,000.
</field>
<field name="approval_type">purchase</field>
<field name="has_partner">required</field>
<field name="has_product">required</field>
<field name="has_amount">required</field>
<field name="has_date">optional</field>
<field name="has_reference">optional</field>
<field name="has_quantity">required</field>
<field name="has_location">no</field>
<field name="has_payment_method">no</field>
<field name="approval_minimum">2</field>
<field name="manager_approval">approver</field>
<field name="automated_sequence">True</field>
<field name="company_id" ref="base.main_company"/>
</record>
<!-- Expense Report: requires amount, date, reference -->
<record id="approval_category_expense_report" model="approval.category">
<field name="name">Expense Report Approval</field>
<field name="sequence">20</field>
<field name="description">
Submit expense reports for manager and finance approval.
</field>
<field name="has_partner">no</field>
<field name="has_product">optional</field>
<field name="has_amount">required</field>
<field name="has_date">required</field>
<field name="has_reference">required</field>
<field name="has_quantity">no</field>
<field name="has_location">optional</field>
<field name="has_payment_method">required</field>
<field name="approval_minimum">1</field>
<field name="manager_approval">approver</field>
<field name="company_id" ref="base.main_company"/>
</record>
</data>
</odoo> Setting automated_sequence = True on an approval category auto-generates a unique reference number (e.g., PR/2026/00042) for each request. This is critical for audit trails — finance teams need to reference specific approval numbers in journal entries, and "that purchase request from last Tuesday" doesn't work in an SOX audit.
Building Multi-Level Approval Chains with Minimum Approvers and Delegation
Single-level approvals work for small teams. But once you have departments, budget thresholds, and compliance requirements, you need multi-level approval chains — where a request passes through multiple approvers in sequence, and each level can have its own rules.
In Odoo 19, approval chains are configured through the Approvers tab on each approval category (approval.category). Each approver entry is stored in the approval.approver model and includes:
| Field | Model Field | Purpose |
|---|---|---|
| User | user_id | The specific user who must approve. Can be set to the requester's manager dynamically. |
| Status | status | Current state: new, pending, approved, refused. |
| Required | required | If True, this approver must approve. If False, they're optional (counts toward minimum threshold). |
| Sequence | sequence | Order in the chain. Lower sequence = earlier in the approval flow. |
Manager as Dynamic Approver
The manager_approval field on approval.category controls whether the requester's direct manager is automatically added to the approval chain. It accepts three values:
'no'— Manager is not involved. Only explicitly defined approvers are used.'approver'— Manager is added as a required approver. The request won't advance until the manager approves.'required'— Same asapproverbut the manager is always the first in the chain regardless of sequence numbers.
Minimum Approver Thresholds
The approval_minimum field defines how many approvers must approve before the request transitions to the approved state. This is independent of the total number of approvers in the chain. For example, a purchase request category with 5 approvers and approval_minimum = 3 means any 3 of the 5 can approve to move the request forward. This is useful for committees where not every member needs to weigh in.
from odoo import models, fields, api
class ApprovalRequestInherit(models.Model):
_inherit = 'approval.request'
@api.onchange('amount')
def _onchange_amount_set_approvers(self):
"""Dynamically adjust approvers based on request amount.
- Under $5,000: manager only (1 approval)
- $5,000 - $25,000: manager + department head (2 approvals)
- Over $25,000: manager + department head + CFO (3 approvals)
"""
if not self.category_id or not self.amount:
return
ApproverSudo = self.env['approval.approver'].sudo()
employee = self.env.user.employee_id
if not employee or not employee.parent_id:
return
manager = employee.parent_id.user_id
dept_head = employee.department_id.manager_id.user_id
cfo_group = self.env.ref('account.group_account_manager')
cfo_user = cfo_group.users[:1]
approver_list = []
if self.amount < 5000:
approver_list = [(manager, True, 10)]
elif self.amount <= 25000:
approver_list = [
(manager, True, 10),
(dept_head, True, 20),
]
else:
approver_list = [
(manager, True, 10),
(dept_head, True, 20),
(cfo_user, True, 30),
]
# Clear existing approvers and set new ones
existing = self.approver_ids.filtered(
lambda a: a.status == 'new'
)
existing.unlink()
for user, required, seq in approver_list:
if user:
ApproverSudo.create({
'request_id': self.id,
'user_id': user.id,
'required': required,
'sequence': seq,
'status': 'new',
})Delegation Rules for Vacation Coverage
When an approver is on vacation, requests pile up. Odoo 19 handles this through delegation — an approver can delegate their approval authority to another user for a specific period. This is configured per-user under Settings → Users → [User] → Preferences → Approval Delegation.
Programmatically, delegation is managed through the res.users model's approval_delegate_id and approval_delegate_date_start / approval_delegate_date_end fields. When a delegated user approves a request, the audit trail records both the delegate and the original approver — so you always know who actually clicked "Approve."
Delegation is time-bound and automatic — the delegate receives approval requests during the defined period. Reassignment is manual — an admin moves a specific pending request to a different approver. Use delegation for planned absences (vacation, parental leave). Use reassignment for unexpected situations (an approver leaves the company mid-workflow). Both are tracked in mail.message for full auditability.
Integrating Approvals with Purchase, Expense, and HR Modules
The real power of the Approvals module emerges when it's connected to Odoo's operational modules. Rather than standalone approval requests that exist in a vacuum, integration means an approved purchase request automatically creates a purchase order, an approved expense report moves directly to the accounting pipeline, and an approved leave request updates the HR calendar.
Purchase Integration (approvals_purchase)
Install approvals_purchase to bridge approvals with the Purchase module. Once active, the approval request form gains a Products tab where requesters can add product lines with quantities and expected unit prices. When the request is approved, clicking "Create Purchase Order" generates a draft purchase.order pre-filled with the vendor, product lines, and quantities from the approval.
The key fields on the purchase-linked approval request:
product_line_ids— one2many toapproval.product.linestoring product, quantity, and descriptionpartner_id— the vendor (linked tores.partnerwithsupplier_rank > 0)purchase_order_count— computed field showing how many POs were created from this approval
Expense Integration (hr_expense_approvals)
The expense bridge connects approval workflows to hr.expense.sheet. When an employee submits an expense report that exceeds a configured threshold, it automatically creates an approval request. The flow is: Employee submits expense → Approval request created → Manager approves → Expense moves to "Approved" state → Finance processes payment.
Email Notifications and Activity Scheduling
Every state transition in the approval workflow triggers an email notification through Odoo's mail.thread mixin. The module uses mail templates stored as mail.template records with the following triggers:
- Request submitted — notifies all approvers in the chain that a new request awaits their review
- Approved by one level — notifies the next approver in sequence that it's their turn
- Fully approved — notifies the requester and any followers that the request is complete
- Refused — notifies the requester with the refusal reason
Additionally, the module creates mail.activity records (the "to-do" items in the chatter) for each pending approver. This means approvals appear in the approver's Activity view across all Odoo modules — not just in the Approvals menu. An approver reviewing their daily tasks in the CRM will see the pending purchase approval right alongside their sales follow-ups.
<odoo>
<data>
<record id="mail_template_high_value_approval" model="mail.template">
<field name="name">High-Value Approval Notification</field>
<field name="model_id" ref="approvals.model_approval_request"/>
<field name="subject">
URGENT: Approval Required - {{ object.name }}
({{ object.amount }} {{ object.currency_id.symbol }})
</field>
<field name="email_from">
{{ (object.request_owner_id.email_formatted) }}
</field>
<field name="email_to">
{{ (object.approver_ids.filtered(
lambda a: a.status == 'pending'
).mapped('user_id.email') | join(',')) }}
</field>
<field name="body_html" type="html">
<div style="margin: 0; padding: 0;">
<p>Dear Approver,</p>
<p>
A high-value approval request requires your attention:
</p>
<table style="border-collapse: collapse; width: 100%;">
<tr>
<td style="padding: 8px; border: 1px solid #ddd;">
<strong>Reference</strong>
</td>
<td style="padding: 8px; border: 1px solid #ddd;">
{{ object.name }}
</td>
</tr>
<tr>
<td style="padding: 8px; border: 1px solid #ddd;">
<strong>Category</strong>
</td>
<td style="padding: 8px; border: 1px solid #ddd;">
{{ object.category_id.name }}
</td>
</tr>
<tr>
<td style="padding: 8px; border: 1px solid #ddd;">
<strong>Amount</strong>
</td>
<td style="padding: 8px; border: 1px solid #ddd;">
{{ object.amount }}
{{ object.currency_id.symbol }}
</td>
</tr>
<tr>
<td style="padding: 8px; border: 1px solid #ddd;">
<strong>Requested By</strong>
</td>
<td style="padding: 8px; border: 1px solid #ddd;">
{{ object.request_owner_id.name }}
</td>
</tr>
</table>
<p style="margin-top: 16px;">
<a href="{{ object.get_base_url() }}/web#id={{ object.id }}&model=approval.request&view_type=form"
style="padding: 10px 20px; background: #875A7B;
color: #fff; text-decoration: none;
border-radius: 4px;">
Review & Approve
</a>
</p>
</div>
</field>
</record>
</data>
</odoo>Adding Custom Approval Fields and Reporting Dashboards
The built-in fields cover standard use cases, but most organizations need custom data on their approval requests. Common examples: cost center codes, project references, urgency levels, and budget line items. Odoo 19 supports two approaches for adding custom fields to approvals.
Approach 1: Studio (No-Code)
If you have Odoo Studio installed, open any approval request form and click the Studio icon (puzzle piece). You can drag and drop new fields directly onto the form. Studio creates x_ prefixed fields in the database and adds them to the approval.request model. This is fast for simple additions but creates technical debt for complex logic.
Approach 2: Custom Module (Recommended)
For production deployments, extend the approval.request model in a custom module. This keeps your customizations version-controlled, testable, and upgrade-safe.
from odoo import models, fields, api
from odoo.exceptions import ValidationError
class ApprovalRequestCustom(models.Model):
_inherit = 'approval.request'
# ── Custom Fields ──────────────────────────────
cost_center_id = fields.Many2one(
'account.analytic.account',
string='Cost Center',
help='Budget line to charge this request against.',
)
urgency = fields.Selection([
('low', 'Low — Within 30 days'),
('medium', 'Medium — Within 7 days'),
('high', 'High — Within 48 hours'),
('critical', 'Critical — Same day'),
], default='medium', required=True, tracking=True)
budget_remaining = fields.Monetary(
related='cost_center_id.balance',
string='Remaining Budget',
readonly=True,
currency_field='currency_id',
)
over_budget = fields.Boolean(
compute='_compute_over_budget',
store=True,
)
@api.depends('amount', 'budget_remaining')
def _compute_over_budget(self):
for request in self:
request.over_budget = (
request.amount > 0
and request.budget_remaining > 0
and request.amount > request.budget_remaining
)
@api.constrains('amount', 'budget_remaining')
def _check_budget_threshold(self):
for request in self:
if request.over_budget and request.urgency != 'critical':
raise ValidationError(
'This request exceeds the remaining budget '
'for cost center "%s". Only critical-urgency '
'requests can exceed budget limits.'
% request.cost_center_id.name
)Reporting and Analytics
The Approvals module includes a built-in reporting view accessible at Approvals → Reporting. It provides pivot and graph views of approval requests grouped by category, status, approver, and date. For custom reporting needs, you can extend the analysis model:
- Average approval time — measure the time between
request_dateand the last approver'sapproval_date - Bottleneck identification — group by approver to find who has the most pending requests
- Category breakdown — see which approval types generate the most volume
- Over-budget requests — filter on the custom
over_budgetfield to track policy exceptions
Portal Access for External Approvers
When use_portal is enabled in settings, external users (board members, external auditors, contractors) can view and approve requests through the Odoo portal without needing a full internal user license. They log in at /my/approvals and see only the requests assigned to them. Portal approvals carry the same audit weight as internal approvals — the approval.approver record tracks the portal user ID and timestamp identically.
3 Approval Workflow Mistakes That Create Compliance Gaps
Setting approval_minimum = 0 Bypasses All Approval Logic
If you set approval_minimum = 0 on an approval category, the request transitions to "Approved" the moment it's submitted — without any approver action. This isn't a bug; it's by design for informational categories (like "notify my manager I'm working remotely"). But we've seen it applied to purchase request categories by mistake during initial configuration. The result: purchase orders created without any human approval, discovered only during quarterly audits.
Never set approval_minimum = 0 on any category that triggers downstream actions (purchase orders, expense payments, leave deductions). Add a server action or automated check that validates approval_minimum >= 1 on all categories nightly, and alerts the admin if a zero-minimum category is found.
Manager Approval Fails When employee.parent_id Is Empty
When manager_approval is set to 'approver', the module looks up the requester's manager via employee_id.parent_id.user_id. If the employee record has no manager set (common for new hires, contractors, or executives), the approval chain has zero required approvers. Combined with approval_minimum = 1, the request gets stuck in "pending" forever because there's nobody to approve it. The requester sees "Waiting for approval" with no indication of who they're waiting for.
Add a validation constraint on approval.request that checks approver_ids is non-empty after the action_confirm method runs. If no approvers were assigned, raise a UserError telling the requester to contact HR to set their manager in the employee directory. Also: run a scheduled action weekly that reports all employees with missing parent_id to the HR manager.
Approved Purchase Requests Don't Auto-Create POs by Default
A common misconception: approving a purchase request does not automatically generate a purchase order. The approved request shows a "Create Purchase Order" button that a procurement user must click manually. If your workflow assumes auto-creation, you'll end up with a backlog of approved-but-unfulfilled requests and vendors wondering why they never received an order. We've seen companies where 30+ approved purchase requests sat untouched for weeks because nobody knew they had to click the button.
If you want automatic PO creation, override the action_approve method on approval.request to call action_create_purchase_orders() when all required approvals are met. Alternatively, create a server action triggered on write of request_status = 'approved' that calls the purchase order creation wizard. Either way, test thoroughly — auto-created POs should be in draft state, not confirmed, to give procurement a final review.
What Structured Approval Workflows Save Your Organization
The Approvals module is included in Odoo Enterprise at no additional cost. The ROI comes from the risks it eliminates and the time it recovers:
Multi-level approval chains with budget validation prevent purchase orders that exceed department budgets or lack proper authorization.
Email notifications and activity scheduling mean approvers see requests within minutes, not days. Average approval cycle drops from 5 days to under 2.
Every approval, refusal, and delegation is timestamped and attributed. SOX, ISO 27001, and internal audits get the documentation they need without manual evidence gathering.
Beyond the metrics: structured approvals change organizational behavior. When employees know that every purchase request will be reviewed against the budget, they self-regulate. The $45,000 order that should have been three separate $15,000 orders — each within the department's spending authority — gets structured correctly from the start because the system enforces the boundary.
Optimization Metadata
Complete guide to the Odoo 19 Approvals module. Configure multi-level approval chains, purchase request workflows, expense approvals, delegation rules, and custom fields.
1. "Installing and Configuring the Approvals Module in Odoo 19 Enterprise"
2. "Building Multi-Level Approval Chains with Minimum Approvers and Delegation"
3. "3 Approval Workflow Mistakes That Create Compliance Gaps"