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 : test_core.py6# Description : Unit tests — money, provenance, ALSL strictness, diagnostics, ledger, backends.7# =============================================================================8"""Unit tests for the non-golden guarantees: float rejection, SSA provenance,9ALSL loader strictness, clang-style diagnostics, the hash-chained ledger,10reversal semantics, and the CSV/native backends."""11from __future__ import annotations1213import json14from datetime import date15from decimal import Decimal16from pathlib import Path1718import pytest1920from aic.compiler import compile_document21from aic.diagnostics import CompilationError22from alsl.loader import AlslLoadError, load_policy_set23from backends.generic_csv.backend import GenericCsvBackend24from backends.native.backend import NativeLedgerBackend25from core.events import AirDocument26from core.money import CurrencyMismatchError, Money, RoundingMode, money27from kernel.ledger import Ledger28from kernel.reporting import balance_sheet, income_statement, trial_balance2930ROOT = Path(__file__).resolve().parents[1]31POLICY_FILE = ROOT / "alsl" / "policies" / "ca-qc-2026.yaml"323334# --- Money -------------------------------------------------------------------35def test_money_rejects_floats() -> None:36 with pytest.raises(TypeError):37 Money(19.99, "CAD") # type: ignore[arg-type]38 with pytest.raises(TypeError):39 money("10.00", "CAD").multiply(1.05) # type: ignore[arg-type]404142def test_money_currency_mismatch() -> None:43 with pytest.raises(CurrencyMismatchError):44 money("1", "CAD") + money("1", "USD")454647def test_rounding_modes_differ_on_ties() -> None:48 half = money("0.125", "CAD")49 assert half.quantized(RoundingMode.HALF_UP).amount == Decimal("0.13")50 assert half.quantized(RoundingMode.HALF_EVEN).amount == Decimal("0.12")515253def test_air_schema_rejects_float_amounts() -> None:54 with pytest.raises(Exception):55 AirDocument.model_validate({56 "events": [{57 "id": "evt_f", "type": "Sale", "date": date(2026, 1, 1),58 "amount": {"amount": 19.99, "currency": "CAD"},59 }]60 })616263# --- ALSL strictness -----------------------------------------------------------64def test_alsl_rejects_uncited_tax_policy(tmp_path: Path) -> None:65 bad = tmp_path / "bad.yaml"66 bad.write_text(67 "alsl_version: \"0.1\"\npolicy_set: bad\nversion: \"1\"\n"68 "policies:\n - name: mystery_tax\n kind: tax\n"69 " when: {jurisdiction: CA-QC}\n"70 " apply:\n - {code: GST, rate: \"0.05\"}\n",71 encoding="utf-8",72 )73 with pytest.raises(AlslLoadError, match="cite a source"):74 load_policy_set(bad)757677def test_alsl_rejects_float_rates(tmp_path: Path) -> None:78 bad = tmp_path / "bad.yaml"79 bad.write_text(80 "alsl_version: \"0.1\"\npolicy_set: bad\nversion: \"1\"\n"81 "policies:\n - name: float_tax\n kind: tax\n"82 " source: docs/research/canada-gst-qst.md\n"83 " when: {jurisdiction: CA-QC}\n"84 " apply:\n - {code: GST, rate: 0.05}\n",85 encoding="utf-8",86 )87 with pytest.raises(AlslLoadError, match="float"):88 load_policy_set(bad)899091# --- Diagnostics -----------------------------------------------------------------92def test_unknown_jurisdiction_is_a_precise_error() -> None:93 policies = load_policy_set(POLICY_FILE)94 document = AirDocument.model_validate({95 "events": [{96 "id": "evt_bc", "type": "Sale", "date": date(2026, 1, 1),97 "amount": {"amount": "100.00", "currency": "CAD"},98 "tax": {"jurisdiction": "CA-BC"},99 }]100 })101 with pytest.raises(CompilationError) as exc:102 compile_document(document, policies)103 codes = [d.code for d in exc.value.diagnostics]104 assert "AIR-E400" in codes105 rendered = "\n".join(d.render() for d in exc.value.diagnostics)106 assert "CA-BC" in rendered and "help:" in rendered107108109def test_missing_fx_rate_is_a_precise_error() -> None:110 policies = load_policy_set(POLICY_FILE)111 document = AirDocument.model_validate({112 "events": [{113 "id": "evt_usd", "type": "Sale", "date": date(2026, 1, 1),114 "amount": {"amount": "100.00", "currency": "USD"},115 "tax": {"jurisdiction": "CA-QC", "exempt": True},116 }]117 })118 with pytest.raises(CompilationError) as exc:119 compile_document(document, policies)120 assert any(d.code == "AIR-E500" for d in exc.value.diagnostics)121122123# --- helpers ---------------------------------------------------------------------124def _demo_journal():125 policies = load_policy_set(POLICY_FILE)126 document = AirDocument.model_validate({127 "events": [128 {"id": "evt_own", "type": "OwnerContribution",129 "date": date(2026, 1, 1),130 "amount": {"amount": "10000.00", "currency": "CAD"}},131 {"id": "evt_sale", "type": "Sale", "date": date(2026, 1, 10),132 "amount": {"amount": "1000.00", "currency": "CAD"},133 "tax": {"jurisdiction": "CA-QC"}},134 {"id": "evt_buy", "type": "Purchase", "date": date(2026, 1, 12),135 "amount": {"amount": "400.00", "currency": "CAD"},136 "tax": {"jurisdiction": "CA-QC"}},137 ]138 })139 journal, _ = compile_document(document, policies)140 return journal141142143# --- Ledger (native, hash-chained) --------------------------------------------------144def test_ledger_hash_chain_and_idempotency(tmp_path: Path) -> None:145 journal = _demo_journal()146 ledger = Ledger(path=tmp_path / "ledger.jsonl")147 assert ledger.post_journal(journal, "key-1") == 3148 assert ledger.post_journal(journal, "key-1") == 0 # idempotent replay149 assert ledger.verify_chain()150151 # reload from disk: same entries, chain still valid152 reloaded = Ledger(path=tmp_path / "ledger.jsonl")153 assert len(reloaded.entries) == 3154 assert reloaded.verify_chain()155156 # tampering is detected157 lines = (tmp_path / "ledger.jsonl").read_text().splitlines()158 record = json.loads(lines[0])159 record["lines"][0]["amount"] = "9999.99"160 lines[0] = json.dumps(record, sort_keys=True, separators=(",", ":"))161 (tmp_path / "ledger.jsonl").write_text("\n".join(lines) + "\n")162 assert not Ledger(path=tmp_path / "ledger.jsonl").verify_chain()163164165def test_ledger_reversal_nets_to_zero() -> None:166 journal = _demo_journal()167 ledger = Ledger()168 ledger.post_journal(journal, "key-1")169 for entry in list(ledger.entries):170 ledger.reverse_entry(entry.id, f"rev:{entry.id}")171 for per_ccy in ledger.balances().values():172 for balance in per_ccy.values():173 assert balance == 0174175176# --- Reporting -------------------------------------------------------------------177def test_reports_balance_and_render() -> None:178 journal = _demo_journal()179 ledger = Ledger()180 ledger.post_journal(journal, "key-1")181182 tb = trial_balance(ledger, "csv")183 total_row = [r for r in tb.splitlines() if r.startswith("TOTAL")][0]184 cells = total_row.split(",")185 assert cells[-2] == cells[-1] # debits == credits186187 inc = income_statement(ledger, "json")188 data = json.loads(inc)189 net = [r for r in data["rows"] if r["section"] == "NET INCOME"][0]190 assert Decimal(net["amount"]) == Decimal("600.00") # 1000 revenue - 400 expense191192 bs = balance_sheet(ledger, "markdown")193 lines = [l for l in bs.splitlines() if "TOTAL" in l]194 assets = [l for l in lines if "TOTAL ASSETS" in l][0].split("|")[-2].strip()195 liabeq = [l for l in lines if "TOTAL LIAB." in l][0].split("|")[-2].strip()196 assert assets == liabeq # Assets = Liabilities + Equity (+ net income)197198199# --- Backends ----------------------------------------------------------------------200def test_csv_backend_post_and_reverse(tmp_path: Path) -> None:201 journal = _demo_journal()202 backend = GenericCsvBackend(tmp_path)203 receipt = backend.post(backend.compile(journal), "abc123")204 content = Path(receipt.reference).read_text()205 assert "je_evt_sale" in content and "1149.75" in content206207 reversal = backend.reverse(receipt)208 rev = Path(reversal.reference).read_text()209 assert "rev_je_evt_sale" in rev and "REVERSAL:" in rev210 # sides swapped: original AR debit becomes credit211 orig_line = [l for l in content.splitlines() if "1149.75" in l][0]212 rev_line = [l for l in rev.splitlines() if "1149.75" in l][0]213 assert "debit" in orig_line and "credit" in rev_line214215216def test_native_backend_roundtrip(tmp_path: Path) -> None:217 journal = _demo_journal()218 backend = NativeLedgerBackend(tmp_path / "books.jsonl")219 receipt = backend.post(backend.compile(journal), "batch-1")220 assert receipt.details["entries_appended"] == "3"221 assert backend.ledger.verify_chain()222