Skip to content

Commit 66a972a

Browse files
[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.
1 parent 0ddf327 commit 66a972a

11 files changed

Lines changed: 298 additions & 62 deletions

File tree

subscription_oca/README.rst

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,19 @@ Usage
4747
To make a subscription:
4848

4949
1. Go to *Subscriptions > Configuration > Subscription templates*.
50-
2. Create the templates you consider, choosing the billing frequency:
51-
daily, monthly... and the method of creating the invoice and/or
52-
order.
50+
2. Create the templates you consider, choosing the billing frequency
51+
(daily, monthly...) and how the recurring document is generated. The
52+
invoicing behaviour is configured through three independent options:
53+
54+
- *Create sale order*: when enabled, a confirmed sale order is
55+
generated before the invoice (required for the subscription to show
56+
up in Sales analysis reports); when disabled, the invoice is created
57+
directly and a warning reminds you that it will not appear there.
58+
- *Invoice status*: leave the generated invoice in *Draft* or post it
59+
automatically (*Posted*).
60+
- *Send invoice by email*: only available for posted invoices; when
61+
enabled, the selected *Invoice Email* template is sent
62+
automatically.
5363
3. Go to *Subscription > Subscriptions*.
5464
4. Create a subscription and indicate the start date. When the
5565
*Subscriptions Management* cron job is executed, the subscription
@@ -58,9 +68,9 @@ To make a subscription:
5868
the execution date matches the next invoice date. Additionally, you
5969
can manually change the subscription status and create an invoice by
6070
using the *Create Invoice* button. This action creates just an
61-
invoice even if the subscription template has the *Sale Order &
62-
Invoice* option selected, because the *Invoicing mode* option is
63-
triggered through the cron job.
71+
invoice even if the subscription template has *Create sale order*
72+
enabled, because the configured invoicing options are only applied by
73+
the cron job.
6474
5. The cron job will also end the subscription if its end date has been
6575
reached.
6676

subscription_oca/__manifest__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
{
44
"name": "Subscription management",
55
"summary": "Generate recurring invoices.",
6-
"version": "19.0.1.0.0",
6+
"version": "19.0.2.0.0",
77
"development_status": "Beta",
88
"category": "Subscription Management",
99
"website": "https://github.com/OCA/contract",
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# Copyright 2026 Domatix
2+
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
3+
4+
5+
def migrate(cr, version):
6+
"""Split the legacy ``invoicing_mode`` selection into orthogonal fields.
7+
8+
Old ``invoicing_mode`` values map to the new fields as follows:
9+
10+
=================== ================= ============== ==============
11+
invoicing_mode create_sale_order invoice_state send_invoice
12+
=================== ================= ============== ==============
13+
draft False draft False
14+
invoice False posted False
15+
invoice_send False posted True
16+
sale_and_invoice True posted False
17+
=================== ================= ============== ==============
18+
"""
19+
cr.execute(
20+
"SELECT 1 FROM information_schema.columns "
21+
"WHERE table_name = 'sale_subscription_template' "
22+
"AND column_name = 'invoicing_mode'"
23+
)
24+
if not cr.fetchone():
25+
return
26+
cr.execute(
27+
"""
28+
UPDATE sale_subscription_template
29+
SET create_sale_order = (invoicing_mode = 'sale_and_invoice'),
30+
invoice_state = CASE
31+
WHEN invoicing_mode IN ('invoice', 'invoice_send', 'sale_and_invoice')
32+
THEN 'posted' ELSE 'draft' END,
33+
send_invoice = (invoicing_mode = 'invoice_send')
34+
"""
35+
)
36+
cr.execute(
37+
"ALTER TABLE sale_subscription_template DROP COLUMN IF EXISTS invoicing_mode"
38+
)

subscription_oca/models/sale_subscription.py

Lines changed: 21 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -340,33 +340,30 @@ def generate_invoice(self):
340340
invoice_number = ""
341341
message_body = ""
342342
msg_static = self.env._("Created invoice with reference")
343-
if self.template_id.invoicing_mode in ["draft", "invoice", "invoice_send"]:
344-
invoice = self.create_invoice()
345-
if self.template_id.invoicing_mode != "draft":
346-
invoice.action_post()
347-
mail_template = self.template_id.invoice_mail_template_id
348-
self.env["account.move.send"]._generate_and_send_invoices(
349-
invoice, mail_template=mail_template, sending_methods=["email"]
350-
)
351-
invoice_number = invoice.name
352-
message_body = (
353-
f"<b>{msg_static}</b> "
354-
f"<a href=# data-oe-model=account.move data-oe-id={invoice.id}>"
355-
f"{invoice_number}"
356-
"</a>"
357-
)
358-
359-
if self.template_id.invoicing_mode == "sale_and_invoice":
343+
template = self.template_id
344+
if template.create_sale_order:
360345
order_id = self.create_sale_order()
361346
order_id.action_confirm()
362347
order_id.action_lock()
363-
new_invoice = order_id._create_invoices()
364-
new_invoice.action_post()
365-
new_invoice.invoice_origin = order_id.name + ", " + self.name
366-
invoice_number = new_invoice.name
367-
message_body = f"<b>{msg_static}</b> \
368-
<a href=# data-oe-model=account.move data-oe-id={new_invoice.id}>\
369-
{invoice_number}</a>"
348+
invoice = order_id._create_invoices()
349+
invoice.invoice_origin = order_id.name + ", " + self.name
350+
else:
351+
invoice = self.create_invoice()
352+
if invoice and template.invoice_state == "posted":
353+
invoice.action_post()
354+
if template.send_invoice:
355+
self.env["account.move.send"]._generate_and_send_invoices(
356+
invoice,
357+
mail_template=template.invoice_mail_template_id,
358+
sending_methods=["email"],
359+
)
360+
invoice_number = invoice.name
361+
message_body = (
362+
f"<b>{msg_static}</b> "
363+
f"<a href=# data-oe-model=account.move data-oe-id={invoice.id}>"
364+
f"{invoice_number}"
365+
"</a>"
366+
)
370367

371368
if not invoice_number:
372369
invoice_number = self.env._("To validate")

subscription_oca/models/sale_subscription_template.py

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,15 +27,21 @@ class SaleSubscriptionTemplate(models.Model):
2727
string="Duration",
2828
default="unlimited",
2929
)
30-
invoicing_mode = fields.Selection(
30+
create_sale_order = fields.Boolean(
31+
string="Create sale order",
32+
help="Generate a confirmed sale order before invoicing. Required for the "
33+
"subscription to appear in Sales analysis reports.",
34+
)
35+
invoice_state = fields.Selection(
36+
selection=[("draft", "Draft"), ("posted", "Posted")],
37+
string="Invoice status",
3138
default="draft",
32-
string="Invoicing mode",
33-
selection=[
34-
("draft", "Draft"),
35-
("invoice", "Invoice"),
36-
("invoice_send", "Invoice & send"),
37-
("sale_and_invoice", "Sale order & Invoice"),
38-
],
39+
required=True,
40+
help="State in which the generated invoice is left.",
41+
)
42+
send_invoice = fields.Boolean(
43+
string="Send invoice by email",
44+
help="Send the invoice by email once posted, using the template below.",
3945
)
4046
code = fields.Char()
4147
recurring_rule_count = fields.Integer(default=1, string="Rule count")

subscription_oca/readme/USAGE.md

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,17 @@
11
To make a subscription:
22

33
1. Go to *Subscriptions \> Configuration \> Subscription templates*.
4-
2. Create the templates you consider, choosing the billing frequency:
5-
daily, monthly... and the method of creating the invoice and/or
6-
order.
4+
2. Create the templates you consider, choosing the billing frequency
5+
(daily, monthly...) and how the recurring document is generated. The
6+
invoicing behaviour is configured through three independent options:
7+
- *Create sale order*: when enabled, a confirmed sale order is
8+
generated before the invoice (required for the subscription to show
9+
up in Sales analysis reports); when disabled, the invoice is created
10+
directly and a warning reminds you that it will not appear there.
11+
- *Invoice status*: leave the generated invoice in *Draft* or post it
12+
automatically (*Posted*).
13+
- *Send invoice by email*: only available for posted invoices; when
14+
enabled, the selected *Invoice Email* template is sent automatically.
715
3. Go to *Subscription \> Subscriptions*.
816
4. Create a subscription and indicate the start date. When the
917
*Subscriptions Management* cron job is executed, the subscription
@@ -12,9 +20,9 @@ To make a subscription:
1220
the execution date matches the next invoice date. Additionally, you
1321
can manually change the subscription status and create an invoice by
1422
using the *Create Invoice* button. This action creates just an
15-
invoice even if the subscription template has the *Sale Order &
16-
Invoice* option selected, because the *Invoicing mode* option is
17-
triggered through the cron job.
23+
invoice even if the subscription template has *Create sale order*
24+
enabled, because the configured invoicing options are only applied by
25+
the cron job.
1826
5. The cron job will also end the subscription if its end date has been
1927
reached.
2028

subscription_oca/static/description/index.html

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -397,9 +397,21 @@ <h2><a class="toc-backref" href="#toc-entry-1">Usage</a></h2>
397397
<p>To make a subscription:</p>
398398
<ol class="arabic simple">
399399
<li>Go to <em>Subscriptions &gt; Configuration &gt; Subscription templates</em>.</li>
400-
<li>Create the templates you consider, choosing the billing frequency:
401-
daily, monthly… and the method of creating the invoice and/or
402-
order.</li>
400+
<li>Create the templates you consider, choosing the billing frequency
401+
(daily, monthly…) and how the recurring document is generated. The
402+
invoicing behaviour is configured through three independent options:<ul>
403+
<li><em>Create sale order</em>: when enabled, a confirmed sale order is
404+
generated before the invoice (required for the subscription to
405+
show up in Sales analysis reports); when disabled, the invoice is
406+
created directly and a warning reminds you that it will not appear
407+
there.</li>
408+
<li><em>Invoice status</em>: leave the generated invoice in <em>Draft</em> or post
409+
it automatically (<em>Posted</em>).</li>
410+
<li><em>Send invoice by email</em>: only available for posted invoices; when
411+
enabled, the selected <em>Invoice Email</em> template is sent
412+
automatically.</li>
413+
</ul>
414+
</li>
403415
<li>Go to <em>Subscription &gt; Subscriptions</em>.</li>
404416
<li>Create a subscription and indicate the start date. When the
405417
<em>Subscriptions Management</em> cron job is executed, the subscription
@@ -408,9 +420,9 @@ <h2><a class="toc-backref" href="#toc-entry-1">Usage</a></h2>
408420
the execution date matches the next invoice date. Additionally, you
409421
can manually change the subscription status and create an invoice by
410422
using the <em>Create Invoice</em> button. This action creates just an
411-
invoice even if the subscription template has the <em>Sale Order &amp;
412-
Invoice</em> option selected, because the <em>Invoicing mode</em> option is
413-
triggered through the cron job.</li>
423+
invoice even if the subscription template has <em>Create sale order</em>
424+
enabled, because the configured invoicing options are only applied by
425+
the cron job.</li>
414426
<li>The cron job will also end the subscription if its end date has been
415427
reached.</li>
416428
</ol>

subscription_oca/tests/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
22

33
from . import test_subscription_oca
4+
from . import test_subscription_invoicing_options
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
# Copyright 2026 Domatix
2+
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
3+
4+
from odoo import fields
5+
6+
from .test_subscription_oca import TestSubscriptionOCA
7+
8+
9+
class TestSubscriptionInvoicingOptions(TestSubscriptionOCA):
10+
"""Cover the orthogonal invoicing options that replaced ``invoicing_mode``.
11+
12+
The three template fields (``create_sale_order``, ``invoice_state`` and
13+
``send_invoice``) must be independent of each other, so the behaviour is
14+
tested as a matrix rather than a single mode selector.
15+
"""
16+
17+
@classmethod
18+
def setUpClass(cls):
19+
super().setUpClass()
20+
# Invoice on order confirmation so the sale-order path is invoiceable.
21+
(cls.product_1 | cls.product_2).write({"invoice_policy": "order"})
22+
23+
def _make_subscription(self, template_vals):
24+
template = self.create_sub_template(template_vals)
25+
subscription = self.create_sub(
26+
{
27+
"template_id": template.id,
28+
"date_start": fields.Date.today(),
29+
"recurring_next_date": fields.Date.today(),
30+
"journal_id": self.sale_journal.id,
31+
"in_progress": True,
32+
}
33+
)
34+
self.create_sub_line(subscription)
35+
return subscription
36+
37+
def test_draft_invoice_without_sale_order(self):
38+
subscription = self._make_subscription(
39+
{"create_sale_order": False, "invoice_state": "draft"}
40+
)
41+
subscription.generate_invoice()
42+
self.assertFalse(subscription.sale_order_ids)
43+
self.assertEqual(len(subscription.invoice_ids), 1)
44+
self.assertEqual(subscription.invoice_ids.state, "draft")
45+
46+
def test_posted_invoice_not_sent(self):
47+
subscription = self._make_subscription(
48+
{
49+
"create_sale_order": False,
50+
"invoice_state": "posted",
51+
"send_invoice": False,
52+
}
53+
)
54+
subscription.generate_invoice()
55+
invoice = subscription.invoice_ids
56+
self.assertEqual(invoice.state, "posted")
57+
self.assertFalse(invoice.is_move_sent)
58+
59+
def test_posted_invoice_sent(self):
60+
subscription = self._make_subscription(
61+
{
62+
"create_sale_order": False,
63+
"invoice_state": "posted",
64+
"send_invoice": True,
65+
"invoice_mail_template_id": self.env.ref(
66+
"account.email_template_edi_invoice"
67+
).id,
68+
}
69+
)
70+
subscription.generate_invoice()
71+
invoice = subscription.invoice_ids
72+
self.assertEqual(invoice.state, "posted")
73+
self.assertTrue(invoice.is_move_sent)
74+
75+
def test_sale_order_with_posted_invoice(self):
76+
subscription = self._make_subscription(
77+
{
78+
"create_sale_order": True,
79+
"invoice_state": "posted",
80+
"send_invoice": False,
81+
}
82+
)
83+
subscription.generate_invoice()
84+
order = subscription.sale_order_ids
85+
self.assertTrue(order)
86+
self.assertEqual(order.state, "sale")
87+
invoice = order.invoice_ids
88+
self.assertEqual(invoice.state, "posted")
89+
self.assertIn(order.name, invoice.invoice_origin)
90+
self.assertIn(subscription.name, invoice.invoice_origin)
91+
92+
def test_sale_order_with_draft_invoice(self):
93+
# The axes are independent: a sale order can be generated while the
94+
# invoice is still left in draft (impossible with the old selector).
95+
subscription = self._make_subscription(
96+
{
97+
"create_sale_order": True,
98+
"invoice_state": "draft",
99+
"send_invoice": False,
100+
}
101+
)
102+
subscription.generate_invoice()
103+
order = subscription.sale_order_ids
104+
self.assertTrue(order)
105+
invoice = order.invoice_ids
106+
self.assertEqual(invoice.state, "draft")

0 commit comments

Comments
 (0)