Spreadsheet Commissions Are Costing You More Than You Think
Most companies running Odoo still calculate sales commissions in spreadsheets. A finance analyst exports confirmed invoices, cross-references them against a PDF commission policy document, manually calculates each rep's payout, and pastes the results into a payroll adjustment. The process takes 2–3 days every month and nobody trusts the numbers.
The problems compound fast. Reps dispute calculations they can't verify. Managers can't see commission accruals mid-month. Finance discovers errors after payroll has already run. When the commission structure changes—a new product tier, a shifted quota threshold, a territory reassignment—the spreadsheet becomes a liability that only one person knows how to maintain.
Odoo 19 has the building blocks for a fully automated commission system: computed fields, server actions, scheduled automation, and deep integration with payroll and accounting. This guide walks you through the complete architecture: how to design commission plans with tiered rules, track performance at the team and individual level, automate payout calculations, and integrate the results with payroll and journal entries.
Designing Commission Plan Structures in Odoo 19
Before writing any code, you need a data model that separates the commission plan (the policy) from the commission line (the individual calculation result). This separation is critical because plans change over time, but historical payouts must remain auditable against the rules that were active when the sale was made.
The Commission Plan Model
from odoo import fields, models
class CommissionPlan(models.Model):
_name = "commission.plan"
_description = "Sales Commission Plan"
name = fields.Char(required=True)
active = fields.Boolean(default=True)
date_from = fields.Date(
string="Effective From",
required=True,
)
date_to = fields.Date(string="Effective Until")
company_id = fields.Many2one(
"res.company",
default=lambda self: self.env.company,
)
rule_ids = fields.One2many(
"commission.plan.rule",
"plan_id",
string="Commission Rules",
)
salesperson_ids = fields.Many2many(
"res.users",
string="Assigned Salespeople",
help="Leave empty to apply to all salespeople.",
)
team_ids = fields.Many2many(
"crm.team",
string="Assigned Sales Teams",
)The Commission Rule Model
Each plan contains one or more rules that define how commissions are calculated. Rules support three calculation types: flat amount per invoice, percentage of revenue, and graduated tiers based on cumulative sales volume.
from odoo import api, fields, models
class CommissionPlanRule(models.Model):
_name = "commission.plan.rule"
_description = "Commission Plan Rule"
_order = "threshold_min asc"
plan_id = fields.Many2one(
"commission.plan",
required=True,
ondelete="cascade",
)
rule_type = fields.Selection(
[
("flat", "Flat Amount per Invoice"),
("percentage", "Percentage of Revenue"),
("graduated", "Graduated Tier"),
],
required=True,
default="percentage",
)
product_category_id = fields.Many2one(
"product.category",
string="Product Category",
help="Leave empty to apply to all categories.",
)
threshold_min = fields.Float(
string="Minimum Revenue Threshold",
help="Cumulative revenue required to activate "
"this tier (graduated rules only).",
)
threshold_max = fields.Float(
string="Maximum Revenue Threshold",
)
rate = fields.Float(
string="Commission Rate (%)",
help="Percentage rate for percentage/graduated rules.",
)
flat_amount = fields.Float(
string="Flat Commission Amount",
help="Fixed amount per qualifying invoice.",
)Always scope commission plans to a date range. When the sales director changes the rate from 5% to 7% mid-quarter, you create a new plan version—you don't edit the existing one. Historical commission lines reference the plan version that was active at the time of the sale. This prevents disputes and audit issues.
Implementing Tiered Commission Rules: Flat, Percentage, and Graduated
The real complexity in commission management isn't the base calculation—it's handling graduated tiers where the rate changes as cumulative revenue crosses thresholds. A rep earning 5% on the first $50,000 and 8% on everything above $50,000 in a quarter needs the calculation engine to split the revenue across tiers correctly.
The Commission Calculation Engine
from odoo import api, fields, models
class CommissionLine(models.Model):
_name = "commission.line"
_description = "Commission Calculation Line"
salesperson_id = fields.Many2one("res.users", required=True)
plan_id = fields.Many2one("commission.plan", required=True)
rule_id = fields.Many2one("commission.plan.rule")
invoice_id = fields.Many2one("account.move")
date = fields.Date(default=fields.Date.today)
base_amount = fields.Float(
string="Revenue Base",
help="Invoice amount used for calculation.",
)
commission_amount = fields.Float(
string="Commission Earned",
compute="_compute_commission",
store=True,
)
state = fields.Selection(
[
("draft", "Draft"),
("confirmed", "Confirmed"),
("paid", "Paid"),
],
default="draft",
)
@api.depends("base_amount", "rule_id", "rule_id.rule_type",
"rule_id.rate", "rule_id.flat_amount")
def _compute_commission(self):
for line in self:
rule = line.rule_id
if not rule:
line.commission_amount = 0.0
continue
if rule.rule_type == "flat":
line.commission_amount = rule.flat_amount
elif rule.rule_type == "percentage":
line.commission_amount = (
line.base_amount * rule.rate / 100.0
)
elif rule.rule_type == "graduated":
line.commission_amount = (
line._calculate_graduated(rule)
)
def _calculate_graduated(self, rule):
"""Split revenue across graduated tiers."""
plan = rule.plan_id
tiers = plan.rule_ids.filtered(
lambda r: r.rule_type == "graduated"
).sorted("threshold_min")
# Get cumulative revenue before this invoice
previous = self.env["commission.line"].search([
("salesperson_id", "=", self.salesperson_id.id),
("plan_id", "=", plan.id),
("id", "!=", self.id or 0),
("date", ">=", plan.date_from),
("date", "<=", plan.date_to or "2099-12-31"),
])
cum_before = sum(previous.mapped("base_amount"))
cum_after = cum_before + self.base_amount
total_commission = 0.0
for tier in tiers:
t_min = tier.threshold_min
t_max = tier.threshold_max or float("inf")
# Portion of this invoice in this tier
tier_start = max(cum_before, t_min)
tier_end = min(cum_after, t_max)
if tier_start < tier_end:
taxable = tier_end - tier_start
total_commission += taxable * tier.rate / 100.0
return total_commissionExample: Graduated Tier Configuration
Here's how a typical quarterly graduated commission plan looks when configured in Odoo:
| Tier | Revenue Range | Commission Rate | Example Payout on $120,000 Quarterly Sales |
|---|---|---|---|
| Base | $0 – $50,000 | 5% | $2,500 |
| Accelerator | $50,001 – $100,000 | 8% | $4,000 |
| Overachiever | $100,001+ | 12% | $2,400 |
| Total Quarterly Commission | $8,900 | ||
Make sure your sales team understands the difference between graduated (each dollar is taxed at the rate of its tier) and flat-tier (all revenue retroactively switches to the highest tier reached). The graduated approach above pays $8,900 on $120K. A flat-tier approach at 12% would pay $14,400. This distinction causes more commission disputes than any other structural decision.
Team vs. Individual Commission Tracking and Sales Attribution
Not every sale is a solo effort. When a sales engineer assists on a deal, when a manager's team hits a collective target, or when territory-based bonuses apply alongside individual commissions, you need attribution rules that split credit accurately without double-counting.
Multi-Level Attribution Model
from odoo import fields, models
class CommissionAttribution(models.Model):
_name = "commission.attribution"
_description = "Commission Credit Attribution"
invoice_id = fields.Many2one(
"account.move", required=True, ondelete="cascade",
)
salesperson_id = fields.Many2one("res.users", required=True)
role = fields.Selection(
[
("primary", "Primary Rep"),
("overlay", "Sales Overlay / Engineer"),
("manager", "Team Manager"),
],
required=True,
default="primary",
)
split_pct = fields.Float(
string="Credit Split %",
default=100.0,
help="Percentage of invoice revenue credited "
"to this person.",
)
team_id = fields.Many2one(
"crm.team",
related="salesperson_id.sale_team_id",
store=True,
)Automated Attribution via Server Action
When an invoice is confirmed, a server action automatically creates attribution records based on the sales order's team structure:
from odoo import models
class AccountMove(models.Model):
_inherit = "account.move"
def action_post(self):
"""Override to create commission attributions."""
res = super().action_post()
for move in self.filtered(
lambda m: m.move_type == "out_invoice"
):
move._create_commission_attributions()
return res
def _create_commission_attributions(self):
Attribution = self.env["commission.attribution"]
so = self.invoice_line_ids.sale_line_ids.order_id[:1]
# Primary rep from the sales order
if so and so.user_id:
Attribution.create({
"invoice_id": self.id,
"salesperson_id": so.user_id.id,
"role": "primary",
"split_pct": 80.0,
})
# Team manager gets override commission
if so and so.team_id and so.team_id.user_id:
Attribution.create({
"invoice_id": self.id,
"salesperson_id": so.team_id.user_id.id,
"role": "manager",
"split_pct": 20.0,
})
Splits don't have to add up to 100%. A common pattern is 100% credit to the rep plus a 10% override to the manager (110% total attribution). This is intentional—the manager override comes from a separate cost center. What you must prevent is creating duplicate attribution records when an invoice is reset to draft and re-posted. Add a check for existing attributions before creating new ones.
Automating Commission Payouts: Scheduled Calculations, Payroll Integration, and Journal Entries
The calculation engine means nothing if payouts still require manual intervention. The final piece is automation: a scheduled action that runs commission calculations, creates payroll inputs or journal entries, and posts the results for review and approval.
Scheduled Commission Calculation
from odoo import fields, models
from dateutil.relativedelta import relativedelta
class CommissionCron(models.Model):
_inherit = "commission.plan"
def _cron_calculate_commissions(self):
"""Monthly cron: calculate commissions for
all confirmed invoices without commission lines."""
today = fields.Date.today()
last_month_start = (
today - relativedelta(months=1)
).replace(day=1)
last_month_end = today.replace(day=1) - relativedelta(
days=1
)
invoices = self.env["account.move"].search([
("move_type", "=", "out_invoice"),
("state", "=", "posted"),
("invoice_date", ">=", last_month_start),
("invoice_date", "<=", last_month_end),
("commission_line_ids", "=", False),
])
for invoice in invoices:
attributions = self.env[
"commission.attribution"
].search([
("invoice_id", "=", invoice.id),
])
for attr in attributions:
plan = self._find_active_plan(
attr.salesperson_id, invoice.invoice_date
)
if not plan:
continue
base = (
invoice.amount_untaxed
* attr.split_pct / 100.0
)
rule = plan._match_rule(
base, attr.salesperson_id,
invoice.invoice_date,
)
if rule:
self.env["commission.line"].create({
"salesperson_id": attr.salesperson_id.id,
"plan_id": plan.id,
"rule_id": rule.id,
"invoice_id": invoice.id,
"base_amount": base,
"state": "draft",
})Cron Job XML Registration
<odoo>
<record id="ir_cron_commission_calculate"
model="ir.cron">
<field name="name">
Calculate Monthly Commissions
</field>
<field name="model_id"
ref="model_commission_plan"/>
<field name="state">code</field>
<field name="code">
model._cron_calculate_commissions()
</field>
<field name="interval_number">1</field>
<field name="interval_type">months</field>
<field name="numbercall">-1</field>
<field name="doall" eval="False"/>
</record>
</odoo>Accounting Integration: Commission Journal Entries
Once commission lines are confirmed, they need to flow into your accounting. The cleanest approach is creating journal entries that debit a commission expense account and credit a payable account for each salesperson:
def action_create_journal_entry(self):
"""Create accounting entries for confirmed
commission lines."""
lines_by_person = {}
for line in self.filtered(
lambda l: l.state == "confirmed"
):
key = line.salesperson_id.id
lines_by_person.setdefault(key, []).append(line)
journal = self.env.ref(
"my_commission.commission_journal"
)
expense_account = self.env.ref(
"my_commission.account_commission_expense"
)
payable_account = self.env.ref(
"my_commission.account_commission_payable"
)
for user_id, comm_lines in lines_by_person.items():
user = self.env["res.users"].browse(user_id)
total = sum(l.commission_amount for l in comm_lines)
move = self.env["account.move"].create({
"journal_id": journal.id,
"ref": f"Commission - {{user.name}}",
"line_ids": [
(0, 0, {
"account_id": expense_account.id,
"debit": total,
"name": f"Commission expense: "
f"{{user.name}}",
}),
(0, 0, {
"account_id": payable_account.id,
"credit": total,
"partner_id":
user.partner_id.id,
"name": f"Commission payable: "
f"{{user.name}}",
}),
],
})
move.action_post()
for cl in comm_lines:
cl.state = "paid"Reporting Dashboard: Pivot and Graph Views
Give sales managers and finance a real-time view of commission accruals, payouts, and performance against targets:
<odoo>
<!-- Pivot view: commissions by salesperson -->
<record id="commission_line_pivot"
model="ir.ui.view">
<field name="name">commission.line.pivot</field>
<field name="model">commission.line</field>
<field name="arch" type="xml">
<pivot string="Commission Analysis">
<field name="salesperson_id" type="row"/>
<field name="date" type="col"
interval="month"/>
<field name="commission_amount"
type="measure"/>
<field name="base_amount"
type="measure"/>
</pivot>
</field>
</record>
<!-- Graph view: monthly commission trends -->
<record id="commission_line_graph"
model="ir.ui.view">
<field name="name">commission.line.graph</field>
<field name="model">commission.line</field>
<field name="arch" type="xml">
<graph string="Commission Trends"
type="bar" stacked="True">
<field name="date" type="row"
interval="month"/>
<field name="salesperson_id" type="col"/>
<field name="commission_amount"
type="measure"/>
</graph>
</field>
</record>
</odoo> If you use Odoo Payroll, create commission lines as payslip input lines instead of journal entries. Define a salary rule with code COMMISSION that reads from commission.line records. This keeps commissions inside the payroll workflow—subject to tax calculations, deductions, and payslip approval—rather than as standalone journal entries that bypass payroll entirely.
3 Commission Management Mistakes That Create Disputes and Accounting Errors
Calculating Commissions on Invoices Instead of Payments
You configure the commission engine to trigger when an invoice is posted. The rep earns 8% of a $50,000 deal and gets paid $4,000 in commission. Two months later, the customer goes silent—the invoice becomes a bad debt. You've paid commission on revenue you never collected. This is the most expensive commission architecture mistake because it compounds across every uncollectable invoice.
Create commission lines in draft state when the invoice is posted, but only move them to confirmed when the corresponding payment is reconciled. Use the reconcile event on account.move.line as the trigger. For partial payments, confirm a proportional commission amount. This way, commissions track actual cash collection, not just booked revenue.
Not Handling Credit Notes and Invoice Reversals
A customer returns $15,000 worth of product. The credit note is posted against the original invoice. But the commission system doesn't see credit notes—it only processes out_invoice records. The rep keeps the full commission on revenue that was clawed back. Over a quarter, unhandled credit notes can inflate commission payouts by 5–15% depending on your return rate.
Extend the commission engine to process out_refund moves. When a credit note is posted, create a negative commission line linked to the original invoice's commission record. The rep's next payout automatically deducts the clawback. For graduated tiers, recalculate the cumulative revenue to potentially shift the rep down a tier—this requires reprocessing all commission lines for the affected period.
Hardcoding Commission Rates Instead of Using Date-Scoped Plan Versions
The sales director changes the commission rate from 5% to 7% starting next quarter. Someone updates the rule record directly. Now every historical commission line recalculates because the stored computed field references the current rule rate, not the rate that was active when the sale was made. The January payouts that were already disbursed at 5% now show 7% in the system. Your audit trail is destroyed.
Never edit an active commission plan. Archive the old plan, create a new version with the updated rates, and set the date_from to the new effective date. Commission lines reference a specific plan_id and rule_id—once paid, those records are immutable. Add a rate_snapshot field on commission.line that stores the rate at calculation time, so even if someone accidentally edits a rule, the historical payout amount is preserved.
What Automated Commission Management Saves Your Business
Commission automation isn't an HR convenience project. It's a finance accuracy and sales retention initiative:
A 20-person sales team generating 200+ invoices per month means 2–3 days of spreadsheet work every pay cycle. Automated calculations reduce this to a 15-minute review and approval step.
Reps can see their commission accruals in real time through their Odoo portal. Every line traces back to a specific invoice, rule, and rate. Transparency eliminates the "I think I was shorted" conversations.
Manual commission calculations typically have a 3–5% error rate from missed credit notes, wrong tier assignments, and split calculation mistakes. Automated systems eliminate these entirely.
For a company with $5M in annual commissioned sales at an average 7% rate, a 4% calculation error means $14,000 in annual overpayments or underpayments. Overpayments erode margins. Underpayments erode trust and drive rep turnover—which costs 50–200% of a sales rep's annual compensation to backfill.
Optimization Metadata
Complete guide to building sales commission management in Odoo 19. Commission plans, tiered rules, team attribution, automated payouts, payroll integration, and reporting dashboards.
1. "Designing Commission Plan Structures in Odoo 19"
2. "Implementing Tiered Commission Rules: Flat, Percentage, and Graduated"
3. "Team vs. Individual Commission Tracking and Sales Attribution"
4. "Automating Commission Payouts: Scheduled Calculations, Payroll Integration, and Journal Entries"