dodil/erp-brick-expenses

DODIL ERP brick: expenses

tests/test_stories.py

"""One test per story of slice A, named for it, written from the spec's Given/When/Then (module.yaml stories).
EXP-08 submit . EXP-09 approve a level . EXP-10 return . EXP-11 finance approves . EXP-12 post . EXP-13 pay . EXP-19 hr's leaver.
The claim is the specification's own (employee 3301, UK01, SALES-N; manager 2280; threshold 500.00), in the own-money lines this
slice holds: the card hotel, the mileage and the personal-card netting arrive with their stories."""
from decimal import Decimal

from conftest import (
    EMP,
    EMPLOYEE,
    FINANCE,
    FINANCE2,
    HR_CLIENT,
    MANAGER,
    MANAGER2,
    MGR,
    MGR2,
    NOBODY,
    PAYMENTS_CLIENT,
    SYSTEM,
    add,
    api,
    approve,
    claim_551,
    must,
    new_report,
    on_day,
    person,
    refused,
    report,
    submit,
)
from erp_platform import seam


def fully_approved(rid):
    must(approve(rid, MANAGER))
    return must(approve(rid, FINANCE))


# ── EXP-08 ──────────────────────────────────────────────────────────────────────────────────────

def test_EXP_08_submitting_fixes_the_chain_from_hr_and_freezes_the_lines(nb):
    rid = claim_551()
    got = submit(rid)
    assert got["status"] == "submitted" and got["total_amount"] == "551.40" and got["submitted_on"] == "2026-11-07"
    assert [(c["level"], c["kind"], c["approver_person_id"]) for c in got["chain"]] == [(1, "manager", "2280"), (2, "finance", None)]
    assert got["waiting_level"] == 1
    refused(api("POST", f"/reports/{rid}/lines", EMPLOYEE, {"category_code": "OFFICE", "expense_date": "2026-11-04", "merchant": "x", "amount": "1.00"}),
            409, "lines are frozen")
    nb.managers[3301][0] = nb.as_manager(9999, "someone.else@uk.example")        # hr changes later: the chain on the claim stays as fixed
    assert report(rid)["chain"][0]["approver_person_id"] == "2280"


def test_EXP_08_a_total_at_or_under_the_threshold_needs_the_manager_alone(nb):
    rid = new_report()["report_id"]
    add(rid, amount="500.00")                                                    # 500.00 is not ABOVE 500.00
    assert [c["kind"] for c in submit(rid)["chain"]] == ["manager"]


def test_EXP_08_with_no_manager_in_hr_the_claim_goes_to_finance(nb):
    nb.managers[3301] = [None, None]
    rid = new_report()["report_id"]
    add(rid, amount="20.00")
    assert [(c["level"], c["kind"]) for c in submit(rid)["chain"]] == [(1, "finance")]


def test_EXP_08_every_unmet_gate_is_named(nb):
    rid = new_report()["report_id"]
    body = refused(api("POST", f"/reports/{rid}/submit", EMPLOYEE), 409, "has_lines")
    assert body["gate"] == "has_lines"
    key = f"raw/expenses/{rid}/2/" + "b" * 64 + ".jpg"
    add(rid, amount="9.50", receipt_key="")                                      # under receipt_required_above (10.00): fine without a receipt
    add(rid, amount="264.00", category_code="ACCOMMODATION", merchant="Manchester Hotel", receipt_key="")
    add(rid, amount="30.00", receipt_key="")
    body = refused(api("POST", f"/reports/{rid}/submit", EMPLOYEE), 409, "receipts_present")
    assert body["gate"] == "receipts_present" and "2, 3" in body["reason"]
    assert [g["gate"] for g in body["gates"] if not g["met"]] == ["receipts_present"]
    assert [g["met"] for g in report(rid)["readiness"]] == [True, True, False, True, True, True, True]       # the screen shows the same gates before submitting
    for n in (2, 3):
        must(api("DELETE", f"/reports/{rid}/lines/{n}", EMPLOYEE))
    add(rid, amount="30.00", receipt_key=key)
    assert submit(rid)["status"] == "submitted"


def test_EXP_08_someone_not_employed_on_the_date_cannot_submit(nb):
    rid = new_report()["report_id"]
    add(rid)
    nb.left_on[3301] = "2026-10-01"
    refused(api("POST", f"/reports/{rid}/submit", EMPLOYEE), 409, "not employed")


def test_EXP_08_only_the_claimant_submits_and_another_persons_claim_is_a_404(nb):
    rid = new_report()["report_id"]
    add(rid)
    refused(api("POST", f"/reports/{rid}/submit", MANAGER), 404, "no expense report")
    refused(api("POST", f"/reports/{rid}/submit", FINANCE), 404)
    refused(api("POST", f"/reports/{rid}/submit", NOBODY), 403, "exp:report:submit")


def test_EXP_08_a_claim_is_numbered_from_its_companys_yearly_series(nb):
    a, b = new_report()["report_id"], new_report()["report_id"]
    assert (a, b) == ("UK01-EXP-2026-00001", "UK01-EXP-2026-00002")
    nb.employee(5120, "other@de.example", company="DE01")
    assert new_report(person("other@de.example", ["exp:report:write_own"]))["report_id"] == "DE01-EXP-2026-00001"


def test_EXP_08_someone_hr_does_not_know_cannot_claim(nb):
    refused(api("POST", "/reports", person("stranger@uk.example", ["exp:report:write_own"]), {"currency": "GBP"}), 409, "hr holds no person")
    seam.unregister_local("hr")
    refused(api("POST", "/reports", EMPLOYEE, {"currency": "GBP"}), 409, "hr is not part of this installation")
    nb.hr_status = 500
    seam.register_local("hr", nb.hr)
    refused(api("POST", "/reports", EMPLOYEE, {"currency": "GBP"}), 502, "hr failed")


# ── EXP-09 ──────────────────────────────────────────────────────────────────────────────────────

def test_EXP_09_the_manager_approves_level_one_and_the_claim_moves_to_finance(nb):
    rid = claim_551()
    submit(rid)
    got = must(approve(rid, MANAGER))
    assert got["status"] == "submitted" and got["waiting_level"] == 2
    assert [(d["level"], d["decision"], d["decided_by"]) for d in got["decisions"]] == [(1, "approved", MGR)]


def test_EXP_09_a_managers_own_claim_goes_to_their_manager_never_to_themselves(nb):
    rid = new_report(MANAGER)["report_id"]
    add(rid, MANAGER, amount="620.00")
    chain = submit(rid, MANAGER)["chain"]
    assert [(c["level"], c["kind"], c["approver_person_id"]) for c in chain] == [(1, "manager", "1904"), (2, "finance", None)]
    refused(approve(rid, MANAGER), 403, "not the approver of level 1")           # it is their own claim: level 1 is 1904's, not theirs


def test_EXP_09_when_hr_names_the_claimant_as_their_own_manager_the_level_goes_one_up(nb):
    nb.managers[3301] = [nb.as_manager(3301, EMP), nb.as_manager(1904, MGR2)]    # hr's level 1 IS the claimant (an override gone wrong)
    rid = new_report()["report_id"]
    add(rid, amount="20.00")
    got = submit(rid)
    assert [(c["approver_person_id"], c["rerouted_from"]) for c in got["chain"]] == [("1904", EMP)]


def test_EXP_09_a_manager_that_is_the_claimant_under_a_second_email_is_still_the_claimant(nb):
    nb.managers[3301] = [{"person_id": 3301, "bp_id": 1, "pool_user_sub": EMP, "email": "other.address@uk.example"}, nb.as_manager(1904, MGR2)]
    rid = new_report()["report_id"]
    add(rid, amount="20.00")
    assert [c["approver_person_id"] for c in submit(rid)["chain"]] == ["1904"]       # matched on the sub even though the email differs


def test_EXP_09_only_the_levels_approver_decides_and_a_manager_cannot_take_finances_level(nb):
    rid = claim_551()
    submit(rid)
    refused(approve(rid, MANAGER2), 404)                                          # 1904 is not on this claim's route: they cannot even see it
    refused(approve(rid, FINANCE), 403, "exp:report:approve")                    # level 1 is the manager's: finance's permission is not it
    anyone = person("someone.else@uk.example", [*MANAGER_PERMS(), *FINANCE_PERMS()])        # may see every claim and holds both permissions
    refused(approve(rid, anyone), 403, "not the approver of level 1")            # ... but level 1 is 2280's alone
    must(approve(rid, MANAGER))
    refused(approve(rid, MANAGER), 403, "exp:report:finance_approve")            # level 2 is finance's: a manager's permission is not it


def test_EXP_09_a_draft_or_unrouted_claim_is_not_approvable_and_not_seen(nb):
    rid = claim_551()
    refused(approve(rid, MANAGER), 404)                                          # a draft is its owner's alone
    refused(api("GET", f"/reports/{rid}", MANAGER), 404)
    submit(rid)
    assert must(api("GET", f"/reports/{rid}", MANAGER))["report_id"] == rid      # once routed to them, they see it
    assert must(api("GET", "/reports", MANAGER, {"scope": "waiting"}))["reports"][0]["report_id"] == rid
    assert must(api("GET", "/reports", MANAGER2, {"scope": "waiting"}))["reports"] == []


# ── EXP-10 ──────────────────────────────────────────────────────────────────────────────────────

def test_EXP_10_a_returned_claim_continues_as_revision_two_and_old_approvals_never_count(nb):
    rid = claim_551()
    submit(rid)
    must(approve(rid, MANAGER))
    got = must(api("POST", f"/reports/{rid}/return", FINANCE, {"reason": "which client was the dinner with?"}))
    assert (got["status"], got["revision"], got["waiting_level"], got["chain"], got["decisions"]) == ("draft", 2, None, [], [])
    assert len(got["lines"]) == 4 and got["total_amount"] == "551.40"                # the lines are copied into revision 2
    assert got["revisions"] == [{"revision": 1, "ended": "returned"}]
    add(rid, merchant="One more train", amount="5.00")
    again = submit(rid)
    assert (again["revision"], again["waiting_level"], again["decisions"]) == (2, 1, [])        # the chain starts again from level 1
    assert [c["kind"] for c in again["chain"]] == ["manager", "finance"]
    must(approve(rid, MANAGER))
    assert must(approve(rid, FINANCE))["status"] == "posted"
    assert [j["source_document"] for j in nb.journals.values()] == [f"exp:{rid}:r2"]       # revision 2's journal, never revision 1's


def test_EXP_10_a_return_and_a_rejection_need_a_reason_and_only_the_level_that_waits_may(nb):
    rid = claim_551()
    submit(rid)
    refused(api("POST", f"/reports/{rid}/return", MANAGER, {"reason": "  "}), 422)
    refused(api("POST", f"/reports/{rid}/return", MANAGER2, {"reason": "x"}), 404)
    refused(api("POST", f"/reports/{rid}/return", FINANCE, {"reason": "x"}), 403, "exp:report:approve")
    refused(api("POST", f"/reports/{rid}/reject", MANAGER, {}), 422)


# ── EXP-11 ──────────────────────────────────────────────────────────────────────────────────────

def test_EXP_11_finance_approves_the_last_level_and_the_claim_is_approved_posted_and_paid_for(nb):
    rid = claim_551()
    submit(rid)
    must(approve(rid, MANAGER))
    got = must(approve(rid, FINANCE))
    assert got["status"] == "posted" and got["approved_on"] == "2026-11-07" and got["journal_id"].isdigit()
    assert [(d["level"], d["kind"]) for d in got["decisions"]] == [(1, "manager"), (2, "finance")]


def test_EXP_11_nobody_decides_two_levels_and_nobody_decides_their_own_claim(nb):
    both = person(MGR, [*MANAGER_PERMS(), *FINANCE_PERMS()])                      # manager 2280, who also holds finance's permission
    rid = claim_551()
    submit(rid)
    must(approve(rid, both))                                                      # level 1 as the manager
    refused(approve(rid, both), 409, "decided an earlier level")                  # SC-EXP-02: not level 2 as finance
    renamed = person("renamed.manager@uk.example", [*MANAGER_PERMS(), *FINANCE_PERMS()], sub=MGR)    # the same sub under another email
    refused(approve(rid, renamed), 409, "decided an earlier level")
    assert must(approve(rid, FINANCE))["status"] == "posted"                      # another finance user is fine


def test_EXP_11_a_finance_user_who_is_the_claimant_cannot_approve_their_own_claim(nb):
    nb.employee(4401, "fin.claimant@uk.example")
    nb.managers[4401] = [None, None]                                              # no manager: finance is level 1
    me = person("fin.claimant@uk.example", [*FINANCE_PERMS()])
    rid = new_report(me)["report_id"]
    add(rid, me, amount="20.00")
    submit(rid, me)
    refused(approve(rid, me), 409, "You are the claimant")
    renamed = person("fin.claimant@renamed.example", [*FINANCE_PERMS()], sub="fin.claimant@uk.example")
    refused(approve(rid, renamed), 409, "You are the claimant")                   # the same sub under a second email
    again = person("fin.claimant@uk.example", [*FINANCE_PERMS()], sub="a-different-sub")
    refused(approve(rid, again), 409, "You are the claimant")                     # the same email under a second sub
    assert must(approve(rid, FINANCE))["status"] == "posted"


def test_EXP_11_a_rejection_is_terminal_and_nothing_is_posted_or_paid(nb):
    rid = claim_551()
    submit(rid)
    must(approve(rid, MANAGER))
    got = must(api("POST", f"/reports/{rid}/reject", FINANCE, {"reason": "not a business cost"}))
    assert got["status"] == "rejected" and got["decisions"][-1]["reason"] == "not a business cost"
    refused(approve(rid, FINANCE2), 409, "rejected")
    assert nb.journals == {} and nb.payouts == {}


def MANAGER_PERMS():
    from conftest import ROLES
    return ROLES["manager"]


def FINANCE_PERMS():
    from conftest import ROLES
    return ROLES["finance"]


# ── EXP-12 ──────────────────────────────────────────────────────────────────────────────────────

def test_EXP_12_the_approved_claim_posts_one_balanced_journal_dated_the_business_day(nb):
    rid = claim_551()
    submit(rid)
    fully_approved(rid)
    (j,) = nb.journals.values()
    ref = f"exp:{rid}:r1"
    assert (j["journal_id"], j["source_module"], j["source_document"], j["event"], j["company_code"], j["journal_date"], j["currency"]) == (
        str(seam.derived_id(ref, "approve")), "expenses", ref, "approve", "UK01", "2026-11-07", "GBP")
    assert j["lines"] == [{"purpose": "accommodation", "cost_centre": "SALES-N", "debit": "264.00"},
                          {"purpose": "entertainment", "cost_centre": "SALES-N", "debit": "96.00"},
                          {"purpose": "travel", "cost_centre": "SALES-N", "debit": "191.40"},
                          {"purpose": "employee_payable", "bp_id": "73301", "credit": "551.40"}]
    assert sum(Decimal(x.get("debit", 0)) for x in j["lines"]) == sum(Decimal(x.get("credit", 0)) for x in j["lines"])
    assert report(rid)["status"] == "posted"


def test_EXP_12_a_timeout_then_a_retry_posts_once_and_the_claim_waits_approved(nb):
    rid = claim_551()
    submit(rid)
    nb.gl_status = 500
    must(approve(rid, MANAGER))
    got = must(approve(rid, FINANCE))
    assert got["status"] == "approved" and nb.journals == {}                       # the ledger did not answer: approved, nothing lost
    assert [(o["target"], o["status"], o["attempts"]) for o in got["outbox"]] == [("gl", "pending", 1)]
    nb.gl_status = None
    must(api("POST", "/outbox/drain", SYSTEM))
    must(api("POST", "/outbox/drain", SYSTEM))                                      # a second worker, the same tick
    assert report(rid)["status"] == "posted" and len(nb.journals) == 1 and nb.journal_posts == 2


def test_EXP_12_a_closed_period_parks_the_claim_as_post_failed_with_gls_reason_until_finance_posts_it(nb):
    rid = claim_551()
    submit(rid)
    nb.closed_through = "2026-11-30"
    must(approve(rid, MANAGER))
    got = must(approve(rid, FINANCE))
    assert got["status"] == "post_failed" and "period_open" in got["fail_reason"] and "closed" in got["fail_reason"]
    nb.closed_through = None                                                        # finance opened the period
    assert must(api("POST", f"/reports/{rid}/post", FINANCE))["status"] == "posted"
    (j,) = nb.journals.values()
    assert j["journal_date"] == "2026-11-07"                                        # the approval's business date, not the retry's
    assert must(api("POST", f"/reports/{rid}/post", FINANCE))["status"] == "posted"  # a repeat changes nothing
    assert len(nb.journals) == 1


def test_EXP_12_the_business_date_is_the_date_in_london_never_the_utc_date(nb):
    with on_day("2026-07-14T22:45:00+00:00"):                                      # 23:45 BST on the 14th
        rid = new_report()["report_id"]
        add(rid, expense_date="2026-07-14", amount="20.00")
        submit(rid)
    with on_day("2026-07-14T23:30:00+00:00"):                                      # 00:30 BST on the 15th: UTC still says the 14th
        must(approve(rid, MANAGER))
    assert report(rid)["approved_on"] == "2026-07-15"
    assert [j["journal_date"] for j in nb.journals.values()] == ["2026-07-15"]
    with on_day("2026-11-09T23:30:00+00:00"):                                      # winter: GMT = UTC, the same date
        rid = new_report()["report_id"]
        add(rid, expense_date="2026-11-09", amount="20.00")
        submit(rid)
        must(approve(rid, MANAGER))
    assert sorted(j["journal_date"] for j in nb.journals.values()) == ["2026-07-15", "2026-11-09"]


# ── EXP-13 ──────────────────────────────────────────────────────────────────────────────────────

def test_EXP_13_the_payout_is_requested_once_and_the_claim_is_paid_only_when_payments_says_so(nb):
    rid = claim_551()
    submit(rid)
    fully_approved(rid)
    ref = f"exp:{rid}:r1:pay"
    assert nb.payouts == {ref: {"payout_ref": ref, "payee_bp_id": "73301", "person_id": "3301", "company_code": "UK01", "currency": "GBP",
                                "amount": "551.40", "due_on": "2026-11-07", "reference": rid, "source_module": "expenses",
                                "source_document": rid, "clearing_purpose": "employee_payable"}}
    got = must(api("POST", "/outbox/drain", SYSTEM))                                # the repeat: nothing pending, nothing sent
    assert got["sent"] == [] and nb.payout_posts == 1
    assert report(rid)["status"] == "posted" and report(rid)["payout_ref"] == ref
    body = {"payout_ref": ref, "payment_id": "pay:77", "paid_on": "2026-11-13", "amount": "551.40"}
    paid = must(api("POST", f"/reports/{rid}/paid", PAYMENTS_CLIENT, body))
    assert (paid["status"], paid["paid_on"]) == ("paid", "2026-11-13")
    assert must(api("POST", f"/reports/{rid}/paid", PAYMENTS_CLIENT, body))["status"] == "paid"      # idempotent on payout_ref


def test_EXP_13_a_wrong_payout_or_amount_is_refused_and_a_claim_not_posted_is_not_paid(nb):
    rid = claim_551()
    submit(rid)
    body = {"payout_ref": f"exp:{rid}:r1:pay", "payment_id": "pay:77", "paid_on": "2026-11-13", "amount": "551.40"}
    refused(api("POST", f"/reports/{rid}/paid", PAYMENTS_CLIENT, body), 409, "is not the payout")       # still submitted
    fully_approved(rid)
    refused(api("POST", f"/reports/{rid}/paid", PAYMENTS_CLIENT, {**body, "payout_ref": "exp:other:r1:pay"}), 409, "is not the payout")
    r = refused(api("POST", f"/reports/{rid}/paid", PAYMENTS_CLIENT, {**body, "amount": "551.00"}), 409, "551.00")
    assert r["gate"] == "amount_matches"
    assert report(rid)["status"] == "posted"


def test_EXP_13_a_payout_payments_refuses_leaves_the_claim_posted_with_the_reason_and_is_retried(nb):
    nb.payout_refusal = {"gate": "payee_is_person", "reason": "no bank details for the payee", "fix": "hr records the account"}
    rid = claim_551()
    submit(rid)
    got = fully_approved(rid)
    assert got["status"] == "posted"
    (box,) = [o for o in got["outbox"] if o["target"] == "payments"]
    assert box["status"] == "pending" and "payee_is_person" in box["last_error"] and nb.payouts == {}
    nb.payout_refusal = None
    must(api("POST", "/outbox/drain", SYSTEM))
    assert len(nb.payouts) == 1 and nb.payout_posts == 2


def test_EXP_13_without_payments_the_claim_stays_posted_and_the_row_waits(nb):
    seam.unregister_local("payments")
    rid = claim_551()
    submit(rid)
    got = fully_approved(rid)
    assert got["status"] == "posted"
    (box,) = [o for o in got["outbox"] if o["target"] == "payments"]
    assert box["status"] == "pending" and "finance pays by hand" in box["last_error"]


def test_EXP_13_only_paymentss_client_marks_a_claim_paid(nb):
    rid = claim_551()
    submit(rid)
    fully_approved(rid)
    body = {"payout_ref": f"exp:{rid}:r1:pay", "payment_id": "pay:77", "paid_on": "2026-11-13", "amount": "551.40"}
    for who in (EMPLOYEE, MANAGER, FINANCE, SYSTEM, HR_CLIENT, NOBODY, person("w@x.example", ["*:*:*"]), person("w2@x.example", ["exp:*:*"])):
        refused(api("POST", f"/reports/{rid}/paid", who, body), 403, "exp:report:mark_paid")
    assert report(rid)["status"] == "posted"


# ── EXP-19: hr's leaver intake ───────────────────────────────────────────────────────────────────

def test_EXP_19_a_leaver_may_claim_for_ninety_days_and_hr_is_answered_the_same_twice(nb):
    body = {"last_day": "2026-11-06", "source_ref": "hr:left:3301:1"}
    first = must(api("POST", "/employees/3301/leaver", HR_CLIENT, body))
    assert first == {"person_id": "3301", "last_day": "2026-11-06", "claims_until": "2027-02-04", "open_reports": 0, "cards_stopped": 0, "rerouted": []}
    assert must(api("POST", "/employees/3301/leaver", HR_CLIENT, body)) == first
    nb.left_on[3301] = "2026-11-06"
    rid = new_report()["report_id"]                                                # employed no more, inside the window: may claim
    add(rid, expense_date="2026-11-05")
    assert submit(rid)["status"] == "submitted"
    with on_day("2027-02-05T09:00:00+00:00"):                                      # the day after the window
        refused(api("POST", "/reports", EMPLOYEE, {"currency": "GBP"}), 409, "window to claim closed on 2027-02-04")


def test_EXP_19_a_leaver_notice_deletes_nothing_and_leaves_a_claim_whose_manager_is_unchanged_alone(nb):
    rid = claim_551()
    submit(rid)
    draft = new_report()["report_id"]
    got = must(api("POST", "/employees/3301/leaver", HR_CLIENT, {"last_day": "2026-11-06", "source_ref": "hr:left:3301:1"}))
    assert got["open_reports"] == 2 and got["rerouted"] == []
    assert [r["report_id"] for r in must(api("GET", "/reports", EMPLOYEE))["reports"]] == [rid, draft]        # nothing deleted
    assert report(rid)["chain"][0]["rerouted_from"] is None


def test_EXP_19_a_submitted_claim_waiting_on_a_manager_is_rerouted_to_the_derived_successor(nb):
    rid = claim_551()
    submit(rid)
    nb.managers[3301] = [nb.as_manager(3000, "new.manager@uk.example"), nb.as_manager(1904, MGR2)]
    got = must(api("POST", "/employees/3301/leaver", HR_CLIENT, {"last_day": "2026-11-06", "source_ref": "hr:left:3301:1"}))
    assert got["rerouted"] == [rid]
    chain = report(rid)["chain"]
    assert (chain[0]["approver_person_id"], chain[0]["rerouted_from"]) == ("3000", MGR)
    successor = person("new.manager@uk.example", MANAGER_PERMS())
    refused(approve(rid, MANAGER), 404)                                              # the old manager is off the route: they no longer even see it
    assert must(approve(rid, successor))["waiting_level"] == 2


def test_EXP_19_bad_input_is_a_422_and_only_hrs_client_may(nb):
    for body in ({"last_day": "yesterday", "source_ref": "hr:left:3301:1"}, {"last_day": "1850-01-01", "source_ref": "hr:left:3301:1"},
                 {"last_day": "2026-11-06", "source_ref": "hr:left:9999:1"}, {"last_day": "2026-11-06", "source_ref": "x" * 300}):
        got, _ = api("POST", "/employees/3301/leaver", HR_CLIENT, body)
        assert got == 422, body
    body = {"last_day": "2026-11-06", "source_ref": "hr:left:3301:1"}
    for who in (EMPLOYEE, FINANCE, OWNER_, SYSTEM, PAYMENTS_CLIENT, NOBODY, person("w@x.example", ["*:*:*"])):
        refused(api("POST", "/employees/3301/leaver", who, body), 403, "exp:leaver:intake")


OWNER_ = person("owner@uk.example", ["exp:report:read_all", "exp:settings:controls"])