diff --git a/subscription_oca/README.rst b/subscription_oca/README.rst index 3bed5854f2..70610eeaf7 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 @@ -36,6 +36,9 @@ This module allows creating subscriptions that generate recurring invoices or orders. It also enables the sale of products that generate subscriptions. +Subscriptions can be renewed into a linked child subscription, keeping +the genealogy between the original contract and its renewals. + **Table of contents** .. contents:: @@ -74,21 +77,35 @@ 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 renew a subscription: + +1. On an in-progress subscription, press *Renew*. A renewal quote is + created as a child subscription in the *pre* stage, copying the + template, lines, pricelist, fiscal position, partner and journal. Its + start date is the finish date of the current subscription (or its + next invoice date when there is no finish date). +2. The *Renew* button is hidden while a renewal quote is still open. +3. When the renewal is started, the original subscription is closed + automatically and a link to the renewal is posted on its chatter. +4. Use the *Parent contract* and *Renewals* smart buttons to navigate + the renewal chain; *origin_subscription_id* always points at the + first contract of the chain. 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 +129,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/models/sale_subscription.py b/subscription_oca/models/sale_subscription.py index b53cbe55b9..22d5d08bf4 100644 --- a/subscription_oca/models/sale_subscription.py +++ b/subscription_oca/models/sale_subscription.py @@ -7,7 +7,7 @@ from markupsafe import Markup from odoo import Command, api, fields, models -from odoo.exceptions import AccessError +from odoo.exceptions import AccessError, UserError logger = logging.getLogger(__name__) @@ -153,6 +153,36 @@ def _read_group_stage_ids(self, stages, domain): ) crm_team_id = fields.Many2one(comodel_name="crm.team", string="Sale team") to_renew = fields.Boolean(default=False, string="To renew") + parent_subscription_id = fields.Many2one( + comodel_name="sale.subscription", + string="Parent contract", + index=True, + ondelete="restrict", + copy=False, + ) + origin_subscription_id = fields.Many2one( + comodel_name="sale.subscription", + string="Original contract", + compute="_compute_origin_subscription_id", + store=True, + recursive=True, + index=True, + ) + child_subscription_ids = fields.One2many( + comodel_name="sale.subscription", + inverse_name="parent_subscription_id", + string="Renewals", + ) + renewal_count = fields.Integer( + compute="_compute_renewal_count", string="Renewal count" + ) + is_renewed = fields.Boolean( + compute="_compute_is_renewed", + store=True, + index="btree_not_null", + help="True when this subscription has at least one child that is " + "not closed yet — its renewal is active or pending.", + ) @api.model def cron_subscription_management(self): @@ -314,6 +344,131 @@ def action_start_subscription(self): [("type", "=", "in_progress")], limit=1 ) self.stage_id = in_progress_stage + for subscription in self: + parent = subscription.parent_subscription_id + if parent: + parent.with_company(parent.company_id)._handle_renewal_activation( + child=subscription + ) + + def _handle_renewal_activation(self, child): + self.ensure_one() + if self.stage_id.type == "post": + return + close_reason_id = self.env.context.get("default_close_reason_id", False) + self.close_subscription(close_reason_id=close_reason_id) + link = Markup( + '{cname}' + ).format(cid=child.id, cname=child.display_name or "") + self.message_post( + body=self.env._("Renewal %(link)s has been activated.", link=link) + ) + + def _prepare_renewal_lines(self): + self.ensure_one() + commands = [] + for line in self.sale_subscription_line_ids: + commands.append( + Command.create( + { + "product_id": line.product_id.id, + "name": line.name, + "product_uom_qty": line.product_uom_qty, + "price_unit": line.price_unit, + "discount": line.discount, + "tax_ids": [Command.set(line.tax_ids.ids)], + "analytic_distribution": line.analytic_distribution, + } + ) + ) + return commands + + def _prepare_renewal_values(self): + self.ensure_one() + renewal_start = self.date or self.recurring_next_date or fields.Date.today() + pre_stage = self.env["sale.subscription.stage"].search( + [("type", "=", "pre")], order="sequence", limit=1 + ) + if not pre_stage: + raise UserError( + self.env._( + "Cannot prepare a renewal: there is no subscription " + "stage of type 'pre' configured." + ) + ) + return { + "parent_subscription_id": self.id, + "partner_id": self.partner_id.id, + "template_id": self.template_id.id, + "pricelist_id": self.pricelist_id.id, + "fiscal_position_id": self.fiscal_position_id.id, + "journal_id": self.journal_id.id, + "user_id": self.user_id.id, + "crm_team_id": self.crm_team_id.id, + "company_id": self.company_id.id, + "date_start": renewal_start, + "recurring_next_date": renewal_start, + "stage_id": pre_stage.id, + "tag_ids": [Command.set(self.tag_ids.ids)], + "sale_subscription_line_ids": self._prepare_renewal_lines(), + } + + def action_prepare_renewal(self): + self.ensure_one() + if self.stage_id.type == "post": + raise UserError(self.env._("Cannot renew a closed subscription.")) + active_child = self.child_subscription_ids.filtered( + lambda c: c.stage_id.type != "post" + ) + if active_child: + raise UserError( + self.env._( + "This subscription already has an active renewal " + "(%(names)s). Close or cancel it before creating a new one.", + names=", ".join(active_child.mapped("display_name")), + ) + ) + values = self._prepare_renewal_values() + # `create()` forces the initial stage to 'draft' when both + # date_start and recurring_next_date are given. We need the + # renewal to start in a 'pre' stage so it can be picked up by + # the cron and so the renewal-activation hook fires. + target_stage_id = values.pop("stage_id") + renewal = self.create(values) + renewal.stage_id = target_stage_id + link = Markup( + '{rname}' + ).format(rid=renewal.id, rname=renewal.display_name or "") + self.message_post( + body=self.env._("Renewal quote created: %(link)s.", link=link) + ) + return { + "type": "ir.actions.act_window", + "res_model": "sale.subscription", + "res_id": renewal.id, + "view_mode": "form", + "target": "current", + } + + def action_view_parent_subscription(self): + self.ensure_one() + return { + "type": "ir.actions.act_window", + "res_model": "sale.subscription", + "res_id": self.parent_subscription_id.id, + "view_mode": "form", + "target": "current", + } + + def action_view_child_subscriptions(self): + self.ensure_one() + return { + "type": "ir.actions.act_window", + "name": self.display_name, + "res_model": "sale.subscription", + "view_mode": "list,form", + "domain": [("id", "in", self.child_subscription_ids.ids)], + } def action_close_subscription(self): return { @@ -467,6 +622,29 @@ def manual_invoice(self): "context": context, } + @api.depends( + "parent_subscription_id", "parent_subscription_id.origin_subscription_id" + ) + def _compute_origin_subscription_id(self): + for record in self: + parent = record.parent_subscription_id + if not parent: + record.origin_subscription_id = False + else: + record.origin_subscription_id = parent.origin_subscription_id or parent + + @api.depends("child_subscription_ids") + def _compute_renewal_count(self): + for record in self: + record.renewal_count = len(record.child_subscription_ids) + + @api.depends("child_subscription_ids.stage_id.type") + def _compute_is_renewed(self): + for record in self: + record.is_renewed = any( + child.stage_id.type != "post" for child in record.child_subscription_ids + ) + @api.depends("invoice_ids", "sale_order_ids.invoice_ids") def _compute_account_invoice_ids_count(self): for record in self: diff --git a/subscription_oca/readme/DESCRIPTION.md b/subscription_oca/readme/DESCRIPTION.md index 826775d357..1cdb03995f 100644 --- a/subscription_oca/readme/DESCRIPTION.md +++ b/subscription_oca/readme/DESCRIPTION.md @@ -1,3 +1,6 @@ This module allows creating subscriptions that generate recurring invoices or orders. It also enables the sale of products that generate subscriptions. + +Subscriptions can be renewed into a linked child subscription, keeping +the genealogy between the original contract and its renewals. diff --git a/subscription_oca/readme/USAGE.md b/subscription_oca/readme/USAGE.md index 8f484f45d0..3612a6df62 100644 --- a/subscription_oca/readme/USAGE.md +++ b/subscription_oca/readme/USAGE.md @@ -36,3 +36,17 @@ 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 renew a subscription: + +1. On an in-progress subscription, press *Renew*. A renewal quote is + created as a child subscription in the *pre* stage, copying the + template, lines, pricelist, fiscal position, partner and journal. + Its start date is the finish date of the current subscription (or + its next invoice date when there is no finish date). +2. The *Renew* button is hidden while a renewal quote is still open. +3. When the renewal is started, the original subscription is closed + automatically and a link to the renewal is posted on its chatter. +4. Use the *Parent contract* and *Renewals* smart buttons to navigate + the renewal chain; *origin_subscription_id* always points at the + first contract of the chain. diff --git a/subscription_oca/static/description/index.html b/subscription_oca/static/description/index.html index 5fcfbe2af6..9ba9949a63 100644 --- a/subscription_oca/static/description/index.html +++ b/subscription_oca/static/description/index.html @@ -372,12 +372,14 @@

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 invoices or orders. It also enables the sale of products that generate subscriptions.

+

Subscriptions can be renewed into a linked child subscription, keeping +the genealogy between the original contract and its renewals.

Table of contents

    @@ -433,6 +435,20 @@

    Usage

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

To renew a subscription:

+
    +
  1. On an in-progress subscription, press Renew. A renewal quote is +created as a child subscription in the pre stage, copying the +template, lines, pricelist, fiscal position, partner and journal. Its +start date is the finish date of the current subscription (or its +next invoice date when there is no finish date).
  2. +
  3. The Renew button is hidden while a renewal quote is still open.
  4. +
  5. When the renewal is started, the original subscription is closed +automatically and a link to the renewal is posted on its chatter.
  6. +
  7. Use the Parent contract and Renewals smart buttons to navigate +the renewal chain; origin_subscription_id always points at the +first contract of the chain.
  8. +

Known issues / Roadmap

diff --git a/subscription_oca/tests/__init__.py b/subscription_oca/tests/__init__.py index db7813c497..72b0e7e49c 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_renewal diff --git a/subscription_oca/tests/test_subscription_renewal.py b/subscription_oca/tests/test_subscription_renewal.py new file mode 100644 index 0000000000..35264e1f17 --- /dev/null +++ b/subscription_oca/tests/test_subscription_renewal.py @@ -0,0 +1,197 @@ +# Copyright 2026 Domatix - Alvaro +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from datetime import date + +from odoo.exceptions import UserError + +from odoo.addons.base.tests.common import BaseCommon +from odoo.addons.product.tests.common import ProductCommon + + +class TestSubscriptionRenewal(ProductCommon, BaseCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) + cls.pricelist = cls.env["product.pricelist"].create({"name": "Renewal pl"}) + cls.partner = cls.env["res.partner"].create( + { + "name": "Renewal partner", + "property_product_pricelist": cls.pricelist.id, + } + ) + cls.product = cls._create_product( + name="Renewal product", + lst_price=100.0, + subscribable=True, + uom_id=cls.uom_unit.id, + taxes_id=[(6, 0, [])], + ) + cls.template = cls.env["sale.subscription.template"].create( + { + "name": "Renewal template", + "code": "RNW", + "recurring_rule_type": "months", + "recurring_interval": 1, + } + ) + cls.stage_pre = cls.env["sale.subscription.stage"].search( + [("type", "=", "pre")], limit=1 + ) + cls.stage_in_progress = cls.env["sale.subscription.stage"].search( + [("type", "=", "in_progress")], limit=1 + ) + cls.stage_post = cls.env["sale.subscription.stage"].search( + [("type", "=", "post")], limit=1 + ) + + def _new_subscription(self, with_line=True): + subscription = self.env["sale.subscription"].create( + { + "partner_id": self.partner.id, + "template_id": self.template.id, + "pricelist_id": self.pricelist.id, + "stage_id": self.stage_in_progress.id, + } + ) + if with_line: + self.env["sale.subscription.line"].create( + { + "sale_subscription_id": subscription.id, + "product_id": self.product.id, + "product_uom_qty": 2.0, + "price_unit": 50.0, + "tax_ids": [(6, 0, [])], + } + ) + return subscription + + def test_prepare_renewal_creates_child_in_pre_stage(self): + parent = self._new_subscription() + action = parent.action_prepare_renewal() + child = self.env["sale.subscription"].browse(action["res_id"]) + self.assertTrue(child.exists()) + self.assertEqual(child.parent_subscription_id, parent) + self.assertEqual(child.stage_id.type, "pre") + + def test_renewal_inherits_lines(self): + parent = self._new_subscription() + parent.action_prepare_renewal() + child = parent.child_subscription_ids + self.assertEqual(len(child.sale_subscription_line_ids), 1) + line = child.sale_subscription_line_ids + self.assertEqual(line.product_id, self.product) + self.assertEqual(line.product_uom_qty, 2.0) + + def test_renewal_inherits_template_pricelist_fiscal(self): + parent = self._new_subscription() + parent.action_prepare_renewal() + child = parent.child_subscription_ids + self.assertEqual(child.template_id, parent.template_id) + self.assertEqual(child.pricelist_id, parent.pricelist_id) + self.assertEqual(child.fiscal_position_id, parent.fiscal_position_id) + self.assertEqual(child.partner_id, parent.partner_id) + + def test_renewal_date_start_uses_parent_finish_date(self): + parent = self._new_subscription() + # Distinct values so the assert actually exercises the finish-date path. + parent.date = date(2026, 12, 31) + parent.recurring_next_date = date(2026, 1, 1) + parent.action_prepare_renewal() + self.assertEqual(parent.child_subscription_ids.date_start, date(2026, 12, 31)) + + def test_renewal_date_start_falls_back_to_next_invoice_date(self): + parent = self._new_subscription() + parent.date = False + parent.recurring_next_date = date(2026, 1, 1) + parent.action_prepare_renewal() + self.assertEqual(parent.child_subscription_ids.date_start, date(2026, 1, 1)) + + def test_renewal_start_closes_parent(self): + parent = self._new_subscription() + parent.action_prepare_renewal() + child = parent.child_subscription_ids + child.action_start_subscription() + self.assertEqual(parent.stage_id.type, "post") + + def test_origin_walks_chain(self): + first = self._new_subscription() + first.action_prepare_renewal() + second = first.child_subscription_ids + second.action_prepare_renewal() + third = second.child_subscription_ids + self.assertEqual(third.origin_subscription_id, first) + self.assertEqual(second.origin_subscription_id, first) + self.assertFalse(first.origin_subscription_id) + + def test_is_renewed_when_child_active(self): + parent = self._new_subscription() + parent.action_prepare_renewal() + self.assertTrue(parent.is_renewed) + + def test_is_renewed_false_when_only_closed_child(self): + parent = self._new_subscription() + parent.action_prepare_renewal() + child = parent.child_subscription_ids + child.close_subscription() + # Closing the child must recompute the stored field automatically. + self.assertFalse(parent.is_renewed) + + def test_cannot_renew_closed_subscription(self): + subscription = self._new_subscription() + subscription.close_subscription() + with self.assertRaises(UserError): + subscription.action_prepare_renewal() + + def test_cannot_renew_when_active_child_exists(self): + parent = self._new_subscription() + parent.action_prepare_renewal() + with self.assertRaises(UserError): + parent.action_prepare_renewal() + + def test_chatter_message_on_renewal_creation(self): + parent = self._new_subscription() + parent.action_prepare_renewal() + child = parent.child_subscription_ids + # The latest message links to the freshly created renewal. + body = parent.message_ids[0].body + self.assertIn("Renewal", body) + self.assertIn(str(child.id), body) + + def test_renewal_count_reflects_children(self): + parent = self._new_subscription() + self.assertEqual(parent.renewal_count, 0) + parent.action_prepare_renewal() + self.assertEqual(parent.renewal_count, 1) + + def test_renewal_activation_skips_already_closed_parent(self): + # If the parent is already closed when its renewal is activated, + # the activation hook is a no-op: it must not re-close the parent + # nor post a "renewal activated" note. + parent = self._new_subscription() + parent.action_prepare_renewal() + child = parent.child_subscription_ids + parent.close_subscription() + messages_before = parent.message_ids + child.action_start_subscription() + self.assertEqual(parent.stage_id.type, "post") + self.assertEqual(parent.message_ids, messages_before) + + def test_renewal_date_start_falls_back_to_today(self): + parent = self._new_subscription() + parent.date = False + parent.recurring_next_date = False + parent.action_prepare_renewal() + self.assertEqual(parent.child_subscription_ids.date_start, date.today()) + + def test_action_view_parent_and_children(self): + parent = self._new_subscription() + parent.action_prepare_renewal() + child = parent.child_subscription_ids + parent_action = child.action_view_parent_subscription() + self.assertEqual(parent_action["res_model"], "sale.subscription") + self.assertEqual(parent_action["res_id"], parent.id) + children_action = parent.action_view_child_subscriptions() + self.assertEqual(children_action["res_model"], "sale.subscription") + self.assertEqual(children_action["domain"], [("id", "in", child.ids)]) diff --git a/subscription_oca/views/sale_subscription_views.xml b/subscription_oca/views/sale_subscription_views.xml index 2c8c9c4e8d..9395b056b7 100644 --- a/subscription_oca/views/sale_subscription_views.xml +++ b/subscription_oca/views/sale_subscription_views.xml @@ -22,12 +22,23 @@ invisible="not in_progress" /> + + +
+ + +