Skip to content
Merged
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
98 changes: 77 additions & 21 deletions account_offbalance_sponsorship/models/account_move_line.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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,
Expand All @@ -242,6 +279,7 @@ def _create_onbalance_move_lines(
"off_balance_line_ids": [],
"debit": 0.0,
"credit": 0.0,
"amount_currency": 0.0,
}
)

Expand All @@ -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,
Expand All @@ -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()
)
Comment thread
ecino marked this conversation as resolved.
current_total = sum(
v["debit"] - v["credit"] for v in lines_to_create.values()
)
Expand All @@ -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
Expand All @@ -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(
Expand All @@ -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,
}
)

Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from odoo import fields
from odoo.tests import tagged

from odoo.addons.account.tests.common import AccountTestInvoicingCommon
Expand Down Expand Up @@ -101,52 +102,70 @@ 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.

:param amounts: Payment amounts (floats)
: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()
Expand Down Expand Up @@ -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
Expand Down