dodil/erp-brick-gl
DODIL ERP brick: general ledger
tests/test_v6.py
"""Spec v6 (seam-gap PR 2): group rows carry account_type and sign_class, fx by month, company tax rate and timezone, migration status,
and the lines behind a balance by group account, paged."""
import fake_odoo
import pytest
from conftest import ACCOUNTANT, AUDITOR, CONSOLIDATION, OWNER, TAXC, api, must, person, refused
from test_group_and_ai import gid, group_chart
from test_migration import LOAD, doors, load, reconcile
from world import account_id, books, invoice, line, post
READER = person("cons-drill@svc.example", ["gl:journal:read", "gl:company:read", "gl:period:read"])
def map_group(pairs):
for code, g in pairs:
must(api("POST", "/accounts/group-mapping", ACCOUNTANT, {"account_id": str(account_id(code)), "group_account_id": str(gid(g)),
"valid_from": "2026-01-01"}))
def test_gl_38_group_rows_name_their_account_type_and_sign_class_so_translation_can_pick_the_rate():
books()
group_chart()
invoice("INV-G2", on="2026-10-10")
post("COST-G2", "x", "2026-10-12", [line(account="6100", debit="300.00"), line(account="2100", credit="300.00")], source_module="inventory")
map_group([("4000", "G-4000"), ("1100", "G-1000"), ("6100", "G-6000")])
r = must(api("GET", "/group-reporting/balances", CONSOLIDATION, {"company_code": "UK01", "ledger_id": "LEAD", "period_id": "2026-10"}))
got = {x["group_account_id"]: (x["account_type"], x["sign_class"]) for x in r["rows"]}
assert got == {str(gid("G-4000")): ("income", "income_statement"), str(gid("G-1000")): ("asset", "balance_sheet"),
str(gid("G-6000")): ("expense", "income_statement")}
def test_gl_33_on_is_the_newest_rate_on_or_before_and_a_month_is_exactly_that_months_average():
books()
for d, rt, rate in (("2026-09-29", "spot", "0.86"), ("2026-10-01", "spot", "0.87"), ("2026-09-30", "average", "0.855"),
("2026-08-31", "average", "0.84")):
must(api("POST", "/fx-rates", ACCOUNTANT, {"from_currency": "EUR", "rate": rate, "rate_date": d, "rate_type": rt}))
assert must(api("GET", "/fx-rates/EUR", TAXC, {"on": "2026-09-30"}))["rate_date"] == "2026-09-29"
assert must(api("GET", "/fx-rates/EUR", TAXC, {"on": "2026-10-15"}))["rate"] == "0.870000"
r = must(api("GET", "/fx-rates/EUR", TAXC, {"type": "average", "month": "2026-09"}))
assert (r["rate_date"], r["rate"]) == ("2026-09-30", "0.855000")
s, b = api("GET", "/fx-rates/EUR", TAXC, {"type": "average", "month": "2026-10"}) # never the nearest
assert s == 404 and b["gate"] == "fx_rate" and "[fx_rate]" in b["detail"] and b["fix"]
refused(api("GET", "/fx-rates/EUR", TAXC, {"type": "average", "month": "2026-09", "on": "2026-09-30"}), 422, "not both")
refused(api("GET", "/fx-rates/EUR", TAXC, {"month": "2026-09"}), 422, "type=average")
def test_gl_61_one_company_answers_its_statutory_tax_rate_and_the_timezone_but_the_list_does_not():
books()
c = must(api("GET", "/companies/UK01", ACCOUNTANT))
assert c["statutory_tax_rate"] is None and c["business_timezone"] == "Europe/London"
must(api("POST", "/controls", OWNER, {"key": "statutory_tax_rate", "value": "0.25", "company_code": "UK01"}))
refused(api("POST", "/controls", OWNER, {"key": "statutory_tax_rate", "value": "25%", "company_code": "UK01"}), 422, "between 0 and 1")
refused(api("POST", "/settings", ACCOUNTANT, {"key": "statutory_tax_rate", "value": "0.2", "company_code": "UK01"}), 403, "control")
assert "statutory_tax_rate" not in must(api("GET", "/companies", ACCOUNTANT))[0]
def test_gl_34_the_migration_status_is_none_without_a_run_and_follows_the_run():
books()
assert must(api("GET", "/migration/status", AUDITOR, {"db": "acme"})) == {
"db": "acme", "run_id": None, "status": "none", "cutover_date": None, "reconciled_at": None, "signed_by": None, "signed_at": None}
@pytest.fixture
def odoo_world():
f = fake_odoo.install()
yield f
fake_odoo.uninstall()
def test_gl_34_the_status_names_loaded_then_reconciled_with_its_time(odoo_world):
must(load())
s = must(api("GET", "/migration/status", LOAD, {"db": "acme"}))
assert (s["status"], s["run_id"], s["cutover_date"], s["reconciled_at"], s["signed_at"]) == ("loaded", "odoo:acme:2026-09-30", "2026-09-30", None, None)
reconcile()
s = must(api("GET", "/migration/status", LOAD, {"db": "acme"}))
assert s["status"] == "reconciled" and s["reconciled_at"] and s["signed_by"] is None
def test_gl_04_lines_by_group_account_are_paged_and_end_on_the_closing_balance():
books()
group_chart()
for n, day in enumerate(("2026-10-05", "2026-10-06", "2026-10-07"), start=1):
invoice(f"INV-P{n}", on=day, net="100.00", vat="0.01")
map_group([("1100", "G-1000")])
q = {"company_code": "UK01", "from": "2026-10-01", "to": "2026-10-31", "group_account_id": str(gid("G-1000"))}
one = must(api("GET", f"/accounts/{gid('G-1000')}/lines", READER, {**q, "limit": 2}))
two = must(api("GET", f"/accounts/{gid('G-1000')}/lines", READER, {**q, "limit": 2, "offset": 2}))
assert (len(one["lines"]), one["more"], len(two["lines"]), two["more"]) == (2, True, 1, False)
assert two["lines"][-1]["running_balance"] == one["closing_balance"] == two["closing_balance"] == "300.03"
assert one["lines"][0]["entry_no"] and "bp_id" in one["lines"][0]
refused(api("GET", f"/accounts/{gid('G-1000')}/lines", CONSOLIDATION, q), 403) # a person or client needs gl:journal:read