dodil/erp-brick-expenses
DODIL ERP brick: expenses
tests/test_rates.py
"""Slice B item 5: mileage and daily allowance by the country's rules (EXP-03, EXP-04), staleness when an earlier claim is approved (EXP-20), and the rows
finance and the owner edit - rates, categories, settings (EXP-22, EXP-23). Each test is written from the story's Given/When/Then (module.yaml)."""
from decimal import Decimal
import pytest
from conftest import (
EMPLOYEE,
FINANCE,
MANAGER,
OWNER,
add,
api,
approve,
must,
new_report,
on_day,
person,
refused,
report,
submit,
)
from brick_expenses.application import use_cases
from brick_expenses.domain import rates as rt
CAR = rt.Rate("mileage", "GB", "car", 1, __import__("datetime").date(2011, 4, 6), Decimal("0.45"), "GBP", Decimal("10000"), "mile")
CAR2 = rt.Rate("mileage", "GB", "car", 2, __import__("datetime").date(2011, 4, 6), Decimal("0.25"), "GBP", None, "mile")
def mileage(rid, miles, when="2026-11-04", who=None, **kw):
return api("POST", f"/reports/{rid}/mileage", who or EMPLOYEE, {"expense_date": when, "miles": str(miles), "route": "Leeds to Manchester and back", **kw})
def approved_miles(miles, when="2026-10-01"):
"""An APPROVED claim of `miles` for 3301 (manager, then finance: it is above 500.00)."""
rid = new_report()["report_id"]
must(mileage(rid, miles, when), 201)
submit(rid)
must(approve(rid, MANAGER))
must(approve(rid, FINANCE))
return rid
# ── EXP-03 ───────────────────────────────────────────────────────────────────────────────────────
def test_EXP_03_300_miles_after_9850_approved_split_at_the_band_and_the_split_is_shown():
approved_miles(9850)
rid = new_report()["report_id"]
got = must(mileage(rid, 300), 201)
line = got["lines"][0]
assert (line["kind"], line["category_code"], line["amount"], line["miles_or_km"]) == ("mileage", "MILEAGE", "105.00", "300.0000")
assert [(b["band"], b["miles"], b["rate"], b["amount"]) for b in line["calc"]["bands"]] == [
(1, "150.0000", "0.4500", "67.5000"), (2, "150.0000", "0.2500", "37.5000")] # 150 x 0.45 + 150 x 0.25
assert (line["calc"]["cumulative_before"], line["calc"]["unit"], line["calc"]["tax_year"]) == ("9850.0000", "mile", ["2026-04-06", "2027-04-05"])
assert line["calc"]["source"].startswith("HMRC") and line["receipt_amount"] == "105.00" and line["vat_amount"] == "0.00"
def test_EXP_03_mileage_needs_no_receipt_and_is_submittable_and_posts_to_the_mileage_purpose(nb):
rid = new_report()["report_id"]
must(mileage(rid, 100), 201)
assert submit(rid)["status"] == "submitted"
must(approve(rid, MANAGER))
(j,) = nb.journals.values()
assert j["lines"][0] == {"purpose": "mileage", "cost_centre": "SALES-N", "debit": "45.00"} and j["lines"][-1]["credit"] == "45.00"
def test_EXP_03_one_long_trip_fills_the_first_band_and_the_rest_goes_to_the_second():
rid = new_report()["report_id"]
line = must(mileage(rid, 12000), 201)["lines"][0]
assert line["amount"] == "5000.00" and len(line["calc"]["bands"]) == 2 # 10,000 x 0.45 + 2,000 x 0.25
def test_EXP_03_the_band_uses_only_approved_miles_of_the_same_tax_year():
approved_miles(9000, "2026-04-05") # the PREVIOUS tax year (ends 2026-04-05)
rid = new_report()["report_id"]
first = must(mileage(rid, 300, "2026-04-06"), 201)["lines"][0]
assert first["amount"] == "135.00" and first["calc"]["cumulative_before"] == "0.0000" and first["calc"]["tax_year"] == ["2026-04-06", "2027-04-05"]
draft_only = new_report()["report_id"]
must(mileage(draft_only, 9000, "2026-10-01"), 201) # a draft counts for nothing
other = new_report()["report_id"]
assert must(mileage(other, 300, "2026-11-04"), 201)["lines"][0]["calc"]["cumulative_before"] == "0.0000"
def test_EXP_03_a_rejected_claims_miles_never_count(nb):
rid = new_report()["report_id"]
must(mileage(rid, 9000, "2026-10-01"), 201)
submit(rid)
must(api("POST", f"/reports/{rid}/reject", MANAGER, {"reason": "not business"}))
nxt = new_report()["report_id"]
assert must(mileage(nxt, 300), 201)["lines"][0]["calc"]["cumulative_before"] == "0.0000"
def test_EXP_03_miles_are_bounded_zero_negative_too_precise_or_enormous_are_a_422():
rid = new_report()["report_id"]
for miles in ("0", "-5", "1.00001", "100001", "abc", "1e3"):
refused(mileage(rid, miles), 422, "miles")
refused(api("POST", f"/reports/{rid}/mileage", EMPLOYEE, {"expense_date": "2026-11-04", "miles": 300.5, "route": "x"}), 422, "exact number")
refused(mileage(rid, "0.0001"), 422, "nothing")
assert report(rid)["lines"] == []
def test_EXP_03_a_vehicle_or_country_with_no_rate_row_is_refused_naming_it_and_a_rate_in_another_currency_is_too():
rid = new_report()["report_id"]
refused(mileage(rid, 100, variant="helicopter"), 409, "No mileage rate for GB helicopter")
refused(mileage(rid, 100, country="DE"), 409, "in EUR; this claim is in GBP")
refused(mileage(rid, 100, when="2010-01-01"), 409, "is in force on 2010-01-01")
assert must(mileage(rid, 100, variant="bicycle"), 201)["lines"][0]["amount"] == "20.00"
assert must(mileage(rid, 100, variant="motorcycle"), 201)["lines"][1]["amount"] == "24.00"
def test_a_mileage_line_is_not_edited_by_amount_only_removed_and_added_again():
rid = new_report()["report_id"]
must(mileage(rid, 100), 201)
refused(api("POST", f"/reports/{rid}/lines/1", EMPLOYEE, {"amount": "1.00"}), 409, "computed from the country's rates")
assert must(api("POST", f"/reports/{rid}/lines/1", EMPLOYEE, {"justification": "client visits"}))["lines"][0]["justification"] == "client visits"
assert must(api("DELETE", f"/reports/{rid}/lines/1", EMPLOYEE))["lines"] == []
def test_a_mileage_line_is_only_the_claimants_to_add_on_a_draft():
rid = new_report()["report_id"]
refused(mileage(rid, 100, who=MANAGER), 404)
submit_it = must(mileage(rid, 100), 201)["report_id"]
submit(submit_it)
refused(mileage(rid, 100), 409, "frozen")
# ── EXP-20 / SC-EXP-03 ───────────────────────────────────────────────────────────────────────────
def two_submitted():
"""9,850 approved miles; claim A of 200 miles and claim B of 300 miles both submitted."""
approved_miles(9850)
a, b = new_report()["report_id"], new_report()["report_id"]
assert must(mileage(a, 200), 201)["lines"][0]["amount"] == "80.00" # 150 x 0.45 + 50 x 0.25
assert must(mileage(b, 300), 201)["lines"][0]["amount"] == "105.00"
submit(a)
submit(b)
return a, b
def test_SC_EXP_03_the_200_approved_first_marks_the_300_stale_and_it_is_recomputed_to_75():
a, b = two_submitted()
must(approve(a, MANAGER))
got = report(b)
assert got["lines"][0]["stale"] is True and "earlier mileage approved" in got["lines"][0]["stale_reason"]
body = refused(approve(b, MANAGER), 409, "not_stale")
assert body["gate"] == "not_stale"
changed = must(api("POST", f"/reports/{b}/recompute", MANAGER))
assert [(c["field"], c["was"], c["now"]) for c in changed["changes"]] == [("amount", "105.00", "75.00")]
assert changed["lines"][0]["amount"] == "75.00" and changed["lines"][0]["stale"] is False and changed["total_amount"] == "75.00"
assert changed["lines"][0]["calc"]["bands"][0]["rate"] == "0.2500"
assert must(approve(b, MANAGER))["status"] == "posted"
assert Decimal(report(a)["total_amount"]) + Decimal(report(b)["total_amount"]) == Decimal("155.00")
def test_SC_EXP_03_the_300_approved_first_ends_at_the_same_total():
a, b = two_submitted()
must(approve(b, MANAGER)) # 105.00 at 0.45 / 0.25
assert report(a)["lines"][0]["stale"] is True
changed = must(api("POST", f"/reports/{a}/recompute", MANAGER))
assert changed["changes"][0]["now"] == "50.00" # 200 x 0.25: the 300 took the band
must(approve(a, MANAGER))
assert Decimal(report(a)["total_amount"]) + Decimal(report(b)["total_amount"]) == Decimal("155.00") # whichever is approved first
def test_EXP_20_an_approved_or_posted_line_is_never_recomputed_and_other_peoples_lines_are_never_marked(nb):
a, b = two_submitted()
must(approve(a, MANAGER))
assert report(a)["lines"][0]["amount"] == "80.00" and report(a)["lines"][0]["stale"] is False # the approved claim itself is untouched
refused(api("POST", f"/reports/{a}/recompute", MANAGER), 409, "posted")
nb.employee(7001, "other.person@uk.example", manager=nb.as_manager(2280, "mgr2280@uk.example"))
other = person("other.person@uk.example", ["exp:report:read_own", "exp:report:write_own", "exp:report:submit"])
theirs = must(api("POST", "/reports", other, {"currency": "GBP"}), 201)["report_id"]
must(api("POST", f"/reports/{theirs}/mileage", other, {"expense_date": "2026-11-04", "miles": "300", "route": "x"}), 201)
c = new_report()["report_id"]
must(mileage(c, 100), 201)
submit(c)
assert report(theirs, other)["lines"][0]["stale"] is False
def test_EXP_20_a_stale_claim_returned_and_resubmitted_keeps_the_mark_until_recomputed():
a, b = two_submitted()
must(approve(a, MANAGER))
assert report(b)["lines"][0]["stale"] is True
must(api("POST", f"/reports/{b}/return", MANAGER, {"reason": "check the miles"}))
again = report(b)
assert again["revision"] == 2 and again["lines"][0]["stale"] is True
refused(api("POST", f"/reports/{b}/submit", EMPLOYEE), 409, "not_stale")
must(api("POST", f"/reports/{b}/recompute", EMPLOYEE))
assert submit(b)["status"] == "submitted"
# ── EXP-04 ───────────────────────────────────────────────────────────────────────────────────────
@pytest.fixture
def later():
"""The German trip of the story ran 10-12 November: the claim is written on the 13th."""
with on_day("2026-11-13T09:00:00+00:00"):
yield
def de_employee(nb):
nb.employee(5120, "emp5120@de.example", company="DE01", manager=nb.as_manager(2280, "mgr2280@uk.example"))
who = person("emp5120@de.example", ["exp:report:read_own", "exp:report:write_own", "exp:report:submit"])
return who, must(api("POST", "/reports", who, {"currency": "EUR"}), 201)["report_id"]
def trip(rid, who, frm="2026-11-10T07:00", to="2026-11-12T19:00", **kw):
return api("POST", f"/reports/{rid}/per-diem", who, {"trip_from": frm, "trip_to": to, "country": "DE", "destination": "domestic", **kw})
def test_EXP_04_berlin_to_munich_with_two_breakfasts_is_44_80_with_the_days_shown(nb, later):
who, rid = de_employee(nb)
got = must(trip(rid, who, meals_provided=[{"date": "2026-11-11", "meal": "breakfast"}, {"date": "2026-11-12", "meal": "breakfast"}],
description="Berlin to Munich"), 201)
line = got["lines"][0]
assert (line["kind"], line["category_code"], line["amount"], line["expense_date"]) == ("per_diem", "PER_DIEM", "44.80", "2026-11-10")
assert [(d["date"], d["kind"], d["base"], d["meals_deducted"], d["allowance"]) for d in line["calc"]["days"]] == [
("2026-11-10", "arrival", "14.00", "0.00", "14.00"), ("2026-11-11", "full", "28.00", "5.60", "22.40"),
("2026-11-12", "departure", "14.00", "5.60", "8.40")] # 14 + 28 + 14 = 56 less 2 x 5.60
assert line["trip_from"] == "2026-11-10T07:00" and line["meals_provided"][0]["meal"] == "breakfast" and line["calc"]["source"].startswith("s. 9 (4a) EStG")
def test_SC_EXP_04_the_trip_needs_no_receipt_is_approved_and_posts_to_the_per_diem_purpose_without_vat(nb, later):
who, rid = de_employee(nb)
must(trip(rid, who, meals_provided=[{"date": "2026-11-11", "meal": "breakfast"}, {"date": "2026-11-12", "meal": "breakfast"}]), 201)
assert must(api("POST", f"/reports/{rid}/submit", who))["status"] == "submitted"
assert must(approve(rid, MANAGER))["status"] == "posted"
(j,) = nb.journals.values()
assert (j["company_code"], j["currency"]) == ("DE01", "EUR")
assert j["lines"] == [{"purpose": "per_diem", "cost_centre": "SALES-N", "debit": "44.80"}, {"purpose": "employee_payable", "bp_id": "75120", "credit": "44.80"}]
def test_EXP_04_a_trip_inside_one_day_earns_the_partial_amount_only_above_eight_hours(nb, later):
who, rid = de_employee(nb)
assert must(trip(rid, who, "2026-11-10T08:00", "2026-11-10T16:01"), 201)["lines"][0]["amount"] == "14.00" # 8h01
body = refused(trip(rid, who, "2026-11-09T08:00", "2026-11-09T16:00"), 409, "more than 8 hours") # exactly 8h: nothing
assert body["gate"] == "no_allowance"
def test_EXP_04_provided_meals_never_take_a_day_below_zero(nb, later):
who, rid = de_employee(nb)
body = refused(trip(rid, who, "2026-11-10T07:00", "2026-11-10T20:00",
meals_provided=[{"date": "2026-11-10", "meal": m} for m in ("breakfast", "lunch", "dinner")]), 409, "no allowance")
assert body["gate"] == "no_allowance" # 14.00 less 28.00 x 100% is zero, never negative
two = must(trip(rid, who, "2026-11-10T07:00", "2026-11-11T19:00", meals_provided=[{"date": "2026-11-10", "meal": m} for m in ("breakfast", "lunch")]), 201)
assert [d["allowance"] for d in two["lines"][0]["calc"]["days"]] == ["0.00", "14.00"] and two["lines"][0]["amount"] == "14.00" # a day floors at zero alone
def test_EXP_04_overlapping_trips_of_one_person_are_refused_and_back_to_back_ones_are_not(nb, later):
who, rid = de_employee(nb)
must(trip(rid, who), 201)
refused(trip(rid, who, "2026-11-12T18:00", "2026-11-13T10:00"), 409, "overlaps another of yours")
other = must(api("POST", "/reports", who, {"currency": "EUR"}), 201)["report_id"]
refused(trip(other, who, "2026-11-11T08:00", "2026-11-11T20:00"), 409, "overlaps") # in another claim too
must(trip(other, who, "2026-11-12T19:00", "2026-11-13T08:00"), 201) # touching ends do not overlap
nb.employee(5121, "emp5121@de.example", company="DE01", manager=nb.as_manager(2280, "mgr2280@uk.example"))
them = person("emp5121@de.example", ["exp:report:write_own"])
theirs = must(api("POST", "/reports", them, {"currency": "EUR"}), 201)["report_id"]
must(trip(theirs, them), 201) # another person's trip is theirs alone
def test_EXP_04_a_meal_outside_the_trip_listed_twice_or_a_bad_trip_is_refused(nb, later):
who, rid = de_employee(nb)
refused(trip(rid, who, meals_provided=[{"date": "2026-11-13", "meal": "lunch"}]), 409, "outside the trip")
refused(trip(rid, who, meals_provided=[{"date": "2026-11-11", "meal": "lunch"}, {"date": "2026-11-11", "meal": "lunch"}]), 409, "twice")
refused(trip(rid, who, meals_provided=[{"date": "2026-11-11", "meal": "snack"}]), 422)
refused(trip(rid, who, "2026-11-12T19:00", "2026-11-10T07:00"), 409, "ends before it starts")
refused(trip(rid, who, "not a time", "2026-11-12T19:00"), 422, "date-times")
refused(trip(rid, who, "2026-11-10T07:00", "2028-11-12T19:00"), 409, "at most 366 days")
refused(trip(rid, who, "2026-12-10T07:00", "2026-12-12T19:00"), 409, "future")
refused(trip(rid, who, "1999-01-01T07:00", "1999-01-02T19:00"), 422, "outside 2000")
assert report(rid, who)["lines"] == []
def test_EXP_04_a_destination_without_a_row_or_in_another_currency_is_refused(nb, later):
who, rid = de_employee(nb)
refused(trip(rid, who, destination="FR"), 409, "No daily allowance for DE FR")
uk = new_report()["report_id"]
refused(api("POST", f"/reports/{uk}/per-diem", EMPLOYEE, {"trip_from": "2026-11-10T07:00", "trip_to": "2026-11-12T19:00"}), 409, "No daily allowance for GB")
refused(api("POST", f"/reports/{uk}/per-diem", EMPLOYEE, {"trip_from": "2026-11-10T07:00", "trip_to": "2026-11-12T19:00", "country": "DE"}), 409, "in EUR")
def test_the_pure_arithmetic_of_a_full_day_and_the_rounding_of_a_band_once():
rate = rt.Rate("per_diem", "DE", "domestic", 1, __import__("datetime").date(2025, 1, 1), Decimal("28.00"), "EUR", partial_day_amount=Decimal("14.00"),
breakfast_pct=Decimal(20), lunch_pct=Decimal(40), dinner_pct=Decimal(40))
from datetime import datetime
amount, days = rt.per_diem(datetime(2026, 11, 10, 0, 0), datetime(2026, 11, 11, 23, 59), [], rate)
assert amount == Decimal("28.00") and [d["kind"] for d in days] == ["arrival", "departure"]
cheap = rt.Rate("mileage", "XX", "car", 1, __import__("datetime").date(2020, 1, 1), Decimal("0.3333"), "GBP", None, "mile")
assert rt.mileage(Decimal("3"), Decimal(0), [cheap])[0] == Decimal("1.00") # 0.9999 rounded once, not 3 x 0.33
assert rt.tax_year(__import__("datetime").date(2026, 4, 5), "04-06") == (__import__("datetime").date(2025, 4, 6), __import__("datetime").date(2026, 4, 5))
assert rt.tax_year(__import__("datetime").date(2026, 4, 6), "04-06")[0] == __import__("datetime").date(2026, 4, 6)
assert [(c.band) for c in rt.in_force([CAR2, CAR], "mileage", "GB", "car", __import__("datetime").date(2026, 1, 1))] == [1, 2]
# ── EXP-23: rows finance edits ────────────────────────────────────────────────────────────────────
def rate_body(**kw):
return {"kind": "per_diem", "country": "DE", "variant": "FR", "band": 1, "valid_from": "2027-01-01", "rate_amount": "32.0000", "currency": "EUR",
"partial_day_amount": "16.0000", "breakfast_pct": "20", "lunch_pct": "40", "dinner_pct": "40", "source": "BMF foreign rates 2027", **kw}
def test_EXP_23_finance_adds_a_dated_per_diem_row_for_france_and_a_trip_uses_the_row_in_force(nb, later):
got = must(api("POST", "/rates", FINANCE, rate_body(valid_from="2026-11-13")), 201)
assert got["lines_marked_stale"] == 0 and got["source"] == "BMF foreign rates 2027"
who, rid = de_employee(nb)
got = must(trip(rid, who, "2026-11-13T07:00", "2026-11-13T19:00", destination="FR"), 201)["lines"][0]
assert (got["calc"]["full_day"], got["amount"]) == ("32.00", "16.00") # the row in force on the trip's date: 12 hours, partial
listed = must(api("GET", "/rates", EMPLOYEE, {"kind": "per_diem", "country": "DE"}))["rates"]
assert {r["variant"] for r in listed} == {"domestic", "FR"} and all(r["source"] for r in listed)
def test_EXP_23_a_row_starts_today_or_after_a_change_starts_after_today_and_rates_have_bounds():
refused(api("POST", "/rates", FINANCE, rate_body(valid_from="2026-11-01")), 422, "in the past")
refused(api("POST", "/rates", FINANCE, rate_body(country="DE", variant="domestic", valid_from="2026-11-07")), 409, "starts after today")
must(api("POST", "/rates", FINANCE, rate_body(country="DE", variant="domestic", valid_from="2027-01-01", rate_amount="30.0000", partial_day_amount="15.0000")), 201)
refused(api("POST", "/rates", FINANCE, rate_body(source="")), 422)
refused(api("POST", "/rates", FINANCE, rate_body(partial_day_amount="40.0000")), 422, "not more than the full day")
refused(api("POST", "/rates", FINANCE, rate_body(partial_day_amount=None)), 422, "partial day")
refused(api("POST", "/rates", FINANCE, rate_body(kind="mileage", variant="car", country="FR")), 422, "unit")
refused(api("POST", "/rates", FINANCE, rate_body(rate_amount="-1")), 422)
refused(api("POST", "/rates", FINANCE, rate_body(rate_amount="1e12")), 422)
refused(api("POST", "/rates", FINANCE, rate_body(breakfast_pct="101")), 422)
refused(api("POST", "/rates", FINANCE, rate_body(country="DEU")), 422)
refused(api("POST", "/rates", EMPLOYEE, rate_body()), 403, "exp:rate:write")
refused(api("POST", "/rates", OWNER, rate_body()), 403, "exp:rate:write")
def test_EXP_23_a_new_band_that_starts_today_marks_the_open_mileage_lines_stale_and_a_recompute_applies_it():
rid = new_report()["report_id"]
must(mileage(rid, 100, "2026-11-07"), 201)
other = new_report()["report_id"]
add(other, amount="10.00")
body = {"kind": "mileage", "country": "GB", "variant": "car", "band": 3, "valid_from": "2026-11-07", "rate_amount": "0.1000", "currency": "GBP", "unit": "mile",
"source": "company policy", "year_start": "04-06"}
assert must(api("POST", "/rates", FINANCE, body), 201)["lines_marked_stale"] == 1 # the mileage line, not the receipt
assert report(rid)["lines"][0]["stale"] is True and report(other)["lines"][0]["stale"] is False
refused(api("POST", f"/reports/{rid}/submit", EMPLOYEE), 409, "not_stale")
must(api("POST", f"/reports/{rid}/recompute", EMPLOYEE))
assert submit(rid)["status"] == "submitted"
def test_EXP_23_a_new_category_conference_maps_to_one_purpose_and_one_tax_category_dated_after_today():
body = {"company_code": "UK01", "category_code": "CONFERENCE", "name": "Conferences", "gl_purpose": "training", "tax_category": "standard", "valid_from": "2026-11-07"}
got = must(api("POST", "/categories", FINANCE, body), 201)
assert (got["gl_purpose"], got["tax_category"], got["receipt_required"], got["kind"]) == ("training", "standard", True, "receipt")
rid = new_report()["report_id"]
add(rid, category_code="CONFERENCE", merchant="DevCon", amount="300.00", expense_date="2026-11-07")
refused(api("POST", f"/reports/{rid}/lines", EMPLOYEE, {"category_code": "CONFERENCE", "expense_date": "2026-11-06", "merchant": "x", "amount": "1.00"}), 409, "No expense category CONFERENCE")
refused(api("POST", "/categories", FINANCE, {**body, "name": "Renamed"}), 409, "starts after today")
must(api("POST", "/categories", FINANCE, {**body, "name": "Conferences and events", "valid_from": "2027-01-01"}), 201)
names = {c["category_code"]: c["name"] for c in must(api("GET", "/categories", EMPLOYEE, {"company_code": "UK01", "on": "2027-01-02"}))["categories"]}
assert names["CONFERENCE"] == "Conferences and events" and names["OFFICE"] == "Office supplies"
assert {c["category_code"] for c in must(api("GET", "/categories", EMPLOYEE, {"company_code": "UK01"}))["categories"]} >= {"CONFERENCE", "MILEAGE", "PER_DIEM"}
def test_EXP_23_a_category_names_a_purpose_never_an_account_number_and_a_company_row_beats_the_default():
base = {"company_code": "UK01", "category_code": "TRAINING", "name": "Training", "gl_purpose": "training", "tax_category": "standard", "valid_from": "2026-11-07"}
for purpose in ("4010", "Training", "6000-01", "x" * 65, ""):
refused(api("POST", "/categories", FINANCE, {**base, "gl_purpose": purpose}), 422)
refused(api("POST", "/categories", FINANCE, {**base, "category_code": "lower"}), 422)
refused(api("POST", "/categories", EMPLOYEE, base), 403, "exp:category:write")
must(api("POST", "/categories", FINANCE, {**base, "company_code": "UK01", "category_code": "OFFICE", "name": "Stationery", "gl_purpose": "other_expense",
"valid_from": "2026-11-08"}), 201)
rid = new_report()["report_id"]
add(rid, category_code="OFFICE", amount="10.00") # the line's date: the '*' row in force then
uk = {c["category_code"]: c for c in must(api("GET", "/categories", EMPLOYEE, {"company_code": "UK01", "on": "2026-11-09"}))["categories"]}
de = {c["category_code"]: c for c in must(api("GET", "/categories", EMPLOYEE, {"company_code": "DE01", "on": "2026-11-09"}))["categories"]}
assert uk["OFFICE"]["gl_purpose"] == "other_expense" and de["OFFICE"]["gl_purpose"] == "office_supplies"
# ── EXP-22: the controls are the owner's ──────────────────────────────────────────────────────────
def test_EXP_22_a_finance_user_cannot_lower_the_threshold_the_owner_raises_it_from_a_date_and_a_claim_keeps_what_it_was_submitted_under():
refused(api("POST", "/settings", FINANCE, {"key": "finance_approval_threshold", "value": "5000.00", "valid_from": "2026-12-01"}), 403, "exp:settings:controls")
refused(api("POST", "/settings", MANAGER, {"key": "finance_approval_threshold", "value": "5000.00", "valid_from": "2026-12-01"}), 403, "exp:settings:controls")
got = must(api("POST", "/settings", OWNER, {"key": "finance_approval_threshold", "value": "750", "valid_from": "2026-12-01"}), 201)
assert (got["value"], got["valid_from"]) == ("750.00", "2026-12-01")
old = new_report()["report_id"]
add(old, amount="620.00")
with on_day("2026-11-30T09:00:00+00:00"):
assert [c["kind"] for c in submit(old)["chain"]] == ["manager", "finance"] # submitted on 11-30: keeps 500.00
new = new_report()["report_id"]
add(new, amount="620.00")
with on_day("2026-12-02T09:00:00+00:00"):
assert [c["kind"] for c in submit(new)["chain"]] == ["manager"] # from 12-01: 750.00
assert [c["kind"] for c in report(old)["chain"]] == ["manager", "finance"]
rows = {s["key"]: s for s in must(api("GET", "/settings", EMPLOYEE))["settings"]}
assert rows["finance_approval_threshold"]["value"] == "500.00" and rows["finance_approval_threshold"]["upcoming"] == [{"valid_from": "2026-12-01", "value": "750.00"}]
assert rows["finance_approval_threshold"]["owner_only"] is True and rows["card_receipt_days"]["owner_only"] is False
def test_EXP_22_a_change_starts_after_today_and_every_setting_is_bounded():
must(api("POST", "/settings", OWNER, {"key": "receipt_required_above", "value": "25.00", "valid_from": "2026-11-07"}), 201) # the first row may start today
refused(api("POST", "/settings", OWNER, {"key": "receipt_required_above", "value": "30.00", "valid_from": "2026-11-07"}), 409, "starts after today")
refused(api("POST", "/settings", OWNER, {"key": "receipt_required_above", "value": "30.00", "valid_from": "2026-11-01"}), 422, "in the past")
for key, value, words in (("finance_approval_threshold", "-1", "zero or more"), ("finance_approval_threshold", "1.234", "two decimal"), ("finance_approval_threshold", "abc", "number"),
("finance_approval_threshold", "1e20", "below"), ("delegation_allowed", "maybe", "true or false"), ("reimburse_with", "cash", "nett or separate"),
("business_timezone", "Mars/Olympus", "time zone"), ("ai_read_receipt_mode", "auto", "off or suggest"), ("home_country", "GBR", "two letters")):
refused(api("POST", "/settings", OWNER if key != "home_country" else FINANCE, {"key": key, "value": value, "valid_from": "2026-12-05"}), 422, words)
refused(api("POST", "/settings", OWNER, {"key": "nonsense", "value": "1", "valid_from": "2026-12-05"}), 422, "not a setting")
refused(api("POST", "/settings", EMPLOYEE, {"key": "card_receipt_days", "value": "7", "valid_from": "2026-12-05"}), 403, "exp:policy:write")
refused(api("POST", "/settings", OWNER, {"key": "card_receipt_days", "value": "7", "valid_from": "2026-12-05"}), 403, "exp:policy:write") # finance's, not the owner's
assert must(api("POST", "/settings", FINANCE, {"key": "card_receipt_days", "value": "7", "valid_from": "2026-12-05"}), 201)["value"] == "7"
def test_EXP_22_the_zone_a_day_is_counted_in_and_the_ai_modes_are_the_owners_alone_and_take_effect():
refused(api("POST", "/settings", FINANCE, {"key": "ai_read_receipt_mode", "value": "off", "valid_from": "2026-11-07"}), 403, "exp:settings:controls")
must(api("POST", "/settings", OWNER, {"key": "ai_read_receipt_mode", "value": "off", "valid_from": "2026-11-07"}), 201)
with use_cases.repository() as repo:
assert repo.settings.on("ai_read_receipt_mode", use_cases._today(repo)) == "off"