SPB Git

spb/air Public MIT

AIR — The Language of Accounting.

Python 100%
4.3 KB · 115 lines python
Raw Blame History
1# =============================================================================2# Projet : AIR — Accounting Intermediate Representation3# Auteur : Simon-Pierre Boucher4# Contact : contact@spboucher.ai5# Fichier : mapper.py6# Description : Lower a CompiledJournal to QuickBooks Online JournalEntry payloads (pure, offline).7# =============================================================================8"""CompiledJournal -> QBO JournalEntry payloads.910Pure lowering, no network: this module runs identically with or without a11QuickBooks account, so the whole backend is testable offline.1213Shape per docs/research/erp-apis.md (QBO API, minorversion=75 baseline):14- Line[] with unsigned Amount + PostingType ("Debit"/"Credit") + AccountRef15- DocNumber max 21 chars (longer ids are truncated with a stable hash suffix)16- amounts stay Decimal end-to-end here; serialization to exact JSON numbers17  is the transport's job (never through float).18"""19from __future__ import annotations2021import hashlib22from typing import Any2324from core.journal import CompiledJournal, JournalEntry, Side2526DOCNUMBER_MAX = 21272829class AccountMappingError(KeyError):30    """An account code has no QuickBooks AccountRef mapping."""313233def doc_number(entry_id: str) -> str:34    """QBO DocNumber (<= 21 chars), stable for a given entry id."""35    if len(entry_id) <= DOCNUMBER_MAX:36        return entry_id37    digest = hashlib.sha256(entry_id.encode("utf-8")).hexdigest()[:6]38    return f"{entry_id[:DOCNUMBER_MAX - 7]}-{digest}"394041def map_entry(42    entry: JournalEntry,43    account_map: dict[str, dict[str, str]] | None = None,44) -> dict[str, Any]:45    """One JournalEntry -> one QBO JournalEntry create body.4647    account_map: our account code -> {"value": <qbo account id>, "name": ...}.48    When None, the code itself is used as the AccountRef value (fine for the49    mock transport and for offline export; a real realm needs the map).50    """51    lines: list[dict[str, Any]] = []52    for line in entry.lines:53        if account_map is not None:54            try:55                ref = account_map[line.account.code]56            except KeyError:57                raise AccountMappingError(58                    f"account '{line.account.code} {line.account.name}' has no "59                    "QuickBooks AccountRef in the account map; add it or post "60                    "to the native backend instead"61                ) from None62        else:63            ref = {"value": line.account.code, "name": line.account.name}64        lines.append({65            "Description": line.memo,66            "Amount": line.amount.amount,          # Decimal, exact67            "DetailType": "JournalEntryLineDetail",68            "JournalEntryLineDetail": {69                "PostingType": "Debit" if line.side is Side.DEBIT else "Credit",70                "AccountRef": dict(ref),71            },72        })73    return {74        "DocNumber": doc_number(entry.id),75        "TxnDate": entry.date.isoformat(),76        "PrivateNote": (77            f"{entry.description} | AIR event {entry.source_event_id} | "78            f"policies {entry.policy_set}@{entry.policy_version}"79        ),80        "Line": lines,81        "CurrencyRef": {"value": entry.lines[0].amount.currency},82    }838485def map_journal(86    journal: CompiledJournal,87    account_map: dict[str, dict[str, str]] | None = None,88) -> list[dict[str, Any]]:89    return [map_entry(entry, account_map) for entry in journal.entries]909192def contra_body(body: dict[str, Any]) -> dict[str, Any]:93    """Reversal payload: QBO has no native journal-entry reversal, so we94    synthesize an exact contra entry (sides swapped, amounts identical)."""95    contra = {96        "DocNumber": doc_number("R" + body["DocNumber"]),97        "TxnDate": body["TxnDate"],98        "PrivateNote": "REVERSAL of " + body["DocNumber"] + " | " + body["PrivateNote"],99        "Line": [],100        "CurrencyRef": dict(body["CurrencyRef"]),101    }102    for line in body["Line"]:103        detail = line["JournalEntryLineDetail"]104        contra["Line"].append({105            "Description": "reversal: " + line["Description"],106            "Amount": line["Amount"],107            "DetailType": "JournalEntryLineDetail",108            "JournalEntryLineDetail": {109                "PostingType": ("Credit" if detail["PostingType"] == "Debit"110                                else "Debit"),111                "AccountRef": dict(detail["AccountRef"]),112            },113        })114    return contra115