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"])