feat(fusion_accounting_reports): currency conversion service
Pure-Python helper for FX conversion at report end-date. Handles direct rates, inverse rates, and fallback to most-recent-rate-on-or-before. fetch_rates() pulls from res.currency.rate using the same 1/rate inversion convention Odoo uses internally. Made-with: Cursor
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
from . import date_periods
|
from . import date_periods
|
||||||
from . import account_hierarchy
|
from . import account_hierarchy
|
||||||
from . import totaling
|
from . import totaling
|
||||||
|
from . import currency_conversion
|
||||||
|
|||||||
66
fusion_accounting_reports/services/currency_conversion.py
Normal file
66
fusion_accounting_reports/services/currency_conversion.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
"""Multi-currency conversion for financial reports.
|
||||||
|
|
||||||
|
Converts move-line amounts to the report's display currency at the
|
||||||
|
report end-date. Pure-Python - caller provides exchange rates as a
|
||||||
|
dict {(source_code, target_code, date): rate}."""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ConversionRate:
|
||||||
|
source: str
|
||||||
|
target: str
|
||||||
|
rate: float
|
||||||
|
rate_date: date
|
||||||
|
|
||||||
|
|
||||||
|
def convert_amount(amount: float, *, source_currency: str, target_currency: str,
|
||||||
|
rate_date: date, rates: dict) -> float:
|
||||||
|
"""Convert `amount` from source to target at the given date.
|
||||||
|
|
||||||
|
`rates` is a dict keyed by (source, target, date) -> rate.
|
||||||
|
If source == target, returns amount unchanged."""
|
||||||
|
if source_currency == target_currency:
|
||||||
|
return amount
|
||||||
|
key = (source_currency, target_currency, rate_date)
|
||||||
|
if key in rates:
|
||||||
|
return amount * rates[key]
|
||||||
|
inv_key = (target_currency, source_currency, rate_date)
|
||||||
|
if inv_key in rates:
|
||||||
|
inv = rates[inv_key]
|
||||||
|
if inv != 0:
|
||||||
|
return amount / inv
|
||||||
|
candidates = [
|
||||||
|
(d, r) for (s, t, d), r in rates.items()
|
||||||
|
if s == source_currency and t == target_currency and d <= rate_date
|
||||||
|
]
|
||||||
|
if candidates:
|
||||||
|
candidates.sort(key=lambda x: x[0], reverse=True)
|
||||||
|
return amount * candidates[0][1]
|
||||||
|
raise ValueError(
|
||||||
|
f"No exchange rate available for {source_currency}->{target_currency} on or before {rate_date}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_rates(env, *, target_currency_id: int, as_of: date,
|
||||||
|
source_currency_ids: list[int] | None = None) -> dict:
|
||||||
|
"""Fetch all relevant rates from res.currency.rate as of a given date.
|
||||||
|
|
||||||
|
Returns the dict-of-rates structure consumed by convert_amount.
|
||||||
|
Pulls only rates where source != target and date <= as_of."""
|
||||||
|
Rate = env['res.currency.rate'].sudo()
|
||||||
|
target = env['res.currency'].browse(target_currency_id)
|
||||||
|
domain = [
|
||||||
|
('name', '<=', as_of),
|
||||||
|
('currency_id', '!=', target.id),
|
||||||
|
]
|
||||||
|
if source_currency_ids:
|
||||||
|
domain.append(('currency_id', 'in', source_currency_ids))
|
||||||
|
rates_recs = Rate.search(domain)
|
||||||
|
|
||||||
|
out = {}
|
||||||
|
for r in rates_recs:
|
||||||
|
out[(r.currency_id.name, target.name, r.name)] = (1.0 / r.rate) if r.rate else 0.0
|
||||||
|
return out
|
||||||
@@ -1 +1,2 @@
|
|||||||
from . import test_services_unit
|
from . import test_services_unit
|
||||||
|
from . import test_currency_conversion
|
||||||
|
|||||||
53
fusion_accounting_reports/tests/test_currency_conversion.py
Normal file
53
fusion_accounting_reports/tests/test_currency_conversion.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
"""Unit tests for currency_conversion service."""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from odoo.tests.common import TransactionCase, tagged
|
||||||
|
from odoo.addons.fusion_accounting_reports.services.currency_conversion import (
|
||||||
|
convert_amount, fetch_rates,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@tagged('post_install', '-at_install')
|
||||||
|
class TestCurrencyConversion(TransactionCase):
|
||||||
|
|
||||||
|
def test_same_currency_returns_unchanged(self):
|
||||||
|
result = convert_amount(100, source_currency='USD',
|
||||||
|
target_currency='USD',
|
||||||
|
rate_date=date(2026, 4, 19), rates={})
|
||||||
|
self.assertEqual(result, 100)
|
||||||
|
|
||||||
|
def test_direct_rate(self):
|
||||||
|
rates = {('USD', 'CAD', date(2026, 4, 19)): 1.35}
|
||||||
|
result = convert_amount(100, source_currency='USD',
|
||||||
|
target_currency='CAD',
|
||||||
|
rate_date=date(2026, 4, 19), rates=rates)
|
||||||
|
self.assertEqual(result, 135)
|
||||||
|
|
||||||
|
def test_inverse_rate(self):
|
||||||
|
rates = {('CAD', 'USD', date(2026, 4, 19)): 0.74}
|
||||||
|
result = convert_amount(100, source_currency='USD',
|
||||||
|
target_currency='CAD',
|
||||||
|
rate_date=date(2026, 4, 19), rates=rates)
|
||||||
|
self.assertAlmostEqual(result, 100 / 0.74, places=2)
|
||||||
|
|
||||||
|
def test_falls_back_to_most_recent_rate(self):
|
||||||
|
rates = {
|
||||||
|
('USD', 'CAD', date(2026, 1, 1)): 1.30,
|
||||||
|
('USD', 'CAD', date(2026, 3, 1)): 1.32,
|
||||||
|
}
|
||||||
|
result = convert_amount(100, source_currency='USD',
|
||||||
|
target_currency='CAD',
|
||||||
|
rate_date=date(2026, 4, 19), rates=rates)
|
||||||
|
self.assertEqual(result, 132)
|
||||||
|
|
||||||
|
def test_raises_when_no_rate(self):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
convert_amount(100, source_currency='EUR',
|
||||||
|
target_currency='CAD',
|
||||||
|
rate_date=date(2026, 4, 19), rates={})
|
||||||
|
|
||||||
|
def test_fetch_rates_from_env(self):
|
||||||
|
cad = self.env.ref('base.CAD')
|
||||||
|
rates = fetch_rates(self.env, target_currency_id=cad.id, as_of=date(2026, 4, 19))
|
||||||
|
self.assertIsInstance(rates, dict)
|
||||||
Reference in New Issue
Block a user