dodil/erp-brick-gl
DODIL ERP brick: general ledger
tests/test_statements.py
"""GL-53 statement versions, GL-06 profit and loss and balance sheet through a version, GL-54 cash flow, GL-55 budgets."""
from decimal import Decimal
from conftest import ACCOUNTANT, ACCOUNTANT2, AUDITOR, CONTROLLER, api, must, refused
from world import books, close_through, invoice, line, post
NODES = [
{"node_id": "ASSETS", "label": "Assets", "sign": 1, "seq": 1},
{"node_id": "CASH", "parent_node_id": "ASSETS", "label": "Cash", "sign": 1, "seq": 1},
{"node_id": "RECV", "parent_node_id": "ASSETS", "label": "Receivables", "sign": 1, "seq": 2},
{"node_id": "LIAB", "label": "Liabilities", "sign": -1, "seq": 2},
{"node_id": "OVERDRAFT", "parent_node_id": "LIAB", "label": "Overdraft", "sign": -1, "seq": 1},
{"node_id": "OTHERL", "parent_node_id": "LIAB", "label": "Other liabilities", "sign": -1, "seq": 2},
{"node_id": "EQUITY", "label": "Equity", "sign": -1, "seq": 3},
{"node_id": "INCOME", "label": "Income", "sign": -1, "seq": 4},
{"node_id": "EXPENSE", "label": "Expenses", "sign": 1, "seq": 5},
]
ASG = [
{"from_code": "1200", "to_code": "1299", "node_id": "CASH", "dc_rule": "debit_balance", "other_node_id": "OVERDRAFT"},
{"from_code": "1100", "node_id": "RECV"},
{"from_code": "1400", "node_id": "RECV"},
{"from_code": "2100", "to_code": "2399", "node_id": "OTHERL"},
{"from_code": "3000", "node_id": "EQUITY"},
{"from_code": "4000", "to_code": "4999", "node_id": "INCOME"},
{"from_code": "6000", "to_code": "6999", "node_id": "EXPENSE"},
]
def fsv(**kw):
return api("POST", "/statement-versions", ACCOUNTANT, {"fsv_id": "IFRS-BS-PL", "name": "IFRS", "kind": "balance_sheet_pl", "chart_id": "MAIN",
"nodes": NODES, "assignments": ASG, **kw})
def node(tree, nid):
for n in tree:
if n["node_id"] == nid:
return n
got = node(n["children"], nid)
if got:
return got
return None
def test_publishing_needs_every_account_with_a_balance_assigned_exactly_once():
books()
invoice("INV-1", on="2026-10-10")
must(fsv(assignments=[a for a in ASG if a["from_code"] != "4000" and a["from_code"] != "1100"]))
r = refused(api("POST", "/statement-versions/IFRS-BS-PL/publish-statement-version", ACCOUNTANT, {}), 409, "[fsv_complete]", "1100", "4000")
must(fsv(assignments=ASG + [{"from_code": "4000", "node_id": "INCOME"}]))
refused(api("POST", "/statement-versions/IFRS-BS-PL/publish-statement-version", ACCOUNTANT, {}), 409, "assigned more than once", "4000")
must(fsv())
pub = must(api("POST", "/statement-versions/IFRS-BS-PL/publish-statement-version", ACCOUNTANT, {}))
assert pub["status"] == "published"
refused(fsv(), 409, "published and never edited")
nxt = must(api("POST", "/statement-versions/IFRS-BS-PL/supersede-statement-version", ACCOUNTANT, {}))
assert nxt["version_no"] == 2 and nxt["status"] == "draft" and len(nxt["nodes"]) == len(NODES)
got = must(api("GET", "/statement-versions/IFRS-BS-PL", AUDITOR, {"version_no": 1}))
assert got["status"] == "superseded"
assert r
def test_the_balance_sheet_and_profit_and_loss_through_a_version():
books()
invoice("INV-1", on="2026-10-10", net="1000.00", vat="200.00")
post("COST-1", "x", "2026-10-12", [line(account="6100", debit="300.00"), line(account="2100", credit="300.00")], source_module="inventory")
post("pay_1", "receipt", "2026-10-20", [line(account="1200", debit="500.00"), line(purpose="receivable", credit="500.00")], source_module="payments")
must(fsv())
must(api("POST", "/statement-versions/IFRS-BS-PL/publish-statement-version", ACCOUNTANT, {}))
pl = must(api("GET", "/reports/statement", ACCOUNTANT, {"fsv_id": "IFRS-BS-PL", "period_id": "2026-10"}))
assert pl["version_no"] == 1 and pl["ledger_id"] == "LEAD" and pl["fsv_id"] == "IFRS-BS-PL"
assert node(pl["nodes"], "INCOME")["total"] == "1000.00" and node(pl["nodes"], "EXPENSE")["total"] == "300.00" and pl["year_result"] == "700.00"
assert node(pl["nodes"], "INCOME")["accounts"][0]["code"] == "4000" # each line opens to the accounts behind it
bs = must(api("GET", "/reports/statement", ACCOUNTANT, {"fsv_id": "IFRS-BS-PL", "as_at": "2026-10-31"}))
assert node(bs["nodes"], "CASH")["total"] == "500.00" and node(bs["nodes"], "RECV")["total"] == "700.00"
assert node(bs["nodes"], "ASSETS")["total"] == "1200.00" and bs["balanced"] is True and bs["year_result"] == "700.00"
# 1200 assets = liabilities (300 accruals) + VAT 200 ... + the year's result 700
assert Decimal(node(bs["nodes"], "OTHERL")["total"]) + Decimal(bs["year_result"]) == Decimal("1200.00")
def test_a_bank_account_with_a_credit_balance_shows_under_overdraft():
books()
post("pay_9", "refund", "2026-10-10", [line(purpose="receivable", debit="100.00"), line(account="1200", credit="100.00")], source_module="payments")
must(fsv())
must(api("POST", "/statement-versions/IFRS-BS-PL/publish-statement-version", ACCOUNTANT, {}))
bs = must(api("GET", "/reports/statement", ACCOUNTANT, {"fsv_id": "IFRS-BS-PL", "as_at": "2026-10-31"}))
assert node(bs["nodes"], "CASH")["total"] == "0.00" and node(bs["nodes"], "OVERDRAFT")["total"] == "100.00"
def test_prior_year_comparison_names_the_version_and_ledger():
books()
invoice("INV-1", on="2026-10-10")
must(fsv())
must(api("POST", "/statement-versions/IFRS-BS-PL/publish-statement-version", ACCOUNTANT, {}))
r = must(api("GET", "/reports/statement", ACCOUNTANT, {"fsv_id": "IFRS-BS-PL", "period_id": "2026-10", "compare": "prior_year"}))
assert node(r["nodes"], "INCOME")["prior_year"] == "0.00" and r["prior_window"]["from"] == "2025-10-01"
# ── cash flow ────────────────────────────────────────────────────────────────────────────────────────────────────────
CF_NODES = [{"node_id": "CASH", "label": "Cash", "cash_flow_class": "cash"},
{"node_id": "WC", "label": "Working capital", "cash_flow_class": "operating"},
{"node_id": "DEP", "label": "Depreciation and other non-cash", "cash_flow_class": "non_cash"},
{"node_id": "FIN", "label": "Financing", "cash_flow_class": "financing"}]
CF_ASG = [{"from_code": "1200", "to_code": "1299", "node_id": "CASH", "basis": "movement"},
{"from_code": "1100", "to_code": "1100", "node_id": "WC", "basis": "movement"},
{"from_code": "1400", "node_id": "WC", "basis": "movement"},
{"from_code": "2100", "to_code": "2200", "node_id": "WC", "basis": "movement"},
{"from_code": "2300", "node_id": "FIN", "basis": "movement"},
{"from_code": "3000", "node_id": "FIN", "basis": "movement"}]
def test_the_cash_flow_statement_equals_the_movement_on_the_cash_accounts():
books()
invoice("INV-1", on="2026-10-10", net="1000.00", vat="0.01")
post("COST-1", "x", "2026-10-12", [line(account="6100", debit="200.00"), line(account="2100", credit="200.00")], source_module="inventory")
post("pay_1", "receipt", "2026-10-20", [line(account="1200", debit="600.00"), line(purpose="receivable", credit="600.00")], source_module="payments")
post("LOAN", "x", "2026-10-21", [line(account="1200", debit="500.00"), line(account="2300", credit="500.00")], source_module="gl")
must(api("POST", "/statement-versions", ACCOUNTANT, {"fsv_id": "CF", "name": "Cash flow", "kind": "cash_flow", "chart_id": "MAIN", "nodes": CF_NODES, "assignments": CF_ASG}))
must(api("POST", "/statement-versions/CF/publish-statement-version", ACCOUNTANT, {}))
cf = must(api("GET", "/reports/statement", ACCOUNTANT, {"fsv_id": "CF", "period_id": "2026-10"}))
assert cf["result"] == "800.00" and cf["cash_movement"] == "1100.00"
assert cf["net_cash_flow"] == "1100.00" and cf["difference"] == "0.00" and cf["gate"]["met"] is True
assert cf["financing_total"] == "500.00" and cf["operating_total"] == "600.00"
def test_unassigned_movements_are_listed_with_the_gate_cash_flow_complete():
books()
post("COST-1", "x", "2026-10-12", [line(account="6100", debit="200.00"), line(account="1400", credit="200.00")], source_module="inventory")
post("pay_1", "receipt", "2026-10-20", [line(account="1200", debit="600.00"), line(account="2300", credit="600.00")], source_module="payments")
asg = [a for a in CF_ASG if a["from_code"] != "1400"]
must(api("POST", "/statement-versions", ACCOUNTANT, {"fsv_id": "CF", "name": "Cash flow", "kind": "cash_flow", "chart_id": "MAIN", "nodes": CF_NODES, "assignments": asg}))
pub = api("POST", "/statement-versions/CF/publish-statement-version", ACCOUNTANT, {})
refused(pub, 409, "[fsv_complete]", "1400")
def test_unrealised_fx_on_cash_is_its_own_line():
books()
must(api("POST", "/accounts", ACCOUNTANT, {"chart_id": "MAIN", "code": "1250", "type": "asset", "subtype": "bank", "currency": "USD", "monetary": True}))
asg = CF_ASG
must(api("POST", "/statement-versions", ACCOUNTANT, {"fsv_id": "CF", "name": "Cash flow", "kind": "cash_flow", "chart_id": "MAIN", "nodes": CF_NODES, "assignments": asg}))
post("USD-IN", "receipt", "2026-09-10", [line(account="1250", debit="10000.00"), line(purpose="revenue", credit="10000.00")], currency="USD",
source_module="payments", fx_rate="0.780000")
must(api("POST", "/settings", ACCOUNTANT, {"key": "revaluation", "value": True, "company_code": "UK01"}))
must(api("POST", "/fx-rates", ACCOUNTANT, {"from_currency": "USD", "rate": "0.790000", "rate_date": "2026-09-30", "rate_type": "closing"}))
must(api("POST", "/periods/2026-09/run-fx-revaluation-balances", ACCOUNTANT, {}))
must(api("POST", "/statement-versions/CF/publish-statement-version", ACCOUNTANT, {}))
cf = must(api("GET", "/reports/statement", ACCOUNTANT, {"fsv_id": "CF", "period_id": "2026-09"}))
assert cf["unrealised_fx_on_cash"] == "100.00" and cf["unrealised_fx_adjustment"] == "-100.00"
assert cf["difference"] == "0.00" and cf["cash_movement"] == "7900.00"
# ── budgets ──────────────────────────────────────────────────────────────────────────────────────────────────────────
def lines_for(*accounts, amount="1000.00", period="2026-10"):
return [{"account_code": a, "period_id": period, "amount": amount} for a in accounts]
def test_a_budget_is_submitted_approved_by_someone_else_and_frozen():
books()
b = must(api("POST", "/budgets", CONTROLLER, {"budget_id": "B2026", "fiscal_year": "FY2026", "lines": lines_for("4000", "6100")}))
assert b["version"] == 1 and b["status"] == "draft" and len(b["lines"]) == 2
must(api("POST", "/budgets/B2026/submit-budget", CONTROLLER, {}))
assert api("POST", "/budgets/B2026/approve-budget", CONTROLLER, {})[0] == 403
ok = must(api("POST", "/budgets/B2026/approve-budget", ACCOUNTANT, {}))
assert ok["status"] == "approved"
refused(api("POST", "/budgets", CONTROLLER, {"budget_id": "B2026", "fiscal_year": "FY2026", "lines": lines_for("4000")}), 409, "frozen")
rev = must(api("POST", "/budgets/B2026/revise-budget", CONTROLLER, {"reason": "June reforecast"}))
assert rev["version"] == 2 and rev["status"] == "draft" and len(rev["lines"]) == 2
assert must(api("GET", "/budgets/B2026", CONTROLLER, {"version": 1}))["status"] == "superseded"
def test_the_submitter_cannot_approve_even_under_a_second_login():
from conftest import person
books()
a = person("pat@firm.example", ["gl:budget:write", "gl:budget:approve", "gl:journal:read", "gl:company:read"], sub="sub-pat")
b = person("pat2@firm.example", ["gl:budget:write", "gl:budget:approve", "gl:journal:read", "gl:company:read"], sub="sub-pat")
must(api("POST", "/budgets", a, {"budget_id": "B1", "fiscal_year": "FY2026", "lines": lines_for("4000")}))
must(api("POST", "/budgets/B1/submit-budget", a, {}))
refused(api("POST", "/budgets/B1/approve-budget", b, {}), 409, "[second_person]")
must(api("POST", "/budgets/B1/return-budget", ACCOUNTANT, {"reason": "more detail"}))
def test_budget_amounts_are_bounded_and_unknown_accounts_refused():
books()
assert api("POST", "/budgets", CONTROLLER, {"budget_id": "B", "fiscal_year": "FY2026", "lines": lines_for("4000", amount="1e20")})[0] == 422
assert api("POST", "/budgets", CONTROLLER, {"budget_id": "B", "fiscal_year": "FY2026", "lines": lines_for("4000", amount="-1")})[0] == 422
refused(api("POST", "/budgets", CONTROLLER, {"budget_id": "B", "fiscal_year": "FY2026", "lines": lines_for("9999")}), 422, "no account 9999")
out = must(api("POST", "/budgets", CONTROLLER, {"budget_id": "B", "fiscal_year": "FY2026", "lines": [],
"csv": "account_code,profit_centre,cost_centre,period_id,amount\n4000,,,2026-10,500.00\n6100,,,2026-10,200.00"}))
assert len(out["lines"]) == 2
def test_plan_against_actual_names_the_version_and_a_budget_never_posts():
books()
invoice("INV-1", on="2026-10-10", net="1200.00", vat="0.01")
post("COST-1", "x", "2026-10-12", [line(account="6100", debit="300.00"), line(account="2100", credit="300.00")], source_module="inventory")
must(api("POST", "/budgets", CONTROLLER, {"budget_id": "B2026", "fiscal_year": "FY2026", "lines": [
{"account_code": "4000", "period_id": "2026-10", "amount": "1000.00"}, {"account_code": "6100", "period_id": "2026-10", "amount": "250.00"}]}))
must(api("POST", "/budgets/B2026/submit-budget", CONTROLLER, {}))
refused(api("GET", "/reports/plan-actual", ACCOUNTANT, {"budget_id": "B2026", "period_id": "2026-10"}), 404, "no approved version")
must(api("POST", "/budgets/B2026/approve-budget", ACCOUNTANT, {}))
pa = must(api("GET", "/reports/plan-actual", ACCOUNTANT, {"budget_id": "B2026", "period_id": "2026-10"}))
rows = {r["code"]: r for r in pa["rows"]}
assert pa["budget_version"] == 1
assert rows["4000"]["actual"] == "1200.00" and rows["4000"]["budget"] == "1000.00" and rows["4000"]["variance"] == "200.00" and rows["4000"]["variance_pct"] == "20.00"
assert rows["6100"]["variance"] == "50.00"
assert len(must(api("GET", "/journals", ACCOUNTANT))["journals"]) == 2 # nothing posted by a budget
must(api("POST", "/statement-versions", ACCOUNTANT, {"fsv_id": "IFRS-BS-PL", "name": "IFRS", "kind": "balance_sheet_pl", "chart_id": "MAIN", "nodes": NODES, "assignments": ASG}))
must(api("POST", "/statement-versions/IFRS-BS-PL/publish-statement-version", ACCOUNTANT, {}))
cmp = must(api("GET", "/reports/statement", ACCOUNTANT, {"fsv_id": "IFRS-BS-PL", "period_id": "2026-10", "compare": "budget", "budget_id": "B2026"}))
inc = node(cmp["nodes"], "INCOME")
assert inc["total"] == "1200.00" and inc["budget"] == "1000.00" and inc["budget_variance"] == "200.00"
_ = (close_through, ACCOUNTANT2)