SPB Git

spb/air Public MIT

AIR — The Language of Accounting.

Python 100%
5.8 KB · 179 lines python
Raw Blame History
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