dodil/erp-brick-gl

DODIL ERP brick: general ledger

tests/test_dimensions.py

"""GL-40 companies, GL-41 group chart, GL-42 ledgers, GL-43 dimensions, GL-22 document splitting, GL-60 company access."""
from decimal import Decimal

from conftest import ACCOUNTANT, AUDITOR, OWNER, api, must, on_day, person, refused
from world import account_id, books, invoice, line, post


def dims():
    """Segments RETAIL and B2B; profit centres PC-LON (RETAIL), PC-WHS (B2B), PC-HQ; CC-SHOP1 -> PC-LON; 4000 defaults to PC-WHS."""
    for seg in ("RETAIL", "B2B"):
        must(api("POST", "/segments", ACCOUNTANT, {"segment": seg}))
    for pc, seg in (("PC-LON", "RETAIL"), ("PC-WHS", "B2B"), ("PC-HQ", "B2B")):
        must(api("POST", "/profit-centres", ACCOUNTANT, {"profit_centre": pc}))
        must(api("POST", f"/profit-centres/{pc}/segment", ACCOUNTANT, {"segment": seg, "valid_from": "2026-01-01"}))
    must(api("POST", "/cost-centres", ACCOUNTANT, {"cost_centre": "CC-SHOP1", "company_code": "UK01"}))
    must(api("POST", "/cost-centres/CC-SHOP1/profit-centre", ACCOUNTANT, {"profit_centre": "PC-LON", "valid_from": "2026-01-01"}))
    must(api("POST", "/accounts", ACCOUNTANT, {"chart_id": "MAIN", "code": "4000", "default_profit_centre": "PC-WHS"}))
    must(api("POST", "/companies", ACCOUNTANT, {"company_code": "UK01", "default_profit_centre": "PC-HQ"}))


def test_dimensions_are_derived_in_a_fixed_order():
    books()
    dims()
    s, b = post("J-1", "x", "2026-10-15", [line(account="6100", debit="10.00", cost_centre="CC-SHOP1"), line(account="4000", credit="4.00"),
                                           line(account="2100", credit="6.00", profit_centre="PC-HQ")], source_module="inventory")
    assert s == 200, b
    j = must(api("GET", f"/journals/{b['journal_id']}", ACCOUNTANT))
    got = [(ln["profit_centre"]) for ln in j["lines"]]
    assert got == ["PC-LON", "PC-WHS", "PC-HQ"]
    seg = {(s_["line_no"]): s_["segment"] for s_ in j["split_lines"]}
    assert seg == {1: "RETAIL", 2: "B2B", 3: "B2B"}


def test_an_invalid_profit_centre_and_a_field_status_are_refused():
    books()
    dims()
    must(api("POST", "/profit-centres", ACCOUNTANT, {"profit_centre": "PC-DE", "company_codes": "DE01"}))
    s, b = post("J-2", "x", "2026-10-15", [line(account="6100", debit="1.00", profit_centre="PC-DE"), line(account="2100", credit="1.00")],
                source_module="inventory")
    refused((s, b), 422, "[profit_centre_valid]", "PC-DE")
    must(api("POST", "/accounts", ACCOUNTANT, {"chart_id": "MAIN", "code": "6400", "cost_centre": "required"}))
    s, b = post("J-3", "x", "2026-10-15", [line(account="6400", debit="1.00"), line(account="2100", credit="1.00")], source_module="inventory")
    refused((s, b), 422, "[field_status]", "cost centre")
    s, b = post("J-4", "x", "2026-10-15", [line(account="6400", debit="1.00", cost_centre="CC-SHOP1"), line(account="2100", credit="1.00")],
                source_module="inventory")
    assert s == 200, b
    s, b = post("J-5", "x", "2026-10-15", [line(account="6400", debit="1.00", cost_centre="CC-NOPE"), line(account="2100", credit="1.00")],
                source_module="inventory")
    refused((s, b), 422, "[cost_centre_known]")


def test_re_assigning_a_profit_centre_to_a_segment_never_moves_posted_lines():
    books()
    dims()
    s, b = post("J-6", "x", "2026-10-15", [line(account="6100", debit="10.00", profit_centre="PC-LON"), line(account="2100", credit="10.00", profit_centre="PC-LON")],
                source_module="inventory")
    must(api("POST", "/profit-centres/PC-LON/segment", ACCOUNTANT, {"segment": "B2B", "valid_from": "2026-10-23"}))
    j = must(api("GET", f"/journals/{b['journal_id']}", ACCOUNTANT))
    assert {s_["segment"] for s_ in j["split_lines"]} == {"RETAIL"}
    refused(api("POST", "/profit-centres/PC-LON/segment", ACCOUNTANT, {"segment": "RETAIL", "valid_from": "2026-10-22"}), 422, "tomorrow")


# ── GL-22: document splitting ────────────────────────────────────────────────────────────────────────────────────────

def splitting_books():
    books(accounts=True, extra_map={"split_clearing": "2100"})
    dims()
    must(api("POST", "/controls", OWNER, {"key": "document_splitting", "value": "on", "company_code": "UK01", "valid_from": "2026-01-01"}))


def nets(j, key):
    out = {}
    for s_ in j["split_lines"]:
        k = s_[key] or ""
        out[k] = out.get(k, Decimal(0)) + Decimal(s_["functional_debit"]) - Decimal(s_["functional_credit"])
    return out


def test_the_invoice_splits_receivable_and_vat_by_the_base_lines_and_every_segment_balances():
    splitting_books()
    s, b = post("INV-S1", "issue", "2026-10-15", [line(purpose="receivable", debit="1200.00"),
                                                   line(purpose="revenue", credit="600.00", profit_centre="PC-LON"),
                                                   line(purpose="revenue", credit="400.00", profit_centre="PC-WHS"),
                                                   line(account="2200", credit="200.00")])
    assert s == 200, b
    j = must(api("GET", f"/journals/{b['journal_id']}", ACCOUNTANT))
    by_acct = {}
    for s_ in j["split_lines"]:
        by_acct.setdefault(s_["account_id"], {})[s_["profit_centre"]] = Decimal(s_["functional_debit"]) - Decimal(s_["functional_credit"])
    assert by_acct[str(account_id("1100"))] == {"PC-LON": Decimal("720.00"), "PC-WHS": Decimal("480.00")}
    assert by_acct[str(account_id("2200"))] == {"PC-LON": Decimal("-120.00"), "PC-WHS": Decimal("-80.00")}
    assert set(nets(j, "profit_centre").values()) == {Decimal(0)} and set(nets(j, "segment").values()) == {Decimal(0)}


def test_a_payment_follows_the_invoice_it_clears_and_without_it_sits_on_the_default_profit_centre():
    splitting_books()
    post("INV-S2", "issue", "2026-10-15", [line(purpose="receivable", debit="1200.00"), line(purpose="revenue", credit="600.00", profit_centre="PC-LON"),
                                            line(purpose="revenue", credit="400.00", profit_centre="PC-WHS"), line(account="2200", credit="200.00")])
    s, b = post("pay_1", "receipt", "2026-10-20", [line(account="1200", debit="1200.00"),
                                                    line(purpose="receivable", credit="1200.00", against_document="INV-S2")], source_module="payments")
    assert s == 200, b
    j = must(api("GET", f"/journals/{b['journal_id']}", ACCOUNTANT))
    got = {(s_["account_id"], s_["profit_centre"]): Decimal(s_["functional_debit"]) - Decimal(s_["functional_credit"]) for s_ in j["split_lines"]}
    assert got[(str(account_id("1200")), "PC-LON")] == Decimal("720.00") and got[(str(account_id("1100")), "PC-WHS")] == Decimal("-480.00")
    s, b = post("pay_2", "receipt", "2026-10-21", [line(account="1200", debit="50.00"), line(purpose="receivable", credit="50.00")],
                source_module="payments")
    j = must(api("GET", f"/journals/{b['journal_id']}", ACCOUNTANT))
    assert {s_["profit_centre"] for s_ in j["split_lines"]} == {"PC-HQ"}


def test_a_transfer_between_profit_centres_gets_zero_balance_clearing_lines():
    splitting_books()
    s, b = post("TR-1", "x", "2026-10-15", [line(account="6000", debit="100.00", profit_centre="PC-LON"),
                                            line(account="6000", credit="100.00", profit_centre="PC-WHS")], source_module="inventory")
    assert s == 200, b
    j = must(api("GET", f"/journals/{b['journal_id']}", ACCOUNTANT))
    clearing = [s_ for s_ in j["split_lines"] if s_["is_clearing"]]
    assert len(clearing) == 2 and {s_["account_id"] for s_ in clearing} == {str(account_id("2100"))}
    assert set(nets(j, "profit_centre").values()) == {Decimal(0)} and set(nets(j, "segment").values()) == {Decimal(0)}
    tbr = must(api("GET", "/reports/trial-balance", ACCOUNTANT, {"as_at": "2026-10-31", "by": "segment"}))
    assert all(Decimal(g["net"]) == 0 for g in tbr["groups"])


def test_split_amounts_sum_to_the_line_exactly_largest_remainder():
    splitting_books()
    s, b = post("INV-S3", "issue", "2026-10-15", [line(purpose="receivable", debit="100.00"), line(purpose="revenue", credit="33.33", profit_centre="PC-LON"),
                                                   line(purpose="revenue", credit="33.33", profit_centre="PC-WHS"),
                                                   line(purpose="revenue", credit="33.34", profit_centre="PC-HQ")])
    j = must(api("GET", f"/journals/{b['journal_id']}", ACCOUNTANT))
    rec = [Decimal(s_["functional_debit"]) for s_ in j["split_lines"] if s_["account_id"] == str(account_id("1100"))]
    assert sum(rec) == Decimal("100.00") and len(rec) == 3
    assert set(nets(j, "profit_centre").values()) == {Decimal(0)}


def test_splitting_changes_only_on_the_first_day_of_a_financial_year():
    books()
    refused(api("POST", "/controls", OWNER, {"key": "document_splitting", "value": "on", "company_code": "UK01", "valid_from": "2026-11-01"}),
            422, "first day of a financial year")
    must(api("POST", "/controls", OWNER, {"key": "document_splitting", "value": "on", "company_code": "UK01", "valid_from": "2027-01-01"}))


# ── GL-42: ledgers ───────────────────────────────────────────────────────────────────────────────────────────────────

def two_ledgers():
    books()
    must(api("POST", "/ledgers", ACCOUNTANT, {"ledger_id": "LOCAL", "name": "UK GAAP", "kind": "non_leading", "accounting_principle": "FRS102"}))
    must(api("POST", "/companies/UK01/ledgers", ACCOUNTANT, {"ledger_id": "LOCAL", "from_fiscal_year": "FY2026"}))


def test_a_common_journal_counts_in_both_ledgers_and_a_ledger_specific_one_in_its_own():
    two_ledgers()
    assert invoice("INV-L1")[0] == 200
    s, b = post("LEASE-1", "capitalise", "2026-10-15", [line(account="6100", debit="500.00"), line(account="2100", credit="500.00")],
                source_module="gl", ledger_id="LEAD")
    assert s == 200, b
    lead = must(api("GET", "/reports/trial-balance", ACCOUNTANT, {"as_at": "2026-10-31", "ledger_id": "LEAD"}))
    local = must(api("GET", "/reports/trial-balance", ACCOUNTANT, {"as_at": "2026-10-31", "ledger_id": "LOCAL"}))
    assert lead["total_debit"] == "1700.00" and local["total_debit"] == "1200.00"
    assert lead["difference"] in ("0", "0.00") and local["difference"] in ("0", "0.00")


def test_naming_a_ledger_the_company_does_not_keep_is_refused():
    books()
    must(api("POST", "/ledgers", ACCOUNTANT, {"ledger_id": "LOCAL", "kind": "non_leading"}))
    s, b = post("X-1", "x", "2026-10-15", [line(account="6100", debit="1.00"), line(account="2100", credit="1.00")], source_module="gl", ledger_id="LOCAL")
    refused((s, b), 422, "[ledger_assigned]")
    refused(api("POST", "/ledgers", ACCOUNTANT, {"ledger_id": "OTHER", "kind": "leading"}), 409, "exactly one ledger leads")
    refused(api("POST", "/companies/UK01/ledgers", ACCOUNTANT, {"ledger_id": "LOCAL"}), 422, "from_fiscal_year")


# ── GL-40: companies ─────────────────────────────────────────────────────────────────────────────────────────────────

def test_a_company_code_and_currency_are_fixed_once_a_journal_uses_them():
    books()
    assert invoice("INV-C1")[0] == 200
    refused(api("POST", "/companies", ACCOUNTANT, {"company_code": "UK01", "functional_currency": "EUR"}), 409, "functional currency")
    assert api("POST", "/companies", ACCOUNTANT, {"company_code": "uk1"})[0] == 422
    assert api("POST", "/companies", ACCOUNTANT, {"company_code": "TOOLONG"})[0] == 422
    must(api("POST", "/companies", ACCOUNTANT, {"company_code": "UK01", "name": "Renamed"}))


def test_a_second_company_has_its_own_periods_and_the_default_company_keeps_every_built_caller_working():
    books()
    must(api("POST", "/companies", ACCOUNTANT, {"company_code": "DE01", "name": "GmbH", "country": "DE", "functional_currency": "EUR", "chart_id": "MAIN"}))
    must(api("POST", "/fiscal-years/open-year", ACCOUNTANT, {"fiscal_year": "FY2026", "company_code": "DE01"}))
    assert [c["company_code"] for c in must(api("GET", "/companies", AUDITOR))] == ["DE01", "UK01"]
    assert invoice("INV-D1")[0] == 200                                   # no company named: the default (UK01)
    s, b = invoice("INV-D2", company_code="DE01")
    assert s == 422 and "fx_rate" in b["detail"]                         # GBP is foreign to a EUR company: its rate is needed
    must(api("POST", "/fx-rates", ACCOUNTANT, {"from_currency": "GBP", "to_currency": "EUR", "rate": "1.150000", "rate_date": "2026-10-01"}))
    s, b = invoice("INV-D2", company_code="DE01")
    assert s == 200, b
    assert must(api("GET", "/companies/DE01", POSTER_OR(AUDITOR)))["functional_currency"] == "EUR"
    refused(post("X", "x", "2026-10-15", [line(purpose="receivable", debit="1.00"), line(purpose="revenue", credit="1.00")], company_code="ZZ99"), 422, "[company_known]")
    # closing UK01's September leaves DE01's open
    from world import close_through
    close_through("2026-09")
    assert must(api("GET", "/periods/open", AUDITOR, {"date": "2026-09-30", "company_code": "DE01"}))["open"] is True
    assert must(api("GET", "/periods/open", AUDITOR, {"date": "2026-09-30"}))["open"] is False


def POSTER_OR(h):
    return h


# ── GL-60: who sees which company ────────────────────────────────────────────────────────────────────────────────────

def test_company_access_is_dated_and_outside_it_everything_is_404():
    books()
    must(api("POST", "/companies", ACCOUNTANT, {"company_code": "DE01", "name": "GmbH", "country": "DE", "functional_currency": "EUR", "chart_id": "MAIN"}))
    must(api("POST", "/fiscal-years/open-year", ACCOUNTANT, {"fiscal_year": "FY2026", "company_code": "DE01"}))
    uk = invoice("INV-A1")[1]
    bk = person("de.bookkeeper@firm.example", ["gl:journal:read", "gl:journal:write", "gl:company:read", "gl:report:read", "gl:period:read"], sub="sub-de")
    assert api("GET", f"/journals/{uk['journal_id']}", bk)[0] == 200            # no row yet: sees everything
    refused(api("POST", "/company-access", ACCOUNTANT, {"principal_sub": "sub-de", "company_code": "DE01"}), 403)
    must(api("POST", "/company-access", OWNER, {"principal_sub": "sub-de", "principal_email": "de.bookkeeper@firm.example",
                                                      "company_code": "DE01", "valid_from": "2026-10-23"}))
    assert api("GET", f"/journals/{uk['journal_id']}", bk)[0] == 200            # takes effect tomorrow
    with on_day("2026-10-23T09:00:00+00:00"):
        assert api("GET", f"/journals/{uk['journal_id']}", bk)[0] == 404
        assert api("GET", "/companies/UK01", bk)[0] == 404
        assert [c["company_code"] for c in must(api("GET", "/companies", bk))] == ["DE01"]
        assert api("GET", "/reports/trial-balance", bk, {"company_code": "UK01"})[0] == 404
        assert api("GET", "/periods/open", bk, {"date": "2026-10-23", "company_code": "UK01"})[0] == 404
        assert api("POST", "/journals/propose-manual", bk, {"journal_date": "2026-10-20", "company_code": "UK01",
                   "lines": [line(account="6100", debit="1.00"), line(account="2100", credit="1.00")]})[0] == 404
        assert must(api("GET", "/journals", bk))["journals"] == []
        tools_status, _ = api("GET", f"/journals/{uk['journal_id']}", bk)
        assert tools_status == 404


def test_a_remainder_cent_goes_to_one_share_and_the_shares_still_sum_exactly():
    splitting_books()
    s, b = post("INV-S4", "issue", "2026-10-15", [line(purpose="receivable", debit="3.01"), line(purpose="revenue", credit="1.00", profit_centre="PC-LON"),
                                                   line(purpose="revenue", credit="1.00", profit_centre="PC-WHS"),
                                                   line(purpose="revenue", credit="1.00", profit_centre="PC-HQ"), line(account="2200", credit="0.01")])
    assert s == 200, b
    j = must(api("GET", f"/journals/{b['journal_id']}", ACCOUNTANT))
    rec = sorted(Decimal(s_["functional_debit"]) for s_ in j["split_lines"] if s_["account_id"] == str(account_id("1100")))
    vat = sorted(Decimal(s_["functional_credit"]) for s_ in j["split_lines"] if s_["account_id"] == str(account_id("2200")))
    assert rec == [Decimal("1.00"), Decimal("1.00"), Decimal("1.01")] and sum(vat) == Decimal("0.01") and len(vat) == 1
    assert set(nets(j, "profit_centre").values()) == {Decimal(0)}