# ============================================================================= # Projet : AIR — Accounting Intermediate Representation # Auteur : Simon-Pierre Boucher # Contact : contact@spboucher.ai # Fichier : ledger.py # Description : Native ledger — append-only, hash-chained event store with balance projections. # ============================================================================= """AIR's own ledger: the standalone mode. AIR does not require any third-party system. Compiled journals can be posted to this native ledger: an append-only, hash-chained JSONL store (each record carries the SHA-256 of the previous record over canonical JSON — tamper evidence, per docs/research/ledger-engines.md). Balances, trial balance and financial statements are projections computed from the store. Corrections never mutate: reversals append contra entries. """ from __future__ import annotations import hashlib import json from collections import defaultdict from dataclasses import dataclass, field from decimal import Decimal from pathlib import Path from core.journal import ( Account, AccountType, CompiledJournal, JournalEntry, JournalLine, Side, ) from core.money import Money GENESIS_HASH = "0" * 64 def _canonical(record: dict[str, object]) -> str: return json.dumps(record, sort_keys=True, separators=(",", ":"), default=str) def _entry_record(entry: JournalEntry, idempotency_key: str) -> dict[str, object]: return { "id": entry.id, "date": entry.date.isoformat(), "description": entry.description, "source_event_id": entry.source_event_id, "policy_set": entry.policy_set, "policy_version": entry.policy_version, "reverses": entry.reverses, "idempotency_key": idempotency_key, "lines": [ { "account_code": line.account.code, "account_name": line.account.name, "account_type": line.account.type.value, "side": line.side.value, "amount": str(line.amount.amount), "currency": line.amount.currency, "memo": line.memo, "provenance_id": line.provenance_id, } for line in entry.lines ], } @dataclass class Ledger: """Append-only ledger with optional JSONL persistence.""" path: Path | None = None _entries: list[JournalEntry] = field(default_factory=list) _records: list[dict[str, object]] = field(default_factory=list) _keys: set[str] = field(default_factory=set) def __post_init__(self) -> None: if self.path is not None and self.path.exists(): for raw in self.path.read_text(encoding="utf-8").splitlines(): if raw.strip(): self._load_record(json.loads(raw)) # -- append ------------------------------------------------------------------ def post_journal(self, journal: CompiledJournal, idempotency_key: str) -> int: """Append all entries of a compiled journal. Idempotent per key. Returns the number of entries actually appended (0 on a replay). """ if idempotency_key in self._keys: return 0 appended = 0 for entry in journal.entries: self._append(entry, idempotency_key) appended += 1 self._keys.add(idempotency_key) return appended def reverse_entry(self, entry_id: str, idempotency_key: str) -> JournalEntry: """Append a contra entry that reverses a posted entry. Never deletes.""" original = next((e for e in self._entries if e.id == entry_id), None) if original is None: raise KeyError(f"ledger has no entry '{entry_id}'") contra = JournalEntry( id=f"rev_{original.id}", date=original.date, description=f"REVERSAL: {original.description}", lines=tuple( JournalLine( account=line.account, side=Side.CREDIT if line.side is Side.DEBIT else Side.DEBIT, amount=line.amount, memo=f"reversal of {original.id}: {line.memo}", provenance_id=line.provenance_id, ) for line in original.lines ), source_event_id=original.source_event_id, policy_set=original.policy_set, policy_version=original.policy_version, reverses=original.id, ) self._append(contra, idempotency_key) return contra def _append(self, entry: JournalEntry, idempotency_key: str) -> None: record = _entry_record(entry, idempotency_key) prev = self._records[-1]["hash"] if self._records else GENESIS_HASH record["prev_hash"] = prev record["hash"] = hashlib.sha256( (str(prev) + _canonical({k: v for k, v in record.items() if k != "hash"})) .encode("utf-8") ).hexdigest() self._records.append(record) self._entries.append(entry) if self.path is not None: self.path.parent.mkdir(parents=True, exist_ok=True) with self.path.open("a", encoding="utf-8") as f: f.write(_canonical(record) + "\n") def _load_record(self, record: dict[str, object]) -> None: lines = tuple( JournalLine( account=Account( code=str(l["account_code"]), name=str(l["account_name"]), type=AccountType(str(l["account_type"])), ), side=Side(str(l["side"])), amount=Money(Decimal(str(l["amount"])), str(l["currency"])), memo=str(l.get("memo", "")), provenance_id=(str(l["provenance_id"]) if l.get("provenance_id") else None), ) for l in record["lines"] # type: ignore[union-attr] ) from datetime import date as _date entry = JournalEntry( id=str(record["id"]), date=_date.fromisoformat(str(record["date"])), description=str(record["description"]), lines=lines, source_event_id=str(record["source_event_id"]), policy_set=str(record.get("policy_set", "")), policy_version=str(record.get("policy_version", "")), reverses=(str(record["reverses"]) if record.get("reverses") else None), ) self._records.append(record) self._entries.append(entry) self._keys.add(str(record.get("idempotency_key", ""))) # -- integrity & projections --------------------------------------------------- def verify_chain(self) -> bool: prev = GENESIS_HASH for record in self._records: expected = hashlib.sha256( (prev + _canonical({k: v for k, v in record.items() if k != "hash"})) .encode("utf-8") ).hexdigest() if record.get("hash") != expected or record.get("prev_hash") != prev: return False prev = str(record["hash"]) return True @property def entries(self) -> list[JournalEntry]: return list(self._entries) def accounts(self) -> dict[str, Account]: out: dict[str, Account] = {} for entry in self._entries: for line in entry.lines: out[line.account.code] = line.account return dict(sorted(out.items())) def balances(self) -> dict[str, dict[str, Decimal]]: """Normal-side balance per account code per currency.""" out: dict[str, dict[str, Decimal]] = defaultdict(lambda: defaultdict(Decimal)) for entry in self._entries: for line in entry.lines: sign = 1 if line.side is line.account.type.normal_side else -1 out[line.account.code][line.amount.currency] += sign * line.amount.amount return {code: dict(per) for code, per in sorted(out.items())}