dodil/erp-brick-gl
DODIL ERP brick: general ledger
tests/test_posting.py
"""GL-30 (the source route), GL-02/03/11 (manual journals, reversal), GL-12 (the account map), GL-32/33 (the reads other modules make).
The bodies are the BUILT callers' (erp-brick-ar, erp-brick-payments): served unchanged in the default company, every ledger."""
from decimal import Decimal
from conftest import (
ACCOUNTANT,
ACCOUNTANT2,
AUDITOR,
BOOKKEEPER,
OWNER,
PAYMENTS,
POSTER,
SYSTEM,
TAXC,
api,
must,
on_day,
refused,
)
from erp_platform.seam import derived_id
from world import account_id, books, close_through, invoice, line, post
def tb(on="2026-10-31", **q):
return must(api("GET", "/reports/trial-balance", ACCOUNTANT, {"as_at": on, **q}))
# ── GL-30: what ar and payments send ─────────────────────────────────────────────────────────────────────────────────
def test_ar_posts_its_body_unchanged_and_replays():
"""ar sends account ids as strings, one side per line, no company, no ledger, no purpose."""
books()
body = {"journal_id": str(derived_id("INV-7", "issue")), "source_module": "ar", "source_document": "INV-7", "event": "issue",
"journal_date": "2026-10-15", "currency": "GBP", "memo": "INV-7 issue",
"lines": [{"account_id": str(account_id("1100")), "debit": "1200.00", "memo": "INV-7 issue"},
{"account_id": str(account_id("4000")), "credit": "1000.00", "memo": "INV-7 issue"},
{"account_id": str(account_id("2200")), "credit": "200.00", "memo": "INV-7 issue"}]}
s, b = api("POST", "/journal-entry/journals", POSTER, body)
assert s == 200 and b["status"] == "posted" and b["replay"] is False and b["lines"] == 3 and b["period_id"] == "2026-10"
assert b["number"] == "SALES/FY2026/0001"
s, again = api("POST", "/journal-entry/journals", POSTER, body)
assert s == 200 and again["replay"] is True and again["journal_id"] == b["journal_id"] and again["entry_no"] == 1
assert tb()["total_debit"] == "1200.00"
def test_a_different_body_for_the_same_journal_is_a_conflict():
books()
assert invoice("INV-8")[0] == 200
s, b = invoice("INV-8", net="999.00", vat="200.00")
refused((s, b), 409, "journal_conflict")
def test_payments_posts_purposes_and_a_bank_account_id():
books()
s, inv = invoice("INV-9")
assert s == 200
body = {"journal_id": str(derived_id("pay_1", "receipt")), "source_module": "pay", "source_document": "pay_1", "event": "receipt",
"journal_date": "2026-10-20", "currency": "GBP", "memo": "Receipt",
"lines": [{"account_id": str(account_id("1200")), "debit": "1200.00"},
{"purpose": "receivable", "bp_id": "5001", "credit": "1200.00"}]}
s, b = api("POST", "/journal-entry/journals", PAYMENTS, body)
assert s == 200 and b["number"].startswith("CASH/")
assert tb()["total_debit"] == "1200.00" # invoice debit 1200 on 1100 and the receipt nets it: bank 1200, receivable 0
def test_a_journal_id_that_is_not_derived_from_the_source_is_refused():
books()
s, b = post("INV-1", "issue", "2026-10-15", [line(purpose="receivable", debit="10.00"), line(purpose="revenue", credit="10.00")],
journal_id="12345")
refused((s, b), 422, "journal_id")
def test_unbalanced_names_the_difference_and_nothing_posts():
books()
s, b = post("INV-2", "issue", "2026-10-15", [line(purpose="receivable", debit="1200.00"), line(purpose="revenue", credit="1000.00")])
refused((s, b), 422, "[balanced]", "200.00")
assert must(api("GET", "/journals", ACCOUNTANT))["journals"] == []
def test_an_unmapped_purpose_is_refused_naming_it():
books()
s, b = post("INV-3", "issue", "2026-10-15", [line(purpose="deferred_revenue", debit="10.00"), line(purpose="revenue", credit="10.00")])
refused((s, b), 422, "[purposes_mapped]", "deferred_revenue")
def test_amounts_are_bounded_never_a_500():
books()
for bad in ("1e20", "-5.00", "10.005", "99999999999999999.00", "abc"):
s, b = post("INV-4", "issue", "2026-10-15", [line(purpose="receivable", debit=bad), line(purpose="revenue", credit="1.00")])
assert s == 422, (bad, s, b)
s, b = post("INV-5", "issue", "2026-10-15", [line(purpose="receivable", debit="1.00", credit="1.00"), line(purpose="revenue", credit="1.00")])
assert s == 422
s, b = post("INV-6", "issue", "2026-10-15", [line(purpose="receivable", debit="1.00", fx_rate="0")], fx_rate="0")
assert s == 422
def test_a_closed_period_refuses_naming_it_and_the_fix():
books()
assert invoice("INV-10", on="2026-09-30")[0] == 200
close_through("2026-09")
s, b = invoice("INV-11", on="2026-09-30")
refused((s, b), 409, "[period_open]", "2026-09", "fix:")
s, c = api("GET", "/periods/open", POSTER, {"date": "2026-09-30"})
assert s == 200 and c["open"] is False and c["status"] == "closed" and c["period_id"] == "2026-09" and c["fix"]
s, c = api("GET", "/periods/open", POSTER, {"date": "2025-01-01"})
assert s == 200 and c["status"] == "none" and c["open"] is False
s, c = api("GET", "/periods/open", POSTER, {"date": "2026-10-01"})
assert c["open"] is True
def test_a_person_cannot_call_the_source_route():
books()
s, b = invoice("INV-12", who=ACCOUNTANT)
assert s == 403
s, b = invoice("INV-12", who=BOOKKEEPER)
assert s == 403
s, b = invoice("INV-12", who=NOBODY_HEADERS)
assert s in (401, 403)
NOBODY_HEADERS = {}
# ── GL-02 / GL-11: manual journals ───────────────────────────────────────────────────────────────────────────────────
def propose(who, amount="1000.00", on="2026-10-20", **kw):
return api("POST", "/journals/propose-manual", who, {"journal_date": on, "memo": "accrual",
"lines": [line(account="6100", debit=amount), line(account="2100", credit=amount)], **kw})
def test_a_manual_journal_waits_for_a_second_person_when_the_owner_asks():
books()
must(api("POST", "/controls", OWNER, {"key": "manual_journal_approval", "value": "always"}))
p = must(propose(BOOKKEEPER))
assert p["status"] == "proposed" and p["needs_approval"] is True and p["number"] == ""
jid = p["journal_id"]
j = must(api("GET", f"/journals/{jid}", ACCOUNTANT))
assert {g["gate"] for g in j["readiness"]} == {"second_person", "period_open"}
assert tb()["rows"] == [] # a proposal counts nowhere
out = must(api("POST", f"/journals/{jid}/approve-manual", ACCOUNTANT, {}))
assert out["status"] == "posted" and out["number"] == "MISC/FY2026/0001" and out["approved_by"] == "accountant@firm.example"
assert tb()["total_debit"] == "1000.00"
def test_the_proposer_cannot_approve_even_under_a_second_login():
"""Matched on BOTH email and sub: the same sub under another email is still the proposer."""
from conftest import person
books()
must(api("POST", "/controls", OWNER, {"key": "manual_journal_approval", "value": "always"}))
me_a = person("pat@firm.example", ["gl:journal:write", "gl:journal:approve"], sub="sub-pat")
me_b = person("pat.alias@firm.example", ["gl:journal:write", "gl:journal:approve"], sub="sub-pat")
jid = must(propose(me_a))["journal_id"]
refused(api("POST", f"/journals/{jid}/approve-manual", me_a, {}), 409, "[second_person]")
refused(api("POST", f"/journals/{jid}/approve-manual", me_b, {}), 409, "[second_person]")
assert must(api("POST", f"/journals/{jid}/approve-manual", ACCOUNTANT, {}))["status"] == "posted"
def test_rejected_stays_readable_with_the_reason_and_takes_no_number():
books()
must(api("POST", "/controls", OWNER, {"key": "manual_journal_approval", "value": "always"}))
jid = must(propose(BOOKKEEPER))["journal_id"]
must(api("POST", f"/journals/{jid}/reject-manual", ACCOUNTANT, {"reason": "wrong account"}))
j = must(api("GET", f"/journals/{jid}", ACCOUNTANT))
assert j["status"] == "rejected" and j["reject_reason"] == "wrong account" and j["entry_no"] is None
must(propose(BOOKKEEPER))
n = must(api("POST", f"/journals/{must(propose(BOOKKEEPER))['journal_id']}/approve-manual", ACCOUNTANT, {}))
assert n["entry_no"] == 1 # a rejected journal never took a number
def test_below_the_threshold_it_posts_at_once_with_its_number():
books()
must(api("POST", "/controls", OWNER, {"key": "manual_journal_threshold", "value": 5000}))
out = must(propose(BOOKKEEPER, amount="1000.00"))
assert out["status"] == "posted" and out["number"] == "MISC/FY2026/0001"
big = must(propose(BOOKKEEPER, amount="6000.00"))
assert big["status"] == "proposed"
def test_a_manual_journal_is_balanced_dated_in_an_open_period_not_in_the_future_and_off_control_accounts():
books()
refused(api("POST", "/journals/propose-manual", BOOKKEEPER, {"journal_date": "2026-10-20",
"lines": [line(account="6100", debit="10.00"), line(account="2100", credit="9.00")]}), 422, "[balanced]")
refused(propose(BOOKKEEPER, on="2026-10-23"), 422, "future")
refused(propose(BOOKKEEPER, on="2025-05-01"), 409, "[period_open]")
refused(api("POST", "/journals/propose-manual", BOOKKEEPER, {"journal_date": "2026-10-20",
"lines": [line(account="1100", debit="10.00"), line(account="2100", credit="10.00")]}), 422, "[source_only_account]")
assert api("POST", "/journals/propose-manual", AUDITOR, {"journal_date": "2026-10-20", "lines": []})[0] in (403, 422)
# ── GL-03: reversal ──────────────────────────────────────────────────────────────────────────────────────────────────
def test_reversal_returns_the_balances_and_happens_once():
books()
s, inv = invoice("INV-20")
jid = inv["journal_id"]
r = must(api("POST", f"/journals/{jid}/reverse-journal", ACCOUNTANT, {"reason": "issued in error"}))
assert r["status"] == "reversed" and r["reversal"]["journal_id"] == str(derived_id(int(jid), "reversal"))
both = must(api("GET", "/journals", ACCOUNTANT))["journals"]
assert sorted(j["status"] for j in both) == ["posted", "reversed"]
assert [j for j in both if j["status"] == "posted"][0]["reverses_journal_id"] == jid
assert tb()["rows"][0]["closing"] == "0.00" and tb()["difference"] in ("0", "0.00")
refused(api("POST", f"/journals/{jid}/reverse-journal", ACCOUNTANT, {"reason": "again"}), 409, "[not_reversed]")
assert len(must(api("GET", "/journals", ACCOUNTANT))["journals"]) == 2
def test_a_bookkeeper_cannot_reverse():
books()
jid = invoice("INV-21")[1]["journal_id"]
assert api("POST", f"/journals/{jid}/reverse-journal", BOOKKEEPER, {"reason": "x"})[0] == 403
def test_an_accrual_that_reverses_itself_posts_on_the_first_day_of_the_next_period_once_it_is_open():
books()
must(api("POST", "/fiscal-years/open-year", ACCOUNTANT, {"fiscal_year": "FY2027"})) # 2027 periods; Dec 2026 is the last of FY2026
with on_day("2026-12-31T10:00:00+00:00"):
out = must(api("POST", "/journals/propose-manual", ACCOUNTANT,
{"journal_date": "2026-12-31", "reverse_next_period": True,
"lines": [line(account="6100", debit="3000.00"), line(account="2100", credit="3000.00")]}))
assert out["status"] == "proposed"
must(api("POST", f"/journals/{out['journal_id']}/approve-manual", ACCOUNTANT2, {}))
j = must(api("GET", f"/journals/{out['journal_id']}", ACCOUNTANT))
assert j["status"] == "reversed" and j["reversal_status"] == "posted" # 2027-01 is open: posted at once
rows = must(api("GET", "/journals", ACCOUNTANT))["journals"]
assert sorted(r["journal_date"] for r in rows) == ["2026-12-31", "2027-01-01"]
assert must(api("GET", "/reports/trial-balance", ACCOUNTANT, {"as_at": "2027-01-31"}))["rows"][0]["closing"] == "0.00"
# ── GL-12 / GL-14: the map and the controls ──────────────────────────────────────────────────────────────────────────
def test_the_account_map_is_dated_after_today_and_earlier_postings_are_untouched():
books()
s, b = api("POST", "/account-map", ACCOUNTANT, {"chart_id": "MAIN", "purpose": "revenue", "account_id": str(account_id("6000")),
"valid_from": "2026-10-22"})
refused((s, b), 422, "tomorrow")
must(api("POST", "/account-map", ACCOUNTANT, {"chart_id": "MAIN", "purpose": "revenue", "account_id": str(account_id("6000")),
"valid_from": "2026-10-23"}))
assert invoice("INV-30", on="2026-10-22")[0] == 200
assert invoice("INV-31", on="2026-10-23")[0] == 200
r = {x["account_id"]: x for x in tb("2026-10-31")["rows"]}
assert r[str(account_id("4000"))]["credit"] == "1000.00" and r[str(account_id("6000"))]["credit"] == "1000.00"
m = must(api("GET", "/account-map", ACCOUNTANT, {"chart_id": "MAIN"}))
assert "payable" in m["unmapped"] and "bank" not in m["unmapped"]
def test_a_control_is_the_owners_and_takes_effect_tomorrow():
books()
refused(api("POST", "/settings", ACCOUNTANT, {"key": "manual_journal_approval", "value": "none"}), 403, "control")
assert api("POST", "/controls", ACCOUNTANT, {"key": "manual_journal_approval", "value": "none"})[0] == 403
must(api("POST", "/controls", OWNER, {"key": "manual_journal_approval", "value": "always"})) # first row: today
refused(api("POST", "/controls", OWNER, {"key": "manual_journal_approval", "value": "none", "valid_from": "2026-10-22"}), 422, "tomorrow")
s = must(api("POST", "/controls", OWNER, {"key": "manual_journal_approval", "value": "above_threshold"}))
assert s["valid_from"] == "2026-10-23"
hist = must(api("GET", "/settings", ACCOUNTANT, {"key": "manual_journal_approval"}))
assert [h["value"] for h in hist["history"]] == ["always", "above_threshold"]
assert hist["in_force"]["manual_journal_approval"] == "always"
# ── GL-33: the rate other modules read ───────────────────────────────────────────────────────────────────────────────
def test_fx_rates_by_date_and_never_changed():
books()
must(api("POST", "/fx-rates", ACCOUNTANT, {"from_currency": "EUR", "rate": "0.860000", "rate_date": "2026-09-29"}))
must(api("POST", "/fx-rates", ACCOUNTANT, {"from_currency": "EUR", "rate": "0.870000", "rate_date": "2026-10-01"}))
r = must(api("GET", "/fx-rates/EUR", TAXC, {"on": "2026-09-30"}))
assert r == {"currency": "EUR", "to": "GBP", "type": "spot", "on": "2026-09-30", "rate_date": "2026-09-29", "rate": "0.860000",
"source": "manual"}
s, b = api("GET", "/fx-rates/EUR", TAXC, {"on": "2026-09-01"})
refused((s, b), 404, "[fx_rate]", "Currencies: enter")
assert must(api("GET", "/fx-rates/GBP", TAXC, {"on": "2026-09-01"}))["rate"] == "1"
refused(api("POST", "/fx-rates", ACCOUNTANT, {"from_currency": "EUR", "rate": "0.9", "rate_date": "2026-09-29"}), 409, "never changed")
refused(api("POST", "/fx-rates", ACCOUNTANT, {"from_currency": "EUR", "rate": "0.9", "rate_date": "2026-10-23"}), 422, "today")
assert api("GET", "/fx-rates/EUR", NOBODY_HEADERS or POSTER, {"on": "2026-09-30"})[0] == 403
def test_a_foreign_posting_is_balanced_in_both_currencies_with_the_rate_in_force():
books()
must(api("POST", "/fx-rates", ACCOUNTANT, {"from_currency": "USD", "rate": "0.800000", "rate_date": "2026-10-01"}))
s, b = post("INV-40", "issue", "2026-10-15", [line(purpose="receivable", debit="1000.00"), line(purpose="revenue", credit="1000.00")],
currency="USD")
assert s == 200, b
j = must(api("GET", f"/journals/{b['journal_id']}", ACCOUNTANT))
assert j["fx_rate"] == "0.800000" and j["total"] == "1000.00" and j["functional_total"] == "800.00"
assert [ln["functional_debit"] for ln in j["lines"]] == ["800.00", "0.00"]
s, b = post("INV-41", "issue", "2026-10-15", [line(purpose="receivable", debit="1.00"), line(purpose="revenue", credit="1.00")],
currency="CHF")
refused((s, b), 422, "[fx_rate]", "CHF")
def test_a_rounding_residual_is_one_line_to_the_rounding_purpose():
books()
must(api("POST", "/fx-rates", ACCOUNTANT, {"from_currency": "USD", "rate": "0.793333", "rate_date": "2026-10-01"}))
s, b = post("INV-50", "issue", "2026-10-15", [line(purpose="receivable", debit="100.01"), line(purpose="revenue", credit="33.34"),
line(purpose="revenue", credit="33.34"), line(purpose="revenue", credit="33.33")],
currency="USD")
assert s == 200, b
j = must(api("GET", f"/journals/{b['journal_id']}", ACCOUNTANT))
f_d = sum(Decimal(ln["functional_debit"]) for ln in j["lines"])
f_c = sum(Decimal(ln["functional_credit"]) for ln in j["lines"])
assert f_d == f_c
def test_the_system_posts_a_waiting_journal_nobody_needs_to_approve_any_more_and_rederives_balances():
books()
must(api("POST", "/controls", OWNER, {"key": "manual_journal_approval", "value": "always"}))
p = must(propose(BOOKKEEPER))
assert p["status"] == "proposed"
refused(api("POST", f"/journals/{p['journal_id']}/post-manual", SYSTEM, {}), 409, "[second_person]")
must(api("POST", "/controls", OWNER, {"key": "manual_journal_approval", "value": "none"}))
with on_day("2026-10-23T09:00:00+00:00"):
out = must(api("POST", f"/journals/{p['journal_id']}/post-manual", SYSTEM, {}))
assert out["status"] == "posted" and out["number"] == "MISC/FY2026/0001"
j = must(api("GET", f"/journals/{p['journal_id']}", ACCOUNTANT))
assert j["approved_by"] is None and j["proposed_by"] == "bookkeeper@firm.example"
assert api("POST", f"/journals/{p['journal_id']}/post-manual", ACCOUNTANT, {})[0] == 403
r = must(api("POST", "/periods/2026-10/rederive-balances", SYSTEM, {}))
assert r["balances"] == 2
def test_a_foreign_journal_unbalanced_by_a_cent_is_refused_even_when_the_functional_amounts_would_round_to_balance():
books()
must(api("POST", "/fx-rates", ACCOUNTANT, {"from_currency": "USD", "rate": "0.800000", "rate_date": "2026-10-01"}))
s, b = post("INV-USD", "issue", "2026-10-15", [line(purpose="receivable", debit="100.01"), line(purpose="revenue", credit="100.00")], currency="USD")
refused((s, b), 422, "[balanced]", "0.01 USD")