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