Skip to content

Commit 1f23128

Browse files
committed
[ADD] account_perf_obligation_start_end_dates
1 parent ce390ed commit 1f23128

15 files changed

Lines changed: 594 additions & 1 deletion

File tree

‎account_perf_obligation/models/perf_obligation.py‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,11 @@ class PerfObligation(models.Model):
4747
string="Total Amount to Recognize",
4848
required=True,
4949
)
50+
recognition_at_date_method = fields.Selection(
51+
selection=[],
52+
help="Method used to compute the amount to recognize at a given date. "
53+
"Leave empty for manual recognition only.",
54+
)
5055
move_line_ids = fields.One2many(
5156
comodel_name="account.move.line",
5257
inverse_name="perf_obligation_id",
@@ -131,6 +136,47 @@ def _get_recognition_config(self) -> RecognitionConfig:
131136
)
132137
return RecognitionConfig(**values)
133138

139+
# ------------------------------------------------------------------
140+
# Recognition at date
141+
# ------------------------------------------------------------------
142+
143+
def _supports_recognition_at_date(self):
144+
"""Return whether this obligation supports automatic recognition
145+
at date computation.
146+
147+
Override this method to add support for other recognition methods.
148+
"""
149+
self.ensure_one()
150+
return bool(self.recognition_at_date_method)
151+
152+
def _compute_amount_to_recognize_at_date(self, date):
153+
"""Compute the cumulative amount to recognize at the given date.
154+
155+
Dispatches to the method matching ``recognition_at_date_method``.
156+
Raises if no recognition method is configured.
157+
"""
158+
self.ensure_one()
159+
if not self._supports_recognition_at_date():
160+
raise ValidationError(
161+
_(
162+
"No recognition at date method configured "
163+
"on performance obligation %(name)s.",
164+
name=self.display_name,
165+
)
166+
)
167+
method_name = f"_compute_amount_to_recognize_{self.recognition_at_date_method}"
168+
compute_method = getattr(self, method_name, None)
169+
if compute_method is None:
170+
raise ValidationError(
171+
_(
172+
"Unknown recognition method '%(method)s' "
173+
"on performance obligation %(name)s.",
174+
method=self.recognition_at_date_method,
175+
name=self.display_name,
176+
)
177+
)
178+
return compute_method(date)
179+
134180
# ------------------------------------------------------------------
135181
# Balance helpers
136182
# ------------------------------------------------------------------

‎account_perf_obligation/tests/test_recognition.py‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Copyright 2026 ACSONE SA/NV
22
# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl).
33

4-
from odoo import Command
4+
from odoo import Command, fields
55
from odoo.exceptions import UserError, ValidationError
66

77
from .common import PerfObligationCommon
@@ -657,3 +657,21 @@ def test_expense_scenario4_bill_in_middle(self):
657657
self.assertAlmostEqual(debit_bs.credit, 100)
658658
self.assertAlmostEqual(pl.debit, 100)
659659
self._assert_bs(po, "2026-04-30", self.exp_debit_bs, 0, self.exp_credit_bs, 0)
660+
661+
# =========================================================
662+
# Recognition at date (base dispatch)
663+
# =========================================================
664+
665+
def test_no_recognition_method_raises(self):
666+
"""Without recognition method, _compute_amount_to_recognize_at_date
667+
raises ValidationError."""
668+
po = self._create_obligation(total_amount=1000.0)
669+
self.assertFalse(po.recognition_at_date_method)
670+
with self.assertRaises(ValidationError):
671+
po._compute_amount_to_recognize_at_date(fields.Date.today())
672+
673+
def test_supports_recognition_at_date_false_by_default(self):
674+
"""Without recognition method, _supports_recognition_at_date
675+
returns False."""
676+
po = self._create_obligation(total_amount=1000.0)
677+
self.assertFalse(po._supports_recognition_at_date())

‎account_perf_obligation/views/perf_obligation.xml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
<field name="name" />
4545
<field name="perf_type" />
4646
<field name="total_amount" />
47+
<field name="recognition_at_date_method" />
4748
</group>
4849
<group>
4950
<field name="create_date" />
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
==================================================
2+
Performance Obligations - Start/End Dates
3+
==================================================
4+
5+
Extends the **Performance Obligations (IFRS 15)** module to support
6+
time-based income and expense recognition using start and end dates.
7+
8+
Purpose
9+
=======
10+
11+
When a performance obligation spans a defined period, this module computes
12+
the amount to recognize at any given date using the **daily pro-rata** method.
13+
14+
The architecture is designed so that alternative computation methods
15+
(e.g. full-month based) can be easily added by extending the selection
16+
field and implementing the corresponding method.
17+
18+
Usage
19+
=====
20+
21+
#. On a performance obligation, select a **Recognition at Date Method**
22+
(e.g. "Daily Pro-Rata")
23+
#. Fill in the **Start Date** and **End Date**
24+
(these fields appear and become required when the daily pro-rata
25+
method is selected)
26+
#. When opening the **Recognize Income/Expense** wizard, the
27+
**Suggested Amount** is automatically computed based on the selected
28+
date and the obligation period
29+
#. The suggested amount pre-fills the **Amount to Recognize** field,
30+
but can be modified before confirmation
31+
32+
Extensibility
33+
=============
34+
35+
To implement a different recognition formula:
36+
37+
#. Extend the ``recognition_at_date_method`` selection field on ``perf.obligation``
38+
to add a new key (e.g. ``"monthly"``)
39+
#. Implement the corresponding
40+
``_compute_amount_to_recognize_monthly(date)`` method on the same model
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
from . import models
2+
from . import wizards
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# Copyright 2026 ACSONE SA/NV
2+
# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl).
3+
4+
{
5+
"name": "Performance Obligations - Start/End Dates",
6+
"summary": """Add start/end dates on performance obligations
7+
for time-based income/expense recognition""",
8+
"version": "18.0.1.0.0",
9+
"category": "Accounting",
10+
"license": "LGPL-3",
11+
"author": "ACSONE SA/NV",
12+
"website": "https://github.com/acsone/ifrs15",
13+
"depends": [
14+
"account_perf_obligation",
15+
],
16+
"data": [
17+
"wizards/perf_obligation_recognize.xml",
18+
"views/perf_obligation.xml",
19+
],
20+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from . import perf_obligation
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
# Copyright 2026 ACSONE SA/NV
2+
# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl).
3+
4+
from odoo import _, api, fields, models
5+
from odoo.exceptions import ValidationError
6+
from odoo.tools import float_round
7+
8+
9+
class PerfObligation(models.Model):
10+
_inherit = "perf.obligation"
11+
12+
recognition_at_date_method = fields.Selection(
13+
selection_add=[
14+
("daily", "Daily Pro-Rata"),
15+
],
16+
)
17+
start_date = fields.Date()
18+
end_date = fields.Date()
19+
is_start_date_required = fields.Boolean(
20+
compute="_compute_is_date_required",
21+
)
22+
is_end_date_required = fields.Boolean(
23+
compute="_compute_is_date_required",
24+
)
25+
26+
@api.depends("recognition_at_date_method")
27+
def _compute_is_date_required(self):
28+
for rec in self:
29+
method = rec.recognition_at_date_method
30+
rec.is_start_date_required = method == "daily"
31+
rec.is_end_date_required = method == "daily"
32+
33+
@api.constrains("start_date", "end_date")
34+
def _check_dates(self):
35+
for rec in self:
36+
if rec.start_date and rec.end_date and rec.start_date > rec.end_date:
37+
raise ValidationError(
38+
_(
39+
"The start date must be before or equal to "
40+
"the end date on performance obligation %(name)s.",
41+
name=rec.display_name,
42+
)
43+
)
44+
45+
@api.constrains("recognition_at_date_method", "start_date", "end_date")
46+
def _check_recognition_method_dates(self):
47+
for rec in self:
48+
if not rec.is_start_date_required and not rec.is_end_date_required:
49+
continue
50+
if (rec.is_start_date_required and not rec.start_date) or (
51+
rec.is_end_date_required and not rec.end_date
52+
):
53+
raise ValidationError(
54+
_(
55+
"Start and end dates are required when the daily "
56+
"pro-rata recognition method is selected "
57+
"on performance obligation %(name)s.",
58+
name=rec.display_name,
59+
)
60+
)
61+
62+
def _compute_amount_to_recognize_daily(self, date):
63+
"""Daily pro-rata recognition method.
64+
65+
Computes the amount to recognize based on the number of calendar
66+
days elapsed from ``start_date`` (inclusive) to ``date`` (inclusive),
67+
over the total number of days of the period.
68+
69+
Rounding strategy: each intermediate amount is rounded using the
70+
currency's decimal precision. The last day always returns exactly
71+
``total_amount`` to avoid any residual rounding difference.
72+
"""
73+
self.ensure_one()
74+
start_date = self.start_date
75+
end_date = self.end_date
76+
77+
if date < start_date:
78+
return 0.0
79+
if date >= end_date:
80+
return self.total_amount
81+
82+
precision = self.currency_id.decimal_places
83+
total_days = (end_date - start_date).days + 1
84+
elapsed_days = (date - start_date).days + 1
85+
daily_amount = self.total_amount / total_days
86+
amount = float_round(daily_amount * elapsed_days, precision_digits=precision)
87+
# Safety cap: never exceed total
88+
return min(amount, self.total_amount)
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
[build-system]
2+
requires = ["whool"]
3+
build-backend = "whool.buildapi"
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from . import test_start_end_dates

0 commit comments

Comments
 (0)