diff --git a/commission_product_fixed/README.rst b/commission_product_fixed/README.rst new file mode 100644 index 000000000..693c5d1cd --- /dev/null +++ b/commission_product_fixed/README.rst @@ -0,0 +1,111 @@ +=============================== +Commission Product Fixed Amount +=============================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:43387485aa173a6d5208903bd11e0b7ab0e2f10fee19839e85d107a11bbe546a + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fcommission-lightgray.png?logo=github + :target: https://github.com/OCA/commission/tree/18.0/commission_product_fixed + :alt: OCA/commission +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/commission-18-0/commission-18-0-commission_product_fixed + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/commission&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module adds a new commission type, **Fixed amount per product**, on +top of the OCA commission framework. + +Instead of computing the commission as a percentage of the sale/invoice +amount or the margin, the commission is a fixed amount defined per +product. The amount is independent from the selling price. + +Because each agent is assigned its own commission, the same product can +grant a different fixed amount depending on the agent: just define a +separate commission per agent with its own per-product amounts. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +1. Go to *Invoicing > Configuration > Commissions > Commission types*. +2. Create a commission and set its *Type* to *Fixed amount per product*. +3. In the *Product amounts* table, add a line per product with the fixed + commission amount granted per unit. Optionally set: + + - a *Min. Quantity* to grant a different amount above a quantity + threshold (add several lines for the same product; the highest + applicable threshold wins); + - a *Start Date* / *End Date* to limit the validity of the amount; + - a *Currency* (the amount is converted to the invoice currency when + they differ). + +4. Assign this commission to an agent (*Contacts > the agent > Sales & + Purchase > Commission*). Use a different commission per agent to + grant different amounts for the same product. +5. On a customer invoice, the agent is added to the lines and the + commission is computed as ``fixed amount x quantity``, using the line + matching the invoice date and quantity. + +If a product on a posted invoice line has no fixed amount defined in the +agent's commission, posting the invoice is blocked until the amount is +configured. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Quartile + +Contributors +------------ + +- Quartile (https://www.quartile.co) + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/commission `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/commission_product_fixed/__init__.py b/commission_product_fixed/__init__.py new file mode 100644 index 000000000..0650744f6 --- /dev/null +++ b/commission_product_fixed/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/commission_product_fixed/__manifest__.py b/commission_product_fixed/__manifest__.py new file mode 100644 index 000000000..f9ca1ebba --- /dev/null +++ b/commission_product_fixed/__manifest__.py @@ -0,0 +1,18 @@ +# Copyright 2026 Quartile +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +{ + "name": "Commission Product Fixed Amount", + "version": "19.0.1.0.0", + "author": "Quartile, Odoo Community Association (OCA)", + "category": "Sales Management", + "license": "AGPL-3", + "website": "https://github.com/OCA/commission", + "depends": [ + "account_commission_oca", + ], + "data": [ + "security/ir.model.access.csv", + "views/commission_views.xml", + ], + "installable": True, +} diff --git a/commission_product_fixed/models/__init__.py b/commission_product_fixed/models/__init__.py new file mode 100644 index 000000000..7c924cf21 --- /dev/null +++ b/commission_product_fixed/models/__init__.py @@ -0,0 +1,3 @@ +from . import commission +from . import commission_line_mixin +from . import account_move diff --git a/commission_product_fixed/models/account_move.py b/commission_product_fixed/models/account_move.py new file mode 100644 index 000000000..e52c5b5ab --- /dev/null +++ b/commission_product_fixed/models/account_move.py @@ -0,0 +1,71 @@ +# Copyright 2026 Quartile +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models +from odoo.exceptions import ValidationError + + +class AccountInvoiceLineAgent(models.Model): + _inherit = "account.invoice.line.agent" + + @api.depends("object_id.quantity", "invoice_date") + def _compute_amount(self): + # ``product_fixed`` amounts depend on the invoiced quantity and on the + # invoice date (for quantity tiers and validity windows), which are not + # part of the base dependencies. Add them so the commission amount is + # recomputed when they change. + return super()._compute_amount() + + +class AccountMove(models.Model): + _inherit = "account.move" + + def action_post(self): + self._check_product_fixed_commission() + return super().action_post() + + def _check_product_fixed_commission(self): + """Block posting when a product/agent fixed commission is not defined. + + For every commission of type ``product_fixed`` assigned on an invoice + line, the corresponding product must have an amount configured in the + commission. Otherwise the commission cannot be determined and the + invoice must not be posted. + """ + missing = [] + for move in self: + if move.move_type[:3] != "out": + continue + for line in move.invoice_line_ids: + if line.commission_free: + continue + if not line.product_id: + continue + for agent in line.agent_ids: + commission = agent.commission_id + if commission.commission_type != "product_fixed": + continue + # Use the same effective date as ``_get_commission_amount`` + # (the agent line's own ``invoice_date``) so the guard and + # the amount computation never disagree on line validity. + date = agent.invoice_date or fields.Date.context_today(agent) + if commission._get_product_fixed_line( + line.product_id, date, line.quantity + ): + continue + missing.append( + self.env._( + "%(agent)s / %(product)s (commission: %(commission)s)", + agent=agent.agent_id.display_name, + product=line.product_id.display_name, + commission=commission.display_name, + ) + ) + if missing: + raise ValidationError( + self.env._( + "No fixed commission amount is defined for the following " + "agent / product combinations:\n%s", + "\n".join(f"- {m}" for m in missing), + ) + ) diff --git a/commission_product_fixed/models/commission.py b/commission_product_fixed/models/commission.py new file mode 100644 index 000000000..7747b9de0 --- /dev/null +++ b/commission_product_fixed/models/commission.py @@ -0,0 +1,93 @@ +# Copyright 2026 Quartile +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models +from odoo.exceptions import ValidationError + + +class Commission(models.Model): + _inherit = "commission" + + commission_type = fields.Selection( + selection_add=[("product_fixed", "Fixed amount per product")], + ondelete={"product_fixed": "set default"}, + ) + product_line_ids = fields.One2many( + comodel_name="commission.product.line", + inverse_name="commission_id", + string="Product amounts", + copy=True, + ) + + def _get_product_fixed_line(self, product, date=None, quantity=0.0): + """Return the applicable product amount line (or empty). + + Lines are filtered by product, validity dates and minimum quantity. + The most specific match (highest applicable ``min_qty``) is selected + explicitly, so the result does not depend on the recordset order. + """ + self.ensure_one() + lines = self.product_line_ids.filtered( + lambda x: x.product_id == product and x.min_qty <= quantity + ) + if date: + lines = lines.filtered( + lambda x: (not x.date_start or x.date_start <= date) + and (not x.date_end or x.date_end >= date) + ) + return lines.sorted(key=lambda x: x.min_qty, reverse=True)[:1] + + +class CommissionProductLine(models.Model): + _name = "commission.product.line" + _description = "Fixed commission amount per product" + _order = "sequence, min_qty desc, id" + _rec_name = "product_id" + + sequence = fields.Integer(default=10) + commission_id = fields.Many2one( + comodel_name="commission", + required=True, + ondelete="cascade", + ) + product_id = fields.Many2one( + comodel_name="product.product", + string="Product", + required=True, + ) + min_qty = fields.Float( + string="Min. Quantity", + default=0.0, + help="The minimum invoiced quantity for this amount to apply. When " + "several lines match a product, the one with the highest applicable " + "minimum quantity is used.", + ) + amount = fields.Monetary( + string="Commission Amount", + required=True, + help="Fixed commission amount granted per unit of the product.", + ) + currency_id = fields.Many2one( + comodel_name="res.currency", + default=lambda self: self.env.company.currency_id, + required=True, + ) + date_start = fields.Date( + string="Start Date", + help="Start date for this amount (inclusive).", + ) + date_end = fields.Date( + string="End Date", + help="End date for this amount (inclusive).", + ) + + @api.constrains("date_start", "date_end") + def _check_dates(self): + for line in self: + if not line.date_start or not line.date_end: + continue + if line.date_start <= line.date_end: + continue + raise ValidationError( + self.env._("The start date cannot be after the end date.") + ) diff --git a/commission_product_fixed/models/commission_line_mixin.py b/commission_product_fixed/models/commission_line_mixin.py new file mode 100644 index 000000000..aa587a2c2 --- /dev/null +++ b/commission_product_fixed/models/commission_line_mixin.py @@ -0,0 +1,41 @@ +# Copyright 2026 Quartile +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class CommissionLineMixin(models.AbstractModel): + _inherit = "commission.line.mixin" + + def _get_commission_amount(self, commission, subtotal, product, quantity): + """Compute the commission as a fixed amount per product unit. + + The sale price, discount and margin are not taken into account: the + amount is the per-product fixed value (which differs by agent, as each + agent points to its own commission) multiplied by the quantity. + """ + self.ensure_one() + if commission and commission.commission_type == "product_fixed": + if product.commission_free: + return 0.0 + date = getattr(self, "invoice_date", False) or ( + fields.Date.context_today(self) + ) + line = commission._get_product_fixed_line(product, date, quantity) + if not line: + # Missing configuration. The amount is left at 0 here and the + # posting of the invoice is blocked in account.move to force a + # proper setup (see _check_product_fixed_commission). + return 0.0 + amount = line.amount * quantity + if ( + line.currency_id + and self.currency_id + and (line.currency_id != self.currency_id) + ): + company = getattr(self, "company_id", False) or self.env.company + amount = line.currency_id._convert( + amount, self.currency_id, company, date + ) + return amount + return super()._get_commission_amount(commission, subtotal, product, quantity) diff --git a/commission_product_fixed/pyproject.toml b/commission_product_fixed/pyproject.toml new file mode 100644 index 000000000..4231d0ccc --- /dev/null +++ b/commission_product_fixed/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/commission_product_fixed/readme/CONTRIBUTORS.md b/commission_product_fixed/readme/CONTRIBUTORS.md new file mode 100644 index 000000000..4a7661be4 --- /dev/null +++ b/commission_product_fixed/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- Quartile () + - Tatsuki Kanda diff --git a/commission_product_fixed/readme/DESCRIPTION.md b/commission_product_fixed/readme/DESCRIPTION.md new file mode 100644 index 000000000..67221f512 --- /dev/null +++ b/commission_product_fixed/readme/DESCRIPTION.md @@ -0,0 +1,10 @@ +This module adds a new commission type, **Fixed amount per product**, on top +of the OCA commission framework. + +Instead of computing the commission as a percentage of the sale/invoice amount +or the margin, the commission is a fixed amount defined per product. The amount +is independent from the selling price. + +Because each agent is assigned its own commission, the same product can grant a +different fixed amount depending on the agent: just define a separate commission +per agent with its own per-product amounts. diff --git a/commission_product_fixed/readme/USAGE.md b/commission_product_fixed/readme/USAGE.md new file mode 100644 index 000000000..cda5ce8fe --- /dev/null +++ b/commission_product_fixed/readme/USAGE.md @@ -0,0 +1,20 @@ +1. Go to *Invoicing > Configuration > Commissions > Commission types*. +2. Create a commission and set its *Type* to *Fixed amount per product*. +3. In the *Product amounts* table, add a line per product with the fixed + commission amount granted per unit. Optionally set: + - a *Min. Quantity* to grant a different amount above a quantity threshold + (add several lines for the same product; the highest applicable + threshold wins); + - a *Start Date* / *End Date* to limit the validity of the amount; + - a *Currency* (the amount is converted to the invoice currency when they + differ). +4. Assign this commission to an agent (*Contacts > the agent > Sales & + Purchase > Commission*). Use a different commission per agent to grant + different amounts for the same product. +5. On a customer invoice, the agent is added to the lines and the commission + is computed as `fixed amount x quantity`, using the line matching the + invoice date and quantity. + +If a product on a posted invoice line has no fixed amount defined in the +agent's commission, posting the invoice is blocked until the amount is +configured. diff --git a/commission_product_fixed/security/ir.model.access.csv b/commission_product_fixed/security/ir.model.access.csv new file mode 100644 index 000000000..241e43b8b --- /dev/null +++ b/commission_product_fixed/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_commission_product_line_manager,access_commission_product_line_manager,model_commission_product_line,commission_oca.group_commission_manager,1,1,1,1 +access_commission_product_line_user,access_commission_product_line_user,model_commission_product_line,commission_oca.group_commission_user,1,0,0,0 diff --git a/commission_product_fixed/static/description/index.html b/commission_product_fixed/static/description/index.html new file mode 100644 index 000000000..7ecf2d982 --- /dev/null +++ b/commission_product_fixed/static/description/index.html @@ -0,0 +1,454 @@ + + + + + +Commission Product Fixed Amount + + + +
+

Commission Product Fixed Amount

+ + +

Beta License: AGPL-3 OCA/commission Translate me on Weblate Try me on Runboat

+

This module adds a new commission type, Fixed amount per product, on +top of the OCA commission framework.

+

Instead of computing the commission as a percentage of the sale/invoice +amount or the margin, the commission is a fixed amount defined per +product. The amount is independent from the selling price.

+

Because each agent is assigned its own commission, the same product can +grant a different fixed amount depending on the agent: just define a +separate commission per agent with its own per-product amounts.

+

Table of contents

+ +
+

Usage

+
    +
  1. Go to Invoicing > Configuration > Commissions > Commission types.
  2. +
  3. Create a commission and set its Type to Fixed amount per product.
  4. +
  5. In the Product amounts table, add a line per product with the fixed +commission amount granted per unit. Optionally set:
      +
    • a Min. Quantity to grant a different amount above a quantity +threshold (add several lines for the same product; the highest +applicable threshold wins);
    • +
    • a Start Date / End Date to limit the validity of the amount;
    • +
    • a Currency (the amount is converted to the invoice currency when +they differ).
    • +
    +
  6. +
  7. Assign this commission to an agent (Contacts > the agent > Sales & +Purchase > Commission). Use a different commission per agent to +grant different amounts for the same product.
  8. +
  9. On a customer invoice, the agent is added to the lines and the +commission is computed as fixed amount x quantity, using the line +matching the invoice date and quantity.
  10. +
+

If a product on a posted invoice line has no fixed amount defined in the +agent’s commission, posting the invoice is blocked until the amount is +configured.

+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Quartile
  • +
+
+ +
+

Maintainers

+

This module is maintained by the OCA.

+Odoo Community Association +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/commission project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/commission_product_fixed/tests/__init__.py b/commission_product_fixed/tests/__init__.py new file mode 100644 index 000000000..86dfe3b14 --- /dev/null +++ b/commission_product_fixed/tests/__init__.py @@ -0,0 +1 @@ +from . import test_commission_product_fixed diff --git a/commission_product_fixed/tests/test_commission_product_fixed.py b/commission_product_fixed/tests/test_commission_product_fixed.py new file mode 100644 index 000000000..0bfe20d57 --- /dev/null +++ b/commission_product_fixed/tests/test_commission_product_fixed.py @@ -0,0 +1,178 @@ +# Copyright 2026 Quartile +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from datetime import date + +from odoo import Command +from odoo.exceptions import ValidationError +from odoo.tests import tagged + +from odoo.addons.account.tests.common import AccountTestInvoicingCommon + + +@tagged("post_install", "-at_install") +class TestCommissionProductFixed(AccountTestInvoicingCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.product_a = cls._create_product(name="Product A") + cls.product_b = cls._create_product(name="Product B") + cls.agent_x = cls.env["res.partner"].create({"name": "Agent X", "agent": True}) + cls.agent_y = cls.env["res.partner"].create({"name": "Agent Y", "agent": True}) + cls.commission_x = cls.env["commission"].create( + { + "name": "Agent X scheme", + "commission_type": "product_fixed", + "settlement_type": "sale_invoice", + "product_line_ids": [ + Command.create({"product_id": cls.product_a.id, "amount": 500}), + Command.create({"product_id": cls.product_b.id, "amount": 300}), + ], + } + ) + cls.commission_y = cls.env["commission"].create( + { + "name": "Agent Y scheme", + "commission_type": "product_fixed", + "settlement_type": "sale_invoice", + "product_line_ids": [ + Command.create({"product_id": cls.product_a.id, "amount": 650}), + ], + } + ) + cls.customer = cls.partner_a + + def _create_invoice( + self, + agent, + commission, + product, + qty, + invoice_date=None, + move_type="out_invoice", + ): + return self.env["account.move"].create( + { + "move_type": move_type, + "partner_id": self.customer.id, + "invoice_date": invoice_date, + "invoice_line_ids": [ + Command.create( + { + "product_id": product.id, + "quantity": qty, + "price_unit": 1000, + "agent_ids": [ + Command.create( + { + "agent_id": agent.id, + "commission_id": commission.id, + } + ) + ], + } + ) + ], + } + ) + + def test_fixed_amount_times_quantity(self): + invoice = self._create_invoice( + self.agent_x, self.commission_x, self.product_a, 3 + ) + agent_line = invoice.invoice_line_ids.agent_ids + # 500 (fixed) x 3 (qty), independent of the 1000 price. + self.assertEqual(agent_line.amount, 1500) + + def test_amount_differs_by_agent(self): + invoice_x = self._create_invoice( + self.agent_x, self.commission_x, self.product_a, 1 + ) + invoice_y = self._create_invoice( + self.agent_y, self.commission_y, self.product_a, 1 + ) + self.assertEqual(invoice_x.invoice_line_ids.agent_ids.amount, 500) + self.assertEqual(invoice_y.invoice_line_ids.agent_ids.amount, 650) + + def test_post_blocked_when_amount_missing(self): + # Agent Y has no amount defined for product B. + invoice = self._create_invoice( + self.agent_y, self.commission_y, self.product_b, 1 + ) + self.assertEqual(invoice.invoice_line_ids.agent_ids.amount, 0) + with self.assertRaises(ValidationError): + invoice.action_post() + + def test_post_allowed_when_amount_defined(self): + invoice = self._create_invoice( + self.agent_x, self.commission_x, self.product_b, 2 + ) + invoice.action_post() + self.assertEqual(invoice.state, "posted") + self.assertEqual(invoice.invoice_line_ids.agent_ids.amount, 600) + + def test_min_qty_tier(self): + # Volume tier: above 10 units, product A grants 400 instead of 500. + self.env["commission.product.line"].create( + { + "commission_id": self.commission_x.id, + "product_id": self.product_a.id, + "min_qty": 10, + "amount": 400, + } + ) + below = self._create_invoice(self.agent_x, self.commission_x, self.product_a, 5) + above = self._create_invoice( + self.agent_x, self.commission_x, self.product_a, 12 + ) + self.assertEqual(below.invoice_line_ids.agent_ids.amount, 2500) # 500 x 5 + self.assertEqual(above.invoice_line_ids.agent_ids.amount, 4800) # 400 x 12 + + def test_date_validity(self): + # Restrict the product A amount to a window that excludes the invoice. + self.commission_x.product_line_ids.filtered( + lambda x: x.product_id == self.product_a + ).write({"date_start": date(2026, 1, 1), "date_end": date(2026, 1, 31)}) + out_of_range = self._create_invoice( + self.agent_x, + self.commission_x, + self.product_a, + 1, + invoice_date=date(2026, 6, 1), + ) + self.assertEqual(out_of_range.invoice_line_ids.agent_ids.amount, 0) + with self.assertRaises(ValidationError): + out_of_range.action_post() + in_range = self._create_invoice( + self.agent_x, + self.commission_x, + self.product_a, + 1, + invoice_date=date(2026, 1, 15), + ) + self.assertEqual(in_range.invoice_line_ids.agent_ids.amount, 500) + + def test_start_date_after_end_date(self): + with self.assertRaises(ValidationError): + self.env["commission.product.line"].create( + { + "commission_id": self.commission_x.id, + "product_id": self.product_b.id, + "amount": 100, + "date_start": date(2026, 2, 1), + "date_end": date(2026, 1, 1), + } + ) + + def test_refund_amount_is_negative(self): + # On a refund the base _compute_amount negates the commission, so the + # fixed amount must come out as -(amount x quantity). + refund = self._create_invoice( + self.agent_x, + self.commission_x, + self.product_a, + 3, + move_type="out_refund", + ) + # -(500 fixed x 3 qty), mirroring test_fixed_amount_times_quantity. + self.assertEqual(refund.invoice_line_ids.agent_ids.amount, -1500) diff --git a/commission_product_fixed/views/commission_views.xml b/commission_product_fixed/views/commission_views.xml new file mode 100644 index 000000000..9d0bb5449 --- /dev/null +++ b/commission_product_fixed/views/commission_views.xml @@ -0,0 +1,28 @@ + + + + commission.form.product.fixed + commission + + + + + + + + + + + + + + + + + +