SPB Git

spb/air

Public MIT

AIR — The Language of Accounting.

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>
simon-pierre boucher committed (Aug 5, 2026) Browse files

98 changed files +11,076 −0

98 changed files
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+[![CI](https://github.com/spboucher-ai/air/actions/workflows/ci.yml/badge.svg)](https://github.com/spboucher-ai/air/actions/workflows/ci.yml)
17+[![Python](https://img.shields.io/badge/python-3.11%2B-3776AB?logo=python&logoColor=white)](pyproject.toml)
18+[![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
19+[![Tests](https://img.shields.io/badge/tests-99%20offline%20%2B%202%20live-brightgreen)](tests/)
20+[![Golden cases](https://img.shields.io/badge/golden%20cases-21-gold)](tests/golden/cases/)
21+[![Amounts](https://img.shields.io/badge/floats-forbidden-red)](docs/adr/0003-monetary-amounts.md)
22+[![Double entry](https://img.shields.io/badge/A%20%3D%20L%20%2B%20E-verified%20after%20every%20pass-blueviolet)](core/invariant.py)
23+[![Ledger](https://img.shields.io/badge/ledger-append--only%20%7C%20hash--chained-orange)](kernel/ledger.py)
24+[![Offline first](https://img.shields.io/badge/works%20offline-no%20ERP%2C%20no%20API%20key%20required-informational)](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
Menu

Theme