# ============================================================================= # Projet : AIR — Accounting Intermediate Representation # Auteur : Simon-Pierre Boucher # Contact : contact@spboucher.ai # Fichier : fx.py # Description : FX pass — converts to the functional currency; computes settlement gains/losses. # ============================================================================= """FX pass (IAS 21 semantics — see docs/research/accounting-standards.md and docs/research/fx-handling.md). - Transactions in the functional currency pass through (quantized for posting). - Foreign-currency transactions are converted at the event's observed rate (a fact provided by ingestion, e.g. a Bank of Canada Valet daily rate — never a constant in code). - Foreign-currency settlements (payments) compare the settlement rate to the booking rate of the related event and realize an FX gain or loss. Every conversion is a provenance node: "fx:USD->CAD@1.3500". """ from __future__ import annotations from aic.diagnostics import Diagnostic, Severity from aic.passes.base import Pass from aic.unit import CompilationUnit, TaxLineState from core.events import EconomicEvent, EventType from core.money import Money SETTLEMENT_TYPES = (EventType.PAYMENT_RECEIVED, EventType.PAYMENT_SENT) class FxPass(Pass): name = "fx" def run(self, unit: CompilationUnit) -> list[Diagnostic]: diags: list[Diagnostic] = [] functional = unit.policies.functional_currency rounding = unit.policies.rounding.mode for event in unit.document.events: state = unit.event_state(event.id) if state.subtotal is None or state.subtotal_node is None: continue ccy = state.subtotal.currency if ccy == functional: state.post_subtotal = state.subtotal.quantized(rounding) state.post_subtotal_node = state.subtotal_node state.post_taxes = list(state.taxes) continue if event.fx is None: diags.append(Diagnostic( code="AIR-E500", severity=Severity.ERROR, message=( f"event is in {ccy} but functional currency is {functional} " "and no fx rate is provided" ), location=f"event {event.id}, field fx", suggestion="attach fx: {rate, source, rate_date} observed on the " "transaction date (e.g. Bank of Canada Valet)", origin_pass=self.name, )) continue rate = event.fx.rate op = f"fx:{ccy}->{functional}@{rate}" converted = Money( state.subtotal.amount * rate, functional ).quantized(rounding) node = unit.provenance.define( kind="fx", operation=op, amount=converted, inputs=(state.subtotal_node,), source_ref=event.fx.source, ) state.post_subtotal = converted state.post_subtotal_node = node.id for tax in state.taxes: tax_converted = Money( tax.amount.amount * rate, functional ).quantized(rounding) tax_node = unit.provenance.define( kind="fx", operation=op, amount=tax_converted, inputs=(tax.node_id,), source_ref=event.fx.source, ) state.post_taxes.append(TaxLineState( code=tax.code, amount=tax_converted, node_id=tax_node.id, payable_role=tax.payable_role, receivable_role=tax.receivable_role, recoverable=tax.recoverable, policy=tax.policy, )) if event.type in SETTLEMENT_TYPES: diags.extend(self._settlement_diff(unit, event, rate, functional)) return diags def _settlement_diff( self, unit: CompilationUnit, event: EconomicEvent, settle_rate: object, functional: str, ) -> list[Diagnostic]: """Realized FX gain/loss: settlement rate vs the related event's booking rate.""" state = unit.event_state(event.id) rounding = unit.policies.rounding.mode if event.related_event is None: return [Diagnostic( code="AIR-E501", severity=Severity.ERROR, message="foreign-currency settlement without related_event: " "the booking rate cannot be determined", location=f"event {event.id}, field related_event", suggestion="reference the Sale/Purchase this payment settles", origin_pass=self.name, )] origin = unit.find_event(event.related_event) if origin is None or origin.fx is None: return [Diagnostic( code="AIR-E502", severity=Severity.ERROR, message=( f"related event '{event.related_event}' not found in this " "document or carries no fx rate" ), location=f"event {event.id}, field related_event", suggestion="compile the settlement together with its origin event, " "or attach the origin's booking rate", origin_pass=self.name, )] assert state.subtotal is not None and state.subtotal_node is not None booked = Money( state.subtotal.amount * origin.fx.rate, functional ).quantized(rounding) settled = state.post_subtotal assert settled is not None and state.post_subtotal_node is not None diff = settled - booked state.booked_amount = booked state.fx_diff = diff if not diff.is_zero(): node = unit.provenance.define( kind="fx_realized", operation=( f"fx_realized:{state.subtotal.currency}->{functional}" f"@{settle_rate}-vs-@{origin.fx.rate}" ), amount=diff, inputs=(state.post_subtotal_node,), source_ref=event.related_event, ) state.fx_diff_node = node.id return []