dodil/erp-brick-gl
DODIL ERP brick: general ledger
tests/test_year_end.py
"""GL-13 the year, GL-23 the carry-forward, GL-56 audit adjustments, GL-07 closing, GL-21 materialised statements."""
from decimal import Decimal
from conftest import ACCOUNTANT, ACCOUNTANT2, SYSTEM, api, must, refused
from world import books, close_through, invoice, line, post
def tb(on, **q):
return must(api("GET", "/reports/trial-balance", ACCOUNTANT, {"as_at": on, **q}))
def row(tbr, code):
return next((r for r in tbr["rows"] if r["code"] == code), None)
def year_with_trading():
books()
invoice("INV-1", on="2026-03-10", net="1000.00", vat="200.00") # revenue 1000
post("COST-1", "x", "2026-05-10", [line(account="6100", debit="400.00"), line(account="2100", credit="400.00")], source_module="inventory")
post("pay_1", "receipt", "2026-04-10", [line(account="1200", debit="1200.00"), line(purpose="receivable", credit="1200.00")], source_module="payments")
def test_closing_a_month_needs_the_months_before_it_and_every_gate():
books()
refused(api("POST", "/periods/2026-03/close-period", ACCOUNTANT, {}), 409, "[prior_closed]", "2026-02")
g = must(api("GET", "/periods/2026-03/readiness", ACCOUNTANT))
assert g["ready"] is False and {x["gate"] for x in g["gates"] if not x["met"]} == {"prior_closed"}
close_through("2026-03")
assert must(api("GET", "/periods", ACCOUNTANT, {}))["periods"][2]["status"] == "closed"
assert must(api("POST", "/periods/2026-03/close-period", ACCOUNTANT, {}))["status"] == "closed" # closing twice changes nothing
def test_a_waiting_manual_journal_blocks_the_close_and_pending_reversals_are_listed():
books()
close_through("2026-08")
j = must(api("POST", "/journals/propose-manual", ACCOUNTANT, {"journal_date": "2026-09-10",
"lines": [line(account="6100", debit="5.00"), line(account="2100", credit="5.00")]}))
assert j["status"] == "proposed"
refused(api("POST", "/periods/2026-09/close-period", ACCOUNTANT, {}), 409, "[no_proposed]")
def test_reopening_is_deliberate_recorded_and_not_by_the_closer():
books()
close_through("2026-01")
refused(api("POST", "/periods/2026-01/reopen-period", ACCOUNTANT, {"reason": "oops"}), 409, "[second_person]")
out = must(api("POST", "/periods/2026-01/reopen-period", ACCOUNTANT2, {"reason": "missed invoice"}))
assert out["status"] == "open"
h = must(api("GET", "/periods", ACCOUNTANT, {}))["periods"][0]["history"]
assert [(e["event"], e["reason"]) for e in h] == [("close", ""), ("reopen", "missed invoice")]
refused(api("POST", "/periods/2026-01/reopen-period", ACCOUNTANT2, {"reason": ""}), 422)
def test_the_closed_period_statements_are_materialised_once_and_stable():
books()
invoice("INV-1", on="2026-01-10")
close_through("2026-01")
a = must(api("GET", "/periods/2026-01/statements", ACCOUNTANT, {}))
assert a["rows"] and not a["stale"]
again = must(api("POST", "/periods/2026-01/materialise-statements", SYSTEM, {}))
b = must(api("GET", "/periods/2026-01/statements", ACCOUNTANT, {}))
assert [r["produced_at"] for r in a["rows"]] == [r["produced_at"] for r in b["rows"]] and again["rows"] == len(a["rows"])
assert api("POST", "/periods/2026-01/materialise-statements", ACCOUNTANT, {})[0] == 403
def test_the_year_closes_with_a_carry_forward_and_no_closing_journal():
year_with_trading()
refused(api("POST", "/fiscal-years/FY2026/close-year", ACCOUNTANT, {}), 409, "[periods_closed]")
close_through("2026-12")
before = len(must(api("GET", "/journals", ACCOUNTANT, {"limit": 500}))["journals"])
out = must(api("POST", "/fiscal-years/FY2026/close-year", ACCOUNTANT, {}))
assert out["status"] == "closed" and out["next_year"] == "FY2027"
again = must(api("POST", "/fiscal-years/FY2026/close-year", ACCOUNTANT, {}))
assert again["carry_forward_rows"] == out["carry_forward_rows"]
assert len(must(api("GET", "/journals", ACCOUNTANT, {"limit": 500}))["journals"]) == before # no closing entry
t27 = tb("2027-01-31")
# bank 1200, tax liability -200, receivable 0, accrual -400, and the year's result (1000 - 400 = 600) on retained earnings
assert row(t27, "1200")["opening"] == "1200.00" and row(t27, "2100")["opening"] == "-400.00" and row(t27, "2200")["opening"] == "-200.00"
assert row(t27, "3000")["opening"] == "-600.00" and row(t27, "4000") is None and row(t27, "6100") is None
assert Decimal(t27["difference"]) == 0
t26 = tb("2026-12-31") # the old year's P&L still reads from its periods
assert row(t26, "4000")["closing"] == "-1000.00" and row(t26, "6100")["closing"] == "400.00"
def test_the_year_cannot_close_without_retained_earnings_mapped():
books()
# a second chart whose map has no retained earnings
must(api("POST", "/charts", ACCOUNTANT, {"chart_id": "ALT", "kind": "operational"}))
must(api("POST", "/accounts", ACCOUNTANT, {"chart_id": "ALT", "code": "1", "type": "asset"}))
must(api("POST", "/companies", ACCOUNTANT, {"company_code": "AL01", "chart_id": "ALT"}))
must(api("POST", "/fiscal-years/open-year", ACCOUNTANT, {"fiscal_year": "FY2026", "company_code": "AL01"}))
close_through("2026-12", company="AL01")
refused(api("POST", "/fiscal-years/FY2026/close-year", ACCOUNTANT, {"company_code": "AL01"}), 409, "[retained_earnings_mapped]")
def test_an_audit_adjustment_posts_into_the_closed_last_period_with_a_second_person_and_moves_the_carry_forward():
year_with_trading()
close_through("2026-12")
adj = {"journal_date": "2026-12-31", "memo": "audit fee", "lines": [line(account="6100", debit="500.00"), line(account="2100", credit="500.00")]}
refused(api("POST", "/journals/propose-audit-adjustment", ACCOUNTANT, {**adj, "journal_date": "2026-11-30"}), 409, "CLOSED last period")
p = must(api("POST", "/journals/propose-audit-adjustment", ACCOUNTANT, adj))
assert p["status"] == "proposed" and p["adjustment"] is True
refused(api("POST", f"/journals/{p['journal_id']}/approve-manual", ACCOUNTANT, {}), 409, "[second_person]")
out = must(api("POST", f"/journals/{p['journal_id']}/approve-manual", ACCOUNTANT2, {}))
assert out["number"] == "ADJUSTMENT/FY2026/0001"
pre = tb("2026-12-31", include_adjustments="false")
post_ = tb("2026-12-31")
assert row(pre, "6100")["closing"] == "400.00" and row(post_, "6100")["closing"] == "900.00"
# operational postings stay refused
refused(invoice("INV-late", on="2026-12-31"), 409, "[period_open]")
out = must(api("POST", "/fiscal-years/FY2026/close-year", ACCOUNTANT, {}))
t27 = tb("2027-01-31")
assert row(t27, "3000")["opening"] == "-100.00" # 1000 - 400 - 500 on retained earnings
# once the year is closed an adjustment is refused
refused(api("POST", "/journals/propose-audit-adjustment", ACCOUNTANT, adj), 409, "[year_open]", "prior-period correction")
def test_a_late_adjustment_after_the_carry_forward_re_derives_it():
year_with_trading()
close_through("2026-12")
must(api("POST", "/fiscal-years/open-year", ACCOUNTANT, {"fiscal_year": "FY2027"}))
p = must(api("POST", "/journals/propose-audit-adjustment", ACCOUNTANT, {"journal_date": "2026-12-31",
"lines": [line(account="6100", debit="500.00"), line(account="2100", credit="500.00")]}))
must(api("POST", f"/journals/{p['journal_id']}/approve-manual", ACCOUNTANT2, {}))
t27 = tb("2027-01-31")
assert row(t27, "3000")["opening"] == "-100.00" and row(t27, "2100")["opening"] == "-900.00"