diff --git a/fusion_accounting_bank_rec/services/__init__.py b/fusion_accounting_bank_rec/services/__init__.py index 25962b99..aea16c90 100644 --- a/fusion_accounting_bank_rec/services/__init__.py +++ b/fusion_accounting_bank_rec/services/__init__.py @@ -1 +1,2 @@ from . import memo_tokenizer +from . import exchange_diff diff --git a/fusion_accounting_bank_rec/services/exchange_diff.py b/fusion_accounting_bank_rec/services/exchange_diff.py new file mode 100644 index 00000000..a5a865aa --- /dev/null +++ b/fusion_accounting_bank_rec/services/exchange_diff.py @@ -0,0 +1,46 @@ +"""Exchange-difference calculation helper. + +Pure-Python FX gain/loss computation. The engine uses this for rapid +pre-checks; Odoo's account.move._create_exchange_difference_move() is +invoked separately for the actual GL posting. +""" + +from dataclasses import dataclass + + +@dataclass +class ExchangeDiffResult: + needs_diff_move: bool + diff_amount: float # in company currency; positive = gain, negative = loss + line_company_amount: float + against_company_amount: float + + +def compute_exchange_diff(*, line_amount, line_currency_code, against_amount, + against_currency_code, line_rate, against_rate) -> ExchangeDiffResult: + """Compute whether an exchange-diff move is needed and its magnitude. + + Args: + line_amount: Bank line amount in its currency + line_currency_code: e.g. 'USD' + against_amount: Matched journal item amount in its currency + against_currency_code: e.g. 'USD' (or different) + line_rate: FX rate (foreign per company currency) at line date + against_rate: FX rate at journal item posting date + + Returns: + ExchangeDiffResult with needs_diff_move flag and computed diff + in company currency (positive = gain, negative = loss). + """ + line_company = line_amount * line_rate + against_company = against_amount * against_rate + + diff = line_company - against_company + needs_diff = abs(diff) > 0.005 # rounding tolerance + + return ExchangeDiffResult( + needs_diff_move=needs_diff, + diff_amount=round(diff, 2), + line_company_amount=round(line_company, 2), + against_company_amount=round(against_company, 2), + ) diff --git a/fusion_accounting_bank_rec/tests/__init__.py b/fusion_accounting_bank_rec/tests/__init__.py index 2769b296..7ef65365 100644 --- a/fusion_accounting_bank_rec/tests/__init__.py +++ b/fusion_accounting_bank_rec/tests/__init__.py @@ -1 +1,2 @@ from . import test_memo_tokenizer +from . import test_exchange_diff diff --git a/fusion_accounting_bank_rec/tests/test_exchange_diff.py b/fusion_accounting_bank_rec/tests/test_exchange_diff.py new file mode 100644 index 00000000..b28e3755 --- /dev/null +++ b/fusion_accounting_bank_rec/tests/test_exchange_diff.py @@ -0,0 +1,56 @@ +from odoo.tests.common import TransactionCase, tagged +from odoo.addons.fusion_accounting_bank_rec.services.exchange_diff import ( + compute_exchange_diff, ExchangeDiffResult, +) + + +@tagged('post_install', '-at_install') +class TestExchangeDiff(TransactionCase): + + def test_no_diff_when_currencies_match_and_rates_match(self): + result = compute_exchange_diff( + line_amount=100.00, line_currency_code='CAD', + against_amount=100.00, against_currency_code='CAD', + line_rate=1.0, against_rate=1.0, + ) + self.assertFalse(result.needs_diff_move) + self.assertEqual(result.diff_amount, 0.0) + + def test_diff_when_rates_differ_same_currency(self): + """USD invoice posted at 1.35, USD bank line settled at 1.40 -> diff exists. + 100 USD at 1.40 = 140 CAD; same at 1.35 = 135 CAD; diff = 5 CAD gain.""" + result = compute_exchange_diff( + line_amount=100.00, line_currency_code='USD', + against_amount=100.00, against_currency_code='USD', + line_rate=1.40, against_rate=1.35, + ) + self.assertTrue(result.needs_diff_move) + self.assertAlmostEqual(result.diff_amount, 5.00, places=2) + + def test_diff_negative_when_rate_dropped(self): + """USD invoice at 1.40, settled at 1.35 -> loss""" + result = compute_exchange_diff( + line_amount=100.00, line_currency_code='USD', + against_amount=100.00, against_currency_code='USD', + line_rate=1.35, against_rate=1.40, + ) + self.assertTrue(result.needs_diff_move) + self.assertAlmostEqual(result.diff_amount, -5.00, places=2) + + def test_company_amounts_computed_correctly(self): + result = compute_exchange_diff( + line_amount=100.00, line_currency_code='USD', + against_amount=100.00, against_currency_code='USD', + line_rate=1.40, against_rate=1.35, + ) + self.assertAlmostEqual(result.line_company_amount, 140.00, places=2) + self.assertAlmostEqual(result.against_company_amount, 135.00, places=2) + + def test_tolerance_handles_rounding_noise(self): + """Tiny FX rounding under 0.005 should NOT trigger a diff move.""" + result = compute_exchange_diff( + line_amount=100.00, line_currency_code='USD', + against_amount=100.00, against_currency_code='USD', + line_rate=1.40000, against_rate=1.40003, # 0.003 cent diff + ) + self.assertFalse(result.needs_diff_move)