# ============================================================================= # Projet : AIR — Accounting Intermediate Representation # Auteur : Simon-Pierre Boucher # Contact : contact@spboucher.ai # Fichier : events.py # Description : AIR v0.1 — the EconomicEvent model (the language of accounting, not journal entries). # ============================================================================= """AIR economic events. An AIR document describes WHAT HAPPENED economically ("3 chairs sold to customer X, paid by Visa, delivery pending") — never the journal entries. Journal entries are produced later, deterministically, by the AIC compiler. This is the core separation: LLMs understand and emit AIR; the compiler applies the rules. Floats are rejected everywhere an amount, quantity, or rate appears. """ from __future__ import annotations import enum from datetime import date, datetime from decimal import Decimal from typing import Annotated, Any from pydantic import BaseModel, BeforeValidator, ConfigDict, Field from core.money import Money AIR_VERSION = "0.1" def _reject_float(v: Any) -> Any: if isinstance(v, float): raise ValueError( "floats are forbidden for amounts/quantities/rates in AIR; " "use a JSON string or integer (e.g. \"19.99\")" ) return v # Exact decimal that refuses to be built from a float. ExactDecimal = Annotated[Decimal, BeforeValidator(_reject_float)] class _AirModel(BaseModel): model_config = ConfigDict(frozen=True, extra="forbid") class EventType(str, enum.Enum): SALE = "Sale" PURCHASE = "Purchase" REFUND = "Refund" # refund issued to a customer (reverses a Sale) PAYMENT_RECEIVED = "PaymentReceived" # settles a receivable PAYMENT_SENT = "PaymentSent" # settles a payable OWNER_CONTRIBUTION = "OwnerContribution" LOAN_RECEIVED = "LoanReceived" class AmountSpec(_AirModel): """An amount as it appears in AIR documents (exact decimal + currency).""" amount: ExactDecimal currency: str def to_money(self) -> Money: return Money(self.amount, self.currency) class LineItem(_AirModel): sku: str | None = None description: str | None = None qty: ExactDecimal = Decimal(1) unit_price: AmountSpec class PaymentInfo(_AirModel): method: str | None = None # e.g. "card.visa", "bank_transfer", "cash" gross: AmountSpec | None = None immediate: bool = False # True: cash settles at event date (no AR/AP) class DeliveryInfo(_AirModel): status: str = "pending" # pending | delivered | partial expected: date | None = None class TaxContext(_AirModel): """Where the supply takes place. Rates NEVER live here — they live in versioned ALSL policies; the compiler resolves jurisdiction → policy.""" jurisdiction: str # e.g. "CA-QC", "CA-ON", "CA-AB" codes: tuple[str, ...] = () # optional explicit tax codes, e.g. ("GST", "QST") exempt: bool = False # zero-rated / exempt supply class FxInfo(_AirModel): """Observed FX data attached to the event (input data, not a rule). The rate is a fact about the world (e.g. Bank of Canada daily rate on the transaction date); it is provided by ingestion, never hardcoded in AIR/AIC. """ rate: ExactDecimal # units of functional currency per 1 unit of event currency source: str # e.g. "bankofcanada.valet:FXUSDCAD" rate_date: date class SourceInfo(_AirModel): kind: str | None = None # invoice_pdf | email | bank_feed | pos | api | manual uri: str | None = None ocr_score: ExactDecimal | None = None class LlmInfo(_AirModel): model: str | None = None confidence: ExactDecimal | None = None reasoning_hash: str | None = None class Timestamps(_AirModel): ingested: datetime | None = None approved: datetime | None = None class Meta(_AirModel): source: SourceInfo | None = None llm: LlmInfo | None = None policy_version: str | None = None timestamps: Timestamps | None = None approver: str | None = None class EconomicEvent(_AirModel): """A single economic event — the atom of the AIR language.""" air_version: str = AIR_VERSION id: str # ULID/unique id, e.g. "evt_01H..." type: EventType date: date description: str | None = None parties: dict[str, str] = Field(default_factory=dict) # role -> entity id items: tuple[LineItem, ...] = () amount: AmountSpec | None = None # for item-less events (payments, loans, ...) payment: PaymentInfo | None = None delivery: DeliveryInfo | None = None tax: TaxContext | None = None fx: FxInfo | None = None related_event: str | None = None # e.g. the Sale a Refund reverses meta: Meta | None = None def currency(self) -> str | None: """The event's transaction currency, inferred from its amounts.""" if self.items: return self.items[0].unit_price.currency if self.amount is not None: return self.amount.currency if self.payment is not None and self.payment.gross is not None: return self.payment.gross.currency return None def subtotal(self) -> Money | None: """Sum of item lines (unrounded), or the flat amount if item-less.""" if self.items: total: Money | None = None for item in self.items: line = item.unit_price.to_money().multiply(item.qty) total = line if total is None else total + line return total if self.amount is not None: return self.amount.to_money() return None class AirDocument(_AirModel): """A batch of economic events (the compilation unit's input).""" air_version: str = AIR_VERSION events: tuple[EconomicEvent, ...] = ()