dodil/erp-brick-gl

DODIL ERP brick: general ledger

tests/test_audit1.py

"""Audit 1: four proven findings."""
from decimal import Decimal

from conftest import ACCOUNTANT, ACCOUNTANT2, OWNER, SYSTEM, api, must, on_day, refused
from sqlalchemy import text
from test_intercompany import recharge, two_companies
from world import books, close_through, line, post

TOMORROW = "2026-10-23T09:00:00+00:00"


def test_soft_close_is_an_owner_control_and_still_waits_for_the_second_approver():
    books()
    close_through("2026-03")
    refused(api("POST", "/settings", ACCOUNTANT, {"key": "soft_close", "value": True}), 403, "control")
    must(api("POST", "/controls", OWNER, {"key": "soft_close", "value": True}))
    must(api("POST", "/controls", OWNER, {"key": "manual_journal_approval", "value": "always"}))
    lines = [line(account="6100", debit="5000.00"), line(account="2100", credit="5000.00")]
    with on_day(TOMORROW):
        out = must(api("POST", "/journals/propose-manual", ACCOUNTANT, {"journal_date": "2026-03-20", "lines": lines}))
        assert out["status"] == "proposed" and out["needs_approval"] is True
        done = must(api("POST", f"/journals/{out['journal_id']}/approve-manual", ACCOUNTANT2, {}))
        assert done["status"] == "posted"


def test_post_manual_never_posts_one_side_of_a_cross_company_document():
    two_companies()
    p = must(recharge())
    must(api("POST", "/controls", OWNER, {"key": "manual_journal_approval", "value": "none", "valid_from": "2026-10-23"}))
    with on_day(TOMORROW):
        out = api("POST", f"/journals/{p['journal_ids']['UK01']}/post-manual", SYSTEM, {})
        statuses = {c: must(api("GET", f"/journals/{p['journal_ids'][c]}", ACCOUNTANT))["status"] for c in ("UK01", "DE01")}
        assert statuses["UK01"] == statuses["DE01"], (out, statuses)


def test_approval_needed_honours_cross_company():
    from types import SimpleNamespace

    from brick_gl.application.posting import approval_needed
    s = SimpleNamespace(manual_journal_approval="above_threshold", manual_journal_threshold=100)
    assert approval_needed(s, Decimal("500"), cross_company=True) is True
    assert approval_needed(s, Decimal("50"), cross_company=True) is False
    s.manual_journal_approval = "none"
    assert approval_needed(s, Decimal("500"), cross_company=True) is True and approval_needed(s, Decimal("500")) is False


def test_the_hash_chain_covers_the_split_lines(engine):
    books()
    post("INV-S1", "issue", "2026-03-10", [line(purpose="receivable", debit="1200.00"), line(purpose="revenue", credit="1000.00"),
         line(account="2200", credit="200.00")])
    assert must(api("GET", "/audit/verify-chain", ACCOUNTANT_AUD(), {"company_code": "UK01"}))["met"] is True
    with engine.begin() as c:
        c.execute(text("UPDATE gl_split_line SET debit = debit + 1, functional_debit = functional_debit + 1 WHERE debit > 0"))
    out = must(api("GET", "/audit/verify-chain", ACCOUNTANT_AUD(), {"company_code": "UK01"}))
    assert out["met"] is False


def ACCOUNTANT_AUD():
    from conftest import AUDITOR
    return AUDITOR


def test_a_replay_with_a_different_reverse_next_period_is_a_conflict():
    from datetime import date

    from brick_gl.domain import journal as jr
    base = dict(company_code="UK01", journal_date=date(2026, 3, 1), currency="GBP", lines=[])
    assert jr.body_digest(jr.Draft(**base)) == jr.body_digest(jr.Draft(**base, reverse_next_period=False))
    assert jr.body_digest(jr.Draft(**base)) != jr.body_digest(jr.Draft(**base, reverse_next_period=True))
    books()
    lines = [line(account="6100", debit="10.00"), line(account="2100", credit="10.00")]
    a = post("ACC-1", "x", "2026-03-31", lines, source_module="gl", reverse_next_period=False)
    assert a[0] == 200, a
    b = post("ACC-1", "x", "2026-03-31", lines, source_module="gl", reverse_next_period=True)
    refused(b, 409, "journal_conflict")