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)}