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_balance_invariant.py6# Description : Property-based tests — the double-entry invariant holds for ALL inputs.7# =============================================================================8"""Property-based tests (hypothesis).910For arbitrary well-formed AIR documents:111. every compiled entry balances (sum debits == sum credits per currency);122. the accounting equation residual is zero;133. compilation is deterministic (same input => identical journal).14"""15from __future__ import annotations1617from datetime import date18from decimal import Decimal19from pathlib import Path2021from hypothesis import given, settings22from hypothesis import strategies as st2324from aic.compiler import compile_document25from alsl.loader import load_policy_set26from core.events import AirDocument27from core.invariant import accounting_equation_residual, entry_imbalances2829ROOT = Path(__file__).resolve().parents[2]30POLICIES = load_policy_set(ROOT / "alsl" / "policies" / "ca-qc-2026.yaml")3132# amounts: positive, exact 2-decimal values up to 10 million33amounts = st.integers(min_value=1, max_value=1_000_000_000).map(34 lambda cents: str(Decimal(cents) / 100)35)36jurisdictions = st.sampled_from(["CA-QC", "CA-ON", "CA-AB"])373839@st.composite40def events(draw, index: int = 0):41 etype = draw(st.sampled_from(42 ["Sale", "Purchase", "Refund", "OwnerContribution", "LoanReceived",43 "PaymentReceived", "PaymentSent"]44 ))45 event: dict = {46 "id": f"evt_{draw(st.integers(min_value=0, max_value=10**9))}_{index}",47 "type": etype,48 "date": date(2026, 7, 15),49 "amount": {"amount": draw(amounts), "currency": "CAD"},50 }51 if etype in ("Sale", "Purchase", "Refund"):52 event["tax"] = {53 "jurisdiction": draw(jurisdictions),54 "exempt": draw(st.booleans()),55 }56 event["payment"] = {"immediate": draw(st.booleans())}57 return event585960documents = st.lists(events(), min_size=1, max_size=8).map(61 lambda evs: {62 "events": [63 {**e, "id": f"{e['id']}_{i}"} for i, e in enumerate(evs)64 ]65 }66)676869@given(documents)70@settings(max_examples=200, deadline=None)71def test_every_entry_balances_and_equation_holds(doc_raw) -> None:72 document = AirDocument.model_validate(doc_raw)73 journal, _ = compile_document(document, POLICIES)74 assert len(journal.entries) == len(document.events)75 for entry in journal.entries:76 assert entry_imbalances(entry) == {}, f"unbalanced entry {entry.id}"77 assert accounting_equation_residual(journal.entries) == {}787980@given(documents)81@settings(max_examples=50, deadline=None)82def test_compilation_is_deterministic(doc_raw) -> None:83 document = AirDocument.model_validate(doc_raw)84 journal_a, _ = compile_document(document, POLICIES)85 journal_b, _ = compile_document(document, POLICIES)8687 def snapshot(journal):88 return [89 (e.id, e.date, e.description,90 [(l.account.code, l.side.value, str(l.amount.amount),91 l.amount.currency, l.memo) for l in e.lines])92 for e in journal.entries93 ]9495 assert snapshot(journal_a) == snapshot(journal_b)969798@given(amounts, jurisdictions)99@settings(max_examples=200, deadline=None)100def test_sale_total_equals_subtotal_plus_taxes(amount, jurisdiction) -> None:101 """For any taxed sale: the receivable equals revenue + all tax lines."""102 document = AirDocument.model_validate({103 "events": [{104 "id": "evt_prop_sale",105 "type": "Sale",106 "date": date(2026, 7, 15),107 "amount": {"amount": amount, "currency": "CAD"},108 "tax": {"jurisdiction": jurisdiction},109 }]110 })111 journal, _ = compile_document(document, POLICIES)112 (entry,) = journal.entries113 debits = [l for l in entry.lines if l.side.value == "debit"]114 credits = [l for l in entry.lines if l.side.value == "credit"]115 assert len(debits) == 1116 assert debits[0].amount.amount == sum(l.amount.amount for l in credits)117 for line in entry.lines:118 assert line.amount.amount >= 0119