diff --git a/account_offbalance_sponsorship/models/account_move_line.py b/account_offbalance_sponsorship/models/account_move_line.py index 0597c0eb..57325bd1 100644 --- a/account_offbalance_sponsorship/models/account_move_line.py +++ b/account_offbalance_sponsorship/models/account_move_line.py @@ -2,7 +2,7 @@ from collections import defaultdict from math import copysign -from odoo import fields, models +from odoo import _, fields, models _logger = logging.getLogger(__name__) @@ -156,16 +156,25 @@ def _build_onbalance_amounts(self, invoice_lines, income_move_lines): """ Build a dictionary of on-balance amounts grouped by invoice line. + The amounts are computed in both company currency and the invoice currency. + :param invoice_lines: Invoice lines to process :param income_move_lines: Income move lines - :return: Dictionary of amounts by invoice line. + :return: Dictionary of (amount, amount_currency) by invoice line. """ - onbalance_amounts_by_line = defaultdict(float) + onbalance_amounts_by_line = {} total_income = abs(sum(income_move_lines.mapped("balance"))) + total_income_currency = abs(sum(income_move_lines.mapped("amount_currency"))) total_already_distributed = abs( sum(invoice_lines.mapped("on_balance_line_ids.balance")) ) + total_already_distributed_currency = abs( + sum(invoice_lines.mapped("on_balance_line_ids.amount_currency")) + ) available_income = total_income - total_already_distributed + available_income_currency = ( + total_income_currency - total_already_distributed_currency + ) # Process invoice lines with on-balance accounts # We group by invoice to compute the distribution ratio once per invoice @@ -187,34 +196,61 @@ def _build_onbalance_amounts(self, invoice_lines, income_move_lines): ) for invoice_line in lines: - remaining_amount = invoice_line.price_total + # Keep track of amounts in company currency and line currency + remaining_amount = invoice_line.balance + remaining_amount_currency = invoice_line.amount_currency if invoice_line.on_balance_line_ids: # When an on-balance line is linked to multiple invoice lines # (e.g. same product), we must split its amount # to avoid double counting the distributed amount. - already_distributed_amount = sum( - line.balance / len(line.off_balance_line_ids) - for line in invoice_line.on_balance_line_ids - if line.off_balance_line_ids - ) + already_distributed_amount = 0.0 + already_distributed_amount_currency = 0.0 + for line in invoice_line.on_balance_line_ids.filtered( + "off_balance_line_ids" + ): + count = len(line.off_balance_line_ids) + already_distributed_amount += line.balance / count + already_distributed_amount_currency += ( + line.amount_currency / count + ) + already_distributed = copysign( already_distributed_amount, remaining_amount, ) + already_distributed_currency = copysign( + already_distributed_amount_currency, + remaining_amount_currency, + ) remaining_amount -= already_distributed + remaining_amount_currency -= already_distributed_currency if invoice_line.currency_id.is_zero(remaining_amount): continue # Use the invoice-level ratio - distributed_amount = copysign( - min(abs(remaining_amount) * ratio, available_income), - invoice_line.balance, + def get_dist_amount(amount_to_distribute, limit): + return copysign( + min(abs(amount_to_distribute), limit), + amount_to_distribute, + ) + + distributed_amount = get_dist_amount( + remaining_amount * ratio, available_income + ) + distributed_amount_currency = get_dist_amount( + remaining_amount_currency * ratio, + available_income_currency, ) + if invoice_line.currency_id.is_zero(distributed_amount): continue - onbalance_amounts_by_line[invoice_line] = distributed_amount + onbalance_amounts_by_line[invoice_line] = ( + distributed_amount, + distributed_amount_currency, + ) available_income -= abs(distributed_amount) + available_income_currency -= abs(distributed_amount_currency) if invoice_line.currency_id.is_zero(available_income): break return onbalance_amounts_by_line @@ -227,12 +263,13 @@ def _create_onbalance_move_lines( """ Create on-balance move lines based on prorated distribution. - :param onbalance_amounts_by_line: Dict of amounts by invoice line + :param onbalance_amounts_by_line: Dict of (amount, amount_currency) + by invoice line :param income_entry: The income journal entry """ # Prepare line creation total_distributed_amount = 0.0 - currency = income_entry.currency_id + total_distributed_amount_currency = 0.0 lines_to_create = defaultdict( lambda: { "move_id": income_entry.id, @@ -242,6 +279,7 @@ def _create_onbalance_move_lines( "off_balance_line_ids": [], "debit": 0.0, "credit": 0.0, + "amount_currency": 0.0, } ) @@ -250,7 +288,8 @@ def _create_onbalance_move_lines( invoice_line, distributed_amount, ) in onbalance_amounts_by_line.items(): - amount = currency.round(abs(distributed_amount)) + currency = invoice_line.currency_id + amount = currency.round(abs(distributed_amount[0])) key = ( invoice_line.account_id.on_balance_account_id.id, invoice_line.product_id.id or False, @@ -259,19 +298,24 @@ def _create_onbalance_move_lines( { "account_id": key[0], "product_id": key[1], + "currency_id": currency.id, } ) - if distributed_amount > 0: + if distributed_amount[0] > 0: lines_to_create[key]["debit"] += amount else: lines_to_create[key]["credit"] += amount + lines_to_create[key]["amount_currency"] += distributed_amount[1] lines_to_create[key]["off_balance_line_ids"].append((4, invoice_line.id)) - total_distributed_amount += distributed_amount + total_distributed_amount += distributed_amount[0] + total_distributed_amount_currency += distributed_amount[1] # Apply rounding adjustment to the last line if lines_to_create: - total_offbalance_amount = sum(onbalance_amounts_by_line.values()) + total_offbalance_amount = sum( + am[0] for am in onbalance_amounts_by_line.values() + ) current_total = sum( v["debit"] - v["credit"] for v in lines_to_create.values() ) @@ -292,12 +336,21 @@ def _create_onbalance_move_lines( ) # Create the off-balance asset line + currency = on_balance_lines.mapped("currency_id") + if len(currency) != 1: + raise ValueError( + _("All created on-balance lines should have the same currency") + ) self._create_offbalance_asset_line( - income_entry, currency, total_distributed_amount, on_balance_lines.ids + income_entry, + currency, + total_distributed_amount, + total_distributed_amount_currency, + on_balance_lines.ids, ) def _create_offbalance_asset_line( - self, income_entry, currency, amount, on_balance_line_ids + self, income_entry, currency, amount, amount_currency, on_balance_line_ids ): """ Create an account move line reflecting the reconciliation amount @@ -306,6 +359,7 @@ def _create_offbalance_asset_line( :param income_entry: The income journal entry :param currency: The currency for rounding :param amount: The total distributed amount (negative for income) + :param amount_currency: The total distributed amount in currency :param on_balance_line_ids: List of created on-balance line IDs """ self.env["account.move.line"].with_context(check_move_validity=False).create( @@ -315,8 +369,10 @@ def _create_offbalance_asset_line( "move_id": income_entry.id, "partner_id": income_entry.partner_id.id, "balance": currency.round(-amount), # Negative to balance the credits + "amount_currency": currency.round(-amount_currency), "is_off_balance_generated": True, "on_balance_line_ids": [(6, 0, on_balance_line_ids)], + "currency_id": currency.id, } ) diff --git a/account_offbalance_sponsorship/tests/test_offbalance_reconciliation_usecases.py b/account_offbalance_sponsorship/tests/test_offbalance_reconciliation_usecases.py index 2a8176cc..5d598fd1 100644 --- a/account_offbalance_sponsorship/tests/test_offbalance_reconciliation_usecases.py +++ b/account_offbalance_sponsorship/tests/test_offbalance_reconciliation_usecases.py @@ -1,3 +1,4 @@ +from odoo import fields from odoo.tests import tagged from odoo.addons.account.tests.common import AccountTestInvoicingCommon @@ -101,7 +102,14 @@ def setUpClass(cls, chart_template_ref=None): ) cls.currency = cls.company.currency_id - def _create_payment(self, amounts, partner=None, dst_account_id=None): + def _create_payment( + self, + amounts, + partner=None, + dst_account_id=None, + currency=None, + amounts_currency=None, + ): """ Helper method to create a bank payment entry. @@ -109,44 +117,55 @@ def _create_payment(self, amounts, partner=None, dst_account_id=None): :param partner: Partner for the payment (default: self.partner) :param dst_account_id: Destination account for the payment (default: receivable_offbalance) + :param currency: Currency of the payment (optional) + :param amounts_currency: Amounts in currency (optional, must match amounts) :return: Posted payment move (account.move) """ if partner is None: partner = self.partner_a + receivable_lines = [] + for i, amount in enumerate(amounts): + vals = { + "name": "Receivable", + "account_id": dst_account_id or self.receivable_offbalance.id, + "debit": 0.0, + "credit": amount, + "partner_id": partner.id, + } + if currency and amounts_currency: + vals.update( + { + "currency_id": currency.id, + "amount_currency": amounts_currency[i], + } + ) + receivable_lines.append((0, 0, vals)) + + bank_vals = { + "name": "Bank Payment", + "account_id": self.bank_account.id, + "debit": sum(amounts), + "credit": 0.0, + "partner_id": partner.id, + } + if currency and amounts_currency: + bank_vals.update( + { + "currency_id": currency.id, + "amount_currency": -sum(amounts_currency), + } + ) + payment = self.AccountMove.create( { "move_type": "entry", "journal_id": self.bank_journal.id, "partner_id": partner.id, "line_ids": [ - ( - 0, - 0, - { - "name": "Bank Payment", - "account_id": self.bank_account.id, - "debit": sum(amounts), - "credit": 0.0, - "partner_id": partner.id, - }, - ), + (0, 0, bank_vals), ] - + [ - ( - 0, - 0, - { - "name": "Receivable", - "account_id": dst_account_id - or self.receivable_offbalance.id, - "debit": 0.0, - "credit": amount, - "partner_id": partner.id, - }, - ) - for amount in amounts - ], + + receivable_lines, } ) payment.action_post() @@ -297,6 +316,71 @@ def test_direct_multiple_payments_one_invoice(self): self._assert_off_balance_lines_created(payment1 + payment2, amount_total) self.assertAlmostEqual(invoice_line.amount_residual, 0.0) + def test_direct_multicurrency_invoice(self): + """Test: Invoice in foreign currency with multiple lines.""" + # Step 1: Setup foreign currency (EUR) + currency_eur = self.env.ref("base.EUR") + currency_eur.active = True + # Rate: 1 Company Currency = 2 EUR (so 1 EUR = 0.5 Company Currency) + self.env["res.currency.rate"].create( + { + "name": fields.Date.today(), + "rate": 2.0, + "currency_id": currency_eur.id, + "company_id": self.company.id, + } + ) + + # Step 2: Create invoice in EUR + # product_o: 1000 EUR -> 500 CC + # product_o_2: 200 EUR -> 100 CC + # Total: 1200 EUR -> 600 CC + invoice = self.init_invoice( + "out_invoice", + post=True, + products=[self.product_o, self.product_o_2], + currency=currency_eur, + ) + self.assertAlmostEqual(invoice.amount_total, 1200.0) + self.assertAlmostEqual(invoice.amount_total_signed, 600.0) + + # Step 3: Create payment in EUR + # We pay the full amount 1200 EUR (600 CC) + # Receivable line: Credit 600 CC, Amount Currency -1200 EUR + payment = self._create_payment( + [600.0], + currency=currency_eur, + amounts_currency=[-1200.0], + ) + + # Step 4: Reconcile + (self._receivable_lines(invoice) | self._receivable_lines(payment)).reconcile() + + # Step 5: Assertions + # Check total off-balance amount in company currency (should be 600) + off_balance_lines = self._assert_off_balance_lines_created(payment, 600.0) + + # Check amount_currency on the generated income lines + # Should sum to -1200 EUR (Credit) + income_lines = off_balance_lines.filtered( + lambda line: line.account_id == self.on_balance_income_account + ) + self.assertAlmostEqual(sum(income_lines.mapped("amount_currency")), -1200.0) + self.assertEqual(income_lines.mapped("currency_id"), currency_eur) + + # Check individual lines distribution + # Line 1 (product_o): 1000 EUR -> -1000 EUR amount_currency + line_1 = income_lines.filtered(lambda line: line.product_id == self.product_o) + self.assertEqual(len(line_1), 1) + self.assertAlmostEqual(line_1.amount_currency, -1000.0) + self.assertAlmostEqual(line_1.credit, 500.0) + + # Line 2 (product_o_2): 200 EUR -> -200 EUR amount_currency + line_2 = income_lines.filtered(lambda line: line.product_id == self.product_o_2) + self.assertEqual(len(line_2), 1) + self.assertAlmostEqual(line_2.amount_currency, -200.0) + self.assertAlmostEqual(line_2.credit, 100.0) + def test_direct_multiple_payments_one_invoice_multiple_lines(self): """Test: Multiple payments reconciling one invoice with multiple lines. - Step 1: Prepare invoice with multiple lines OF THE SAME PRODUCT