From 31e593ccbe7d8505d89cd460a6c5a3c7fd3919f6 Mon Sep 17 00:00:00 2001
From: sbejaoui
Date: Sat, 15 Nov 2025 12:01:02 +0100
Subject: [PATCH 1/8] [ADD] rma_sale_stock_restocking_fee_invoicing
This module extends the standard RMA flow and the behavior of
`sale_stock_restocking_fee_invoicing` by allowing:
- Fixed or percentage-based restocking fees.
- Automatic fee application during RMA receipt.
- Integration with different refund strategies:
- Update sale order quantity.
- Manual refund after receipt.
---
.../README.rst | 147 +++++
.../__init__.py | 1 +
.../__manifest__.py | 16 +
.../models/__init__.py | 3 +
.../models/rma.py | 105 ++++
.../models/rma_operation.py | 42 ++
.../models/sale_order.py | 18 +
.../pyproject.toml | 3 +
.../readme/CONFIGURE.md | 10 +
.../readme/CONTEXT.md | 3 +
.../readme/CONTRIBUTORS.md | 2 +
.../readme/DESCRIPTION.md | 8 +
.../readme/USAGE.md | 16 +
.../static/description/icon.png | Bin 0 -> 9455 bytes
.../static/description/index.html | 500 ++++++++++++++++++
.../tests/__init__.py | 1 +
...rma_sale_stock_restocking_fee_invoicing.py | 140 +++++
.../views/rma.xml | 21 +
.../views/rma_operation.xml | 23 +
19 files changed, 1059 insertions(+)
create mode 100644 rma_sale_stock_restocking_fee_invoicing/README.rst
create mode 100644 rma_sale_stock_restocking_fee_invoicing/__init__.py
create mode 100644 rma_sale_stock_restocking_fee_invoicing/__manifest__.py
create mode 100644 rma_sale_stock_restocking_fee_invoicing/models/__init__.py
create mode 100644 rma_sale_stock_restocking_fee_invoicing/models/rma.py
create mode 100644 rma_sale_stock_restocking_fee_invoicing/models/rma_operation.py
create mode 100644 rma_sale_stock_restocking_fee_invoicing/models/sale_order.py
create mode 100644 rma_sale_stock_restocking_fee_invoicing/pyproject.toml
create mode 100644 rma_sale_stock_restocking_fee_invoicing/readme/CONFIGURE.md
create mode 100644 rma_sale_stock_restocking_fee_invoicing/readme/CONTEXT.md
create mode 100644 rma_sale_stock_restocking_fee_invoicing/readme/CONTRIBUTORS.md
create mode 100644 rma_sale_stock_restocking_fee_invoicing/readme/DESCRIPTION.md
create mode 100644 rma_sale_stock_restocking_fee_invoicing/readme/USAGE.md
create mode 100644 rma_sale_stock_restocking_fee_invoicing/static/description/icon.png
create mode 100644 rma_sale_stock_restocking_fee_invoicing/static/description/index.html
create mode 100644 rma_sale_stock_restocking_fee_invoicing/tests/__init__.py
create mode 100644 rma_sale_stock_restocking_fee_invoicing/tests/test_rma_sale_stock_restocking_fee_invoicing.py
create mode 100644 rma_sale_stock_restocking_fee_invoicing/views/rma.xml
create mode 100644 rma_sale_stock_restocking_fee_invoicing/views/rma_operation.xml
diff --git a/rma_sale_stock_restocking_fee_invoicing/README.rst b/rma_sale_stock_restocking_fee_invoicing/README.rst
new file mode 100644
index 000000000..781ebab4d
--- /dev/null
+++ b/rma_sale_stock_restocking_fee_invoicing/README.rst
@@ -0,0 +1,147 @@
+=======================================
+Rma Sale Stock Restocking Fee Invoicing
+=======================================
+
+..
+ !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
+ !! This file is generated by oca-gen-addon-readme !!
+ !! changes will be overwritten. !!
+ !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
+ !! source digest: sha256:4c8ef119dafb288179ed2f770f06093332f3bfcc350cb2fbd7976b603579a8a7
+ !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
+
+.. |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%2Frma-lightgray.png?logo=github
+ :target: https://github.com/OCA/rma/tree/18.0/rma_sale_stock_restocking_fee_invoicing
+ :alt: OCA/rma
+.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png
+ :target: https://translation.odoo-community.org/projects/rma-18-0/rma-18-0-rma_sale_stock_restocking_fee_invoicing
+ :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/rma&target_branch=18.0
+ :alt: Try me on Runboat
+
+|badge1| |badge2| |badge3| |badge4| |badge5|
+
+This module extends the standard RMA flow and the behavior of
+``sale_stock_restocking_fee_invoicing`` by allowing:
+
+- Fixed or percentage-based restocking fees.
+- Automatic fee application during RMA receipt.
+- Integration with different refund strategies:
+
+ - Update sale order quantity.
+ - Manual refund after receipt.
+
+**Table of contents**
+
+.. contents::
+ :local:
+
+Use Cases / Context
+===================
+
+RMAs often involve administrative and logistic processing costs when
+goods are returned. Depending on the company policy and the nature of
+the return, these costs may be passed on to the customer as a
+*restocking fee*.
+
+Configuration
+=============
+
+To enable and configure restocking fees for RMAs:
+
+1. Go to **RMA / Configuration / Operations**.
+2. Open the RMA operation for which restocking fees should apply.
+3. Set **Restocking Fee Type**:
+
+ - **Fixed Amount**: A fixed amount will be added or invoiced.
+ - **Percentage**: A percentage of the original sale line subtotal is
+ used.
+
+4. Set the **Restocking Fee Amount**:
+
+ - If *fixed*: monetary amount.
+ - If *percentage*: value between 0 and 100.
+
+Usage
+=====
+
+Applying a Restocking Fee
+-------------------------
+
+1. Create or select a sale order.
+2. Deliver the products.
+3. Initiate an RMA from the sale order.
+
+The RMA operation determines how the fee will be applied:
+
+1. Update Quantity Strategy
+~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+If the RMA operation "Refund Action" is "Update Quantities":
+
+- A restocking fee sale order line is automatically added at reception.
+- The fee value depends on the selected fee type.
+
+2. Manual Refund Strategy
+~~~~~~~~~~~~~~~~~~~~~~~~~
+
+If the RMA operation uses "Refund Action" is different than "Update
+Quantities"
+
+- A restocking fee invoice is automatically created at reception.
+
+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
+-------
+
+* ACSONE SA/NV
+
+Contributors
+------------
+
+- Souheil Bejaoui - ACSONE SA/NV souheil.bejaoui@acsone.eu
+- Marie Lejeune - ACSONE SA/NV marie.lejeune@acsone.eu
+
+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.
+
+.. |maintainer-sbejaoui| image:: https://github.com/sbejaoui.png?size=40px
+ :target: https://github.com/sbejaoui
+ :alt: sbejaoui
+
+Current `maintainer `__:
+
+|maintainer-sbejaoui|
+
+This module is part of the `OCA/rma `_ project on GitHub.
+
+You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.
diff --git a/rma_sale_stock_restocking_fee_invoicing/__init__.py b/rma_sale_stock_restocking_fee_invoicing/__init__.py
new file mode 100644
index 000000000..0650744f6
--- /dev/null
+++ b/rma_sale_stock_restocking_fee_invoicing/__init__.py
@@ -0,0 +1 @@
+from . import models
diff --git a/rma_sale_stock_restocking_fee_invoicing/__manifest__.py b/rma_sale_stock_restocking_fee_invoicing/__manifest__.py
new file mode 100644
index 000000000..1b40ed697
--- /dev/null
+++ b/rma_sale_stock_restocking_fee_invoicing/__manifest__.py
@@ -0,0 +1,16 @@
+# Copyright 2025 ACSONE SA/NV
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
+
+{
+ "name": "Rma Sale Stock Restocking Fee Invoicing",
+ "summary": """Extends the RMA flow to automatically apply fixed or percentage
+ restocking fees.""",
+ "version": "18.0.1.0.0",
+ "license": "AGPL-3",
+ "author": "ACSONE SA/NV,Odoo Community Association (OCA)",
+ "maintainers": ["sbejaoui"],
+ "website": "https://github.com/OCA/rma",
+ "depends": ["sale_stock_restocking_fee_invoicing", "rma_sale"],
+ "data": ["views/rma_operation.xml", "views/rma.xml"],
+ "demo": [],
+}
diff --git a/rma_sale_stock_restocking_fee_invoicing/models/__init__.py b/rma_sale_stock_restocking_fee_invoicing/models/__init__.py
new file mode 100644
index 000000000..53927a65a
--- /dev/null
+++ b/rma_sale_stock_restocking_fee_invoicing/models/__init__.py
@@ -0,0 +1,3 @@
+from . import rma_operation
+from . import rma
+from . import sale_order
diff --git a/rma_sale_stock_restocking_fee_invoicing/models/rma.py b/rma_sale_stock_restocking_fee_invoicing/models/rma.py
new file mode 100644
index 000000000..ddf5b7737
--- /dev/null
+++ b/rma_sale_stock_restocking_fee_invoicing/models/rma.py
@@ -0,0 +1,105 @@
+# Copyright 2025 ACSONE SA/NV
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
+
+from odoo import Command, _, api, fields, models
+from odoo.exceptions import ValidationError
+
+
+class Rma(models.Model):
+ _inherit = "rma"
+
+ restocking_fee_invoice_id = fields.Many2one(
+ comodel_name="account.move", readonly=True
+ )
+ manual_restocking_fee_invoice_needed = fields.Boolean(
+ compute="_compute_manual_restocking_fee_invoice_needed"
+ )
+
+ @api.depends(
+ "operation_id.action_create_receipt",
+ "operation_id.action_create_refund",
+ "operation_id.restocking_fee_type",
+ "restocking_fee_invoice_id",
+ )
+ def _compute_manual_restocking_fee_invoice_needed(self):
+ for rec in self:
+ rec.manual_restocking_fee_invoice_needed = (
+ not rec.restocking_fee_invoice_id
+ and rec.operation_id.action_create_receipt
+ and rec.operation_id.action_create_refund != "update_quantity"
+ and rec.operation_id.restocking_fee_type
+ )
+
+ def _prepare_reception_procurement_vals(self, group=None):
+ vals = super()._prepare_reception_procurement_vals(group=group)
+ vals["charge_restocking_fee"] = bool(self.operation_id.restocking_fee_type)
+ return vals
+
+ def update_received_state_on_reception(self):
+ res = super().update_received_state_on_reception()
+ self._create_restocking_fee_invoice()
+ return res
+
+ def _create_restocking_fee_invoice(self):
+ for rec in self:
+ if not rec.manual_restocking_fee_invoice_needed:
+ continue
+ rec.restocking_fee_invoice_id = self.env["account.move"].create(
+ rec._prepare_restocking_fee_invoice_vals()
+ )
+
+ def _prepare_restocking_fee_invoice_vals(self):
+ self.ensure_one()
+ return {
+ "move_type": "out_invoice",
+ "company_id": self.company_id.id,
+ "partner_id": self.partner_invoice_id.id,
+ "invoice_line_ids": [
+ Command.create(self._prepare_restocking_fee_invoice_line_vals())
+ ],
+ }
+
+ def _get_restocking_fee_invoice_line_name(self):
+ lang = self.partner_id.lang
+ return _(
+ "Restocking fee for %(prod_uom_qty)s %(prod_uom)s. RMA %(rma)s",
+ prod_uom_qty=self.product_uom_qty,
+ prod_uom=self.product_uom.with_context(lang=lang).name,
+ rma=self.name,
+ )
+
+ def _prepare_restocking_fee_invoice_line_vals(self):
+ self.ensure_one()
+ product_id = self.company_id.restocking_fee_product_id
+ if not product_id:
+ raise ValidationError(
+ _(
+ "No product configured for restocking fee. "
+ "Please fix the configuration into stock settings or "
+ "contact you administrator."
+ )
+ )
+ name = self.with_context(
+ lang=self.partner_id.lang
+ )._get_restocking_fee_invoice_line_name()
+ return {
+ "name": name,
+ "quantity": 1,
+ "product_uom_id": product_id.uom_id.id,
+ "product_id": product_id.id,
+ "price_unit": self.operation_id._get_restocking_fee_amount(
+ self.product_id.lst_price
+ ),
+ }
+
+ def action_view_restocking_fee_invoice(self):
+ self.ensure_one()
+ return {
+ "name": self.env._("Invoice"),
+ "type": "ir.actions.act_window",
+ "view_type": "form",
+ "view_mode": "form",
+ "res_model": "account.move",
+ "views": [(self.env.ref("account.view_move_form").id, "form")],
+ "res_id": self.restocking_fee_invoice_id.id,
+ }
diff --git a/rma_sale_stock_restocking_fee_invoicing/models/rma_operation.py b/rma_sale_stock_restocking_fee_invoicing/models/rma_operation.py
new file mode 100644
index 000000000..839b47cda
--- /dev/null
+++ b/rma_sale_stock_restocking_fee_invoicing/models/rma_operation.py
@@ -0,0 +1,42 @@
+# Copyright 2025 ACSONE SA/NV
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
+
+from odoo import _, api, fields, models
+from odoo.exceptions import ValidationError
+
+
+class RmaOperation(models.Model):
+ _inherit = "rma.operation"
+
+ restocking_fee_type = fields.Selection(
+ [
+ ("fixed", "Fixed Amount"),
+ ("percent", "Percentage"),
+ ],
+ help="Define whether the restocking fee is a fixed amount or a percentage.",
+ )
+
+ restocking_fee_amount = fields.Float()
+
+ @api.constrains("restocking_fee_type", "restocking_fee_amount")
+ def _check_restocking_fee_amount(self):
+ for rec in self:
+ if not rec.restocking_fee_type:
+ continue
+ if rec.restocking_fee_amount <= 0:
+ raise ValidationError(
+ _("Restocking fee amount must be greater than zero.")
+ )
+ if rec.restocking_fee_type == "percent":
+ if rec.restocking_fee_amount > 100:
+ raise ValidationError(
+ _("Restocking fee percentage cannot exceed 100%.")
+ )
+
+ def _get_restocking_fee_amount(self, price_subtotal):
+ self.ensure_one()
+ if not self.restocking_fee_type:
+ return 0
+ if self.restocking_fee_type == "fixed":
+ return self.restocking_fee_amount
+ return price_subtotal * (self.restocking_fee_amount / 100)
diff --git a/rma_sale_stock_restocking_fee_invoicing/models/sale_order.py b/rma_sale_stock_restocking_fee_invoicing/models/sale_order.py
new file mode 100644
index 000000000..74ce8925b
--- /dev/null
+++ b/rma_sale_stock_restocking_fee_invoicing/models/sale_order.py
@@ -0,0 +1,18 @@
+# Copyright 2025 ACSONE SA/NV
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
+
+from odoo import models
+
+
+class SaleOrder(models.Model):
+ _inherit = "sale.order"
+
+ def _get_restocking_fee_line_value(self, stock_move):
+ vals = super()._get_restocking_fee_line_value(stock_move)
+ rma = stock_move.rma_receiver_ids
+ if not rma:
+ return vals
+ vals["price_unit"] = rma.operation_id._get_restocking_fee_amount(
+ stock_move.sale_line_id.price_subtotal
+ )
+ return vals
diff --git a/rma_sale_stock_restocking_fee_invoicing/pyproject.toml b/rma_sale_stock_restocking_fee_invoicing/pyproject.toml
new file mode 100644
index 000000000..4231d0ccc
--- /dev/null
+++ b/rma_sale_stock_restocking_fee_invoicing/pyproject.toml
@@ -0,0 +1,3 @@
+[build-system]
+requires = ["whool"]
+build-backend = "whool.buildapi"
diff --git a/rma_sale_stock_restocking_fee_invoicing/readme/CONFIGURE.md b/rma_sale_stock_restocking_fee_invoicing/readme/CONFIGURE.md
new file mode 100644
index 000000000..d5081bd8f
--- /dev/null
+++ b/rma_sale_stock_restocking_fee_invoicing/readme/CONFIGURE.md
@@ -0,0 +1,10 @@
+To enable and configure restocking fees for RMAs:
+
+1. Go to **RMA / Configuration / Operations**.
+2. Open the RMA operation for which restocking fees should apply.
+3. Set **Restocking Fee Type**:
+ - **Fixed Amount**: A fixed amount will be added or invoiced.
+ - **Percentage**: A percentage of the original sale line subtotal is used.
+4. Set the **Restocking Fee Amount**:
+ - If *fixed*: monetary amount.
+ - If *percentage*: value between 0 and 100.
diff --git a/rma_sale_stock_restocking_fee_invoicing/readme/CONTEXT.md b/rma_sale_stock_restocking_fee_invoicing/readme/CONTEXT.md
new file mode 100644
index 000000000..95c8189f1
--- /dev/null
+++ b/rma_sale_stock_restocking_fee_invoicing/readme/CONTEXT.md
@@ -0,0 +1,3 @@
+RMAs often involve administrative and logistic processing costs when goods are
+returned. Depending on the company policy and the nature of the return,
+these costs may be passed on to the customer as a *restocking fee*.
diff --git a/rma_sale_stock_restocking_fee_invoicing/readme/CONTRIBUTORS.md b/rma_sale_stock_restocking_fee_invoicing/readme/CONTRIBUTORS.md
new file mode 100644
index 000000000..0c9b52500
--- /dev/null
+++ b/rma_sale_stock_restocking_fee_invoicing/readme/CONTRIBUTORS.md
@@ -0,0 +1,2 @@
+- Souheil Bejaoui - ACSONE SA/NV
+- Marie Lejeune - ACSONE SA/NV
diff --git a/rma_sale_stock_restocking_fee_invoicing/readme/DESCRIPTION.md b/rma_sale_stock_restocking_fee_invoicing/readme/DESCRIPTION.md
new file mode 100644
index 000000000..cadca0659
--- /dev/null
+++ b/rma_sale_stock_restocking_fee_invoicing/readme/DESCRIPTION.md
@@ -0,0 +1,8 @@
+This module extends the standard RMA flow and the behavior of
+`sale_stock_restocking_fee_invoicing` by allowing:
+
+- Fixed or percentage-based restocking fees.
+- Automatic fee application during RMA receipt.
+- Integration with different refund strategies:
+ - Update sale order quantity.
+ - Manual refund after receipt.
diff --git a/rma_sale_stock_restocking_fee_invoicing/readme/USAGE.md b/rma_sale_stock_restocking_fee_invoicing/readme/USAGE.md
new file mode 100644
index 000000000..a26e174d0
--- /dev/null
+++ b/rma_sale_stock_restocking_fee_invoicing/readme/USAGE.md
@@ -0,0 +1,16 @@
+## Applying a Restocking Fee
+
+1. Create or select a sale order.
+2. Deliver the products.
+3. Initiate an RMA from the sale order.
+
+The RMA operation determines how the fee will be applied:
+
+### 1. Update Quantity Strategy
+If the RMA operation "Refund Action" is "Update Quantities":
+- A restocking fee sale order line is automatically added at reception.
+- The fee value depends on the selected fee type.
+
+### 2. Manual Refund Strategy
+If the RMA operation uses "Refund Action" is different than "Update Quantities"
+- A restocking fee invoice is automatically created at reception.
\ No newline at end of file
diff --git a/rma_sale_stock_restocking_fee_invoicing/static/description/icon.png b/rma_sale_stock_restocking_fee_invoicing/static/description/icon.png
new file mode 100644
index 0000000000000000000000000000000000000000..3a0328b516c4980e8e44cdb63fd945757ddd132d
GIT binary patch
literal 9455
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
z
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
Xviia!U7SGha1wx#SCgwmn*{w2TRX*I
literal 0
HcmV?d00001
diff --git a/rma_sale_stock_restocking_fee_invoicing/static/description/index.html b/rma_sale_stock_restocking_fee_invoicing/static/description/index.html
new file mode 100644
index 000000000..c77c0b6b4
--- /dev/null
+++ b/rma_sale_stock_restocking_fee_invoicing/static/description/index.html
@@ -0,0 +1,500 @@
+
+
+
+
+
+Rma Sale Stock Restocking Fee Invoicing
+
+
+
+
+
Rma Sale Stock Restocking Fee Invoicing
+
+
+
+
This module extends the standard RMA flow and the behavior of
+sale_stock_restocking_fee_invoicing by allowing:
+
+Fixed or percentage-based restocking fees.
+Automatic fee application during RMA receipt.
+Integration with different refund strategies:
+Update sale order quantity.
+Manual refund after receipt.
+
+
+
+
Table of contents
+
+
+
+
RMAs often involve administrative and logistic processing costs when
+goods are returned. Depending on the company policy and the nature of
+the return, these costs may be passed on to the customer as a
+restocking fee .
+
+
+
+
To enable and configure restocking fees for RMAs:
+
+Go to RMA / Configuration / Operations .
+Open the RMA operation for which restocking fees should apply.
+Set Restocking Fee Type :
+Fixed Amount : A fixed amount will be added or invoiced.
+Percentage : A percentage of the original sale line subtotal is
+used.
+
+
+Set the Restocking Fee Amount :
+If fixed : monetary amount.
+If percentage : value between 0 and 100.
+
+
+
+
+
+
+
+
+
+Create or select a sale order.
+Deliver the products.
+Initiate an RMA from the sale order.
+
+
The RMA operation determines how the fee will be applied:
+
+
+
If the RMA operation “Refund Action” is “Update Quantities”:
+
+A restocking fee sale order line is automatically added at reception.
+The fee value depends on the selected fee type.
+
+
+
+
+
If the RMA operation uses “Refund Action” is different than “Update
+Quantities”
+
+A restocking fee invoice is automatically created at reception.
+
+
+
+
+
+
+
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.
+
+
+
+
+
+
+
+
This module is maintained by the OCA.
+
+
+
+
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.
+
Current maintainer :
+
+
This module is part of the OCA/rma project on GitHub.
+
You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute .
+
+
+
+
+
diff --git a/rma_sale_stock_restocking_fee_invoicing/tests/__init__.py b/rma_sale_stock_restocking_fee_invoicing/tests/__init__.py
new file mode 100644
index 000000000..a1d1a8b34
--- /dev/null
+++ b/rma_sale_stock_restocking_fee_invoicing/tests/__init__.py
@@ -0,0 +1 @@
+from . import test_rma_sale_stock_restocking_fee_invoicing
diff --git a/rma_sale_stock_restocking_fee_invoicing/tests/test_rma_sale_stock_restocking_fee_invoicing.py b/rma_sale_stock_restocking_fee_invoicing/tests/test_rma_sale_stock_restocking_fee_invoicing.py
new file mode 100644
index 000000000..4a4807a94
--- /dev/null
+++ b/rma_sale_stock_restocking_fee_invoicing/tests/test_rma_sale_stock_restocking_fee_invoicing.py
@@ -0,0 +1,140 @@
+# Copyright 2025 ACSONE SA/NV
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
+
+from odoo.exceptions import ValidationError
+
+from odoo.addons.rma_sale.tests.test_rma_sale import TestRmaSaleBase
+
+
+class TestRmaSaleStockRestockingFeeInvoicing(TestRmaSaleBase):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.sale_order = cls._create_sale_order([[cls.product_1, 5]])
+ cls.sale_order.action_confirm()
+ cls.order_line = cls.sale_order.order_line.filtered(
+ lambda r: r.product_id == cls.product_1
+ )
+ cls.order_out_picking = cls.sale_order.picking_ids
+ cls.order_out_picking.move_ids.quantity = 5
+ cls.order_out_picking.button_validate()
+ cls.product_restocking_fee = cls.env.ref(
+ "sale_stock_restocking_fee_invoicing.product_restocking_fee"
+ )
+
+ def _create_receive_rma(self):
+ wizard = self._rma_sale_wizard(self.sale_order)
+ rma = self.env["rma"].browse(wizard.create_and_open_rma()["res_id"])
+ self.assertTrue(rma.reception_move_id)
+ self.assertTrue(rma.reception_move_id.charge_restocking_fee)
+ rma.reception_move_id.picking_id.button_validate()
+ self.assertEqual(rma.reception_move_id.state, "done")
+ return rma
+
+ def test_0(self):
+ """ensure restocking_fee_type enforces correct constraints on amount and
+ percentage"""
+ with self.assertRaisesRegex(
+ ValidationError, "Restocking fee amount must be greater than zero"
+ ):
+ self.operation.restocking_fee_type = "fixed"
+ with self.assertRaisesRegex(
+ ValidationError, "Restocking fee amount must be greater than zero"
+ ):
+ self.operation.restocking_fee_type = "percent"
+
+ with self.assertRaisesRegex(
+ ValidationError, "Restocking fee percentage cannot exceed 100%"
+ ):
+ self.operation.write(
+ {"restocking_fee_type": "percent", "restocking_fee_amount": 105}
+ )
+
+ def test_1(self):
+ """no restocking fee is applied when restocking_fee_type is not set"""
+ self.assertFalse(self.operation.restocking_fee_type)
+ wizard = self._rma_sale_wizard(self.sale_order)
+ rma = self.env["rma"].browse(wizard.create_and_open_rma()["res_id"])
+ self.assertTrue(rma.reception_move_id)
+ self.assertFalse(rma.reception_move_id.charge_restocking_fee)
+ self.assertFalse(rma.manual_restocking_fee_invoice_needed)
+
+ def test_2(self):
+ """a restocking fee must not be added to the sale order when refund strategy
+ is not 'update_quantity'"""
+ self.assertEqual(len(self.sale_order.order_line), 1)
+ self.operation.write(
+ {"restocking_fee_type": "fixed", "restocking_fee_amount": 5.5}
+ )
+ self._create_receive_rma()
+ self.assertEqual(len(self.sale_order.order_line), 1)
+
+ def test_3(self):
+ """when refund strategy is 'update_quantity' and fee type is fixed,
+ a restocking fee line is added with the correct amount, fixed case"""
+ self.assertEqual(len(self.sale_order.order_line), 1)
+ self.operation.write(
+ {"restocking_fee_type": "fixed", "restocking_fee_amount": 5.5}
+ )
+ self.operation.action_create_refund = "update_quantity"
+ self._create_receive_rma()
+ self.assertEqual(len(self.sale_order.order_line), 2)
+ restocking_fee_line = self.sale_order.order_line.filtered(
+ lambda line: line.product_id == self.product_restocking_fee
+ )
+ self.assertTrue(restocking_fee_line)
+ self.assertEqual(restocking_fee_line.price_subtotal, 5.5)
+
+ def test_4(self):
+ """when refund strategy is 'update_quantity' and fee type is percentage-based,
+ the fee line is added using the sale line subtotal"""
+ self.sale_order.order_line.price_unit = 300
+ self.assertEqual(self.sale_order.order_line.price_subtotal, 1500) # 5 * 300
+ self.assertEqual(len(self.sale_order.order_line), 1)
+ self.operation.write(
+ {"restocking_fee_type": "percent", "restocking_fee_amount": 80}
+ )
+ self.operation.action_create_refund = "update_quantity"
+ self._create_receive_rma()
+ self.assertEqual(len(self.sale_order.order_line), 2)
+ restocking_fee_line = self.sale_order.order_line.filtered(
+ lambda line: line.product_id == self.product_restocking_fee
+ )
+ self.assertTrue(restocking_fee_line)
+ self.assertEqual(restocking_fee_line.price_subtotal, 1200) # 1500*0.8
+
+ def test_5(self):
+ """when refund strategy is 'manual_after_receipt' and fee type is fixed,
+ a restocking fee invoice is created with the correct amount, fixed case"""
+ self.assertEqual(len(self.sale_order.order_line), 1)
+ self.operation.write(
+ {"restocking_fee_type": "fixed", "restocking_fee_amount": 5.5}
+ )
+ self.operation.action_create_refund = "manual_after_receipt"
+ rma = self._create_receive_rma()
+ self.assertEqual(len(self.sale_order.order_line), 1)
+ invoice = rma.restocking_fee_invoice_id
+ self.assertTrue(invoice)
+ action = rma.action_view_restocking_fee_invoice()
+ self.assertEqual(action.get("res_id"), invoice.id)
+ self.assertEqual(len(invoice.invoice_line_ids), 1)
+ self.assertEqual(invoice.invoice_line_ids.price_subtotal, 5.5)
+
+ def test_6(self):
+ """when refund strategy is 'manual_after_receipt' and fee type is
+ percentage-based, the fee invoice uses the correct percentage of the
+ product price"""
+ self.assertEqual(len(self.sale_order.order_line), 1)
+ self.product_1.lst_price = 300
+ self.operation.write(
+ {"restocking_fee_type": "percent", "restocking_fee_amount": 80}
+ )
+ self.operation.action_create_refund = "manual_after_receipt"
+ rma = self._create_receive_rma()
+ self.assertEqual(len(self.sale_order.order_line), 1)
+ invoice = rma.restocking_fee_invoice_id
+ self.assertTrue(invoice)
+ action = rma.action_view_restocking_fee_invoice()
+ self.assertEqual(action.get("res_id"), invoice.id)
+ self.assertEqual(len(invoice.invoice_line_ids), 1)
+ self.assertEqual(invoice.invoice_line_ids.price_subtotal, 240) # 300*0.8
diff --git a/rma_sale_stock_restocking_fee_invoicing/views/rma.xml b/rma_sale_stock_restocking_fee_invoicing/views/rma.xml
new file mode 100644
index 000000000..9c524b737
--- /dev/null
+++ b/rma_sale_stock_restocking_fee_invoicing/views/rma.xml
@@ -0,0 +1,21 @@
+
+
+
+
+ rma
+
+
+
+
+
+
+
+
diff --git a/rma_sale_stock_restocking_fee_invoicing/views/rma_operation.xml b/rma_sale_stock_restocking_fee_invoicing/views/rma_operation.xml
new file mode 100644
index 000000000..4d8003bff
--- /dev/null
+++ b/rma_sale_stock_restocking_fee_invoicing/views/rma_operation.xml
@@ -0,0 +1,23 @@
+
+
+
+
+ rma.operation
+
+
+
+
+
+
+
+
+
+
+
+
+
From 2e91e5d78bc98e5f38f7939b07dc4ca27e80d30b Mon Sep 17 00:00:00 2001
From: sbejaoui
Date: Sat, 15 Nov 2025 12:02:10 +0100
Subject: [PATCH 2/8] [DON'T MERGE] add test-requirements.txt
---
test-requirements.txt | 1 +
1 file changed, 1 insertion(+)
create mode 100644 test-requirements.txt
diff --git a/test-requirements.txt b/test-requirements.txt
new file mode 100644
index 000000000..162a6b199
--- /dev/null
+++ b/test-requirements.txt
@@ -0,0 +1 @@
+odoo-addon-sale-stock-restocking-fee-invoicing @ git+https://github.com/OCA/stock-logistics-workflow.git@refs/pull/2150/head#subdirectory=sale_stock_restocking_fee_invoicing
From cc6fc67227d67aaeb5e14d412d440f7459f8a62b Mon Sep 17 00:00:00 2001
From: sbejaoui
Date: Thu, 20 Nov 2025 12:29:01 +0100
Subject: [PATCH 3/8] [IMP] rma_sale_stock_restocking_fee_invoicing: allow to
input restocking fee on rma
---
.../models/__init__.py | 1 +
.../models/restocking_fee_mixin.py | 43 +++++++++++++++++++
.../models/rma.py | 37 +++++++++++++---
.../models/rma_operation.py | 39 ++---------------
.../models/sale_order.py | 2 +-
...rma_sale_stock_restocking_fee_invoicing.py | 43 ++++++++++++++++++-
.../views/rma.xml | 15 +++++++
7 files changed, 135 insertions(+), 45 deletions(-)
create mode 100644 rma_sale_stock_restocking_fee_invoicing/models/restocking_fee_mixin.py
diff --git a/rma_sale_stock_restocking_fee_invoicing/models/__init__.py b/rma_sale_stock_restocking_fee_invoicing/models/__init__.py
index 53927a65a..1188d664e 100644
--- a/rma_sale_stock_restocking_fee_invoicing/models/__init__.py
+++ b/rma_sale_stock_restocking_fee_invoicing/models/__init__.py
@@ -1,3 +1,4 @@
+from . import restocking_fee_mixin
from . import rma_operation
from . import rma
from . import sale_order
diff --git a/rma_sale_stock_restocking_fee_invoicing/models/restocking_fee_mixin.py b/rma_sale_stock_restocking_fee_invoicing/models/restocking_fee_mixin.py
new file mode 100644
index 000000000..3cedd0512
--- /dev/null
+++ b/rma_sale_stock_restocking_fee_invoicing/models/restocking_fee_mixin.py
@@ -0,0 +1,43 @@
+# Copyright 2025 ACSONE SA/NV
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
+
+from odoo import _, api, fields, models
+from odoo.exceptions import ValidationError
+
+
+class RestockingFeeMixin(models.AbstractModel):
+ _name = "restocking.fee.mixin"
+ _description = "Restocking Fee Mixin"
+
+ restocking_fee_type = fields.Selection(
+ [
+ ("fixed", "Fixed Amount"),
+ ("percent", "Percentage"),
+ ],
+ help="Define whether the restocking fee is a fixed amount or a percentage.",
+ )
+
+ restocking_fee_amount = fields.Float()
+
+ @api.constrains("restocking_fee_type", "restocking_fee_amount")
+ def _check_restocking_fee_amount(self):
+ for rec in self:
+ if not rec.restocking_fee_type:
+ continue
+ if rec.restocking_fee_amount <= 0:
+ raise ValidationError(
+ _("Restocking fee amount must be greater than zero.")
+ )
+ if rec.restocking_fee_type == "percent":
+ if rec.restocking_fee_amount > 100:
+ raise ValidationError(
+ _("Restocking fee percentage cannot exceed 100%.")
+ )
+
+ def _get_restocking_fee_amount(self, price_subtotal):
+ self.ensure_one()
+ if not self.restocking_fee_type:
+ return 0
+ if self.restocking_fee_type == "fixed":
+ return self.restocking_fee_amount
+ return price_subtotal * (self.restocking_fee_amount / 100)
diff --git a/rma_sale_stock_restocking_fee_invoicing/models/rma.py b/rma_sale_stock_restocking_fee_invoicing/models/rma.py
index ddf5b7737..78e9a74a5 100644
--- a/rma_sale_stock_restocking_fee_invoicing/models/rma.py
+++ b/rma_sale_stock_restocking_fee_invoicing/models/rma.py
@@ -6,7 +6,8 @@
class Rma(models.Model):
- _inherit = "rma"
+ _name = "rma"
+ _inherit = ["rma", "restocking.fee.mixin"]
restocking_fee_invoice_id = fields.Many2one(
comodel_name="account.move", readonly=True
@@ -14,11 +15,35 @@ class Rma(models.Model):
manual_restocking_fee_invoice_needed = fields.Boolean(
compute="_compute_manual_restocking_fee_invoice_needed"
)
+ restocking_fee_type = fields.Selection(
+ compute="_compute_restocking_fee", store=True, readonly=False
+ )
+ restocking_fee_amount = fields.Float(
+ compute="_compute_restocking_fee", store=True, readonly=False
+ )
+ restocking_fee_visibility = fields.Boolean(
+ compute="_compute_restocking_fee_visibility"
+ )
+
+ @api.depends("operation_id")
+ def _compute_restocking_fee(self):
+ for rec in self:
+ rec.update(
+ {
+ "restocking_fee_type": rec.operation_id.restocking_fee_type,
+ "restocking_fee_amount": rec.operation_id.restocking_fee_amount,
+ }
+ )
+
+ @api.depends("operation_id")
+ def _compute_restocking_fee_visibility(self):
+ for rec in self:
+ rec.restocking_fee_visibility = bool(rec.action_create_receipt)
@api.depends(
"operation_id.action_create_receipt",
"operation_id.action_create_refund",
- "operation_id.restocking_fee_type",
+ "restocking_fee_type",
"restocking_fee_invoice_id",
)
def _compute_manual_restocking_fee_invoice_needed(self):
@@ -27,12 +52,12 @@ def _compute_manual_restocking_fee_invoice_needed(self):
not rec.restocking_fee_invoice_id
and rec.operation_id.action_create_receipt
and rec.operation_id.action_create_refund != "update_quantity"
- and rec.operation_id.restocking_fee_type
+ and rec.restocking_fee_type
)
def _prepare_reception_procurement_vals(self, group=None):
vals = super()._prepare_reception_procurement_vals(group=group)
- vals["charge_restocking_fee"] = bool(self.operation_id.restocking_fee_type)
+ vals["charge_restocking_fee"] = bool(self.restocking_fee_type)
return vals
def update_received_state_on_reception(self):
@@ -87,9 +112,7 @@ def _prepare_restocking_fee_invoice_line_vals(self):
"quantity": 1,
"product_uom_id": product_id.uom_id.id,
"product_id": product_id.id,
- "price_unit": self.operation_id._get_restocking_fee_amount(
- self.product_id.lst_price
- ),
+ "price_unit": self._get_restocking_fee_amount(self.product_id.lst_price),
}
def action_view_restocking_fee_invoice(self):
diff --git a/rma_sale_stock_restocking_fee_invoicing/models/rma_operation.py b/rma_sale_stock_restocking_fee_invoicing/models/rma_operation.py
index 839b47cda..bcc5ddc4a 100644
--- a/rma_sale_stock_restocking_fee_invoicing/models/rma_operation.py
+++ b/rma_sale_stock_restocking_fee_invoicing/models/rma_operation.py
@@ -1,42 +1,9 @@
# Copyright 2025 ACSONE SA/NV
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
-from odoo import _, api, fields, models
-from odoo.exceptions import ValidationError
+from odoo import models
class RmaOperation(models.Model):
- _inherit = "rma.operation"
-
- restocking_fee_type = fields.Selection(
- [
- ("fixed", "Fixed Amount"),
- ("percent", "Percentage"),
- ],
- help="Define whether the restocking fee is a fixed amount or a percentage.",
- )
-
- restocking_fee_amount = fields.Float()
-
- @api.constrains("restocking_fee_type", "restocking_fee_amount")
- def _check_restocking_fee_amount(self):
- for rec in self:
- if not rec.restocking_fee_type:
- continue
- if rec.restocking_fee_amount <= 0:
- raise ValidationError(
- _("Restocking fee amount must be greater than zero.")
- )
- if rec.restocking_fee_type == "percent":
- if rec.restocking_fee_amount > 100:
- raise ValidationError(
- _("Restocking fee percentage cannot exceed 100%.")
- )
-
- def _get_restocking_fee_amount(self, price_subtotal):
- self.ensure_one()
- if not self.restocking_fee_type:
- return 0
- if self.restocking_fee_type == "fixed":
- return self.restocking_fee_amount
- return price_subtotal * (self.restocking_fee_amount / 100)
+ _name = "rma.operation"
+ _inherit = ["rma.operation", "restocking.fee.mixin"]
diff --git a/rma_sale_stock_restocking_fee_invoicing/models/sale_order.py b/rma_sale_stock_restocking_fee_invoicing/models/sale_order.py
index 74ce8925b..c607441b0 100644
--- a/rma_sale_stock_restocking_fee_invoicing/models/sale_order.py
+++ b/rma_sale_stock_restocking_fee_invoicing/models/sale_order.py
@@ -12,7 +12,7 @@ def _get_restocking_fee_line_value(self, stock_move):
rma = stock_move.rma_receiver_ids
if not rma:
return vals
- vals["price_unit"] = rma.operation_id._get_restocking_fee_amount(
+ vals["price_unit"] = rma._get_restocking_fee_amount(
stock_move.sale_line_id.price_subtotal
)
return vals
diff --git a/rma_sale_stock_restocking_fee_invoicing/tests/test_rma_sale_stock_restocking_fee_invoicing.py b/rma_sale_stock_restocking_fee_invoicing/tests/test_rma_sale_stock_restocking_fee_invoicing.py
index 4a4807a94..ef4c0a9c7 100644
--- a/rma_sale_stock_restocking_fee_invoicing/tests/test_rma_sale_stock_restocking_fee_invoicing.py
+++ b/rma_sale_stock_restocking_fee_invoicing/tests/test_rma_sale_stock_restocking_fee_invoicing.py
@@ -22,11 +22,15 @@ def setUpClass(cls):
"sale_stock_restocking_fee_invoicing.product_restocking_fee"
)
- def _create_receive_rma(self):
+ def _create_rma(self):
wizard = self._rma_sale_wizard(self.sale_order)
rma = self.env["rma"].browse(wizard.create_and_open_rma()["res_id"])
self.assertTrue(rma.reception_move_id)
self.assertTrue(rma.reception_move_id.charge_restocking_fee)
+ return rma
+
+ def _create_receive_rma(self):
+ rma = self._create_rma()
rma.reception_move_id.picking_id.button_validate()
self.assertEqual(rma.reception_move_id.state, "done")
return rma
@@ -138,3 +142,40 @@ def test_6(self):
self.assertEqual(action.get("res_id"), invoice.id)
self.assertEqual(len(invoice.invoice_line_ids), 1)
self.assertEqual(invoice.invoice_line_ids.price_subtotal, 240) # 300*0.8
+
+ def test_7(self):
+ """update_quantity, custom restocking fee"""
+ self.assertEqual(len(self.sale_order.order_line), 1)
+ self.operation.write(
+ {"restocking_fee_type": "fixed", "restocking_fee_amount": 5.5}
+ )
+ self.operation.action_create_refund = "update_quantity"
+ rma = self._create_rma()
+ rma.write({"restocking_fee_type": "fixed", "restocking_fee_amount": 12.5})
+ rma.reception_move_id.picking_id.button_validate()
+ self.assertEqual(rma.reception_move_id.state, "done")
+ self.assertEqual(len(self.sale_order.order_line), 2)
+ restocking_fee_line = self.sale_order.order_line.filtered(
+ lambda line: line.product_id == self.product_restocking_fee
+ )
+ self.assertTrue(restocking_fee_line)
+ self.assertEqual(restocking_fee_line.price_subtotal, 12.5)
+
+ def test_8(self):
+ """invoice manual_after_receipt, custom restocking fee"""
+ self.assertEqual(len(self.sale_order.order_line), 1)
+ self.operation.write(
+ {"restocking_fee_type": "fixed", "restocking_fee_amount": 5.5}
+ )
+ self.operation.action_create_refund = "manual_after_receipt"
+ rma = self._create_rma()
+ rma.write({"restocking_fee_type": "fixed", "restocking_fee_amount": 12.5})
+ rma.reception_move_id.picking_id.button_validate()
+ self.assertEqual(rma.reception_move_id.state, "done")
+ self.assertEqual(len(self.sale_order.order_line), 1)
+ invoice = rma.restocking_fee_invoice_id
+ self.assertTrue(invoice)
+ action = rma.action_view_restocking_fee_invoice()
+ self.assertEqual(action.get("res_id"), invoice.id)
+ self.assertEqual(len(invoice.invoice_line_ids), 1)
+ self.assertEqual(invoice.invoice_line_ids.price_subtotal, 12.5)
diff --git a/rma_sale_stock_restocking_fee_invoicing/views/rma.xml b/rma_sale_stock_restocking_fee_invoicing/views/rma.xml
index 9c524b737..460016e97 100644
--- a/rma_sale_stock_restocking_fee_invoicing/views/rma.xml
+++ b/rma_sale_stock_restocking_fee_invoicing/views/rma.xml
@@ -16,6 +16,21 @@
invisible="not restocking_fee_invoice_id"
/>
+
+
+
+
+
+
+
+
From 7b9d31b09597ce148cd7c73c4d3d072f181798f6 Mon Sep 17 00:00:00 2001
From: sbejaoui
Date: Thu, 20 Nov 2025 14:12:46 +0100
Subject: [PATCH 4/8] [FIX] rma_sale_stock_restocking_fee_invoicing: price is
based on sale line
---
.../models/rma.py | 6 +++++-
.../test_rma_sale_stock_restocking_fee_invoicing.py | 12 +++++++++++-
2 files changed, 16 insertions(+), 2 deletions(-)
diff --git a/rma_sale_stock_restocking_fee_invoicing/models/rma.py b/rma_sale_stock_restocking_fee_invoicing/models/rma.py
index 78e9a74a5..a94fa5b7f 100644
--- a/rma_sale_stock_restocking_fee_invoicing/models/rma.py
+++ b/rma_sale_stock_restocking_fee_invoicing/models/rma.py
@@ -112,7 +112,11 @@ def _prepare_restocking_fee_invoice_line_vals(self):
"quantity": 1,
"product_uom_id": product_id.uom_id.id,
"product_id": product_id.id,
- "price_unit": self._get_restocking_fee_amount(self.product_id.lst_price),
+ "price_unit": self._get_restocking_fee_amount(
+ self.sale_line_id.price_subtotal
+ if self.sale_line_id
+ else self.product_id.lst_price
+ ),
}
def action_view_restocking_fee_invoice(self):
diff --git a/rma_sale_stock_restocking_fee_invoicing/tests/test_rma_sale_stock_restocking_fee_invoicing.py b/rma_sale_stock_restocking_fee_invoicing/tests/test_rma_sale_stock_restocking_fee_invoicing.py
index ef4c0a9c7..f4ad6fd54 100644
--- a/rma_sale_stock_restocking_fee_invoicing/tests/test_rma_sale_stock_restocking_fee_invoicing.py
+++ b/rma_sale_stock_restocking_fee_invoicing/tests/test_rma_sale_stock_restocking_fee_invoicing.py
@@ -134,7 +134,17 @@ def test_6(self):
{"restocking_fee_type": "percent", "restocking_fee_amount": 80}
)
self.operation.action_create_refund = "manual_after_receipt"
- rma = self._create_receive_rma()
+ rma = self.env["rma"].create(
+ {
+ "partner_id": self.partner.id,
+ "product_id": self.product_1.id,
+ "product_uom_qty": 5,
+ "operation_id": self.operation.id,
+ }
+ )
+ rma.action_confirm()
+ rma.reception_move_id.picking_id.button_validate()
+ self.assertEqual(rma.reception_move_id.state, "done")
self.assertEqual(len(self.sale_order.order_line), 1)
invoice = rma.restocking_fee_invoice_id
self.assertTrue(invoice)
From 56c4044ac7ccf763cdc47ab0ef90ac8b2735e850 Mon Sep 17 00:00:00 2001
From: sbejaoui
Date: Thu, 20 Nov 2025 14:19:20 +0100
Subject: [PATCH 5/8] [FIX] rma_sale_stock_restocking_fee_invoicing: create
invoice at reception
---
.../models/__init__.py | 1 +
.../models/rma.py | 5 -----
.../models/stock_move.py | 14 ++++++++++++++
3 files changed, 15 insertions(+), 5 deletions(-)
create mode 100644 rma_sale_stock_restocking_fee_invoicing/models/stock_move.py
diff --git a/rma_sale_stock_restocking_fee_invoicing/models/__init__.py b/rma_sale_stock_restocking_fee_invoicing/models/__init__.py
index 1188d664e..b67ac6ff8 100644
--- a/rma_sale_stock_restocking_fee_invoicing/models/__init__.py
+++ b/rma_sale_stock_restocking_fee_invoicing/models/__init__.py
@@ -2,3 +2,4 @@
from . import rma_operation
from . import rma
from . import sale_order
+from . import stock_move
diff --git a/rma_sale_stock_restocking_fee_invoicing/models/rma.py b/rma_sale_stock_restocking_fee_invoicing/models/rma.py
index a94fa5b7f..6ce320a71 100644
--- a/rma_sale_stock_restocking_fee_invoicing/models/rma.py
+++ b/rma_sale_stock_restocking_fee_invoicing/models/rma.py
@@ -60,11 +60,6 @@ def _prepare_reception_procurement_vals(self, group=None):
vals["charge_restocking_fee"] = bool(self.restocking_fee_type)
return vals
- def update_received_state_on_reception(self):
- res = super().update_received_state_on_reception()
- self._create_restocking_fee_invoice()
- return res
-
def _create_restocking_fee_invoice(self):
for rec in self:
if not rec.manual_restocking_fee_invoice_needed:
diff --git a/rma_sale_stock_restocking_fee_invoicing/models/stock_move.py b/rma_sale_stock_restocking_fee_invoicing/models/stock_move.py
new file mode 100644
index 000000000..ba61e53da
--- /dev/null
+++ b/rma_sale_stock_restocking_fee_invoicing/models/stock_move.py
@@ -0,0 +1,14 @@
+# Copyright 2025 ACSONE SA/NV
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
+
+from odoo import models
+
+
+class StockMove(models.Model):
+ _inherit = "stock.move"
+
+ def _action_done(self, cancel_backorder=False):
+ res = super()._action_done(cancel_backorder=cancel_backorder)
+ move_done = self.filtered(lambda r: r.state == "done").sudo()
+ move_done.sudo().mapped("rma_receiver_ids")._create_restocking_fee_invoice()
+ return res
From a59f388f72cc18410767b11f29faf8500226d8c4 Mon Sep 17 00:00:00 2001
From: sbejaoui
Date: Mon, 24 Nov 2025 17:31:12 +0100
Subject: [PATCH 6/8] [IMP] rma_sale_stock_restocking_fee_invoicing: manage
different UoM & use sale line price_unit
---
.../models/restocking_fee_mixin.py | 8 ----
.../models/rma.py | 21 +++++++---
.../models/sale_order.py | 4 +-
...rma_sale_stock_restocking_fee_invoicing.py | 38 +++++++++++++++++--
4 files changed, 52 insertions(+), 19 deletions(-)
diff --git a/rma_sale_stock_restocking_fee_invoicing/models/restocking_fee_mixin.py b/rma_sale_stock_restocking_fee_invoicing/models/restocking_fee_mixin.py
index 3cedd0512..7ad6ddc54 100644
--- a/rma_sale_stock_restocking_fee_invoicing/models/restocking_fee_mixin.py
+++ b/rma_sale_stock_restocking_fee_invoicing/models/restocking_fee_mixin.py
@@ -33,11 +33,3 @@ def _check_restocking_fee_amount(self):
raise ValidationError(
_("Restocking fee percentage cannot exceed 100%.")
)
-
- def _get_restocking_fee_amount(self, price_subtotal):
- self.ensure_one()
- if not self.restocking_fee_type:
- return 0
- if self.restocking_fee_type == "fixed":
- return self.restocking_fee_amount
- return price_subtotal * (self.restocking_fee_amount / 100)
diff --git a/rma_sale_stock_restocking_fee_invoicing/models/rma.py b/rma_sale_stock_restocking_fee_invoicing/models/rma.py
index 6ce320a71..69b53007c 100644
--- a/rma_sale_stock_restocking_fee_invoicing/models/rma.py
+++ b/rma_sale_stock_restocking_fee_invoicing/models/rma.py
@@ -107,11 +107,7 @@ def _prepare_restocking_fee_invoice_line_vals(self):
"quantity": 1,
"product_uom_id": product_id.uom_id.id,
"product_id": product_id.id,
- "price_unit": self._get_restocking_fee_amount(
- self.sale_line_id.price_subtotal
- if self.sale_line_id
- else self.product_id.lst_price
- ),
+ "price_unit": self._get_restocking_fee_amount(),
}
def action_view_restocking_fee_invoice(self):
@@ -125,3 +121,18 @@ def action_view_restocking_fee_invoice(self):
"views": [(self.env.ref("account.view_move_form").id, "form")],
"res_id": self.restocking_fee_invoice_id.id,
}
+
+ def _get_restocking_fee_amount(self):
+ self.ensure_one()
+ if not self.restocking_fee_type:
+ return 0
+ if self.restocking_fee_type == "fixed":
+ return self.restocking_fee_amount
+ if self.sale_line_id:
+ price_unit = self.sale_line_id.price_unit
+ price_unit = self.sale_line_id.product_uom._compute_price(
+ price_unit, self.product_uom
+ )
+ else:
+ price_unit = self.product_id.lst_price
+ return (price_unit * (self.restocking_fee_amount / 100)) * self.product_uom_qty
diff --git a/rma_sale_stock_restocking_fee_invoicing/models/sale_order.py b/rma_sale_stock_restocking_fee_invoicing/models/sale_order.py
index c607441b0..047a5eb44 100644
--- a/rma_sale_stock_restocking_fee_invoicing/models/sale_order.py
+++ b/rma_sale_stock_restocking_fee_invoicing/models/sale_order.py
@@ -12,7 +12,5 @@ def _get_restocking_fee_line_value(self, stock_move):
rma = stock_move.rma_receiver_ids
if not rma:
return vals
- vals["price_unit"] = rma._get_restocking_fee_amount(
- stock_move.sale_line_id.price_subtotal
- )
+ vals["price_unit"] = rma._get_restocking_fee_amount()
return vals
diff --git a/rma_sale_stock_restocking_fee_invoicing/tests/test_rma_sale_stock_restocking_fee_invoicing.py b/rma_sale_stock_restocking_fee_invoicing/tests/test_rma_sale_stock_restocking_fee_invoicing.py
index f4ad6fd54..9a202b594 100644
--- a/rma_sale_stock_restocking_fee_invoicing/tests/test_rma_sale_stock_restocking_fee_invoicing.py
+++ b/rma_sale_stock_restocking_fee_invoicing/tests/test_rma_sale_stock_restocking_fee_invoicing.py
@@ -131,14 +131,14 @@ def test_6(self):
self.assertEqual(len(self.sale_order.order_line), 1)
self.product_1.lst_price = 300
self.operation.write(
- {"restocking_fee_type": "percent", "restocking_fee_amount": 80}
+ {"restocking_fee_type": "percent", "restocking_fee_amount": 25}
)
self.operation.action_create_refund = "manual_after_receipt"
rma = self.env["rma"].create(
{
"partner_id": self.partner.id,
"product_id": self.product_1.id,
- "product_uom_qty": 5,
+ "product_uom_qty": 2,
"operation_id": self.operation.id,
}
)
@@ -151,7 +151,7 @@ def test_6(self):
action = rma.action_view_restocking_fee_invoice()
self.assertEqual(action.get("res_id"), invoice.id)
self.assertEqual(len(invoice.invoice_line_ids), 1)
- self.assertEqual(invoice.invoice_line_ids.price_subtotal, 240) # 300*0.8
+ self.assertEqual(invoice.invoice_line_ids.price_subtotal, 150) # 300*0.25*2
def test_7(self):
"""update_quantity, custom restocking fee"""
@@ -189,3 +189,35 @@ def test_8(self):
self.assertEqual(action.get("res_id"), invoice.id)
self.assertEqual(len(invoice.invoice_line_ids), 1)
self.assertEqual(invoice.invoice_line_ids.price_subtotal, 12.5)
+
+ def test_10(self):
+ """when refund strategy is 'update_quantity' and fee type is percentage-based,
+ the fee line is added using the sale line subtotal, even if the sale line UoM
+ differs from the product UoM
+ """
+ uom_unit = self.env.ref("uom.product_uom_unit")
+ uom_dozen = self.env.ref("uom.product_uom_dozen")
+ self.product_1.uom_id = uom_unit
+ sale_line = self.sale_order.order_line
+ sale_line.product_uom = uom_dozen
+ sale_line.product_uom_qty = 5 # 5 dozens
+ sale_line.price_unit = 300
+ self.assertEqual(sale_line.price_subtotal, 1500)
+ self.assertEqual(len(self.sale_order.order_line), 1)
+ self.operation.write(
+ {"restocking_fee_type": "percent", "restocking_fee_amount": 10}
+ )
+ self.operation.action_create_refund = "update_quantity"
+ rma = self._create_receive_rma()
+ self.assertEqual(rma.product_uom, uom_unit)
+ self.assertEqual(rma.product_uom_qty, 5)
+ self.assertEqual(sale_line.product_uom, uom_dozen)
+ self.assertEqual(sale_line.product_uom_qty, 5)
+ self.assertEqual(len(self.sale_order.order_line), 2)
+ restocking_fee_line = self.sale_order.order_line.filtered(
+ lambda line: line.product_id == self.product_restocking_fee
+ )
+ self.assertTrue(restocking_fee_line)
+ self.assertEqual(
+ restocking_fee_line.price_subtotal, 12.5
+ ) # (300 /12) * 10% * 5
From 08d0cb6e06ed018aea39f2dc786191c22583974e Mon Sep 17 00:00:00 2001
From: Marie Lejeune
Date: Fri, 13 Mar 2026 09:33:35 +0100
Subject: [PATCH 7/8] [DONT MERGE] test-requirements
---
test-requirements.txt | 1 +
1 file changed, 1 insertion(+)
diff --git a/test-requirements.txt b/test-requirements.txt
index 162a6b199..8eae6ddbb 100644
--- a/test-requirements.txt
+++ b/test-requirements.txt
@@ -1 +1,2 @@
odoo-addon-sale-stock-restocking-fee-invoicing @ git+https://github.com/OCA/stock-logistics-workflow.git@refs/pull/2150/head#subdirectory=sale_stock_restocking_fee_invoicing
+odoo-addon-stock-move-propagate-first-move @ git+https://github.com/OCA/stock-logistics-workflow.git@refs/pull/2270/head#subdirectory=stock_move_propagate_first_move
From 2e2f7b0b3afa76061477642c76e895d8a0b4371c Mon Sep 17 00:00:00 2001
From: Marie Lejeune
Date: Tue, 17 Mar 2026 11:44:32 +0100
Subject: [PATCH 8/8] [IMP] rma_sale_stock_restocking_fee_invoicing: multi-step
return process Charge fees at the last step of the return process in case of
a multi-step return route.
---
.../README.rst | 42 +--
.../__manifest__.py | 6 +-
.../models/rma.py | 25 ++
.../models/sale_order.py | 2 +-
.../models/stock_move.py | 23 +-
.../readme/CONFIGURE.md | 3 +
.../readme/CONTEXT.md | 4 +-
.../readme/DESCRIPTION.md | 3 +-
.../readme/USAGE.md | 4 +-
.../static/description/index.html | 17 +-
.../tests/__init__.py | 1 +
.../tests/common.py | 37 +++
.../tests/test_rma_multi_step_return_route.py | 255 ++++++++++++++++++
...rma_sale_stock_restocking_fee_invoicing.py | 34 +--
14 files changed, 398 insertions(+), 58 deletions(-)
create mode 100644 rma_sale_stock_restocking_fee_invoicing/tests/common.py
create mode 100644 rma_sale_stock_restocking_fee_invoicing/tests/test_rma_multi_step_return_route.py
diff --git a/rma_sale_stock_restocking_fee_invoicing/README.rst b/rma_sale_stock_restocking_fee_invoicing/README.rst
index 781ebab4d..b27c2745e 100644
--- a/rma_sale_stock_restocking_fee_invoicing/README.rst
+++ b/rma_sale_stock_restocking_fee_invoicing/README.rst
@@ -31,12 +31,14 @@ Rma Sale Stock Restocking Fee Invoicing
This module extends the standard RMA flow and the behavior of
``sale_stock_restocking_fee_invoicing`` by allowing:
-- Fixed or percentage-based restocking fees.
-- Automatic fee application during RMA receipt.
-- Integration with different refund strategies:
+- Fixed or percentage-based restocking fees.
+- Restocking fees configurable on the RMA operation or directly on the
+ RMA itself.
+- Automatic fee application at last step of RMA receipt.
+- Integration with different refund strategies:
- - Update sale order quantity.
- - Manual refund after receipt.
+ - Update sale order quantity.
+ - Manual refund after receipt.
**Table of contents**
@@ -49,7 +51,9 @@ Use Cases / Context
RMAs often involve administrative and logistic processing costs when
goods are returned. Depending on the company policy and the nature of
the return, these costs may be passed on to the customer as a
-*restocking fee*.
+*restocking fee*. These restocking fees can be fixed before the customer
+returns the product (in case of a late return demand for eg) or decided
+in the middle of the return process (if the product is damaged for eg).
Configuration
=============
@@ -60,14 +64,18 @@ To enable and configure restocking fees for RMAs:
2. Open the RMA operation for which restocking fees should apply.
3. Set **Restocking Fee Type**:
- - **Fixed Amount**: A fixed amount will be added or invoiced.
- - **Percentage**: A percentage of the original sale line subtotal is
- used.
+ - **Fixed Amount**: A fixed amount will be added or invoiced.
+ - **Percentage**: A percentage of the original sale line subtotal is
+ used.
4. Set the **Restocking Fee Amount**:
- - If *fixed*: monetary amount.
- - If *percentage*: value between 0 and 100.
+ - If *fixed*: monetary amount.
+ - If *percentage*: value between 0 and 100.
+
+It is also possible to add, remove or change restocking fees directly on
+the RMA, as soon as the last move of the return moves is not validated
+yet.
Usage
=====
@@ -86,8 +94,9 @@ The RMA operation determines how the fee will be applied:
If the RMA operation "Refund Action" is "Update Quantities":
-- A restocking fee sale order line is automatically added at reception.
-- The fee value depends on the selected fee type.
+- A restocking fee sale order line is automatically added when the last
+ move of the reception chain is validated.
+- The fee value depends on the selected fee type.
2. Manual Refund Strategy
~~~~~~~~~~~~~~~~~~~~~~~~~
@@ -95,7 +104,8 @@ If the RMA operation "Refund Action" is "Update Quantities":
If the RMA operation uses "Refund Action" is different than "Update
Quantities"
-- A restocking fee invoice is automatically created at reception.
+- A restocking fee invoice is automatically created when the last move
+ of the reception chain is validated.
Bug Tracker
===========
@@ -118,8 +128,8 @@ Authors
Contributors
------------
-- Souheil Bejaoui - ACSONE SA/NV souheil.bejaoui@acsone.eu
-- Marie Lejeune - ACSONE SA/NV marie.lejeune@acsone.eu
+- Souheil Bejaoui - ACSONE SA/NV souheil.bejaoui@acsone.eu
+- Marie Lejeune - ACSONE SA/NV marie.lejeune@acsone.eu
Maintainers
-----------
diff --git a/rma_sale_stock_restocking_fee_invoicing/__manifest__.py b/rma_sale_stock_restocking_fee_invoicing/__manifest__.py
index 1b40ed697..8f28193d5 100644
--- a/rma_sale_stock_restocking_fee_invoicing/__manifest__.py
+++ b/rma_sale_stock_restocking_fee_invoicing/__manifest__.py
@@ -10,7 +10,11 @@
"author": "ACSONE SA/NV,Odoo Community Association (OCA)",
"maintainers": ["sbejaoui"],
"website": "https://github.com/OCA/rma",
- "depends": ["sale_stock_restocking_fee_invoicing", "rma_sale"],
+ "depends": [
+ "sale_stock_restocking_fee_invoicing",
+ "rma_sale",
+ "stock_move_propagate_first_move",
+ ],
"data": ["views/rma_operation.xml", "views/rma.xml"],
"demo": [],
}
diff --git a/rma_sale_stock_restocking_fee_invoicing/models/rma.py b/rma_sale_stock_restocking_fee_invoicing/models/rma.py
index 69b53007c..90c7b9af9 100644
--- a/rma_sale_stock_restocking_fee_invoicing/models/rma.py
+++ b/rma_sale_stock_restocking_fee_invoicing/models/rma.py
@@ -136,3 +136,28 @@ def _get_restocking_fee_amount(self):
else:
price_unit = self.product_id.lst_price
return (price_unit * (self.restocking_fee_amount / 100)) * self.product_uom_qty
+
+ def _get_related_moves(self):
+ """
+ Get all chained moves from the reception move
+ """
+ res_moves = self.mapped("reception_move_id")
+ done_moves = self.env["stock.move"]
+ moves_to_parse = res_moves
+ while moves_to_parse:
+ next_moves = moves_to_parse.mapped("move_dest_ids")
+ res_moves |= next_moves
+ done_moves |= moves_to_parse
+ moves_to_parse = next_moves - done_moves
+ return res_moves
+
+ def write(self, vals):
+ """
+ Be able to propagate restocking fees on all moves of the chain
+ even if added once the reception move is already created.
+ """
+ res = super().write(vals)
+ if "restocking_fee_type" in vals:
+ moves = self._get_related_moves()
+ moves.charge_restocking_fee = bool(vals["restocking_fee_type"])
+ return res
diff --git a/rma_sale_stock_restocking_fee_invoicing/models/sale_order.py b/rma_sale_stock_restocking_fee_invoicing/models/sale_order.py
index 047a5eb44..7ed4aaadc 100644
--- a/rma_sale_stock_restocking_fee_invoicing/models/sale_order.py
+++ b/rma_sale_stock_restocking_fee_invoicing/models/sale_order.py
@@ -9,7 +9,7 @@ class SaleOrder(models.Model):
def _get_restocking_fee_line_value(self, stock_move):
vals = super()._get_restocking_fee_line_value(stock_move)
- rma = stock_move.rma_receiver_ids
+ rma = stock_move.first_move_id.rma_receiver_ids
if not rma:
return vals
vals["price_unit"] = rma._get_restocking_fee_amount()
diff --git a/rma_sale_stock_restocking_fee_invoicing/models/stock_move.py b/rma_sale_stock_restocking_fee_invoicing/models/stock_move.py
index ba61e53da..701eb1122 100644
--- a/rma_sale_stock_restocking_fee_invoicing/models/stock_move.py
+++ b/rma_sale_stock_restocking_fee_invoicing/models/stock_move.py
@@ -7,8 +7,27 @@
class StockMove(models.Model):
_inherit = "stock.move"
+ def _is_restocking_fee_chargeable(self):
+ """
+ In RMA process only the last move of the return chain is chargeable.
+ """
+ res = super()._is_restocking_fee_chargeable()
+ if self.first_move_id.rma_receiver_ids:
+ if self.move_dest_ids:
+ return False
+ if self.charge_restocking_fee:
+ # Need to return True here because in super(), result is False
+ # if the move has no origin_returned_move_id
+ return True
+
+ return res
+
def _action_done(self, cancel_backorder=False):
res = super()._action_done(cancel_backorder=cancel_backorder)
- move_done = self.filtered(lambda r: r.state == "done").sudo()
- move_done.sudo().mapped("rma_receiver_ids")._create_restocking_fee_invoice()
+ move_chargeable = self.filtered(
+ lambda r: r.state == "done" and r._is_restocking_fee_chargeable()
+ ).sudo()
+ move_chargeable.sudo().mapped(
+ "first_move_id.rma_receiver_ids"
+ )._create_restocking_fee_invoice()
return res
diff --git a/rma_sale_stock_restocking_fee_invoicing/readme/CONFIGURE.md b/rma_sale_stock_restocking_fee_invoicing/readme/CONFIGURE.md
index d5081bd8f..6c32a1ffe 100644
--- a/rma_sale_stock_restocking_fee_invoicing/readme/CONFIGURE.md
+++ b/rma_sale_stock_restocking_fee_invoicing/readme/CONFIGURE.md
@@ -8,3 +8,6 @@ To enable and configure restocking fees for RMAs:
4. Set the **Restocking Fee Amount**:
- If *fixed*: monetary amount.
- If *percentage*: value between 0 and 100.
+
+It is also possible to add, remove or change restocking fees directly on the RMA, as soon as the
+last move of the return moves is not validated yet.
diff --git a/rma_sale_stock_restocking_fee_invoicing/readme/CONTEXT.md b/rma_sale_stock_restocking_fee_invoicing/readme/CONTEXT.md
index 95c8189f1..10fffa473 100644
--- a/rma_sale_stock_restocking_fee_invoicing/readme/CONTEXT.md
+++ b/rma_sale_stock_restocking_fee_invoicing/readme/CONTEXT.md
@@ -1,3 +1,5 @@
RMAs often involve administrative and logistic processing costs when goods are
-returned. Depending on the company policy and the nature of the return,
+returned. Depending on the company policy and the nature of the return,
these costs may be passed on to the customer as a *restocking fee*.
+These restocking fees can be fixed before the customer returns the product (in case of a late return demand for eg)
+or decided in the middle of the return process (if the product is damaged for eg).
diff --git a/rma_sale_stock_restocking_fee_invoicing/readme/DESCRIPTION.md b/rma_sale_stock_restocking_fee_invoicing/readme/DESCRIPTION.md
index cadca0659..19d9ec4fc 100644
--- a/rma_sale_stock_restocking_fee_invoicing/readme/DESCRIPTION.md
+++ b/rma_sale_stock_restocking_fee_invoicing/readme/DESCRIPTION.md
@@ -2,7 +2,8 @@ This module extends the standard RMA flow and the behavior of
`sale_stock_restocking_fee_invoicing` by allowing:
- Fixed or percentage-based restocking fees.
-- Automatic fee application during RMA receipt.
+- Restocking fees configurable on the RMA operation or directly on the RMA itself.
+- Automatic fee application at last step of RMA receipt.
- Integration with different refund strategies:
- Update sale order quantity.
- Manual refund after receipt.
diff --git a/rma_sale_stock_restocking_fee_invoicing/readme/USAGE.md b/rma_sale_stock_restocking_fee_invoicing/readme/USAGE.md
index a26e174d0..68e68436b 100644
--- a/rma_sale_stock_restocking_fee_invoicing/readme/USAGE.md
+++ b/rma_sale_stock_restocking_fee_invoicing/readme/USAGE.md
@@ -8,9 +8,9 @@ The RMA operation determines how the fee will be applied:
### 1. Update Quantity Strategy
If the RMA operation "Refund Action" is "Update Quantities":
-- A restocking fee sale order line is automatically added at reception.
+- A restocking fee sale order line is automatically added when the last move of the reception chain is validated.
- The fee value depends on the selected fee type.
### 2. Manual Refund Strategy
If the RMA operation uses "Refund Action" is different than "Update Quantities"
-- A restocking fee invoice is automatically created at reception.
\ No newline at end of file
+- A restocking fee invoice is automatically created when the last move of the reception chain is validated.
diff --git a/rma_sale_stock_restocking_fee_invoicing/static/description/index.html b/rma_sale_stock_restocking_fee_invoicing/static/description/index.html
index c77c0b6b4..88ef038ba 100644
--- a/rma_sale_stock_restocking_fee_invoicing/static/description/index.html
+++ b/rma_sale_stock_restocking_fee_invoicing/static/description/index.html
@@ -374,7 +374,9 @@ Rma Sale Stock Restocking Fee Invoicing
sale_stock_restocking_fee_invoicing by allowing:
Fixed or percentage-based restocking fees.
-Automatic fee application during RMA receipt.
+Restocking fees configurable on the RMA operation or directly on the
+RMA itself.
+Automatic fee application at last step of RMA receipt.
Integration with different refund strategies:
Update sale order quantity.
Manual refund after receipt.
@@ -408,7 +410,9 @@
RMAs often involve administrative and logistic processing costs when
goods are returned. Depending on the company policy and the nature of
the return, these costs may be passed on to the customer as a
-restocking fee .
+restocking fee . These restocking fees can be fixed before the customer
+returns the product (in case of a late return demand for eg) or decided
+in the middle of the return process (if the product is damaged for eg).
@@ -428,6 +432,9 @@
+
It is also possible to add, remove or change restocking fees directly on
+the RMA, as soon as the last move of the return moves is not validated
+yet.
@@ -443,7 +450,8 @@
1. Update Quantity Strategy
If the RMA operation “Refund Action” is “Update Quantities”:
-A restocking fee sale order line is automatically added at reception.
+A restocking fee sale order line is automatically added when the last
+move of the reception chain is validated.
The fee value depends on the selected fee type.
@@ -452,7 +460,8 @@ If the RMA operation uses “Refund Action” is different than “Update
Quantities”
-A restocking fee invoice is automatically created at reception.
+A restocking fee invoice is automatically created when the last move
+of the reception chain is validated.
diff --git a/rma_sale_stock_restocking_fee_invoicing/tests/__init__.py b/rma_sale_stock_restocking_fee_invoicing/tests/__init__.py
index a1d1a8b34..a4b85783e 100644
--- a/rma_sale_stock_restocking_fee_invoicing/tests/__init__.py
+++ b/rma_sale_stock_restocking_fee_invoicing/tests/__init__.py
@@ -1 +1,2 @@
from . import test_rma_sale_stock_restocking_fee_invoicing
+from . import test_rma_multi_step_return_route
diff --git a/rma_sale_stock_restocking_fee_invoicing/tests/common.py b/rma_sale_stock_restocking_fee_invoicing/tests/common.py
new file mode 100644
index 000000000..924326c4b
--- /dev/null
+++ b/rma_sale_stock_restocking_fee_invoicing/tests/common.py
@@ -0,0 +1,37 @@
+# Copyright 2025 ACSONE SA/NV
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
+
+
+from odoo.addons.rma_sale.tests.test_rma_sale import TestRmaSaleBase
+
+
+class TestRmaSaleStockRestockingFeeInvoicingCommon(TestRmaSaleBase):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.sale_order = cls._create_sale_order([[cls.product_1, 5]])
+ cls.sale_order.action_confirm()
+ cls.order_line = cls.sale_order.order_line.filtered(
+ lambda r: r.product_id == cls.product_1
+ )
+ cls.order_out_picking = cls.sale_order.picking_ids
+ cls.order_out_picking.move_ids.quantity = 5
+ cls.order_out_picking.button_validate()
+ cls.product_restocking_fee = cls.env.ref(
+ "sale_stock_restocking_fee_invoicing.product_restocking_fee"
+ )
+
+ def _create_rma(self, rma_operation=None):
+ wizard = self._rma_sale_wizard(self.sale_order)
+ if rma_operation:
+ wizard.operation_id = rma_operation
+ wizard.line_ids.write({"operation_id": rma_operation})
+ rma = self.env["rma"].browse(wizard.create_and_open_rma()["res_id"])
+ self.assertTrue(rma.reception_move_id)
+ return rma
+
+ def _create_receive_rma(self):
+ rma = self._create_rma()
+ rma.reception_move_id.picking_id.button_validate()
+ self.assertEqual(rma.reception_move_id.state, "done")
+ return rma
diff --git a/rma_sale_stock_restocking_fee_invoicing/tests/test_rma_multi_step_return_route.py b/rma_sale_stock_restocking_fee_invoicing/tests/test_rma_multi_step_return_route.py
new file mode 100644
index 000000000..ea5a65a94
--- /dev/null
+++ b/rma_sale_stock_restocking_fee_invoicing/tests/test_rma_multi_step_return_route.py
@@ -0,0 +1,255 @@
+# Copyright 2025 ACSONE SA/NV
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
+
+from odoo import Command
+
+from .common import TestRmaSaleStockRestockingFeeInvoicingCommon
+
+
+class TestRmaMultiStepRoute(TestRmaSaleStockRestockingFeeInvoicingCommon):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.warehouse = cls.env["stock.warehouse"].search([], limit=1)
+ cls.loc_stock = cls.warehouse.lot_stock_id
+ cls.loc_input = cls.warehouse.wh_input_stock_loc_id
+ cls.loc_customer = cls.env.ref("stock.stock_location_customers")
+ cls.loc_rma_entry = cls.loc_input.copy({"name": "RMA Entry"})
+ cls.picking_type_out = cls.env.ref("stock.picking_type_out")
+ cls.picking_type_in = cls.env.ref("stock.picking_type_in")
+ cls.picking_type_inter = cls.env.ref("stock.picking_type_internal")
+ cls.picking_type_rma_in = cls.env["stock.picking.type"].create(
+ {
+ "name": "RMA reception",
+ "code": "incoming",
+ "sequence_code": "RMA-IN",
+ "default_location_src_id": cls.loc_customer.id,
+ "default_location_dest_id": cls.loc_rma_entry.id,
+ }
+ )
+
+ cls.rma_in_route = cls.env["stock.route"].create(
+ {
+ "name": "RMA IN in 2 steps",
+ "sequence": 1,
+ "sale_selectable": True,
+ "warehouse_selectable": True,
+ "warehouse_ids": [Command.link(cls.warehouse.id)],
+ "rule_ids": [
+ Command.create(
+ {
+ "name": "RMA Reception",
+ "action": "pull",
+ "picking_type_id": cls.picking_type_rma_in.id,
+ "location_src_id": cls.loc_customer.id,
+ "location_dest_id": cls.loc_rma_entry.id,
+ "procure_method": "make_to_stock",
+ }
+ ),
+ Command.create(
+ {
+ "name": "RMA Quality Check",
+ "action": "push",
+ "picking_type_id": cls.picking_type_inter.id,
+ "location_src_id": cls.loc_rma_entry.id,
+ "location_dest_id": cls.loc_stock.id,
+ "auto": "manual",
+ }
+ ),
+ ],
+ }
+ )
+
+ cls.warehouse.rma_loc_id = cls.loc_rma_entry
+ cls.warehouse.rma_in_type_id = cls.picking_type_rma_in
+ cls.warehouse.rma_in_route_id = cls.rma_in_route
+ cls.rma_operation_refund = cls.env.ref("rma.rma_operation_refund")
+
+ def test_setup(self):
+ """
+ Basic unit test to test setup is correct.
+ """
+ rma = self._create_rma()
+ reception_move = rma.reception_move_id
+ self.assertEqual(reception_move.location_id, self.loc_customer)
+ self.assertEqual(reception_move.location_dest_id, self.loc_rma_entry)
+ reception_move.picking_id.button_validate()
+ self.assertTrue(reception_move.move_dest_ids)
+ qc_move = reception_move.move_dest_ids
+ self.assertEqual(qc_move.location_id, self.loc_rma_entry)
+ self.assertEqual(qc_move.location_dest_id, self.loc_stock)
+ qc_move.picking_id.button_validate()
+ self.assertFalse(qc_move.move_dest_ids)
+
+ def test_fixed_restocking_fee_invoice_last_step(self):
+ """
+ Define fixed restocking fees on the RMA operation, RMA operation with
+ a refund operation = "manual after receipt".
+ Check that the restocking fee invoice is only created after the
+ last move is validated and the refund amount is correct
+ """
+ self.rma_operation_refund.write(
+ {
+ "action_create_refund": "manual_after_receipt",
+ "restocking_fee_type": "fixed",
+ "restocking_fee_amount": 3.2,
+ }
+ )
+ rma = self._create_rma(rma_operation=self.rma_operation_refund)
+ self.assertEqual(rma.restocking_fee_type, "fixed")
+ self.assertEqual(rma.restocking_fee_amount, 3.2)
+ self.assertFalse(rma.restocking_fee_invoice_id)
+ # Validate first move
+ rma.reception_move_id.picking_id.button_validate()
+ self.assertFalse(rma.restocking_fee_invoice_id)
+ # Validate last move
+ rma.reception_move_id.move_dest_ids.picking_id.button_validate()
+ self.assertTrue(rma.restocking_fee_invoice_id)
+ self.assertEqual(
+ rma.restocking_fee_invoice_id.invoice_line_ids[0].price_unit, 3.2
+ )
+
+ def test_percentage_restocking_fee_invoice_last_step(self):
+ """
+ Define percentage restocking fees on the RMA operation, RMA operation with
+ a refund operation = "manual after receipt".
+ Check that the restocking fee invoice is only created after the
+ last move is validated and the refund amount is correct
+ """
+ self.rma_operation_refund.write(
+ {
+ "action_create_refund": "manual_after_receipt",
+ "restocking_fee_type": "percent",
+ "restocking_fee_amount": 15,
+ }
+ )
+ rma = self._create_rma(rma_operation=self.rma_operation_refund)
+ self.assertEqual(rma.restocking_fee_type, "percent")
+ self.assertEqual(rma.restocking_fee_amount, 15)
+ self.assertFalse(rma.restocking_fee_invoice_id)
+ # Validate first move
+ rma.reception_move_id.picking_id.button_validate()
+ self.assertFalse(rma.restocking_fee_invoice_id)
+ # Validate last move
+ rma.reception_move_id.move_dest_ids.picking_id.button_validate()
+ self.assertTrue(rma.restocking_fee_invoice_id)
+ self.assertEqual(
+ rma.restocking_fee_invoice_id.invoice_line_ids[0].price_unit,
+ self.sale_order.order_line[0].price_unit * 0.15 * rma.product_uom_qty,
+ )
+
+ def test_fixed_restocking_fee_on_so_last_step(self):
+ """
+ Define fixed restocking fees on the RMA operation, RMA operation with
+ a refund operation = "update quantities".
+ Check that the restocking fee SO line is only created after the
+ last move is validated and the amount is correct
+ """
+ self.rma_operation_refund.write(
+ {
+ "action_create_refund": "update_quantity",
+ "restocking_fee_type": "fixed",
+ "restocking_fee_amount": 3.2,
+ }
+ )
+ rma = self._create_rma(rma_operation=self.rma_operation_refund)
+ self.assertEqual(rma.restocking_fee_type, "fixed")
+ self.assertEqual(rma.restocking_fee_amount, 3.2)
+ self.assertEqual(len(self.sale_order.order_line), 1)
+ # Validate first move
+ rma.reception_move_id.picking_id.button_validate()
+ self.assertEqual(len(self.sale_order.order_line), 1)
+ # Validate last move
+ rma.reception_move_id.move_dest_ids.picking_id.button_validate()
+ self.assertEqual(len(self.sale_order.order_line), 2)
+ self.assertTrue(self.sale_order.order_line[1].is_restocking_fee)
+ self.assertEqual(self.sale_order.order_line[1].price_unit, 3.2)
+
+ def test_percentage_restocking_fee_on_so_last_step(self):
+ """
+ Define percentage restocking fees on the RMA operation, RMA operation with
+ a refund operation = "update quantities".
+ Check that the restocking fee SO line is only created after the
+ last move is validated and the amount is correct
+ """
+ self.rma_operation_refund.write(
+ {
+ "action_create_refund": "update_quantity",
+ "restocking_fee_type": "percent",
+ "restocking_fee_amount": 15,
+ }
+ )
+ rma = self._create_rma(rma_operation=self.rma_operation_refund)
+ self.assertEqual(rma.restocking_fee_type, "percent")
+ self.assertEqual(rma.restocking_fee_amount, 15)
+ self.assertEqual(len(self.sale_order.order_line), 1)
+ # Validate first move
+ rma.reception_move_id.picking_id.button_validate()
+ self.assertEqual(len(self.sale_order.order_line), 1)
+ # Validate last move
+ rma.reception_move_id.move_dest_ids.picking_id.button_validate()
+ self.assertEqual(len(self.sale_order.order_line), 2)
+ self.assertTrue(self.sale_order.order_line[1].is_restocking_fee)
+ self.assertEqual(
+ self.sale_order.order_line[1].price_unit,
+ self.sale_order.order_line[0].price_unit * 0.15 * rma.product_uom_qty,
+ )
+
+ def test_restocking_fee_invoice_add_during_process(self):
+ """
+ Add restocking fees on the RMA in the middle of the process,
+ when first reception move is already validated.
+ RMA operation refund type is "Manual after receipt".
+ Check that the restocking fee invoice is well created (after the
+ last move is validated)
+ """
+ self.rma_operation_refund.write(
+ {
+ "action_create_refund": "manual_after_receipt",
+ }
+ )
+ rma = self._create_rma(rma_operation=self.rma_operation_refund)
+ self.assertFalse(rma.restocking_fee_type)
+ self.assertFalse(rma.restocking_fee_invoice_id)
+ # Validate first move
+ rma.reception_move_id.picking_id.button_validate()
+ self.assertFalse(rma.restocking_fee_invoice_id)
+ # Add restocking fees and then validate second move
+ rma.write(
+ {
+ "restocking_fee_type": "fixed",
+ "restocking_fee_amount": 2,
+ }
+ )
+ rma.reception_move_id.move_dest_ids.picking_id.button_validate()
+ self.assertTrue(rma.restocking_fee_invoice_id)
+
+ def test_restocking_fee_on_so_add_during_process(self):
+ """
+ Add restocking fees on the RMA in the middle of the process,
+ when first reception move is already validated.
+ RMA operation refund type is "Update quantities".
+ Check that the restocking fee SO line is well created (after the
+ last move is validated)
+ """
+ self.rma_operation_refund.write(
+ {
+ "action_create_refund": "update_quantity",
+ }
+ )
+ rma = self._create_rma(rma_operation=self.rma_operation_refund)
+ self.assertFalse(rma.restocking_fee_type)
+ self.assertFalse(rma.restocking_fee_invoice_id)
+ # Validate first move
+ rma.reception_move_id.picking_id.button_validate()
+ self.assertEqual(len(self.sale_order.order_line), 1)
+ # Add restocking fees and then validate second move
+ rma.write(
+ {
+ "restocking_fee_type": "fixed",
+ "restocking_fee_amount": 2,
+ }
+ )
+ rma.reception_move_id.move_dest_ids.picking_id.button_validate()
+ self.assertEqual(len(self.sale_order.order_line), 2)
+ self.assertTrue(self.sale_order.order_line[1].is_restocking_fee)
diff --git a/rma_sale_stock_restocking_fee_invoicing/tests/test_rma_sale_stock_restocking_fee_invoicing.py b/rma_sale_stock_restocking_fee_invoicing/tests/test_rma_sale_stock_restocking_fee_invoicing.py
index 9a202b594..0a6cf2acf 100644
--- a/rma_sale_stock_restocking_fee_invoicing/tests/test_rma_sale_stock_restocking_fee_invoicing.py
+++ b/rma_sale_stock_restocking_fee_invoicing/tests/test_rma_sale_stock_restocking_fee_invoicing.py
@@ -3,38 +3,12 @@
from odoo.exceptions import ValidationError
-from odoo.addons.rma_sale.tests.test_rma_sale import TestRmaSaleBase
+from .common import TestRmaSaleStockRestockingFeeInvoicingCommon
-class TestRmaSaleStockRestockingFeeInvoicing(TestRmaSaleBase):
- @classmethod
- def setUpClass(cls):
- super().setUpClass()
- cls.sale_order = cls._create_sale_order([[cls.product_1, 5]])
- cls.sale_order.action_confirm()
- cls.order_line = cls.sale_order.order_line.filtered(
- lambda r: r.product_id == cls.product_1
- )
- cls.order_out_picking = cls.sale_order.picking_ids
- cls.order_out_picking.move_ids.quantity = 5
- cls.order_out_picking.button_validate()
- cls.product_restocking_fee = cls.env.ref(
- "sale_stock_restocking_fee_invoicing.product_restocking_fee"
- )
-
- def _create_rma(self):
- wizard = self._rma_sale_wizard(self.sale_order)
- rma = self.env["rma"].browse(wizard.create_and_open_rma()["res_id"])
- self.assertTrue(rma.reception_move_id)
- self.assertTrue(rma.reception_move_id.charge_restocking_fee)
- return rma
-
- def _create_receive_rma(self):
- rma = self._create_rma()
- rma.reception_move_id.picking_id.button_validate()
- self.assertEqual(rma.reception_move_id.state, "done")
- return rma
-
+class TestRmaSaleStockRestockingFeeInvoicing(
+ TestRmaSaleStockRestockingFeeInvoicingCommon
+):
def test_0(self):
"""ensure restocking_fee_type enforces correct constraints on amount and
percentage"""