dodil/erp-brick-gl

DODIL ERP brick: general ledger

tests/test_scheduled.py

"""GL-45 recurring entries, GL-49 accruals and deferrals, GL-50 the accrual that reverses itself."""
from decimal import Decimal

from conftest import ACCOUNTANT, ACCOUNTANT2, BOOKKEEPER, OWNER, api, must, refused
from world import account_id, books, line


def rent_template(**kw):
    return api("POST", "/recurring", ACCOUNTANT, {"recurring_id": "RENT-LON", "first_period": "2026-01", "last_period": "2026-12",
               "frequency": "monthly", "posting_day": "last", "memo": "Rent",
               "lines": [line(account="6100", debit="2500.00"), line(account="2100", credit="2500.00")], **kw})


def test_a_recurring_entry_posts_once_per_period_after_a_second_person_approves():
    books()
    t = must(rent_template())
    assert t["version"] == 1 and t["status"] == "proposed"
    out = must(api("POST", "/periods/2026-10/run-recurring-entries", ACCOUNTANT, {}))
    assert out["posted"] == [] and out["skipped"] == [{"recurring_id": "RENT-LON", "version": 1, "reason": "not approved"}]
    refused(api("POST", "/recurring/RENT-LON/approve-recurring", ACCOUNTANT, {}), 409, "[second_person]")
    must(api("POST", "/recurring/RENT-LON/approve-recurring", ACCOUNTANT2, {}))
    first = must(api("POST", "/periods/2026-10/run-recurring-entries", ACCOUNTANT, {}))
    assert len(first["posted"]) == 1 and first["posted"][0]["replay"] is False and first["posted"][0]["number"] == "MISC/FY2026/0001"
    j = must(api("GET", f"/journals/{first['posted'][0]['journal_id']}", ACCOUNTANT))
    from erp_platform.seam import derived_id
    assert j["journal_date"] == "2026-10-31" and j["journal_id"] == str(derived_id("recurring", "RENT-LON", 1, "2026-10"))
    second = must(api("POST", "/periods/2026-10/run-recurring-entries", ACCOUNTANT, {}))
    assert second["posted"][0]["replay"] is True and second["posted"][0]["journal_id"] == first["posted"][0]["journal_id"]
    assert len(must(api("GET", "/journals", ACCOUNTANT))["journals"]) == 1


def test_changing_a_template_makes_a_new_version_that_needs_approval_and_the_old_one_stops():
    books()
    must(rent_template())
    must(api("POST", "/recurring/RENT-LON/approve-recurring", ACCOUNTANT2, {}))
    rev = must(api("POST", "/recurring/RENT-LON/revise-recurring", ACCOUNTANT, {"reason": "rent rise", "from_period": "2026-11",
               "lines": [line(account="6100", debit="2600.00"), line(account="2100", credit="2600.00")]}))
    assert rev["version"] == 2 and rev["status"] == "proposed" and rev["first_period"] == "2026-11"
    out = must(api("POST", "/periods/2026-11/run-recurring-entries", ACCOUNTANT, {}))
    assert out["posted"] == [] and out["skipped"][0]["reason"] == "not approved"
    must(api("POST", "/recurring/RENT-LON/approve-recurring", ACCOUNTANT2, {}))
    out = must(api("POST", "/periods/2026-11/run-recurring-entries", ACCOUNTANT, {}))
    j = must(api("GET", f"/journals/{out['posted'][0]['journal_id']}", ACCOUNTANT))
    assert out["posted"][0]["version"] == 2 and j["total"] == "2600.00"
    old = must(api("POST", "/periods/2026-10/run-recurring-entries", ACCOUNTANT, {}))
    assert old["posted"][0]["version"] == 1 and old["posted"][0]["replay"] is False         # version 1 still covers October


def test_a_closed_period_is_skipped_with_its_reason():
    books()
    must(rent_template())
    from world import close_through
    close_through("2026-01")                      # nothing approved yet, so January closes
    must(api("POST", "/recurring/RENT-LON/approve-recurring", ACCOUNTANT2, {}))
    out = must(api("POST", "/periods/2026-01/run-recurring-entries", ACCOUNTANT, {}))
    assert out["posted"] == [] and out["skipped"][0]["reason"] == "period 2026-01 closed"


def test_the_readiness_gate_recurring_posted_follows_the_runs():
    books()
    must(rent_template())
    must(api("POST", "/recurring/RENT-LON/approve-recurring", ACCOUNTANT2, {}))
    g = must(api("GET", "/periods/2026-10/readiness", ACCOUNTANT))
    assert next(x for x in g["gates"] if x["gate"] == "recurring_posted")["met"] is False
    must(api("POST", "/periods/2026-10/run-recurring-entries", ACCOUNTANT, {}))
    g = must(api("GET", "/periods/2026-10/readiness", ACCOUNTANT))
    assert next(x for x in g["gates"] if x["gate"] == "recurring_posted")["met"] is True
    assert api("POST", "/recurring", BOOKKEEPER, {"recurring_id": "X", "first_period": "2026-01", "lines": [line(account="6100", debit="1.00")]})[0] == 403


# ── accruals ─────────────────────────────────────────────────────────────────────────────────────────────────────────

def register(**kw):
    return api("POST", "/accruals", ACCOUNTANT, {"accrual_code": "INS-2026", "kind": "prepaid_expense", "amount": "12000.00",
               "start_period": "2026-01", "end_period": "2026-12", "pl_account_id": str(account_id("6300")),
               "balance_account_id": str(account_id("1400")), "profit_centre": None, **{k: v for k, v in kw.items()}})


def test_a_prepayment_is_released_over_its_months_to_the_penny():
    books()
    a = must(register(amount="12000.00"))
    assert a["status"] == "proposed" and [x["amount"] for x in a["schedule"]][:2] == ["1000.00", "1000.00"]
    refused(api("POST", "/accruals/INS-2026/approve-accrual", ACCOUNTANT, {}), 409, "[second_person]")
    must(api("POST", "/accruals/INS-2026/approve-accrual", ACCOUNTANT2, {}))
    r = must(api("POST", "/periods/2026-01/run-accruals", ACCOUNTANT, {}))
    assert r["released"][0]["amount"] == "1000.00" and r["released"][0]["replay"] is False
    again = must(api("POST", "/periods/2026-01/run-accruals", ACCOUNTANT, {}))
    assert again["released"][0]["replay"] is True
    j = must(api("GET", f"/journals/{r['released'][0]['journal_id']}", ACCOUNTANT))
    assert j["book"] == "accrual" and [(ln["account_id"], ln["debit"], ln["credit"]) for ln in j["lines"]] == [
        (str(account_id("6300")), "1000.00", "0.00"), (str(account_id("1400")), "0.00", "1000.00")]


def test_the_last_period_takes_the_remainder_so_the_total_equals_the_amount():
    books()
    must(register(accrual_code="ODD", amount="100.00", start_period="2026-01", end_period="2026-03"))
    sched = must(api("GET", "/accruals", ACCOUNTANT))["accruals"][0]["schedule"]
    assert [s["amount"] for s in sched] == ["33.33", "33.33", "33.34"]
    assert sum(Decimal(s["amount"]) for s in sched) == Decimal("100.00")


def test_daily_proration_sums_exactly():
    books()
    a = must(register(accrual_code="DAILY", amount="1000.00", start_period="2026-01", end_period="2026-04", method="daily"))
    assert sum(Decimal(s["amount"]) for s in a["schedule"]) == Decimal("1000.00")
    assert a["schedule"][1]["amount"] != a["schedule"][0]["amount"]            # February holds fewer days


def test_terminating_early_releases_the_remainder_in_the_termination_period_and_nothing_after():
    books()
    must(register(amount="12000.00", start_period="2026-01", end_period="2026-12"))
    must(api("POST", "/accruals/INS-2026/approve-accrual", ACCOUNTANT2, {}))
    for m in range(1, 7):
        must(api("POST", f"/periods/2026-{m:02d}/run-accruals", ACCOUNTANT, {}))
    out = must(api("POST", "/accruals/INS-2026/terminate-accrual", ACCOUNTANT, {"reason": "policy cancelled", "termination_period": "2026-07"}))
    assert out["status"] == "terminated" and out["final_release"]["amount"] == "6000.00" and out["remaining"] == "0.00"
    after = must(api("POST", "/periods/2026-08/run-accruals", ACCOUNTANT, {}))
    assert after["released"] == []


def test_a_small_registration_needs_no_second_person_and_completes_when_every_period_is_released():
    books()
    must(api("POST", "/controls", OWNER, {"key": "manual_journal_threshold", "value": 50000}))
    a = must(register(amount="300.00", start_period="2026-01", end_period="2026-03"))
    assert a["status"] == "active"
    for m in (1, 2, 3):
        must(api("POST", f"/periods/2026-0{m}/run-accruals", ACCOUNTANT, {}))
    done = must(api("GET", "/accruals", ACCOUNTANT))["accruals"][0]
    assert done["status"] == "completed" and done["released"] == "300.00"


def test_deferred_revenue_runs_the_other_way():
    books(extra_map={"deferred_revenue": "2100"})
    must(api("POST", "/controls", OWNER, {"key": "manual_journal_threshold", "value": 50000}))
    must(api("POST", "/accruals", ACCOUNTANT, {"accrual_code": "SUB-1", "kind": "deferred_revenue", "amount": "1200.00", "start_period": "2026-10",
         "end_period": "2026-12", "pl_account_id": str(account_id("4000"))}))
    r = must(api("POST", "/periods/2026-10/run-accruals", ACCOUNTANT, {}))
    j = must(api("GET", f"/journals/{r['released'][0]['journal_id']}", ACCOUNTANT))
    assert sorted((ln["account_id"], ln["debit"], ln["credit"]) for ln in j["lines"]) == sorted([
        (str(account_id("2100")), "400.00", "0.00"), (str(account_id("4000")), "0.00", "400.00")])