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
5 changes: 4 additions & 1 deletion contract/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

{
"name": "Recurring - Contracts Management",
"version": "19.0.1.0.0",
"version": "19.0.1.1.3",
"category": "Contract Management",
"license": "AGPL-3",
"author": "Tecnativa, ACSONE SA/NV, Odoo Community Association (OCA)",
Expand Down Expand Up @@ -39,6 +39,9 @@
"views/contract_portal_templates.xml",
"wizards/contract_manually_create_invoice.xml",
],
"demo": [
"demo/contract_demo.xml",
],
"assets": {
"web.assets_frontend": ["contract/static/src/scss/frontend.scss"],
"web.assets_tests": ["contract/static/src/js/contract_portal_tour.esm.js"],
Expand Down
145 changes: 145 additions & 0 deletions contract/demo/contract_demo.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
<?xml version="1.0" encoding="utf-8" ?>
<!-- Copyright 2026 OCA - bosd
License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -->
<odoo>
<!--Tags-->
<record id="contract_tag_premium" model="contract.tag">
<field name="name">Premium</field>
<field name="color" eval="2" />
</record>
<record id="contract_tag_renewal" model="contract.tag">
<field name="name">Renewal Required</field>
<field name="color" eval="3" />
</record>
<record id="contract_tag_internal" model="contract.tag">
<field name="name">Internal</field>
<field name="color" eval="5" />
</record>

<!--Contract Template-->
<record id="contract_template_monthly_demo" model="contract.template">
<field name="name">Monthly Subscription Template</field>
<field name="contract_type">sale</field>
<field name="recurring_rule_type">monthly</field>
<field name="recurring_invoicing_type">post-paid</field>
<field name="recurring_interval">1</field>
<field
name="contract_line_ids"
eval="[
Command.create({
'display_type': 'line_section',
'name': 'Subscription services',
}),
Command.create({
'product_id': ref('product.product_product_5'),
'name': 'Service from #START# to #END# (#INVOICEMONTHNAME#)',
'quantity': 1,
'price_unit': 75.0,
}),
Command.create({
'display_type': 'line_note',
'name': 'Cancellation requires 30 days written notice.',
}),
]"
/>
</record>

<!--Running contract: started 6 months ago, ends 6 months from now-->
<record id="contract_running_demo" model="contract.contract">
<field name="name">Demo Running Contract</field>
<field name="code">DEMO/RUN/0001</field>
<field name="partner_id" ref="base.res_partner_2" />
<field name="contract_type">sale</field>
<field name="line_recurrence">True</field>
<field
name="date_start"
eval="(DateTime.now() - relativedelta(months=6)).strftime('%Y-%m-%d')"
/>
<field name="tag_ids" eval="[(6, 0, [ref('contract_tag_premium')])]" />
<field
name="contract_line_ids"
eval="[
Command.create({
'display_type': 'line_section',
'name': 'Recurring services',
}),
Command.create({
'product_id': ref('product.product_product_5'),
'name': 'Hosting from #START# to #END# (#INVOICEMONTHNAME#)',
'quantity': 1,
'price_unit': 120.0,
'recurring_rule_type': 'monthly',
'recurring_invoicing_type': 'post-paid',
'recurring_interval': 1,
'date_start': (DateTime.now() - relativedelta(months=6)).strftime('%Y-%m-%d'),
'date_end': (DateTime.now() + relativedelta(months=6)).strftime('%Y-%m-%d'),
'recurring_next_date': (DateTime.now() + relativedelta(months=1)).strftime('%Y-%m-01'),
}),
]"
/>
</record>

<!--Expired contract: ended last month-->
<record id="contract_expired_demo" model="contract.contract">
<field name="name">Demo Expired Contract</field>
<field name="code">DEMO/EXP/0001</field>
<field name="partner_id" ref="base.res_partner_3" />
<field name="contract_type">sale</field>
<field name="line_recurrence">True</field>
<field
name="date_start"
eval="(DateTime.now() - relativedelta(months=12)).strftime('%Y-%m-%d')"
/>
<field name="tag_ids" eval="[(6, 0, [ref('contract_tag_renewal')])]" />
<field
name="contract_line_ids"
eval="[
Command.create({
'product_id': ref('product.product_product_5'),
'name': 'Service from #START# to #END# (#INVOICEMONTHNAME#)',
'quantity': 1,
'price_unit': 80.0,
'recurring_rule_type': 'monthly',
'recurring_invoicing_type': 'post-paid',
'recurring_interval': 1,
'date_start': (DateTime.now() - relativedelta(months=12)).strftime('%Y-%m-%d'),
'date_end': (DateTime.now() - relativedelta(months=1)).strftime('%Y-%m-%d'),
}),
]"
/>
</record>

<!--Upcoming contract: starts next month-->
<record id="contract_upcoming_demo" model="contract.contract">
<field name="name">Demo Upcoming Contract</field>
<field name="code">DEMO/UP/0001</field>
<field name="partner_id" ref="base.res_partner_4" />
<field name="contract_type">sale</field>
<field name="line_recurrence">True</field>
<field name="contract_template_id" ref="contract_template_monthly_demo" />
<field
name="date_start"
eval="(DateTime.now() + relativedelta(months=1, day=1)).strftime('%Y-%m-%d')"
/>
<field
name="tag_ids"
eval="[(6, 0, [ref('contract_tag_premium'), ref('contract_tag_internal')])]"
/>
<field
name="contract_line_ids"
eval="[
Command.create({
'product_id': ref('product.product_product_5'),
'name': 'Service from #START# to #END# (#INVOICEMONTHNAME#)',
'quantity': 2,
'price_unit': 100.0,
'recurring_rule_type': 'monthly',
'recurring_invoicing_type': 'pre-paid',
'recurring_interval': 1,
'date_start': (DateTime.now() + relativedelta(months=1, day=1)).strftime('%Y-%m-%d'),
'date_end': (DateTime.now() + relativedelta(months=13)).strftime('%Y-%m-%d'),
}),
]"
/>
</record>
</odoo>
51 changes: 49 additions & 2 deletions contract/models/contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,15 +39,23 @@ class ContractContract(models.Model):
default=lambda self: self.env.user,
)
group_id = fields.Many2one(
string="Group",
string="Analytic Account",
comodel_name="account.analytic.account",
compute="_compute_group_id",
store=True,
readonly=False,
ondelete="restrict",
help=(
"Analytic account shared by every line of this contract. "
"Computed from the lines' analytic distribution when all lines "
"point at the same account; left empty when the lines use "
"different accounts. Stored compute, editable: clearing or "
"setting it manually overrides the computed value."
),
)
tag_ids = fields.Many2many(comodel_name="contract.tag", string="Tags")
note = fields.Text(string="Notes")
color = fields.Integer(string="Color Index")
note = fields.Html(string="Notes", sanitize=True)

# === Partner and Commercial Info ===
partner_id = fields.Many2one(
Expand Down Expand Up @@ -124,6 +132,13 @@ class ContractContract(models.Model):

# === Dates ===
date_end = fields.Date(compute="_compute_date_end", store=True, readonly=False)
last_date_invoiced = fields.Date(
string="Date of Last Invoice",
compute="_compute_last_date_invoiced",
store=True,
readonly=True,
copy=False,
)

# === Compute Methods ===

Expand Down Expand Up @@ -210,6 +225,37 @@ def _compute_date_end(self):
if date_end and all(date_end):
contract.date_end = max(date_end)

@api.constrains("date_start", "date_end")
def _check_contract_start_end_dates(self):
# When lines are present, the line-level _check_start_end_dates
# constraint covers per-line validation, and contract.date_end is
# computed from the lines while contract.date_start may legitimately
# default to today even for an existing-line contract — so the
# contract-level pair is not strictly comparable. An empty date_end
# (open-ended contract) is also allowed.
for rec in self:
if rec.contract_line_ids:
continue
if rec.date_start and rec.date_end and rec.date_start > rec.date_end:
raise ValidationError(
self.env._("Contract end date must be after the start date.")
)

@api.depends(
"contract_line_ids.last_date_invoiced",
"contract_line_ids.is_canceled",
)
def _compute_last_date_invoiced(self):
for contract in self:
dates = contract.contract_line_ids.filtered(
lambda line: (
line.last_date_invoiced
and not line.is_canceled
and (not line.display_type or line.is_recurring_note)
)
).mapped("last_date_invoiced")
contract.last_date_invoiced = max(dates) if dates else False

def _inverse_partner_id(self):
for rec in self:
if not rec.invoice_partner_id:
Expand Down Expand Up @@ -655,6 +701,7 @@ def _recurring_create_invoice(self, date_ref=False):
self._add_contract_origin(moves)
self._invoice_followers(moves)
self._compute_recurring_next_date()
self._compute_last_date_invoiced()
return moves

@api.model
Expand Down
1 change: 1 addition & 0 deletions contract/models/contract_recurring_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ class ContractRecurringMixin(models.AbstractModel):
# Define when and how invoices should be issued within the recurrence.

last_date_invoiced = fields.Date(
string="Date of Last Invoice",
readonly=True,
copy=False,
)
Expand Down
9 changes: 8 additions & 1 deletion contract/models/contract_tag.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,22 @@
# Copyright 2019 ACSONE SA/NV
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).

from random import randint

from odoo import fields, models


class ContractTag(models.Model):
_name = "contract.tag"
_description = "Contract Tag"

def _get_default_color(self):
return randint(1, 11)

name = fields.Char(required=True)
company_id = fields.Many2one(
"res.company", string="Company", default=lambda self: self.env.company
)
color = fields.Integer("Color Index", default=0)
color = fields.Integer(
string="Color Index", default=lambda self: self._get_default_color()
)
1 change: 1 addition & 0 deletions contract/models/contract_template.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ class ContractTemplate(models.Model):

# === Basic Info ===

active = fields.Boolean(default=True)
name = fields.Char(required=True)
partner_id = fields.Many2one(
comodel_name="res.partner",
Expand Down
Binary file modified contract/static/description/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
87 changes: 87 additions & 0 deletions contract/static/description/icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading