# ============================================================================= # Projet : AIR — Accounting Intermediate Representation # Auteur : Simon-Pierre Boucher # Contact : contact@spboucher.ai # Fichier : backend.py # Description : Generic CSV backend — the first, universal journal export target. # ============================================================================= """Generic CSV backend. Lowers a CompiledJournal to flat CSV rows (one row per journal line) that any accounting system or spreadsheet can import. Reversal emits contra rows (sides swapped) — history is never deleted. """ from __future__ import annotations import csv import io from pathlib import Path from backends.base import ( Backend, BackendCapabilities, PostingReceipt, ReversalReceipt, TargetPayload, ) from core.journal import CompiledJournal, Side COLUMNS = [ "entry_id", "date", "description", "account_code", "account_name", "account_type", "side", "amount", "currency", "memo", "source_event_id", "provenance_id", "policy_set", "policy_version", "reverses", ] class GenericCsvBackend(Backend): """Writes journals to CSV files under an output directory.""" def __init__(self, output_dir: str | Path = "out"): self.output_dir = Path(output_dir) def capabilities(self) -> BackendCapabilities: return BackendCapabilities( name="generic_csv", posts_remotely=False, native_reversal=False, # reversal = contra rows multi_currency=True, idempotency="client-side", notes="universal flat-file journal export", ) def _rows(self, journal: CompiledJournal, *, reverse: bool = False) -> list[list[str]]: rows: list[list[str]] = [] for entry in journal.entries: for line in entry.lines: side = line.side if reverse: side = Side.CREDIT if side is Side.DEBIT else Side.DEBIT rows.append([ ("rev_" if reverse else "") + entry.id, entry.date.isoformat(), ("REVERSAL: " if reverse else "") + entry.description, line.account.code, line.account.name, line.account.type.value, side.value, str(line.amount.amount), line.amount.currency, line.memo, entry.source_event_id, line.provenance_id or "", entry.policy_set, entry.policy_version, (entry.id if reverse else entry.reverses or ""), ]) return rows def compile(self, journal: CompiledJournal) -> TargetPayload: buf = io.StringIO() writer = csv.writer(buf) writer.writerow(COLUMNS) writer.writerows(self._rows(journal)) return TargetPayload(backend="generic_csv", format="csv", body=buf.getvalue()) def post(self, payload: TargetPayload, idempotency_key: str) -> PostingReceipt: self.output_dir.mkdir(parents=True, exist_ok=True) path = self.output_dir / f"journal_{idempotency_key}.csv" if not path.exists(): # idempotent: same key never rewrites path.write_text(str(payload.body), encoding="utf-8") return PostingReceipt( backend="generic_csv", reference=str(path), idempotency_key=idempotency_key, ) def reverse(self, receipt: PostingReceipt) -> ReversalReceipt: original = Path(receipt.reference) reader = csv.reader(io.StringIO(original.read_text(encoding="utf-8"))) rows = list(reader) header, body = rows[0], rows[1:] side_i, entry_i, desc_i, rev_i = ( header.index("side"), header.index("entry_id"), header.index("description"), header.index("reverses"), ) for row in body: row[rev_i] = row[entry_i] row[entry_i] = "rev_" + row[entry_i] row[desc_i] = "REVERSAL: " + row[desc_i] row[side_i] = "credit" if row[side_i] == "debit" else "debit" buf = io.StringIO() writer = csv.writer(buf) writer.writerow(header) writer.writerows(body) path = original.with_name(original.stem + "_reversal.csv") path.write_text(buf.getvalue(), encoding="utf-8") return ReversalReceipt( backend="generic_csv", reference=str(path), reversed_reference=receipt.reference, )