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")])