diff --git a/contract_invoice_offset/README.rst b/contract_invoice_offset/README.rst new file mode 100644 index 0000000000..a0962a32bc --- /dev/null +++ b/contract_invoice_offset/README.rst @@ -0,0 +1,112 @@ +======================= +Contract Invoice Offset +======================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:cc0cdda8117975e2abacbfce602f28372973e27343551ddfcdb69fb65024eb5d + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fcontract-lightgray.png?logo=github + :target: https://github.com/OCA/contract/tree/18.0/contract_invoice_offset + :alt: OCA/contract +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/contract-18-0/contract-18-0-contract_invoice_offset + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/contract&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module extends the **Contract** module to provide flexible +invoicing offsets. It allows you to define offsets in Days, Weeks, +Months, or Years, enabling scenarios such as **Pre-paid + 1 month in +advance** (e.g. Invoicing January's service in December). + +**Table of contents** + +.. contents:: + :local: + +Use Cases / Context +=================== + +In the standard behavior of the Contract module, invoicing offsets are +restricted to a fixed number of days. While this works for many cases, +it is difficult to configure reliable "in advance" invoicing for +intervals like months, given the varying number of days in each month. + +In some business scenarios, a contract must be invoiced exactly one +month in advance (e.g., invoicing January service in December) or with a +specific delay in weeks or years. + +This module introduces flexible invoicing offsets, allowing users to +define offsets in Days, Weeks, Months, or Years, ensuring the invoice +date is logically consistent with the billing period regardless of +calendar variations. + +Usage +===== + +To use this module: + +1. Go to **Accounting > Customers > Contracts**. +2. Select or create a contract. +3. In the **Invoicing** section: + + - Set **Invoicing type** (e.g., Pre-paid). + - Set **Invoicing Offset Type** (e.g., Months). + - Set **Invoicing Offset Value** (e.g., -1 for 1 month in advance). + +4. The **Date of Next Invoice** will be calculated based on your + configuration. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* bosd + +Contributors +------------ + +- bosd + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/contract `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/contract_invoice_offset/__init__.py b/contract_invoice_offset/__init__.py new file mode 100644 index 0000000000..0650744f6b --- /dev/null +++ b/contract_invoice_offset/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/contract_invoice_offset/__manifest__.py b/contract_invoice_offset/__manifest__.py new file mode 100644 index 0000000000..92c0c18c32 --- /dev/null +++ b/contract_invoice_offset/__manifest__.py @@ -0,0 +1,19 @@ +{ + "name": "Contract Invoice Offset", + "summary": "Flexible invoicing offsets (e.g. 1 month in advance)", + "version": "19.0.1.0.0", + "category": "Accounting", + "website": "https://github.com/OCA/contract", + "author": "bosd, Odoo Community Association (OCA)", + "license": "AGPL-3", + "depends": [ + "contract", + ], + "data": [ + "views/contract_view.xml", + ], + "demo": [ + "demo/contract_demo.xml", + ], + "installable": True, +} diff --git a/contract_invoice_offset/demo/contract_demo.xml b/contract_invoice_offset/demo/contract_demo.xml new file mode 100644 index 0000000000..6f6a82e096 --- /dev/null +++ b/contract_invoice_offset/demo/contract_demo.xml @@ -0,0 +1,52 @@ + + + + Demo Contract: 1 Month Advance + + 1 + monthly + + pre-paid + months + -1 + + + + + Demo Contract: 2 Weeks Delay + + 1 + monthly + + post-paid + weeks + 2 + + + diff --git a/contract_invoice_offset/models/__init__.py b/contract_invoice_offset/models/__init__.py new file mode 100644 index 0000000000..371f6a16eb --- /dev/null +++ b/contract_invoice_offset/models/__init__.py @@ -0,0 +1,3 @@ +from . import contract_recurring_mixin +from . import contract +from . import contract_line diff --git a/contract_invoice_offset/models/contract.py b/contract_invoice_offset/models/contract.py new file mode 100644 index 0000000000..2b2bca6816 --- /dev/null +++ b/contract_invoice_offset/models/contract.py @@ -0,0 +1,87 @@ +# Copyright 2025 bosd +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import api, models + + +class ContractContract(models.Model): + _inherit = "contract.contract" + + @api.depends( + "next_period_date_start", + "recurring_invoicing_type", + "recurring_invoicing_offset", + "recurring_rule_type", + "recurring_interval", + "date_end", + "contract_line_ids.recurring_next_date", + "contract_line_ids.is_canceled", + "invoicing_offset_type", + "invoicing_offset_value", + ) + def _compute_recurring_next_date(self): + for contract in self: + recurring_next_date = contract.contract_line_ids.filtered( + lambda line: ( + line.recurring_next_date + and not line.is_canceled + and (not line.display_type or line.is_recurring_note) + ) + ).mapped("recurring_next_date") + # we give priority to computation from date_start if modified + if ( + contract._origin + and contract._origin.date_start != contract.date_start + or not recurring_next_date + ): + kwargs = { + "invoicing_offset_type": contract.invoicing_offset_type, + "invoicing_offset_value": contract.invoicing_offset_value, + } + if hasattr(contract, "align_billing_cycle"): + kwargs["align_billing_cycle"] = contract.align_billing_cycle + + contract.recurring_next_date = self.get_next_invoice_date( + contract.next_period_date_start, + contract.recurring_invoicing_type, + contract.recurring_invoicing_offset, + contract.recurring_rule_type, + contract.recurring_interval, + max_date_end=contract.date_end, + **kwargs, + ) + else: + contract.recurring_next_date = min(recurring_next_date) + + @api.depends( + "next_period_date_start", + "recurring_invoicing_type", + "recurring_invoicing_offset", + "recurring_rule_type", + "recurring_interval", + "date_end", + "recurring_next_date", + "invoicing_offset_type", + "invoicing_offset_value", + ) + def _compute_next_period_date_end(self): + """Compute the end date of the next billing period.""" + # Override to pass offset settings + for rec in self: + kwargs = { + "invoicing_offset_type": rec.invoicing_offset_type, + "invoicing_offset_value": rec.invoicing_offset_value, + } + if hasattr(rec, "align_billing_cycle"): + kwargs["align_billing_cycle"] = rec.align_billing_cycle + + rec.next_period_date_end = self.get_next_period_date_end( + rec.next_period_date_start, + rec.recurring_rule_type, + rec.recurring_interval, + max_date_end=rec.date_end, + next_invoice_date=rec.recurring_next_date, + recurring_invoicing_type=rec.recurring_invoicing_type, + recurring_invoicing_offset=rec.recurring_invoicing_offset, + **kwargs, + ) diff --git a/contract_invoice_offset/models/contract_line.py b/contract_invoice_offset/models/contract_line.py new file mode 100644 index 0000000000..60dcb0320c --- /dev/null +++ b/contract_invoice_offset/models/contract_line.py @@ -0,0 +1,113 @@ +# Copyright 2025 bosd +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from dateutil.relativedelta import relativedelta + +from odoo import api, models + + +class ContractLine(models.Model): + _inherit = "contract.line" + + @api.depends( + "next_period_date_start", + "recurring_invoicing_type", + "recurring_invoicing_offset", + "recurring_rule_type", + "recurring_interval", + "date_end", + "contract_id.invoicing_offset_type", + "contract_id.invoicing_offset_value", + "recurring_next_date", + ) + def _compute_next_period_date_end(self): + for rec in self: + kwargs = { + "invoicing_offset_type": rec.contract_id.invoicing_offset_type, + "invoicing_offset_value": rec.contract_id.invoicing_offset_value, + } + if hasattr(rec.contract_id, "align_billing_cycle"): + kwargs["align_billing_cycle"] = rec.contract_id.align_billing_cycle + + rec.next_period_date_end = self.get_next_period_date_end( + rec.next_period_date_start, + rec.recurring_rule_type, + rec.recurring_interval, + max_date_end=rec.date_end, + next_invoice_date=rec.recurring_next_date, + recurring_invoicing_type=rec.recurring_invoicing_type, + recurring_invoicing_offset=rec.recurring_invoicing_offset, + **kwargs, + ) + + @api.depends( + "next_period_date_start", + "recurring_invoicing_type", + "recurring_invoicing_offset", + "recurring_rule_type", + "recurring_interval", + "date_end", + "contract_id.invoicing_offset_type", + "contract_id.invoicing_offset_value", + ) + def _compute_recurring_next_date(self): + # Override to look at contract_id for offset settings, + # since lines don't (typically) have these fields themselves set independently. + for rec in self: + kwargs = { + "invoicing_offset_type": rec.contract_id.invoicing_offset_type, + "invoicing_offset_value": rec.contract_id.invoicing_offset_value, + } + if hasattr(rec.contract_id, "align_billing_cycle"): + kwargs["align_billing_cycle"] = rec.contract_id.align_billing_cycle + + rec.recurring_next_date = self.get_next_invoice_date( + rec.next_period_date_start, + rec.recurring_invoicing_type, + rec.recurring_invoicing_offset, + rec.recurring_rule_type, + rec.recurring_interval, + max_date_end=rec.date_end, + **kwargs, + ) + + @api.constrains("recurring_next_date", "date_start") + def _check_recurring_next_date_start_date(self): + # Filter out lines that have a negative offset (advance payment) + lines_to_check = self.filtered( + lambda line: line.contract_id.invoicing_offset_value >= 0 + ) + + if lines_to_check: + super(ContractLine, lines_to_check)._check_recurring_next_date_start_date() + return + + def _get_period_to_invoice( + self, last_date_invoiced, recurring_next_date, stop_at_date_end=True + ): + self.ensure_one() + if not recurring_next_date: + return False, False, False + first_date_invoiced = ( + last_date_invoiced + relativedelta(days=1) + if last_date_invoiced + else self.date_start + ) + kwargs = { + "invoicing_offset_type": self.contract_id.invoicing_offset_type, + "invoicing_offset_value": self.contract_id.invoicing_offset_value, + } + if hasattr(self.contract_id, "align_billing_cycle"): + kwargs["align_billing_cycle"] = self.contract_id.align_billing_cycle + + last_date_invoiced = self.get_next_period_date_end( + first_date_invoiced, + self.recurring_rule_type, + self.recurring_interval, + max_date_end=(self.date_end if stop_at_date_end else False), + next_invoice_date=recurring_next_date, + recurring_invoicing_type=self.recurring_invoicing_type, + recurring_invoicing_offset=self.recurring_invoicing_offset, + **kwargs, + ) + return first_date_invoiced, last_date_invoiced, recurring_next_date diff --git a/contract_invoice_offset/models/contract_recurring_mixin.py b/contract_invoice_offset/models/contract_recurring_mixin.py new file mode 100644 index 0000000000..7574c1065c --- /dev/null +++ b/contract_invoice_offset/models/contract_recurring_mixin.py @@ -0,0 +1,147 @@ +# Copyright 2025 bosd +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from dateutil.relativedelta import relativedelta + +from odoo import api, fields, models + + +class ContractRecurringMixin(models.AbstractModel): + _inherit = "contract.recurring.mixin" + + invoicing_offset_type = fields.Selection( + [ + ("days", "Days"), + ("weeks", "Weeks"), + ("months", "Months"), + ("years", "Years"), + ], + default="days", + required=True, + ) + invoicing_offset_value = fields.Integer( + default=0, + help="Positive value delays invoice, negative value invoices in advance. " + "E.g., -1 for 1 month in advance.", + ) + + @api.model + def get_next_invoice_date( + self, + next_period_date_start, + recurring_invoicing_type, + recurring_invoicing_offset, + recurring_rule_type, + recurring_interval, + max_date_end, + invoicing_offset_type="days", + invoicing_offset_value=0, + **kwargs, + ): + """Compute the date of the next invoice based on all parameters, + including flexible offsets. + """ + next_period_date_end = self.get_next_period_date_end( + next_period_date_start, + recurring_rule_type, + recurring_interval, + max_date_end=max_date_end, + invoicing_offset_type=invoicing_offset_type, + invoicing_offset_value=invoicing_offset_value, + **kwargs, + ) + if not next_period_date_end: + return False + + # Calculate base date + if recurring_invoicing_type == "pre-paid": + base_date = next_period_date_start + else: + base_date = next_period_date_end + + # Apply offset + # First, apply the original days-based offset for consistency. + base_date += relativedelta(days=recurring_invoicing_offset) + # Then, apply the new flexible offset. + if invoicing_offset_type == "days": + return base_date + relativedelta(days=invoicing_offset_value) + elif invoicing_offset_type == "weeks": + return base_date + relativedelta(weeks=invoicing_offset_value) + elif invoicing_offset_type == "months": + return base_date + relativedelta(months=invoicing_offset_value) + elif invoicing_offset_type == "years": + return base_date + relativedelta(years=invoicing_offset_value) + + return base_date + + @api.model + def get_next_period_date_end( + self, + next_period_date_start, + recurring_rule_type, + recurring_interval, + max_date_end, + next_invoice_date=False, + recurring_invoicing_type=False, + recurring_invoicing_offset=False, + invoicing_offset_type="days", + invoicing_offset_value=0, + **kwargs, + ): + """Compute the end date for the next period, supporting flexible + reverse calculation.""" + if not next_period_date_start or ( + max_date_end and next_period_date_start > max_date_end + ): + return False + + # Check for billing cycle alignment from contract_invoice_align_start + align_billing_cycle = kwargs.get("align_billing_cycle") + if align_billing_cycle and next_period_date_start.day != 1: + # Force end to be end of the current month (alignment takes precedence) + next_period_date_end = next_period_date_start + relativedelta(day=31) + if max_date_end and next_period_date_end > max_date_end: + next_period_date_end = max_date_end + return next_period_date_end + + if not next_invoice_date: + # Regular case: use relative delta (unchanged from base) + next_period_date_end = ( + next_period_date_start + + self.get_relative_delta(recurring_rule_type, recurring_interval) + - relativedelta(days=1) + ) + else: + # Forced invoice date: back-calculate period end + # We need to reverse the offset to find the base date (start or end) + + # 1. Reverse the offset to get the 'base date' (which is start or end) + base_date = next_invoice_date + # First, reverse the new flexible offset. + if invoicing_offset_type == "days": + base_date -= relativedelta(days=invoicing_offset_value) + elif invoicing_offset_type == "weeks": + base_date -= relativedelta(weeks=invoicing_offset_value) + elif invoicing_offset_type == "months": + base_date -= relativedelta(months=invoicing_offset_value) + elif invoicing_offset_type == "years": + base_date -= relativedelta(years=invoicing_offset_value) + # Then, reverse the original days-based offset. + base_date -= relativedelta(days=recurring_invoicing_offset) + + # 2. From base date, derive period end. + if recurring_invoicing_type == "pre-paid": + # base_date is Start Date + # End Date = Start Date + Duration - 1 day + next_period_date_end = ( + base_date + + self.get_relative_delta(recurring_rule_type, recurring_interval) + - relativedelta(days=1) + ) + else: # post-paid + # base_date is End Date + next_period_date_end = base_date + + if max_date_end and next_period_date_end > max_date_end: + next_period_date_end = max_date_end + return next_period_date_end diff --git a/contract_invoice_offset/pyproject.toml b/contract_invoice_offset/pyproject.toml new file mode 100644 index 0000000000..4231d0cccb --- /dev/null +++ b/contract_invoice_offset/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/contract_invoice_offset/readme/CONTEXT.md b/contract_invoice_offset/readme/CONTEXT.md new file mode 100644 index 0000000000..2570737a1a --- /dev/null +++ b/contract_invoice_offset/readme/CONTEXT.md @@ -0,0 +1,12 @@ +In the standard behavior of the Contract module, invoicing offsets are restricted +to a fixed number of days. While this works for many cases, it is difficult to +configure reliable "in advance" invoicing for intervals like months, given the +varying number of days in each month. + +In some business scenarios, a contract must be invoiced exactly one month +in advance (e.g., invoicing January service in December) or with a specific +delay in weeks or years. + +This module introduces flexible invoicing offsets, allowing users to define +offsets in Days, Weeks, Months, or Years, ensuring the invoice date is +logically consistent with the billing period regardless of calendar variations. diff --git a/contract_invoice_offset/readme/CONTRIBUTORS.md b/contract_invoice_offset/readme/CONTRIBUTORS.md new file mode 100644 index 0000000000..611190d79a --- /dev/null +++ b/contract_invoice_offset/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +* bosd diff --git a/contract_invoice_offset/readme/DESCRIPTION.md b/contract_invoice_offset/readme/DESCRIPTION.md new file mode 100644 index 0000000000..cd8d6800b4 --- /dev/null +++ b/contract_invoice_offset/readme/DESCRIPTION.md @@ -0,0 +1,3 @@ +This module extends the **Contract** module to provide flexible invoicing offsets. +It allows you to define offsets in Days, Weeks, Months, or Years, enabling scenarios +such as **Pre-paid + 1 month in advance** (e.g. Invoicing January's service in December). diff --git a/contract_invoice_offset/readme/USAGE.md b/contract_invoice_offset/readme/USAGE.md new file mode 100644 index 0000000000..b367fcd81f --- /dev/null +++ b/contract_invoice_offset/readme/USAGE.md @@ -0,0 +1,9 @@ +To use this module: + +1. Go to **Accounting > Customers > Contracts**. +2. Select or create a contract. +3. In the **Invoicing** section: + * Set **Invoicing type** (e.g., Pre-paid). + * Set **Invoicing Offset Type** (e.g., Months). + * Set **Invoicing Offset Value** (e.g., -1 for 1 month in advance). +4. The **Date of Next Invoice** will be calculated based on your configuration. diff --git a/contract_invoice_offset/static/description/icon.png b/contract_invoice_offset/static/description/icon.png new file mode 100644 index 0000000000..1dcc49c24f Binary files /dev/null and b/contract_invoice_offset/static/description/icon.png differ diff --git a/contract_invoice_offset/static/description/index.html b/contract_invoice_offset/static/description/index.html new file mode 100644 index 0000000000..0abb88ac7f --- /dev/null +++ b/contract_invoice_offset/static/description/index.html @@ -0,0 +1,458 @@ + + + + + +Contract Invoice Offset + + + +
+

Contract Invoice Offset

+ + +

Beta License: AGPL-3 OCA/contract Translate me on Weblate Try me on Runboat

+

This module extends the Contract module to provide flexible +invoicing offsets. It allows you to define offsets in Days, Weeks, +Months, or Years, enabling scenarios such as Pre-paid + 1 month in +advance (e.g. Invoicing January’s service in December).

+

Table of contents

+ +
+

Use Cases / Context

+

In the standard behavior of the Contract module, invoicing offsets are +restricted to a fixed number of days. While this works for many cases, +it is difficult to configure reliable “in advance” invoicing for +intervals like months, given the varying number of days in each month.

+

In some business scenarios, a contract must be invoiced exactly one +month in advance (e.g., invoicing January service in December) or with a +specific delay in weeks or years.

+

This module introduces flexible invoicing offsets, allowing users to +define offsets in Days, Weeks, Months, or Years, ensuring the invoice +date is logically consistent with the billing period regardless of +calendar variations.

+
+
+

Usage

+

To use this module:

+
    +
  1. Go to Accounting > Customers > Contracts.
  2. +
  3. Select or create a contract.
  4. +
  5. In the Invoicing section:
      +
    • Set Invoicing type (e.g., Pre-paid).
    • +
    • Set Invoicing Offset Type (e.g., Months).
    • +
    • Set Invoicing Offset Value (e.g., -1 for 1 month in advance).
    • +
    +
  6. +
  7. The Date of Next Invoice will be calculated based on your +configuration.
  8. +
+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • bosd
  • +
+
+
+

Contributors

+
    +
  • bosd
  • +
+
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/contract project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/contract_invoice_offset/tests/__init__.py b/contract_invoice_offset/tests/__init__.py new file mode 100644 index 0000000000..80ac4899b9 --- /dev/null +++ b/contract_invoice_offset/tests/__init__.py @@ -0,0 +1 @@ +from . import test_invoice_offset diff --git a/contract_invoice_offset/tests/test_invoice_offset.py b/contract_invoice_offset/tests/test_invoice_offset.py new file mode 100644 index 0000000000..d319d380f3 --- /dev/null +++ b/contract_invoice_offset/tests/test_invoice_offset.py @@ -0,0 +1,139 @@ +# Copyright 2024 bosd +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields +from odoo.tests import common, tagged + + +@tagged("post_install", "-at_install") +class TestContractInvoiceOffset(common.TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.partner = cls.env["res.partner"].create({"name": "Test Partner"}) + cls.product = cls.env["product.product"].create( + {"name": "Test Service", "type": "service"} + ) + cls.contract = cls.env["contract.contract"].create( + { + "name": "Test Offset Contract", + "partner_id": cls.partner.id, + "recurring_interval": 1, + "recurring_rule_type": "monthly", + "date_start": "2025-01-01", + "contract_line_ids": [ + ( + 0, + 0, + { + "product_id": cls.product.id, + "name": "Service", + "quantity": 1, + "price_unit": 100, + "recurring_interval": 1, + "recurring_rule_type": "monthly", + "date_start": "2025-01-01", + }, + ) + ], + } + ) + + def test_prepaid_advance_one_month(self): + """Test Pre-paid + 1 month advance (offset -1 month).""" + self.contract.write( + { + "recurring_invoicing_type": "pre-paid", + "recurring_invoicing_offset": 0, # Explicitly set to 0 for pre-paid + "invoicing_offset_type": "months", + "invoicing_offset_value": -1, + } + ) + # Next Invoice for 2025-01-01 start is 2025-01-01 + # (with recurring_invoicing_offset 0). + # With -1 month offset, it should be 2024-12-01. + self.contract._compute_recurring_next_date() + self.assertEqual( + self.contract.recurring_next_date, + fields.Date.to_date("2024-12-01"), + "Invoice date should be one month prior to start date", + ) + + def test_postpaid_delayed_one_month(self): + """Test Post-paid + 1 month delay.""" + self.contract.write( + { + "recurring_invoicing_type": "post-paid", + "recurring_invoicing_offset": 1, # Explicitly set to 1 day + "invoicing_offset_type": "months", + "invoicing_offset_value": 1, + } + ) + + self.contract._compute_recurring_next_date() + # End date of first period (2025-01-01 monthly) is 2025-01-31. + # Apply recurring_invoicing_offset first (1 day), then invoicing_offset_value. + # Next Invoice Date = 2025-01-31 + 1 day + 1 month = 2025-03-01. + self.assertEqual( + self.contract.recurring_next_date, + fields.Date.to_date("2025-03-01"), + "Invoice date should be 1 day + 1 month after period end", + ) + + def test_days_fallback(self): + """Test that 'days' works with consistent behavior + (both recurring_invoicing_offset and invoicing_offset_value).""" + self.contract.write( + { + "recurring_invoicing_type": "post-paid", + "recurring_invoicing_offset": 1, # Explicitly set to 1 day + "invoicing_offset_type": "days", + "invoicing_offset_value": 5, # 5 additional days + } + ) + # With post-paid, base_date is period end (Jan 31 for Jan monthly). + # Apply recurring_invoicing_offset first (1 day), then invoicing_offset_value. + # Our logic: base_date (Jan 31) + recurring_invoicing_offset (1) + + # invoicing_offset_value (5) = Jan 31 + 6 days = Feb 6. + self.contract._compute_recurring_next_date() + self.assertEqual( + self.contract.recurring_next_date, + fields.Date.to_date("2025-02-06"), + "Invoice date should be 6 days after period end " + "(1 from recurring_invoicing_offset + 5 from invoicing_offset_value)", + ) + + def test_apply_offset_on_invoice_creation(self): + """Verify that invoice creation respects the offsets.""" + self.contract.write( + { + "recurring_invoicing_type": "pre-paid", + "invoicing_offset_type": "months", + "invoicing_offset_value": -1, + } + ) + # Next date is 2024-12-01. If we run invoice creation for that date, + # we expect an invoice for the period 2025-01-01 to 2025-01-31. + + # Force next date just to be sure + self.contract.recurring_next_date = "2024-12-01" + + invoice = self.contract._recurring_create_invoice() + self.assertTrue(invoice, "Invoice should be created") + + # Check Invoice Date + self.assertEqual( + invoice.invoice_date, + fields.Date.to_date("2024-12-01"), + "Invoice date should be Dec 1st", + ) + + # Check Line Description or Metadata if available to verify period? + # Standard contract creates description "Service" (or template). + # We can check contract line's last_date_invoiced. + line = self.contract.contract_line_ids[0] + self.assertEqual( + line.last_date_invoiced, + fields.Date.to_date("2025-01-31"), + "Last date invoiced should be end of the service period (Jan 31)", + ) diff --git a/contract_invoice_offset/views/contract_view.xml b/contract_invoice_offset/views/contract_view.xml new file mode 100644 index 0000000000..95a93fe8b4 --- /dev/null +++ b/contract_invoice_offset/views/contract_view.xml @@ -0,0 +1,17 @@ + + + + contract.contract.form.inherit + contract.contract + + + + + + + + + diff --git a/test-requirements.txt b/test-requirements.txt new file mode 100644 index 0000000000..b4db471c5f --- /dev/null +++ b/test-requirements.txt @@ -0,0 +1 @@ +odoo-addon-contract @ git+https://github.com/OCA/contract@refs/pull/1312/head#subdirectory=contract