SPB Git

spb/air Public MIT

AIR — The Language of Accounting.

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