AIR v0.1.0 — Accounting Intermediate Representation
The language of accounting: LLMs produce economic events (AIR), a deterministic compiler (AIC) produces balanced journal entries under versioned, source-cited ALSL policies. All six phases complete: - Phase 0: 9 cited research reports (GST/QST from official sources, LLVM architecture, ledger engines, ERP APIs, camt.053/MT940, ...) - Phase 1: AIR v0.1 schema, provenance graph (accounting SSA), double-entry invariant verified after every pass, 21 golden cases - Phase 2: incremental compilation (content-fingerprint diff, reversal + replacement entries) - Phase 3: QuickBooks backend (100% offline-tested mock transport), managed AIR home (hash-chained ledger, document archive) - Phase 4: LLM ingestion with confidence routing + human approval inbox - Phase 5: agent syscalls + hash-chained audit log, closed periods - Phase 6: fusion/netting/duplicate passes, bank reconciliation (camt.053, MT940 with integrity check, CSV) 99 offline tests + 2 opt-in live tests. No floats, no rules in code, append-only everything. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
98 changed files +11,076 −0
added
.github/workflows/ci.yml
+49 −0
@@ -0,0 +1,49 @@ | |||||
| 1 | +# Projet : AIR — Accounting Intermediate Representation | ||||
| 2 | +# Auteur : Simon-Pierre Boucher | ||||
| 3 | +# Contact : contact@spboucher.ai | ||||
| 4 | +# CI: offline test suite (no API keys) + author-header gate on every push/PR. | ||||
| 5 | + | ||||
| 6 | +name: CI | ||||
| 7 | + | ||||
| 8 | +on: | ||||
| 9 | + push: | ||||
| 10 | + branches: [main] | ||||
| 11 | + pull_request: | ||||
| 12 | + | ||||
| 13 | +jobs: | ||||
| 14 | + test: | ||||
| 15 | + runs-on: ubuntu-latest | ||||
| 16 | + strategy: | ||||
| 17 | + fail-fast: false | ||||
| 18 | + matrix: | ||||
| 19 | + python-version: ["3.11", "3.12", "3.13"] | ||||
| 20 | + steps: | ||||
| 21 | + - uses: actions/checkout@v4 | ||||
| 22 | + - uses: actions/setup-python@v5 | ||||
| 23 | + with: | ||||
| 24 | + python-version: ${{ matrix.python-version }} | ||||
| 25 | + - name: Install | ||||
| 26 | + run: pip install -e ".[dev]" | ||||
| 27 | + - name: Author-header gate | ||||
| 28 | + run: python scripts/check_headers.py | ||||
| 29 | + - name: Offline test suite (99 tests — no network, no API keys) | ||||
| 30 | + run: pytest tests/ -q --ignore=tests/test_llm_live.py | ||||
| 31 | + | ||||
| 32 | + golden-determinism: | ||||
| 33 | + # compile the demo document twice on a fresh interpreter each time: | ||||
| 34 | + # byte-identical CSV output or the build fails | ||||
| 35 | + runs-on: ubuntu-latest | ||||
| 36 | + steps: | ||||
| 37 | + - uses: actions/checkout@v4 | ||||
| 38 | + - uses: actions/setup-python@v5 | ||||
| 39 | + with: | ||||
| 40 | + python-version: "3.12" | ||||
| 41 | + - name: Install | ||||
| 42 | + run: pip install -e . | ||||
| 43 | + - name: Determinism check | ||||
| 44 | + run: | | ||||
| 45 | + python -m sdk.cli compile tests/fixtures/demo_document.yaml \ | ||||
| 46 | + --policies alsl/policies/ca-qc-2026.yaml --backend csv --out out_a | ||||
| 47 | + python -m sdk.cli compile tests/fixtures/demo_document.yaml \ | ||||
| 48 | + --policies alsl/policies/ca-qc-2026.yaml --backend csv --out out_b | ||||
| 49 | + diff out_a/*.csv out_b/*.csv && echo "byte-identical: OK" | ||||
added
.gitignore
+17 −0
@@ -0,0 +1,17 @@ | |||||
| 1 | +__pycache__/ | ||||
| 2 | +*.pyc | ||||
| 3 | +.venv/ | ||||
| 4 | +.pytest_cache/ | ||||
| 5 | +.hypothesis/ | ||||
| 6 | +.mypy_cache/ | ||||
| 7 | +out/ | ||||
| 8 | +books/ | ||||
| 9 | +dist/ | ||||
| 10 | +build/ | ||||
| 11 | +*.egg-info/ | ||||
| 12 | +.DS_Store | ||||
| 13 | + | ||||
| 14 | +.env | ||||
| 15 | +books/ | ||||
| 16 | +out_a/ | ||||
| 17 | +out_b/ | ||||
added
CLAUDE.md
+320 −0
@@ -0,0 +1,320 @@ | |||||
| 1 | +# CLAUDE.md — Projet AIR (Accounting Intermediate Representation) | ||||
| 2 | + | ||||
| 3 | +> **Auteur du projet : Simon-Pierre Boucher — contact@spboucher.ai** | ||||
| 4 | +> Ce fichier est la source de vérité pour Claude Code. Lis-le intégralement avant toute action. | ||||
| 5 | + | ||||
| 6 | +--- | ||||
| 7 | + | ||||
| 8 | +## 0. RÈGLE ABSOLUE — En-tête obligatoire de chaque fichier | ||||
| 9 | + | ||||
| 10 | +**CHAQUE fichier créé ou modifié dans ce projet DOIT commencer par un en-tête d'auteur.** | ||||
| 11 | +Aucune exception : code source, tests, docs, scripts, configs, schémas, notebooks. | ||||
| 12 | + | ||||
| 13 | +### Formats selon le type de fichier | ||||
| 14 | + | ||||
| 15 | +**Python (.py)** | ||||
| 16 | +```python | ||||
| 17 | +# ============================================================================= | ||||
| 18 | +# Projet : AIR — Accounting Intermediate Representation | ||||
| 19 | +# Auteur : Simon-Pierre Boucher | ||||
| 20 | +# Contact : contact@spboucher.ai | ||||
| 21 | +# Fichier : <nom_du_fichier> | ||||
| 22 | +# Description : <une ligne décrivant le rôle du fichier> | ||||
| 23 | +# ============================================================================= | ||||
| 24 | +``` | ||||
| 25 | + | ||||
| 26 | +**TypeScript / JavaScript / Rust / Go / C / Zig (.ts, .js, .rs, .go, .c, .zig)** | ||||
| 27 | +```ts | ||||
| 28 | +// ============================================================================= | ||||
| 29 | +// Projet : AIR — Accounting Intermediate Representation | ||||
| 30 | +// Auteur : Simon-Pierre Boucher | ||||
| 31 | +// Contact : contact@spboucher.ai | ||||
| 32 | +// Fichier : <nom_du_fichier> | ||||
| 33 | +// Description : <une ligne> | ||||
| 34 | +// ============================================================================= | ||||
| 35 | +``` | ||||
| 36 | + | ||||
| 37 | +**Markdown (.md)** | ||||
| 38 | +```markdown | ||||
| 39 | +<!-- | ||||
| 40 | +Projet : AIR — Accounting Intermediate Representation | ||||
| 41 | +Auteur : Simon-Pierre Boucher | ||||
| 42 | +Contact : contact@spboucher.ai | ||||
| 43 | +Fichier : <nom_du_fichier> | ||||
| 44 | +--> | ||||
| 45 | +``` | ||||
| 46 | + | ||||
| 47 | +**YAML / TOML / config (.yaml, .yml, .toml)** | ||||
| 48 | +```yaml | ||||
| 49 | +# Projet : AIR — Accounting Intermediate Representation | ||||
| 50 | +# Auteur : Simon-Pierre Boucher | ||||
| 51 | +# Contact : contact@spboucher.ai | ||||
| 52 | +``` | ||||
| 53 | + | ||||
| 54 | +**JSON** : le format JSON n'accepte pas les commentaires. Ajouter une clé au niveau racine : | ||||
| 55 | +```json | ||||
| 56 | +{ "_author": "Simon-Pierre Boucher <contact@spboucher.ai>", ... } | ||||
| 57 | +``` | ||||
| 58 | + | ||||
| 59 | +**SQL (.sql)** | ||||
| 60 | +```sql | ||||
| 61 | +-- ============================================================================= | ||||
| 62 | +-- Projet : AIR | Auteur : Simon-Pierre Boucher | contact@spboucher.ai | ||||
| 63 | +-- ============================================================================= | ||||
| 64 | +``` | ||||
| 65 | + | ||||
| 66 | +✅ Avant de terminer toute tâche, vérifie que tous les fichiers touchés portent l'en-tête. | ||||
| 67 | +✅ Ajoute un hook / script `scripts/check_headers.py` qui échoue en CI si un fichier n'a pas l'en-tête. | ||||
| 68 | + | ||||
| 69 | +--- | ||||
| 70 | + | ||||
| 71 | +## 1. RÈGLE ABSOLUE — Recherche web intensive AVANT de coder | ||||
| 72 | + | ||||
| 73 | +Ce projet touche des domaines où tes connaissances peuvent être incomplètes ou périmées : normes comptables, fiscalité, API des ERP, design de compilateurs. **Tu ne dois JAMAIS deviner.** | ||||
| 74 | + | ||||
| 75 | +### Protocole de recherche obligatoire | ||||
| 76 | + | ||||
| 77 | +Avant chaque nouveau module ou décision de design importante : | ||||
| 78 | + | ||||
| 79 | +1. **Fais au minimum 3 à 5 recherches web ciblées** sur le sujet (spécifications, docs officielles, articles récents, RFC, standards existants). | ||||
| 80 | +2. **Consulte les sources primaires** : documentation officielle des ERP (SAP, QuickBooks, Xero, Odoo, Oracle NetSuite, Sage, Dynamics), sites gouvernementaux (ARC/Revenu Québec pour TPS/TVQ, IRS pour sales tax US), sites des normalisateurs (IFRS Foundation, FASB pour US GAAP, CPA Canada pour ASPE). | ||||
| 81 | +3. **Documente tes trouvailles** dans `docs/research/<sujet>.md` (avec en-tête auteur) : sources, dates de consultation, résumé, décisions prises. | ||||
| 82 | +4. **Vérifie l'existant** : avant d'inventer un format, recherche les standards déjà en place (voir §1.1) pour t'en inspirer ou t'y aligner. | ||||
| 83 | +5. Si une info fiscale ou normative est incertaine ou pourrait avoir changé : **recherche web obligatoire**, jamais de mémoire seule. | ||||
| 84 | + | ||||
| 85 | +### 1.1 Sujets à rechercher intensivement (checklist de démarrage) | ||||
| 86 | + | ||||
| 87 | +- [ ] **Standards de données comptables existants** : XBRL / XBRL-GL, ISO 20022, UBL (Universal Business Language), Peppol, OFX, camt.053, EDIFACT, hledger/beancount/ledger-cli (plain text accounting), REA ontology (Resources-Events-Agents), ValueFlows, OpenCorporates schemas. | ||||
| 88 | +- [ ] **Architecture LLVM** : structure de l'IR, forme SSA, pass manager, backends, tablegen — pour transposer correctement les concepts. | ||||
| 89 | +- [ ] **API ERP** : SAP OData/BAPI/IDoc, QuickBooks Online API (JournalEntry, Invoice, Payment), Xero Accounting API, Odoo XML-RPC/ORM, NetSuite SuiteTalk, Sage Intacct. | ||||
| 90 | +- [ ] **Normes comptables** : IFRS (IFRS 15 revenus, IFRS 16 locations, IAS 21 devises), US GAAP (ASC 606, ASC 842), ASPE canadien (chapitres pertinents). | ||||
| 91 | +- [ ] **Fiscalité** : TPS/TVQ Canada (taux actuels, règles de lieu de fourniture), sales tax US (nexus, taux par État), TVA UE si pertinent. | ||||
| 92 | +- [ ] **Event sourcing & double-entry engines** : Martin Fowler (Accounting Patterns), TigerBeetle, Formance Ledger, Modern Treasury, Increase, Stripe Ledger — architectures de grands ledgers. | ||||
| 93 | +- [ ] **Rapprochement bancaire & formats bancaires** : ISO 20022 camt, MT940, Plaid/Flinks API. | ||||
| 94 | +- [ ] **Structured outputs LLM** : meilleures pratiques actuelles pour extraction structurée (JSON Schema, tool use, validation). | ||||
| 95 | + | ||||
| 96 | +⚠️ Les taux de taxes, seuils de capitalisation, et versions d'API **changent**. Toujours vérifier la date des sources et privilégier les documents officiels récents. | ||||
| 97 | + | ||||
| 98 | +--- | ||||
| 99 | + | ||||
| 100 | +## 2. Vision du projet | ||||
| 101 | + | ||||
| 102 | +AIR est à la comptabilité ce que LLVM est à la compilation. | ||||
| 103 | + | ||||
| 104 | +**Problème** : chaque ERP (SAP, Oracle, QuickBooks, Xero, Sage, Odoo, Dynamics) réinvente le même modèle comptable. Les LLM sont excellents pour comprendre ("cette facture = 3 ordinateurs payés par Visa") mais peu fiables pour appliquer des centaines de règles comptables/fiscales sans erreur. | ||||
| 105 | + | ||||
| 106 | +**Solution** : séparer la *compréhension* (LLM) de l'*application des règles* (compilateur déterministe). | ||||
| 107 | + | ||||
| 108 | +``` | ||||
| 109 | +Facture / Email / Banque / POS / API | ||||
| 110 | + ↓ | ||||
| 111 | + LLM (extraction) | ||||
| 112 | + ↓ | ||||
| 113 | + AIR (événement économique, PAS une écriture) | ||||
| 114 | + ↓ | ||||
| 115 | + Passes (validation, taxes, FX, fraude, approbation) | ||||
| 116 | + ↓ | ||||
| 117 | + Compilation déterministe (AIC) | ||||
| 118 | + ↓ | ||||
| 119 | + Backends : SAP | QuickBooks | Xero | Odoo | IFRS | US GAAP | ASPE | ||||
| 120 | +``` | ||||
| 121 | + | ||||
| 122 | +**Principe fondamental** : le LLM ne produit JAMAIS une écriture comptable finale. Il produit uniquement de l'AIR. Le compilateur applique les politiques, normes et taxes de façon déterministe, traçable et testable. | ||||
| 123 | + | ||||
| 124 | +--- | ||||
| 125 | + | ||||
| 126 | +## 3. Composants de l'écosystème | ||||
| 127 | + | ||||
| 128 | +| Composant | Analogie LLVM | Rôle | | ||||
| 129 | +|---|---|---| | ||||
| 130 | +| **AIR** | LLVM IR | Format universel décrivant les événements économiques | | ||||
| 131 | +| **AIC** | clang/llc | Compilateur AIR → écritures comptables | | ||||
| 132 | +| **ALSL** | TableGen | Langage déclaratif de règles (politiques, taxes, normes) | | ||||
| 133 | +| **Backends** | x86/ARM backends | Générateurs SAP, QBO, Xero, Odoo, etc. | | ||||
| 134 | +| **Passes** | Optimization passes | Validation, fusion, netting, doublons, conformité | | ||||
| 135 | +| **SDK Agents** | libclang | API standard pour agents IA (syscalls comptables) | | ||||
| 136 | +| **AIR Kernel** | microkernel | Services : Ledger, Tax, FX, Policy, Period, Audit, Approval, Reporting | | ||||
| 137 | + | ||||
| 138 | +### 3.1 AIR — le format | ||||
| 139 | + | ||||
| 140 | +Un événement économique (`EconomicEvent`), pas un journal. Exemple cible : | ||||
| 141 | + | ||||
| 142 | +```yaml | ||||
| 143 | +EconomicEvent: | ||||
| 144 | + id: evt_01H... # ULID | ||||
| 145 | + type: Sale | ||||
| 146 | + seller: company:acme | ||||
| 147 | + buyer: customer:cust_123 | ||||
| 148 | + items: | ||||
| 149 | + - sku: chair-std | ||||
| 150 | + qty: 3 | ||||
| 151 | + unit_price: {amount: 333.33, currency: CAD} | ||||
| 152 | + payment: | ||||
| 153 | + method: card.visa | ||||
| 154 | + gross: {amount: 1150.00, currency: CAD} | ||||
| 155 | + delivery: {status: pending, expected: 2026-09-01} | ||||
| 156 | + tax: | ||||
| 157 | + jurisdiction: CA-QC | ||||
| 158 | + codes: [GST, QST] | ||||
| 159 | + meta: | ||||
| 160 | + source: {kind: invoice_pdf, uri: "s3://...", ocr_score: 0.97} | ||||
| 161 | + llm: {model: "...", confidence: 0.93, reasoning_hash: "sha256:..."} | ||||
| 162 | + policy_version: "2026.08" | ||||
| 163 | + timestamps: {ingested: ..., approved: null} | ||||
| 164 | + approver: null | ||||
| 165 | +``` | ||||
| 166 | + | ||||
| 167 | +Exigences du format : | ||||
| 168 | +- **Schéma formel** (JSON Schema + types Pydantic/TypeScript générés). | ||||
| 169 | +- **Forme SSA comptable** : chaque montant a une origine unique et traçable (Invoice → Tax → Payment → FX → Settlement → Write-off). Implémenter un graphe de provenance immuable. | ||||
| 170 | +- **Versionné** : `air_version` dans chaque document ; migrations explicites. | ||||
| 171 | +- **Immutabilité + diff** : compilation incrémentale — si une facture change, on recompile uniquement le delta (comme Git), avec écritures de contrepassation générées automatiquement. | ||||
| 172 | + | ||||
| 173 | +### 3.2 AIC — le compilateur | ||||
| 174 | + | ||||
| 175 | +Pipeline de passes ordonnées : | ||||
| 176 | + | ||||
| 177 | +``` | ||||
| 178 | +OCR pass → Classification pass → Tax pass → FX pass → | ||||
| 179 | +Fraud pass → Approval pass → Optimization passes → Posting pass | ||||
| 180 | +``` | ||||
| 181 | + | ||||
| 182 | +Passes d'optimisation : | ||||
| 183 | +- **Fusion** : 50 paiements identiques → 1 batch. | ||||
| 184 | +- **Netting** : 100 remboursements → compensation. | ||||
| 185 | +- **Reclassement** : Expense → Asset selon politiques (ex. capitalisation > 5000 $). | ||||
| 186 | +- **Détection doublons** : hachage + similarité. | ||||
| 187 | +- **Invariant permanent** : `Assets = Liabilities + Equity` vérifié après CHAQUE passe. Toute violation = échec de compilation avec diagnostic précis (comme les erreurs clang : localisation, cause, suggestion). | ||||
| 188 | + | ||||
| 189 | +### 3.3 ALSL — le langage de règles | ||||
| 190 | + | ||||
| 191 | +Déclaratif, versionné, testable : | ||||
| 192 | + | ||||
| 193 | +``` | ||||
| 194 | +policy capitalization_ca: | ||||
| 195 | + when event.type == Purchase and event.amount > 5000 CAD | ||||
| 196 | + then classify as Asset(class: equipment) | ||||
| 197 | + | ||||
| 198 | +policy tax_quebec: | ||||
| 199 | + when event.jurisdiction == CA-QC | ||||
| 200 | + then apply GST(5%), QST(9.975%) | ||||
| 201 | +``` | ||||
| 202 | + | ||||
| 203 | +⚠️ Les taux ci-dessus sont des exemples — **vérifie les taux actuels par recherche web** avant de les coder, et ne les code JAMAIS en dur dans le moteur : ils vivent dans les policies ALSL versionnées. | ||||
| 204 | + | ||||
| 205 | +### 3.4 Backends | ||||
| 206 | + | ||||
| 207 | +Chaque backend implémente une interface commune `Backend`: | ||||
| 208 | +- `capabilities()` — ce que la cible supporte | ||||
| 209 | +- `compile(journal: CompiledJournal) -> TargetPayload` | ||||
| 210 | +- `post(payload) -> PostingReceipt` | ||||
| 211 | +- `reverse(receipt) -> ReversalReceipt` | ||||
| 212 | + | ||||
| 213 | +Ordre de développement : **1) Backend générique CSV/journal**, 2) QuickBooks Online (API la plus accessible), 3) Xero, 4) Odoo, 5) SAP (le plus complexe — recherche approfondie requise sur OData/BAPI). | ||||
| 214 | + | ||||
| 215 | +### 3.5 SDK Agents — les "syscalls" | ||||
| 216 | + | ||||
| 217 | +Les agents IA n'accèdent JAMAIS au grand livre directement. API exclusive : | ||||
| 218 | + | ||||
| 219 | +``` | ||||
| 220 | +CreateEconomicEvent() | Validate() | Compile() | Post() | Reverse() | ||||
| 221 | +Merge() | ClosePeriod() | Reconcile() | GenerateReport() | ||||
| 222 | +``` | ||||
| 223 | + | ||||
| 224 | +Chaque appel est journalisé (audit log append-only, hash-chaîné). | ||||
| 225 | + | ||||
| 226 | +--- | ||||
| 227 | + | ||||
| 228 | +## 4. Architecture technique | ||||
| 229 | + | ||||
| 230 | +### Stack recommandée (à valider par recherche) | ||||
| 231 | +- **Cœur (AIR + AIC + passes)** : Rust (fiabilité, typage fort) OU Python typé strict (vitesse de dev). Décision à documenter dans `docs/adr/0001-language.md` après recherche comparative. | ||||
| 232 | +- **Schémas** : JSON Schema comme source de vérité → génération de types Rust/Python/TS. | ||||
| 233 | +- **Montants** : JAMAIS de float. Décimal fixe (rust_decimal / Python Decimal), arrondi banker's rounding documenté par juridiction (à vérifier par recherche : règles d'arrondi TPS/TVQ). | ||||
| 234 | +- **Stockage** : event store append-only (PostgreSQL) + projections. | ||||
| 235 | +- **ALSL** : parser dédié (pest/lark) ; commencer par un sous-ensemble YAML avant le DSL complet. | ||||
| 236 | + | ||||
| 237 | +### Structure du dépôt | ||||
| 238 | + | ||||
| 239 | +``` | ||||
| 240 | +air/ | ||||
| 241 | +├── CLAUDE.md | ||||
| 242 | +├── README.md | ||||
| 243 | +├── docs/ | ||||
| 244 | +│ ├── adr/ # Architecture Decision Records | ||||
| 245 | +│ ├── research/ # Résultats de recherches web (OBLIGATOIRE) | ||||
| 246 | +│ └── spec/ # Spécification formelle AIR / ALSL | ||||
| 247 | +├── schemas/ # JSON Schemas versionnés | ||||
| 248 | +├── core/ # AIR types + graphe de provenance | ||||
| 249 | +├── aic/ # Compilateur + pass manager | ||||
| 250 | +│ └── passes/ | ||||
| 251 | +├── alsl/ # Parser + évaluateur de règles | ||||
| 252 | +├── backends/ | ||||
| 253 | +│ ├── generic_csv/ | ||||
| 254 | +│ ├── quickbooks/ | ||||
| 255 | +│ ├── xero/ | ||||
| 256 | +│ └── odoo/ | ||||
| 257 | +├── kernel/ # Services (ledger, tax, fx, policy, audit...) | ||||
| 258 | +├── sdk/ # SDK agents (syscalls) | ||||
| 259 | +├── ingestion/ # OCR + extraction LLM → AIR | ||||
| 260 | +├── tests/ | ||||
| 261 | +│ ├── golden/ # Cas dorés : AIR → écritures attendues | ||||
| 262 | +│ ├── property/ # Property-based (invariant bilan) | ||||
| 263 | +│ └── fixtures/ # Factures réelles anonymisées | ||||
| 264 | +└── scripts/ | ||||
| 265 | + └── check_headers.py # Vérifie les en-têtes auteur | ||||
| 266 | +``` | ||||
| 267 | + | ||||
| 268 | +--- | ||||
| 269 | + | ||||
| 270 | +## 5. Exigences de qualité non négociables | ||||
| 271 | + | ||||
| 272 | +1. **Déterminisme** : même AIR + mêmes policies = mêmes écritures, toujours. Aucun appel LLM dans le compilateur. | ||||
| 273 | +2. **Invariant comptable** : partie double vérifiée à chaque étape ; property-based testing (hypothesis/proptest) sur `Assets = Liabilities + Equity`. | ||||
| 274 | +3. **Traçabilité totale** : de chaque ligne d'écriture, on remonte à l'événement source, au document, au score OCR/LLM, à la version de policy, à l'approbateur. | ||||
| 275 | +4. **Golden tests** : chaque fonctionnalité comptable = cas dorés validés contre des exemples de la littérature comptable (trouvés par recherche web, sources citées). | ||||
| 276 | +5. **Diagnostics de qualité compilateur** : erreurs précises, localisées, avec suggestions. | ||||
| 277 | +6. **Aucun taux/seuil en dur** : tout paramètre fiscal ou de politique vit dans ALSL. | ||||
| 278 | +7. **En-têtes auteur** partout (voir §0), vérifiés en CI. | ||||
| 279 | + | ||||
| 280 | +--- | ||||
| 281 | + | ||||
| 282 | +## 6. Plan de développement par phases | ||||
| 283 | + | ||||
| 284 | +**Phase 0 — Recherche (docs/research/)** : compléter la checklist §1.1, produire un rapport par sujet, rédiger les ADR fondateurs (langage, alignement ou non avec XBRL-GL/UBL/REA, modèle de montants). | ||||
| 285 | + | ||||
| 286 | +**Phase 1 — Cœur** : schéma AIR v0.1, types, graphe de provenance, invariant bilan, backend CSV générique, 20 golden tests (vente simple, achat, taxes QC, FX, remboursement). | ||||
| 287 | + | ||||
| 288 | +**Phase 2 — Compilateur** : pass manager, passes Tax (CA-QC d'abord) / FX / Validation, ALSL v0.1 (YAML), compilation incrémentale (diff + reversal). | ||||
| 289 | + | ||||
| 290 | +**Phase 3 — Backend réel** : QuickBooks Online (sandbox), OAuth, idempotence, Post/Reverse. | ||||
| 291 | + | ||||
| 292 | +**Phase 4 — Ingestion** : PDF → extraction structurée LLM → AIR, avec scores de confiance et file d'approbation humaine sous seuil. | ||||
| 293 | + | ||||
| 294 | +**Phase 5 — Kernel + SDK** : services, syscalls, audit log hash-chaîné, agent de démonstration. | ||||
| 295 | + | ||||
| 296 | +**Phase 6 — Optimisations** : fusion, netting, doublons, rapprochement bancaire (camt.053/MT940). | ||||
| 297 | + | ||||
| 298 | +--- | ||||
| 299 | + | ||||
| 300 | +## 7. Workflow de travail attendu de Claude | ||||
| 301 | + | ||||
| 302 | +À chaque session : | ||||
| 303 | +1. Relire ce CLAUDE.md. | ||||
| 304 | +2. Identifier la tâche → **recherche web d'abord** si le sujet touche normes, taxes, API externes, ou standards. | ||||
| 305 | +3. Consigner la recherche dans `docs/research/`. | ||||
| 306 | +4. Écrire les tests avant/avec le code (golden + property). | ||||
| 307 | +5. Coder avec en-têtes auteur. | ||||
| 308 | +6. Lancer `scripts/check_headers.py` + suite de tests. | ||||
| 309 | +7. Mettre à jour la doc/ADR si une décision de design a été prise. | ||||
| 310 | + | ||||
| 311 | +### Interdits | ||||
| 312 | +- ❌ Coder une règle fiscale de mémoire sans source web vérifiée et citée. | ||||
| 313 | +- ❌ Utiliser des floats pour des montants. | ||||
| 314 | +- ❌ Laisser le LLM produire des écritures finales. | ||||
| 315 | +- ❌ Créer un fichier sans l'en-tête Simon-Pierre Boucher / contact@spboucher.ai. | ||||
| 316 | +- ❌ Casser l'invariant de la partie double, même temporairement. | ||||
| 317 | + | ||||
| 318 | +--- | ||||
| 319 | + | ||||
| 320 | +*Fin du CLAUDE.md — Projet AIR — Simon-Pierre Boucher — contact@spboucher.ai* | ||||
added
CONTRIBUTING.md
+57 −0
@@ -0,0 +1,57 @@ | |||||
| 1 | +<!-- | ||||
| 2 | +Projet : AIR — Accounting Intermediate Representation | ||||
| 3 | +Auteur : Simon-Pierre Boucher | ||||
| 4 | +Contact : contact@spboucher.ai | ||||
| 5 | +Fichier : CONTRIBUTING.md | ||||
| 6 | +--> | ||||
| 7 | + | ||||
| 8 | +# Contributing to AIR | ||||
| 9 | + | ||||
| 10 | +Thank you for considering a contribution! AIR has a few **non-negotiable | ||||
| 11 | +rules** that CI enforces — read them before opening a PR. | ||||
| 12 | + | ||||
| 13 | +## The rules (enforced) | ||||
| 14 | + | ||||
| 15 | +1. **Author header on every file.** Every source/doc/config file starts with | ||||
| 16 | + the project header (see any file for the format). `python scripts/check_headers.py` | ||||
| 17 | + must pass — CI fails otherwise. | ||||
| 18 | +2. **No floats for money — ever.** Amounts, quantities, rates are `Decimal` | ||||
| 19 | + in code and strings in JSON/YAML. Floats are rejected at every boundary. | ||||
| 20 | +3. **No tax rate, threshold, or rounding mode in code.** All fiscal and policy | ||||
| 21 | + parameters live in versioned ALSL policy sets with a mandatory source | ||||
| 22 | + citation. The loader rejects uncited rates. | ||||
| 23 | +4. **Research before rules.** Anything touching tax law, accounting standards, | ||||
| 24 | + external APIs, or data formats needs a cited report in `docs/research/` | ||||
| 25 | + (official sources, consultation dates). Never from memory. | ||||
| 26 | +5. **The compiler stays deterministic.** No LLM, network, or clock inside AIC. | ||||
| 27 | + Same document + same policies = byte-identical journal. | ||||
| 28 | +6. **The invariant is sacred.** `Assets = Liabilities + Equity` is verified | ||||
| 29 | + after every compiler pass. New passes must keep entries balanced. | ||||
| 30 | +7. **Append-only history.** Corrections are reversal entries; nothing posted | ||||
| 31 | + is ever edited or deleted. | ||||
| 32 | + | ||||
| 33 | +## Workflow | ||||
| 34 | + | ||||
| 35 | +```bash | ||||
| 36 | +python3 -m venv .venv && .venv/bin/pip install -e ".[dev]" | ||||
| 37 | +.venv/bin/python -m pytest tests/ -q --ignore=tests/test_llm_live.py # offline suite | ||||
| 38 | +python3 scripts/check_headers.py | ||||
| 39 | +``` | ||||
| 40 | + | ||||
| 41 | +- Write tests with the code: golden cases for accounting behavior | ||||
| 42 | + (`tests/golden/cases/*.yaml`), property tests for invariants, unit tests | ||||
| 43 | + for mechanics. | ||||
| 44 | +- New event types need: posting rules, golden cases, and a spec update. | ||||
| 45 | +- New backends implement `backends/base.py` (`capabilities/compile/post/reverse`) | ||||
| 46 | + and must be fully testable offline (mock transport pattern — | ||||
| 47 | + see `backends/quickbooks/`). | ||||
| 48 | +- Architecture decisions get an ADR in `docs/adr/`. | ||||
| 49 | + | ||||
| 50 | +## What makes a good first contribution | ||||
| 51 | + | ||||
| 52 | +- A new ALSL policy set for another jurisdiction (with cited research) | ||||
| 53 | +- The beancount/hledger export backend (near-free per the research) | ||||
| 54 | +- Reference-first matching (`EndToEndId`) in reconciliation | ||||
| 55 | +- A Xero or Odoo backend with an offline mock transport | ||||
| 56 | + | ||||
| 57 | +*Author: Simon-Pierre Boucher — contact@spboucher.ai* | ||||
added
LICENSE
+21 −0
@@ -0,0 +1,21 @@ | |||||
| 1 | +MIT License | ||||
| 2 | + | ||||
| 3 | +Copyright (c) 2026 Simon-Pierre Boucher <contact@spboucher.ai> | ||||
| 4 | + | ||||
| 5 | +Permission is hereby granted, free of charge, to any person obtaining a copy | ||||
| 6 | +of this software and associated documentation files (the "Software"), to deal | ||||
| 7 | +in the Software without restriction, including without limitation the rights | ||||
| 8 | +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | ||||
| 9 | +copies of the Software, and to permit persons to whom the Software is | ||||
| 10 | +furnished to do so, subject to the following conditions: | ||||
| 11 | + | ||||
| 12 | +The above copyright notice and this permission notice shall be included in all | ||||
| 13 | +copies or substantial portions of the Software. | ||||
| 14 | + | ||||
| 15 | +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | ||||
| 16 | +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | ||||
| 17 | +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | ||||
| 18 | +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | ||||
| 19 | +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | ||||
| 20 | +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | ||||
| 21 | +SOFTWARE. | ||||
added
README.md
+233 −0
@@ -0,0 +1,233 @@ | |||||
| 1 | +<!-- | ||||
| 2 | +Projet : AIR — Accounting Intermediate Representation | ||||
| 3 | +Auteur : Simon-Pierre Boucher | ||||
| 4 | +Contact : contact@spboucher.ai | ||||
| 5 | +Fichier : README.md | ||||
| 6 | +--> | ||||
| 7 | + | ||||
| 8 | +<div align="center"> | ||||
| 9 | + | ||||
| 10 | +# ⚙️ AIR | ||||
| 11 | + | ||||
| 12 | +### The Language of Accounting | ||||
| 13 | + | ||||
| 14 | +**LLVM for the ledger** — LLMs understand your documents, a deterministic compiler keeps your books. | ||||
| 15 | + | ||||
| 16 | +[](https://github.com/spboucher-ai/air/actions/workflows/ci.yml) | ||||
| 17 | +[](pyproject.toml) | ||||
| 18 | +[](LICENSE) | ||||
| 19 | +[](tests/) | ||||
| 20 | +[](tests/golden/cases/) | ||||
| 21 | +[](docs/adr/0003-monetary-amounts.md) | ||||
| 22 | +[](core/invariant.py) | ||||
| 23 | +[](kernel/ledger.py) | ||||
| 24 | +[](docs/cli-reference.md) | ||||
| 25 | + | ||||
| 26 | +*by [Simon-Pierre Boucher](mailto:contact@spboucher.ai)* | ||||
| 27 | + | ||||
| 28 | +</div> | ||||
| 29 | + | ||||
| 30 | +--- | ||||
| 31 | + | ||||
| 32 | +## The idea | ||||
| 33 | + | ||||
| 34 | +Every ERP reinvents the same accounting model. LLMs are brilliant at *understanding* | ||||
| 35 | +("this invoice = 3 chairs paid by Visa") and unreliable at *applying* hundreds of | ||||
| 36 | +tax and accounting rules. **AIR separates the two** — exactly like LLVM separated | ||||
| 37 | +language frontends from machine backends: | ||||
| 38 | + | ||||
| 39 | +```mermaid | ||||
| 40 | +flowchart LR | ||||
| 41 | + A[📄 Invoice / Email<br/>Bank / POS / API] --> B["🧠 LLM<br/><i>understanding</i>"] | ||||
| 42 | + B -->|"AIR events<br/>(never journal entries)"| C["⚙️ AIC Compiler<br/><i>deterministic rules</i>"] | ||||
| 43 | + P["📜 ALSL Policies<br/>taxes · thresholds · rounding<br/><i>versioned & cited</i>"] --> C | ||||
| 44 | + C -->|"balanced entries<br/>+ full provenance"| D["📗 Native Ledger<br/>hash-chained, standalone"] | ||||
| 45 | + C --> E["📤 CSV · QuickBooks<br/>Xero · Odoo · SAP*"] | ||||
| 46 | + D --> F["📊 Trial balance · Balance sheet<br/>Income statement · GL"] | ||||
| 47 | + style B fill:#f9e79f,stroke:#b7950b | ||||
| 48 | + style C fill:#aed6f1,stroke:#2471a3 | ||||
| 49 | + style D fill:#a9dfbf,stroke:#1e8449 | ||||
| 50 | + style P fill:#f5b7b1,stroke:#c0392b | ||||
| 51 | +``` | ||||
| 52 | + | ||||
| 53 | +> **The one rule that never bends:** an LLM (or any frontend) only ever produces | ||||
| 54 | +> **AIR** — a description of *what happened economically*. It never writes a journal | ||||
| 55 | +> entry, an account code, or a debit. The compiler does that, deterministically, | ||||
| 56 | +> with the double-entry invariant `Assets = Liabilities + Equity` verified after | ||||
| 57 | +> **every** pass and clang-style diagnostics when anything is wrong. | ||||
| 58 | + | ||||
| 59 | +## What you get | ||||
| 60 | + | ||||
| 61 | +| | | | ||||
| 62 | +|---|---| | ||||
| 63 | +| 🗣️ **A universal language** | `EconomicEvent` (REA-based): sales, purchases, refunds, payments, FX — perspective-neutral, schema-validated, no debits anywhere | | ||||
| 64 | +| ⚙️ **A real compiler** | Pass pipeline (validation → classification → tax → FX → posting), pass manager with verify-after-every-pass, diagnostics with location + cause + `help:` fix | | ||||
| 65 | +| 📜 **Rules as data (ALSL)** | GST 5%, QST 9.975%, HST, capitalization thresholds, rounding modes — all in versioned YAML policy sets with **mandatory source citations** (the loader rejects uncited rates) | | ||||
| 66 | +| 🏠 **Standalone books** | No ERP required: managed AIR home with an append-only, tamper-evident ledger, content-addressed document archive, and all financial statements in text/markdown/CSV/JSON | | ||||
| 67 | +| 🔁 **Git-style corrections** | Changed invoice? `air recompile` diffs by content fingerprint and posts reversal + replacement entries — history is never edited | | ||||
| 68 | +| 🤖 **An agent SDK** | AI agents get syscalls (`CreateEconomicEvent`, `Post`, `Reverse`, `ClosePeriod`, `Merge`, `Reconcile`…) — never the ledger. Every call, including refusals, lands in a hash-chained audit log | | ||||
| 69 | +| 🧾 **LLM ingestion** | Invoice text → Claude (structured outputs) → schema-validated AIR → confidence routing → human approval inbox. Fully offline mock for tests | | ||||
| 70 | +| 🚀 **Optimizations** | 50 identical payments → 1 batch entry (fusion), refund↔sale netting, duplicate detection — provenance preserved through every transformation | | ||||
| 71 | +| 🏦 **Bank reconciliation** | camt.053 + MT940 (with statement integrity check) + CSV → matched against the books, differences reported on both sides | | ||||
| 72 | +| 🔍 **Total traceability** | Every posted cent walks back through the provenance graph (accounting SSA) to its source event, document, OCR/LLM confidence, policy version, and rounding mode | | ||||
| 73 | + | ||||
| 74 | +## Quickstart | ||||
| 75 | + | ||||
| 76 | +```bash | ||||
| 77 | +git clone https://github.com/spboucher-ai/air && cd air | ||||
| 78 | +python3 -m venv .venv && .venv/bin/pip install -e ".[dev]" | ||||
| 79 | +source .venv/bin/activate | ||||
| 80 | + | ||||
| 81 | +# 1) Your books live in a managed AIR home — one command | ||||
| 82 | +air init --home books --policies alsl/policies/ca-qc-2026.yaml | ||||
| 83 | + | ||||
| 84 | +# 2) Compile economic events into the books (document archived, ledger chained) | ||||
| 85 | +air compile tests/fixtures/demo_document.yaml --home books \ | ||||
| 86 | + --report trial-balance --report balance-sheet | ||||
| 87 | + | ||||
| 88 | +# 3) A source invoice was corrected? Post only the delta (reversal + replacement) | ||||
| 89 | +air recompile old.yaml corrected.yaml --home books | ||||
| 90 | + | ||||
| 91 | +# 4) Statements any time, any format | ||||
| 92 | +air report income-statement --home books --format markdown | ||||
| 93 | + | ||||
| 94 | +# 5) Ingest a real document (offline extractor; add --llm for Claude) | ||||
| 95 | +air ingest tests/fixtures/invoice_high_confidence.txt --home books | ||||
| 96 | +air inbox --home books | ||||
| 97 | + | ||||
| 98 | +# 6) Month end | ||||
| 99 | +air reconcile statement.mt940 --home books | ||||
| 100 | +air audit --home books | ||||
| 101 | +``` | ||||
| 102 | + | ||||
| 103 | +Full command reference: **[docs/cli-reference.md](docs/cli-reference.md)** | ||||
| 104 | + | ||||
| 105 | +## Sixty seconds of AIR | ||||
| 106 | + | ||||
| 107 | +An economic event — *what happened*, nothing else: | ||||
| 108 | + | ||||
| 109 | +```yaml | ||||
| 110 | +events: | ||||
| 111 | + - id: evt_01H8XGJWBW | ||||
| 112 | + type: Sale | ||||
| 113 | + date: 2026-07-20 | ||||
| 114 | + parties: {seller: "company:acme", buyer: "customer:cust_123"} | ||||
| 115 | + items: | ||||
| 116 | + - {sku: chair-std, qty: "3", unit_price: {amount: "333.33", currency: CAD}} | ||||
| 117 | + payment: {method: card.visa, immediate: true} | ||||
| 118 | + tax: {jurisdiction: CA-QC} # ← where. Rates live in policies, never here | ||||
| 119 | +``` | ||||
| 120 | + | ||||
| 121 | +The compiler applies the cited CA-QC policy set (GST 5%, QST 9.975% on the | ||||
| 122 | +pre-GST price, half-up per Excise Tax Act s.165.2(2)) and emits a balanced entry: | ||||
| 123 | + | ||||
| 124 | +``` | ||||
| 125 | +DR 1000 Cash 1149.74 | ||||
| 126 | + CR 4000 Sales revenue 999.99 | ||||
| 127 | + CR 2310 GST payable 50.00 tax:GST@0.05~half_up | ||||
| 128 | + CR 2320 QST payable 99.75 tax:QST@0.09975~half_up | ||||
| 129 | +``` | ||||
| 130 | + | ||||
| 131 | +Same input + same policies = **byte-identical output, always** (property-tested, | ||||
| 132 | +and CI compiles the demo twice and `diff`s the results). When something is wrong, | ||||
| 133 | +you get a compiler error, not a wrong number: | ||||
| 134 | + | ||||
| 135 | +``` | ||||
| 136 | +error[AIR-E400]: no tax policy in set 'ca-qc' matches jurisdiction 'CA-BC' | ||||
| 137 | + --> event evt_x, field tax.jurisdiction | ||||
| 138 | + pass: tax | ||||
| 139 | + help: add an ALSL tax policy for this jurisdiction or mark the event tax.exempt: true | ||||
| 140 | +``` | ||||
| 141 | + | ||||
| 142 | +## Agents keep books through syscalls — and only syscalls | ||||
| 143 | + | ||||
| 144 | +```python | ||||
| 145 | +from sdk.syscalls import AirKernel | ||||
| 146 | + | ||||
| 147 | +k = AirKernel("books", actor="agent:alice") | ||||
| 148 | +k.create_economic_event({...}) # schema-validated draft | ||||
| 149 | +k.post() # deterministic compile → books (idempotent) | ||||
| 150 | +k.reverse("je_evt_x") # corrections are contra entries, never edits | ||||
| 151 | +k.close_period("2026-01") # posting into it now fails with AIR-E700 | ||||
| 152 | +k.merge() # post with netting + payment fusion | ||||
| 153 | +k.reconcile("statement.mt940") # bank matching | ||||
| 154 | +``` | ||||
| 155 | + | ||||
| 156 | +``` | ||||
| 157 | +$ air audit --home books | ||||
| 158 | +#0000 OK agent:alice CreateEconomicEvent {"event_id": "evt_x", "type": "Sale"} | ||||
| 159 | +#0001 OK agent:alice Post {"drafts": 1} | ||||
| 160 | +#0008 ERR agent:alice Post {"drafts": 1} <- error[AIR-E700]: period 2026-01 is closed | ||||
| 161 | +10 syscalls, hash chain VALID | ||||
| 162 | +``` | ||||
| 163 | + | ||||
| 164 | +Refused actions are audited like successful ones. Editing any record breaks the chain. | ||||
| 165 | + | ||||
| 166 | +## The seven guarantees | ||||
| 167 | + | ||||
| 168 | +1. **Determinism** — no LLM, network, or clock inside the compiler. | ||||
| 169 | +2. **Double entry** — the invariant is verified after *every* pass; violations abort compilation. | ||||
| 170 | +3. **Traceability** — provenance graph from every posted line to its source (accounting SSA). | ||||
| 171 | +4. **No floats** — exact decimals end to end; floats rejected at every boundary. | ||||
| 172 | +5. **No rules in code** — every rate/threshold lives in cited, versioned ALSL policies. | ||||
| 173 | +6. **Append-only** — reversals, never edits; ledger and audit log are hash-chained. | ||||
| 174 | +7. **Human in the loop** — low-confidence or schema-invalid extractions always route to a person. | ||||
| 175 | + | ||||
| 176 | +## Repository map | ||||
| 177 | + | ||||
| 178 | +| Path | Role | LLVM analogy | | ||||
| 179 | +|---|---|---| | ||||
| 180 | +| [`schemas/`](schemas/) | AIR JSON Schema | the IR definition | | ||||
| 181 | +| [`core/`](core/) | events, Money, provenance, invariants | IR + verifier | | ||||
| 182 | +| [`aic/`](aic/) | pass manager, passes, diagnostics, incremental | opt/llc | | ||||
| 183 | +| [`alsl/`](alsl/) | rule language + cited policy sets | TableGen | | ||||
| 184 | +| [`backends/`](backends/) | native, CSV, QuickBooks (offline-tested) | targets | | ||||
| 185 | +| [`kernel/`](kernel/) | ledger, reporting, workspace, audit, reconciliation | runtime | | ||||
| 186 | +| [`sdk/`](sdk/) | CLI, agent syscalls, demo agent | libclang | | ||||
| 187 | +| [`ingestion/`](ingestion/) | extractors, confidence routing, approval queue | frontend | | ||||
| 188 | +| [`docs/spec/`](docs/spec/) | **the AIR/ALSL specification** | LangRef | | ||||
| 189 | +| [`docs/adr/`](docs/adr/) | architecture decision records | — | | ||||
| 190 | +| [`docs/research/`](docs/research/) | 9 cited research reports (tax law, formats, APIs) | — | | ||||
| 191 | +| [`tests/`](tests/) | golden cases, property tests, unit + live tests | lit | | ||||
| 192 | + | ||||
| 193 | +## Documentation | ||||
| 194 | + | ||||
| 195 | +- 📖 **[The AIR Specification](docs/spec/air-spec-v0.1.md)** — the language, the compiler, diagnostics, syscalls, reconciliation | ||||
| 196 | +- 🧭 **[CLI Reference](docs/cli-reference.md)** — every command with examples | ||||
| 197 | +- 🏛️ **[ADRs](docs/adr/)** — why Python, why REA events, why rounding is policy | ||||
| 198 | +- 🔬 **[Research](docs/research/)** — GST/QST from official sources, camt.053/MT940 specs, ERP API surveys, LLVM architecture lessons | ||||
| 199 | +- 🤝 **[Contributing](CONTRIBUTING.md)** — the non-negotiable rules CI enforces | ||||
| 200 | + | ||||
| 201 | +## Development | ||||
| 202 | + | ||||
| 203 | +```bash | ||||
| 204 | +.venv/bin/python -m pytest tests/ -q --ignore=tests/test_llm_live.py # 99 tests, fully offline | ||||
| 205 | +python3 scripts/check_headers.py # author-header gate | ||||
| 206 | + | ||||
| 207 | +# opt-in live LLM tests (cost a few cents) | ||||
| 208 | +ANTHROPIC_API_KEY=sk-ant-... .venv/bin/python -m pytest tests/test_llm_live.py -v | ||||
| 209 | +``` | ||||
| 210 | + | ||||
| 211 | +Everything — including the QuickBooks backend and the LLM ingestion — develops | ||||
| 212 | +and tests **fully offline**: mock transports simulate the real APIs' documented | ||||
| 213 | +behaviors (idempotency replay, duplicate errors), so no account or key is ever | ||||
| 214 | +required to work on AIR. | ||||
| 215 | + | ||||
| 216 | +## Roadmap | ||||
| 217 | + | ||||
| 218 | +- [x] Phases 0–6: research → core → compiler → backends → ingestion → agent SDK → optimizations & reconciliation *(complete)* | ||||
| 219 | +- [ ] beancount/hledger export backend | ||||
| 220 | +- [ ] Xero & Odoo backends (offline mock pattern) | ||||
| 221 | +- [ ] Reference-first bank matching (`EndToEndId`) | ||||
| 222 | +- [ ] More jurisdictions as cited ALSL policy sets (US sales tax, EU VAT) | ||||
| 223 | +- [ ] AIR v0.2: REA commitments (pending deliveries, IFRS 15 performance obligations) | ||||
| 224 | + | ||||
| 225 | +--- | ||||
| 226 | + | ||||
| 227 | +<div align="center"> | ||||
| 228 | + | ||||
| 229 | +**AIR** — *because your books deserve a compiler.* | ||||
| 230 | + | ||||
| 231 | +MIT © 2026 [Simon-Pierre Boucher](mailto:contact@spboucher.ai) | ||||
| 232 | + | ||||
| 233 | +</div> | ||||
added
aic/__init__.py
+7 −0
@@ -0,0 +1,7 @@ | |||||
| 1 | +# ============================================================================= | ||||
| 2 | +# Projet : AIR — Accounting Intermediate Representation | ||||
| 3 | +# Auteur : Simon-Pierre Boucher | ||||
| 4 | +# Contact : contact@spboucher.ai | ||||
| 5 | +# Fichier : __init__.py | ||||
| 6 | +# Description : Package marker. | ||||
| 7 | +# ============================================================================= | ||||
added
aic/compiler.py
+76 −0
@@ -0,0 +1,76 @@ | |||||
| 1 | +# ============================================================================= | ||||
| 2 | +# Projet : AIR — Accounting Intermediate Representation | ||||
| 3 | +# Auteur : Simon-Pierre Boucher | ||||
| 4 | +# Contact : contact@spboucher.ai | ||||
| 5 | +# Fichier : compiler.py | ||||
| 6 | +# Description : Top-level AIC entry point — AIR document + ALSL policies -> CompiledJournal. | ||||
| 7 | +# ============================================================================= | ||||
| 8 | +"""compile_document(): the deterministic core of AIR. | ||||
| 9 | + | ||||
| 10 | +Same AIR document + same policy set => byte-identical journal, always. | ||||
| 11 | +No LLM, no network, no clock inside this function. | ||||
| 12 | +""" | ||||
| 13 | +from __future__ import annotations | ||||
| 14 | + | ||||
| 15 | +from aic.diagnostics import Diagnostic | ||||
| 16 | +from aic.pass_manager import PassManager | ||||
| 17 | +from aic.passes.classification import ClassificationPass | ||||
| 18 | +from aic.passes.fx import FxPass | ||||
| 19 | +from aic.passes.posting import PostingPass | ||||
| 20 | +from aic.passes.tax import TaxPass | ||||
| 21 | +from aic.passes.validation import ValidationPass | ||||
| 22 | +from aic.unit import CompilationUnit | ||||
| 23 | +from alsl.model import PolicySet | ||||
| 24 | +from core.events import AirDocument | ||||
| 25 | +from core.journal import CompiledJournal | ||||
| 26 | + | ||||
| 27 | +DEFAULT_PIPELINE = [ | ||||
| 28 | + ValidationPass(), | ||||
| 29 | + ClassificationPass(), | ||||
| 30 | + TaxPass(), | ||||
| 31 | + FxPass(), | ||||
| 32 | + PostingPass(), | ||||
| 33 | +] | ||||
| 34 | + | ||||
| 35 | + | ||||
| 36 | +def _optimized_pipeline() -> list: | ||||
| 37 | + from aic.passes.optimize import ( | ||||
| 38 | + DuplicateDetectionPass, | ||||
| 39 | + FusionPass, | ||||
| 40 | + NettingPass, | ||||
| 41 | + ) | ||||
| 42 | + return [ | ||||
| 43 | + ValidationPass(), | ||||
| 44 | + DuplicateDetectionPass(), | ||||
| 45 | + ClassificationPass(), | ||||
| 46 | + TaxPass(), | ||||
| 47 | + FxPass(), | ||||
| 48 | + PostingPass(), | ||||
| 49 | + NettingPass(), | ||||
| 50 | + FusionPass(), | ||||
| 51 | + ] | ||||
| 52 | + | ||||
| 53 | + | ||||
| 54 | +def compile_document( | ||||
| 55 | + document: AirDocument, | ||||
| 56 | + policies: PolicySet, | ||||
| 57 | + pipeline: list | None = None, | ||||
| 58 | + optimize: bool = False, | ||||
| 59 | +) -> tuple[CompiledJournal, list[Diagnostic]]: | ||||
| 60 | + """Compile an AIR document into a journal. Raises CompilationError on errors. | ||||
| 61 | + | ||||
| 62 | + optimize=True adds the Phase 6 passes (duplicate detection, netting, | ||||
| 63 | + fusion); the invariant is still verified after every pass. | ||||
| 64 | + """ | ||||
| 65 | + if pipeline is None: | ||||
| 66 | + pipeline = _optimized_pipeline() if optimize else DEFAULT_PIPELINE | ||||
| 67 | + unit = CompilationUnit(document=document, policies=policies) | ||||
| 68 | + manager = PassManager(pipeline) | ||||
| 69 | + manager.run(unit) | ||||
| 70 | + journal = CompiledJournal( | ||||
| 71 | + entries=list(unit.entries), | ||||
| 72 | + provenance=unit.provenance, | ||||
| 73 | + policy_set=policies.name, | ||||
| 74 | + policy_version=policies.version, | ||||
| 75 | + ) | ||||
| 76 | + return journal, unit.diagnostics | ||||
added
aic/diagnostics.py
+53 −0
@@ -0,0 +1,53 @@ | |||||
| 1 | +# ============================================================================= | ||||
| 2 | +# Projet : AIR — Accounting Intermediate Representation | ||||
| 3 | +# Auteur : Simon-Pierre Boucher | ||||
| 4 | +# Contact : contact@spboucher.ai | ||||
| 5 | +# Fichier : diagnostics.py | ||||
| 6 | +# Description : Clang-style diagnostics — precise location, cause, suggestion. | ||||
| 7 | +# ============================================================================= | ||||
| 8 | +"""Compiler-quality diagnostics for AIC. | ||||
| 9 | + | ||||
| 10 | +Modeled on clang: every diagnostic names WHERE (event id / field path), | ||||
| 11 | +WHAT (computed cause, e.g. the exact imbalance), and HOW TO FIX (suggestion). | ||||
| 12 | +Errors abort compilation; warnings and notes ride along in the result. | ||||
| 13 | +""" | ||||
| 14 | +from __future__ import annotations | ||||
| 15 | + | ||||
| 16 | +import enum | ||||
| 17 | +from dataclasses import dataclass | ||||
| 18 | + | ||||
| 19 | + | ||||
| 20 | +class Severity(enum.Enum): | ||||
| 21 | + ERROR = "error" | ||||
| 22 | + WARNING = "warning" | ||||
| 23 | + NOTE = "note" | ||||
| 24 | + | ||||
| 25 | + | ||||
| 26 | +@dataclass(frozen=True, slots=True) | ||||
| 27 | +class Diagnostic: | ||||
| 28 | + code: str # e.g. "AIR-E101" | ||||
| 29 | + severity: Severity | ||||
| 30 | + message: str | ||||
| 31 | + location: str # e.g. "event evt_01H..., field items[0].unit_price" | ||||
| 32 | + suggestion: str | None = None | ||||
| 33 | + origin_pass: str | None = None | ||||
| 34 | + | ||||
| 35 | + def render(self) -> str: | ||||
| 36 | + head = f"{self.severity.value}[{self.code}]: {self.message}" | ||||
| 37 | + lines = [head, f" --> {self.location}"] | ||||
| 38 | + if self.origin_pass: | ||||
| 39 | + lines.append(f" pass: {self.origin_pass}") | ||||
| 40 | + if self.suggestion: | ||||
| 41 | + lines.append(f" help: {self.suggestion}") | ||||
| 42 | + return "\n".join(lines) | ||||
| 43 | + | ||||
| 44 | + | ||||
| 45 | +class CompilationError(Exception): | ||||
| 46 | + """Raised when any ERROR diagnostic is produced; carries all diagnostics.""" | ||||
| 47 | + | ||||
| 48 | + def __init__(self, diagnostics: list[Diagnostic]): | ||||
| 49 | + self.diagnostics = diagnostics | ||||
| 50 | + errors = [d for d in diagnostics if d.severity is Severity.ERROR] | ||||
| 51 | + super().__init__( | ||||
| 52 | + f"{len(errors)} error(s):\n" + "\n".join(d.render() for d in errors) | ||||
| 53 | + ) | ||||
added
aic/incremental.py
+154 −0
@@ -0,0 +1,154 @@ | |||||
| 1 | +# ============================================================================= | ||||
| 2 | +# Projet : AIR — Accounting Intermediate Representation | ||||
| 3 | +# Auteur : Simon-Pierre Boucher | ||||
| 4 | +# Contact : contact@spboucher.ai | ||||
| 5 | +# Fichier : incremental.py | ||||
| 6 | +# Description : Incremental compilation — diff two AIR documents, emit reversal + replacement entries. | ||||
| 7 | +# ============================================================================= | ||||
| 8 | +"""Incremental compilation (Phase 2). | ||||
| 9 | + | ||||
| 10 | +When a source document changes (a corrected invoice, a removed duplicate, a | ||||
| 11 | +new event), AIR never mutates posted history. Instead, like Git, we diff the | ||||
| 12 | +old and new documents by event fingerprint and emit: | ||||
| 13 | + | ||||
| 14 | +- a REVERSAL entry (exact contra) for every changed or removed event; | ||||
| 15 | +- a replacement entry (with a revision-suffixed id) for every changed event; | ||||
| 16 | +- a normal entry for every added event; | ||||
| 17 | +- nothing for unchanged events. | ||||
| 18 | + | ||||
| 19 | +Determinism holds: same (old, new, policies) triple => identical delta. | ||||
| 20 | + | ||||
| 21 | +Provenance scoping: reversal lines keep the provenance ids of the prior | ||||
| 22 | +compilation (they describe the amounts being reversed); the `reverses` field | ||||
| 23 | +links each contra entry to the original. Replacement/added entries carry the | ||||
| 24 | +new compilation's provenance graph. | ||||
| 25 | +""" | ||||
| 26 | +from __future__ import annotations | ||||
| 27 | + | ||||
| 28 | +import hashlib | ||||
| 29 | +from dataclasses import dataclass, field, replace | ||||
| 30 | + | ||||
| 31 | +from aic.compiler import compile_document | ||||
| 32 | +from aic.diagnostics import CompilationError, Diagnostic, Severity | ||||
| 33 | +from alsl.model import PolicySet | ||||
| 34 | +from core.events import AirDocument, EconomicEvent | ||||
| 35 | +from core.invariant import verify_entries, verify_equation | ||||
| 36 | +from core.journal import CompiledJournal, JournalEntry, JournalLine, Side | ||||
| 37 | + | ||||
| 38 | + | ||||
| 39 | +def event_fingerprint(event: EconomicEvent) -> str: | ||||
| 40 | + """Content hash of an event (canonical JSON, field order fixed by the schema).""" | ||||
| 41 | + return hashlib.sha256(event.model_dump_json().encode("utf-8")).hexdigest() | ||||
| 42 | + | ||||
| 43 | + | ||||
| 44 | +@dataclass(frozen=True, slots=True) | ||||
| 45 | +class DocumentDiff: | ||||
| 46 | + added: tuple[str, ...] | ||||
| 47 | + removed: tuple[str, ...] | ||||
| 48 | + changed: tuple[str, ...] | ||||
| 49 | + unchanged: tuple[str, ...] | ||||
| 50 | + | ||||
| 51 | + def is_empty(self) -> bool: | ||||
| 52 | + return not (self.added or self.removed or self.changed) | ||||
| 53 | + | ||||
| 54 | + | ||||
| 55 | +def diff_documents(old: AirDocument, new: AirDocument) -> DocumentDiff: | ||||
| 56 | + old_fp = {e.id: event_fingerprint(e) for e in old.events} | ||||
| 57 | + new_fp = {e.id: event_fingerprint(e) for e in new.events} | ||||
| 58 | + added = tuple(i for i in new_fp if i not in old_fp) | ||||
| 59 | + removed = tuple(i for i in old_fp if i not in new_fp) | ||||
| 60 | + changed = tuple(i for i in new_fp if i in old_fp and new_fp[i] != old_fp[i]) | ||||
| 61 | + unchanged = tuple(i for i in new_fp if i in old_fp and new_fp[i] == old_fp[i]) | ||||
| 62 | + return DocumentDiff(added=added, removed=removed, changed=changed, | ||||
| 63 | + unchanged=unchanged) | ||||
| 64 | + | ||||
| 65 | + | ||||
| 66 | +def make_reversal(entry: JournalEntry) -> JournalEntry: | ||||
| 67 | + """Exact contra of a posted entry (sides swapped, amounts identical).""" | ||||
| 68 | + return JournalEntry( | ||||
| 69 | + id=f"rev_{entry.id}", | ||||
| 70 | + date=entry.date, | ||||
| 71 | + description=f"REVERSAL: {entry.description}", | ||||
| 72 | + lines=tuple( | ||||
| 73 | + JournalLine( | ||||
| 74 | + account=line.account, | ||||
| 75 | + side=Side.CREDIT if line.side is Side.DEBIT else Side.DEBIT, | ||||
| 76 | + amount=line.amount, | ||||
| 77 | + memo=f"reversal of {entry.id}: {line.memo}", | ||||
| 78 | + provenance_id=line.provenance_id, | ||||
| 79 | + ) | ||||
| 80 | + for line in entry.lines | ||||
| 81 | + ), | ||||
| 82 | + source_event_id=entry.source_event_id, | ||||
| 83 | + policy_set=entry.policy_set, | ||||
| 84 | + policy_version=entry.policy_version, | ||||
| 85 | + reverses=entry.id, | ||||
| 86 | + ) | ||||
| 87 | + | ||||
| 88 | + | ||||
| 89 | +@dataclass | ||||
| 90 | +class IncrementalResult: | ||||
| 91 | + """The delta between two compilations: what must be posted on top.""" | ||||
| 92 | + | ||||
| 93 | + diff: DocumentDiff | ||||
| 94 | + reversals: list[JournalEntry] = field(default_factory=list) | ||||
| 95 | + new_entries: list[JournalEntry] = field(default_factory=list) | ||||
| 96 | + journal: CompiledJournal = field(default_factory=CompiledJournal) # reversals + new | ||||
| 97 | + diagnostics: list[Diagnostic] = field(default_factory=list) | ||||
| 98 | + | ||||
| 99 | + | ||||
| 100 | +def recompile( | ||||
| 101 | + old: AirDocument, | ||||
| 102 | + new: AirDocument, | ||||
| 103 | + policies: PolicySet, | ||||
| 104 | +) -> IncrementalResult: | ||||
| 105 | + """Compile only the delta between two AIR documents. | ||||
| 106 | + | ||||
| 107 | + Both documents are compiled (compilation is cheap and deterministic; the | ||||
| 108 | + OLD compile reconstructs exactly what was posted, so no external state is | ||||
| 109 | + needed), but the returned journal contains ONLY the delta entries. | ||||
| 110 | + """ | ||||
| 111 | + old_journal, _ = compile_document(old, policies) | ||||
| 112 | + new_journal, new_diags = compile_document(new, policies) | ||||
| 113 | + diff = diff_documents(old, new) | ||||
| 114 | + | ||||
| 115 | + old_by_event = {e.source_event_id: e for e in old_journal.entries} | ||||
| 116 | + new_by_event = {e.source_event_id: e for e in new_journal.entries} | ||||
| 117 | + new_fp = {e.id: event_fingerprint(e) for e in new.events} | ||||
| 118 | + | ||||
| 119 | + result = IncrementalResult(diff=diff, diagnostics=list(new_diags)) | ||||
| 120 | + | ||||
| 121 | + for event_id in diff.removed + diff.changed: | ||||
| 122 | + original = old_by_event.get(event_id) | ||||
| 123 | + if original is not None: | ||||
| 124 | + result.reversals.append(make_reversal(original)) | ||||
| 125 | + | ||||
| 126 | + for event_id in diff.added: | ||||
| 127 | + entry = new_by_event.get(event_id) | ||||
| 128 | + if entry is not None: | ||||
| 129 | + result.new_entries.append(entry) | ||||
| 130 | + | ||||
| 131 | + for event_id in diff.changed: | ||||
| 132 | + entry = new_by_event.get(event_id) | ||||
| 133 | + if entry is not None: | ||||
| 134 | + # revision-suffixed id: deterministic, unique per content revision | ||||
| 135 | + revision = new_fp[event_id][:8] | ||||
| 136 | + result.new_entries.append(replace(entry, id=f"{entry.id}_r{revision}")) | ||||
| 137 | + | ||||
| 138 | + delta_entries = result.reversals + result.new_entries | ||||
| 139 | + result.journal = CompiledJournal( | ||||
| 140 | + entries=delta_entries, | ||||
| 141 | + provenance=new_journal.provenance, | ||||
| 142 | + policy_set=policies.name, | ||||
| 143 | + policy_version=policies.version, | ||||
| 144 | + ) | ||||
| 145 | + | ||||
| 146 | + # the permanent invariant applies to the delta as well | ||||
| 147 | + invariant = ( | ||||
| 148 | + verify_entries(delta_entries, "incremental") | ||||
| 149 | + + verify_equation(delta_entries, "incremental") | ||||
| 150 | + ) | ||||
| 151 | + result.diagnostics.extend(invariant) | ||||
| 152 | + if any(d.severity is Severity.ERROR for d in result.diagnostics): | ||||
| 153 | + raise CompilationError(result.diagnostics) | ||||
| 154 | + return result | ||||
added
aic/pass_manager.py
+35 −0
@@ -0,0 +1,35 @@ | |||||
| 1 | +# ============================================================================= | ||||
| 2 | +# Projet : AIR — Accounting Intermediate Representation | ||||
| 3 | +# Auteur : Simon-Pierre Boucher | ||||
| 4 | +# Contact : contact@spboucher.ai | ||||
| 5 | +# Fichier : pass_manager.py | ||||
| 6 | +# Description : Pass manager — ordered passes, invariant verified after every pass. | ||||
| 7 | +# ============================================================================= | ||||
| 8 | +"""AIC pass manager. | ||||
| 9 | + | ||||
| 10 | +Runs the pass pipeline in order. After EVERY pass, the double-entry invariant | ||||
| 11 | +(per-entry balance + accounting equation) is verified — the accounting | ||||
| 12 | +analogue of `opt -verify-each`. Any ERROR diagnostic aborts compilation with | ||||
| 13 | +a CompilationError carrying clang-style diagnostics. | ||||
| 14 | +""" | ||||
| 15 | +from __future__ import annotations | ||||
| 16 | + | ||||
| 17 | +from aic.diagnostics import CompilationError, Severity | ||||
| 18 | +from aic.passes.base import Pass | ||||
| 19 | +from aic.unit import CompilationUnit | ||||
| 20 | +from core.invariant import verify_entries, verify_equation | ||||
| 21 | + | ||||
| 22 | + | ||||
| 23 | +class PassManager: | ||||
| 24 | + def __init__(self, passes: list[Pass]): | ||||
| 25 | + self.passes = passes | ||||
| 26 | + | ||||
| 27 | + def run(self, unit: CompilationUnit) -> CompilationUnit: | ||||
| 28 | + for p in self.passes: | ||||
| 29 | + unit.diagnostics.extend(p.run(unit)) | ||||
| 30 | + # the permanent invariant: checked after every pass, no exceptions | ||||
| 31 | + unit.diagnostics.extend(verify_entries(unit.entries, p.name)) | ||||
| 32 | + unit.diagnostics.extend(verify_equation(unit.entries, p.name)) | ||||
| 33 | + if any(d.severity is Severity.ERROR for d in unit.diagnostics): | ||||
| 34 | + raise CompilationError(unit.diagnostics) | ||||
| 35 | + return unit | ||||
added
aic/passes/__init__.py
+7 −0
@@ -0,0 +1,7 @@ | |||||
| 1 | +# ============================================================================= | ||||
| 2 | +# Projet : AIR — Accounting Intermediate Representation | ||||
| 3 | +# Auteur : Simon-Pierre Boucher | ||||
| 4 | +# Contact : contact@spboucher.ai | ||||
| 5 | +# Fichier : __init__.py | ||||
| 6 | +# Description : Package marker. | ||||
| 7 | +# ============================================================================= | ||||
added
aic/passes/base.py
+29 −0
@@ -0,0 +1,29 @@ | |||||
| 1 | +# ============================================================================= | ||||
| 2 | +# Projet : AIR — Accounting Intermediate Representation | ||||
| 3 | +# Auteur : Simon-Pierre Boucher | ||||
| 4 | +# Contact : contact@spboucher.ai | ||||
| 5 | +# Fichier : base.py | ||||
| 6 | +# Description : Pass interface — every AIC transformation implements this. | ||||
| 7 | +# ============================================================================= | ||||
| 8 | +"""Base class for AIC passes (LLVM-pass analogue). | ||||
| 9 | + | ||||
| 10 | +A pass is a deterministic transformation over the CompilationUnit. It returns | ||||
| 11 | +diagnostics; it never calls an LLM, the network, or a clock. The pass manager | ||||
| 12 | +re-verifies the double-entry invariant after every pass. | ||||
| 13 | +""" | ||||
| 14 | +from __future__ import annotations | ||||
| 15 | + | ||||
| 16 | +import abc | ||||
| 17 | + | ||||
| 18 | +from aic.diagnostics import Diagnostic | ||||
| 19 | +from aic.unit import CompilationUnit | ||||
| 20 | + | ||||
| 21 | + | ||||
| 22 | +class Pass(abc.ABC): | ||||
| 23 | + """One deterministic compilation step.""" | ||||
| 24 | + | ||||
| 25 | + name: str = "pass" | ||||
| 26 | + | ||||
| 27 | + @abc.abstractmethod | ||||
| 28 | + def run(self, unit: CompilationUnit) -> list[Diagnostic]: | ||||
| 29 | + """Transform the unit in place; return any diagnostics produced.""" | ||||
added
aic/passes/classification.py
+55 −0
@@ -0,0 +1,55 @@ | |||||
| 1 | +# ============================================================================= | ||||
| 2 | +# Projet : AIR — Accounting Intermediate Representation | ||||
| 3 | +# Auteur : Simon-Pierre Boucher | ||||
| 4 | +# Contact : contact@spboucher.ai | ||||
| 5 | +# Fichier : classification.py | ||||
| 6 | +# Description : Classification pass — applies ALSL classification policies (e.g. capitalization). | ||||
| 7 | +# ============================================================================= | ||||
| 8 | +"""Classification pass. | ||||
| 9 | + | ||||
| 10 | +Decides which account role receives the debit side of a Purchase | ||||
| 11 | +(expense vs capitalized asset) by evaluating ALSL classification policies — | ||||
| 12 | +e.g. "capitalize equipment purchases at or above the policy threshold". | ||||
| 13 | +Thresholds live in ALSL, never here. | ||||
| 14 | +""" | ||||
| 15 | +from __future__ import annotations | ||||
| 16 | + | ||||
| 17 | +from aic.diagnostics import Diagnostic, Severity | ||||
| 18 | +from aic.passes.base import Pass | ||||
| 19 | +from aic.unit import CompilationUnit | ||||
| 20 | +from alsl.evaluator import applicable_classifications | ||||
| 21 | +from core.events import EventType | ||||
| 22 | + | ||||
| 23 | + | ||||
| 24 | +class ClassificationPass(Pass): | ||||
| 25 | + name = "classification" | ||||
| 26 | + | ||||
| 27 | + def run(self, unit: CompilationUnit) -> list[Diagnostic]: | ||||
| 28 | + diags: list[Diagnostic] = [] | ||||
| 29 | + for event in unit.document.events: | ||||
| 30 | + if event.type is not EventType.PURCHASE: | ||||
| 31 | + continue | ||||
| 32 | + state = unit.event_state(event.id) | ||||
| 33 | + if state.subtotal is None: | ||||
| 34 | + continue # validation already failed this event | ||||
| 35 | + | ||||
| 36 | + hits = applicable_classifications(unit.policies, event, state.subtotal) | ||||
| 37 | + if not hits: | ||||
| 38 | + continue | ||||
| 39 | + if len(hits) > 1: | ||||
| 40 | + diags.append(Diagnostic( | ||||
| 41 | + code="AIR-W300", severity=Severity.WARNING, | ||||
| 42 | + message=( | ||||
| 43 | + "multiple classification policies match: " | ||||
| 44 | + + ", ".join(p.name for p in hits) | ||||
| 45 | + + f"; applying '{hits[0].name}'" | ||||
| 46 | + ), | ||||
| 47 | + location=f"event {event.id}", | ||||
| 48 | + suggestion="tighten the policies' when-clauses so at most one matches", | ||||
| 49 | + origin_pass=self.name, | ||||
| 50 | + )) | ||||
| 51 | + policy = hits[0] | ||||
| 52 | + state.classify_as = policy.classify_as | ||||
| 53 | + state.classification_role = policy.account_role | ||||
| 54 | + state.classification_policy = policy.name | ||||
| 55 | + return diags | ||||
added
aic/passes/fx.py
+148 −0
@@ -0,0 +1,148 @@ | |||||
| 1 | +# ============================================================================= | ||||
| 2 | +# Projet : AIR — Accounting Intermediate Representation | ||||
| 3 | +# Auteur : Simon-Pierre Boucher | ||||
| 4 | +# Contact : contact@spboucher.ai | ||||
| 5 | +# Fichier : fx.py | ||||
| 6 | +# Description : FX pass — converts to the functional currency; computes settlement gains/losses. | ||||
| 7 | +# ============================================================================= | ||||
| 8 | +"""FX pass (IAS 21 semantics — see docs/research/accounting-standards.md and | ||||
| 9 | +docs/research/fx-handling.md). | ||||
| 10 | + | ||||
| 11 | +- Transactions in the functional currency pass through (quantized for posting). | ||||
| 12 | +- Foreign-currency transactions are converted at the event's observed rate | ||||
| 13 | + (a fact provided by ingestion, e.g. a Bank of Canada Valet daily rate — | ||||
| 14 | + never a constant in code). | ||||
| 15 | +- Foreign-currency settlements (payments) compare the settlement rate to the | ||||
| 16 | + booking rate of the related event and realize an FX gain or loss. | ||||
| 17 | +Every conversion is a provenance node: "fx:USD->CAD@1.3500". | ||||
| 18 | +""" | ||||
| 19 | +from __future__ import annotations | ||||
| 20 | + | ||||
| 21 | +from aic.diagnostics import Diagnostic, Severity | ||||
| 22 | +from aic.passes.base import Pass | ||||
| 23 | +from aic.unit import CompilationUnit, TaxLineState | ||||
| 24 | +from core.events import EconomicEvent, EventType | ||||
| 25 | +from core.money import Money | ||||
| 26 | + | ||||
| 27 | +SETTLEMENT_TYPES = (EventType.PAYMENT_RECEIVED, EventType.PAYMENT_SENT) | ||||
| 28 | + | ||||
| 29 | + | ||||
| 30 | +class FxPass(Pass): | ||||
| 31 | + name = "fx" | ||||
| 32 | + | ||||
| 33 | + def run(self, unit: CompilationUnit) -> list[Diagnostic]: | ||||
| 34 | + diags: list[Diagnostic] = [] | ||||
| 35 | + functional = unit.policies.functional_currency | ||||
| 36 | + rounding = unit.policies.rounding.mode | ||||
| 37 | + | ||||
| 38 | + for event in unit.document.events: | ||||
| 39 | + state = unit.event_state(event.id) | ||||
| 40 | + if state.subtotal is None or state.subtotal_node is None: | ||||
| 41 | + continue | ||||
| 42 | + ccy = state.subtotal.currency | ||||
| 43 | + | ||||
| 44 | + if ccy == functional: | ||||
| 45 | + state.post_subtotal = state.subtotal.quantized(rounding) | ||||
| 46 | + state.post_subtotal_node = state.subtotal_node | ||||
| 47 | + state.post_taxes = list(state.taxes) | ||||
| 48 | + continue | ||||
| 49 | + | ||||
| 50 | + if event.fx is None: | ||||
| 51 | + diags.append(Diagnostic( | ||||
| 52 | + code="AIR-E500", severity=Severity.ERROR, | ||||
| 53 | + message=( | ||||
| 54 | + f"event is in {ccy} but functional currency is {functional} " | ||||
| 55 | + "and no fx rate is provided" | ||||
| 56 | + ), | ||||
| 57 | + location=f"event {event.id}, field fx", | ||||
| 58 | + suggestion="attach fx: {rate, source, rate_date} observed on the " | ||||
| 59 | + "transaction date (e.g. Bank of Canada Valet)", | ||||
| 60 | + origin_pass=self.name, | ||||
| 61 | + )) | ||||
| 62 | + continue | ||||
| 63 | + | ||||
| 64 | + rate = event.fx.rate | ||||
| 65 | + op = f"fx:{ccy}->{functional}@{rate}" | ||||
| 66 | + | ||||
| 67 | + converted = Money( | ||||
| 68 | + state.subtotal.amount * rate, functional | ||||
| 69 | + ).quantized(rounding) | ||||
| 70 | + node = unit.provenance.define( | ||||
| 71 | + kind="fx", operation=op, amount=converted, | ||||
| 72 | + inputs=(state.subtotal_node,), source_ref=event.fx.source, | ||||
| 73 | + ) | ||||
| 74 | + state.post_subtotal = converted | ||||
| 75 | + state.post_subtotal_node = node.id | ||||
| 76 | + | ||||
| 77 | + for tax in state.taxes: | ||||
| 78 | + tax_converted = Money( | ||||
| 79 | + tax.amount.amount * rate, functional | ||||
| 80 | + ).quantized(rounding) | ||||
| 81 | + tax_node = unit.provenance.define( | ||||
| 82 | + kind="fx", operation=op, amount=tax_converted, | ||||
| 83 | + inputs=(tax.node_id,), source_ref=event.fx.source, | ||||
| 84 | + ) | ||||
| 85 | + state.post_taxes.append(TaxLineState( | ||||
| 86 | + code=tax.code, amount=tax_converted, node_id=tax_node.id, | ||||
| 87 | + payable_role=tax.payable_role, | ||||
| 88 | + receivable_role=tax.receivable_role, | ||||
| 89 | + recoverable=tax.recoverable, policy=tax.policy, | ||||
| 90 | + )) | ||||
| 91 | + | ||||
| 92 | + if event.type in SETTLEMENT_TYPES: | ||||
| 93 | + diags.extend(self._settlement_diff(unit, event, rate, functional)) | ||||
| 94 | + return diags | ||||
| 95 | + | ||||
| 96 | + def _settlement_diff( | ||||
| 97 | + self, unit: CompilationUnit, event: EconomicEvent, | ||||
| 98 | + settle_rate: object, functional: str, | ||||
| 99 | + ) -> list[Diagnostic]: | ||||
| 100 | + """Realized FX gain/loss: settlement rate vs the related event's booking rate.""" | ||||
| 101 | + state = unit.event_state(event.id) | ||||
| 102 | + rounding = unit.policies.rounding.mode | ||||
| 103 | + | ||||
| 104 | + if event.related_event is None: | ||||
| 105 | + return [Diagnostic( | ||||
| 106 | + code="AIR-E501", severity=Severity.ERROR, | ||||
| 107 | + message="foreign-currency settlement without related_event: " | ||||
| 108 | + "the booking rate cannot be determined", | ||||
| 109 | + location=f"event {event.id}, field related_event", | ||||
| 110 | + suggestion="reference the Sale/Purchase this payment settles", | ||||
| 111 | + origin_pass=self.name, | ||||
| 112 | + )] | ||||
| 113 | + origin = unit.find_event(event.related_event) | ||||
| 114 | + if origin is None or origin.fx is None: | ||||
| 115 | + return [Diagnostic( | ||||
| 116 | + code="AIR-E502", severity=Severity.ERROR, | ||||
| 117 | + message=( | ||||
| 118 | + f"related event '{event.related_event}' not found in this " | ||||
| 119 | + "document or carries no fx rate" | ||||
| 120 | + ), | ||||
| 121 | + location=f"event {event.id}, field related_event", | ||||
| 122 | + suggestion="compile the settlement together with its origin event, " | ||||
| 123 | + "or attach the origin's booking rate", | ||||
| 124 | + origin_pass=self.name, | ||||
| 125 | + )] | ||||
| 126 | + | ||||
| 127 | + assert state.subtotal is not None and state.subtotal_node is not None | ||||
| 128 | + booked = Money( | ||||
| 129 | + state.subtotal.amount * origin.fx.rate, functional | ||||
| 130 | + ).quantized(rounding) | ||||
| 131 | + settled = state.post_subtotal | ||||
| 132 | + assert settled is not None and state.post_subtotal_node is not None | ||||
| 133 | + diff = settled - booked | ||||
| 134 | + state.booked_amount = booked | ||||
| 135 | + state.fx_diff = diff | ||||
| 136 | + if not diff.is_zero(): | ||||
| 137 | + node = unit.provenance.define( | ||||
| 138 | + kind="fx_realized", | ||||
| 139 | + operation=( | ||||
| 140 | + f"fx_realized:{state.subtotal.currency}->{functional}" | ||||
| 141 | + f"@{settle_rate}-vs-@{origin.fx.rate}" | ||||
| 142 | + ), | ||||
| 143 | + amount=diff, | ||||
| 144 | + inputs=(state.post_subtotal_node,), | ||||
| 145 | + source_ref=event.related_event, | ||||
| 146 | + ) | ||||
| 147 | + state.fx_diff_node = node.id | ||||
| 148 | + return [] | ||||
added
aic/passes/optimize.py
+222 −0
@@ -0,0 +1,222 @@ | |||||
| 1 | +# ============================================================================= | ||||
| 2 | +# Projet : AIR — Accounting Intermediate Representation | ||||
| 3 | +# Auteur : Simon-Pierre Boucher | ||||
| 4 | +# Contact : contact@spboucher.ai | ||||
| 5 | +# Fichier : optimize.py | ||||
| 6 | +# Description : Optimization passes — duplicate detection, netting, fusion (Phase 6). | ||||
| 7 | +# ============================================================================= | ||||
| 8 | +"""Optimization passes (the LLVM optimization-pass analogue). | ||||
| 9 | + | ||||
| 10 | +Opt-in (compile with optimize=True); the default pipeline is untouched. | ||||
| 11 | +All three preserve the double-entry invariant, which the pass manager | ||||
| 12 | +re-verifies after each of them, and full provenance: every optimized line's | ||||
| 13 | +provenance node points back to the lines it absorbed. | ||||
| 14 | + | ||||
| 15 | +- DuplicateDetectionPass — events with identical economic content but | ||||
| 16 | + different ids get a warning (AIR-W800); a human decides, nothing is dropped. | ||||
| 17 | +- NettingPass — a Refund referencing a Sale compiled in the same batch is | ||||
| 18 | + netted against it: one net entry (or none, when fully offset) replaces both. | ||||
| 19 | +- FusionPass — N same-day payments with the same posting shape become one | ||||
| 20 | + batch entry (50 identical payouts -> 1 entry, amounts summed per line). | ||||
| 21 | +""" | ||||
| 22 | +from __future__ import annotations | ||||
| 23 | + | ||||
| 24 | +import hashlib | ||||
| 25 | +import json | ||||
| 26 | +from collections import defaultdict | ||||
| 27 | +from decimal import Decimal | ||||
| 28 | + | ||||
| 29 | +from aic.diagnostics import Diagnostic, Severity | ||||
| 30 | +from aic.passes.base import Pass | ||||
| 31 | +from aic.unit import CompilationUnit | ||||
| 32 | +from core.events import EconomicEvent, EventType | ||||
| 33 | +from core.journal import JournalEntry, JournalLine, Side | ||||
| 34 | +from core.money import Money | ||||
| 35 | + | ||||
| 36 | + | ||||
| 37 | +def _event_fingerprint(event: EconomicEvent) -> str: | ||||
| 38 | + """Economic-content hash: identical business facts, ignoring id and meta.""" | ||||
| 39 | + payload = json.loads(event.model_dump_json(exclude_none=True)) | ||||
| 40 | + payload.pop("id", None) | ||||
| 41 | + payload.pop("meta", None) | ||||
| 42 | + return hashlib.sha256( | ||||
| 43 | + json.dumps(payload, sort_keys=True).encode("utf-8")).hexdigest() | ||||
| 44 | + | ||||
| 45 | + | ||||
| 46 | +class DuplicateDetectionPass(Pass): | ||||
| 47 | + name = "duplicate-detection" | ||||
| 48 | + | ||||
| 49 | + def run(self, unit: CompilationUnit) -> list[Diagnostic]: | ||||
| 50 | + diags: list[Diagnostic] = [] | ||||
| 51 | + by_content: dict[str, list[str]] = defaultdict(list) | ||||
| 52 | + for event in unit.document.events: | ||||
| 53 | + by_content[_event_fingerprint(event)].append(event.id) | ||||
| 54 | + for ids in by_content.values(): | ||||
| 55 | + if len(ids) > 1: | ||||
| 56 | + diags.append(Diagnostic( | ||||
| 57 | + code="AIR-W800", severity=Severity.WARNING, | ||||
| 58 | + message=( | ||||
| 59 | + f"{len(ids)} events carry identical economic content: " | ||||
| 60 | + + ", ".join(ids) | ||||
| 61 | + ), | ||||
| 62 | + location=f"events {ids[0]} .. {ids[-1]}", | ||||
| 63 | + suggestion="if these are duplicates, remove all but one and " | ||||
| 64 | + "recompile; if legitimate repeats, add a " | ||||
| 65 | + "distinguishing description", | ||||
| 66 | + origin_pass=self.name, | ||||
| 67 | + )) | ||||
| 68 | + return diags | ||||
| 69 | + | ||||
| 70 | + | ||||
| 71 | +class NettingPass(Pass): | ||||
| 72 | + """Net Refund entries against the Sale they reverse (same compilation).""" | ||||
| 73 | + | ||||
| 74 | + name = "netting" | ||||
| 75 | + | ||||
| 76 | + def run(self, unit: CompilationUnit) -> list[Diagnostic]: | ||||
| 77 | + diags: list[Diagnostic] = [] | ||||
| 78 | + by_event = {e.source_event_id: e for e in unit.entries} | ||||
| 79 | + | ||||
| 80 | + for event in unit.document.events: | ||||
| 81 | + if event.type is not EventType.REFUND or not event.related_event: | ||||
| 82 | + continue | ||||
| 83 | + refund_entry = by_event.get(event.id) | ||||
| 84 | + sale_entry = by_event.get(event.related_event) | ||||
| 85 | + if refund_entry is None or sale_entry is None: | ||||
| 86 | + continue | ||||
| 87 | + | ||||
| 88 | + # per-(account, sale side): refund lines mirror sale lines, so a | ||||
| 89 | + # refund debit offsets the sale's credit on the same account | ||||
| 90 | + sale_amts: dict[tuple[str, Side], Money] = { | ||||
| 91 | + (l.account.code, l.side): l.amount for l in sale_entry.lines} | ||||
| 92 | + refund_amts: dict[tuple[str, Side], Money] = {} | ||||
| 93 | + refund_prov: dict[tuple[str, Side], str | None] = {} | ||||
| 94 | + for l in refund_entry.lines: | ||||
| 95 | + mirrored = Side.CREDIT if l.side is Side.DEBIT else Side.DEBIT | ||||
| 96 | + refund_amts[(l.account.code, mirrored)] = l.amount | ||||
| 97 | + refund_prov[(l.account.code, mirrored)] = l.provenance_id | ||||
| 98 | + if set(refund_amts) - set(sale_amts): | ||||
| 99 | + continue # shapes differ; not safely nettable | ||||
| 100 | + if any((sale_amts[k] - refund_amts[k]).is_negative() | ||||
| 101 | + for k in refund_amts): | ||||
| 102 | + continue # refund exceeds sale; leave both entries alone | ||||
| 103 | + | ||||
| 104 | + net_lines: list[JournalLine] = [] | ||||
| 105 | + for sale_line in sale_entry.lines: | ||||
| 106 | + key = (sale_line.account.code, sale_line.side) | ||||
| 107 | + refunded = refund_amts.get(key) | ||||
| 108 | + net = (sale_line.amount - refunded) if refunded else sale_line.amount | ||||
| 109 | + if net.is_zero(): | ||||
| 110 | + continue | ||||
| 111 | + inputs = tuple(p for p in (sale_line.provenance_id, | ||||
| 112 | + refund_prov.get(key)) if p) | ||||
| 113 | + node = unit.provenance.define( | ||||
| 114 | + kind="netting", | ||||
| 115 | + operation=f"net:{event.related_event}-{event.id}", | ||||
| 116 | + amount=net, | ||||
| 117 | + inputs=inputs, | ||||
| 118 | + ) | ||||
| 119 | + net_lines.append(JournalLine( | ||||
| 120 | + account=sale_line.account, side=sale_line.side, amount=net, | ||||
| 121 | + memo=f"net of {sale_entry.id} minus {refund_entry.id}", | ||||
| 122 | + provenance_id=node.id, | ||||
| 123 | + )) | ||||
| 124 | + | ||||
| 125 | + unit.entries.remove(sale_entry) | ||||
| 126 | + unit.entries.remove(refund_entry) | ||||
| 127 | + if net_lines: | ||||
| 128 | + unit.entries.append(JournalEntry( | ||||
| 129 | + id=f"je_net_{event.related_event}", | ||||
| 130 | + date=refund_entry.date, | ||||
| 131 | + description=(f"Netting: {sale_entry.description} " | ||||
| 132 | + f"minus {refund_entry.description}"), | ||||
| 133 | + lines=tuple(net_lines), | ||||
| 134 | + source_event_id=f"net({event.related_event},{event.id})", | ||||
| 135 | + policy_set=sale_entry.policy_set, | ||||
| 136 | + policy_version=sale_entry.policy_version, | ||||
| 137 | + )) | ||||
| 138 | + detail = "netted into one entry" | ||||
| 139 | + else: | ||||
| 140 | + detail = "fully offset: no entry posted" | ||||
| 141 | + diags.append(Diagnostic( | ||||
| 142 | + code="AIR-N801", severity=Severity.NOTE, | ||||
| 143 | + message=(f"refund '{event.id}' netted against sale " | ||||
| 144 | + f"'{event.related_event}' ({detail})"), | ||||
| 145 | + location=f"events {event.related_event}, {event.id}", | ||||
| 146 | + origin_pass=self.name, | ||||
| 147 | + )) | ||||
| 148 | + return diags | ||||
| 149 | + | ||||
| 150 | + | ||||
| 151 | +class FusionPass(Pass): | ||||
| 152 | + """Fuse same-day payments with identical posting shape into one batch.""" | ||||
| 153 | + | ||||
| 154 | + name = "fusion" | ||||
| 155 | + FUSABLE = (EventType.PAYMENT_RECEIVED, EventType.PAYMENT_SENT) | ||||
| 156 | + | ||||
| 157 | + def run(self, unit: CompilationUnit) -> list[Diagnostic]: | ||||
| 158 | + diags: list[Diagnostic] = [] | ||||
| 159 | + events_by_id = {e.id: e for e in unit.document.events} | ||||
| 160 | + | ||||
| 161 | + groups: dict[tuple, list[JournalEntry]] = defaultdict(list) | ||||
| 162 | + for entry in unit.entries: | ||||
| 163 | + event = events_by_id.get(entry.source_event_id) | ||||
| 164 | + if event is None or event.type not in self.FUSABLE: | ||||
| 165 | + continue | ||||
| 166 | + shape = tuple(sorted( | ||||
| 167 | + (l.account.code, l.side.value, l.amount.currency) | ||||
| 168 | + for l in entry.lines)) | ||||
| 169 | + groups[(event.type, entry.date, shape)].append(entry) | ||||
| 170 | + | ||||
| 171 | + for (etype, date, shape), entries in groups.items(): | ||||
| 172 | + if len(entries) < 2: | ||||
| 173 | + continue | ||||
| 174 | + summed: dict[tuple[str, str, str], Decimal] = defaultdict(Decimal) | ||||
| 175 | + accounts = {} | ||||
| 176 | + inputs: dict[tuple[str, str, str], list[str]] = defaultdict(list) | ||||
| 177 | + for entry in entries: | ||||
| 178 | + for line in entry.lines: | ||||
| 179 | + key = (line.account.code, line.side.value, | ||||
| 180 | + line.amount.currency) | ||||
| 181 | + summed[key] += line.amount.amount | ||||
| 182 | + accounts[line.account.code] = line.account | ||||
| 183 | + if line.provenance_id: | ||||
| 184 | + inputs[key].append(line.provenance_id) | ||||
| 185 | + | ||||
| 186 | + batch_hash = hashlib.sha256( | ||||
| 187 | + ",".join(sorted(e.id for e in entries)).encode("utf-8") | ||||
| 188 | + ).hexdigest()[:8] | ||||
| 189 | + lines = [] | ||||
| 190 | + for (code, side, ccy), amount in sorted(summed.items()): | ||||
| 191 | + money = Money(amount, ccy) | ||||
| 192 | + node = unit.provenance.define( | ||||
| 193 | + kind="fusion", | ||||
| 194 | + operation=f"fuse:{len(entries)}x{etype.value}", | ||||
| 195 | + amount=money, | ||||
| 196 | + inputs=tuple(inputs[(code, side, ccy)]), | ||||
| 197 | + ) | ||||
| 198 | + lines.append(JournalLine( | ||||
| 199 | + account=accounts[code], side=Side(side), amount=money, | ||||
| 200 | + memo=f"batch of {len(entries)} {etype.value} entries", | ||||
| 201 | + provenance_id=node.id, | ||||
| 202 | + )) | ||||
| 203 | + for entry in entries: | ||||
| 204 | + unit.entries.remove(entry) | ||||
| 205 | + unit.entries.append(JournalEntry( | ||||
| 206 | + id=f"je_batch_{batch_hash}", | ||||
| 207 | + date=date, | ||||
| 208 | + description=f"Batch: {len(entries)} x {etype.value}", | ||||
| 209 | + lines=tuple(lines), | ||||
| 210 | + source_event_id=( | ||||
| 211 | + "batch(" + ",".join(e.source_event_id for e in entries) + ")"), | ||||
| 212 | + policy_set=entries[0].policy_set, | ||||
| 213 | + policy_version=entries[0].policy_version, | ||||
| 214 | + )) | ||||
| 215 | + diags.append(Diagnostic( | ||||
| 216 | + code="AIR-N800", severity=Severity.NOTE, | ||||
| 217 | + message=(f"fused {len(entries)} {etype.value} entries dated " | ||||
| 218 | + f"{date} into one batch entry (je_batch_{batch_hash})"), | ||||
| 219 | + location=f"entries {', '.join(e.id for e in entries)}", | ||||
| 220 | + origin_pass=self.name, | ||||
| 221 | + )) | ||||
| 222 | + return diags | ||||
added
aic/passes/posting.py
+241 −0
@@ -0,0 +1,241 @@ | |||||
| 1 | +# ============================================================================= | ||||
| 2 | +# Projet : AIR — Accounting Intermediate Representation | ||||
| 3 | +# Auteur : Simon-Pierre Boucher | ||||
| 4 | +# Contact : contact@spboucher.ai | ||||
| 5 | +# Fichier : posting.py | ||||
| 6 | +# Description : Posting pass — lowers economic events into balanced journal entries. | ||||
| 7 | +# ============================================================================= | ||||
| 8 | +"""Posting pass: the lowering stage (AIR -> journal entries). | ||||
| 9 | + | ||||
| 10 | +Maps each economic event to a balanced double-entry journal entry using the | ||||
| 11 | +account roles defined in the ALSL policy set. Every line carries a provenance | ||||
| 12 | +node so any posted figure traces back to its origin. | ||||
| 13 | + | ||||
| 14 | +Posting rules (v0.1): | ||||
| 15 | +- Sale DR cash/AR (total) CR revenue, CR tax payables | ||||
| 16 | +- Refund DR revenue, DR taxes CR cash/AR (mirror of Sale) | ||||
| 17 | +- Purchase DR expense/asset (+non-recoverable tax), DR recoverable taxes | ||||
| 18 | + CR cash/AP | ||||
| 19 | +- PaymentReceived DR cash (settled) CR AR (booked) [± realized FX] | ||||
| 20 | +- PaymentSent DR AP (booked) CR cash (settled) [± realized FX] | ||||
| 21 | +- OwnerContribution DR cash CR owner capital | ||||
| 22 | +- LoanReceived DR cash CR loan payable | ||||
| 23 | +""" | ||||
| 24 | +from __future__ import annotations | ||||
| 25 | + | ||||
| 26 | +from aic.diagnostics import Diagnostic, Severity | ||||
| 27 | +from aic.passes.base import Pass | ||||
| 28 | +from aic.unit import CompilationUnit, EventState | ||||
| 29 | +from core.events import EconomicEvent, EventType | ||||
| 30 | +from core.journal import Account, JournalEntry, JournalLine, Side | ||||
| 31 | +from core.money import Money | ||||
| 32 | + | ||||
| 33 | + | ||||
| 34 | +class PostingPass(Pass): | ||||
| 35 | + name = "posting" | ||||
| 36 | + | ||||
| 37 | + def run(self, unit: CompilationUnit) -> list[Diagnostic]: | ||||
| 38 | + diags: list[Diagnostic] = [] | ||||
| 39 | + for event in unit.document.events: | ||||
| 40 | + state = unit.event_state(event.id) | ||||
| 41 | + if state.post_subtotal is None: | ||||
| 42 | + continue # a previous pass already rejected this event | ||||
| 43 | + try: | ||||
| 44 | + lines = self._lower(unit, event, state, diags) | ||||
| 45 | + except KeyError as exc: | ||||
| 46 | + diags.append(Diagnostic( | ||||
| 47 | + code="AIR-E600", severity=Severity.ERROR, | ||||
| 48 | + message=str(exc.args[0]), | ||||
| 49 | + location=f"event {event.id}", | ||||
| 50 | + suggestion="add the missing account role to the policy set's " | ||||
| 51 | + "accounts section", | ||||
| 52 | + origin_pass=self.name, | ||||
| 53 | + )) | ||||
| 54 | + continue | ||||
| 55 | + if not lines: | ||||
| 56 | + continue | ||||
| 57 | + unit.entries.append(JournalEntry( | ||||
| 58 | + id=f"je_{event.id}", | ||||
| 59 | + date=event.date, | ||||
| 60 | + description=event.description or event.type.value, | ||||
| 61 | + lines=tuple(lines), | ||||
| 62 | + source_event_id=event.id, | ||||
| 63 | + policy_set=unit.policies.name, | ||||
| 64 | + policy_version=unit.policies.version, | ||||
| 65 | + reverses=( | ||||
| 66 | + f"je_{event.related_event}" | ||||
| 67 | + if event.type is EventType.REFUND and event.related_event | ||||
| 68 | + else None | ||||
| 69 | + ), | ||||
| 70 | + )) | ||||
| 71 | + return diags | ||||
| 72 | + | ||||
| 73 | + # -- helpers --------------------------------------------------------------- | ||||
| 74 | + def _line( | ||||
| 75 | + self, unit: CompilationUnit, account: Account, side: Side, | ||||
| 76 | + amount: Money, memo: str, parent_node: str | None, | ||||
| 77 | + ) -> JournalLine: | ||||
| 78 | + node = unit.provenance.define( | ||||
| 79 | + kind="journal_line", | ||||
| 80 | + operation=f"post:{side.value}:{account.code}", | ||||
| 81 | + amount=amount, | ||||
| 82 | + inputs=(parent_node,) if parent_node else (), | ||||
| 83 | + ) | ||||
| 84 | + return JournalLine( | ||||
| 85 | + account=account, side=side, amount=amount, | ||||
| 86 | + memo=memo, provenance_id=node.id, | ||||
| 87 | + ) | ||||
| 88 | + | ||||
| 89 | + def _settlement_account(self, unit: CompilationUnit, event: EconomicEvent, | ||||
| 90 | + counterparty_role: str) -> Account: | ||||
| 91 | + immediate = bool(event.payment and event.payment.immediate) | ||||
| 92 | + return unit.policies.account("cash" if immediate else counterparty_role) | ||||
| 93 | + | ||||
| 94 | + def _check_gross(self, event: EconomicEvent, computed_total: Money, | ||||
| 95 | + diags: list[Diagnostic], pass_name: str) -> None: | ||||
| 96 | + """Warn when the document's stated gross disagrees with the computed total.""" | ||||
| 97 | + if event.payment is None or event.payment.gross is None: | ||||
| 98 | + return | ||||
| 99 | + gross = event.payment.gross.to_money() | ||||
| 100 | + if gross.currency != computed_total.currency: | ||||
| 101 | + return # gross stated in transaction currency, total in functional | ||||
| 102 | + if gross.amount != computed_total.amount: | ||||
| 103 | + diags.append(Diagnostic( | ||||
| 104 | + code="AIR-W600", severity=Severity.WARNING, | ||||
| 105 | + message=( | ||||
| 106 | + f"stated gross {gross} differs from computed total " | ||||
| 107 | + f"{computed_total}; the computed total is authoritative" | ||||
| 108 | + ), | ||||
| 109 | + location=f"event {event.id}, field payment.gross", | ||||
| 110 | + suggestion="check the source document; a small delta usually means " | ||||
| 111 | + "the issuer rounded differently", | ||||
| 112 | + origin_pass=pass_name, | ||||
| 113 | + )) | ||||
| 114 | + | ||||
| 115 | + # -- lowering per event type ------------------------------------------------- | ||||
| 116 | + def _lower( | ||||
| 117 | + self, unit: CompilationUnit, event: EconomicEvent, | ||||
| 118 | + state: EventState, diags: list[Diagnostic], | ||||
| 119 | + ) -> list[JournalLine]: | ||||
| 120 | + pol = unit.policies | ||||
| 121 | + assert state.post_subtotal is not None | ||||
| 122 | + subtotal = state.post_subtotal | ||||
| 123 | + sub_node = state.post_subtotal_node | ||||
| 124 | + taxes = state.post_taxes | ||||
| 125 | + total = subtotal | ||||
| 126 | + for t in taxes: | ||||
| 127 | + total = total + t.amount | ||||
| 128 | + | ||||
| 129 | + if event.type is EventType.SALE: | ||||
| 130 | + self._check_gross(event, total, diags, self.name) | ||||
| 131 | + counter = self._settlement_account(unit, event, "accounts_receivable") | ||||
| 132 | + lines = [self._line(unit, counter, Side.DEBIT, total, | ||||
| 133 | + "sale: consideration incl. taxes", sub_node)] | ||||
| 134 | + lines.append(self._line(unit, pol.account("revenue"), Side.CREDIT, | ||||
| 135 | + subtotal, "sale: revenue", sub_node)) | ||||
| 136 | + for t in taxes: | ||||
| 137 | + lines.append(self._line( | ||||
| 138 | + unit, pol.account(t.payable_role), Side.CREDIT, t.amount, | ||||
| 139 | + f"{t.code} collected ({t.policy})", t.node_id)) | ||||
| 140 | + return lines | ||||
| 141 | + | ||||
| 142 | + if event.type is EventType.REFUND: | ||||
| 143 | + counter = self._settlement_account(unit, event, "accounts_receivable") | ||||
| 144 | + lines = [self._line(unit, pol.account("revenue"), Side.DEBIT, | ||||
| 145 | + subtotal, "refund: revenue reversal", sub_node)] | ||||
| 146 | + for t in taxes: | ||||
| 147 | + lines.append(self._line( | ||||
| 148 | + unit, pol.account(t.payable_role), Side.DEBIT, t.amount, | ||||
| 149 | + f"{t.code} refunded ({t.policy})", t.node_id)) | ||||
| 150 | + lines.append(self._line(unit, counter, Side.CREDIT, total, | ||||
| 151 | + "refund: consideration incl. taxes", sub_node)) | ||||
| 152 | + return lines | ||||
| 153 | + | ||||
| 154 | + if event.type is EventType.PURCHASE: | ||||
| 155 | + debit_role = state.classification_role or "expense_default" | ||||
| 156 | + debit_base = subtotal | ||||
| 157 | + recoverable = [t for t in taxes if t.recoverable] | ||||
| 158 | + for t in taxes: | ||||
| 159 | + if not t.recoverable: | ||||
| 160 | + debit_base = debit_base + t.amount # non-recoverable tax is a cost | ||||
| 161 | + memo = ( | ||||
| 162 | + f"purchase: capitalized ({state.classification_policy})" | ||||
| 163 | + if state.classify_as == "asset" else "purchase: cost" | ||||
| 164 | + ) | ||||
| 165 | + lines = [self._line(unit, pol.account(debit_role), Side.DEBIT, | ||||
| 166 | + debit_base, memo, sub_node)] | ||||
| 167 | + for t in recoverable: | ||||
| 168 | + lines.append(self._line( | ||||
| 169 | + unit, pol.account(t.receivable_role), Side.DEBIT, t.amount, | ||||
| 170 | + f"{t.code} recoverable ({t.policy})", t.node_id)) | ||||
| 171 | + counter = self._settlement_account(unit, event, "accounts_payable") | ||||
| 172 | + lines.append(self._line(unit, counter, Side.CREDIT, total, | ||||
| 173 | + "purchase: consideration incl. taxes", sub_node)) | ||||
| 174 | + self._check_gross(event, total, diags, self.name) | ||||
| 175 | + return lines | ||||
| 176 | + | ||||
| 177 | + if event.type is EventType.PAYMENT_RECEIVED: | ||||
| 178 | + settled = subtotal | ||||
| 179 | + booked = state.booked_amount or settled | ||||
| 180 | + lines = [self._line(unit, pol.account("cash"), Side.DEBIT, settled, | ||||
| 181 | + "payment received", sub_node)] | ||||
| 182 | + lines.append(self._line( | ||||
| 183 | + unit, pol.account("accounts_receivable"), Side.CREDIT, booked, | ||||
| 184 | + f"settles {event.related_event or 'receivable'}", sub_node)) | ||||
| 185 | + diff = state.fx_diff | ||||
| 186 | + if diff is not None and not diff.is_zero(): | ||||
| 187 | + if diff.is_negative(): | ||||
| 188 | + lines.append(self._line(unit, pol.account("fx_loss"), | ||||
| 189 | + Side.DEBIT, -diff, | ||||
| 190 | + "realized FX loss", state.fx_diff_node)) | ||||
| 191 | + else: | ||||
| 192 | + lines.append(self._line(unit, pol.account("fx_gain"), | ||||
| 193 | + Side.CREDIT, diff, | ||||
| 194 | + "realized FX gain", state.fx_diff_node)) | ||||
| 195 | + return lines | ||||
| 196 | + | ||||
| 197 | + if event.type is EventType.PAYMENT_SENT: | ||||
| 198 | + settled = subtotal | ||||
| 199 | + booked = state.booked_amount or settled | ||||
| 200 | + lines = [self._line( | ||||
| 201 | + unit, pol.account("accounts_payable"), Side.DEBIT, booked, | ||||
| 202 | + f"settles {event.related_event or 'payable'}", sub_node)] | ||||
| 203 | + diff = state.fx_diff | ||||
| 204 | + if diff is not None and not diff.is_zero(): | ||||
| 205 | + if diff.is_negative(): | ||||
| 206 | + # paying fewer functional units than booked -> gain | ||||
| 207 | + lines.append(self._line(unit, pol.account("fx_gain"), | ||||
| 208 | + Side.CREDIT, -diff, | ||||
| 209 | + "realized FX gain", state.fx_diff_node)) | ||||
| 210 | + else: | ||||
| 211 | + lines.append(self._line(unit, pol.account("fx_loss"), | ||||
| 212 | + Side.DEBIT, diff, | ||||
| 213 | + "realized FX loss", state.fx_diff_node)) | ||||
| 214 | + lines.append(self._line(unit, pol.account("cash"), Side.CREDIT, settled, | ||||
| 215 | + "payment sent", sub_node)) | ||||
| 216 | + return lines | ||||
| 217 | + | ||||
| 218 | + if event.type is EventType.OWNER_CONTRIBUTION: | ||||
| 219 | + return [ | ||||
| 220 | + self._line(unit, pol.account("cash"), Side.DEBIT, subtotal, | ||||
| 221 | + "owner contribution", sub_node), | ||||
| 222 | + self._line(unit, pol.account("owner_capital"), Side.CREDIT, subtotal, | ||||
| 223 | + "owner contribution", sub_node), | ||||
| 224 | + ] | ||||
| 225 | + | ||||
| 226 | + if event.type is EventType.LOAN_RECEIVED: | ||||
| 227 | + return [ | ||||
| 228 | + self._line(unit, pol.account("cash"), Side.DEBIT, subtotal, | ||||
| 229 | + "loan proceeds", sub_node), | ||||
| 230 | + self._line(unit, pol.account("loan_payable"), Side.CREDIT, subtotal, | ||||
| 231 | + "loan principal", sub_node), | ||||
| 232 | + ] | ||||
| 233 | + | ||||
| 234 | + diags.append(Diagnostic( | ||||
| 235 | + code="AIR-E601", severity=Severity.ERROR, | ||||
| 236 | + message=f"no posting rule for event type '{event.type.value}'", | ||||
| 237 | + location=f"event {event.id}", | ||||
| 238 | + suggestion="extend the posting pass or use a supported event type", | ||||
| 239 | + origin_pass=self.name, | ||||
| 240 | + )) | ||||
| 241 | + return [] | ||||
added
aic/passes/tax.py
+105 −0
Large diff (105 lines) is not loaded by default to keep the page fast.Load diff
added
aic/passes/validation.py
+109 −0
Large diff (109 lines) is not loaded by default to keep the page fast.Load diff
added
aic/unit.py
+81 −0
Large diff (81 lines) is not loaded by default to keep the page fast.Load diff
added
alsl/__init__.py
+7 −0
Large diff (7 lines) is not loaded by default to keep the page fast.Load diff
added
alsl/evaluator.py
+46 −0
Large diff (46 lines) is not loaded by default to keep the page fast.Load diff
added
alsl/loader.py
+144 −0
Large diff (144 lines) is not loaded by default to keep the page fast.Load diff
added
alsl/model.py
+101 −0
Large diff (101 lines) is not loaded by default to keep the page fast.Load diff
added
alsl/policies/ca-qc-2026.yaml
+100 −0
Large diff (100 lines) is not loaded by default to keep the page fast.Load diff
added
backends/__init__.py
+7 −0
Large diff (7 lines) is not loaded by default to keep the page fast.Load diff
added
backends/base.py
+75 −0
Large diff (75 lines) is not loaded by default to keep the page fast.Load diff
added
backends/generic_csv/__init__.py
+7 −0
Large diff (7 lines) is not loaded by default to keep the page fast.Load diff
added
backends/generic_csv/backend.py
+121 −0
Large diff (121 lines) is not loaded by default to keep the page fast.Load diff
added
backends/native/__init__.py
+7 −0
Large diff (7 lines) is not loaded by default to keep the page fast.Load diff
added
backends/native/backend.py
+73 −0
Large diff (73 lines) is not loaded by default to keep the page fast.Load diff
added
backends/quickbooks/__init__.py
+7 −0
Large diff (7 lines) is not loaded by default to keep the page fast.Load diff
added
backends/quickbooks/backend.py
+94 −0
Large diff (94 lines) is not loaded by default to keep the page fast.Load diff
added
backends/quickbooks/mapper.py
+114 −0
Large diff (114 lines) is not loaded by default to keep the page fast.Load diff
added
backends/quickbooks/transport.py
+147 −0
Large diff (147 lines) is not loaded by default to keep the page fast.Load diff
added
core/__init__.py
+7 −0
Large diff (7 lines) is not loaded by default to keep the page fast.Load diff
added
core/document_io.py
+43 −0
Large diff (43 lines) is not loaded by default to keep the page fast.Load diff
added
core/events.py
+178 −0
Large diff (178 lines) is not loaded by default to keep the page fast.Load diff
added
core/invariant.py
+116 −0
Large diff (116 lines) is not loaded by default to keep the page fast.Load diff
added
core/journal.py
+90 −0
Large diff (90 lines) is not loaded by default to keep the page fast.Load diff
added
core/money.py
+121 −0
Large diff (121 lines) is not loaded by default to keep the page fast.Load diff
added
core/provenance.py
+117 −0
Large diff (117 lines) is not loaded by default to keep the page fast.Load diff
added
docs/adr/0001-implementation-language.md
+44 −0
Large diff (44 lines) is not loaded by default to keep the page fast.Load diff
added
docs/adr/0002-economic-event-model.md
+43 −0
Large diff (43 lines) is not loaded by default to keep the page fast.Load diff
added
docs/adr/0003-monetary-amounts.md
+40 −0
Large diff (40 lines) is not loaded by default to keep the page fast.Load diff
added
docs/cli-reference.md
+87 −0
Large diff (87 lines) is not loaded by default to keep the page fast.Load diff
added
docs/research/accounting-data-standards.md
+164 −0
Large diff (164 lines) is not loaded by default to keep the page fast.Load diff
added
docs/research/accounting-standards.md
+132 −0
Large diff (132 lines) is not loaded by default to keep the page fast.Load diff
added
docs/research/bank-statement-formats.md
+447 −0
Large diff (447 lines) is not loaded by default to keep the page fast.Load diff
added
docs/research/canada-gst-qst.md
+240 −0
Large diff (240 lines) is not loaded by default to keep the page fast.Load diff
added
docs/research/erp-apis.md
+194 −0
Large diff (194 lines) is not loaded by default to keep the page fast.Load diff
added
docs/research/fx-handling.md
+96 −0
Large diff (96 lines) is not loaded by default to keep the page fast.Load diff
added
docs/research/ledger-engines.md
+216 −0
Large diff (216 lines) is not loaded by default to keep the page fast.Load diff
added
docs/research/llm-structured-extraction.md
+116 −0
Large diff (116 lines) is not loaded by default to keep the page fast.Load diff
added
docs/research/llvm-architecture.md
+204 −0
Large diff (204 lines) is not loaded by default to keep the page fast.Load diff
added
docs/spec/air-spec-v0.1.md
+240 −0
Large diff (240 lines) is not loaded by default to keep the page fast.Load diff
added
ingestion/__init__.py
+7 −0
Large diff (7 lines) is not loaded by default to keep the page fast.Load diff
added
ingestion/approval.py
+134 −0
Large diff (134 lines) is not loaded by default to keep the page fast.Load diff
added
ingestion/extractor.py
+233 −0
Large diff (233 lines) is not loaded by default to keep the page fast.Load diff
added
ingestion/pipeline.py
+102 −0
Large diff (102 lines) is not loaded by default to keep the page fast.Load diff
added
kernel/__init__.py
+7 −0
Large diff (7 lines) is not loaded by default to keep the page fast.Load diff
added
kernel/audit.py
+88 −0
Large diff (88 lines) is not loaded by default to keep the page fast.Load diff
added
kernel/bank_formats.py
+222 −0
Large diff (222 lines) is not loaded by default to keep the page fast.Load diff
added
kernel/ledger.py
+203 −0
Large diff (203 lines) is not loaded by default to keep the page fast.Load diff
added
kernel/reconcile.py
+139 −0
Large diff (139 lines) is not loaded by default to keep the page fast.Load diff
added
kernel/reporting.py
+187 −0
Large diff (187 lines) is not loaded by default to keep the page fast.Load diff
added
kernel/workspace.py
+126 −0
Large diff (126 lines) is not loaded by default to keep the page fast.Load diff
added
pyproject.toml
+66 −0
Large diff (66 lines) is not loaded by default to keep the page fast.Load diff
added
schemas/air-0.1.schema.json
+565 −0
Large diff (566 lines) is not loaded by default to keep the page fast.Load diff
added
scripts/check_headers.py
+74 −0
Large diff (74 lines) is not loaded by default to keep the page fast.Load diff
added
sdk/__init__.py
+7 −0
Large diff (7 lines) is not loaded by default to keep the page fast.Load diff
added
sdk/cli.py
+453 −0
Large diff (453 lines) is not loaded by default to keep the page fast.Load diff
added
sdk/demo_agent.py
+92 −0
Large diff (92 lines) is not loaded by default to keep the page fast.Load diff
added
sdk/syscalls.py
+230 −0
Large diff (230 lines) is not loaded by default to keep the page fast.Load diff
added
tests/__init__.py
+7 −0
Large diff (7 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/bank_statement.csv
+4 −0
Large diff (4 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/bank_statement.mt940
+9 −0
Large diff (9 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/bank_statement.xml
+64 −0
Large diff (64 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/demo_document.yaml
+72 −0
Large diff (72 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/invoice_high_confidence.txt
+12 −0
Large diff (12 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/invoice_low_confidence.txt
+11 −0
Large diff (11 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/invoice_realistic.txt
+24 −0
Large diff (24 lines) is not loaded by default to keep the page fast.Load diff
added
tests/golden/__init__.py
+7 −0
Large diff (7 lines) is not loaded by default to keep the page fast.Load diff
added
tests/golden/cases/fx_payments_equity.yaml
+181 −0
Large diff (181 lines) is not loaded by default to keep the page fast.Load diff
added
tests/golden/cases/purchases.yaml
+78 −0
Large diff (78 lines) is not loaded by default to keep the page fast.Load diff
added
tests/golden/cases/sales_and_refunds.yaml
+148 −0
Large diff (148 lines) is not loaded by default to keep the page fast.Load diff
added
tests/golden/test_golden.py
+93 −0
Large diff (93 lines) is not loaded by default to keep the page fast.Load diff
added
tests/property/__init__.py
+7 −0
Large diff (7 lines) is not loaded by default to keep the page fast.Load diff
added
tests/property/test_balance_invariant.py
+118 −0
Large diff (118 lines) is not loaded by default to keep the page fast.Load diff
added
tests/test_core.py
+221 −0
Large diff (221 lines) is not loaded by default to keep the page fast.Load diff
added
tests/test_incremental.py
+128 −0
Large diff (128 lines) is not loaded by default to keep the page fast.Load diff
added
tests/test_ingestion.py
+152 −0
Large diff (152 lines) is not loaded by default to keep the page fast.Load diff
added
tests/test_llm_live.py
+74 −0
Large diff (74 lines) is not loaded by default to keep the page fast.Load diff
added
tests/test_optimize.py
+130 −0
Large diff (130 lines) is not loaded by default to keep the page fast.Load diff
added
tests/test_quickbooks.py
+140 −0
Large diff (140 lines) is not loaded by default to keep the page fast.Load diff
added
tests/test_reconcile.py
+164 −0
Large diff (164 lines) is not loaded by default to keep the page fast.Load diff
added
tests/test_syscalls.py
+188 −0
Large diff (188 lines) is not loaded by default to keep the page fast.Load diff
added
tests/test_workspace.py
+105 −0
Large diff (106 lines) is not loaded by default to keep the page fast.Load diff