dodil/erp-brick-expenses

DODIL ERP brick: expenses

tests/test_gates.py

"""Slice B item 1: the rest of EXP-08's submit gates (lines_ready, policy_blocks, card_resolved, not_stale), the policy rows of EXP-07 / EXP-22 /
EXP-23, and EXP-09's delegate. Each test is written from the story's Given/When/Then (module.yaml)."""
from decimal import Decimal

from conftest import (
    EMPLOYEE,
    FINANCE,
    MANAGER,
    MGR,
    OWNER,
    add,
    api,
    approve,
    must,
    new_report,
    on_day,
    person,
    policy,
    refused,
    report,
    seed_card,
    submit,
)

from brick_expenses.application import use_cases

TODAY = "2026-11-07"                     # a policy row starts today (the first row of a rule may), so the lines it judges are dated today


def gate(body, name):
    return next(g for g in body["gates"] if g["gate"] == name)


def draft(**kw):
    rid = new_report()["report_id"]
    add(rid, **kw)
    return rid


def readiness(rid, who=None):
    return {g["gate"]: g for g in report(rid, who)["readiness"]}


# ── lines_ready ──────────────────────────────────────────────────────────────────────────────

def test_EXP_08_a_line_with_a_suggested_field_nobody_confirmed_stops_submission_until_it_is_confirmed_or_corrected():
    rid = new_report()["report_id"]
    add(rid, amount="30.00")
    add(rid, amount="40.00", merchant="Northern Rail")
    with use_cases.repository() as repo:                       # what the receipt reader (EXP-01) leaves: suggested fields, marked
        r = repo.reports.get(rid)
        r.lines[1].unconfirmed = ["merchant", "amount"]
        repo.reports.save_lines(rid, 1, [r.lines[1]], use_cases._now())
    body = refused(api("POST", f"/reports/{rid}/submit", EMPLOYEE), 409, "lines_ready")
    assert body["gate"] == "lines_ready" and "lines with fields not confirmed: 2" in body["reason"]
    assert report(rid)["lines"][1]["unconfirmed_fields"] == ["merchant", "amount"]            # the screen marks them
    got = must(api("POST", f"/reports/{rid}/lines/2", EMPLOYEE, {"amount": "41.00"}))          # correcting a field IS confirming it
    assert got["lines"][1]["unconfirmed_fields"] == ["merchant"] and got["lines"][1]["amount"] == "41.00"
    refused(api("POST", f"/reports/{rid}/submit", EMPLOYEE), 409, "lines_ready")
    refused(api("POST", f"/reports/{rid}/lines/2", EMPLOYEE, {"confirm_fields": ["vat_amount"]}), 422, "no unconfirmed field")
    must(api("POST", f"/reports/{rid}/lines/2", EMPLOYEE, {"confirm_fields": ["merchant"]}))
    assert submit(rid)["status"] == "submitted"


def test_editing_a_line_is_the_claimants_and_a_drafts_alone():
    rid = draft(amount="30.00")
    refused(api("POST", f"/reports/{rid}/lines/1", MANAGER, {"amount": "31.00"}), 404)
    refused(api("POST", f"/reports/{rid}/lines/9", EMPLOYEE, {"amount": "31.00"}), 404, "no line 9")
    refused(api("POST", f"/reports/{rid}/lines/1", EMPLOYEE, {"expense_date": "2026-12-25"}), 409, "in the future")
    submit(rid)
    refused(api("POST", f"/reports/{rid}/lines/1", EMPLOYEE, {"amount": "31.00"}), 409, "frozen")


# ── policy_blocks: EXP-07 ──────────────────────────────────────────────────────────────────────

def uk01_policy():
    policy(FINANCE, rule_code="ACCOM_LONDON", category_code="ACCOMMODATION", scope="per night", limit_amount="180.00", city_or_country="London")
    policy(FINANCE, rule_code="ACCOM_ELSEWHERE", category_code="ACCOMMODATION", scope="per night", limit_amount="140.00", city_or_country="*")
    policy(FINANCE, rule_code="MEALS_CLIENT", category_code="MEALS_CLIENT", scope="per person", limit_amount="75.00", mode="block", requires="attendees")
    policy(FINANCE, rule_code="ALCOHOL", category_code="ALCOHOL", scope="per item", limit_amount="0.00", mode="block")


def test_EXP_07_the_hotel_and_the_dinner_pass_the_bar_bill_is_refused_naming_its_row():
    uk01_policy()
    rid = new_report()["report_id"]
    hotel = add(rid, category_code="ACCOMMODATION", merchant="Manchester Hotel", amount="264.00", city="Manchester", nights=2, expense_date=TODAY)
    assert hotel["lines"][0]["policy"] == []                                                       # 132.00 a night is under 140.00
    dinner = add(rid, category_code="MEALS_CLIENT", merchant="The Ivy", amount="96.00", attendees=["A. Client", "B. Client", "Me"], expense_date=TODAY)
    assert dinner["lines"][1]["policy"] == []                                                      # 32.00 a person, attendees listed
    bar = add(rid, category_code="ALCOHOL", merchant="The Bar", amount="22.00", expense_date=TODAY)
    (hit,) = bar["lines"][2]["policy"]
    assert (hit["rule_code"], hit["mode"]) == ("ALCOHOL", "block") and "policy UK01 ALCOHOL" in hit["message"] and "not reimbursed" in hit["message"]
    body = refused(api("POST", f"/reports/{rid}/submit", EMPLOYEE), 409, "policy_blocks")
    assert body["gate"] == "policy_blocks" and "line 3" in gate(body, "policy_blocks")["reason"]
    must(api("DELETE", f"/reports/{rid}/lines/3", EMPLOYEE))
    assert submit(rid)["status"] == "submitted"


def test_EXP_07_a_meal_with_no_attendees_named_breaks_the_per_person_block_and_naming_them_clears_it():
    uk01_policy()
    rid = new_report()["report_id"]
    add(rid, category_code="MEALS_CLIENT", merchant="The Ivy", amount="96.00", expense_date=TODAY)
    assert readiness(rid)["policy_blocks"]["met"] is False                                         # alone: 96.00 a person
    got = must(api("POST", f"/reports/{rid}/lines/1", EMPLOYEE, {"attendees": ["A", "B", "C"]}))
    assert got["lines"][0]["policy"] == [] and readiness(rid)["policy_blocks"]["met"] is True


def test_EXP_07_a_warn_rule_lets_the_claim_through_with_the_employees_reason_for_the_approver_to_see():
    uk01_policy()
    rid = new_report()["report_id"]
    add(rid, category_code="ACCOMMODATION", merchant="Manchester Hotel", amount="320.00", city="Manchester", nights=2, expense_date=TODAY)      # 160.00 > 140.00
    (hit,) = report(rid)["lines"][0]["policy"]
    assert (hit["rule_code"], hit["mode"], hit["actual"], hit["limit"]) == ("ACCOM_ELSEWHERE", "warn", "160.00", "140.00")
    must(api("POST", f"/reports/{rid}/lines/1", EMPLOYEE, {"justification": "the conference venue's only room"}))
    assert submit(rid)["status"] == "submitted"
    line = must(api("GET", f"/reports/{rid}", MANAGER))["lines"][0]                                  # the approver sees the warning and the reason
    assert line["policy"][0]["mode"] == "warn" and line["justification"] == "the conference venue's only room"


def test_EXP_07_the_cities_own_row_beats_the_general_one_and_a_rule_in_another_company_never_applies():
    uk01_policy()
    rid = new_report()["report_id"]
    add(rid, category_code="ACCOMMODATION", merchant="London Hotel", amount="340.00", city="london", nights=2, expense_date=TODAY)      # 170.00 under 180.00
    assert report(rid)["lines"][0]["policy"] == []
    nb_rule = {"company_code": "DE01", "rule_code": "ACCOM_DE", "category_code": "ACCOMMODATION", "scope": "per night", "limit_amount": "1.00",
               "mode": "block", "valid_from": "2026-11-07"}
    must(api("POST", "/policy-rules", FINANCE, nb_rule), 201)
    add(rid, category_code="ACCOMMODATION", merchant="London Hotel 2", amount="340.00", city="London", nights=2, expense_date=TODAY)
    assert report(rid)["lines"][1]["policy"] == []


def test_a_rule_dated_after_the_day_of_a_line_does_not_apply_to_it_and_the_rules_in_force_on_the_submission_day_decide():
    rid = new_report()["report_id"]
    add(rid, category_code="OFFICE", merchant="Staples", amount="50.00")
    assert readiness(rid)["policy_blocks"]["met"] is True
    policy(FINANCE, rule_code="NO_OFFICE", category_code="OFFICE", limit_amount="0.00", mode="block", valid_from="2026-11-08")     # tomorrow
    assert submit(rid)["status"] == "submitted"                                                      # the line is dated 2026-11-04: the old rules
    rid2 = new_report()["report_id"]
    add(rid2, category_code="OFFICE", merchant="Staples", amount="50.00", expense_date="2026-11-07")
    with on_day("2026-11-09T09:00:00+00:00"):
        add(rid2, category_code="OFFICE", merchant="Staples 2", amount="50.00", expense_date="2026-11-09")
        assert report(rid2)["lines"][1]["policy"][0]["rule_code"] == "NO_OFFICE"


def test_EXP_22_finance_cannot_weaken_a_block_the_owners_control_can_and_a_row_starts_after_today():
    policy(FINANCE, rule_code="ALCOHOL", category_code="ALCOHOL", limit_amount="0.00", mode="block")
    weaker = {"company_code": "UK01", "rule_code": "ALCOHOL", "category_code": "ALCOHOL", "scope": "per item", "limit_amount": "0.00", "mode": "warn",
              "valid_from": "2026-12-01"}
    refused(api("POST", "/policy-rules", FINANCE, weaker), 403, "exp:settings:controls")             # a finance user tries to lower a block to a warn
    assert must(api("POST", "/policy-rules", OWNER, weaker), 201)["mode"] == "warn"                  # the owner holds the control: weakening is theirs
    refused(api("POST", "/policy-rules", OWNER, {**weaker, "rule_code": "NEW_BAN", "mode": "block", "valid_from": "2026-12-05"}), 403, "exp:policy:write")                                                                      # a new rule is policy editing, which the owner does not hold
    stricter = {**weaker, "mode": "block", "limit_amount": "0.00", "valid_from": "2026-12-02"}
    must(api("POST", "/policy-rules", FINANCE, stricter), 201)                                         # making it stricter is finance's own
    refused(api("POST", "/policy-rules", FINANCE, {**weaker, "mode": "block", "valid_from": "2026-11-07"}), 409, "starts after today")
    refused(api("POST", "/policy-rules", FINANCE, {**weaker, "rule_code": "X", "valid_from": "2026-11-01"}), 422, "in the past")
    refused(api("POST", "/policy-rules", EMPLOYEE, weaker), 403, "exp:policy:write")
    in_force = must(api("GET", "/policy-rules", EMPLOYEE, {"company_code": "UK01", "on": "2026-12-01"}))["rules"]
    assert [(r["rule_code"], r["mode"]) for r in in_force] == [("ALCOHOL", "warn")]                  # the row dated 2026-12-01 is in force that day
    in_force = must(api("GET", "/policy-rules", EMPLOYEE, {"company_code": "UK01", "on": "2026-12-02"}))["rules"]
    assert [(r["rule_code"], r["mode"]) for r in in_force] == [("ALCOHOL", "block")]


def test_EXP_23_a_rule_needs_a_category_that_exists():
    refused(api("POST", "/policy-rules", FINANCE, {"company_code": "UK01", "rule_code": "X", "category_code": "NOPE", "scope": "per item",
                                                   "limit_amount": "1.00", "mode": "warn", "valid_from": "2026-11-07"}), 409, "No expense category NOPE")


# ── card_resolved ─────────────────────────────────────────────────────────────────────────────

def test_EXP_08_card_transactions_of_the_period_not_matched_or_personal_stop_submission_and_are_named():
    seed_card("4417", 3301, [("TX-1", "2026-11-05", "Manchester Hotel", Decimal("264.00")), ("TX-2", "2026-11-06", "Cinema", Decimal("18.00")),
                             ("TX-0", "2026-10-01", "Old lunch", Decimal("9.00"))])
    seed_card("9999", 2280, [("TX-9", "2026-11-05", "Someone else's", Decimal("5.00"))])
    rid = draft(amount="30.00")
    body = refused(api("POST", f"/reports/{rid}/submit", EMPLOYEE), 409, "card_resolved")
    why = gate(body, "card_resolved")["reason"]
    assert "card:4417:TX-1" in why and "card:4417:TX-2" in why           # the period runs from the first receipt (11-04) to today
    assert "TX-0" not in why and "TX-9" not in why                       # before the period, and another holder's: not this claim's business
    with use_cases.repository() as repo:
        for ref, status in (("TX-1", "matched"), ("TX-2", "personal")):
            row = repo.cards.txn(f"card:4417:{ref}")
            repo.cards.save_txn({**row, "status": status}, use_cases._now())
    assert submit(rid)["status"] == "submitted"


# ── not_stale ───────────────────────────────────────────────────────────────────────────────────

def test_EXP_20_a_policy_row_that_starts_today_marks_the_open_lines_stale_and_a_recompute_clears_them():
    rid = new_report()["report_id"]
    add(rid, category_code="OFFICE", merchant="Staples", amount="50.00", expense_date="2026-11-07")
    old = new_report()["report_id"]
    add(old, category_code="OFFICE", merchant="Staples", amount="50.00", expense_date="2026-11-04")
    got = policy(FINANCE, rule_code="NO_OFFICE", category_code="OFFICE", limit_amount="0.00", mode="block")
    assert got["lines_marked_stale"] == 1                                                              # only a line dated on or after the row
    body = refused(api("POST", f"/reports/{rid}/submit", EMPLOYEE), 409, "not_stale")
    assert "1 (policy NO_OFFICE from 2026-11-07)" in gate(body, "not_stale")["reason"] and gate(body, "not_stale")["met"] is False
    again = must(api("POST", f"/reports/{rid}/recompute", EMPLOYEE))
    assert [(c["line_no"], c["field"], c["now"]) for c in again["changes"]] == [(1, "policy", ["NO_OFFICE"])]
    assert again["lines"][0]["stale"] is False and again["lines"][0]["policy"][0]["mode"] == "block"
    refused(api("POST", f"/reports/{rid}/submit", EMPLOYEE), 409, "policy_blocks")                       # fresh, and now the block is what stops it
    assert submit(old)["status"] == "submitted"


def test_EXP_20_approval_is_refused_on_a_stale_line_until_the_approver_recomputes():
    rid = new_report()["report_id"]
    add(rid, category_code="OFFICE", merchant="Staples", amount="50.00", expense_date="2026-11-07")
    submit(rid)
    policy(FINANCE, rule_code="CAP_OFFICE", category_code="OFFICE", limit_amount="10.00", mode="warn")
    body = refused(approve(rid, MANAGER), 409, "not_stale")
    assert body["gate"] == "not_stale"
    refused(api("POST", f"/reports/{rid}/recompute", EMPLOYEE), 403, "exp:report:approve")              # the claimant does not recompute a submitted claim
    refused(api("POST", f"/reports/{rid}/recompute", FINANCE), 403, "exp:report:approve")                # only the level that waits
    got = must(api("POST", f"/reports/{rid}/recompute", MANAGER))
    assert got["changes"][0]["cause"] == "policy CAP_OFFICE from 2026-11-07" and got["lines"][0]["policy"][0]["mode"] == "warn"
    assert must(approve(rid, MANAGER))["status"] == "posted"                                            # a warn does not stop an approval


def test_a_recompute_keeps_what_changed_and_why_on_the_line():
    rid = new_report()["report_id"]
    add(rid, category_code="OFFICE", merchant="Staples", amount="50.00", expense_date="2026-11-07")
    policy(FINANCE, rule_code="CAP_OFFICE", category_code="OFFICE", limit_amount="10.00", mode="warn")
    must(api("POST", f"/reports/{rid}/recompute", EMPLOYEE))
    with use_cases.repository() as repo:
        (ln,) = repo.reports.get(rid).lines
    assert ln.recomputed[0]["field"] == "policy" and ln.recomputed[0]["on"] == "2026-11-07" and not ln.stale


# ── EXP-09: delegates ───────────────────────────────────────────────────────────────────────────

DELEGATE = person("cover@uk.example", ["exp:report:read_own", "exp:report:write_own", "exp:report:submit", "exp:report:read_team", "exp:report:approve",
                                       "exp:delegation:write"])


def delegate(frm="2026-11-07", to="2026-11-14", **kw):
    return api("POST", "/delegations", MANAGER, {"delegate_email": "cover@uk.example", "valid_from": frm, "valid_to": to, **kw})


def test_EXP_09_a_delegate_sees_and_decides_the_managers_level_while_the_cover_lasts():
    rid = new_report()["report_id"]
    add(rid, amount="30.00")
    submit(rid)
    refused(approve(rid, DELEGATE), 404)                                                             # not on the route, no cover yet
    assert must(delegate(), 201)["valid_to"] == "2026-11-14"
    assert [r["report_id"] for r in must(api("GET", "/reports", DELEGATE, {"scope": "waiting"}))["reports"]] == [rid]
    assert must(api("GET", f"/reports/{rid}", DELEGATE))["report_id"] == rid
    got = must(approve(rid, DELEGATE))
    assert got["status"] == "posted" and got["decisions"][0]["decided_by"] == "cover@uk.example" and got["decisions"][0]["delegate_for"] == MGR
    assert must(api("GET", "/delegations", MANAGER))["given"][0]["delegate"] == "cover@uk.example"
    assert must(api("GET", "/delegations", DELEGATE))["covering_me"][0]["delegator"] == MGR


def test_EXP_09_the_cover_is_dated_and_the_owner_can_switch_delegation_off():
    rid = new_report()["report_id"]
    add(rid, amount="30.00")
    submit(rid)
    must(delegate("2026-11-08", "2026-11-14"), 201)                                                   # starts tomorrow
    refused(approve(rid, DELEGATE), 404)
    with on_day("2026-11-09T09:00:00+00:00"):
        assert [r["report_id"] for r in must(api("GET", "/reports", DELEGATE, {"scope": "waiting"}))["reports"]] == [rid]
    with on_day("2026-11-20T09:00:00+00:00"):                                                         # the cover ended
        refused(approve(rid, DELEGATE), 404)
    with use_cases.repository() as repo:                                                              # the owner turns it off from today
        repo.settings.put("delegation_allowed", "false", use_cases._today(repo), "owner", use_cases._now())
    with on_day("2026-11-09T09:00:00+00:00"):
        refused(approve(rid, DELEGATE), 404)
    refused(delegate("2026-11-10", "2026-11-12"), 409, "switched off")


def test_EXP_09_the_delegate_who_is_the_claimant_cannot_approve_their_own_claim_under_either_login(nb):
    nb.employee(4402, "cover@uk.example", manager=nb.as_manager(2280, MGR))                           # the delegate is an employee whose manager is 2280
    must(delegate(), 201)
    rid = new_report(DELEGATE)["report_id"]
    add(rid, DELEGATE, amount="30.00")
    submit(rid, DELEGATE)
    refused(approve(rid, DELEGATE), 409, "You are the claimant")                                      # 2280's level, covered by the claimant: still theirs to refuse
    again = person("cover@uk.example", [*DELEGATE_PERMS(), "exp:report:read_own"], sub="a-different-sub")
    refused(approve(rid, again), 409, "You are the claimant")                                         # the same email under a second sub
    renamed = person("another.login@uk.example", [*DELEGATE_PERMS(), "exp:report:read_own"], sub="cover@uk.example")
    refused(approve(rid, renamed), 403, "not the approver")                                           # the same sub under a second email is not on the cover
    assert must(approve(rid, MANAGER))["status"] == "posted"                                          # the manager is still the manager


def test_EXP_09_a_delegate_who_decided_level_one_does_not_decide_level_two_and_finances_level_is_never_covered():
    rid = new_report()["report_id"]
    add(rid, amount="620.00")
    submit(rid)
    must(delegate(), 201)
    must(approve(rid, DELEGATE))
    both = person("cover@uk.example", [*DELEGATE_PERMS(), "exp:report:finance_approve"])
    refused(approve(rid, both), 409, "decided an earlier level")
    refused(approve(rid, DELEGATE), 403, "exp:report:finance_approve")


def DELEGATE_PERMS():
    return ["exp:report:read_own", "exp:report:read_team", "exp:report:approve"]


def test_EXP_09_only_a_manager_with_the_permission_delegates_and_to_a_bounded_window():
    refused(api("POST", "/delegations", EMPLOYEE, {"delegate_email": "x@uk.example", "valid_from": "2026-11-07", "valid_to": "2026-11-08"}), 403,
            "exp:delegation:write")
    refused(delegate("2026-11-01", "2026-11-14"), 422, "starts today or later")
    refused(delegate("2026-11-10", "2026-11-09"), 422)
    refused(delegate("2026-11-07", "2028-01-01"), 422, "at most a year")
    refused(api("POST", "/delegations", MANAGER, {"delegate_email": MGR.upper(), "valid_from": "2026-11-07", "valid_to": "2026-11-08"}), 409, "yourself")
    refused(api("POST", "/delegations", MANAGER, {"delegate_email": "x@uk.example", "delegate_sub": MGR, "valid_from": "2026-11-07", "valid_to": "2026-11-08"}),
            409, "yourself")                                                                          # the same sub under another email is still them


def test_EXP_09_ending_a_delegation_stops_the_cover_without_touching_what_was_decided():
    rid = new_report()["report_id"]
    add(rid, amount="30.00")
    submit(rid)
    must(delegate(), 201)
    must(api("POST", "/delegations/end", MANAGER, {"delegate_email": "cover@uk.example", "valid_from": "2026-11-07"}))
    refused(approve(rid, DELEGATE), 404)
    refused(api("POST", "/delegations/end", MANAGER, {"delegate_email": "nobody@uk.example", "valid_from": "2026-11-07"}), 404)