diff --git a/README.md b/README.md
index ea5ea50656..a679f95b7a 100644
--- a/README.md
+++ b/README.md
@@ -23,7 +23,7 @@ Available addons
addon | version | maintainers | summary
--- | --- | --- | ---
[agreement_rebate_partner_company_group](agreement_rebate_partner_company_group/) | 18.0.1.0.0 | | Rebate agreements applied to all company group members
-[contract](contract/) | 18.0.2.5.0 | | Recurring - Contracts Management
+[contract](contract/) | 18.0.2.5.1 | | Recurring - Contracts Management
[contract_analytic_tag](contract_analytic_tag/) | 18.0.1.0.0 |
| Contract Analytic Tag
[contract_forecast](contract_forecast/) | 18.0.1.0.1 |
| Contract Forecast
[contract_forecast_variable_quantity](contract_forecast_variable_quantity/) | 18.0.1.0.0 | | Contract Forecast Variable Quantity
@@ -42,6 +42,7 @@ addon | version | maintainers | summary
[contract_sale_mandate](contract_sale_mandate/) | 18.0.1.0.0 | | This module manages the banking mandate from the sale order to the contract.
[contract_sale_payment_mode](contract_sale_payment_mode/) | 18.0.1.0.0 | | This addon manages payment mode from sale order to contract.
[contract_sale_transmit_method](contract_sale_transmit_method/) | 18.0.1.0.0 | | Propagate transmit method (email, post, portal, ...) from sale orders to contracts.
+[contract_service_dates](contract_service_dates/) | 18.0.1.0.0 | | This module adds Service Dates fields to contract lines
[contract_termination](contract_termination/) | 18.0.1.0.0 | | contract_termination
[contract_transmit_method](contract_transmit_method/) | 18.0.1.1.0 | | Set transmit method (email, post, portal, ...) in contracts and propagate it to invoices.
[contract_update_last_date_invoiced](contract_update_last_date_invoiced/) | 18.0.1.0.1 |
| This module allows to update the last date invoiced if invoices are deleted.
diff --git a/contract/README.rst b/contract/README.rst
index 0e8c685553..6d7d7db1fc 100644
--- a/contract/README.rst
+++ b/contract/README.rst
@@ -11,7 +11,7 @@ Recurring - Contracts Management
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
- !! source digest: sha256:f31a70112d86e7b2f6237d0ad274d0a55e384fe483eb0b1a4fe4a0fca1b05c9a
+ !! source digest: sha256:18b03b2e2527c4875b06eb16fdea75963f902195518f9c5c755649aa89352108
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
.. |badge1| image:: https://img.shields.io/badge/maturity-Production%2FStable-green.png
diff --git a/contract/__manifest__.py b/contract/__manifest__.py
index c31d17ee55..9753be2b45 100644
--- a/contract/__manifest__.py
+++ b/contract/__manifest__.py
@@ -11,7 +11,7 @@
{
"name": "Recurring - Contracts Management",
- "version": "18.0.2.5.0",
+ "version": "18.0.2.5.1",
"category": "Contract Management",
"license": "AGPL-3",
"author": "Tecnativa, ACSONE SA/NV, Odoo Community Association (OCA)",
diff --git a/contract/models/contract_line.py b/contract/models/contract_line.py
index a86aa706f8..23efbc4744 100644
--- a/contract/models/contract_line.py
+++ b/contract/models/contract_line.py
@@ -361,9 +361,32 @@ def _get_contract_line_total_value(self):
self.display_name,
)
)
- quantity = self._get_quantity_to_invoice(
- self.date_start,
- self.date_end,
- self.date_start,
+
+ period_length = self.get_relative_delta(
+ self.recurring_rule_type, self.recurring_interval
+ )
+ currency = (
+ self.contract_id.pricelist_id.currency_id
+ if self.contract_id.pricelist_id
+ else self.currency_id
)
- return quantity * self.price_unit
+
+ current_period_start = self.date_start
+ total_amount = 0.0
+ while current_period_start <= self.date_end:
+ current_period_end = (
+ current_period_start + period_length - relativedelta(days=1)
+ )
+ if current_period_end > self.date_end:
+ current_period_end = self.date_end
+ qty = self._get_quantity_to_invoice(
+ current_period_start,
+ current_period_end,
+ current_period_start, # Mock an invoice date at period start
+ )
+ subtotal = qty * self.price_unit * (1 - (self.discount / 100.0))
+ if currency:
+ subtotal = currency.round(subtotal)
+ total_amount += subtotal
+ current_period_start += period_length
+ return total_amount
diff --git a/contract/static/description/index.html b/contract/static/description/index.html
index 736d38a5a2..aa4bd95ab9 100644
--- a/contract/static/description/index.html
+++ b/contract/static/description/index.html
@@ -372,7 +372,7 @@
Recurring - Contracts Management
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
-!! source digest: sha256:f31a70112d86e7b2f6237d0ad274d0a55e384fe483eb0b1a4fe4a0fca1b05c9a
+!! source digest: sha256:18b03b2e2527c4875b06eb16fdea75963f902195518f9c5c755649aa89352108
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->

This module enables contracts management with recurring invoicing
diff --git a/contract/tests/test_contract.py b/contract/tests/test_contract.py
index 96073b686f..6a4727f49a 100644
--- a/contract/tests/test_contract.py
+++ b/contract/tests/test_contract.py
@@ -11,6 +11,7 @@
from odoo import Command, fields
from odoo.exceptions import UserError, ValidationError
+from odoo.fields import Date
from odoo.tests import Form, common
@@ -1617,33 +1618,41 @@ def test_get_contract_line_total_value_basic(self):
{
"quantity": 3,
"price_unit": 400.0,
+ "discount": 0.0,
"date_start": "2026-01-01",
"date_end": "2026-12-31",
- "recurring_next_date": "2026-01-01",
+ "recurring_interval": 1,
+ "recurring_rule_type": "monthly",
}
)
- self.assertEqual(self.acct_line._get_contract_line_total_value(), 3 * 400.0)
+ # 12 months * (3 * 400.0) = 14,400.0
+ self.assertEqual(self.acct_line._get_contract_line_total_value(), 14400.0)
def test_get_contract_line_total_value_single_unit(self):
self.acct_line.write(
{
"quantity": 1,
"price_unit": 1200.0,
+ "discount": 0.0,
"date_start": "2026-01-01",
"date_end": "2026-12-31",
- "recurring_next_date": "2026-01-01",
+ "recurring_interval": 1,
+ "recurring_rule_type": "monthly",
}
)
- self.assertEqual(self.acct_line._get_contract_line_total_value(), 1200.0)
+ # 12 months * 1200.0 = 14,400.0
+ self.assertEqual(self.acct_line._get_contract_line_total_value(), 14400.0)
def test_get_contract_line_total_value_zero_price(self):
self.acct_line.write(
{
"quantity": 5,
"price_unit": 0.0,
+ "discount": 0.0,
"date_start": "2026-01-01",
"date_end": "2026-06-30",
- "recurring_next_date": "2026-01-01",
+ "recurring_interval": 1,
+ "recurring_rule_type": "monthly",
}
)
self.assertEqual(self.acct_line._get_contract_line_total_value(), 0.0)
@@ -1657,8 +1666,8 @@ def test_get_contract_line_total_value_no_date_end_raises(self):
"name": "Test",
"quantity": 1,
"price_unit": 1000.0,
+ "discount": 0.0,
"date_start": to_date("2026-01-01"),
- "recurring_next_date": to_date("2026-01-01"),
"recurring_interval": 1,
"recurring_rule_type": "monthly",
"recurring_invoicing_type": "pre-paid",
@@ -1668,3 +1677,49 @@ def test_get_contract_line_total_value_no_date_end_raises(self):
line.date_end = False
with self.assertRaises(UserError):
line._get_contract_line_total_value()
+
+ def test_get_contract_line_total_value_with_discount(self):
+ self.acct_line.write(
+ {
+ "quantity": 2,
+ "price_unit": 100.0,
+ "discount": 10.0,
+ "date_start": "2026-01-01",
+ "date_end": "2026-12-31",
+ "recurring_interval": 1,
+ "recurring_rule_type": "monthly",
+ }
+ )
+ # Per period: 2 * 100 * 0.90 = 180.0
+ # Total (12 periods): 2160.0
+ self.assertEqual(self.acct_line._get_contract_line_total_value(), 2160.0)
+
+ def test_contract_line_total_value_period_prorata(self):
+ """Test a contract that terminates mid-way through an invoicing period."""
+ self.acct_line.write(
+ {
+ "quantity": 1,
+ "price_unit": 100.0,
+ "discount": 0.0,
+ "date_start": "2026-01-01",
+ "date_end": "2026-02-15",
+ "recurring_interval": 1,
+ "recurring_rule_type": "monthly",
+ }
+ )
+
+ expected_qty_period_1 = self.acct_line._get_quantity_to_invoice(
+ Date.to_date("2026-01-01"),
+ Date.to_date("2026-01-31"),
+ Date.to_date("2026-01-01"),
+ )
+ expected_qty_period_2 = self.acct_line._get_quantity_to_invoice(
+ Date.to_date("2026-02-01"),
+ Date.to_date("2026-02-15"),
+ Date.to_date("2026-02-01"),
+ )
+
+ expected_total = (expected_qty_period_1 + expected_qty_period_2) * 100.0
+ self.assertEqual(
+ self.acct_line._get_contract_line_total_value(), expected_total
+ )
diff --git a/contract_service_dates/README.rst b/contract_service_dates/README.rst
new file mode 100644
index 0000000000..bca34517bb
--- /dev/null
+++ b/contract_service_dates/README.rst
@@ -0,0 +1,108 @@
+.. image:: https://odoo-community.org/readme-banner-image
+ :target: https://odoo-community.org/get-involved?utm_source=readme
+ :alt: Odoo Community Association
+
+======================
+Contract Service Dates
+======================
+
+..
+ !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
+ !! This file is generated by oca-gen-addon-readme !!
+ !! changes will be overwritten. !!
+ !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
+ !! source digest: sha256:f5918b19fed1521629a39f7c85979c385121f87628d91fecd00889f2c7d699f4
+ !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
+
+.. |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/license-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_service_dates
+ :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_service_dates
+ :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 adds **Service Start Date** and **Service End Date** fields
+to contract lines, allowing you to track the actual service period
+independently from the billing dates.
+
+**Table of contents**
+
+.. contents::
+ :local:
+
+Use Cases / Context
+===================
+
+Contract lines in the base contract module carry a single start date and
+end date that drive the recurring invoicing schedule. There is no
+dedicated field to record the period during which the service is
+actually delivered, which is often distinct from the billing period.
+This module fills that gap.
+
+Usage
+=====
+
+On a contract line form, a new **Service Dates** group is displayed in
+the **Recurrence** tab, below the contract line start and end dates:
+
+- **Service Start Date**: start of the service period. Populated
+ automatically from the contract line start date and kept in sync with
+ it unless manually overridden.
+- **Service End Date**: end of the service period. Populated
+ automatically from the contract line end date and kept in sync with it
+ unless manually overridden.
+
+Once a service date is set to a value different from its corresponding
+contract line date, it is considered decoupled and will no longer be
+updated automatically when the contract line date changes.
+
+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
+-------
+
+* ACSONE SA/NV
+
+Contributors
+------------
+
+- Benoit Aimont benoit.aimont@acsone.eu (https://www.acsone.eu/)
+
+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_service_dates/__init__.py b/contract_service_dates/__init__.py
new file mode 100644
index 0000000000..0650744f6b
--- /dev/null
+++ b/contract_service_dates/__init__.py
@@ -0,0 +1 @@
+from . import models
diff --git a/contract_service_dates/__manifest__.py b/contract_service_dates/__manifest__.py
new file mode 100644
index 0000000000..23e6bdb08a
--- /dev/null
+++ b/contract_service_dates/__manifest__.py
@@ -0,0 +1,18 @@
+# Copyright 2026 ACSONE SA/NV
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
+
+{
+ "name": "Contract Service Dates",
+ "summary": """This module adds Service Dates fields to contract lines""",
+ "version": "18.0.1.0.0",
+ "license": "AGPL-3",
+ "author": "ACSONE SA/NV, Odoo Community Association (OCA)",
+ "website": "https://github.com/OCA/contract",
+ "depends": [
+ "contract",
+ ],
+ "data": [
+ "views/contract_line.xml",
+ ],
+ "installable": True,
+}
diff --git a/contract_service_dates/i18n/contract_service_dates.pot b/contract_service_dates/i18n/contract_service_dates.pot
new file mode 100644
index 0000000000..ca155fc6e1
--- /dev/null
+++ b/contract_service_dates/i18n/contract_service_dates.pot
@@ -0,0 +1,48 @@
+# Translation of Odoo Server.
+# This file contains the translation of the following modules:
+# * contract_service_dates
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: Odoo Server 18.0\n"
+"Report-Msgid-Bugs-To: \n"
+"Last-Translator: \n"
+"Language-Team: \n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: \n"
+"Plural-Forms: \n"
+
+#. module: contract_service_dates
+#: model:ir.model,name:contract_service_dates.model_contract_line
+msgid "Contract Line"
+msgstr ""
+
+#. module: contract_service_dates
+#: model:ir.model.fields,help:contract_service_dates.field_contract_line__service_end_date
+msgid ""
+"End date of the service period covered by this contract line. Defaults to "
+"the contract line end date and follows it unless manually overridden."
+msgstr ""
+
+#. module: contract_service_dates
+#: model_terms:ir.ui.view,arch_db:contract_service_dates.contract_line_form_view
+msgid "Service Dates"
+msgstr ""
+
+#. module: contract_service_dates
+#: model:ir.model.fields,field_description:contract_service_dates.field_contract_line__service_end_date
+msgid "Service End Date"
+msgstr ""
+
+#. module: contract_service_dates
+#: model:ir.model.fields,field_description:contract_service_dates.field_contract_line__service_start_date
+msgid "Service Start Date"
+msgstr ""
+
+#. module: contract_service_dates
+#: model:ir.model.fields,help:contract_service_dates.field_contract_line__service_start_date
+msgid ""
+"Start date of the service period covered by this contract line. Defaults to "
+"the contract line start date and follows it unless manually overridden."
+msgstr ""
diff --git a/contract_service_dates/models/__init__.py b/contract_service_dates/models/__init__.py
new file mode 100644
index 0000000000..6143a36501
--- /dev/null
+++ b/contract_service_dates/models/__init__.py
@@ -0,0 +1 @@
+from . import contract_line
diff --git a/contract_service_dates/models/contract_line.py b/contract_service_dates/models/contract_line.py
new file mode 100644
index 0000000000..6453b6da4c
--- /dev/null
+++ b/contract_service_dates/models/contract_line.py
@@ -0,0 +1,58 @@
+# Copyright 2026 ACSONE SA/NV
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
+
+from odoo import api, fields, models
+
+
+class ContractLine(models.Model):
+ _inherit = "contract.line"
+
+ service_start_date = fields.Date(
+ help="Start date of the service period covered by this contract line. "
+ "Defaults to the contract line start date and follows it unless "
+ "manually overridden.",
+ )
+ service_end_date = fields.Date(
+ help="End date of the service period covered by this contract line. "
+ "Defaults to the contract line end date and follows it unless "
+ "manually overridden.",
+ )
+
+ def _get_service_date_create_vals(self, vals):
+ """Return service date vals to initialize on create."""
+ result = {}
+ if vals.get("date_start") and not vals.get("service_start_date"):
+ result["service_start_date"] = vals["date_start"]
+ if vals.get("date_end") and not vals.get("service_end_date"):
+ result["service_end_date"] = vals["date_end"]
+ return result
+
+ @api.model_create_multi
+ def create(self, vals_list):
+ for vals in vals_list:
+ vals.update(self._get_service_date_create_vals(vals))
+ return super().create(vals_list)
+
+ def _get_service_date_sync_vals(self, vals):
+ """Return service date vals to sync from contract date vals."""
+ self.ensure_one()
+ service_vals = {}
+ if "date_start" in vals and self.service_start_date == self.date_start:
+ service_vals["service_start_date"] = vals["date_start"]
+ if "date_end" in vals and self.service_end_date == self.date_end:
+ service_vals["service_end_date"] = vals["date_end"]
+ return service_vals
+
+ def write(self, vals):
+ if not self.env.context.get("_sync_service_dates"):
+ service_vals_by_id = {}
+ for rec in self:
+ service_vals = rec._get_service_date_sync_vals(vals)
+ if service_vals:
+ service_vals_by_id[rec.id] = service_vals
+ for rec in self:
+ if rec.id in service_vals_by_id:
+ rec.with_context(_sync_service_dates=True).write(
+ service_vals_by_id[rec.id]
+ )
+ return super().write(vals)
diff --git a/contract_service_dates/pyproject.toml b/contract_service_dates/pyproject.toml
new file mode 100644
index 0000000000..4231d0cccb
--- /dev/null
+++ b/contract_service_dates/pyproject.toml
@@ -0,0 +1,3 @@
+[build-system]
+requires = ["whool"]
+build-backend = "whool.buildapi"
diff --git a/contract_service_dates/readme/CONTEXT.md b/contract_service_dates/readme/CONTEXT.md
new file mode 100644
index 0000000000..de57297386
--- /dev/null
+++ b/contract_service_dates/readme/CONTEXT.md
@@ -0,0 +1,4 @@
+Contract lines in the base contract module carry a single start date and end
+date that drive the recurring invoicing schedule. There is no dedicated field
+to record the period during which the service is actually delivered, which is
+often distinct from the billing period. This module fills that gap.
diff --git a/contract_service_dates/readme/CONTRIBUTORS.md b/contract_service_dates/readme/CONTRIBUTORS.md
new file mode 100644
index 0000000000..1fbb3f1bf0
--- /dev/null
+++ b/contract_service_dates/readme/CONTRIBUTORS.md
@@ -0,0 +1 @@
+- Benoit Aimont (https://www.acsone.eu/)
diff --git a/contract_service_dates/readme/DESCRIPTION.md b/contract_service_dates/readme/DESCRIPTION.md
new file mode 100644
index 0000000000..ee04e3c2f0
--- /dev/null
+++ b/contract_service_dates/readme/DESCRIPTION.md
@@ -0,0 +1,3 @@
+This module adds **Service Start Date** and **Service End Date** fields to
+contract lines, allowing you to track the actual service period independently
+from the billing dates.
diff --git a/contract_service_dates/readme/USAGE.md b/contract_service_dates/readme/USAGE.md
new file mode 100644
index 0000000000..5a2ad71f39
--- /dev/null
+++ b/contract_service_dates/readme/USAGE.md
@@ -0,0 +1,13 @@
+On a contract line form, a new **Service Dates** group is displayed in the
+**Recurrence** tab, below the contract line start and end dates:
+
+- **Service Start Date**: start of the service period. Populated automatically
+ from the contract line start date and kept in sync with it unless manually
+ overridden.
+- **Service End Date**: end of the service period. Populated automatically from
+ the contract line end date and kept in sync with it unless manually
+ overridden.
+
+Once a service date is set to a value different from its corresponding contract
+line date, it is considered decoupled and will no longer be updated
+automatically when the contract line date changes.
diff --git a/contract_service_dates/static/description/icon.png b/contract_service_dates/static/description/icon.png
new file mode 100644
index 0000000000..3a0328b516
Binary files /dev/null and b/contract_service_dates/static/description/icon.png differ
diff --git a/contract_service_dates/static/description/index.html b/contract_service_dates/static/description/index.html
new file mode 100644
index 0000000000..68de22a185
--- /dev/null
+++ b/contract_service_dates/static/description/index.html
@@ -0,0 +1,457 @@
+
+
+
+
+
+README.rst
+
+
+
+
+
+
+
+
+
+
+
Contract Service Dates
+
+

+
This module adds Service Start Date and Service End Date fields
+to contract lines, allowing you to track the actual service period
+independently from the billing dates.
+
Table of contents
+
+
+
+
Contract lines in the base contract module carry a single start date and
+end date that drive the recurring invoicing schedule. There is no
+dedicated field to record the period during which the service is
+actually delivered, which is often distinct from the billing period.
+This module fills that gap.
+
+
+
+
On a contract line form, a new Service Dates group is displayed in
+the Recurrence tab, below the contract line start and end dates:
+
+- Service Start Date: start of the service period. Populated
+automatically from the contract line start date and kept in sync with
+it unless manually overridden.
+- Service End Date: end of the service period. Populated
+automatically from the contract line end date and kept in sync with it
+unless manually overridden.
+
+
Once a service date is set to a value different from its corresponding
+contract line date, it is considered decoupled and will no longer be
+updated automatically when the contract line date changes.
+
+
+
+
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.
+
+
+
+
+
+
+
+
This module is maintained by the OCA.
+
+
+
+
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_service_dates/tests/__init__.py b/contract_service_dates/tests/__init__.py
new file mode 100644
index 0000000000..46011f4449
--- /dev/null
+++ b/contract_service_dates/tests/__init__.py
@@ -0,0 +1 @@
+from . import test_contract_service_dates
diff --git a/contract_service_dates/tests/test_contract_service_dates.py b/contract_service_dates/tests/test_contract_service_dates.py
new file mode 100644
index 0000000000..09e3831d26
--- /dev/null
+++ b/contract_service_dates/tests/test_contract_service_dates.py
@@ -0,0 +1,100 @@
+# Copyright 2026 ACSONE SA/NV
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
+
+from datetime import date
+
+from odoo.tests.common import TransactionCase
+
+
+class TestContractServiceDates(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 Product", "type": "service"}
+ )
+ journal = cls.env["account.journal"].search(
+ [("type", "=", "sale"), ("company_id", "=", cls.env.company.id)],
+ limit=1,
+ )
+ cls.contract = cls.env["contract.contract"].create(
+ {
+ "name": "Test Contract",
+ "partner_id": cls.partner.id,
+ "journal_id": journal.id,
+ "contract_type": "sale",
+ "line_recurrence": True,
+ }
+ )
+
+ def _create_line(self, date_start, date_end=None, **extra):
+ vals = {
+ "contract_id": self.contract.id,
+ "product_id": self.product.id,
+ "name": "Test line",
+ "quantity": 1.0,
+ "price_unit": 100.0,
+ "date_start": date_start,
+ "recurring_next_date": date_start,
+ "recurring_rule_type": "monthly",
+ "recurring_invoicing_type": "pre-paid",
+ "recurring_interval": 1,
+ }
+ if date_end is not None:
+ vals["date_end"] = date_end
+ vals.update(extra)
+ return self.env["contract.line"].create(vals)
+
+ def test_create_copies_dates(self):
+ """service dates are initialised from date_start / date_end on create."""
+ line = self._create_line(date(2025, 1, 1), date(2025, 12, 31))
+ self.assertEqual(line.service_start_date, date(2025, 1, 1))
+ self.assertEqual(line.service_end_date, date(2025, 12, 31))
+
+ def test_create_no_end_date(self):
+ """service_end_date is False when date_end is not set."""
+ line = self._create_line(date(2025, 1, 1))
+ self.assertEqual(line.service_start_date, date(2025, 1, 1))
+ self.assertFalse(line.service_end_date)
+
+ def test_create_explicit_service_dates_preserved(self):
+ """Explicitly supplied service dates are not overwritten on create."""
+ line = self._create_line(
+ date(2025, 1, 1),
+ date(2025, 12, 31),
+ service_start_date=date(2025, 3, 1),
+ service_end_date=date(2025, 9, 30),
+ )
+ self.assertEqual(line.service_start_date, date(2025, 3, 1))
+ self.assertEqual(line.service_end_date, date(2025, 9, 30))
+
+ def test_write_date_start_syncs_service_start(self):
+ """Modifying date_start updates service_start_date when still in sync."""
+ line = self._create_line(date(2025, 1, 1), date(2025, 12, 31))
+ line.write({"date_start": date(2025, 2, 1)})
+ self.assertEqual(line.service_start_date, date(2025, 2, 1))
+
+ def test_write_date_end_syncs_service_end(self):
+ """Modifying date_end updates service_end_date when still in sync."""
+ line = self._create_line(date(2025, 1, 1), date(2025, 12, 31))
+ line.write({"date_end": date(2025, 6, 30)})
+ self.assertEqual(line.service_end_date, date(2025, 6, 30))
+
+ def test_write_date_start_does_not_overwrite_decoupled_service_start(self):
+ """Modifying date_start leaves service_start_date alone when decoupled."""
+ line = self._create_line(date(2025, 1, 1), date(2025, 12, 31))
+ # Manually decouple service_start_date
+ line.write({"service_start_date": date(2025, 3, 1)})
+ # Now change date_start — service_start_date must not follow
+ line.write({"date_start": date(2025, 2, 1)})
+ self.assertEqual(line.service_start_date, date(2025, 3, 1))
+
+ def test_write_date_end_does_not_overwrite_decoupled_service_end(self):
+ """Modifying date_end leaves service_end_date alone when decoupled."""
+ line = self._create_line(date(2025, 1, 1), date(2025, 12, 31))
+ # Manually decouple service_end_date
+ line.write({"service_end_date": date(2025, 9, 30)})
+ # Now change date_end — service_end_date must not follow
+ line.write({"date_end": date(2026, 12, 31)})
+ self.assertEqual(line.service_end_date, date(2025, 9, 30))
diff --git a/contract_service_dates/views/contract_line.xml b/contract_service_dates/views/contract_line.xml
new file mode 100644
index 0000000000..12c55a4596
--- /dev/null
+++ b/contract_service_dates/views/contract_line.xml
@@ -0,0 +1,22 @@
+
+
+
+
+ contract.line form view (in contract_service_dates)
+ contract.line
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/setup/_metapackage/pyproject.toml b/setup/_metapackage/pyproject.toml
index 7ef6614daf..fa4079e2b8 100644
--- a/setup/_metapackage/pyproject.toml
+++ b/setup/_metapackage/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "odoo-addons-oca-contract"
-version = "18.0.20260227.1"
+version = "18.0.20260728.0"
dependencies = [
"odoo-addon-agreement_rebate_partner_company_group==18.0.*",
"odoo-addon-contract==18.0.*",
@@ -22,6 +22,7 @@ dependencies = [
"odoo-addon-contract_sale_mandate==18.0.*",
"odoo-addon-contract_sale_payment_mode==18.0.*",
"odoo-addon-contract_sale_transmit_method==18.0.*",
+ "odoo-addon-contract_service_dates==18.0.*",
"odoo-addon-contract_termination==18.0.*",
"odoo-addon-contract_transmit_method==18.0.*",
"odoo-addon-contract_update_last_date_invoiced==18.0.*",