dodil/erp-brick-gl
DODIL ERP brick: general ledger
tests/test_reports.py
"""GL-04 an account's lines, GL-05 the trial balance, GL-10 the rollup, GL-61 the reads other modules make, business time, the soft close."""
from decimal import Decimal
from conftest import ACCOUNTANT, AUDITOR, OWNER, PAYMENTS, api, must, on_day, person, refused
from world import account_id, books, close_through, invoice, line, post
from brick_gl.infrastructure import business_time
def test_an_accounts_lines_run_to_the_balance_oldest_first_with_their_source():
books()
invoice("INV-1", on="2026-10-05", net="1000.00", vat="0.01")
post("pay_1", "receipt", "2026-10-12", [line(account="1200", debit="400.00"), line(purpose="receivable", credit="400.00", bp_id="5001")], source_module="payments")
post("pay_2", "receipt", "2026-10-20", [line(account="1200", debit="100.00"), line(purpose="receivable", credit="100.00", bp_id="5001")], source_module="payments")
r = must(api("GET", f"/accounts/{account_id('1100')}/lines", AUDITOR, {"from": "2026-10-01", "to": "2026-10-31"}))
assert [ln["journal_date"] for ln in r["lines"]] == ["2026-10-05", "2026-10-12", "2026-10-20"]
assert [ln["running_balance"] for ln in r["lines"]] == ["1000.01", "600.01", "500.01"] and r["closing_balance"] == "500.01"
assert r["lines"][1]["source_module"] == "payments" and r["lines"][1]["source_document"] == "pay_1" and r["lines"][1]["number"].startswith("CASH/")
later = must(api("GET", f"/accounts/{account_id('1100')}/lines", AUDITOR, {"from": "2026-10-15"}))
assert later["opening_balance"] == "600.01" and later["closing_balance"] == "500.01"
bal = must(api("GET", f"/accounts/{account_id('1100')}/balance", PAYMENTS, {"as_of": "2026-10-15"}))
assert bal == {"account_id": str(account_id("1100")), "company_code": "UK01", "as_of": "2026-10-15", "balance": "600.01", "currency": "GBP"}
assert api("GET", "/accounts/12345/balance", PAYMENTS)[0] == 404
def test_the_trial_balance_has_opening_movement_and_closing_for_a_period_and_nets_to_zero_exactly():
books()
invoice("INV-1", on="2026-09-10", net="1000.00", vat="200.00")
invoice("INV-2", on="2026-10-10", net="500.00", vat="100.00")
tb = must(api("GET", "/reports/trial-balance", ACCOUNTANT, {"period_id": "2026-10"}))
rec = next(r for r in tb["rows"] if r["code"] == "1100")
assert (rec["opening"], rec["debit"], rec["credit"], rec["closing"]) == ("1200.00", "600.00", "0.00", "1800.00")
rev = next(r for r in tb["rows"] if r["code"] == "4000")
assert (rev["opening"], rev["credit"], rev["closing"]) == ("-1000.00", "500.00", "-1500.00")
assert tb["difference"] == "0.00" and tb["total_debit"] == tb["total_credit"] == "1800.00"
asat = must(api("GET", "/reports/trial-balance", ACCOUNTANT, {"as_at": "2026-09-30"}))
assert next(r for r in asat["rows"] if r["code"] == "1100")["closing"] == "1200.00"
def test_the_rollup_totals_everything_beneath_a_parent_however_deep():
books()
must(api("POST", "/accounts", ACCOUNTANT, {"chart_id": "MAIN", "code": "6", "name": "Costs", "type": "expense"}))
must(api("POST", "/accounts", ACCOUNTANT, {"chart_id": "MAIN", "code": "61", "name": "Premises", "type": "expense", "parent_code": "6"}))
must(api("POST", "/accounts", ACCOUNTANT, {"chart_id": "MAIN", "code": "6100", "type": "expense", "parent_code": "61"}))
must(api("POST", "/accounts", ACCOUNTANT, {"chart_id": "MAIN", "code": "6300", "type": "expense", "parent_code": "6"}))
refused(api("POST", "/accounts", ACCOUNTANT, {"chart_id": "MAIN", "code": "6", "parent_code": "6100"}), 422, "loop")
post("C1", "x", "2026-10-10", [line(account="6100", debit="100.00"), line(account="2100", credit="100.00")], source_module="inventory")
post("C2", "x", "2026-10-11", [line(account="6300", debit="50.00"), line(account="2100", credit="50.00")], source_module="inventory")
r = must(api("GET", f"/accounts/{account_id('6')}/rollup", ACCOUNTANT, {"as_at": "2026-10-31"}))
assert r["total"] == "150.00" and r["own"] == "0.00"
kids = {k["code"]: k for k in r["children"]}
assert kids["61"]["total"] == "100.00" and kids["61"]["children"][0]["code"] == "6100" and kids["6300"]["total"] == "50.00"
def test_the_reads_other_modules_make_company_cost_centre_and_ledgers():
books()
must(api("POST", "/cost-centres", ACCOUNTANT, {"cost_centre": "CC-410", "company_code": "UK01"}))
must(api("POST", "/cost-centres", ACCOUNTANT, {"cost_centre": "CC-999", "company_code": "UK01", "active_until": "2026-10-01"}))
inv = person("inventory@svc.example", ["gl:cost_centre:read", "gl:period:read"])
assert must(api("GET", "/cost-centres/CC-410", inv)) == {"cost_centre": "CC-410", "name": "CC-410", "status": "open", "company_code": "UK01"}
assert must(api("GET", "/cost-centres/CC-999", inv))["status"] == "closed"
assert api("GET", "/cost-centres/NOPE", inv)[0] == 404
co = must(api("GET", "/companies/UK01", PAYMENTS))
assert co["functional_currency"] == "GBP" and co["chart_id"] == "MAIN" and co["ledgers"] == ["LEAD"] and co["name"] == "UK01"
led = must(api("GET", "/companies/UK01/ledgers", PAYMENTS))
assert led["ledgers"][0]["kind"] == "leading" and led["fiscal_year_end_month"] == 12 and led["functional_currency"] == "GBP"
assert api("GET", "/companies/ZZ99", PAYMENTS)[0] == 404
cs = must(api("GET", "/charts/MAIN/accounts", PAYMENTS))
assert cs[0]["code"] == "1100" and {"account_id", "code", "name", "type", "subtype"} == set(cs[0])
# ── business time ────────────────────────────────────────────────────────────────────────────────────────────────────
def test_today_is_the_business_date_in_the_zone_not_the_utc_date():
books()
lines = [line(account="6100", debit="1.00"), line(account="2100", credit="1.00")]
with on_day("2026-03-31T16:30:00+00:00"): # 17:30 in London, 01:30 on 1 April in Tokyo
refused(api("POST", "/journals/propose-manual", ACCOUNTANT, {"journal_date": "2026-04-01", "lines": lines}), 422, "future")
must(api("POST", "/controls", OWNER, {"key": "business_timezone", "value": "Asia/Tokyo"}))
with on_day("2026-03-31T16:30:00+00:00"):
out = must(api("POST", "/journals/propose-manual", ACCOUNTANT, {"journal_date": "2026-04-01", "lines": lines}))
assert out["period_id"] == "2026-04"
def test_a_zone_change_never_moves_today_backwards():
from datetime import date, datetime, timezone
rows = [{"valid_from": date(1970, 1, 1), "value": "Asia/Tokyo"}, {"valid_from": date(2026, 4, 1), "value": "Pacific/Pago_Pago"}]
now = datetime(2026, 4, 1, 2, 0, tzinfo=timezone.utc) # 11:00 on 1 April in Tokyo, 15:00 on 31 March in Pago Pago
zone, today = business_time.resolve(rows, now)
assert zone == "Pacific/Pago_Pago" and today == date(2026, 4, 1) # the date in the zone left, never 31 March
zone, today = business_time.resolve([rows[0]], now)
assert today == date(2026, 4, 1)
# ── the soft close ───────────────────────────────────────────────────────────────────────────────────────────────────
def test_a_soft_close_lets_the_accountant_post_into_a_closed_period_with_a_warning_and_nobody_else():
books()
close_through("2026-03")
lines = [line(account="6100", debit="5.00"), line(account="2100", credit="5.00")]
refused(api("POST", "/journals/propose-manual", ACCOUNTANT, {"journal_date": "2026-03-20", "lines": lines}), 409, "[period_open]")
must(api("POST", "/controls", OWNER, {"key": "soft_close", "value": True}))
must(api("POST", "/controls", OWNER, {"key": "manual_journal_approval", "value": "none"}))
from conftest import BOOKKEEPER
from fakes import Payments
with on_day("2026-10-23T09:00:00+00:00"):
refused(api("POST", "/journals/propose-manual", BOOKKEEPER, {"journal_date": "2026-03-20", "lines": lines}), 409, "[period_open]")
out = must(api("POST", "/journals/propose-manual", ACCOUNTANT, {"journal_date": "2026-03-20", "lines": lines}))
assert out["status"] == "posted" and "soft close" in out["warning"]
j = must(api("GET", f"/journals/{out['journal_id']}", ACCOUNTANT))
assert [t["event"] for t in j["trail"]] == ["posted into a closed period (soft close)"]
assert invoice("INV-X", on="2026-03-20")[0] == 409 # a module's posting is refused as ever
_ = (Decimal, Payments)