dodil/erp-brick-gl

DODIL ERP brick: general ledger

tests/test_revaluation.py

"""GL-09 balances restated at the rate of the day, GL-48 open items, GL-25 the period-end re-split, GL-24 the reversal."""
from decimal import Decimal

from conftest import ACCOUNTANT, SYSTEM, api, must, refused
from erp_platform.seam import derived_id
from fakes import Payments, Purchasing
from world import account_id, books, line, post


def rate(cur, r, on):
    must(api("POST", "/fx-rates", ACCOUNTANT, {"from_currency": cur, "rate": r, "rate_date": on, "rate_type": "spot"}))


def closing(cur, r, on):
    must(api("POST", "/fx-rates", ACCOUNTANT, {"from_currency": cur, "rate": r, "rate_date": on, "rate_type": "closing"}))


def usd_books(next_year=True):
    books(year="FY2026")
    must(api("POST", "/accounts", ACCOUNTANT, {"chart_id": "MAIN", "code": "1210", "currency": "USD", "monetary": True}))
    if next_year:
        must(api("POST", "/fiscal-years/open-year", ACCOUNTANT, {"fiscal_year": "FY2027"}))
    must(api("POST", "/settings", ACCOUNTANT, {"key": "revaluation", "value": True, "company_code": "UK01"}))


def test_a_usd_bank_balance_is_restated_and_the_journal_reverses_next_period():
    usd_books()
    s, b = post("USD-IN", "receipt", "2026-09-10", [line(account="1210", debit="10000.00"), line(purpose="revenue", credit="10000.00")],
                currency="USD", source_module="payments", fx_rate="0.780000")
    assert s == 200, b
    refused(api("POST", "/periods/2026-09/run-fx-revaluation-balances", ACCOUNTANT, {}), 422, "[fx_rate]", "USD")
    closing("USD", "0.790000", "2026-09-30")
    out = must(api("POST", "/periods/2026-09/run-fx-revaluation-balances", ACCOUNTANT, {}))
    run = out["runs"][0]
    assert run["run_id"] == "UK01:LEAD:2026-09-30:balances" and run["items"][0]["gain_loss"] == "100.00" and run["items"][0]["booked_functional"] == "7800.00"
    j = must(api("GET", f"/journals/{run['journal_id']}", ACCOUNTANT))
    assert j["ledger_id"] == "LEAD" and j["book"] == "fx" and j["reverse_next_period"] and j["journal_date"] == "2026-09-30"
    assert sorted((ln["debit"], ln["credit"]) for ln in j["lines"]) == [("0.00", "100.00"), ("100.00", "0.00")]
    again = must(api("POST", "/periods/2026-09/run-fx-revaluation-balances", ACCOUNTANT, {}))
    assert again["runs"][0]["replay"] is True and again["runs"][0]["journal_id"] == run["journal_id"]
    assert len(must(api("GET", "/journals", ACCOUNTANT))["journals"]) == 3        # receipt, revaluation, its reversal (2026-10 is open)
    rev = [x for x in must(api("GET", "/journals", ACCOUNTANT))["journals"] if x["reverses_journal_id"] == run["journal_id"]][0]
    assert rev["journal_date"] == "2026-10-01"
    bank = must(api("GET", "/reports/trial-balance", ACCOUNTANT, {"as_at": "2026-10-31"}))
    assert next(r for r in bank["rows"] if r["code"] == "1210")["closing"] == "7800.00"


def test_the_reversal_waits_until_the_next_period_opens_and_posts_once_when_it_does():
    usd_books(next_year=False)
    post("USD-IN", "receipt", "2026-12-10", [line(account="1210", debit="10000.00"), line(purpose="revenue", credit="10000.00")],
         currency="USD", source_module="payments", fx_rate="0.780000")
    must(api("POST", "/fx-rates", ACCOUNTANT, {"from_currency": "USD", "rate": "0.790000", "rate_date": "2026-10-20", "rate_type": "closing"}))
    out = must(api("POST", "/periods/2026-12/run-fx-revaluation-balances", ACCOUNTANT, {}))
    jid = out["runs"][0]["journal_id"]
    assert must(api("GET", f"/journals/{jid}", ACCOUNTANT))["reversal_status"] == "pending"
    refused(api("POST", f"/journals/{jid}/reverse-journal", ACCOUNTANT, {"reason": "by hand"}), 409, "[no_pending_reversal]")
    assert must(api("GET", "/periods", ACCOUNTANT))["periods"][11]["pending_reversals"] == []
    pend = must(api("GET", "/periods", ACCOUNTANT, {"fiscal_year": "FY2026"}))
    assert pend["periods"][-1]["status"] == "open"
    must(api("POST", "/fiscal-years/open-year", ACCOUNTANT, {"fiscal_year": "FY2027"}))
    assert must(api("GET", f"/journals/{jid}", ACCOUNTANT))["reversal_status"] == "posted"
    again = must(api("POST", "/periods/open-periods", ACCOUNTANT, {"fiscal_year": "FY2027"}))
    assert again["reversals_posted"] == [] and again["opened"] is False
    assert len([x for x in must(api("GET", "/journals", ACCOUNTANT))["journals"] if x["reverses_journal_id"] == jid]) == 1


def test_losses_only_recognises_a_loss_but_not_a_gain():
    usd_books()
    must(api("POST", "/ledgers", ACCOUNTANT, {"ledger_id": "HGB", "kind": "non_leading", "reval_method": "losses_only"}))
    must(api("POST", "/companies/UK01/ledgers", ACCOUNTANT, {"ledger_id": "HGB", "from_fiscal_year": "FY2026"}))
    post("USD-IN", "receipt", "2026-09-10", [line(account="1210", debit="10000.00"), line(purpose="revenue", credit="10000.00")],
         currency="USD", source_module="payments", fx_rate="0.780000")
    closing("USD", "0.790000", "2026-09-30")
    runs = {r["ledger_id"]: r for r in must(api("POST", "/periods/2026-09/run-fx-revaluation-balances", ACCOUNTANT, {}))["runs"]}
    assert runs["LEAD"]["journal_id"] and runs["HGB"]["journal_id"] is None and runs["HGB"]["items"] == []
    lead = must(api("GET", "/reports/trial-balance", ACCOUNTANT, {"as_at": "2026-09-30", "ledger_id": "LEAD"}))
    hgb = must(api("GET", "/reports/trial-balance", ACCOUNTANT, {"as_at": "2026-09-30", "ledger_id": "HGB"}))
    assert next(r for r in lead["rows"] if r["code"] == "1210")["closing"] == "7900.00"
    assert next(r for r in hgb["rows"] if r["code"] == "1210")["closing"] == "7800.00"


def test_open_items_are_revalued_at_their_booked_rate_and_an_unposted_item_is_a_break():
    usd_books()
    pay = Payments().install()
    pur = Purchasing().install()
    must(api("POST", "/account-map", ACCOUNTANT, {"chart_id": "MAIN", "purpose": "payable", "account_id": str(account_id("2300")), "valid_from": "2026-01-01"}))
    post("INV-88", "issue", "2026-09-10", [line(purpose="receivable", debit="1000.00"), line(purpose="revenue", credit="1000.00")], currency="USD",
         fx_rate="0.800000")
    post("B-17", "approve", "2026-09-12", [line(purpose="payable", credit="500.00"), line(account="6000", debit="500.00")], currency="EUR",
         source_module="purchasing", fx_rate="0.860000")
    pay.invoices["INV-88"] = {"currency": "USD", "total": "1000.00", "outstanding": "1000.00"}
    pur.bills = [{"bill_id": "B-17", "bill_no": "17", "bp_id": 1, "currency": "EUR", "total": "500.00", "outstanding": "500.00", "due_date": "2026-10-12"},
                 {"bill_id": "B-99", "bill_no": "99", "bp_id": 1, "currency": "EUR", "total": "10.00", "outstanding": "10.00", "due_date": "2026-10-12"}]
    closing("USD", "0.790000", "2026-09-30")
    refused(api("POST", "/periods/2026-09/run-fx-revaluation-open-items", ACCOUNTANT, {}), 422, "[fx_rate]", "EUR")       # no EUR rate at all
    closing("EUR", "0.870000", "2026-09-30")
    out = must(api("POST", "/periods/2026-09/run-fx-revaluation-open-items", ACCOUNTANT, {}))
    run = out["runs"][0]
    got = {i["document"]: i["gain_loss"] for i in run["items"]}
    assert got == {"INV-88": "-10.00", "B-17": "-5.00"} and out["breaks"] == [{"document": "B-99", "kind": "not_posted_by_gl"}]
    j = must(api("GET", f"/journals/{run['journal_id']}", ACCOUNTANT))
    assert {ln["against_document"] for ln in j["lines"] if ln["against_document"]} == {"INV-88", "B-17"}
    assert sum(Decimal(ln["debit"]) for ln in j["lines"]) == Decimal("15.00")
    again = must(api("POST", "/periods/2026-09/run-fx-revaluation-open-items", ACCOUNTANT, {}))
    assert again["runs"][0]["replay"] is True
    rev = [x for x in must(api("GET", "/journals", ACCOUNTANT))["journals"] if x["reverses_journal_id"] == run["journal_id"]]
    assert rev and rev[0]["journal_date"] == "2026-10-01"


def test_revaluation_is_off_by_default_and_a_period_that_needs_it_cannot_close_without_it():
    books()
    must(api("POST", "/accounts", ACCOUNTANT, {"chart_id": "MAIN", "code": "1210", "currency": "USD", "monetary": True}))
    refused(api("POST", "/periods/2026-09/run-fx-revaluation-balances", ACCOUNTANT, {}), 409, "[revaluation_on]")


# ── GL-25: the re-split ──────────────────────────────────────────────────────────────────────────────────────────────

def test_the_resplit_moves_a_receipt_posted_before_it_was_allocated_and_posts_once():
    from test_dimensions import splitting_books
    splitting_books()
    pay = Payments().install()
    post("INV-S", "issue", "2026-10-05", [line(purpose="receivable", debit="1200.00"), line(purpose="revenue", credit="600.00", profit_centre="PC-LON"),
                                           line(purpose="revenue", credit="400.00", profit_centre="PC-WHS"), line(account="2200", credit="200.00")])
    post("pay_9", "receipt", "2026-10-20", [line(account="1200", debit="1200.00"), line(purpose="receivable", credit="1200.00")], source_module="payments")
    pay.invoices["INV-S"] = {"currency": "GBP", "total": "1200.00", "outstanding": "0.00"}        # allocated in payments afterwards
    out = must(api("POST", "/periods/2026-10/resplit-open-items", SYSTEM, {}))
    assert out["ledgers"][0]["journal_id"] == str(derived_id("UK01", "LEAD", "2026-10-31", "resplit")) and out["ledgers"][0]["replay"] is False
    again = must(api("POST", "/periods/2026-10/resplit-open-items", SYSTEM, {}))
    assert again["ledgers"][0]["replay"] is True
    tb = must(api("GET", "/reports/trial-balance", ACCOUNTANT, {"as_at": "2026-10-31", "by": "profit_centre"}))
    rec = {r["profit_centre"]: r["closing"] for r in tb["rows"] if r["code"] == "1100"}
    assert all(Decimal(v) == 0 for v in rec.values()), rec
    assert all(Decimal(g["net"]) == 0 for g in tb["groups"])
    j = must(api("GET", f"/journals/{out['ledgers'][0]['journal_id']}", ACCOUNTANT))
    assert j["reverse_next_period"] is True