diff --git a/subscription_oca/README.rst b/subscription_oca/README.rst index 3bed5854f2..a291ae04ae 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,10 @@ This module allows creating subscriptions that generate recurring invoices or orders. It also enables the sale of products that generate subscriptions. +Subscriptions can be paused and resumed, so they can be put on hold +without being closed; the recurring cron skips paused subscriptions and +can resume them automatically on a configured date. + **Table of contents** .. contents:: @@ -74,21 +78,31 @@ 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 pause and resume a subscription: + +1. On an in-progress subscription, press *Pause*. A dialog lets you + optionally set a *Resume on* date. +2. While paused, the cron skips the subscription: it is not invoiced, + started or closed. +3. Press *Resume* to reactivate it manually. If a *Resume on* date was + set, the cron resumes the subscription automatically once that date + is reached. Leaving the date empty pauses it indefinitely. 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 +126,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..82fa87b2a1 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/pause_subscription_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..af2a311d92 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,15 +153,33 @@ 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") + is_paused = fields.Boolean( + string="Paused", + tracking=True, + index="btree_not_null", + copy=False, + help="When ticked, the recurring cron will skip this subscription " + "until it is resumed manually or until `paused_until` is reached.", + ) + paused_until = fields.Date( + string="Resume on", + tracking=True, + copy=False, + help="If set, the cron will automatically resume this subscription " + "on or after this date.", + ) @api.model def cron_subscription_management(self): + self._cron_resume_due_subscriptions() today = date.today() subscription_count = self.search_count([]) for subscription in self.search( [], order="recurring_next_date asc", limit=subscription_count ): subscription = subscription.with_company(subscription.company_id) + if subscription.is_paused: + continue if subscription.in_progress: if ( subscription.recurring_next_date <= today @@ -182,6 +200,24 @@ def cron_subscription_management(self): subscription.action_start_subscription() subscription.generate_invoice() + @api.model + def _cron_resume_due_subscriptions(self, limit=None): + today = fields.Date.context_today(self) + domain = [ + ("is_paused", "=", True), + ("paused_until", "!=", False), + ("paused_until", "<=", today), + ] + for subscription in self.search(domain, limit=limit): + try: + subscription.with_company(subscription.company_id).action_resume( + automatic=True + ) + except Exception: + logger.exception( + "Error resuming paused subscription %s", subscription.id + ) + @api.depends("sale_subscription_line_ids") def _compute_total(self): for record in self: @@ -315,6 +351,40 @@ def action_start_subscription(self): ) self.stage_id = in_progress_stage + def action_open_pause_wizard(self): + self.ensure_one() + return { + "type": "ir.actions.act_window", + "name": self.env._("Pause subscription"), + "res_model": "sale.subscription.pause.wizard", + "view_mode": "form", + "target": "new", + } + + def action_pause(self, paused_until=None): + self.ensure_one() + if self.stage_id.type == "post": + raise UserError(self.env._("Cannot pause a closed subscription.")) + if self.is_paused: + raise UserError(self.env._("This subscription is already paused.")) + self.write({"is_paused": True, "paused_until": paused_until or False}) + if paused_until: + body = self.env._("Subscription paused until %(date)s.", date=paused_until) + else: + body = self.env._("Subscription paused.") + self.message_post(body=body) + + def action_resume(self, automatic=False): + self.ensure_one() + if not self.is_paused: + raise UserError(self.env._("This subscription is not paused.")) + self.write({"is_paused": False, "paused_until": False}) + if automatic: + body = self.env._("Subscription resumed automatically.") + else: + body = self.env._("Subscription resumed.") + self.message_post(body=body) + def action_close_subscription(self): return { "view_type": "form", diff --git a/subscription_oca/readme/DESCRIPTION.md b/subscription_oca/readme/DESCRIPTION.md index 826775d357..8ea62ffe87 100644 --- a/subscription_oca/readme/DESCRIPTION.md +++ b/subscription_oca/readme/DESCRIPTION.md @@ -1,3 +1,7 @@ This module allows creating subscriptions that generate recurring invoices or orders. It also enables the sale of products that generate subscriptions. + +Subscriptions can be paused and resumed, so they can be put on hold +without being closed; the recurring cron skips paused subscriptions and +can resume them automatically on a configured date. diff --git a/subscription_oca/readme/USAGE.md b/subscription_oca/readme/USAGE.md index 8f484f45d0..96eaec1ddd 100644 --- a/subscription_oca/readme/USAGE.md +++ b/subscription_oca/readme/USAGE.md @@ -36,3 +36,13 @@ 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 pause and resume a subscription: + +1. On an in-progress subscription, press *Pause*. A dialog lets you + optionally set a *Resume on* date. +2. While paused, the cron skips the subscription: it is not invoiced, + started or closed. +3. Press *Resume* to reactivate it manually. If a *Resume on* date was + set, the cron resumes the subscription automatically once that date + is reached. Leaving the date empty pauses it indefinitely. diff --git a/subscription_oca/security/ir.model.access.csv b/subscription_oca/security/ir.model.access.csv index cd0f7dba90..c16b4a4912 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_pause_subscription_wizard,Pause subscription wizard access,model_sale_subscription_pause_wizard,sales_team.group_sale_salesman,1,1,1,1 diff --git a/subscription_oca/static/description/index.html b/subscription_oca/static/description/index.html index 5fcfbe2af6..53677f6469 100644 --- a/subscription_oca/static/description/index.html +++ b/subscription_oca/static/description/index.html @@ -372,12 +372,15 @@

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 paused and resumed, so they can be put on hold +without being closed; the recurring cron skips paused subscriptions and +can resume them automatically on a configured date.

Table of contents

    @@ -433,6 +436,16 @@

    Usage

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

To pause and resume a subscription:

+
    +
  1. On an in-progress subscription, press Pause. A dialog lets you +optionally set a Resume on date.
  2. +
  3. While paused, the cron skips the subscription: it is not invoiced, +started or closed.
  4. +
  5. Press Resume to reactivate it manually. If a Resume on date was +set, the cron resumes the subscription automatically once that date +is reached. Leaving the date empty pauses it indefinitely.
  6. +

Known issues / Roadmap

diff --git a/subscription_oca/tests/__init__.py b/subscription_oca/tests/__init__.py index db7813c497..5e061a35d0 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_pause diff --git a/subscription_oca/tests/test_subscription_pause.py b/subscription_oca/tests/test_subscription_pause.py new file mode 100644 index 0000000000..2277bca1bb --- /dev/null +++ b/subscription_oca/tests/test_subscription_pause.py @@ -0,0 +1,193 @@ +# Copyright 2026 Domatix - Alvaro +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from unittest.mock import patch + +from dateutil.relativedelta import relativedelta + +from odoo import fields +from odoo.exceptions import UserError +from odoo.tools import mute_logger + +from odoo.addons.base.tests.common import BaseCommon +from odoo.addons.product.tests.common import ProductCommon + + +class TestSubscriptionPause(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": "Pause pl"}) + cls.partner = cls.env["res.partner"].create( + { + "name": "Pause partner", + "property_product_pricelist": cls.pricelist.id, + } + ) + cls.product = cls._create_product( + name="Pause 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": "Pause template", + "code": "PAU", + "recurring_rule_type": "months", + "recurring_interval": 1, + "invoicing_mode": "draft", + } + ) + + def _new_subscription(self, in_progress=True, with_line=True): + stage = self.env["sale.subscription.stage"].search( + [("type", "=", "in_progress" if in_progress else "draft")], limit=1 + ) + subscription = self.env["sale.subscription"].create( + { + "partner_id": self.partner.id, + "template_id": self.template.id, + "pricelist_id": self.pricelist.id, + "stage_id": stage.id, + "recurring_next_date": fields.Date.today(), + } + ) + if with_line: + self.env["sale.subscription.line"].create( + { + "sale_subscription_id": subscription.id, + "product_id": self.product.id, + "product_uom_qty": 1.0, + "price_unit": 100.0, + "tax_ids": [(6, 0, [])], + } + ) + return subscription + + def test_action_pause_sets_flag_and_logs_chatter(self): + sub = self._new_subscription() + before = len(sub.message_ids) + sub.action_pause() + self.assertTrue(sub.is_paused) + self.assertFalse(sub.paused_until) + self.assertGreater(len(sub.message_ids), before) + + def test_action_pause_with_date_stores_paused_until(self): + sub = self._new_subscription() + target = fields.Date.today() + relativedelta(days=7) + sub.action_pause(paused_until=target) + self.assertTrue(sub.is_paused) + self.assertEqual(sub.paused_until, target) + + def test_action_pause_on_closed_raises(self): + sub = self._new_subscription() + sub.close_subscription() + with self.assertRaises(UserError): + sub.action_pause() + + def test_action_pause_when_already_paused_raises(self): + sub = self._new_subscription() + sub.action_pause() + with self.assertRaises(UserError): + sub.action_pause() + + def test_action_resume_clears_flag(self): + sub = self._new_subscription() + sub.action_pause(paused_until=fields.Date.today() + relativedelta(days=3)) + sub.action_resume() + self.assertFalse(sub.is_paused) + self.assertFalse(sub.paused_until) + + def test_action_resume_when_not_paused_raises(self): + sub = self._new_subscription() + with self.assertRaises(UserError): + sub.action_resume() + + def test_cron_does_not_invoice_paused(self): + sub = self._new_subscription() + sub.in_progress = True + sub.recurring_next_date = fields.Date.today() - relativedelta(days=1) + sub.action_pause() + invoices_before = self.env["account.move"].search_count( + [("subscription_id", "=", sub.id)] + ) + sub.cron_subscription_management() + invoices_after = self.env["account.move"].search_count( + [("subscription_id", "=", sub.id)] + ) + self.assertEqual(invoices_after, invoices_before) + self.assertTrue(sub.is_paused) + + def test_cron_resumes_when_paused_until_past(self): + sub = self._new_subscription() + sub.action_pause(paused_until=fields.Date.today() - relativedelta(days=1)) + sub._cron_resume_due_subscriptions() + self.assertFalse(sub.is_paused) + self.assertFalse(sub.paused_until) + + def test_cron_keeps_paused_when_paused_until_future(self): + sub = self._new_subscription() + future = fields.Date.today() + relativedelta(days=30) + sub.action_pause(paused_until=future) + sub._cron_resume_due_subscriptions() + self.assertTrue(sub.is_paused) + self.assertEqual(sub.paused_until, future) + + def test_cron_keeps_paused_when_no_paused_until(self): + sub = self._new_subscription() + sub.action_pause() + sub._cron_resume_due_subscriptions() + self.assertTrue(sub.is_paused) + self.assertFalse(sub.paused_until) + + def test_cron_invoices_active_not_paused(self): + # Control for test_cron_does_not_invoice_paused: the same due + # subscription, when NOT paused, must actually be invoiced. + sub = self._new_subscription() + sub.in_progress = True + sub.recurring_next_date = fields.Date.today() - relativedelta(days=1) + invoices_before = self.env["account.move"].search_count( + [("subscription_id", "=", sub.id)] + ) + sub.cron_subscription_management() + invoices_after = self.env["account.move"].search_count( + [("subscription_id", "=", sub.id)] + ) + self.assertGreater(invoices_after, invoices_before) + + def test_pause_wizard_schedules_resume(self): + # The wizard is the only UI path that can set paused_until. + sub = self._new_subscription() + target = fields.Date.today() + relativedelta(days=14) + wizard = ( + self.env["sale.subscription.pause.wizard"] + .with_context(active_id=sub.id) + .create({"paused_until": target}) + ) + wizard.button_confirm() + self.assertTrue(sub.is_paused) + self.assertEqual(sub.paused_until, target) + + def test_action_open_pause_wizard_returns_action(self): + sub = self._new_subscription() + action = sub.action_open_pause_wizard() + self.assertEqual(action["res_model"], "sale.subscription.pause.wizard") + self.assertEqual(action["view_mode"], "form") + self.assertEqual(action["target"], "new") + + @mute_logger("odoo.addons.subscription_oca.models.sale_subscription") + def test_cron_resume_logs_error_when_resume_fails(self): + # A failing resume must be swallowed by the cron (logged) and leave + # the subscription paused, so one bad record cannot break the batch. + sub = self._new_subscription() + sub.action_pause(paused_until=fields.Date.today() - relativedelta(days=1)) + with patch.object( + type(self.env["sale.subscription"]), + "action_resume", + side_effect=ValueError("boom"), + ): + self.env["sale.subscription"]._cron_resume_due_subscriptions() + self.assertTrue(sub.is_paused) diff --git a/subscription_oca/views/sale_subscription_views.xml b/subscription_oca/views/sale_subscription_views.xml index 2c8c9c4e8d..4049635f8c 100644 --- a/subscription_oca/views/sale_subscription_views.xml +++ b/subscription_oca/views/sale_subscription_views.xml @@ -22,14 +22,47 @@ invisible="not in_progress" /> +