dodil/erp-brick-expenses
DODIL ERP brick: expenses
tests/test_cards.py
"""Slice B item 2: company cards - the feed (EXP-15), matching and personal spend (EXP-06), the journal's card liability (EXP-12), recovery by
netting or repayment (EXP-13, EXP-14) and the leaver's cards (EXP-19). Each test is written from the story's Given/When/Then (module.yaml)."""
from decimal import Decimal
from conftest import (
EMPLOYEE,
FINANCE,
FINANCE2,
HR_CLIENT,
MANAGER,
PAYMENTS_CLIENT,
SYSTEM,
add,
api,
approve,
must,
new_report,
refused,
report,
seed_card,
submit,
)
from erp_platform import seam
from brick_expenses.application import use_cases
HEADER = "card_id,ref,posted_on,merchant,amount,currency,original_amount,original_currency"
def feed(*rows, header=HEADER):
return "\n".join([header, *rows])
def card(card_id="4417", holder=3301, who=FINANCE):
return must(api("POST", "/cards", who, {"card_id": card_id, "holder_person_id": str(holder), "provider": "Visa", "currency": "GBP"}), 201)
def import_feed(content, total, who=FINANCE, ref="2026-11"):
return api("POST", "/card-feeds", who, {"statement_ref": ref, "total": total, "content": content})
def txns(who=EMPLOYEE, **kw):
return must(api("GET", "/card-txns", who, kw))["card_txns"]
def claim_with_hotel_on_card(hotel_amount="264.00"):
"""EXP-12's claim: train 86.40 and dinner 96.00 and a 105.00 trip on own money; the Manchester hotel on card 4417 with VAT 44.00."""
rid = new_report()["report_id"]
add(rid, category_code="TRAVEL_RAIL", merchant="Northern Rail", amount="86.40")
add(rid, category_code="ACCOMMODATION", merchant="Manchester Hotel", amount=hotel_amount, vat_amount="44.00", expense_date="2026-11-05", paid_with="card")
add(rid, category_code="MEALS_CLIENT", merchant="The Ivy", amount="96.00", vat_amount="16.00")
add(rid, category_code="TRAVEL_RAIL", merchant="Trains to Leeds", amount="105.00")
return rid
# ── EXP-15: the feed ────────────────────────────────────────────────────────────────────────────
def test_EXP_15_a_feed_is_imported_per_card_to_its_holder_and_importing_it_again_imports_none(nb):
nb.employee(5520, "holder5520@uk.example")
card("4417", 3301)
card("5520", 5520)
rows = [f"4417,TX-{k:03},2026-11-0{1 + k % 7},Shop {k},10.00,GBP" for k in range(23)] + [f"5520,TX-{k:03},2026-11-0{1 + k % 7},Shop {k},{k + 1}.50,GBP" for k in range(23)]
total = Decimal("230.00") + sum(Decimal(f"{k + 1}.50") for k in range(23))
got = must(import_feed(feed(*rows), str(total)), 201)
assert (got["transactions"], got["imported"], got["skipped"], got["total"]) == (46, 46, 0, f"{total:.2f}")
mine = txns(EMPLOYEE)
assert len(mine) == 23 and {t["card_id"] for t in mine} == {"4417"} and mine[0]["card_txn_id"].startswith("card:4417:TX-") # each to its holder
again = must(import_feed(feed(*rows), str(total)), 201)
assert (again["imported"], again["skipped"]) == (0, 46) and len(txns(EMPLOYEE)) == 23
def test_EXP_15_a_file_whose_total_is_not_its_lines_imports_nothing():
card()
body = refused(import_feed(feed("4417,A,2026-11-01,Shop,10.00,GBP", "4417,B,2026-11-02,Shop,5.00,GBP"), "16.00"), 422, "add up to 15.00")
assert body["gate"] == "feed_total" and txns() == []
def test_EXP_15_a_card_nobody_holds_is_refused_with_the_card_named_and_nothing_is_imported():
card()
body = refused(import_feed(feed("4417,A,2026-11-01,Shop,10.00,GBP", "9999,B,2026-11-02,Shop,5.00,GBP"), "15.00"), 409, "9999")
assert body["gate"] == "card_holder" and txns() == []
def test_EXP_15_a_file_not_in_the_documented_shape_is_a_422_never_a_500():
card()
for content, words in ((feed(), "no transactions"), ("", "at least 1"), ("a,b,c\n1,2,3", "header"),
(feed("4417,A,2026-13-45,Shop,10.00,GBP"), "a date"), (feed("4417,A,2026-11-01,Shop,-10.00,GBP"), "more than zero"),
(feed("4417,A,2026-11-01,Shop,1e30,GBP"), "more than zero"), (feed("4417,A,2026-11-01,Shop,1.001,GBP"), "two decimal"),
(feed("4417,A,2026-11-01,Shop,1.00,POUNDS"), "three letters"), (feed("4417,A,2026-11-01,Shop"), "columns"),
(feed("4417,A,2026-11-01,Shop,1.00,GBP", "4417,A,2026-11-02,Shop,1.00,GBP"), "twice"),
(feed("4417,A,2026-11-01,Shop,1.00,GBP,5.00,"), "go together")):
refused(import_feed(content, "1.00"), 422, words)
refused(import_feed(feed("4417,A,2026-11-01,Shop,1.00,GBP"), "0"), 422)
big = feed(*[f"4417,T{k},2026-11-01,Shop,1.00,GBP" for k in range(5001)])
refused(import_feed(big, "5001.00"), 422, "at most 5000")
def test_EXP_15_a_feed_of_five_thousand_transactions_imports_in_one_request():
card()
rows = [f"4417,T{k:05},2026-11-01,Shop,1.00,GBP" for k in range(5000)]
got = must(import_feed(feed(*rows), "5000.00"), 201)
assert got["imported"] == 5000
def test_EXP_15_importing_is_finances_or_the_systems_and_a_card_needs_an_employee_holder(nb):
card()
refused(import_feed(feed("4417,A,2026-11-01,Shop,1.00,GBP"), "1.00", EMPLOYEE), 403, "exp:card_feed:import")
must(import_feed(feed("4417,A,2026-11-01,Shop,1.00,GBP"), "1.00", SYSTEM), 201) # the worker's own client may ingest too
refused(api("POST", "/cards", FINANCE, {"card_id": "4417", "holder_person_id": "3301"}), 409, "already registered")
refused(api("POST", "/cards", FINANCE, {"card_id": "7777", "holder_person_id": "1"}), 409, "employed")
refused(api("POST", "/cards", FINANCE, {"card_id": "a:b", "holder_person_id": "3301"}), 422, "colon")
refused(api("POST", "/cards", EMPLOYEE, {"card_id": "8888", "holder_person_id": "3301"}), 403, "exp:card:write")
nb.left_on[2280] = "2026-01-01"
refused(api("POST", "/cards", FINANCE, {"card_id": "8889", "holder_person_id": "2280"}), 409, "employed")
def test_a_card_feed_in_a_foreign_billed_currency_keeps_the_original_amount():
card()
must(import_feed(feed("4417,PARIS-1,2026-11-03,Paris Bistro,56.12,GBP,64.00,EUR"), "56.12"), 201)
(t,) = txns()
assert (t["billed_amount"], t["billed_currency"], t["original_amount"], t["original_currency"]) == ("56.12", "GBP", "64.00", "EUR")
# ── EXP-06: match and personal ─────────────────────────────────────────────────────────────────
def seeded_period():
"""EXP-06's given: card 4417 (3301), 5 transactions, one 264.00 from the Manchester hotel on 11-05, one 18.00 at a cinema on 11-06."""
seed_card("4417", 3301, [("TX-88213", "2026-11-06", "Odeon Cinema", Decimal("18.00")), ("TX-88200", "2026-11-05", "Manchester Hotel", Decimal("264.00")),
("TX-88201", "2026-11-01", "Costa", Decimal("4.20")), ("TX-88202", "2026-11-02", "Tesco", Decimal("12.00")),
("TX-88203", "2026-11-03", "Shell", Decimal("40.00"))])
def test_EXP_06_the_hotel_transaction_is_suggested_matched_to_the_line_and_the_cinema_marked_personal(nb):
seeded_period()
rid = claim_with_hotel_on_card()
sug = must(api("GET", f"/reports/{rid}/card-suggestions", EMPLOYEE))["suggestions"]
assert [(s["card_txn_id"], s["line_no"]) for s in sug] == [("card:4417:TX-88200", 2)] # same amount, within 3 days, merchant alike
got = must(api("POST", "/card-txns/card:4417:TX-88200/match", EMPLOYEE, {"report_id": rid, "line_no": 2}))
line = got["lines"][1]
assert (line["paid_with"], line["card_txn_id"], line["amount"]) == ("card", "card:4417:TX-88200", "264.00")
cinema = must(api("POST", "/card-txns/card:4417:TX-88213/personal", EMPLOYEE))
assert cinema["status"] == "personal"
status = {t["card_txn_id"]: t["status"] for t in txns()}
assert status["card:4417:TX-88200"] == "matched" and status["card:4417:TX-88213"] == "personal"
assert [t["card_txn_id"] for t in txns(status="imported")] == ["card:4417:TX-88201", "card:4417:TX-88202", "card:4417:TX-88203"] # what still waits
assert [t["matched_line_no"] for t in txns() if t["status"] == "matched"] == [2]
def test_EXP_06_a_line_for_a_card_with_no_matched_transaction_stops_submission(nb):
rid = claim_with_hotel_on_card()
body = refused(api("POST", f"/reports/{rid}/submit", EMPLOYEE), 409, "card_resolved")
assert "line 2 (paid with the card, no transaction matched)" in next(g for g in body["gates"] if g["gate"] == "card_resolved")["reason"]
def test_EXP_06_a_transaction_matches_one_line_only_and_a_line_one_transaction(nb):
seeded_period()
seed_card("4418", 3301, [("TX-2", "2026-11-05", "Manchester Hotel", Decimal("264.00"))])
rid = claim_with_hotel_on_card()
other = add(rid, category_code="ACCOMMODATION", merchant="Manchester Hotel", amount="264.00", expense_date="2026-11-05", paid_with="card")["lines"][-1]["line_no"]
must(api("POST", "/card-txns/card:4417:TX-88200/match", EMPLOYEE, {"report_id": rid, "line_no": 2}))
refused(api("POST", "/card-txns/card:4417:TX-88200/match", EMPLOYEE, {"report_id": rid, "line_no": other}), 409, "matched") # the transaction is taken
refused(api("POST", "/card-txns/card:4418:TX-2/match", EMPLOYEE, {"report_id": rid, "line_no": 2}), 409, "already matched") # the line is taken
must(api("POST", "/card-txns/card:4418:TX-2/match", EMPLOYEE, {"report_id": rid, "line_no": other}))
def test_EXP_06_two_racing_matches_of_one_transaction_one_wins_through_the_claim_row(nb):
seeded_period()
rid = claim_with_hotel_on_card()
other = add(rid, category_code="ACCOMMODATION", merchant="Manchester Hotel", amount="264.00", expense_date="2026-11-05", paid_with="card")["lines"][-1]["line_no"]
with use_cases.repository() as repo: # the racer that got there first took (txn, match) and has not saved its status yet
repo.cards.claim("card:4417:TX-88200", "match", use_cases._now(), "racer", report_id=rid)
refused(api("POST", "/card-txns/card:4417:TX-88200/match", EMPLOYEE, {"report_id": rid, "line_no": other}), 409, "a moment ago")
assert report(rid)["lines"][other - 1]["card_txn_id"] is None # the loser changed nothing
def test_EXP_06_only_a_card_in_the_claims_currency_matches_and_only_the_holders_own_transaction_is_theirs(nb):
seed_card("4417", 3301, [("TX-USD", "2026-11-05", "Hotel", Decimal("100.00"))])
seed_card("9999", 2280, [("TX-M", "2026-11-05", "Hotel", Decimal("264.00"))])
with use_cases.repository() as repo:
t = repo.cards.txn("card:4417:TX-USD")
repo.cards.save_txn({**t, "billed_currency": "USD"}, use_cases._now())
rid = claim_with_hotel_on_card()
refused(api("POST", "/card-txns/card:4417:TX-USD/match", EMPLOYEE, {"report_id": rid, "line_no": 2}), 409, "same currency billed")
refused(api("POST", "/card-txns/card:9999:TX-M/match", EMPLOYEE, {"report_id": rid, "line_no": 2}), 404, "no card transaction") # another holder's
refused(api("POST", "/card-txns/card:9999:TX-M/personal", EMPLOYEE), 404)
assert txns(MANAGER)[0]["card_txn_id"] == "card:9999:TX-M" and len(txns(MANAGER)) == 1 # a holder sees only their own card
refused(api("POST", "/card-txns/card:4417:TX-USD/match", MANAGER, {"report_id": rid, "line_no": 2}), 404) # nor another person's claim
refused(api("GET", "/card-txns", EMPLOYEE, {"scope": "all"}), 403, "exp:card_txn:resolve")
def test_a_matched_line_cannot_be_removed_or_given_another_amount_and_its_claim_cannot_be_cancelled(nb):
seeded_period()
rid = claim_with_hotel_on_card()
must(api("POST", "/card-txns/card:4417:TX-88200/match", EMPLOYEE, {"report_id": rid, "line_no": 2}))
refused(api("DELETE", f"/reports/{rid}/lines/2", EMPLOYEE), 409, "match is final")
refused(api("POST", f"/reports/{rid}/lines/2", EMPLOYEE, {"amount": "10.00"}), 409, "billed amount")
refused(api("POST", f"/reports/{rid}/cancel", EMPLOYEE), 409, "cannot be cancelled")
must(api("POST", f"/reports/{rid}/lines/2", EMPLOYEE, {"description": "two nights"}))
def test_a_personal_mark_posts_one_recovery_journal_and_a_second_mark_is_a_409(nb):
seeded_period()
must(api("POST", "/card-txns/card:4417:TX-88213/personal", EMPLOYEE))
(j,) = nb.journals.values()
assert (j["source_module"], j["event"], j["source_document"], j["journal_date"], j["journal_id"]) == (
"expenses", "personal", "card:4417:TX-88213", "2026-11-07", str(seam.derived_id("card:4417:TX-88213", "personal")))
assert j["lines"] == [{"purpose": "employee_receivable", "bp_id": "73301", "debit": "18.00"}, {"purpose": "card_liability", "memo": "card 4417", "credit": "18.00"}]
refused(api("POST", "/card-txns/card:4417:TX-88213/personal", EMPLOYEE), 409, "needs it to be imported")
must(api("POST", "/outbox/drain", SYSTEM))
assert nb.journal_posts == 1 # sent once; the drain finds nothing pending
def test_a_recovery_journal_the_ledger_refused_waits_pending_and_is_sent_by_the_next_drain(nb):
seeded_period()
nb.closed_through = "2026-11-30"
must(api("POST", "/card-txns/card:4417:TX-88213/personal", EMPLOYEE))
assert nb.journals == {}
nb.closed_through = None
out = must(api("POST", "/outbox/drain", SYSTEM))
assert [s["status"] for s in out["sent"]] == ["posted"] and len(nb.journals) == 1
# ── EXP-12: the journal carries the card's liability ───────────────────────────────────────────────
def test_EXP_12_a_claim_with_a_card_line_posts_the_cost_and_credits_the_employee_and_the_card(nb):
seeded_period()
rid = claim_with_hotel_on_card()
must(api("POST", "/card-txns/card:4417:TX-88200/match", EMPLOYEE, {"report_id": rid, "line_no": 2}))
must(api("POST", "/card-txns/card:4417:TX-88213/personal", EMPLOYEE))
submit(rid)
must(approve(rid, MANAGER))
got = must(approve(rid, FINANCE))
assert got["status"] == "posted" and got["reimbursable_amount"] == "287.40" and got["total_amount"] == "551.40"
j = next(x for x in nb.journals.values() if x["event"] == "approve")
assert j["lines"][-2:] == [{"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")
# ── EXP-13 / EXP-14: personal spend recovered once ─────────────────────────────────────────────────────
def _resolved_period():
"""Mark every other card transaction of the period matched, so only the personal one is in play."""
for ref in ("TX-88201", "TX-88202", "TX-88203"):
with use_cases.repository() as repo:
t = repo.cards.txn(f"card:4417:{ref}")
repo.cards.save_txn({**t, "status": "matched"}, use_cases._now())
def test_EXP_13_the_payout_nets_the_personal_spend_and_the_recovery_has_its_own_claim_row(nb):
seeded_period()
rid = claim_with_hotel_on_card()
must(api("POST", "/card-txns/card:4417:TX-88200/match", EMPLOYEE, {"report_id": rid, "line_no": 2}))
must(api("POST", "/card-txns/card:4417:TX-88213/personal", EMPLOYEE)) # 18.00 owed
_resolved_period() # the rest of the period is resolved too
submit(rid)
must(approve(rid, MANAGER))
got = must(approve(rid, FINANCE))
assert got["reimbursable_amount"] == "287.40" and got["netted_amount"] == "18.00" and got["payout_amount"] == "269.40"
(p,) = nb.payouts.values()
assert p["amount"] == "269.40" and p["payout_ref"] == f"exp:{rid}:r1:pay"
with use_cases.repository() as repo:
(c,) = repo.cards.claims_of_report(rid, "recover")
assert (c["card_txn_id"], c["payout_ref"]) == ("card:4417:TX-88213", f"exp:{rid}:r1:pay") # its own key: never the payout's
assert repo.cards.txn("card:4417:TX-88213")["status"] == "personal" # not recovered until the bank pays
refused(api("POST", f"/reports/{rid}/paid", PAYMENTS_CLIENT, {"payout_ref": f"exp:{rid}:r1:pay", "payment_id": "BANK-1", "paid_on": "2026-11-12",
"amount": "287.40"}), 409, "payout is 269.40")
must(api("POST", f"/reports/{rid}/paid", PAYMENTS_CLIENT, {"payout_ref": f"exp:{rid}:r1:pay", "payment_id": "BANK-1", "paid_on": "2026-11-12", "amount": "269.40"}))
assert [t["status"] for t in txns() if t["card_txn_id"].endswith("TX-88213")] == ["recovered"]
assert report(rid)["status"] == "paid"
def test_EXP_14_netting_and_repayment_of_one_recovery_collide_on_one_claim_row_and_only_one_wins(nb):
seeded_period()
_resolved_period()
rid = claim_with_hotel_on_card()
must(api("POST", "/card-txns/card:4417:TX-88200/match", EMPLOYEE, {"report_id": rid, "line_no": 2}))
must(api("POST", "/card-txns/card:4417:TX-88213/personal", EMPLOYEE))
must(api("POST", "/card-txns/card:4417:TX-88213/recover", FINANCE, {"payment_id": "RECEIPT-9"})) # the employee repaid first
assert [t["status"] for t in txns() if t["card_txn_id"].endswith("TX-88213")] == ["recovered"]
submit(rid)
must(approve(rid, MANAGER))
got = must(approve(rid, FINANCE))
assert got["netted_amount"] is None and got["payout_amount"] == "287.40" # nothing left to net: never netted twice
refused(api("POST", "/card-txns/card:4417:TX-88213/recover", FINANCE2, {"payment_id": "RECEIPT-10"}), 409, "recovered")
def test_EXP_14_a_repayment_after_netting_lost_the_claim_row_and_is_refused(nb):
seeded_period()
_resolved_period()
rid = claim_with_hotel_on_card()
must(api("POST", "/card-txns/card:4417:TX-88200/match", EMPLOYEE, {"report_id": rid, "line_no": 2}))
must(api("POST", "/card-txns/card:4417:TX-88213/personal", EMPLOYEE))
submit(rid)
must(approve(rid, MANAGER))
must(approve(rid, FINANCE)) # netted, not yet paid
refused(api("POST", "/card-txns/card:4417:TX-88213/recover", FINANCE, {"payment_id": "RECEIPT-9"}), 409, "a moment ago")
with use_cases.repository() as repo:
assert repo.cards.txn("card:4417:TX-88213")["status"] == "personal"
def test_EXP_14_only_a_personal_transaction_is_recovered_by_finance(nb):
seeded_period()
refused(api("POST", "/card-txns/card:4417:TX-88213/recover", FINANCE, {"payment_id": "R"}), 409, "needs it to be personal")
refused(api("POST", "/card-txns/card:4417:NOPE/recover", FINANCE, {"payment_id": "R"}), 404)
refused(api("POST", "/card-txns/card:4417:TX-88213/recover", EMPLOYEE, {"payment_id": "R"}), 403, "exp:card_txn:resolve")
def test_EXP_13_when_the_personal_spend_is_more_than_the_claim_the_surplus_stays_owed(nb):
seed_card("4417", 3301, [("TX-A", "2026-11-05", "Spa", Decimal("500.00")), ("TX-B", "2026-11-05", "Cinema", Decimal("18.00"))])
rid = new_report()["report_id"]
add(rid, amount="100.00")
for ref in ("TX-A", "TX-B"):
must(api("POST", f"/card-txns/card:4417:{ref}/personal", EMPLOYEE))
submit(rid)
must(approve(rid, MANAGER))
got = report(rid)
assert got["netted_amount"] == "18.00" and got["payout_amount"] == "82.00" # 500.00 does not fit inside 100.00: still owed
assert [t["status"] for t in txns()] == ["personal", "personal"]
def test_EXP_13_a_claim_wholly_netted_has_nothing_to_pay_and_is_settled(nb):
seed_card("4417", 3301, [("TX-B", "2026-11-05", "Cinema", Decimal("18.00"))])
rid = new_report()["report_id"]
add(rid, amount="18.00")
must(api("POST", "/card-txns/card:4417:TX-B/personal", EMPLOYEE))
submit(rid)
must(approve(rid, MANAGER))
got = report(rid)
assert (got["status"], got["payout_amount"], got["paid_on"]) == ("paid", "0.00", "2026-11-07") and nb.payouts == {}
assert txns()[0]["status"] == "recovered"
def test_EXP_13_reimburse_with_separate_nets_nothing(nb):
seed_card("4417", 3301, [("TX-B", "2026-11-05", "Cinema", Decimal("18.00"))])
with use_cases.repository() as repo:
repo.settings.put("reimburse_with", "separate", use_cases._today(repo), "owner", use_cases._now())
rid = new_report()["report_id"]
add(rid, amount="100.00")
must(api("POST", "/card-txns/card:4417:TX-B/personal", EMPLOYEE))
submit(rid)
must(approve(rid, MANAGER))
assert report(rid)["payout_amount"] == "100.00" and report(rid)["netted_amount"] is None and txns()[0]["status"] == "personal"
# ── EXP-19: a leaver's cards ───────────────────────────────────────────────────────────────────────
def test_EXP_19_a_leavers_cards_stop_from_the_day_after_and_finance_lists_what_is_unmatched(nb):
seeded_period()
got = must(api("POST", "/employees/3301/leaver", HR_CLIENT, {"last_day": "2026-11-06", "source_ref": "hr:left:3301:1"}))
assert got["cards_stopped"] == 1
with use_cases.repository() as repo:
c = repo.cards.get_card("4417")
assert (c["status"], c["stopped_from"].isoformat()) == ("stopped", "2026-11-07")
unmatched = must(api("GET", "/card-txns", FINANCE, {"scope": "unmatched"}))["card_txns"]
assert len(unmatched) == 5 and {t["holder_person_id"] for t in unmatched} == {"3301"}
again = must(api("POST", "/employees/3301/leaver", HR_CLIENT, {"last_day": "2026-11-06", "source_ref": "hr:left:3301:1"}))
assert again["cards_stopped"] == 1 # the repeat answers the same
def test_EXP_19_the_unmatched_list_filters_by_card_and_never_holds_other_cards_rows(nb):
seeded_period()
seed_card("9999", 2280, [("TX-M", "2026-11-05", "Hotel", Decimal("1.00"))])
only = must(api("GET", "/card-txns", FINANCE, {"scope": "unmatched", "card_id": "9999"}))["card_txns"]
assert [t["card_txn_id"] for t in only] == ["card:9999:TX-M"]