diff --git a/subscription_oca/README.rst b/subscription_oca/README.rst index 3bed5854f2..8371e1ef27 100644 --- a/subscription_oca/README.rst +++ b/subscription_oca/README.rst @@ -11,7 +11,7 @@ Subscription management !! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - !! source digest: sha256:ea96c858a40ee527d0e32ee03859918c082521f205ccc1cb26e96cb3bef27800 + !! source digest: sha256:1d7163cf2d7da35b3460a208800f960258c0e321470ab537d8aee3c7dfa6197e !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! .. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png @@ -74,21 +74,32 @@ To create subscriptions with the sale of a product: Invoice and delivery addresses: -- Each subscription has an *Invoice address* and a *Delivery address*. - They default to the customer's corresponding addresses and can be - overridden per subscription. -- These addresses are propagated to the recurring invoices (the invoice - is addressed to the invoice address and records the delivery one) and - to the sale orders generated by the subscription. -- The fields are shown when the *Customer Addresses* setting (group - *Display Delivery / Invoice addresses*) is enabled. +- Each subscription has an *Invoice address* and a *Delivery address*. + They default to the customer's corresponding addresses and can be + overridden per subscription. +- These addresses are propagated to the recurring invoices (the invoice + is addressed to the invoice address and records the delivery one) and + to the sale orders generated by the subscription. +- The fields are shown when the *Customer Addresses* setting (group + *Display Delivery / Invoice addresses*) is enabled. + +To change the customer of one or several subscriptions: + +1. Select the subscriptions in the list (or open one) and use the + *Change customer* action. +2. Pick the new customer. Optionally reapply the customer's sale + pricelist and recompute the fiscal position. +3. The change applies to the subscription and to its **future** invoices + and orders. Invoices already issued are **not** reassigned, so the + fiscal trail of posted documents is preserved. Closed subscriptions + cannot be changed. Known issues / Roadmap ====================== -- Refactor all the onchanges that have business logic to computed - write-able fields when possible. Keep onchanges only for UI purposes. -- Add tests. +- Refactor all the onchanges that have business logic to computed + write-able fields when possible. Keep onchanges only for UI purposes. +- Add tests. Bug Tracker =========== @@ -112,22 +123,22 @@ Authors Contributors ------------ -- Carlos Martínez -- Carolina Ferrer -- `Ooops404 `__: +- Carlos Martínez +- Carolina Ferrer +- `Ooops404 `__: - - Ilyas + - Ilyas -- `Sygel `__: +- `Sygel `__: - - Harald Panten - - Valentin Vinagre - - Alberto Martínez + - Harald Panten + - Valentin Vinagre + - Alberto Martínez -- Dennis Sluijk -- `IKU Solutions `__: +- Dennis Sluijk +- `IKU Solutions `__: - - Yan Chirino + - Yan Chirino Maintainers ----------- diff --git a/subscription_oca/__manifest__.py b/subscription_oca/__manifest__.py index b4206d377b..b32f68397c 100644 --- a/subscription_oca/__manifest__.py +++ b/subscription_oca/__manifest__.py @@ -22,6 +22,7 @@ "data/ir_cron.xml", "data/sale_subscription_data.xml", "wizard/close_subscription_wizard.xml", + "wizard/sale_subscription_change_partner_wizard.xml", "security/subscription_security.xml", "security/ir.model.access.csv", ], diff --git a/subscription_oca/models/sale_subscription.py b/subscription_oca/models/sale_subscription.py index b53cbe55b9..3fbc38d988 100644 --- a/subscription_oca/models/sale_subscription.py +++ b/subscription_oca/models/sale_subscription.py @@ -300,14 +300,24 @@ def _compute_partner_address_ids(self): def onchange_partner_id(self): self.pricelist_id = self.partner_id.property_product_pricelist - @api.onchange("partner_id", "company_id") - def onchange_partner_id_fpos(self): - self.fiscal_position_id = ( + def _get_fiscal_position_from_partner(self, partner=None): + """Return the fiscal position computed from a partner and the + subscription company. Plain method so it can be reused outside the + onchange UI flow (e.g. the change-customer wizard) without relying on + onchange side effects for persistence. Defaults to the current + partner when none is provided.""" + self.ensure_one() + partner = partner or self.partner_id + return ( self.env["account.fiscal.position"] .with_company(self.company_id) - ._get_fiscal_position(self.partner_id) + ._get_fiscal_position(partner) ) + @api.onchange("partner_id", "company_id") + def onchange_partner_id_fpos(self): + self.fiscal_position_id = self._get_fiscal_position_from_partner() + def action_start_subscription(self): self.close_reason_id = False in_progress_stage = self.env["sale.subscription.stage"].search( diff --git a/subscription_oca/readme/USAGE.md b/subscription_oca/readme/USAGE.md index 8f484f45d0..bee8924b89 100644 --- a/subscription_oca/readme/USAGE.md +++ b/subscription_oca/readme/USAGE.md @@ -36,3 +36,14 @@ Invoice and delivery addresses: to the sale orders generated by the subscription. - The fields are shown when the *Customer Addresses* setting (group *Display Delivery / Invoice addresses*) is enabled. + +To change the customer of one or several subscriptions: + +1. Select the subscriptions in the list (or open one) and use the + *Change customer* action. +2. Pick the new customer. Optionally reapply the customer's sale + pricelist and recompute the fiscal position. +3. The change applies to the subscription and to its **future** + invoices and orders. Invoices already issued are **not** reassigned, + so the fiscal trail of posted documents is preserved. Closed + subscriptions cannot be changed. diff --git a/subscription_oca/security/ir.model.access.csv b/subscription_oca/security/ir.model.access.csv index cd0f7dba90..c8486223f8 100644 --- a/subscription_oca/security/ir.model.access.csv +++ b/subscription_oca/security/ir.model.access.csv @@ -6,3 +6,4 @@ access_custom_sale_subscription_stage,sale.subscription.stage,model_sale_subscri access_custom_sale_subscription_line,sale.subscription.line,model_sale_subscription_line,sales_team.group_sale_salesman,1,1,1,1 access_custom_sale_subscription_tag,sale.subscription.tag,model_sale_subscription_tag,sales_team.group_sale_salesman,1,1,1,1 access_close_subscription,Close subscription access,model_close_reason_wizard,sales_team.group_sale_salesman,1,1,1,1 +access_change_partner_wizard,Change partner wizard access,model_sale_subscription_change_partner_wizard,sales_team.group_sale_manager,1,1,1,1 diff --git a/subscription_oca/static/description/index.html b/subscription_oca/static/description/index.html index 5fcfbe2af6..bf809d1fb4 100644 --- a/subscription_oca/static/description/index.html +++ b/subscription_oca/static/description/index.html @@ -372,7 +372,7 @@

Subscription management

!! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -!! source digest: sha256:ea96c858a40ee527d0e32ee03859918c082521f205ccc1cb26e96cb3bef27800 +!! source digest: sha256:1d7163cf2d7da35b3460a208800f960258c0e321470ab537d8aee3c7dfa6197e !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->

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

This module allows creating subscriptions that generate recurring @@ -433,6 +433,17 @@

Usage

  • The fields are shown when the Customer Addresses setting (group Display Delivery / Invoice addresses) is enabled.
  • +

    To change the customer of one or several subscriptions:

    +
      +
    1. Select the subscriptions in the list (or open one) and use the +Change customer action.
    2. +
    3. Pick the new customer. Optionally reapply the customer’s sale +pricelist and recompute the fiscal position.
    4. +
    5. The change applies to the subscription and to its future invoices +and orders. Invoices already issued are not reassigned, so the +fiscal trail of posted documents is preserved. Closed subscriptions +cannot be changed.
    6. +

    Known issues / Roadmap

    diff --git a/subscription_oca/tests/__init__.py b/subscription_oca/tests/__init__.py index db7813c497..b39240396a 100644 --- a/subscription_oca/tests/__init__.py +++ b/subscription_oca/tests/__init__.py @@ -4,3 +4,4 @@ from . import test_subscription_security from . import test_subscription_recurrence_dates from . import test_subscription_partner_addresses +from . import test_subscription_change_partner diff --git a/subscription_oca/tests/test_subscription_change_partner.py b/subscription_oca/tests/test_subscription_change_partner.py new file mode 100644 index 0000000000..86147539fc --- /dev/null +++ b/subscription_oca/tests/test_subscription_change_partner.py @@ -0,0 +1,230 @@ +# Copyright 2026 Domatix - Alvaro +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo.exceptions import AccessError, UserError +from odoo.tests import new_test_user + +from odoo.addons.account.tests.common import AccountTestInvoicingCommon +from odoo.addons.base.tests.common import BaseCommon +from odoo.addons.product.tests.common import ProductCommon + + +class TestSubscriptionChangePartner(ProductCommon, BaseCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) + cls.pricelist_a = cls.env["product.pricelist"].create({"name": "PL A"}) + cls.pricelist_b = cls.env["product.pricelist"].create({"name": "PL B"}) + cls.partner_a = cls.env["res.partner"].create({"name": "Partner A"}) + cls.partner_b = cls.env["res.partner"].create({"name": "Partner B"}) + cls.product = cls._create_product( + name="CC product", + lst_price=10.0, + subscribable=True, + uom_id=cls.uom_unit.id, + taxes_id=[(6, 0, [])], + ) + cls.template = cls.env["sale.subscription.template"].create( + { + "name": "CC template", + "code": "CC", + "recurring_rule_type": "months", + "recurring_interval": 1, + } + ) + cls.stage = cls.env["sale.subscription.stage"].search( + [("type", "=", "in_progress")], limit=1 + ) + + def _new_subscription(self, partner=None, pricelist=None): + partner = partner or self.partner_a + pricelist = pricelist or self.pricelist_a + sub = self.env["sale.subscription"].create( + { + "partner_id": partner.id, + "template_id": self.template.id, + "pricelist_id": pricelist.id, + "stage_id": self.stage.id, + } + ) + self.env["sale.subscription.line"].create( + { + "sale_subscription_id": sub.id, + "product_id": self.product.id, + "product_uom_qty": 1.0, + "price_unit": 10.0, + "tax_ids": [(6, 0, [])], + } + ) + return sub + + def _make_wizard(self, subscriptions, **values): + return ( + self.env["sale.subscription.change.partner.wizard"] + .with_context(active_ids=subscriptions.ids) + .create({"partner_id": self.partner_b.id, **values}) + ) + + def test_change_partner_writes_partner(self): + sub = self._new_subscription() + wizard = self._make_wizard(sub) + wizard.action_apply() + self.assertEqual(sub.partner_id, self.partner_b) + + def test_change_partner_updates_pricelist_when_flagged(self): + self.partner_b.property_product_pricelist = self.pricelist_b + sub = self._new_subscription(pricelist=self.pricelist_a) + wizard = self._make_wizard(sub, update_pricelist=True) + wizard.action_apply() + self.assertEqual(sub.pricelist_id, self.pricelist_b) + + def test_change_partner_keeps_pricelist_when_new_partner_has_none(self): + # When the new customer has no sale pricelist, the current one is kept + # even with the flag on (the "honest minimal" path). + self.partner_b.property_product_pricelist = False + sub = self._new_subscription(pricelist=self.pricelist_a) + wizard = self._make_wizard(sub, update_pricelist=True) + wizard.action_apply() + self.assertEqual(sub.pricelist_id, self.pricelist_a) + + def test_change_partner_keeps_pricelist_when_unflagged(self): + self.partner_b.property_product_pricelist = self.pricelist_b + sub = self._new_subscription(pricelist=self.pricelist_a) + wizard = self._make_wizard(sub, update_pricelist=False) + wizard.action_apply() + self.assertEqual(sub.pricelist_id, self.pricelist_a) + + def test_change_partner_recomputes_fiscal_position_when_flagged(self): + fpos = self.env["account.fiscal.position"].create( + {"name": "FP B", "company_id": self.env.company.id} + ) + self.partner_b.with_company( + self.env.company + ).property_account_position_id = fpos + sub = self._new_subscription() + self.assertFalse(sub.fiscal_position_id) + self._make_wizard(sub, update_fiscal_position=True).action_apply() + self.assertEqual(sub.fiscal_position_id, fpos) + + def test_change_partner_keeps_fiscal_position_when_unflagged(self): + fpos = self.env["account.fiscal.position"].create( + {"name": "FP B", "company_id": self.env.company.id} + ) + self.partner_b.with_company( + self.env.company + ).property_account_position_id = fpos + sub = self._new_subscription() + self._make_wizard(sub, update_fiscal_position=False).action_apply() + self.assertFalse(sub.fiscal_position_id) + + def test_change_partner_logs_chatter_message(self): + sub = self._new_subscription() + self._make_wizard(sub).action_apply() + self.assertIn("Customer changed", sub.message_ids[0].body) + + def test_change_partner_same_partner_is_noop(self): + sub = self._new_subscription(partner=self.partner_b) + before = len(sub.message_ids) + self._make_wizard(sub).action_apply() + self.assertEqual(sub.partner_id, self.partner_b) + # No change posted: same partner must not produce chatter noise. + self.assertEqual(len(sub.message_ids), before) + + def test_change_partner_on_closed_raises(self): + sub = self._new_subscription() + sub.close_subscription() + wizard = self._make_wizard(sub) + with self.assertRaises(UserError): + wizard.action_apply() + + def test_change_partner_bulk(self): + subs = ( + self._new_subscription() + | self._new_subscription() + | self._new_subscription() + ) + wizard = self._make_wizard(subs) + wizard.action_apply() + for sub in subs: + self.assertEqual(sub.partner_id, self.partner_b) + + def test_change_partner_requires_manager_group(self): + sub = self._new_subscription() + salesman = new_test_user( + self.env, + login="cc_salesman", + groups="sales_team.group_sale_salesman", + ) + with self.assertRaises(AccessError): + ( + self.env["sale.subscription.change.partner.wizard"] + .with_user(salesman) + .with_context(active_ids=sub.ids) + .create({"partner_id": self.partner_b.id}) + ) + + +class TestSubscriptionChangePartnerInvoicing(AccountTestInvoicingCommon): + """Covers the business value of the wizard: future invoices use the new + customer while already issued invoices keep their original one.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) + cls.env.user.group_ids += cls.env.ref("sales_team.group_sale_manager") + cls.pricelist = cls.env["product.pricelist"].create( + {"name": "CC invoicing PL", "currency_id": cls.company.currency_id.id} + ) + cls.template = cls.env["sale.subscription.template"].create( + { + "name": "CC invoicing template", + "code": "CCI", + "recurring_rule_type": "months", + "recurring_interval": 1, + "invoicing_mode": "draft", + } + ) + cls.stage = cls.env["sale.subscription.stage"].search( + [("type", "=", "in_progress")], limit=1 + ) + + def _new_subscription(self, partner): + sub = self.env["sale.subscription"].create( + { + "partner_id": partner.id, + "template_id": self.template.id, + "pricelist_id": self.pricelist.id, + "stage_id": self.stage.id, + } + ) + self.env["sale.subscription.line"].create( + { + "sale_subscription_id": sub.id, + "product_id": self.product_a.id, + "product_uom_qty": 1.0, + "price_unit": 100.0, + "tax_ids": [(6, 0, [])], + } + ) + return sub + + def _change_to(self, sub, partner): + self.env["sale.subscription.change.partner.wizard"].with_context( + active_ids=sub.ids + ).create({"partner_id": partner.id, "update_pricelist": False}).action_apply() + + def test_future_invoice_uses_new_partner(self): + sub = self._new_subscription(self.partner_a) + self._change_to(sub, self.partner_b) + invoice = sub.create_invoice() + self.assertEqual(invoice.partner_id, self.partner_b) + + def test_issued_invoice_is_not_reassigned(self): + sub = self._new_subscription(self.partner_a) + invoice = sub.create_invoice() + invoice.action_post() + self._change_to(sub, self.partner_b) + self.assertEqual(sub.partner_id, self.partner_b) + self.assertEqual(invoice.partner_id, self.partner_a) diff --git a/subscription_oca/wizard/__init__.py b/subscription_oca/wizard/__init__.py index a1aca59def..ebbcc17bc2 100644 --- a/subscription_oca/wizard/__init__.py +++ b/subscription_oca/wizard/__init__.py @@ -1 +1 @@ -from . import close_subscription_wizard +from . import close_subscription_wizard, sale_subscription_change_partner_wizard diff --git a/subscription_oca/wizard/sale_subscription_change_partner_wizard.py b/subscription_oca/wizard/sale_subscription_change_partner_wizard.py new file mode 100644 index 0000000000..4d12e66f99 --- /dev/null +++ b/subscription_oca/wizard/sale_subscription_change_partner_wizard.py @@ -0,0 +1,80 @@ +# Copyright 2026 Domatix - Alvaro +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from odoo import fields, models +from odoo.exceptions import UserError + + +class SaleSubscriptionChangePartnerWizard(models.TransientModel): + _name = "sale.subscription.change.partner.wizard" + _description = "Change customer wizard for subscriptions" + + subscription_ids = fields.Many2many( + comodel_name="sale.subscription", + string="Subscriptions", + default=lambda self: self._default_subscription_ids(), + ) + partner_id = fields.Many2one( + comodel_name="res.partner", + required=True, + string="New customer", + ) + update_pricelist = fields.Boolean( + string="Apply customer pricelist", + default=True, + ) + update_fiscal_position = fields.Boolean( + string="Recompute fiscal position", + default=True, + ) + + def _default_subscription_ids(self): + active_ids = self.env.context.get("active_ids") + if not active_ids: + return self.env["sale.subscription"] + return self.env["sale.subscription"].browse(active_ids) + + def _prepare_subscription_values(self, subscription): + """Build the values to write on a single subscription. The new + customer drives the partner and, optionally, the sale pricelist and the + fiscal position, all in a single write.""" + values = {"partner_id": self.partner_id.id} + if self.update_pricelist: + pricelist = self.partner_id.with_company( + subscription.company_id + ).property_product_pricelist + if pricelist: + values["pricelist_id"] = pricelist.id + if self.update_fiscal_position: + values["fiscal_position_id"] = ( + subscription._get_fiscal_position_from_partner(self.partner_id).id + ) + return values + + def action_apply(self): + self.ensure_one() + if not self.subscription_ids: + raise UserError( + self.env._("No subscriptions selected to change customer on.") + ) + for subscription in self.subscription_ids: + if subscription.stage_id.type == "post": + raise UserError( + self.env._( + "Cannot change the customer of a closed subscription " + "(%(name)s).", + name=subscription.display_name, + ) + ) + old_partner = subscription.partner_id + if old_partner == self.partner_id: + # Nothing to change: skip to avoid noisy chatter and writes. + continue + subscription.write(self._prepare_subscription_values(subscription)) + subscription.message_post( + body=self.env._( + "Customer changed from %(old)s to %(new)s.", + old=old_partner.display_name, + new=self.partner_id.display_name, + ) + ) + return {"type": "ir.actions.act_window_close"} diff --git a/subscription_oca/wizard/sale_subscription_change_partner_wizard.xml b/subscription_oca/wizard/sale_subscription_change_partner_wizard.xml new file mode 100644 index 0000000000..0b847c8830 --- /dev/null +++ b/subscription_oca/wizard/sale_subscription_change_partner_wizard.xml @@ -0,0 +1,57 @@ + + + + sale.subscription.change.partner.wizard.form + sale.subscription.change.partner.wizard + +
    + + + + + + + + + + + + + + +
    +
    +
    +
    +
    + + + Change customer + sale.subscription.change.partner.wizard + form + new + + list,form + +