SPB Git

spb/air Public MIT

AIR — The Language of Accounting.

Python 100%
5.2 KB · 145 lines python
Raw Blame History
1# =============================================================================2# Projet : AIR — Accounting Intermediate Representation3# Auteur : Simon-Pierre Boucher4# Contact : contact@spboucher.ai5# Fichier : loader.py6# Description : ALSL v0.1 YAML loader — strict parsing, floats and uncited rates rejected.7# =============================================================================8"""Load ALSL v0.1 policy sets from YAML.910Strictness rules:11- Rates, thresholds and any decimal value MUST be YAML strings ("0.05"),12  never bare numbers — bare YAML floats lose precision and are rejected.13- Every tax policy MUST cite a source (research doc or official URL).14"""15from __future__ import annotations1617from decimal import Decimal18from pathlib import Path19from typing import Any2021import yaml2223from alsl.model import (24    ALSL_VERSION,25    ClassificationPolicy,26    PolicySet,27    RoundingPolicy,28    TaxComponent,29    TaxPolicy,30    WhenClause,31    parse_account_type,32)33from core.journal import Account34from core.money import RoundingMode353637class AlslLoadError(ValueError):38    """Raised when a policy file violates ALSL v0.1 rules."""394041def _decimal(value: Any, context: str) -> Decimal:42    if isinstance(value, float):43        raise AlslLoadError(44            f"{context}: decimal values must be YAML strings (e.g. \"0.05\"), "45            f"got float {value!r} — floats are forbidden in ALSL"46        )47    if isinstance(value, (int, str)):48        return Decimal(str(value))49    raise AlslLoadError(f"{context}: cannot parse decimal from {value!r}")505152def _when(raw: dict[str, Any] | None, context: str) -> WhenClause:53    raw = raw or {}54    jurisdiction_in = raw.get("jurisdiction_in")55    return WhenClause(56        jurisdiction=raw.get("jurisdiction"),57        jurisdiction_in=tuple(jurisdiction_in) if jurisdiction_in else None,58        event_type=raw.get("event_type"),59        min_amount=(60            _decimal(raw["min_amount"], context) if "min_amount" in raw else None61        ),62        currency=raw.get("currency"),63    )646566def load_policy_set(path: str | Path) -> PolicySet:67    path = Path(path)68    data = yaml.safe_load(path.read_text(encoding="utf-8"))69    if not isinstance(data, dict):70        raise AlslLoadError(f"{path}: not a mapping")7172    version = str(data.get("alsl_version", ""))73    if version != ALSL_VERSION:74        raise AlslLoadError(75            f"{path}: alsl_version {version!r} unsupported (expected {ALSL_VERSION!r})"76        )7778    accounts: dict[str, Account] = {}79    for role, spec in (data.get("accounts") or {}).items():80        accounts[role] = Account(81            code=str(spec["code"]),82            name=str(spec["name"]),83            type=parse_account_type(str(spec["type"])),84        )8586    rounding_raw = data.get("rounding") or {}87    rounding = RoundingPolicy(88        mode=RoundingMode(rounding_raw.get("mode", "half_up")),89        source=str(rounding_raw.get("source", "")),90    )9192    tax_policies: list[TaxPolicy] = []93    classification_policies: list[ClassificationPolicy] = []94    for raw in data.get("policies") or []:95        name = str(raw.get("name", "<unnamed>"))96        kind = raw.get("kind")97        ctx = f"{path}: policy '{name}'"98        if kind == "tax":99            source = str(raw.get("source", "")).strip()100            if not source:101                raise AlslLoadError(102                    f"{ctx}: tax policies must cite a source (research doc or "103                    "official URL); uncited rates are forbidden"104                )105            components = tuple(106                TaxComponent(107                    code=str(c["code"]),108                    rate=_decimal(c["rate"], f"{ctx} component {c.get('code')}"),109                    base=str(c.get("base", "subtotal")),110                    payable_role=str(c.get("payable_role", "")),111                    receivable_role=str(c.get("receivable_role", "")),112                    recoverable_on_purchase=bool(c.get("recoverable_on_purchase", True)),113                )114                for c in raw.get("apply") or []115            )116            if not components:117                raise AlslLoadError(f"{ctx}: tax policy applies no components")118            tax_policies.append(TaxPolicy(119                name=name, when=_when(raw.get("when"), ctx),120                components=components, source=source,121            ))122        elif kind == "classification":123            then = raw.get("then") or {}124            classification_policies.append(ClassificationPolicy(125                name=name,126                when=_when(raw.get("when"), ctx),127                classify_as=str(then.get("classify", "expense")),128                account_role=str(then.get("account_role", "")),129                source=str(raw.get("source", "")),130            ))131        else:132            raise AlslLoadError(f"{ctx}: unknown policy kind {kind!r}")133134    return PolicySet(135        alsl_version=version,136        name=str(data.get("policy_set", path.stem)),137        version=str(data.get("version", "")),138        description=str(data.get("description", "")),139        functional_currency=str(data.get("functional_currency", "CAD")),140        rounding=rounding,141        accounts=accounts,142        tax_policies=tuple(tax_policies),143        classification_policies=tuple(classification_policies),144    )145