# ============================================================================= # Projet : AIR — Accounting Intermediate Representation # Auteur : Simon-Pierre Boucher # Contact : contact@spboucher.ai # Fichier : test_quickbooks.py # Description : Tests — QuickBooks backend, entirely offline via the mock transport. # ============================================================================= """QuickBooks backend tests. NO network, NO QuickBooks account: everything runs against MockQboTransport, which simulates the QBO behaviors documented in docs/research/erp-apis.md (requestid replay, error 6140, assigned Ids).""" from __future__ import annotations from datetime import date from decimal import Decimal from pathlib import Path import pytest from aic.compiler import compile_document from alsl.loader import load_policy_set from backends.quickbooks.backend import QuickBooksBackend from backends.quickbooks.mapper import ( AccountMappingError, contra_body, doc_number, map_entry, ) from backends.quickbooks.transport import MockQboTransport, QboError, _dumps_exact from core.events import AirDocument ROOT = Path(__file__).resolve().parents[1] POLICIES = load_policy_set(ROOT / "alsl" / "policies" / "ca-qc-2026.yaml") def _journal(): document = AirDocument.model_validate({ "events": [{ "id": "evt_qbo_sale", "type": "Sale", "date": date(2026, 7, 15), "amount": {"amount": "1000.00", "currency": "CAD"}, "tax": {"jurisdiction": "CA-QC"}, }] }) journal, _ = compile_document(document, POLICIES) return journal # --- mapper (pure, offline) ------------------------------------------------------ def test_mapper_shapes_a_balanced_qbo_journal_entry() -> None: journal = _journal() body = map_entry(journal.entries[0]) assert body["TxnDate"] == "2026-07-15" assert body["DocNumber"] == "je_evt_qbo_sale" postings = [l["JournalEntryLineDetail"]["PostingType"] for l in body["Line"]] assert postings.count("Debit") == 1 and postings.count("Credit") == 3 debits = sum(l["Amount"] for l in body["Line"] if l["JournalEntryLineDetail"]["PostingType"] == "Debit") credits = sum(l["Amount"] for l in body["Line"] if l["JournalEntryLineDetail"]["PostingType"] == "Credit") assert debits == credits == Decimal("1149.75") assert all(isinstance(l["Amount"], Decimal) for l in body["Line"]) # never float def test_doc_number_respects_qbo_21_char_limit() -> None: long_id = "je_evt_" + "x" * 40 dn = doc_number(long_id) assert len(dn) <= 21 assert dn == doc_number(long_id) # stable def test_missing_account_mapping_is_a_clear_error() -> None: journal = _journal() with pytest.raises(AccountMappingError, match="1100"): map_entry(journal.entries[0], account_map={"9999": {"value": "1"}}) def test_contra_body_swaps_sides() -> None: body = map_entry(_journal().entries[0]) contra = contra_body(body) assert contra["DocNumber"].startswith("R") originals = [l["JournalEntryLineDetail"]["PostingType"] for l in body["Line"]] contras = [l["JournalEntryLineDetail"]["PostingType"] for l in contra["Line"]] assert all(a != b for a, b in zip(originals, contras)) # --- mock transport behaviors ------------------------------------------------------- def test_requestid_idempotency_never_double_posts() -> None: backend = QuickBooksBackend() payload = backend.compile(_journal()) first = backend.post(payload, "batch-1") replay = backend.post(payload, "batch-1") assert first.details == replay.details assert isinstance(backend.transport, MockQboTransport) assert len(backend.transport.store) == 1 # one entity, not two def test_duplicate_docnumber_with_new_requestid_raises_6140() -> None: backend = QuickBooksBackend() payload = backend.compile(_journal()) backend.post(payload, "batch-1") with pytest.raises(QboError, match="6140"): backend.post(payload, "batch-2") # same DocNumber, different requestid def test_unbalanced_body_rejected_by_mock() -> None: transport = MockQboTransport() with pytest.raises(QboError, match="6000"): transport.create_journal_entry({ "DocNumber": "bad", "TxnDate": "2026-01-01", "Line": [{ "Amount": Decimal("10"), "DetailType": "JournalEntryLineDetail", "JournalEntryLineDetail": {"PostingType": "Debit", "AccountRef": {"value": "1"}}, }], }, "r1") def test_reverse_posts_contra_entries() -> None: backend = QuickBooksBackend() receipt = backend.post(backend.compile(_journal()), "batch-1") reversal = backend.reverse(receipt) assert isinstance(backend.transport, MockQboTransport) assert len(backend.transport.store) == 2 assert reversal.reversed_reference == receipt.reference # reversing again with the same key replays, never double-posts backend.reverse(receipt) assert len(backend.transport.store) == 2 def test_capabilities_declare_the_contract() -> None: caps = QuickBooksBackend().capabilities() assert caps.posts_remotely and not caps.native_reversal assert caps.idempotency == "requestid" # --- exact-decimal serialization (for the real transport, tested offline) ----------- def test_dumps_exact_emits_decimal_literals_not_floats() -> None: out = _dumps_exact({"Amount": Decimal("1149.75"), "note": 'keep "quotes"'}) assert '"Amount": 1149.75' in out assert '"note": "keep \\"quotes\\""' in out assert "1149.750000" not in out # no float artifacts