dodil/erp-brick-ar
DODIL ERP brick: accounts receivable
tests/test_wiring.py
"""The cross-brick wiring pass (2026-10-22): what this brick asks and answers matches its neighbours' REAL use cases, and the
three rules the pass found missing - numbers never fill a gap, a functional currency decides what is foreign, tax frozen at issue."""
from decimal import Decimal
from conftest import (
APPROVER,
BILLER,
BP,
OWNER,
api,
billing,
control,
hand_draft,
issue,
issued,
line,
migrate_payments,
must,
on_day,
)
from test_migration import RUN, explain_and_sign, load, reconcile
def test_a_new_number_never_fills_a_gap_below_the_highest_number_in_its_series(odoo, world):
"""Odoo carried INV/2026/00001, 00002, 00004, 00005 (00003 deleted in Odoo). The owner's format renders that very series:
the next numbers are 00006 onwards - never 00003, the gap below Odoo's high-water mark - and then above our own."""
must(load(doors={"numbering": "new_prefix"}))
migrate_payments(world, odoo)
reconcile()
explain_and_sign(world)
must(control("invoice_number_format", "INV/{yyyy}/{seq:5}"))
with on_day("2026-11-21"):
billing()
got = [issue(hand_draft()["invoice_id"], world=world)["invoice_no"] for _ in range(3)]
assert got == ["INV/2026/00006", "INV/2026/00007", "INV/2026/00008"]
def test_foreign_means_not_the_functional_currency_a_dated_owner_control(world):
"""functional_currency (ar:settings:controls, default GBP): an invoice in any other currency needs its rate to issue; the
control is dated like every control (never today or earlier once set), and a EUR business needs no rate for EUR."""
billing()
eur = hand_draft(currency="EUR")
gates = {g["gate"]: g for g in must(api("GET", f"/invoices/{eur['invoice_id']}/readiness", BILLER))["gates"]}
assert gates["fx_rate"]["met"] is False and "kept in GBP" in gates["fx_rate"]["reason"]
assert control("functional_currency", "eur", "2026-11-21")[0] == 422
must(control("functional_currency", "EUR", "2026-11-22"))
assert control("functional_currency", "USD", "2026-11-20")[0] == 409 # once set, a change dated today or earlier is refused
with on_day("2026-11-22"):
gates = {g["gate"]: g for g in must(api("GET", f"/invoices/{eur['invoice_id']}/readiness", BILLER))["gates"]}
assert gates["fx_rate"]["met"] is True
gbp = hand_draft()
gates = {g["gate"]: g for g in must(api("GET", f"/invoices/{gbp['invoice_id']}/readiness", BILLER))["gates"]}
assert gates["fx_rate"]["met"] is False and "kept in EUR" in gates["fx_rate"]["reason"]
def test_issue_freezes_the_tax_in_tax_under_the_invoice_id_with_the_number(world):
"""tax keeps the draft's tax under document_ref = invoice_id (compute), checks the contents before issue without the
number (ours to claim), and freezes it once the issue has committed, with the number, the dates and the bill-to."""
inv = issued(world)
assert world.tax.calls[-1]["document_ref"] == inv["invoice_id"]
assert inv["tax_frozen"] is True and world.tax.frozen[inv["invoice_id"]]["invoice_number"] == inv["invoice_no"]
assert world.tax.frozen[inv["invoice_id"]]["customer_address"] == "1 High St, London"
again = must(api("POST", f"/invoices/{inv['invoice_id']}/issue", BILLER, {})) # a repeat asks again: a no-op there
assert again["invoice_no"] == inv["invoice_no"] and again["tax_frozen"] is True
def test_payments_reads_invoice_totals_by_customer_and_the_credit_allocates_after_the_approval_commits(world):
inv = issued(world)
totals = must(api("GET", "/invoice-totals", BILLER, {"bp_id": BP}))["invoices"]
assert totals[inv["invoice_id"]]["total"] == "240.00" and totals[inv["invoice_id"]]["lifecycle"] == "issued"
assert inv["invoice_id"] in must(api("GET", "/invoice-totals", BILLER))["invoices"]
world.payments.down = True
d = must(api("POST", "/credit-notes", BILLER, {"invoice_id": inv["invoice_id"], "reason": "service credit", "lines": [line(qty="1")]}))
world.payments.credit_faces[d["credit_id"]] = Decimal(d["total"])
first = must(api("POST", f"/credit-notes/{d['credit_id']}/approve", APPROVER, {}))
assert first["status"] == "approved" and first["allocated"] is None and "approve it again" in first["allocation_pending"]
world.payments.down = False
again = must(api("POST", f"/credit-notes/{d['credit_id']}/approve", APPROVER, {}))
assert again["credit_no"] == first["credit_no"] and again["allocated"] == {"invoice_id": inv["invoice_id"], "amount": Decimal("120.00")} \
or again["allocated"]["amount"] in ("120.00", Decimal("120.00"))
assert [a for a in world.payments.allocations if a[1] == d["credit_id"]] == [("credit_note", d["credit_id"], inv["invoice_id"], Decimal("120.00"))]
def test_the_odoo_map_and_the_open_balance_other_bricks_read(odoo, world):
must(load())
migrate_payments(world, odoo)
reconcile()
got = must(api("GET", "/migration-map/odooprod/account.move", OWNER, {"ids": "100,999"}))
assert got == [{"odoo_model": "account.move", "odoo_id": "100", "ours": "odoo:odooprod:100"}]
bal = must(api("GET", "/migration/open-balances", OWNER, {"db": "odooprod"}))
assert bal["run_id"] == RUN and bal["balances"] and all({"bp_id", "currency", "open_balance"} <= set(b) for b in bal["balances"])
assert api("GET", "/migration/open-balances", OWNER, {"db": "nope"})[0] == 404