# ============================================================================= # Projet : AIR — Accounting Intermediate Representation # Auteur : Simon-Pierre Boucher # Contact : contact@spboucher.ai # Fichier : invariant.py # Description : The accounting verifier — double-entry balance checked after every pass. # ============================================================================= """Accounting invariants (AIR's equivalent of the LLVM IR verifier). Two levels, both hard errors when violated: 1. Entry level — every journal entry balances per currency: sum(debits) == sum(credits). This is double-entry itself. 2. Ledger level — the accounting equation: Assets = Liabilities + Equity + (Revenue - Expenses) computed from typed account balances across all entries. The pass manager runs these after EVERY pass that touches journal entries; a violation aborts compilation with a clang-style diagnostic. """ from __future__ import annotations from collections import defaultdict from decimal import Decimal from aic.diagnostics import Diagnostic, Severity from core.journal import AccountType, JournalEntry, Side def entry_imbalances(entry: JournalEntry) -> dict[str, Decimal]: """Per-currency imbalance (debits - credits). Empty dict == balanced.""" balance: dict[str, Decimal] = defaultdict(Decimal) for line in entry.lines: balance[line.amount.currency] += line.signed() return {ccy: diff for ccy, diff in balance.items() if diff != 0} def verify_entries(entries: list[JournalEntry], pass_name: str) -> list[Diagnostic]: """Entry-level double-entry check. Returns error diagnostics (empty == OK).""" diags: list[Diagnostic] = [] for entry in entries: if not entry.lines: diags.append(Diagnostic( code="AIR-E100", severity=Severity.ERROR, message=f"journal entry '{entry.id}' has no lines", location=f"entry {entry.id} (event {entry.source_event_id})", suggestion="the posting pass must emit at least two lines per entry", origin_pass=pass_name, )) continue for ccy, diff in entry_imbalances(entry).items(): diags.append(Diagnostic( code="AIR-E101", severity=Severity.ERROR, message=( f"journal entry '{entry.id}' is unbalanced in {ccy}: " f"debits - credits = {diff}" ), location=f"entry {entry.id} (event {entry.source_event_id})", suggestion="every entry must satisfy sum(debits) == sum(credits) per currency", origin_pass=pass_name, )) return diags def balances_by_type(entries: list[JournalEntry]) -> dict[AccountType, dict[str, Decimal]]: """Normal-side balances per account type per currency. Asset/expense balances are debit-positive; liability/equity/revenue balances are credit-positive. """ out: dict[AccountType, dict[str, Decimal]] = { t: defaultdict(Decimal) for t in AccountType } for entry in entries: for line in entry.lines: sign = 1 if line.side is line.account.type.normal_side else -1 out[line.account.type][line.amount.currency] += sign * line.amount.amount return out def accounting_equation_residual(entries: list[JournalEntry]) -> dict[str, Decimal]: """Assets - (Liabilities + Equity + Revenue - Expenses), per currency. Zero everywhere iff the ledger satisfies the accounting equation. """ b = balances_by_type(entries) currencies = {ccy for per_ccy in b.values() for ccy in per_ccy} residual: dict[str, Decimal] = {} for ccy in currencies: assets = b[AccountType.ASSET][ccy] liabilities = b[AccountType.LIABILITY][ccy] equity = b[AccountType.EQUITY][ccy] revenue = b[AccountType.REVENUE][ccy] expenses = b[AccountType.EXPENSE][ccy] residual[ccy] = assets - (liabilities + equity + revenue - expenses) return {ccy: r for ccy, r in residual.items() if r != 0} def verify_equation(entries: list[JournalEntry], pass_name: str) -> list[Diagnostic]: """Ledger-level accounting-equation check.""" diags: list[Diagnostic] = [] for ccy, res in accounting_equation_residual(entries).items(): diags.append(Diagnostic( code="AIR-E102", severity=Severity.ERROR, message=( f"accounting equation violated in {ccy}: " f"Assets - (Liabilities + Equity + Revenue - Expenses) = {res}" ), location="ledger", suggestion="an entry posted to a mistyped account or an unbalanced entry slipped through", origin_pass=pass_name, )) return diags