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