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..b27c2745e --- /dev/null +++ b/rma_sale_stock_restocking_fee_invoicing/README.rst @@ -0,0 +1,157 @@ +======================================= +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. +- 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. + +**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*. 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 +============= + +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. + +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 +===== + +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 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 when the last move + of the reception chain is validated. + +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..8f28193d5 --- /dev/null +++ b/rma_sale_stock_restocking_fee_invoicing/__manifest__.py @@ -0,0 +1,20 @@ +# 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", + "stock_move_propagate_first_move", + ], + "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..b67ac6ff8 --- /dev/null +++ b/rma_sale_stock_restocking_fee_invoicing/models/__init__.py @@ -0,0 +1,5 @@ +from . import restocking_fee_mixin +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/restocking_fee_mixin.py b/rma_sale_stock_restocking_fee_invoicing/models/restocking_fee_mixin.py new file mode 100644 index 000000000..7ad6ddc54 --- /dev/null +++ b/rma_sale_stock_restocking_fee_invoicing/models/restocking_fee_mixin.py @@ -0,0 +1,35 @@ +# 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%.") + ) 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..90c7b9af9 --- /dev/null +++ b/rma_sale_stock_restocking_fee_invoicing/models/rma.py @@ -0,0 +1,163 @@ +# 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): + _name = "rma" + _inherit = ["rma", "restocking.fee.mixin"] + + 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" + ) + 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", + "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.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.restocking_fee_type) + return vals + + 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._get_restocking_fee_amount(), + } + + 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, + } + + 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 + + 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/rma_operation.py b/rma_sale_stock_restocking_fee_invoicing/models/rma_operation.py new file mode 100644 index 000000000..bcc5ddc4a --- /dev/null +++ b/rma_sale_stock_restocking_fee_invoicing/models/rma_operation.py @@ -0,0 +1,9 @@ +# Copyright 2025 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import models + + +class RmaOperation(models.Model): + _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 new file mode 100644 index 000000000..7ed4aaadc --- /dev/null +++ b/rma_sale_stock_restocking_fee_invoicing/models/sale_order.py @@ -0,0 +1,16 @@ +# 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.first_move_id.rma_receiver_ids + if not rma: + return vals + vals["price_unit"] = rma._get_restocking_fee_amount() + return vals 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..701eb1122 --- /dev/null +++ b/rma_sale_stock_restocking_fee_invoicing/models/stock_move.py @@ -0,0 +1,33 @@ +# 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 _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_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/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..6c32a1ffe --- /dev/null +++ b/rma_sale_stock_restocking_fee_invoicing/readme/CONFIGURE.md @@ -0,0 +1,13 @@ +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. + +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 new file mode 100644 index 000000000..10fffa473 --- /dev/null +++ b/rma_sale_stock_restocking_fee_invoicing/readme/CONTEXT.md @@ -0,0 +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, +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/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..19d9ec4fc --- /dev/null +++ b/rma_sale_stock_restocking_fee_invoicing/readme/DESCRIPTION.md @@ -0,0 +1,9 @@ +This module extends the standard RMA flow and the behavior of +`sale_stock_restocking_fee_invoicing` by allowing: + +- 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. 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..68e68436b --- /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 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 when the last move of the reception chain is validated. 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 000000000..3a0328b51 Binary files /dev/null and b/rma_sale_stock_restocking_fee_invoicing/static/description/icon.png differ 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..88ef038ba --- /dev/null +++ b/rma_sale_stock_restocking_fee_invoicing/static/description/index.html @@ -0,0 +1,509 @@ + + + + + +Rma Sale Stock Restocking Fee Invoicing + + + +
+

Rma Sale Stock Restocking Fee Invoicing

+ + +

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

+

This module extends the standard RMA flow and the behavior of +sale_stock_restocking_fee_invoicing by allowing:

+
    +
  • 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.
    • +
    +
  • +
+

Table of contents

+ +
+

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. 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

+

To enable and configure restocking fees for RMAs:

+
    +
  1. Go to RMA / Configuration / Operations.
  2. +
  3. Open the RMA operation for which restocking fees should apply.
  4. +
  5. 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.
    • +
    +
  6. +
  7. Set the Restocking Fee Amount:
      +
    • If fixed: monetary amount.
    • +
    • If percentage: value between 0 and 100.
    • +
    +
  8. +
+

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

+
+

Applying a Restocking Fee

+
    +
  1. Create or select a sale order.
  2. +
  3. Deliver the products.
  4. +
  5. Initiate an RMA from the sale order.
  6. +
+

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 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 when the last move +of the reception chain is validated.
  • +
+
+
+
+
+

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

+ +
+
+

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.

+

Current 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/tests/__init__.py b/rma_sale_stock_restocking_fee_invoicing/tests/__init__.py new file mode 100644 index 000000000..a4b85783e --- /dev/null +++ b/rma_sale_stock_restocking_fee_invoicing/tests/__init__.py @@ -0,0 +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 new file mode 100644 index 000000000..0a6cf2acf --- /dev/null +++ b/rma_sale_stock_restocking_fee_invoicing/tests/test_rma_sale_stock_restocking_fee_invoicing.py @@ -0,0 +1,197 @@ +# Copyright 2025 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo.exceptions import ValidationError + +from .common import TestRmaSaleStockRestockingFeeInvoicingCommon + + +class TestRmaSaleStockRestockingFeeInvoicing( + TestRmaSaleStockRestockingFeeInvoicingCommon +): + 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": 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": 2, + "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) + 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, 150) # 300*0.25*2 + + 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) + + 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 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..460016e97 --- /dev/null +++ b/rma_sale_stock_restocking_fee_invoicing/views/rma.xml @@ -0,0 +1,36 @@ + + + + + rma + + + +