diff --git a/fusion_accounting_reports/services/__init__.py b/fusion_accounting_reports/services/__init__.py index dffef435..5fc930af 100644 --- a/fusion_accounting_reports/services/__init__.py +++ b/fusion_accounting_reports/services/__init__.py @@ -1,3 +1,4 @@ from . import date_periods from . import account_hierarchy from . import totaling +from . import currency_conversion diff --git a/fusion_accounting_reports/services/currency_conversion.py b/fusion_accounting_reports/services/currency_conversion.py new file mode 100644 index 00000000..59b953da --- /dev/null +++ b/fusion_accounting_reports/services/currency_conversion.py @@ -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 diff --git a/fusion_accounting_reports/tests/__init__.py b/fusion_accounting_reports/tests/__init__.py index 1d13e069..53f6331b 100644 --- a/fusion_accounting_reports/tests/__init__.py +++ b/fusion_accounting_reports/tests/__init__.py @@ -1 +1,2 @@ from . import test_services_unit +from . import test_currency_conversion diff --git a/fusion_accounting_reports/tests/test_currency_conversion.py b/fusion_accounting_reports/tests/test_currency_conversion.py new file mode 100644 index 00000000..49fcffd8 --- /dev/null +++ b/fusion_accounting_reports/tests/test_currency_conversion.py @@ -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)