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