From 4914636f9ed22ee794294847680ae712bee967ba Mon Sep 17 00:00:00 2001 From: alvaro-domatix Date: Thu, 18 Jun 2026 17:48:18 +0000 Subject: [PATCH] [IMP] subscription_oca: split invoicing mode into orthogonal options The invoicing_mode selection conflated three independent decisions and treated "Invoice" and "Invoice & send" identically (both posted and sent the invoice). Replace it with three orthogonal template fields: * create_sale_order: generate a confirmed sale order before invoicing * invoice_state: leave the invoice in draft or post it automatically * send_invoice: send the posted invoice by email once it is posted A warning banner is shown on templates that do not create a sale order, since those subscriptions will not appear in Sales analysis reports. A post-migration script backfills the new fields from the legacy invoicing_mode values. --- subscription_oca/README.rst | 68 ++++++----- subscription_oca/__manifest__.py | 2 +- .../migrations/19.0.2.0.0/post-migration.py | 38 +++++++ subscription_oca/models/sale_subscription.py | 45 ++++---- .../models/sale_subscription_template.py | 22 ++-- subscription_oca/readme/USAGE.md | 20 +++- .../static/description/index.html | 24 +++- subscription_oca/tests/__init__.py | 1 + .../test_subscription_invoicing_options.py | 106 ++++++++++++++++++ .../tests/test_subscription_oca.py | 52 +++++++-- .../test_subscription_partner_addresses.py | 5 +- .../test_subscription_recurrence_dates.py | 2 +- .../sale_subscription_template_views.xml | 28 ++++- 13 files changed, 326 insertions(+), 87 deletions(-) create mode 100644 subscription_oca/migrations/19.0.2.0.0/post-migration.py create mode 100644 subscription_oca/tests/test_subscription_invoicing_options.py diff --git a/subscription_oca/README.rst b/subscription_oca/README.rst index 3bed5854f2..89e7273154 100644 --- a/subscription_oca/README.rst +++ b/subscription_oca/README.rst @@ -47,9 +47,21 @@ Usage To make a subscription: 1. Go to *Subscriptions > Configuration > Subscription templates*. -2. Create the templates you consider, choosing the billing frequency: - daily, monthly... and the method of creating the invoice and/or - order. +2. Create the templates you consider, choosing the billing frequency + (daily, monthly...) and how the recurring document is generated. The + invoicing behaviour is configured through three independent options: + + - *Create sale order*: when enabled, a confirmed sale order is + generated before the invoice (required for the subscription to + show up in Sales analysis reports); when disabled, the invoice is + created directly and a warning reminds you that it will not appear + there. + - *Invoice status*: leave the generated invoice in *Draft* or post + it automatically (*Posted*). + - *Send invoice by email*: only available for posted invoices; when + enabled, the selected *Invoice Email* template is sent + automatically. + 3. Go to *Subscription > Subscriptions*. 4. Create a subscription and indicate the start date. When the *Subscriptions Management* cron job is executed, the subscription @@ -58,9 +70,9 @@ To make a subscription: the execution date matches the next invoice date. Additionally, you can manually change the subscription status and create an invoice by using the *Create Invoice* button. This action creates just an - invoice even if the subscription template has the *Sale Order & - Invoice* option selected, because the *Invoicing mode* option is - triggered through the cron job. + invoice even if the subscription template has *Create sale order* + enabled, because the configured invoicing options are only applied by + the cron job. 5. The cron job will also end the subscription if its end date has been reached. The end date honours the template recurrence (days, weeks, months or years) and the interval, instead of always assuming months. @@ -74,21 +86,21 @@ 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. 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 +124,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..e4f888ba53 100644 --- a/subscription_oca/__manifest__.py +++ b/subscription_oca/__manifest__.py @@ -3,7 +3,7 @@ { "name": "Subscription management", "summary": "Generate recurring invoices.", - "version": "19.0.1.1.3", + "version": "19.0.2.0.0", "development_status": "Beta", "category": "Subscription Management", "website": "https://github.com/OCA/contract", diff --git a/subscription_oca/migrations/19.0.2.0.0/post-migration.py b/subscription_oca/migrations/19.0.2.0.0/post-migration.py new file mode 100644 index 0000000000..44dcb95b8f --- /dev/null +++ b/subscription_oca/migrations/19.0.2.0.0/post-migration.py @@ -0,0 +1,38 @@ +# Copyright 2026 Domatix +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + + +def migrate(cr, version): + """Split the legacy ``invoicing_mode`` selection into orthogonal fields. + + Old ``invoicing_mode`` values map to the new fields as follows: + + =================== ================= ============== ============== + invoicing_mode create_sale_order invoice_state send_invoice + =================== ================= ============== ============== + draft False draft False + invoice False posted False + invoice_send False posted True + sale_and_invoice True posted False + =================== ================= ============== ============== + """ + cr.execute( + "SELECT 1 FROM information_schema.columns " + "WHERE table_name = 'sale_subscription_template' " + "AND column_name = 'invoicing_mode'" + ) + if not cr.fetchone(): + return + cr.execute( + """ + UPDATE sale_subscription_template + SET create_sale_order = (invoicing_mode = 'sale_and_invoice'), + invoice_state = CASE + WHEN invoicing_mode IN ('invoice', 'invoice_send', 'sale_and_invoice') + THEN 'posted' ELSE 'draft' END, + send_invoice = (invoicing_mode = 'invoice_send') + """ + ) + cr.execute( + "ALTER TABLE sale_subscription_template DROP COLUMN IF EXISTS invoicing_mode" + ) diff --git a/subscription_oca/models/sale_subscription.py b/subscription_oca/models/sale_subscription.py index b53cbe55b9..3424443911 100644 --- a/subscription_oca/models/sale_subscription.py +++ b/subscription_oca/models/sale_subscription.py @@ -414,33 +414,30 @@ def generate_invoice(self): invoice_number = "" message_body = "" msg_static = self.env._("Created invoice with reference") - if self.template_id.invoicing_mode in ["draft", "invoice", "invoice_send"]: - invoice = self.create_invoice() - if self.template_id.invoicing_mode != "draft": - invoice.action_post() - mail_template = self.template_id.invoice_mail_template_id - self.env["account.move.send"]._generate_and_send_invoices( - invoice, mail_template=mail_template, sending_methods=["email"] - ) - invoice_number = invoice.name - message_body = ( - f"{msg_static} " - f"" - f"{invoice_number}" - "" - ) - - if self.template_id.invoicing_mode == "sale_and_invoice": + template = self.template_id + if template.create_sale_order: order_id = self.create_sale_order() order_id.action_confirm() order_id.action_lock() - new_invoice = order_id._create_invoices() - new_invoice.action_post() - new_invoice.invoice_origin = order_id.name + ", " + self.name - invoice_number = new_invoice.name - message_body = f"{msg_static} \ -\ -{invoice_number}" + invoice = order_id._create_invoices() + invoice.invoice_origin = order_id.name + ", " + self.name + else: + invoice = self.create_invoice() + if invoice and template.invoice_state == "posted": + invoice.action_post() + if template.send_invoice: + self.env["account.move.send"]._generate_and_send_invoices( + invoice, + mail_template=template.invoice_mail_template_id, + sending_methods=["email"], + ) + invoice_number = invoice.name + message_body = ( + f"{msg_static} " + f"" + f"{invoice_number}" + "" + ) if not invoice_number: invoice_number = self.env._("To validate") diff --git a/subscription_oca/models/sale_subscription_template.py b/subscription_oca/models/sale_subscription_template.py index a6a57edaf8..6430c9dca5 100644 --- a/subscription_oca/models/sale_subscription_template.py +++ b/subscription_oca/models/sale_subscription_template.py @@ -27,15 +27,21 @@ class SaleSubscriptionTemplate(models.Model): string="Duration", default="unlimited", ) - invoicing_mode = fields.Selection( + create_sale_order = fields.Boolean( + string="Create sale order", + help="Generate a confirmed sale order before invoicing. Required for the " + "subscription to appear in Sales analysis reports.", + ) + invoice_state = fields.Selection( + selection=[("draft", "Draft"), ("posted", "Posted")], + string="Invoice status", default="draft", - string="Invoicing mode", - selection=[ - ("draft", "Draft"), - ("invoice", "Invoice"), - ("invoice_send", "Invoice & send"), - ("sale_and_invoice", "Sale order & Invoice"), - ], + required=True, + help="State in which the generated invoice is left.", + ) + send_invoice = fields.Boolean( + string="Send invoice by email", + help="Send the invoice by email once posted, using the template below.", ) code = fields.Char() recurring_rule_count = fields.Integer(default=1, string="Rule count") diff --git a/subscription_oca/readme/USAGE.md b/subscription_oca/readme/USAGE.md index 8f484f45d0..f3cac25962 100644 --- a/subscription_oca/readme/USAGE.md +++ b/subscription_oca/readme/USAGE.md @@ -1,9 +1,17 @@ To make a subscription: 1. Go to *Subscriptions \> Configuration \> Subscription templates*. -2. Create the templates you consider, choosing the billing frequency: - daily, monthly... and the method of creating the invoice and/or - order. +2. Create the templates you consider, choosing the billing frequency + (daily, monthly...) and how the recurring document is generated. The + invoicing behaviour is configured through three independent options: + - *Create sale order*: when enabled, a confirmed sale order is + generated before the invoice (required for the subscription to show + up in Sales analysis reports); when disabled, the invoice is created + directly and a warning reminds you that it will not appear there. + - *Invoice status*: leave the generated invoice in *Draft* or post it + automatically (*Posted*). + - *Send invoice by email*: only available for posted invoices; when + enabled, the selected *Invoice Email* template is sent automatically. 3. Go to *Subscription \> Subscriptions*. 4. Create a subscription and indicate the start date. When the *Subscriptions Management* cron job is executed, the subscription @@ -12,9 +20,9 @@ To make a subscription: the execution date matches the next invoice date. Additionally, you can manually change the subscription status and create an invoice by using the *Create Invoice* button. This action creates just an - invoice even if the subscription template has the *Sale Order & - Invoice* option selected, because the *Invoicing mode* option is - triggered through the cron job. + invoice even if the subscription template has *Create sale order* + enabled, because the configured invoicing options are only applied by + the cron job. 5. The cron job will also end the subscription if its end date has been reached. The end date honours the template recurrence (days, weeks, months or years) and the interval, instead of always assuming months. diff --git a/subscription_oca/static/description/index.html b/subscription_oca/static/description/index.html index 5fcfbe2af6..0d53b75a51 100644 --- a/subscription_oca/static/description/index.html +++ b/subscription_oca/static/description/index.html @@ -397,9 +397,21 @@

Usage

To make a subscription:

  1. Go to Subscriptions > Configuration > Subscription templates.
  2. -
  3. Create the templates you consider, choosing the billing frequency: -daily, monthly… and the method of creating the invoice and/or -order.
  4. +
  5. Create the templates you consider, choosing the billing frequency +(daily, monthly…) and how the recurring document is generated. The +invoicing behaviour is configured through three independent options:
      +
    • Create sale order: when enabled, a confirmed sale order is +generated before the invoice (required for the subscription to +show up in Sales analysis reports); when disabled, the invoice is +created directly and a warning reminds you that it will not appear +there.
    • +
    • Invoice status: leave the generated invoice in Draft or post +it automatically (Posted).
    • +
    • Send invoice by email: only available for posted invoices; when +enabled, the selected Invoice Email template is sent +automatically.
    • +
    +
  6. Go to Subscription > Subscriptions.
  7. Create a subscription and indicate the start date. When the Subscriptions Management cron job is executed, the subscription @@ -408,9 +420,9 @@

    Usage

    the execution date matches the next invoice date. Additionally, you can manually change the subscription status and create an invoice by using the Create Invoice button. This action creates just an -invoice even if the subscription template has the Sale Order & -Invoice option selected, because the Invoicing mode option is -triggered through the cron job.
  8. +invoice even if the subscription template has Create sale order +enabled, because the configured invoicing options are only applied by +the cron job.
  9. The cron job will also end the subscription if its end date has been reached. The end date honours the template recurrence (days, weeks, months or years) and the interval, instead of always assuming months.
  10. diff --git a/subscription_oca/tests/__init__.py b/subscription_oca/tests/__init__.py index db7813c497..74e71f86aa 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_invoicing_options diff --git a/subscription_oca/tests/test_subscription_invoicing_options.py b/subscription_oca/tests/test_subscription_invoicing_options.py new file mode 100644 index 0000000000..7eb5c6b831 --- /dev/null +++ b/subscription_oca/tests/test_subscription_invoicing_options.py @@ -0,0 +1,106 @@ +# Copyright 2026 Domatix +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields + +from .test_subscription_oca import TestSubscriptionOCA + + +class TestSubscriptionInvoicingOptions(TestSubscriptionOCA): + """Cover the orthogonal invoicing options that replaced ``invoicing_mode``. + + The three template fields (``create_sale_order``, ``invoice_state`` and + ``send_invoice``) must be independent of each other, so the behaviour is + tested as a matrix rather than a single mode selector. + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + # Invoice on order confirmation so the sale-order path is invoiceable. + (cls.product_1 | cls.product_2).write({"invoice_policy": "order"}) + + def _make_subscription(self, template_vals): + template = self.create_sub_template(template_vals) + subscription = self.create_sub( + { + "template_id": template.id, + "date_start": fields.Date.today(), + "recurring_next_date": fields.Date.today(), + "journal_id": self.sale_journal.id, + "in_progress": True, + } + ) + self.create_sub_line(subscription) + return subscription + + def test_draft_invoice_without_sale_order(self): + subscription = self._make_subscription( + {"create_sale_order": False, "invoice_state": "draft"} + ) + subscription.generate_invoice() + self.assertFalse(subscription.sale_order_ids) + self.assertEqual(len(subscription.invoice_ids), 1) + self.assertEqual(subscription.invoice_ids.state, "draft") + + def test_posted_invoice_not_sent(self): + subscription = self._make_subscription( + { + "create_sale_order": False, + "invoice_state": "posted", + "send_invoice": False, + } + ) + subscription.generate_invoice() + invoice = subscription.invoice_ids + self.assertEqual(invoice.state, "posted") + self.assertFalse(invoice.is_move_sent) + + def test_posted_invoice_sent(self): + subscription = self._make_subscription( + { + "create_sale_order": False, + "invoice_state": "posted", + "send_invoice": True, + "invoice_mail_template_id": self.env.ref( + "account.email_template_edi_invoice" + ).id, + } + ) + subscription.generate_invoice() + invoice = subscription.invoice_ids + self.assertEqual(invoice.state, "posted") + self.assertTrue(invoice.is_move_sent) + + def test_sale_order_with_posted_invoice(self): + subscription = self._make_subscription( + { + "create_sale_order": True, + "invoice_state": "posted", + "send_invoice": False, + } + ) + subscription.generate_invoice() + order = subscription.sale_order_ids + self.assertTrue(order) + self.assertEqual(order.state, "sale") + invoice = order.invoice_ids + self.assertEqual(invoice.state, "posted") + self.assertIn(order.name, invoice.invoice_origin) + self.assertIn(subscription.name, invoice.invoice_origin) + + def test_sale_order_with_draft_invoice(self): + # The axes are independent: a sale order can be generated while the + # invoice is still left in draft (impossible with the old selector). + subscription = self._make_subscription( + { + "create_sale_order": True, + "invoice_state": "draft", + "send_invoice": False, + } + ) + subscription.generate_invoice() + order = subscription.sale_order_ids + self.assertTrue(order) + invoice = order.invoice_ids + self.assertEqual(invoice.state, "draft") diff --git a/subscription_oca/tests/test_subscription_oca.py b/subscription_oca/tests/test_subscription_oca.py index 29d301ee1f..c14f08015b 100644 --- a/subscription_oca/tests/test_subscription_oca.py +++ b/subscription_oca/tests/test_subscription_oca.py @@ -117,14 +117,14 @@ def setUpClass(cls): cls.tmpl4 = cls.create_sub_template( { "recurring_rule_boundary": "limited", - "invoicing_mode": "invoice", + "invoice_state": "posted", "recurring_rule_type": "years", } ) cls.tmpl5 = cls.create_sub_template( { "recurring_rule_boundary": "unlimited", - "invoicing_mode": "invoice", + "invoice_state": "posted", "recurring_rule_type": "days", } ) @@ -533,13 +533,31 @@ def test_subscription_oca_sub8_workflow(self): subscription.create_invoice() self.sub8.journal_id = self.sale_journal subscription.create_invoice() - self.sub8.template_id.invoicing_mode = "invoice" + self.sub8.template_id.write( + { + "create_sale_order": False, + "invoice_state": "posted", + "send_invoice": False, + } + ) with self.assertRaises(exceptions.UserError): subscription.generate_invoice() - self.sub8.template_id.invoicing_mode = "invoice_send" + self.sub8.template_id.write( + { + "create_sale_order": False, + "invoice_state": "posted", + "send_invoice": True, + } + ) with self.assertRaises(exceptions.UserError): subscription.generate_invoice() - self.sub8.template_id.invoicing_mode = "sale_and_invoice" + self.sub8.template_id.write( + { + "create_sale_order": True, + "invoice_state": "posted", + "send_invoice": False, + } + ) with self.assertRaises(exceptions.UserError): subscription.generate_invoice() # add lines and repeat @@ -561,11 +579,29 @@ def test_subscription_oca_sub8_workflow(self): subscription.create_invoice() subscription.journal_id = self.sale_journal subscription.create_invoice() - subscription.template_id.invoicing_mode = "invoice" + subscription.template_id.write( + { + "create_sale_order": False, + "invoice_state": "posted", + "send_invoice": False, + } + ) subscription.generate_invoice() - subscription.template_id.invoicing_mode = "invoice_send" + subscription.template_id.write( + { + "create_sale_order": False, + "invoice_state": "posted", + "send_invoice": True, + } + ) subscription.generate_invoice() - subscription.template_id.invoicing_mode = "sale_and_invoice" + subscription.template_id.write( + { + "create_sale_order": True, + "invoice_state": "posted", + "send_invoice": False, + } + ) order = subscription.create_sale_order() order.with_context(uid=1).action_confirm() subscription.sale_subscription_line_ids.mapped("product_id").write( diff --git a/subscription_oca/tests/test_subscription_partner_addresses.py b/subscription_oca/tests/test_subscription_partner_addresses.py index dc04894e66..cb903a7d4d 100644 --- a/subscription_oca/tests/test_subscription_partner_addresses.py +++ b/subscription_oca/tests/test_subscription_partner_addresses.py @@ -39,7 +39,7 @@ def setUpClass(cls): "code": "ADDR", "recurring_rule_type": "months", "recurring_interval": 1, - "invoicing_mode": "draft", + "invoice_state": "draft", } ) cls.addr_stage = cls.env["sale.subscription.stage"].search( @@ -112,7 +112,8 @@ def test_invoice_follows_manual_address_override(self): self.assertEqual(invoice.partner_id, other_invoice) def test_sale_order_carries_addresses(self): - self.addr_template.invoicing_mode = "sale_and_invoice" + self.addr_template.create_sale_order = True + self.addr_template.invoice_state = "posted" sub = self._new_subscription(self.addr_customer) order = sub.create_sale_order() self.assertEqual(order.partner_id, self.addr_customer) diff --git a/subscription_oca/tests/test_subscription_recurrence_dates.py b/subscription_oca/tests/test_subscription_recurrence_dates.py index c3184acbfd..609db0ac37 100644 --- a/subscription_oca/tests/test_subscription_recurrence_dates.py +++ b/subscription_oca/tests/test_subscription_recurrence_dates.py @@ -165,7 +165,7 @@ def test_set_next_invoice_date_after_invoice_advances_one_period(self): # ------------------------------------------------------------------ def test_generate_invoice_advances_recurring_next_date(self): - template = self._make_template("months") # invoicing_mode defaults to draft + template = self._make_template("months") # invoice_state defaults to draft sub = self._make_subscription(template, date_start=date(2026, 1, 1)) sub.recurring_next_date = date(2026, 1, 1) self.env["sale.subscription.line"].create( diff --git a/subscription_oca/views/sale_subscription_template_views.xml b/subscription_oca/views/sale_subscription_template_views.xml index 3190c89b82..bdc24e3962 100644 --- a/subscription_oca/views/sale_subscription_template_views.xml +++ b/subscription_oca/views/sale_subscription_template_views.xml @@ -75,11 +75,33 @@ /> month(s) - + + + +