SPB Git

spb/air Public MIT

AIR — The Language of Accounting.

Python 100%
4.5 KB · 110 lines python
Raw Blame History
1# =============================================================================2# Projet : AIR — Accounting Intermediate Representation3# Auteur : Simon-Pierre Boucher4# Contact : contact@spboucher.ai5# Fichier : validation.py6# Description : Validation pass — structural checks + subtotal provenance definitions.7# =============================================================================8"""Validation pass: the front door of the compiler.910Checks structural well-formedness of the AIR document and defines each11event's subtotal in the provenance graph (the SSA origin every later12derivation points back to).13"""14from __future__ import annotations1516from aic.diagnostics import Diagnostic, Severity17from aic.passes.base import Pass18from aic.unit import CompilationUnit19from core.events import EventType2021TAXABLE_TYPES = (EventType.SALE, EventType.PURCHASE, EventType.REFUND)222324class ValidationPass(Pass):25    name = "validation"2627    def run(self, unit: CompilationUnit) -> list[Diagnostic]:28        diags: list[Diagnostic] = []29        seen: set[str] = set()3031        for event in unit.document.events:32            loc = f"event {event.id}"3334            if event.id in seen:35                diags.append(Diagnostic(36                    code="AIR-E200", severity=Severity.ERROR,37                    message=f"duplicate event id '{event.id}'",38                    location=loc,39                    suggestion="event ids must be unique within a document (use ULIDs)",40                    origin_pass=self.name,41                ))42                continue43            seen.add(event.id)4445            # single transaction currency per event46            currencies = {i.unit_price.currency for i in event.items}47            if event.amount is not None:48                currencies.add(event.amount.currency)49            if len(currencies) > 1:50                diags.append(Diagnostic(51                    code="AIR-E201", severity=Severity.ERROR,52                    message=f"mixed currencies in one event: {sorted(currencies)}",53                    location=f"{loc}, field items[].unit_price",54                    suggestion="split into one event per currency",55                    origin_pass=self.name,56                ))57                continue5859            subtotal = event.subtotal()60            if subtotal is None:61                diags.append(Diagnostic(62                    code="AIR-E202", severity=Severity.ERROR,63                    message="event has no amount: neither items nor a flat amount",64                    location=f"{loc}, fields items / amount",65                    suggestion="provide items[] with unit prices, or a flat amount",66                    origin_pass=self.name,67                ))68                continue69            if subtotal.is_negative():70                diags.append(Diagnostic(71                    code="AIR-E203", severity=Severity.ERROR,72                    message=f"negative subtotal {subtotal}",73                    location=f"{loc}, field items/amount",74                    suggestion="amounts are positive; direction comes from the event type "75                               "(use Refund instead of a negative Sale)",76                    origin_pass=self.name,77                ))78                continue7980            if event.type in TAXABLE_TYPES and event.tax is None:81                diags.append(Diagnostic(82                    code="AIR-W200", severity=Severity.WARNING,83                    message=f"{event.type.value} event has no tax context; "84                            "it will compile untaxed",85                    location=f"{loc}, field tax",86                    suggestion="set tax.jurisdiction (e.g. 'CA-QC') or tax.exempt: true",87                    origin_pass=self.name,88                ))8990            if event.type is EventType.REFUND and event.related_event is None:91                diags.append(Diagnostic(92                    code="AIR-W201", severity=Severity.WARNING,93                    message="Refund does not reference the Sale it reverses",94                    location=f"{loc}, field related_event",95                    suggestion="set related_event to the original Sale id for traceability",96                    origin_pass=self.name,97                ))9899            state = unit.event_state(event.id)100            state.subtotal = subtotal101            node = unit.provenance.define(102                kind="event_subtotal",103                operation=f"subtotal:{event.type.value}",104                amount=subtotal,105                source_ref=event.id,106            )107            state.subtotal_node = node.id108109        return diags110