# ============================================================================= # Projet : AIR — Accounting Intermediate Representation # Auteur : Simon-Pierre Boucher # Contact : contact@spboucher.ai # Fichier : test_balance_invariant.py # Description : Property-based tests — the double-entry invariant holds for ALL inputs. # ============================================================================= """Property-based tests (hypothesis). For arbitrary well-formed AIR documents: 1. every compiled entry balances (sum debits == sum credits per currency); 2. the accounting equation residual is zero; 3. compilation is deterministic (same input => identical journal). """ from __future__ import annotations from datetime import date from decimal import Decimal from pathlib import Path from hypothesis import given, settings from hypothesis import strategies as st from aic.compiler import compile_document from alsl.loader import load_policy_set from core.events import AirDocument from core.invariant import accounting_equation_residual, entry_imbalances ROOT = Path(__file__).resolve().parents[2] POLICIES = load_policy_set(ROOT / "alsl" / "policies" / "ca-qc-2026.yaml") # amounts: positive, exact 2-decimal values up to 10 million amounts = st.integers(min_value=1, max_value=1_000_000_000).map( lambda cents: str(Decimal(cents) / 100) ) jurisdictions = st.sampled_from(["CA-QC", "CA-ON", "CA-AB"]) @st.composite def events(draw, index: int = 0): etype = draw(st.sampled_from( ["Sale", "Purchase", "Refund", "OwnerContribution", "LoanReceived", "PaymentReceived", "PaymentSent"] )) event: dict = { "id": f"evt_{draw(st.integers(min_value=0, max_value=10**9))}_{index}", "type": etype, "date": date(2026, 7, 15), "amount": {"amount": draw(amounts), "currency": "CAD"}, } if etype in ("Sale", "Purchase", "Refund"): event["tax"] = { "jurisdiction": draw(jurisdictions), "exempt": draw(st.booleans()), } event["payment"] = {"immediate": draw(st.booleans())} return event documents = st.lists(events(), min_size=1, max_size=8).map( lambda evs: { "events": [ {**e, "id": f"{e['id']}_{i}"} for i, e in enumerate(evs) ] } ) @given(documents) @settings(max_examples=200, deadline=None) def test_every_entry_balances_and_equation_holds(doc_raw) -> None: document = AirDocument.model_validate(doc_raw) journal, _ = compile_document(document, POLICIES) assert len(journal.entries) == len(document.events) for entry in journal.entries: assert entry_imbalances(entry) == {}, f"unbalanced entry {entry.id}" assert accounting_equation_residual(journal.entries) == {} @given(documents) @settings(max_examples=50, deadline=None) def test_compilation_is_deterministic(doc_raw) -> None: document = AirDocument.model_validate(doc_raw) journal_a, _ = compile_document(document, POLICIES) journal_b, _ = compile_document(document, POLICIES) def snapshot(journal): return [ (e.id, e.date, e.description, [(l.account.code, l.side.value, str(l.amount.amount), l.amount.currency, l.memo) for l in e.lines]) for e in journal.entries ] assert snapshot(journal_a) == snapshot(journal_b) @given(amounts, jurisdictions) @settings(max_examples=200, deadline=None) def test_sale_total_equals_subtotal_plus_taxes(amount, jurisdiction) -> None: """For any taxed sale: the receivable equals revenue + all tax lines.""" document = AirDocument.model_validate({ "events": [{ "id": "evt_prop_sale", "type": "Sale", "date": date(2026, 7, 15), "amount": {"amount": amount, "currency": "CAD"}, "tax": {"jurisdiction": jurisdiction}, }] }) journal, _ = compile_document(document, POLICIES) (entry,) = journal.entries debits = [l for l in entry.lines if l.side.value == "debit"] credits = [l for l in entry.lines if l.side.value == "credit"] assert len(debits) == 1 assert debits[0].amount.amount == sum(l.amount.amount for l in credits) for line in entry.lines: assert line.amount.amount >= 0