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 : events.py6# Description : AIR v0.1 — the EconomicEvent model (the language of accounting, not journal entries).7# =============================================================================8"""AIR economic events.910An AIR document describes WHAT HAPPENED economically ("3 chairs sold to11customer X, paid by Visa, delivery pending") — never the journal entries.12Journal entries are produced later, deterministically, by the AIC compiler.13This is the core separation: LLMs understand and emit AIR; the compiler14applies the rules.1516Floats are rejected everywhere an amount, quantity, or rate appears.17"""18from __future__ import annotations1920import enum21from datetime import date, datetime22from decimal import Decimal23from typing import Annotated, Any2425from pydantic import BaseModel, BeforeValidator, ConfigDict, Field2627from core.money import Money2829AIR_VERSION = "0.1"303132def _reject_float(v: Any) -> Any:33 if isinstance(v, float):34 raise ValueError(35 "floats are forbidden for amounts/quantities/rates in AIR; "36 "use a JSON string or integer (e.g. \"19.99\")"37 )38 return v394041# Exact decimal that refuses to be built from a float.42ExactDecimal = Annotated[Decimal, BeforeValidator(_reject_float)]434445class _AirModel(BaseModel):46 model_config = ConfigDict(frozen=True, extra="forbid")474849class EventType(str, enum.Enum):50 SALE = "Sale"51 PURCHASE = "Purchase"52 REFUND = "Refund" # refund issued to a customer (reverses a Sale)53 PAYMENT_RECEIVED = "PaymentReceived" # settles a receivable54 PAYMENT_SENT = "PaymentSent" # settles a payable55 OWNER_CONTRIBUTION = "OwnerContribution"56 LOAN_RECEIVED = "LoanReceived"575859class AmountSpec(_AirModel):60 """An amount as it appears in AIR documents (exact decimal + currency)."""6162 amount: ExactDecimal63 currency: str6465 def to_money(self) -> Money:66 return Money(self.amount, self.currency)676869class LineItem(_AirModel):70 sku: str | None = None71 description: str | None = None72 qty: ExactDecimal = Decimal(1)73 unit_price: AmountSpec747576class PaymentInfo(_AirModel):77 method: str | None = None # e.g. "card.visa", "bank_transfer", "cash"78 gross: AmountSpec | None = None79 immediate: bool = False # True: cash settles at event date (no AR/AP)808182class DeliveryInfo(_AirModel):83 status: str = "pending" # pending | delivered | partial84 expected: date | None = None858687class TaxContext(_AirModel):88 """Where the supply takes place. Rates NEVER live here — they live in89 versioned ALSL policies; the compiler resolves jurisdiction → policy."""9091 jurisdiction: str # e.g. "CA-QC", "CA-ON", "CA-AB"92 codes: tuple[str, ...] = () # optional explicit tax codes, e.g. ("GST", "QST")93 exempt: bool = False # zero-rated / exempt supply949596class FxInfo(_AirModel):97 """Observed FX data attached to the event (input data, not a rule).9899 The rate is a fact about the world (e.g. Bank of Canada daily rate on the100 transaction date); it is provided by ingestion, never hardcoded in AIR/AIC.101 """102103 rate: ExactDecimal # units of functional currency per 1 unit of event currency104 source: str # e.g. "bankofcanada.valet:FXUSDCAD"105 rate_date: date106107108class SourceInfo(_AirModel):109 kind: str | None = None # invoice_pdf | email | bank_feed | pos | api | manual110 uri: str | None = None111 ocr_score: ExactDecimal | None = None112113114class LlmInfo(_AirModel):115 model: str | None = None116 confidence: ExactDecimal | None = None117 reasoning_hash: str | None = None118119120class Timestamps(_AirModel):121 ingested: datetime | None = None122 approved: datetime | None = None123124125class Meta(_AirModel):126 source: SourceInfo | None = None127 llm: LlmInfo | None = None128 policy_version: str | None = None129 timestamps: Timestamps | None = None130 approver: str | None = None131132133class EconomicEvent(_AirModel):134 """A single economic event — the atom of the AIR language."""135136 air_version: str = AIR_VERSION137 id: str # ULID/unique id, e.g. "evt_01H..."138 type: EventType139 date: date140 description: str | None = None141 parties: dict[str, str] = Field(default_factory=dict) # role -> entity id142 items: tuple[LineItem, ...] = ()143 amount: AmountSpec | None = None # for item-less events (payments, loans, ...)144 payment: PaymentInfo | None = None145 delivery: DeliveryInfo | None = None146 tax: TaxContext | None = None147 fx: FxInfo | None = None148 related_event: str | None = None # e.g. the Sale a Refund reverses149 meta: Meta | None = None150151 def currency(self) -> str | None:152 """The event's transaction currency, inferred from its amounts."""153 if self.items:154 return self.items[0].unit_price.currency155 if self.amount is not None:156 return self.amount.currency157 if self.payment is not None and self.payment.gross is not None:158 return self.payment.gross.currency159 return None160161 def subtotal(self) -> Money | None:162 """Sum of item lines (unrounded), or the flat amount if item-less."""163 if self.items:164 total: Money | None = None165 for item in self.items:166 line = item.unit_price.to_money().multiply(item.qty)167 total = line if total is None else total + line168 return total169 if self.amount is not None:170 return self.amount.to_money()171 return None172173174class AirDocument(_AirModel):175 """A batch of economic events (the compilation unit's input)."""176177 air_version: str = AIR_VERSION178 events: tuple[EconomicEvent, ...] = ()179