dodil/erp-brick-expenses
DODIL ERP brick: expenses
tests/test_scenarios.py
"""SC-EXP-01 and SC-EXP-02 of the spec (module.yaml tests.scenarios), end to end through the real doors with every neighbour registered: a UK sales trip from the
receipt photo to the bank, and a manager's own claim with a second login. (SC-EXP-03 is tests/test_rates.py, SC-EXP-04 the German trip there, SC-EXP-05
tests/test_ai.py.)"""
import base64
from decimal import Decimal
import pytest
from conftest import (
FINANCE,
FINANCE2,
JPEG,
MANAGER,
MGR,
PAYMENTS_CLIENT,
add,
api,
approve,
must,
new_report,
person,
refused,
seed_card,
submit,
)
from erp_platform import seam
from test_ai import EMPLOYEE, FakeReader
from test_fx_vat import HOTEL_CONTENTS
from brick_expenses.infrastructure import readers
@pytest.fixture
def world(nb):
seam.register_local("tax", nb.tax)
reader = FakeReader()
readers.ports.reader = reader
yield nb, reader
readers.ports.reader = None
def test_SC_EXP_01_a_uk_sales_trip_from_receipt_photo_to_bank(world):
nb, reader = world
# given: employee 3301 with a train, a hotel on card 4417, a client dinner and 300 miles; 9,850 approved miles; the cinema on the card is personal
must(api("POST", "/ai/consent", EMPLOYEE, {"decision": "given"}), 201)
prior = new_report()["report_id"]
must(api("POST", f"/reports/{prior}/mileage", EMPLOYEE, {"expense_date": "2026-10-01", "miles": "9850", "route": "earlier trips"}), 201)
submit(prior)
must(approve(prior, MANAGER))
must(approve(prior, FINANCE))
nb.journals.clear()
nb.payouts.clear()
seed_card("4417", 3301, [("TX-HOTEL", "2026-11-05", "Manchester Grand Hotel", Decimal("264.00")), ("TX-CINEMA", "2026-11-06", "Odeon", Decimal("18.00"))])
# when: the claim is filled with the reader ...
rid = new_report()["report_id"]
add(rid, category_code="TRAVEL_RAIL", merchant="Northern Rail", amount="86.40")
reader.fields = {"date": "2026-11-05", "merchant": "Manchester Grand Hotel", "total": "264.00", "currency": "GBP", "vat": "44.00", "vat_number": "GB 987 6543 21",
"category_code": "ACCOMMODATION", "category_reason": "a hotel invoice"}
suggested = must(api("POST", f"/reports/{rid}/receipts", EMPLOYEE, {"content_type": "image/jpeg", "content_base64": base64.b64encode(JPEG).decode()}), 201)
assert suggested["suggested"] is True and suggested["line_no"] == 2
must(api("POST", f"/reports/{rid}/lines/2", EMPLOYEE, {"receipt": HOTEL_CONTENTS, "confirm_fields": [
"category_code", "expense_date", "merchant", "amount", "vat_amount"]}))
must(api("POST", "/card-txns/card:4417:TX-HOTEL/match", EMPLOYEE, {"report_id": rid, "line_no": 2})) # the card line, matched: 264.00 billed
add(rid, category_code="MEALS_CLIENT", merchant="The Ivy", amount="96.00", vat_amount="16.00", attendees=["A. Client", "B. Client", "Me"])
miles = must(api("POST", f"/reports/{rid}/mileage", EMPLOYEE, {"expense_date": "2026-11-04", "miles": "300", "route": "Leeds to Manchester and back"}), 201)
assert miles["lines"][-1]["amount"] == "105.00"
must(api("POST", "/card-txns/card:4417:TX-CINEMA/personal", EMPLOYEE))
# ... submitted, approved by 2280 and finance, posted and paid
got = submit(rid)
assert got["total_amount"] == "551.40" and got["reimbursable_amount"] == "287.40" and [c["kind"] for c in got["chain"]] == ["manager", "finance"]
must(approve(rid, MANAGER))
posted = must(approve(rid, FINANCE))
assert posted["status"] == "posted" and posted["netted_amount"] == "18.00" and posted["payout_amount"] == "269.40"
# then: a journal of 551.40 each side; a payout of 269.40 after 18.00 personal spend; paid on the statement date
j = next(x for x in nb.journals.values() if x["event"] == "approve")
assert j["lines"] == [{"purpose": "accommodation", "cost_centre": "SALES-N", "debit": "220.00"}, {"purpose": "entertainment", "cost_centre": "SALES-N", "debit": "96.00"},
{"purpose": "mileage", "cost_centre": "SALES-N", "debit": "105.00"}, {"purpose": "travel", "cost_centre": "SALES-N", "debit": "86.40"},
{"purpose": "tax", "tax_code": "PS", "cost_centre": "SALES-N", "debit": "44.00"},
{"purpose": "employee_payable", "bp_id": "73301", "credit": "287.40"}, {"purpose": "card_liability", "memo": "card 4417", "credit": "264.00"}]
assert sum(Decimal(x.get("debit", 0)) for x in j["lines"]) == sum(Decimal(x.get("credit", 0)) for x in j["lines"]) == Decimal("551.40")
(p,) = nb.payouts.values()
assert p["amount"] == "269.40" and p["payout_ref"] == f"exp:{rid}:r1:pay"
paid = must(api("POST", f"/reports/{rid}/paid", PAYMENTS_CLIENT, {"payout_ref": p["payout_ref"], "payment_id": "BANK-77", "paid_on": "2026-11-12", "amount": "269.40"}))
assert (paid["status"], paid["paid_on"]) == ("paid", "2026-11-12")
cinema = must(api("GET", "/card-txns", EMPLOYEE, {"status": "recovered"}))["card_txns"]
assert [t["card_txn_id"] for t in cinema] == ["card:4417:TX-CINEMA"] # the personal spend is recovered, once
def test_SC_EXP_02_a_managers_own_claim_and_a_second_login(nb):
nb.employee(2280, MGR, manager=nb.as_manager(1904, "mgr1904@uk.example"))
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)] # 1904, then finance
second_login = person("2280.second@uk.example", ["exp:report:read_own", "exp:report:read_team", "exp:report:approve"], sub=MGR)
refused(approve(rid, second_login), 403, "not the approver of level 1") # a second email with the same sub: still the claimant, never on the chain
# finance user F, who also holds the manager permission, decides level 1 as 1904 - and so cannot decide level 2
f_as_1904 = person("mgr1904@uk.example", ["exp:report:read_team", "exp:report:approve", "exp:report:finance_approve"], sub="mgr1904@uk.example")
must(approve(rid, f_as_1904))
refused(approve(rid, f_as_1904), 409, "decided an earlier level")
assert must(approve(rid, FINANCE2))["status"] == "posted" # another finance user does