Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 31 additions & 23 deletions subscription_oca/README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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:f284a98a2170cda678d0f33678999997099c9b35bafd0705c49a659120f75f87
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!

.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png
Expand Down Expand Up @@ -74,21 +74,29 @@ 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.

Duplicate invoice prevention:

- A subscription will not be invoiced twice for the same period. If a
non-cancelled invoice already exists for the period being billed,
*Create Invoice* raises an error and the cron skips the subscription,
so an interrupted and re-run batch cannot duplicate invoices.
Cancelled invoices do not block re-invoicing.

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
===========
Expand All @@ -112,22 +120,22 @@ Authors
Contributors
------------

- Carlos Martínez <carlos@domatix.com>
- Carolina Ferrer <carolina@domatix.com>
- `Ooops404 <https://www.ooops404.com>`__:
- Carlos Martínez <carlos@domatix.com>
- Carolina Ferrer <carolina@domatix.com>
- `Ooops404 <https://www.ooops404.com>`__:

- Ilyas <irazor147@gmail.com>
- Ilyas <irazor147@gmail.com>

- `Sygel <https://www.sygel.es>`__:
- `Sygel <https://www.sygel.es>`__:

- Harald Panten
- Valentin Vinagre
- Alberto Martínez
- Harald Panten
- Valentin Vinagre
- Alberto Martínez

- Dennis Sluijk <d.sluijk@onestein.nl>
- `IKU Solutions <https://www.iku.solutions>`__:
- Dennis Sluijk <d.sluijk@onestein.nl>
- `IKU Solutions <https://www.iku.solutions>`__:

- Yan Chirino <yan.chirino@iku.solutions>
- Yan Chirino <yan.chirino@iku.solutions>

Maintainers
-----------
Expand Down
53 changes: 52 additions & 1 deletion subscription_oca/models/sale_subscription.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
from markupsafe import Markup

from odoo import Command, api, fields, models
from odoo.exceptions import AccessError
from odoo.exceptions import AccessError, UserError
from odoo.tools.misc import format_date, get_lang

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -376,6 +377,31 @@ def _prepare_account_move(self, line_ids):
values["journal_id"] = self.journal_id.id
return values

def _get_existing_invoice_for_period(self, period_start, period_end):
self.ensure_one()
line = self.env["account.move.line"].search(
[
("subscription_id", "=", self.id),
("subscription_period_start", "=", period_start),
("subscription_period_end", "=", period_end),
("move_id.state", "!=", "cancel"),
],
limit=1,
)
return line.move_id

def _can_create_invoice_for_period(self, period_start, period_end):
self.ensure_one()
return not self._get_existing_invoice_for_period(period_start, period_end)

def _format_period_for_message(self, period_start, period_end):
self.ensure_one()
lang_code = get_lang(self.env, self.partner_id.lang).code
return (
format_date(self.env, period_start, lang_code=lang_code),
format_date(self.env, period_end, lang_code=lang_code),
)

def create_invoice(self):
if not self.env["account.move"].has_access("create"):
try:
Expand Down Expand Up @@ -411,6 +437,19 @@ def create_sale_order(self):
return order_id

def generate_invoice(self):
period_start, period_end = self._get_invoice_period()
if not self._can_create_invoice_for_period(period_start, period_end):
start_str, end_str = self._format_period_for_message(
period_start, period_end
)
logger.info(
"Subscription %s: an invoice already exists "
"for the period %s - %s, skipping",
self.id,
start_str,
end_str,
)
return False
invoice_number = ""
message_body = ""
msg_static = self.env._("Created invoice with reference")
Expand Down Expand Up @@ -449,6 +488,18 @@ def generate_invoice(self):
self.message_post(body=Markup(message_body))

def manual_invoice(self):
period_start, period_end = self._get_invoice_period()
if not self._can_create_invoice_for_period(period_start, period_end):
start_str, end_str = self._format_period_for_message(
period_start, period_end
)
raise UserError(
self.env._(
"An invoice already exists for the period %(start)s - %(end)s.",
start=start_str,
end=end_str,
)
)
invoice_id = self.create_invoice()
self._set_next_invoice_date_after_invoice()
context = dict(self.env.context)
Expand Down
8 changes: 8 additions & 0 deletions subscription_oca/readme/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,11 @@ 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.

Duplicate invoice prevention:

- A subscription will not be invoiced twice for the same period. If a
non-cancelled invoice already exists for the period being billed,
*Create Invoice* raises an error and the cron skips the
subscription, so an interrupted and re-run batch cannot duplicate
invoices. Cancelled invoices do not block re-invoicing.
10 changes: 9 additions & 1 deletion subscription_oca/static/description/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -372,7 +372,7 @@ <h1>Subscription management</h1>
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! source digest: sha256:ea96c858a40ee527d0e32ee03859918c082521f205ccc1cb26e96cb3bef27800
!! source digest: sha256:f284a98a2170cda678d0f33678999997099c9b35bafd0705c49a659120f75f87
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->
<p><a class="reference external image-reference" href="https://odoo-community.org/page/development-status"><img alt="Beta" src="https://img.shields.io/badge/maturity-Beta-yellow.png" /></a> <a class="reference external image-reference" href="http://www.gnu.org/licenses/agpl-3.0-standalone.html"><img alt="License: AGPL-3" src="https://img.shields.io/badge/license-AGPL--3-blue.png" /></a> <a class="reference external image-reference" href="https://github.com/OCA/contract/tree/19.0/subscription_oca"><img alt="OCA/contract" src="https://img.shields.io/badge/github-OCA%2Fcontract-lightgray.png?logo=github" /></a> <a class="reference external image-reference" href="https://translation.odoo-community.org/projects/contract-19-0/contract-19-0-subscription_oca"><img alt="Translate me on Weblate" src="https://img.shields.io/badge/weblate-Translate%20me-F47D42.png" /></a> <a class="reference external image-reference" href="https://runboat.odoo-community.org/builds?repo=OCA/contract&amp;target_branch=19.0"><img alt="Try me on Runboat" src="https://img.shields.io/badge/runboat-Try%20me-875A7B.png" /></a></p>
<p>This module allows creating subscriptions that generate recurring
Expand Down Expand Up @@ -433,6 +433,14 @@ <h2><a class="toc-backref" href="#toc-entry-1">Usage</a></h2>
<li>The fields are shown when the <em>Customer Addresses</em> setting (group
<em>Display Delivery / Invoice addresses</em>) is enabled.</li>
</ul>
<p>Duplicate invoice prevention:</p>
<ul class="simple">
<li>A subscription will not be invoiced twice for the same period. If a
non-cancelled invoice already exists for the period being billed,
<em>Create Invoice</em> raises an error and the cron skips the subscription,
so an interrupted and re-run batch cannot duplicate invoices.
Cancelled invoices do not block re-invoicing.</li>
</ul>
</div>
<div class="section" id="known-issues-roadmap">
<h2><a class="toc-backref" href="#toc-entry-2">Known issues / Roadmap</a></h2>
Expand Down
1 change: 1 addition & 0 deletions subscription_oca/tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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_duplicate_invoices
129 changes: 129 additions & 0 deletions subscription_oca/tests/test_subscription_duplicate_invoices.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
# Copyright 2026 Domatix - Alvaro Domatix
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).

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 TestSubscriptionDuplicateInvoices(ProductCommon, BaseCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True))
cls.partner = cls.env["res.partner"].create({"name": "Dup partner"})
cls.pricelist = cls.env["product.pricelist"].create({"name": "Dup pricelist"})
cls.template = cls.env["sale.subscription.template"].create(
{
"name": "Dup template",
"code": "DUP-MTH",
"recurring_rule_type": "months",
"recurring_rule_boundary": "unlimited",
"invoicing_mode": "draft",
}
)
cls.product = cls._create_product(
name="Dup product",
lst_price=50.0,
subscribable=True,
uom_id=cls.uom_unit.id,
)

def _make_subscription(self):
sub = self.env["sale.subscription"].create(
{
"partner_id": self.partner.id,
"pricelist_id": self.pricelist.id,
"template_id": self.template.id,
"date_start": fields.Date.today(),
"recurring_next_date": fields.Date.today(),
}
)
self.env["sale.subscription.line"].create(
{
"sale_subscription_id": sub.id,
"product_id": self.product.id,
}
)
return sub

def _rewind_to_invoiced_period(self, sub, period_start):
"""Put ``recurring_next_date`` back to the period that was just billed.

This reproduces the real situation the guard protects against: a run
created (and committed) the invoice for that period but was interrupted
before advancing ``recurring_next_date`` (e.g. the cron crashed, or the
email step failed after the invoice was posted). On the next run the
subscription is still due for the *same* period and must not be billed
twice.
"""
sub.recurring_next_date = period_start

def test_draft_invoice_blocks_duplicate(self):
sub = self._make_subscription()
period_start = sub.recurring_next_date
sub.manual_invoice()
self._rewind_to_invoiced_period(sub, period_start)
with self.assertRaises(UserError):
sub.manual_invoice()

def test_posted_invoice_blocks_duplicate(self):
sub = self._make_subscription()
period_start = sub.recurring_next_date
invoice = sub.create_invoice()
invoice.action_post()
self._rewind_to_invoiced_period(sub, period_start)
with self.assertRaises(UserError):
sub.manual_invoice()

def test_next_period_is_not_blocked(self):
# After a normal invoice the date advances to the next period, which is
# a different (not-yet-billed) period and must be allowed.
sub = self._make_subscription()
sub.manual_invoice()
period_start, period_end = sub._get_invoice_period()
self.assertTrue(sub._can_create_invoice_for_period(period_start, period_end))
invoice = sub.manual_invoice()
self.assertTrue(invoice)

def test_cancelled_invoice_does_not_block(self):
sub = self._make_subscription()
period_start = sub.recurring_next_date
invoice = sub.create_invoice()
invoice.button_cancel()
self._rewind_to_invoiced_period(sub, period_start)
period_start, period_end = sub._get_invoice_period()
self.assertTrue(sub._can_create_invoice_for_period(period_start, period_end))

def test_can_create_invoice_for_fresh_period(self):
sub = self._make_subscription()
period_start, period_end = sub._get_invoice_period()
self.assertTrue(sub._can_create_invoice_for_period(period_start, period_end))

def test_user_error_message_contains_period(self):
sub = self._make_subscription()
period_start = sub.recurring_next_date
sub.manual_invoice()
self._rewind_to_invoiced_period(sub, period_start)
with self.assertRaises(UserError) as ctx:
sub.manual_invoice()
self.assertIn("already exists", str(ctx.exception))

def test_generate_invoice_skips_duplicate(self):
sub = self._make_subscription()
period_start = sub.recurring_next_date
sub.manual_invoice()
invoices_before = self.env["account.move"].search_count(
[("subscription_id", "=", sub.id)]
)
self._rewind_to_invoiced_period(sub, period_start)
with mute_logger("odoo.addons.subscription_oca.models.sale_subscription"):
result = sub.generate_invoice()
self.assertFalse(result)
invoices_after = self.env["account.move"].search_count(
[("subscription_id", "=", sub.id)]
)
self.assertEqual(invoices_before, invoices_after)
1 change: 1 addition & 0 deletions subscription_oca/tests/test_subscription_oca.py
Original file line number Diff line number Diff line change
Expand Up @@ -728,6 +728,7 @@ def _collect_all_sub_test_results(self, subscription):
test_res.append(sale_order)
move_id = subscription.create_invoice()
test_res.append(move_id)
subscription.calculate_recurring_next_date(subscription.recurring_next_date)
res = subscription.manual_invoice()
test_res.append(res["type"])
inv_ids = self.env["account.move"].search(
Expand Down
Loading