SPB Git

spb/air Public MIT

AIR — The Language of Accounting.

Python 100%
6.2 KB · 149 lines python
Raw Blame History
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