spb/air Public MIT
AIR — The Language of Accounting.
Python 100%
1# =============================================================================2# Projet : AIR — Accounting Intermediate Representation3# Auteur : Simon-Pierre Boucher4# Contact : contact@spboucher.ai5# Fichier : fx.py6# Description : FX pass — converts to the functional currency; computes settlement gains/losses.7# =============================================================================8"""FX pass (IAS 21 semantics — see docs/research/accounting-standards.md and9docs/research/fx-handling.md).1011- Transactions in the functional currency pass through (quantized for posting).12- Foreign-currency transactions are converted at the event's observed rate13 (a fact provided by ingestion, e.g. a Bank of Canada Valet daily rate —14 never a constant in code).15- Foreign-currency settlements (payments) compare the settlement rate to the16 booking rate of the related event and realize an FX gain or loss.17Every conversion is a provenance node: "fx:USD->CAD@1.3500".18"""19from __future__ import annotations2021from aic.diagnostics import Diagnostic, Severity22from aic.passes.base import Pass23from aic.unit import CompilationUnit, TaxLineState24from core.events import EconomicEvent, EventType25from core.money import Money2627SETTLEMENT_TYPES = (EventType.PAYMENT_RECEIVED, EventType.PAYMENT_SENT)282930class FxPass(Pass):31 name = "fx"3233 def run(self, unit: CompilationUnit) -> list[Diagnostic]:34 diags: list[Diagnostic] = []35 functional = unit.policies.functional_currency36 rounding = unit.policies.rounding.mode3738 for event in unit.document.events:39 state = unit.event_state(event.id)40 if state.subtotal is None or state.subtotal_node is None:41 continue42 ccy = state.subtotal.currency4344 if ccy == functional:45 state.post_subtotal = state.subtotal.quantized(rounding)46 state.post_subtotal_node = state.subtotal_node47 state.post_taxes = list(state.taxes)48 continue4950 if event.fx is None:51 diags.append(Diagnostic(52 code="AIR-E500", severity=Severity.ERROR,53 message=(54 f"event is in {ccy} but functional currency is {functional} "55 "and no fx rate is provided"56 ),57 location=f"event {event.id}, field fx",58 suggestion="attach fx: {rate, source, rate_date} observed on the "59 "transaction date (e.g. Bank of Canada Valet)",60 origin_pass=self.name,61 ))62 continue6364 rate = event.fx.rate65 op = f"fx:{ccy}->{functional}@{rate}"6667 converted = Money(68 state.subtotal.amount * rate, functional69 ).quantized(rounding)70 node = unit.provenance.define(71 kind="fx", operation=op, amount=converted,72 inputs=(state.subtotal_node,), source_ref=event.fx.source,73 )74 state.post_subtotal = converted75 state.post_subtotal_node = node.id7677 for tax in state.taxes:78 tax_converted = Money(79 tax.amount.amount * rate, functional80 ).quantized(rounding)81 tax_node = unit.provenance.define(82 kind="fx", operation=op, amount=tax_converted,83 inputs=(tax.node_id,), source_ref=event.fx.source,84 )85 state.post_taxes.append(TaxLineState(86 code=tax.code, amount=tax_converted, node_id=tax_node.id,87 payable_role=tax.payable_role,88 receivable_role=tax.receivable_role,89 recoverable=tax.recoverable, policy=tax.policy,90 ))9192 if event.type in SETTLEMENT_TYPES:93 diags.extend(self._settlement_diff(unit, event, rate, functional))94 return diags9596 def _settlement_diff(97 self, unit: CompilationUnit, event: EconomicEvent,98 settle_rate: object, functional: str,99 ) -> list[Diagnostic]:100 """Realized FX gain/loss: settlement rate vs the related event's booking rate."""101 state = unit.event_state(event.id)102 rounding = unit.policies.rounding.mode103104 if event.related_event is None:105 return [Diagnostic(106 code="AIR-E501", severity=Severity.ERROR,107 message="foreign-currency settlement without related_event: "108 "the booking rate cannot be determined",109 location=f"event {event.id}, field related_event",110 suggestion="reference the Sale/Purchase this payment settles",111 origin_pass=self.name,112 )]113 origin = unit.find_event(event.related_event)114 if origin is None or origin.fx is None:115 return [Diagnostic(116 code="AIR-E502", severity=Severity.ERROR,117 message=(118 f"related event '{event.related_event}' not found in this "119 "document or carries no fx rate"120 ),121 location=f"event {event.id}, field related_event",122 suggestion="compile the settlement together with its origin event, "123 "or attach the origin's booking rate",124 origin_pass=self.name,125 )]126127 assert state.subtotal is not None and state.subtotal_node is not None128 booked = Money(129 state.subtotal.amount * origin.fx.rate, functional130 ).quantized(rounding)131 settled = state.post_subtotal132 assert settled is not None and state.post_subtotal_node is not None133 diff = settled - booked134 state.booked_amount = booked135 state.fx_diff = diff136 if not diff.is_zero():137 node = unit.provenance.define(138 kind="fx_realized",139 operation=(140 f"fx_realized:{state.subtotal.currency}->{functional}"141 f"@{settle_rate}-vs-@{origin.fx.rate}"142 ),143 amount=diff,144 inputs=(state.post_subtotal_node,),145 source_ref=event.related_event,146 )147 state.fx_diff_node = node.id148 return []149