dodil/erp-brick-gl

DODIL ERP brick: general ledger

tests/test_reconcile.py

"""GL-08 / GL-31 / GL-36 / GL-39: control accounts reconciled against the module that owns the open items."""
from decimal import Decimal

from conftest import ACCOUNTANT, AUDITOR, BOOKKEEPER, api, must, refused
from fakes import Assets, Payments, Purchasing
from world import account_id, books, invoice, line, post


def test_nothing_to_reconcile_against_without_the_owner_of_the_open_items():
    books()
    refused(api("POST", "/reconcile/receivable", BOOKKEEPER, {"as_at": "2026-10-31"}), 409, "payments is not installed")
    refused(api("POST", "/reconcile/payable", BOOKKEEPER, {"as_at": "2026-10-31"}), 409, "purchasing is not installed")


def test_receivable_per_invoice_with_every_kind_of_break():
    books()
    from world import close_through
    close_through("2026-09")
    pay = Payments().install()
    invoice("INV-1", net="1000.00", vat="200.00")                       # posted 1200, payments agrees
    post("INV-2", "issue", "2026-10-15", [line(purpose="receivable", debit="500.00"), line(purpose="revenue", credit="500.00")])      # payments says 400 outstanding
    post("INV-3", "issue", "2026-10-15", [line(purpose="receivable", debit="300.00"), line(purpose="revenue", credit="300.00")])      # payments has never heard of it
    post("pay_x", "receipt", "2026-10-20", [line(account="1200", debit="40.00"), line(purpose="receivable", credit="40.00")], source_module="payments")
    pay.invoices.update({"INV-1": {"currency": "GBP", "total": "1200.00", "outstanding": "1200.00"},
                         "INV-2": {"currency": "GBP", "total": "500.00", "outstanding": "400.00"}})
    r = must(api("POST", "/reconcile/receivable", BOOKKEEPER, {"as_at": "2026-10-31"}))
    kinds = {b["document"]: b["kind"] for b in r["breaks"]}
    assert kinds == {"INV-2": "settled_not_posted", "INV-3": "posted_not_in_payments", "unattributed:pay_x": "unattributed"}
    assert r["gl_balance"] == "1960.00" and r["outstanding_sum"] == "1600.00" and r["variance"] == "360.00" and r["reconciled"] is False
    assert all(b["fix"] for b in r["breaks"])
    # the close gate follows; accepting the difference with a reason satisfies it
    g = must(api("GET", "/periods/2026-10/readiness", ACCOUNTANT))
    assert next(x for x in g["gates"] if x["gate"] == "receivable_reconciled")["met"] is False
    acc = must(api("POST", "/reconcile/accept-difference", ACCOUNTANT, {"kind": "receivable", "as_at": "2026-10-31", "reason": "timing, receipts in transit"}))
    assert acc["reconciled"] is True
    g = must(api("GET", "/periods/2026-10/readiness", ACCOUNTANT))
    assert next(x for x in g["gates"] if x["gate"] == "receivable_reconciled")["met"] is True
    assert api("POST", "/reconcile/receivable", AUDITOR, {})[0] == 403


def test_an_agreeing_receivable_has_no_break_and_pages_through_two_hundred_invoices():
    books()
    pay = Payments().install()
    for i in range(205):
        post(f"INV-{i}", "issue", "2026-10-02", [line(purpose="receivable", debit="1.00"), line(purpose="revenue", credit="1.00")])
        pay.invoices[f"INV-{i}"] = {"currency": "GBP", "total": "1.00", "outstanding": "1.00"}
    r = must(api("POST", "/reconcile/receivable", BOOKKEEPER, {"as_at": "2026-10-31"}))
    assert r["breaks"] == [] and r["variance"] == "0.00" and r["reconciled"] is True
    assert [len(c) for c in pay.calls] == [200, 5]


def test_a_reversed_invoice_nets_to_zero_on_its_own_document():
    books()
    pay = Payments().install()
    s, inv = invoice("INV-9")
    must(api("POST", f"/journals/{inv['journal_id']}/reverse-journal", ACCOUNTANT, {"reason": "void"}))
    pay.invoices["INV-9"] = {"currency": "GBP", "total": "1200.00", "outstanding": "0.00"}
    r = must(api("POST", "/reconcile/receivable", BOOKKEEPER, {"as_at": "2026-10-31"}))
    assert r["breaks"] == [] and r["gl_balance"] == "0.00"


def test_payables_against_purchasings_open_bills():
    books()
    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("B-17", "approve", "2026-10-05", [line(account="6000", debit="216.00"), line(purpose="payable", credit="216.00")], source_module="purchasing")
    post("sp_1", "pay", "2026-10-20", [line(purpose="payable", debit="100.00", against_document="B-17"), line(account="1200", credit="100.00")],
         source_module="purchasing")
    pur.bills = [{"bill_id": "B-17", "bill_no": "17", "bp_id": 1, "currency": "GBP", "total": "216.00", "outstanding": "116.00", "due_date": "2026-11-05"}]
    r = must(api("POST", "/reconcile/payable", BOOKKEEPER, {"as_at": "2026-10-31"}))
    assert r["breaks"] == [] and r["gl_balance"] == "116.00" and r["outstanding_sum"] == "116.00"
    pur.bills.append({"bill_id": "B-18", "bill_no": "18", "bp_id": 1, "currency": "GBP", "total": "50.00", "outstanding": "50.00", "due_date": "2026-11-05"})
    r = must(api("POST", "/reconcile/payable", BOOKKEEPER, {"as_at": "2026-10-31"}))
    assert [(b["document"], b["kind"]) for b in r["breaks"]] == [("B-18", "open_not_posted")]


def test_asset_accounts_against_the_register():
    books()
    fa = Assets().install()
    post("fa:1", "acquire", "2026-10-02", [line(account="1400", debit="30000.00"), line(account="2100", credit="30000.00")], source_module="fixed-assets")
    fa.balances["LEAD"] = [{"account_id": str(account_id("1400")), "role": "cost", "class_code": "VEH", "balance": "30000.00"}]
    assert must(api("POST", "/reconcile/assets", ACCOUNTANT, {"as_at": "2026-10-31"}))["breaks"] == []
    fa.balances["LEAD"][0]["balance"] = "29000.00"
    r = must(api("POST", "/reconcile/assets", ACCOUNTANT, {"as_at": "2026-10-31"}))
    assert r["breaks"][0]["difference"] == "1000.00"


def test_the_depreciation_gate_is_asked_only_when_fixed_assets_is_installed():
    books()
    from world import close_through
    close_through("2026-09")
    g = must(api("GET", "/periods/2026-10/readiness", ACCOUNTANT))
    assert next(x for x in g["gates"] if x["gate"] == "depreciation_posted")["met"] is True
    fa = Assets().install()
    fa.posted["LEAD"] = False
    g = must(api("GET", "/periods/2026-10/readiness", ACCOUNTANT))
    assert next(x for x in g["gates"] if x["gate"] == "depreciation_posted")["met"] is False
    fa.posted["LEAD"] = True
    g = must(api("GET", "/periods/2026-10/readiness", ACCOUNTANT))
    assert next(x for x in g["gates"] if x["gate"] == "depreciation_posted")["met"] is True
    _ = Decimal