spb/air Public MIT
AIR — The Language of Accounting.
Python 100%
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