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"