SPB Git

spb/air Public MIT

AIR — The Language of Accounting.

Python 100%
4.5 KB · 122 lines python
Raw Blame History
1# =============================================================================2# Projet : AIR — Accounting Intermediate Representation3# Auteur : Simon-Pierre Boucher4# Contact : contact@spboucher.ai5# Fichier : backend.py6# Description : Generic CSV backend — the first, universal journal export target.7# =============================================================================8"""Generic CSV backend.910Lowers a CompiledJournal to flat CSV rows (one row per journal line) that any11accounting system or spreadsheet can import. Reversal emits contra rows12(sides swapped) — history is never deleted.13"""14from __future__ import annotations1516import csv17import io18from pathlib import Path1920from backends.base import (21    Backend,22    BackendCapabilities,23    PostingReceipt,24    ReversalReceipt,25    TargetPayload,26)27from core.journal import CompiledJournal, Side2829COLUMNS = [30    "entry_id", "date", "description", "account_code", "account_name",31    "account_type", "side", "amount", "currency", "memo",32    "source_event_id", "provenance_id", "policy_set", "policy_version",33    "reverses",34]353637class GenericCsvBackend(Backend):38    """Writes journals to CSV files under an output directory."""3940    def __init__(self, output_dir: str | Path = "out"):41        self.output_dir = Path(output_dir)4243    def capabilities(self) -> BackendCapabilities:44        return BackendCapabilities(45            name="generic_csv",46            posts_remotely=False,47            native_reversal=False,      # reversal = contra rows48            multi_currency=True,49            idempotency="client-side",50            notes="universal flat-file journal export",51        )5253    def _rows(self, journal: CompiledJournal, *, reverse: bool = False) -> list[list[str]]:54        rows: list[list[str]] = []55        for entry in journal.entries:56            for line in entry.lines:57                side = line.side58                if reverse:59                    side = Side.CREDIT if side is Side.DEBIT else Side.DEBIT60                rows.append([61                    ("rev_" if reverse else "") + entry.id,62                    entry.date.isoformat(),63                    ("REVERSAL: " if reverse else "") + entry.description,64                    line.account.code,65                    line.account.name,66                    line.account.type.value,67                    side.value,68                    str(line.amount.amount),69                    line.amount.currency,70                    line.memo,71                    entry.source_event_id,72                    line.provenance_id or "",73                    entry.policy_set,74                    entry.policy_version,75                    (entry.id if reverse else entry.reverses or ""),76                ])77        return rows7879    def compile(self, journal: CompiledJournal) -> TargetPayload:80        buf = io.StringIO()81        writer = csv.writer(buf)82        writer.writerow(COLUMNS)83        writer.writerows(self._rows(journal))84        return TargetPayload(backend="generic_csv", format="csv", body=buf.getvalue())8586    def post(self, payload: TargetPayload, idempotency_key: str) -> PostingReceipt:87        self.output_dir.mkdir(parents=True, exist_ok=True)88        path = self.output_dir / f"journal_{idempotency_key}.csv"89        if not path.exists():             # idempotent: same key never rewrites90            path.write_text(str(payload.body), encoding="utf-8")91        return PostingReceipt(92            backend="generic_csv",93            reference=str(path),94            idempotency_key=idempotency_key,95        )9697    def reverse(self, receipt: PostingReceipt) -> ReversalReceipt:98        original = Path(receipt.reference)99        reader = csv.reader(io.StringIO(original.read_text(encoding="utf-8")))100        rows = list(reader)101        header, body = rows[0], rows[1:]102        side_i, entry_i, desc_i, rev_i = (103            header.index("side"), header.index("entry_id"),104            header.index("description"), header.index("reverses"),105        )106        for row in body:107            row[rev_i] = row[entry_i]108            row[entry_i] = "rev_" + row[entry_i]109            row[desc_i] = "REVERSAL: " + row[desc_i]110            row[side_i] = "credit" if row[side_i] == "debit" else "debit"111        buf = io.StringIO()112        writer = csv.writer(buf)113        writer.writerow(header)114        writer.writerows(body)115        path = original.with_name(original.stem + "_reversal.csv")116        path.write_text(buf.getvalue(), encoding="utf-8")117        return ReversalReceipt(118            backend="generic_csv",119            reference=str(path),120            reversed_reference=receipt.reference,121        )122