Skip to content

Commit 17ab60e

Browse files
matteo.togniniOCA-git-bot
authored andcommitted
[IMP]l10n_it_riba_oca: riba policy expenses
1 parent 6ccb506 commit 17ab60e

6 files changed

Lines changed: 307 additions & 66 deletions

File tree

l10n_it_riba_oca/models/account.py

Lines changed: 136 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -173,17 +173,39 @@ def _onchange_riba_partner_bank_id(self):
173173
bank_ids = allowed_banks
174174
self.riba_partner_bank_id = bank_ids[0] if bank_ids else None
175175

176-
def month_check(self, invoice_date_due, all_date_due):
176+
def month_check(self, all_invoice_date):
177177
"""
178-
:param invoice_date_due: first due date of invoice
179-
:param all_date_due: list of due dates for partner
180-
:return: True if month of invoice_date_due is in a list of all_date_due
178+
Check if collection fees should be applied based on invoice date month.
179+
:param all_invoice_date: list of invoice dates for partner
180+
:return: True if month of current invoice date is already in all_invoice_date
181181
"""
182-
for d in all_date_due:
183-
if invoice_date_due.month == d.month and invoice_date_due.year == d.year:
182+
self.ensure_one()
183+
date = self.invoice_date or self.date or fields.Date.context_today(self)
184+
current_invoice_month = date.strftime("%Y-%m")
185+
for d in all_invoice_date:
186+
if d and current_invoice_month == d.strftime("%Y-%m"):
184187
return True
185188
return False
186189

190+
def maturity_check(self, invoice_date_due, all_date_due):
191+
"""
192+
Check if expenses should be applied based on exact maturity date.
193+
Used for 'one_a_maturity' policy.
194+
:param invoice_date_due: due date of current invoice
195+
:param all_date_due: list of existing due dates for partner
196+
:return: True if invoice_date_due already exists in all_date_due
197+
Example:
198+
- Invoice 1: Oct -> Dec (60 days) -> expenses YES
199+
- Invoice 2: Nov -> Dec (30 days) -> expenses NO (Dec already exists)
200+
- Invoice with 30/60 days: 2 different dates -> 2 expenses
201+
"""
202+
self.ensure_one()
203+
if self.partner_id.riba_policy_expenses == "one_a_maturity":
204+
for d in all_date_due:
205+
if invoice_date_due == d:
206+
return True
207+
return False
208+
187209
def _post(self, soft=True):
188210
inv_riba_no_bank = self.filtered(
189211
lambda x: x.is_riba_payment
@@ -210,80 +232,128 @@ def _post(self, soft=True):
210232
)
211233
return super()._post(soft=soft)
212234

235+
def _get_riba_expense_line_vals(self, pay_date=None):
236+
"""
237+
Prepare values for RiBa collection fees invoice line.
238+
:param pay_date: optional date for the expense description
239+
:return: dict with invoice line values
240+
"""
241+
self.ensure_one()
242+
service_prod = self.company_id.due_cost_service_id
243+
account = service_prod.product_tmpl_id.get_product_accounts(
244+
self.fiscal_position_id
245+
)["income"]
246+
line_vals = {
247+
"partner_id": self.partner_id.id,
248+
"product_id": service_prod.id,
249+
"move_id": self.id,
250+
"price_unit": self.invoice_payment_term_id.riba_payment_cost,
251+
"due_cost_line": True,
252+
"account_id": account.id,
253+
"sequence": 9999,
254+
}
255+
if pay_date:
256+
line_vals["name"] = self.env._("{line_name} for {month}-{year}").format(
257+
line_name=service_prod.name,
258+
month=pay_date.month,
259+
year=pay_date.year,
260+
)
261+
if self.company_id.due_cost_service_id.taxes_id:
262+
tax = self.fiscal_position_id.map_tax(service_prod.taxes_id)
263+
line_vals["tax_ids"] = [(4, tax.id)]
264+
return line_vals
265+
266+
def _add_riba_expense_line(self, pay_date=None):
267+
"""Add a RiBa collection fees line to the invoice."""
268+
self.ensure_one()
269+
line_vals = self._get_riba_expense_line_vals(pay_date)
270+
self.write({"invoice_line_ids": [(0, 0, line_vals)]})
271+
272+
def _apply_riba_collection_fees(self):
273+
"""
274+
Apply collection fees based on partner's riba_policy_expenses.
275+
"""
276+
self.ensure_one()
277+
278+
# Get existing move lines with RiBa expenses for this partner.
279+
# Use commercial_partner_id because receivable lines store that instead
280+
# of the invoice partner (which may be a contact). Include draft state
281+
# so that invoices being posted in the same batch are also considered.
282+
move_line = self.env["account.move.line"].search(
283+
[
284+
(
285+
"partner_id",
286+
"=",
287+
self.partner_id.commercial_partner_id.id,
288+
),
289+
("move_id.invoice_payment_term_id.riba", "=", True),
290+
("move_id.state", "in", ("posted", "draft")),
291+
("move_id.id", "!=", self.id),
292+
]
293+
)
294+
move_line = move_line.filtered(
295+
lambda line: any(
296+
inv_line.due_cost_line for inv_line in line.move_id.invoice_line_ids
297+
)
298+
)
299+
move_line = move_line.filtered(lambda line: line.date_maturity is not False)
300+
move_line = move_line.sorted(key=lambda r: r.date_maturity)
301+
302+
previous_date_due = move_line.mapped("date_maturity")
303+
all_invoice_date = move_line.mapped("invoice_date")
304+
305+
# Compute payment term dates
306+
pterm_list = self.invoice_payment_term_id._compute_terms(
307+
date_ref=self.invoice_date or self.date or fields.Date.context_today(self),
308+
currency=self.currency_id,
309+
company=self.company_id,
310+
tax_amount=1,
311+
tax_amount_currency=1,
312+
untaxed_amount=0,
313+
sign=1 if self.is_inbound(include_receipts=True) else -1,
314+
untaxed_amount_currency=self.amount_untaxed,
315+
)
316+
317+
policy = self.partner_id.riba_policy_expenses
318+
319+
if policy == "one_per_invoice":
320+
# One expense per invoice, no date in description
321+
self._add_riba_expense_line()
322+
elif policy == "unlimited":
323+
# One expense for each due date, no checks
324+
for pay_date in pterm_list["line_ids"]:
325+
self._add_riba_expense_line(pay_date["date"])
326+
elif policy == "one_a_maturity":
327+
# One expense per maturity date, skip if date already exists
328+
for pay_date in pterm_list["line_ids"]:
329+
if not self.maturity_check(pay_date["date"], previous_date_due):
330+
self._add_riba_expense_line(pay_date["date"])
331+
else:
332+
# Default: one_a_month - one expense per month
333+
if not self.month_check(all_invoice_date):
334+
pay_date = pterm_list["line_ids"][0]
335+
self._add_riba_expense_line(pay_date["date"])
336+
337+
# Recompute invoice taxes
338+
self._sync_dynamic_lines(container={"records": self, "self": self})
339+
213340
def action_post(self):
214341
for invoice in self:
215-
# ---- Add a line with collection fees for each due date only for first due
216-
# ---- date of the month
342+
# Check if collection fees should be applied
217343
if (
218344
invoice.move_type != "out_invoice"
219345
or not invoice.invoice_payment_term_id
220346
or not invoice.invoice_payment_term_id.riba
221347
or invoice.invoice_payment_term_id.riba_payment_cost == 0.0
348+
or invoice.partner_id.commercial_partner_id.riba_exclude_expenses
222349
):
223350
continue
224351
if not invoice.company_id.due_cost_service_id:
225352
raise UserError(
226353
self.env._("Set a Service for Collection Fees in Company Config.")
227354
)
228-
# ---- Apply Collection Fees on invoice only on first due date of the month
229-
# ---- Get Date of first due date
230-
move_line = self.env["account.move.line"].search(
231-
[("partner_id", "=", invoice.partner_id.id)]
232-
)
233-
if not any(line.due_cost_line for line in move_line):
234-
move_line = self.env["account.move.line"]
235-
# ---- Filtered recordset with date_maturity
236-
move_line = move_line.filtered(lambda line: line.date_maturity is not False)
237-
# ---- Sorted
238-
move_line = move_line.sorted(key=lambda r: r.date_maturity)
239-
# ---- Get date
240-
previous_date_due = move_line.mapped("date_maturity")
241-
pterm = self.env["account.payment.term"].browse(
242-
self.invoice_payment_term_id.id
243-
)
244-
pterm_list = pterm._compute_terms(
245-
date_ref=self.invoice_date,
246-
currency=self.currency_id,
247-
company=self.company_id,
248-
tax_amount=1,
249-
tax_amount_currency=1,
250-
untaxed_amount=0,
251-
untaxed_amount_currency=0,
252-
sign=1,
253-
)
355+
invoice._apply_riba_collection_fees()
254356

255-
for pay_date in pterm_list["line_ids"]:
256-
if not self.month_check(pay_date["date"], previous_date_due):
257-
# ---- Get Line values for service product
258-
service_prod = invoice.company_id.due_cost_service_id
259-
account = service_prod.product_tmpl_id.get_product_accounts(
260-
invoice.fiscal_position_id
261-
)["income"]
262-
line_vals = {
263-
"partner_id": invoice.partner_id.id,
264-
"product_id": service_prod.id,
265-
"move_id": invoice.id,
266-
"price_unit": (
267-
invoice.invoice_payment_term_id.riba_payment_cost
268-
),
269-
"due_cost_line": True,
270-
"name": self.env._("{line_name} for {month}-{year}").format(
271-
line_name=service_prod.name,
272-
month=pay_date["date"].month,
273-
year=pay_date["date"].year,
274-
),
275-
"account_id": account.id,
276-
"sequence": 9999,
277-
}
278-
# ---- Update Line Value with tax if is set on product
279-
if invoice.company_id.due_cost_service_id.taxes_id:
280-
tax = invoice.fiscal_position_id.map_tax(service_prod.taxes_id)
281-
line_vals.update({"tax_ids": [(4, tax.id)]})
282-
invoice.write({"invoice_line_ids": [(0, 0, line_vals)]})
283-
# ---- recompute invoice taxes
284-
invoice._sync_dynamic_lines(
285-
container={"records": invoice, "self": invoice}
286-
)
287357
res = super().action_post()
288358

289359
# Automatic reconciliation for RiBa credit moves

l10n_it_riba_oca/models/partner.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,20 @@ class ResPartner(models.Model):
2222
readonly=True,
2323
)
2424

25+
riba_exclude_expenses = fields.Boolean(
26+
string="Exclude expenses Ri.Ba.",
27+
)
28+
riba_policy_expenses = fields.Selection(
29+
[
30+
("one_a_month", "More invoices, one expense per Month"),
31+
("unlimited", "One expense per maturity"),
32+
("one_a_maturity", "More invoices, one expense per maturity"),
33+
("one_per_invoice", "One expense per invoice"),
34+
],
35+
default="one_a_month",
36+
string="Ri.Ba. Policy expenses",
37+
)
38+
2539
def _domain_property_riba_supplier_company_bank_id(self):
2640
"""Allow to select bank accounts linked to the current company."""
2741
return self.env["res.partner.bank"]._domain_riba_partner_bank_id()

l10n_it_riba_oca/tests/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
66

77
from . import test_account_move
8+
from . import test_different_payment_terms
89
from . import test_menu
910
from . import test_riba
1011
from . import riba_common
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
# Copyright 2026 Nextev Srl
2+
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
3+
4+
from odoo import fields
5+
6+
from .riba_common import TestRibaCommon
7+
8+
9+
class TestDifferentPaymentTermsDueCost(TestRibaCommon):
10+
"""Regression tests: one_a_maturity must not add duplicate expenses when two
11+
invoices for the same partner share a maturity date but use different RiBa
12+
payment terms."""
13+
14+
def setUp(self):
15+
super().setUp()
16+
self.invoice.company_id.due_cost_service_id = self.service_due_cost.id
17+
self.partner.riba_policy_expenses = "one_a_maturity"
18+
# Single-maturity 60-day payment term
19+
self.payment_term_60 = self.env["account.payment.term"].create(
20+
{
21+
"name": "RiBa 60",
22+
"riba": True,
23+
"riba_payment_cost": 5.00,
24+
"line_ids": [
25+
(
26+
0,
27+
0,
28+
{
29+
"value": "percent",
30+
"delay_type": "days_after",
31+
"nb_days": 60,
32+
"days_next_month": "0",
33+
},
34+
)
35+
],
36+
}
37+
)
38+
39+
def _make_invoice(self, payment_term, invoice_date="2026-01-01"):
40+
self.partner.property_account_receivable_id = self.account_rec1_id.id
41+
return self.env["account.move"].create(
42+
{
43+
"invoice_date": fields.Date.from_string(invoice_date),
44+
"move_type": "out_invoice",
45+
"journal_id": self.sale_journal.id,
46+
"partner_id": self.partner.id,
47+
"invoice_payment_term_id": payment_term.id,
48+
"riba_partner_bank_id": self.partner.bank_ids[0].id,
49+
"invoice_line_ids": [
50+
(
51+
0,
52+
0,
53+
{
54+
"name": self.product1.name,
55+
"product_id": self.product1.id,
56+
"quantity": 1.0,
57+
"price_unit": 100.00,
58+
"account_id": self.sale_account.id,
59+
"tax_ids": [[6, 0, []]],
60+
},
61+
)
62+
],
63+
}
64+
)
65+
66+
def _due_cost_count(self, invoice):
67+
return len(invoice.invoice_line_ids.filtered("due_cost_line"))
68+
69+
def test_no_duplicate_sequential_different_terms(self):
70+
"""FATTURA 1 (30/60GG) is posted first; its 60-day maturity coincides
71+
with FATTURA 2 (60GG). Posting FATTURA 2 afterwards must not create a
72+
second expense for that shared date."""
73+
# Same invoice date so the 60-day maturities coincide exactly.
74+
inv1 = self._make_invoice(self.payment_term1) # 30/60GG -> 2 maturities
75+
inv2 = self._make_invoice(
76+
self.payment_term_60
77+
) # 60GG -> 1 maturity (same as inv1's second)
78+
79+
inv1.action_post()
80+
inv2.action_post()
81+
82+
# inv1 keeps expenses for both maturities (30-day and 60-day)
83+
self.assertEqual(self._due_cost_count(inv1), 2)
84+
# inv2's only maturity is already covered by inv1 – no new expense
85+
self.assertEqual(self._due_cost_count(inv2), 0)
86+
87+
def test_no_duplicate_batch_different_terms(self):
88+
"""Same scenario but both invoices are confirmed in a single action_post
89+
call (batch). inv1 is not yet 'posted' when inv2 is checked, so the fix
90+
must find inv1's draft receivable lines to detect the shared maturity."""
91+
inv1 = self._make_invoice(self.payment_term1)
92+
inv2 = self._make_invoice(self.payment_term_60)
93+
94+
(inv1 | inv2).action_post()
95+
96+
self.assertEqual(self._due_cost_count(inv1), 2)
97+
self.assertEqual(self._due_cost_count(inv2), 0)
98+
99+
def test_expense_added_when_different_maturity(self):
100+
"""Control: if FATTURA 2's maturity is genuinely new (no overlap with
101+
FATTURA 1), an expense must still be added for it."""
102+
# inv1: 30/60GG from 2026-01-01 -> maturities 2026-01-31 and 2026-03-02
103+
inv1 = self._make_invoice(self.payment_term1, invoice_date="2026-01-01")
104+
# inv2: 60GG from 2026-02-01 -> maturity 2026-04-02 (not in inv1's set)
105+
inv2 = self._make_invoice(self.payment_term_60, invoice_date="2026-02-01")
106+
107+
inv1.action_post()
108+
inv2.action_post()
109+
110+
self.assertEqual(self._due_cost_count(inv1), 2)
111+
# New maturity -> expense must be present
112+
self.assertEqual(self._due_cost_count(inv2), 1)

0 commit comments

Comments
 (0)