# ============================================================================= # Projet : AIR — Accounting Intermediate Representation # Auteur : Simon-Pierre Boucher # Contact : contact@spboucher.ai # Fichier : validation.py # Description : Validation pass — structural checks + subtotal provenance definitions. # ============================================================================= """Validation pass: the front door of the compiler. Checks structural well-formedness of the AIR document and defines each event's subtotal in the provenance graph (the SSA origin every later derivation points back to). """ from __future__ import annotations from aic.diagnostics import Diagnostic, Severity from aic.passes.base import Pass from aic.unit import CompilationUnit from core.events import EventType TAXABLE_TYPES = (EventType.SALE, EventType.PURCHASE, EventType.REFUND) class ValidationPass(Pass): name = "validation" def run(self, unit: CompilationUnit) -> list[Diagnostic]: diags: list[Diagnostic] = [] seen: set[str] = set() for event in unit.document.events: loc = f"event {event.id}" if event.id in seen: diags.append(Diagnostic( code="AIR-E200", severity=Severity.ERROR, message=f"duplicate event id '{event.id}'", location=loc, suggestion="event ids must be unique within a document (use ULIDs)", origin_pass=self.name, )) continue seen.add(event.id) # single transaction currency per event currencies = {i.unit_price.currency for i in event.items} if event.amount is not None: currencies.add(event.amount.currency) if len(currencies) > 1: diags.append(Diagnostic( code="AIR-E201", severity=Severity.ERROR, message=f"mixed currencies in one event: {sorted(currencies)}", location=f"{loc}, field items[].unit_price", suggestion="split into one event per currency", origin_pass=self.name, )) continue subtotal = event.subtotal() if subtotal is None: diags.append(Diagnostic( code="AIR-E202", severity=Severity.ERROR, message="event has no amount: neither items nor a flat amount", location=f"{loc}, fields items / amount", suggestion="provide items[] with unit prices, or a flat amount", origin_pass=self.name, )) continue if subtotal.is_negative(): diags.append(Diagnostic( code="AIR-E203", severity=Severity.ERROR, message=f"negative subtotal {subtotal}", location=f"{loc}, field items/amount", suggestion="amounts are positive; direction comes from the event type " "(use Refund instead of a negative Sale)", origin_pass=self.name, )) continue if event.type in TAXABLE_TYPES and event.tax is None: diags.append(Diagnostic( code="AIR-W200", severity=Severity.WARNING, message=f"{event.type.value} event has no tax context; " "it will compile untaxed", location=f"{loc}, field tax", suggestion="set tax.jurisdiction (e.g. 'CA-QC') or tax.exempt: true", origin_pass=self.name, )) if event.type is EventType.REFUND and event.related_event is None: diags.append(Diagnostic( code="AIR-W201", severity=Severity.WARNING, message="Refund does not reference the Sale it reverses", location=f"{loc}, field related_event", suggestion="set related_event to the original Sale id for traceability", origin_pass=self.name, )) state = unit.event_state(event.id) state.subtotal = subtotal node = unit.provenance.define( kind="event_subtotal", operation=f"subtotal:{event.type.value}", amount=subtotal, source_ref=event.id, ) state.subtotal_node = node.id return diags