# ============================================================================= # Projet : AIR — Accounting Intermediate Representation # Auteur : Simon-Pierre Boucher # Contact : contact@spboucher.ai # Fichier : backend.py # Description : QuickBooks Online backend — optional target, fully testable offline. # ============================================================================= """QuickBooks Online backend. QuickBooks is an OPTIONAL export target: AIR's books of record live in the native ledger regardless. This backend lowers compiled journals to QBO JournalEntry payloads and posts them through a pluggable transport — the offline mock by default (no QuickBooks account needed anywhere in the project), the HTTP transport only when the user supplies credentials. Idempotency: every create carries requestid = f"{key}:{DocNumber}" so retries never double-post. Reversal: QBO has no native journal reversal, so reverse() posts exact contra entries (never deletes). """ from __future__ import annotations from typing import Any from backends.base import ( Backend, BackendCapabilities, PostingReceipt, ReversalReceipt, TargetPayload, ) from backends.quickbooks.mapper import contra_body, map_journal from backends.quickbooks.transport import MockQboTransport, QboTransport from core.journal import CompiledJournal class QuickBooksBackend(Backend): def __init__( self, transport: QboTransport | None = None, account_map: dict[str, dict[str, str]] | None = None, ): self.transport = transport if transport is not None else MockQboTransport() self.account_map = account_map self._posted: dict[str, dict[str, Any]] = {} # DocNumber -> body posted def capabilities(self) -> BackendCapabilities: return BackendCapabilities( name="quickbooks", posts_remotely=True, native_reversal=False, # contra-entry strategy multi_currency=True, idempotency="requestid", notes="QBO JournalEntry API, minorversion=75; optional target — " "native ledger remains the book of record", ) def compile(self, journal: CompiledJournal) -> TargetPayload: return TargetPayload( backend="quickbooks", format="qbo.journalentry", body=map_journal(journal, self.account_map), ) def post(self, payload: TargetPayload, idempotency_key: str) -> PostingReceipt: bodies = payload.body assert isinstance(bodies, list) details: dict[str, str] = {} for body in bodies: request_id = f"{idempotency_key}:{body['DocNumber']}" entity = self.transport.create_journal_entry(body, request_id) details[body["DocNumber"]] = entity["Id"] self._posted[body["DocNumber"]] = body return PostingReceipt( backend="quickbooks", reference=",".join(details.values()), idempotency_key=idempotency_key, details=details, ) def reverse(self, receipt: PostingReceipt) -> ReversalReceipt: reversed_ids: list[str] = [] for doc_number in receipt.details: body = self._posted.get(doc_number) if body is None: continue contra = contra_body(body) request_id = f"rev:{receipt.idempotency_key}:{contra['DocNumber']}" entity = self.transport.create_journal_entry(contra, request_id) reversed_ids.append(entity["Id"]) return ReversalReceipt( backend="quickbooks", reference=",".join(reversed_ids), reversed_reference=receipt.reference, )