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 : 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