本文目前仅提供英文版本。翻译即将推出。
By the end of this recipe, your Odoo 19 instance will compute monthly sales commission per rep based on tiered rates, handle commission splits across multiple reps on the same deal, claw back commission on customer refunds, and feed the result into payroll for the same-month paycheck. Skill required: Odoo developer with Python + accounting context. Time required: 4 hours setup, 30 minutes per monthly close. ECOSIRE has built commission systems for clients running 5 reps and 50 reps, and the recipe below is the playbook.
The reason DIY commission systems break in production: they treat commission as a one-time computation per invoice. Real-world commission has to handle splits ("the deal was 70% Mary, 30% John"), claw-backs (customer refunded in month 3, commission paid in month 1 — owed back), and tier escalation (rep crosses quota mid-month, lower-rate deals get retroactively adjusted). The recipe below handles all three.
What you will need
- Odoo version: 17, 18, or 19. The OCA
commissionmodule is a starting point; for production we extend it. - HR module:
hrfor the rep records andhr_payroll(Enterprise) for the paycheck integration. - Posted invoices with sales rep assigned (
invoice_user_id). - Time: 4 hours setup, 30 min per monthly close.
Step-by-step
1. Define commission plans
from odoo import models, fields, api
class CommissionPlan(models.Model):
_name = 'commission.plan'
name = fields.Char(required=True)
base = fields.Selection([
('invoice', 'Invoice Total (excl. tax)'),
('margin', 'Gross Margin'),
('payment', 'Cash Collected'),
], default='invoice')
tier_ids = fields.One2many('commission.plan.tier', 'plan_id')
clawback_on_refund = fields.Boolean(default=True)
payout_after_payment = fields.Boolean(default=True,
help="Only pay commission after invoice is fully paid")
class CommissionPlanTier(models.Model):
_name = 'commission.plan.tier'
_order = 'monthly_threshold'
plan_id = fields.Many2one('commission.plan')
monthly_threshold = fields.Float(help="Monthly cumulative base above which this tier applies")
rate_pct = fields.Float(help="Commission rate (e.g., 5 means 5%)")
Configure tiers like:
- 0-50k: 5%
- 50k-100k: 8%
- 100k+: 12%
Verification: a plan record exists with multiple tiers in ascending order.
2. Assign plans to reps
Add a Many2one on hr.employee:
class HrEmployee(models.Model):
_inherit = 'hr.employee'
commission_plan_id = fields.Many2one('commission.plan')
commission_team_split = fields.Float(default=100.0,
help="Default % when this rep is the sole owner")
Verification: every sales rep has a plan assigned.
3. Build the commission computation
Triggered on invoice payment (or invoice posting if payout_after_payment = False):
class AccountMove(models.Model):
_inherit = 'account.move'
def _post(self, soft=True):
result = super()._post(soft=soft)
for move in self.filtered(lambda m: m.move_type == 'out_invoice' and m.state == 'posted'):
move._compute_commission()
return result
def _compute_commission(self):
for move in self:
rep = move.invoice_user_id.employee_id
if not rep or not rep.commission_plan_id:
continue
plan = rep.commission_plan_id
base = move.amount_untaxed if plan.base == 'invoice' else move._compute_margin()
# Determine which tier applies (use rep's MTD cumulative base)
mtd_base = self._get_mtd_base_for_rep(rep, move.invoice_date)
applicable_tier = max(
(t for t in plan.tier_ids if t.monthly_threshold <= mtd_base),
key=lambda t: t.monthly_threshold,
)
commission_amount = base * applicable_tier.rate_pct / 100
self.env['commission.line'].create({
'invoice_id': move.id,
'employee_id': rep.id,
'plan_id': plan.id,
'tier_id': applicable_tier.id,
'base_amount': base,
'rate_pct': applicable_tier.rate_pct,
'commission_amount': commission_amount,
'state': 'pending',
})
@api.model
def _get_mtd_base_for_rep(self, rep, invoice_date):
self.env.cr.execute("""
SELECT COALESCE(SUM(base_amount), 0)
FROM commission_line cl
JOIN account_move am ON cl.invoice_id = am.id
WHERE cl.employee_id = %s
AND DATE_TRUNC('month', am.invoice_date) = DATE_TRUNC('month', %s)
""", (rep.id, invoice_date))
return self.env.cr.fetchone()[0]
Verification: post an invoice; a commission.line is created with the right tier rate.
4. Handle commission splits
For deals where multiple reps share credit, store a separate split table:
class SaleOrderRepSplit(models.Model):
_name = 'sale.order.rep.split'
order_id = fields.Many2one('sale.order')
employee_id = fields.Many2one('hr.employee')
split_pct = fields.Float() # must sum to 100 across the order's splits
@api.constrains('split_pct')
def _check_total_100(self):
for order in self.mapped('order_id'):
total = sum(order.rep_split_ids.mapped('split_pct'))
if abs(total - 100) > 0.01:
raise ValidationError(f'Splits on {order.name} must total 100%, got {total}%')
In _compute_commission, iterate splits instead of just invoice_user_id:
splits = move.invoice_origin_so.rep_split_ids if move.invoice_origin_so else False
if splits:
for split in splits:
commission_amount = base * split.split_pct/100 * applicable_tier.rate_pct/100
# Create commission line per rep
Verification: a 70/30 split deal creates two commission lines totaling the single-rep commission.
5. Implement clawback on refunds
When a customer refund is processed:
def _post(self, soft=True):
result = super()._post(soft=soft)
for move in self.filtered(lambda m: m.move_type == 'out_refund' and m.state == 'posted'):
related_invoice = move.reversed_entry_id
if related_invoice:
for cl in related_invoice.commission_line_ids.filtered(lambda c: c.state == 'paid'):
self.env['commission.line'].create({
'invoice_id': move.id,
'employee_id': cl.employee_id.id,
'plan_id': cl.plan_id.id,
'tier_id': cl.tier_id.id,
'base_amount': -move.amount_untaxed * (cl.base_amount / related_invoice.amount_untaxed),
'rate_pct': cl.rate_pct,
'commission_amount': -cl.commission_amount * (move.amount_untaxed / related_invoice.amount_untaxed),
'state': 'clawback',
})
return result
Verification: a $1,000 refund on a $5,000 invoice creates a -$50 clawback commission line (assuming 5% rate, 10% of original amount).
6. Build the monthly statement
For each rep, on the 1st of the next month, generate a PDF showing:
- All commission lines from the prior month
- Tier breakdown
- Clawbacks
- Net payable
@api.model
def _cron_generate_monthly_statements(self):
last_month = (date.today().replace(day=1) - timedelta(days=1)).replace(day=1)
for emp in self.env['hr.employee'].search([('commission_plan_id', '!=', False)]):
lines = self.env['commission.line'].search([
('employee_id', '=', emp.id),
('invoice_id.invoice_date', '>=', last_month),
('invoice_id.invoice_date', '<', last_month + relativedelta(months=1)),
])
if not lines:
continue
report = self.env.ref('commission.report_commission_statement')
pdf = report._render_qweb_pdf(lines.ids)[0]
# Email to rep + finance
self.env['mail.mail'].create({
'subject': f'Commission Statement - {last_month.strftime("%B %Y")}',
'body_html': '<p>Your monthly commission statement is attached.</p>',
'email_to': emp.work_email,
'attachment_ids': [(0,0,{'name':'statement.pdf','datas':base64.b64encode(pdf)})],
}).send()
Verification: each rep receives their PDF on the 1st.
7. Feed into payroll
In Odoo Payroll Enterprise, configure a salary rule named "Commission" that pulls from commission.line records of type "pending" and "clawback":
# In payroll.rule, the python code field:
result = sum(payslip.employee_id.commission_line_ids.filtered(
lambda c: c.state == 'pending' and c.invoice_id.invoice_date.month == payslip.date_from.month
).mapped('commission_amount'))
After payroll posts, mark the commission lines as paid.
Verification: the payslip shows the commission line with the correct amount.
8. Build the leaderboard dashboard
Pivot view of commission lines by rep, by month:
<pivot string="Commission Leaderboard">
<field name="employee_id" type="row"/>
<field name="invoice_id" type="col" interval="month"/>
<field name="commission_amount" type="measure"/>
</pivot>
Verification: leaderboard ranks reps by current month commission.
Common mistakes
- Computing commission on invoice posting before payment received. Causes large clawback exposure if customer pays late or refunds.
- Hardcoding tier thresholds in code. Always store in the model — finance changes them often.
- Not handling pro-rata for partial payments. If invoice is half-paid, commission should be half-recognized (or zero, depending on policy).
- Forgetting to clawback on refunds. Reps love this oversight; finance hates it.
- Computing tiers on annual rather than monthly base. Most plans are monthly; check yours.
Going further
Quota-based accelerators: pay a higher rate after quota is hit (e.g., 8% to 100% of quota, 12% above quota). Drives end-of-quarter push behavior. Configure as a third tier with monthly_threshold = quota_amount per rep.
Multi-product plans: different commission rates per product category. Strategic products at 15%, commodity at 5%. Add product_category_id join to the commission line creation logic.
Commission on net new vs. expansion: pay more for net new logos than for upsells to existing customers. Tag invoice lines as "new" vs "expansion" using customer first-invoice-date logic. Different commission plans per type.
Spiff / SPIF for short-term promos: time-limited bonus on specific products to clear inventory or push new launches. Add a spiff.campaign model with date range, product filter, and bonus amount. Computed alongside regular commission.
Team-vs-individual splits: SaaS deals often involve an AE + SE + CSM. Configure default split percentages per team role and override per deal as needed.
Ramp-down on tenure: junior reps get accelerated commission their first 6 months ("ramp"), normalizing to standard rates after. Helps recruiting without long-term margin impact.
Compliance audit trail: every commission calculation should be reproducible from inputs. Store the full formula context (tier, rate, base, multiplier) on the commission line so reviewers can trace any number back to its source.
Manager overrides: sometimes a deal warrants a manual adjustment ("they upsold mid-quarter so it counts as new logo"). Build a wizard that creates a documented override with approval workflow, never silent edits.
Forecasted commission expense: run the commission calculation against the sales pipeline (open opportunities × close-rate × expected close month) to forecast next-quarter commission expense. Useful for FP&A.
Disputes process: every commission system needs a dispute workflow. Reps challenge a number, manager reviews, finance signs off. Build in a "disputed" state on commission.line with audit trail.
For complex multi-tier commission rollouts including AI-driven quota setting, ECOSIRE custom Odoo development builds the full system. Pair this with how to track project profitability.
Frequently Asked Questions
How do I handle commission on contracts spanning multiple months?
Recognize commission per recognized revenue (matching ASC 606 / IFRS 15). Each month of recognized subscription revenue earns its commission. Saves clawback drama on early cancellations.
What about SDR (Sales Development Rep) commission?
SDRs are typically paid per qualified meeting booked, not per closed deal. Build a separate commission plan keyed off CRM stage transitions instead of invoices.
How do I forecast commission expense?
Run the commission calculation against the sales pipeline (open opportunities × close-rate × deal size) instead of just closed invoices. Useful for FP&A.
What about non-quota team contributions?
Add a "team commission pool" where the team's collective performance funds bonuses to support staff (CSMs, solutions engineers, BDRs).
For multi-plan commission systems including spiffs and team pools, ECOSIRE custom Odoo development builds the entire stack. Or read how to calculate customer lifetime value for the upstream view.
作者
ECOSIRE TeamTechnical Writing
The ECOSIRE technical writing team covers Odoo ERP, Shopify eCommerce, AI agents, Power BI analytics, GoHighLevel automation, and enterprise software best practices. Our guides help businesses make informed technology decisions.
相关文章
如何将自定义按钮添加到 Odoo 表单视图 (2026)
将自定义操作按钮添加到 Odoo 19 表单视图:Python 操作方法、视图继承、条件可见性、确认对话框。经过生产测试。
如何在没有 Studio 的情况下在 Odoo 中添加自定义字段 (2026)
通过 Odoo 19 中的自定义模块添加自定义字段:模型继承、视图扩展、计算字段、存储/非存储决策。代码优先,版本控制。
如何使用外部布局在 Odoo 中添加自定义报告
使用 web.external_layout 在 Odoo 19 中构建品牌 PDF 报告:QWeb 模板、paperformat、操作绑定。带有印刷徽标+页脚覆盖。