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 : test_quickbooks.py6# Description : Tests — QuickBooks backend, entirely offline via the mock transport.7# =============================================================================8"""QuickBooks backend tests. NO network, NO QuickBooks account: everything9runs against MockQboTransport, which simulates the QBO behaviors documented10in docs/research/erp-apis.md (requestid replay, error 6140, assigned Ids)."""11from __future__ import annotations1213from datetime import date14from decimal import Decimal15from pathlib import Path1617import pytest1819from aic.compiler import compile_document20from alsl.loader import load_policy_set21from backends.quickbooks.backend import QuickBooksBackend22from backends.quickbooks.mapper import (23 AccountMappingError,24 contra_body,25 doc_number,26 map_entry,27)28from backends.quickbooks.transport import MockQboTransport, QboError, _dumps_exact29from core.events import AirDocument3031ROOT = Path(__file__).resolve().parents[1]32POLICIES = load_policy_set(ROOT / "alsl" / "policies" / "ca-qc-2026.yaml")333435def _journal():36 document = AirDocument.model_validate({37 "events": [{38 "id": "evt_qbo_sale", "type": "Sale", "date": date(2026, 7, 15),39 "amount": {"amount": "1000.00", "currency": "CAD"},40 "tax": {"jurisdiction": "CA-QC"},41 }]42 })43 journal, _ = compile_document(document, POLICIES)44 return journal454647# --- mapper (pure, offline) ------------------------------------------------------48def test_mapper_shapes_a_balanced_qbo_journal_entry() -> None:49 journal = _journal()50 body = map_entry(journal.entries[0])51 assert body["TxnDate"] == "2026-07-15"52 assert body["DocNumber"] == "je_evt_qbo_sale"53 postings = [l["JournalEntryLineDetail"]["PostingType"] for l in body["Line"]]54 assert postings.count("Debit") == 1 and postings.count("Credit") == 355 debits = sum(l["Amount"] for l in body["Line"]56 if l["JournalEntryLineDetail"]["PostingType"] == "Debit")57 credits = sum(l["Amount"] for l in body["Line"]58 if l["JournalEntryLineDetail"]["PostingType"] == "Credit")59 assert debits == credits == Decimal("1149.75")60 assert all(isinstance(l["Amount"], Decimal) for l in body["Line"]) # never float616263def test_doc_number_respects_qbo_21_char_limit() -> None:64 long_id = "je_evt_" + "x" * 4065 dn = doc_number(long_id)66 assert len(dn) <= 2167 assert dn == doc_number(long_id) # stable686970def test_missing_account_mapping_is_a_clear_error() -> None:71 journal = _journal()72 with pytest.raises(AccountMappingError, match="1100"):73 map_entry(journal.entries[0], account_map={"9999": {"value": "1"}})747576def test_contra_body_swaps_sides() -> None:77 body = map_entry(_journal().entries[0])78 contra = contra_body(body)79 assert contra["DocNumber"].startswith("R")80 originals = [l["JournalEntryLineDetail"]["PostingType"] for l in body["Line"]]81 contras = [l["JournalEntryLineDetail"]["PostingType"] for l in contra["Line"]]82 assert all(a != b for a, b in zip(originals, contras))838485# --- mock transport behaviors -------------------------------------------------------86def test_requestid_idempotency_never_double_posts() -> None:87 backend = QuickBooksBackend()88 payload = backend.compile(_journal())89 first = backend.post(payload, "batch-1")90 replay = backend.post(payload, "batch-1")91 assert first.details == replay.details92 assert isinstance(backend.transport, MockQboTransport)93 assert len(backend.transport.store) == 1 # one entity, not two949596def test_duplicate_docnumber_with_new_requestid_raises_6140() -> None:97 backend = QuickBooksBackend()98 payload = backend.compile(_journal())99 backend.post(payload, "batch-1")100 with pytest.raises(QboError, match="6140"):101 backend.post(payload, "batch-2") # same DocNumber, different requestid102103104def test_unbalanced_body_rejected_by_mock() -> None:105 transport = MockQboTransport()106 with pytest.raises(QboError, match="6000"):107 transport.create_journal_entry({108 "DocNumber": "bad", "TxnDate": "2026-01-01",109 "Line": [{110 "Amount": Decimal("10"), "DetailType": "JournalEntryLineDetail",111 "JournalEntryLineDetail": {"PostingType": "Debit",112 "AccountRef": {"value": "1"}},113 }],114 }, "r1")115116117def test_reverse_posts_contra_entries() -> None:118 backend = QuickBooksBackend()119 receipt = backend.post(backend.compile(_journal()), "batch-1")120 reversal = backend.reverse(receipt)121 assert isinstance(backend.transport, MockQboTransport)122 assert len(backend.transport.store) == 2123 assert reversal.reversed_reference == receipt.reference124 # reversing again with the same key replays, never double-posts125 backend.reverse(receipt)126 assert len(backend.transport.store) == 2127128129def test_capabilities_declare_the_contract() -> None:130 caps = QuickBooksBackend().capabilities()131 assert caps.posts_remotely and not caps.native_reversal132 assert caps.idempotency == "requestid"133134135# --- exact-decimal serialization (for the real transport, tested offline) -----------136def test_dumps_exact_emits_decimal_literals_not_floats() -> None:137 out = _dumps_exact({"Amount": Decimal("1149.75"), "note": 'keep "quotes"'})138 assert '"Amount": 1149.75' in out139 assert '"note": "keep \\"quotes\\""' in out140 assert "1149.750000" not in out # no float artifacts141