SPB Git

spb/air Public MIT

AIR — The Language of Accounting.

Python 100%
5.5 KB · 141 lines python
Raw Blame History
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