# ============================================================================= # Projet : AIR — Accounting Intermediate Representation # Auteur : Simon-Pierre Boucher # Contact : contact@spboucher.ai # Fichier : transport.py # Description : QBO transports — offline mock (default, no QuickBooks needed) and real HTTP. # ============================================================================= """Transports for the QuickBooks backend. AIR must build and test WITHOUT any QuickBooks access, so the transport is an interface with two implementations: - MockQboTransport (default): a faithful in-memory simulation of the QBO behaviors that matter (per docs/research/erp-apis.md): `requestid` idempotency replay, duplicate-DocNumber error 6140, server-assigned Ids. All tests run against this — no network, no account, ever. - HttpQboTransport: the real thing (stdlib urllib, OAuth2 bearer token, minorversion=75, exact-decimal JSON serialization). It is only constructed when the user explicitly provides credentials; nothing in the test suite or default CLI paths touches it. """ from __future__ import annotations import abc import json import re import urllib.request from decimal import Decimal from typing import Any QBO_MINOR_VERSION = "75" # minorversion baseline since 2025-08-01 (research) class QboError(RuntimeError): def __init__(self, code: str, message: str): self.code = code super().__init__(f"QBO error {code}: {message}") class QboTransport(abc.ABC): """Minimal surface the backend needs: create a JournalEntry.""" @abc.abstractmethod def create_journal_entry(self, body: dict[str, Any], request_id: str) -> dict[str, Any]: """POST a JournalEntry; MUST be idempotent on request_id. Returns the created (or replayed) entity, including its server 'Id'.""" class MockQboTransport(QboTransport): """Offline QBO simulator — the default transport. Simulated behaviors: - requestid replay: same request_id returns the original entity, creates nothing (QBO guarantees this for supported entities); - duplicate DocNumber with a NEW request_id raises error 6140; - server-assigned incremental Ids and SyncToken 0. """ def __init__(self) -> None: self.store: dict[str, dict[str, Any]] = {} # Id -> entity self._by_request: dict[str, str] = {} # request_id -> Id self._by_docnumber: dict[str, str] = {} # DocNumber -> Id self._next_id = 1 def create_journal_entry(self, body: dict[str, Any], request_id: str) -> dict[str, Any]: if request_id in self._by_request: # idempotent replay return self.store[self._by_request[request_id]] doc = str(body.get("DocNumber", "")) if doc and doc in self._by_docnumber: raise QboError( "6140", f"Duplicate Document Number Error: DocNumber '{doc}' already exists", ) debits = sum( Decimal(str(l["Amount"])) for l in body["Line"] if l["JournalEntryLineDetail"]["PostingType"] == "Debit" ) credits = sum( Decimal(str(l["Amount"])) for l in body["Line"] if l["JournalEntryLineDetail"]["PostingType"] == "Credit" ) if debits != credits: raise QboError("6000", f"Journal entry must balance: {debits} != {credits}") entity = dict(body) entity["Id"] = str(self._next_id) entity["SyncToken"] = "0" self._next_id += 1 self.store[entity["Id"]] = entity self._by_request[request_id] = entity["Id"] if doc: self._by_docnumber[doc] = entity["Id"] return entity _DEC_SENTINEL = re.compile(r'"__DEC__(-?\d+(?:\.\d+)?)__"') def _dumps_exact(obj: Any) -> str: """JSON with Decimals emitted as exact numeric literals (never float). Decimals are encoded as strict sentinel strings, then unquoted. The sentinel pattern only matches the exact canonical form generated here, so ordinary string values can never be corrupted. """ def encode(o: Any) -> Any: if isinstance(o, Decimal): return f"__DEC__{o}__" raise TypeError(type(o).__name__) return _DEC_SENTINEL.sub(r"\1", json.dumps(obj, default=encode)) class HttpQboTransport(QboTransport): """Real QuickBooks Online transport. OPTIONAL — requires explicit credentials; never used by tests or default flows. Note: token refresh is the caller's concern for now (Phase 3 scope); pass a valid OAuth2 access token. """ SANDBOX_BASE = "https://sandbox-quickbooks.api.intuit.com" PRODUCTION_BASE = "https://quickbooks.api.intuit.com" def __init__(self, realm_id: str, access_token: str, *, sandbox: bool = True): self.realm_id = realm_id self.access_token = access_token self.base = self.SANDBOX_BASE if sandbox else self.PRODUCTION_BASE def create_journal_entry(self, body: dict[str, Any], request_id: str) -> dict[str, Any]: url = ( f"{self.base}/v3/company/{self.realm_id}/journalentry" f"?minorversion={QBO_MINOR_VERSION}&requestid={request_id}" ) request = urllib.request.Request( url, data=_dumps_exact(body).encode("utf-8"), headers={ "Authorization": f"Bearer {self.access_token}", "Content-Type": "application/json", "Accept": "application/json", }, method="POST", ) with urllib.request.urlopen(request) as response: # pragma: no cover payload = json.loads(response.read().decode("utf-8")) return payload.get("JournalEntry", payload) # pragma: no cover