From 05a8b802747a84c66ef21700a426dac2c2c1a443 Mon Sep 17 00:00:00 2001 From: alexis-anthonissen Date: Wed, 23 Sep 2026 15:14:13 +0200 Subject: [PATCH 1/2] [IMP] contract - add invoice offset configurations --- contract/models/contract.py | 2 + contract/models/contract_line.py | 45 +++---- contract/models/contract_recurring_mixin.py | 84 +++++++----- contract/tests/test_contract.py | 122 ++++++++++++++---- contract/views/contract.xml | 13 ++ contract/views/contract_line.xml | 2 + contract/views/contract_template_line.xml | 5 + contract_forecast/models/contract_line.py | 1 + .../models/contract_line.py | 2 + .../models/contract_line.py | 1 + product_contract/models/product_template.py | 18 +++ product_contract/models/sale_order_line.py | 11 +- .../models/sale_order_line_contract_mixin.py | 22 ++++ .../contract_configurator_controller.esm.js | 4 + .../static/src/js/sale_product_field.esm.js | 4 + product_contract/views/product_template.xml | 13 ++ product_contract/views/sale_order.xml | 7 + .../product_contract_configurator_views.xml | 13 ++ 18 files changed, 278 insertions(+), 91 deletions(-) diff --git a/contract/models/contract.py b/contract/models/contract.py index ddcc91ade6..de940296e0 100644 --- a/contract/models/contract.py +++ b/contract/models/contract.py @@ -170,6 +170,7 @@ def _compute_invoice_count(self): "next_period_date_start", "recurring_invoicing_type", "recurring_invoicing_offset", + "recurring_invoicing_offset_type", "recurring_rule_type", "recurring_interval", "date_end", @@ -195,6 +196,7 @@ def _compute_recurring_next_date(self): contract.next_period_date_start, contract.recurring_invoicing_type, contract.recurring_invoicing_offset, + contract.recurring_invoicing_offset_type, contract.recurring_rule_type, contract.recurring_interval, max_date_end=contract.date_end, diff --git a/contract/models/contract_line.py b/contract/models/contract_line.py index 23efbc4744..2c6cd0bd51 100644 --- a/contract/models/contract_line.py +++ b/contract/models/contract_line.py @@ -60,26 +60,6 @@ def _compute_display_name(self): for rec in self: rec.display_name = f"{rec.date_start} - {rec.name}" - @api.model - def _compute_first_recurring_next_date( - self, - date_start, - recurring_invoicing_type, - recurring_rule_type, - recurring_interval, - ): - # deprecated method for backward compatibility - return self.get_next_invoice_date( - date_start, - recurring_invoicing_type, - self._get_default_recurring_invoicing_offset( - recurring_invoicing_type, recurring_rule_type - ), - recurring_rule_type, - recurring_interval, - max_date_end=False, - ) - def _get_analytic_distribution_arguments(self): self.ensure_one() partner_categ_ids = self.contract_id.partner_id.category_id.ids @@ -100,7 +80,12 @@ def _compute_analytic_distribution(self): ]._get_distribution(line._get_analytic_distribution_arguments()) line.analytic_distribution = distribution or line.analytic_distribution - @api.constrains("recurring_next_date", "date_start") + @api.constrains( + "recurring_next_date", + "date_start", + "recurring_invoicing_offset", + "recurring_invoicing_offset_type", + ) def _check_recurring_next_date_start_date(self): for line in self: if ( @@ -109,7 +94,16 @@ def _check_recurring_next_date_start_date(self): ): continue if line.date_start and line.recurring_next_date: - if line.date_start > line.recurring_next_date: + if ( + line.date_start + + self._get_invoicing_offset_relative_delta( + line.recurring_invoicing_type, + line.recurring_rule_type, + line.recurring_invoicing_offset, + line.recurring_invoicing_offset_type, + ) + > line.recurring_next_date + ): raise ValidationError( self.env._( "You can't have a date of next invoice anterior " @@ -163,8 +157,7 @@ def _check_recurring_next_date_recurring_invoices(self): ): raise ValidationError( self.env._( - "You must supply a date of next invoice for contract " - "line '%s'" + "You must supply a date of next invoice for contract line '%s'" ) % rec.name ) @@ -176,8 +169,7 @@ def _check_start_end_dates(self): if line.date_start > line.date_end: raise ValidationError( self.env._( - "Contract line '%s' start date can't be later than" - " end date" + "Contract line '%s' start date can't be later than end date" ) % line.name ) @@ -243,6 +235,7 @@ def _get_period_to_invoice( next_invoice_date=recurring_next_date, recurring_invoicing_type=self.recurring_invoicing_type, recurring_invoicing_offset=self.recurring_invoicing_offset, + recurring_invoicing_offset_type=self.recurring_invoicing_offset_type, ) return first_date_invoiced, last_date_invoiced, recurring_next_date diff --git a/contract/models/contract_recurring_mixin.py b/contract/models/contract_recurring_mixin.py index 88b1659fb3..780090f446 100644 --- a/contract/models/contract_recurring_mixin.py +++ b/contract/models/contract_recurring_mixin.py @@ -59,13 +59,23 @@ class ContractRecurringMixin(models.AbstractModel): ), ) recurring_invoicing_offset = fields.Integer( - compute="_compute_recurring_invoicing_offset", string="Invoicing offset", help=( "Number of days to offset the invoice from the period end " "date (in post-paid mode) or start date (in pre-paid mode)." ), ) + recurring_invoicing_offset_type = fields.Selection( + selection=[ + ("daily", "Day(s)"), + ("weekly", "Week(s)"), + ("monthly", "Month(s)"), + ("quarterly", "Quarter(s)"), + ("semesterly", "Semester(s)"), + ("yearly", "Year(s)"), + ], + string="Invoicing offset type", + ) # === Invoicing Configuration Fields === # Define when and how invoices should be issued within the recurrence. @@ -109,6 +119,7 @@ def _compute_next_period_date_start(self): "next_period_date_start", "recurring_invoicing_type", "recurring_invoicing_offset", + "recurring_invoicing_offset_type", "recurring_rule_type", "recurring_interval", "date_end", @@ -125,21 +136,14 @@ def _compute_next_period_date_end(self): next_invoice_date=rec.recurring_next_date, recurring_invoicing_type=rec.recurring_invoicing_type, recurring_invoicing_offset=rec.recurring_invoicing_offset, - ) - - @api.depends("recurring_invoicing_type", "recurring_rule_type") - def _compute_recurring_invoicing_offset(self): - """Compute the invoicing offset based on type and rule.""" - for rec in self: - method = self._get_default_recurring_invoicing_offset - rec.recurring_invoicing_offset = method( - rec.recurring_invoicing_type, rec.recurring_rule_type + recurring_invoicing_offset_type=rec.recurring_invoicing_offset_type, ) @api.depends( "next_period_date_start", "recurring_invoicing_type", "recurring_invoicing_offset", + "recurring_invoicing_offset_type", "recurring_rule_type", "recurring_interval", "date_end", @@ -151,6 +155,7 @@ def _compute_recurring_next_date(self): rec.next_period_date_start, rec.recurring_invoicing_type, rec.recurring_invoicing_offset, + rec.recurring_invoicing_offset_type, rec.recurring_rule_type, rec.recurring_interval, max_date_end=rec.date_end, @@ -186,6 +191,7 @@ def get_next_period_date_end( next_invoice_date=False, recurring_invoicing_type=False, recurring_invoicing_offset=False, + recurring_invoicing_offset_type=False, ): """Compute the end date for the next period.""" if not next_period_date_start or ( @@ -202,17 +208,20 @@ def get_next_period_date_end( ) else: # Forced invoice date: back-calculate period end - if recurring_invoicing_type == "pre-paid": - next_period_date_end = ( - next_invoice_date - - relativedelta(days=recurring_invoicing_offset) - + self.get_relative_delta(recurring_rule_type, recurring_interval) - - relativedelta(days=1) - ) - else: # post-paid - next_period_date_end = next_invoice_date - relativedelta( - days=recurring_invoicing_offset + next_period_date_end = ( + next_invoice_date + - self._get_invoicing_offset_relative_delta( + recurring_invoicing_type, + recurring_rule_type, + recurring_invoicing_offset, + recurring_invoicing_offset_type, ) + ) + + if recurring_invoicing_type == "pre-paid": + next_period_date_end += self.get_relative_delta( + recurring_rule_type, recurring_interval + ) - relativedelta(days=1) if max_date_end and next_period_date_end > max_date_end: next_period_date_end = max_date_end @@ -224,6 +233,7 @@ def get_next_invoice_date( next_period_date_start, recurring_invoicing_type, recurring_invoicing_offset, + recurring_invoicing_offset_type, recurring_rule_type, recurring_interval, max_date_end, @@ -239,20 +249,36 @@ def get_next_invoice_date( return False if recurring_invoicing_type == "pre-paid": - return next_period_date_start + relativedelta( - days=recurring_invoicing_offset - ) + next_invoice_date = next_period_date_start else: - return next_period_date_end + relativedelta(days=recurring_invoicing_offset) + next_invoice_date = next_period_date_end + + return next_invoice_date + self._get_invoicing_offset_relative_delta( + recurring_invoicing_type, + recurring_rule_type, + recurring_invoicing_offset, + recurring_invoicing_offset_type, + ) @api.model - def _get_default_recurring_invoicing_offset( - self, recurring_invoicing_type, recurring_rule_type + def _get_invoicing_offset_relative_delta( + self, + recurring_invoicing_type, + recurring_rule_type, + recurring_invoicing_offset, + recurring_invoicing_offset_type, ): - """Return default offset in days based on invoicing type and rule.""" if ( recurring_invoicing_type == "pre-paid" or recurring_rule_type == "monthlylastday" ): - return 0 - return 1 + invoice_offset = relativedelta() + else: + invoice_offset = relativedelta(days=1) + + if recurring_invoicing_offset != 0 and recurring_invoicing_offset_type: + invoice_offset += self.get_relative_delta( + recurring_invoicing_offset_type, recurring_invoicing_offset + ) + + return invoice_offset diff --git a/contract/tests/test_contract.py b/contract/tests/test_contract.py index 6a4727f49a..1ecd6d564c 100644 --- a/contract/tests/test_contract.py +++ b/contract/tests/test_contract.py @@ -670,22 +670,6 @@ def test_act_show_contract(self): "There was an error and the view couldn't be opened.", ) - def test_get_default_recurring_invoicing_offset(self): - clm = self.env["contract.line"] - self.assertEqual( - clm._get_default_recurring_invoicing_offset("pre-paid", "monthly"), 0 - ) - self.assertEqual( - clm._get_default_recurring_invoicing_offset("post-paid", "monthly"), 1 - ) - self.assertEqual( - clm._get_default_recurring_invoicing_offset("pre-paid", "monthlylastday"), 0 - ) - self.assertEqual( - clm._get_default_recurring_invoicing_offset("post-paid", "monthlylastday"), - 0, - ) - def test_get_next_invoice_date(self): """Test different combination to compute recurring_next_date Combination format @@ -705,16 +689,18 @@ def error_message( date_start, recurring_invoicing_type, recurring_invoicing_offset, + recurring_invoicing_offset_type, recurring_rule_type, recurring_interval, max_date_end, ): return ( - "Error in %s-%d every %d %s case, " + "Error in %s-%d %s every %d %s case, " "start with %s (max_date_end=%s)" % ( recurring_invoicing_type, recurring_invoicing_offset, + recurring_invoicing_offset_type, recurring_interval, recurring_rule_type, date_start, @@ -725,7 +711,7 @@ def error_message( combinations = [ ( to_date("2018-01-01"), - (to_date("2018-01-01"), "pre-paid", 0, "monthly", 1, False), + (to_date("2018-01-01"), "pre-paid", 0, None, "monthly", 1, False), ), ( to_date("2018-01-01"), @@ -733,6 +719,7 @@ def error_message( to_date("2018-01-01"), "pre-paid", 0, + None, "monthly", 1, to_date("2018-01-15"), @@ -744,6 +731,7 @@ def error_message( to_date("2018-01-16"), "pre-paid", 0, + None, "monthly", 1, to_date("2018-01-15"), @@ -751,11 +739,11 @@ def error_message( ), ( to_date("2018-01-01"), - (to_date("2018-01-01"), "pre-paid", 0, "monthly", 2, False), + (to_date("2018-01-01"), "pre-paid", 0, None, "monthly", 2, False), ), ( to_date("2018-02-01"), - (to_date("2018-01-01"), "post-paid", 1, "monthly", 1, False), + (to_date("2018-01-01"), "post-paid", 1, None, "monthly", 1, False), ), ( to_date("2018-01-16"), @@ -763,6 +751,7 @@ def error_message( to_date("2018-01-01"), "post-paid", 1, + None, "monthly", 1, to_date("2018-01-15"), @@ -774,6 +763,7 @@ def error_message( to_date("2018-01-16"), "post-paid", 1, + None, "monthly", 1, to_date("2018-01-15"), @@ -781,31 +771,63 @@ def error_message( ), ( to_date("2018-03-01"), - (to_date("2018-01-01"), "post-paid", 1, "monthly", 2, False), + (to_date("2018-01-01"), "post-paid", 1, None, "monthly", 2, False), ), ( to_date("2018-01-31"), - (to_date("2018-01-05"), "post-paid", 0, "monthlylastday", 1, False), + ( + to_date("2018-01-05"), + "post-paid", + 0, + None, + "monthlylastday", + 1, + False, + ), ), ( to_date("2018-01-06"), - (to_date("2018-01-06"), "pre-paid", 0, "monthlylastday", 1, False), + ( + to_date("2018-01-06"), + "pre-paid", + 0, + None, + "monthlylastday", + 1, + False, + ), ), ( to_date("2018-02-28"), - (to_date("2018-01-05"), "post-paid", 0, "monthlylastday", 2, False), + ( + to_date("2018-01-05"), + "post-paid", + 0, + None, + "monthlylastday", + 2, + False, + ), ), ( to_date("2018-01-05"), - (to_date("2018-01-05"), "pre-paid", 0, "monthlylastday", 2, False), + ( + to_date("2018-01-05"), + "pre-paid", + 0, + None, + "monthlylastday", + 2, + False, + ), ), ( to_date("2018-01-05"), - (to_date("2018-01-05"), "pre-paid", 0, "yearly", 1, False), + (to_date("2018-01-05"), "pre-paid", 0, None, "yearly", 1, False), ), ( to_date("2019-01-05"), - (to_date("2018-01-05"), "post-paid", 1, "yearly", 1, False), + (to_date("2018-01-05"), "post-paid", 1, None, "yearly", 1, False), ), ] contract_line_env = self.env["contract.line"] @@ -1723,3 +1745,49 @@ def test_contract_line_total_value_period_prorata(self): self.assertEqual( self.acct_line._get_contract_line_total_value(), expected_total ) + + def test_get_next_invoice_date_with_offset_type(self): + """Offset values use the selected recurrence unit.""" + contract_line_env = self.env["contract.line"] + date_start = to_date("2018-01-01") + + expected_dates = { + "daily": to_date("2018-01-02"), + "weekly": to_date("2018-01-08"), + "monthly": to_date("2018-02-01"), + "quarterly": to_date("2018-04-01"), + "semesterly": to_date("2018-07-01"), + "yearly": to_date("2019-01-01"), + } + + for offset_type, expected_date in expected_dates.items(): + self.assertEqual( + contract_line_env.get_next_invoice_date( + date_start, + "pre-paid", + 1, + offset_type, + "monthly", + 1, + False, + ), + expected_date, + ) + + def test_get_next_period_date_end_with_offset_type(self): + """The period end is back-calculated using the offset type.""" + contract_line_env = self.env["contract.line"] + + self.assertEqual( + contract_line_env.get_next_period_date_end( + to_date("2018-01-01"), + "monthly", + 1, + max_date_end=False, + next_invoice_date=to_date("2018-02-08"), + recurring_invoicing_type="post-paid", + recurring_invoicing_offset=1, + recurring_invoicing_offset_type="weekly", + ), + to_date("2018-01-31"), + ) diff --git a/contract/views/contract.xml b/contract/views/contract.xml index 36e19ed76e..8f0eaa22ba 100644 --- a/contract/views/contract.xml +++ b/contract/views/contract.xml @@ -305,6 +305,19 @@ nolabel="1" /> +