dodil/erp-brick-expenses
DODIL ERP brick: expenses
tests/test_reminders.py
"""Slice B item 5: the daily chase (EXP-17): a card transaction with no receipt and a claim waiting too long, one reminder each per business date, handed
to the mail capability from pending rows - or to a person when no mail provider is wired."""
from decimal import Decimal
import pytest
from conftest import EMPLOYEE, FINANCE, MANAGER, SYSTEM, add, api, approve, must, new_report, on_day, refused, report, seed_card, submit
from erp_platform import capabilities, seam
from fastapi import FastAPI, Response
from brick_expenses.application import use_cases
def run(who=None):
return api("POST", "/reminders/run", who or SYSTEM)
def monday_claim(amount="30.00"):
"""Claim 00047 of the story: submitted on Monday 2026-11-09, waiting at level 1 (manager 2280)."""
with on_day("2026-11-09T09:00:00+00:00"):
rid = new_report()["report_id"]
add(rid, amount=amount, expense_date="2026-11-04")
submit(rid)
return rid
@pytest.fixture
def mail():
"""A mail provider that answers done, and what it was asked to send."""
sent, state = [], {"status": None}
app = FastAPI()
@app.post("/capabilities/mail.send")
def send(body: dict, response: Response):
if state["status"]:
response.status_code = state["status"]
return {"detail": "down"}
sent.append(body)
return {"status": "done", "provider": "gws", "provider_ref": f"m{len(sent)}", "output": {"message_id": f"m{len(sent)}"}}
seam.register_local("gws", app)
capabilities.register_provider("mail.send", "gws")
yield type("Mail", (), {"sent": sent, "state": state})
capabilities.reset_providers()
seam.unregister_local("gws")
def test_EXP_17_the_card_holder_and_the_approver_are_each_reminded_once_a_business_date_and_the_second_pass_sends_nothing(nb):
seed_card("4417", 3301, [("TX-88301", "2026-11-02", "Shell", Decimal("42.00"))], holder_email="emp3301@uk.example")
rid = monday_claim()
with on_day("2026-11-17T09:00:00+00:00"): # 14 days since 11-02 is 15 on the 17th; 6 business days since the claim
first = must(run())
assert first["business_date"] == "2026-11-17" and first["reminders"] == 2 and first["unaddressed"] == []
box = must(api("GET", "/reminders", FINANCE))["reminders"]
assert sorted((r["ref"], r["to"]) for r in box) == [
(f"approval:{rid}:1:2026-11-17", ["mgr2280@uk.example"]), ("card_receipt:card:4417:TX-88301:2026-11-17", ["emp3301@uk.example"])]
again = must(run())
assert (again["reminders"], again["skipped_already_sent"], again["sent"]) == (0, 2, []) # the second pass the same day sends nothing
with on_day("2026-11-18T09:00:00+00:00"):
assert must(run())["reminders"] == 2 # tomorrow's pass chases again
assert len(must(api("GET", "/reminders", FINANCE))["reminders"]) == 4
def test_EXP_17_a_transaction_is_chased_only_when_older_than_card_receipt_days_and_a_matched_or_personal_one_never(nb):
seed_card("4417", 3301, [("OLD", "2026-11-02", "Shell", Decimal("42.00")), ("EDGE", "2026-11-03", "Tesco", Decimal("5.00")),
("MATCHED", "2026-10-01", "Costa", Decimal("4.00")), ("PERSONAL", "2026-10-02", "Cinema", Decimal("18.00"))],
holder_email="emp3301@uk.example")
with use_cases.repository() as repo:
for ref, status in (("MATCHED", "matched"), ("PERSONAL", "personal")):
repo.cards.save_txn({**repo.cards.txn(f"card:4417:{ref}"), "status": status}, use_cases._now())
with on_day("2026-11-17T09:00:00+00:00"):
assert [r["ref"] for r in must(api("GET", "/reminders", FINANCE))["reminders"]] == []
must(run())
refs = [r["ref"] for r in must(api("GET", "/reminders", FINANCE))["reminders"]]
assert refs == ["card_receipt:card:4417:OLD:2026-11-17"] # 15 days is older than 14; 14 is not; resolved ones are not chased
def test_EXP_17_a_claim_is_chased_after_the_business_days_counted_from_when_it_began_waiting_at_its_level(nb):
rid = monday_claim("620.00") # manager, then finance
with on_day("2026-11-13T09:00:00+00:00"): # Friday: 4 business days
assert must(run())["reminders"] == 0
with on_day("2026-11-16T09:00:00+00:00"): # Monday: 5
got = must(run())
assert got["reminders"] == 1 and must(api("GET", "/reminders", FINANCE))["reminders"][0]["to"] == ["mgr2280@uk.example"]
must(approve(rid, MANAGER)) # level 2 begins waiting HERE (finance), not on the 9th
assert report(rid, FINANCE)["waiting_level"] == 2
got = must(run())
assert got["reminders"] == 0 and any("waiting at finance's level" in u for u in got["unaddressed"]) is False # waiting 0 days: nothing yet
with on_day("2026-11-23T09:00:00+00:00"): # 5 business days after the 16th
got = must(run())
assert got["reminders"] == 0 and got["unaddressed"] == [f"claim {rid} waiting at finance's level: no address to remind"]
with use_cases.repository() as repo:
repo.settings.put("finance_reminder_email", "finance@uk.example", use_cases._today(repo), "fin", use_cases._now())
with on_day("2026-11-23T09:00:00+00:00"):
assert must(run())["reminders"] == 1
assert [r["to"] for r in must(api("GET", "/reminders", FINANCE))["reminders"] if r["ref"].endswith(":2:2026-11-23")] == [["finance@uk.example"]]
def test_EXP_17_a_decided_claim_is_not_chased_and_the_threshold_is_a_setting(nb):
rid = monday_claim()
with use_cases.repository() as repo:
repo.settings.put("approval_reminder_days", "3", __import__("datetime").date(2026, 11, 1), "fin", use_cases._now())
with on_day("2026-11-12T09:00:00+00:00"): # Thursday: 3 business days
assert must(run())["reminders"] == 1
must(approve(rid, MANAGER))
with on_day("2026-11-13T09:00:00+00:00"):
assert must(run())["reminders"] == 0 # approved, posted: nobody to chase
def test_EXP_17_a_card_with_no_holder_address_is_named_not_silently_skipped(nb):
seed_card("4417", 3301, [("OLD", "2026-11-02", "Shell", Decimal("42.00"))])
with on_day("2026-11-17T09:00:00+00:00"):
got = must(run())
assert got["reminders"] == 0 and got["unaddressed"] == ["card transaction card:4417:OLD: card 4417 has no holder_email"]
def test_EXP_17_the_business_date_is_the_date_in_business_timezone_never_the_utc_date(nb):
seed_card("4417", 3301, [("OLD", "2026-06-01", "Shell", Decimal("42.00"))], holder_email="emp3301@uk.example")
with on_day("2026-07-14T23:30:00+00:00"): # 00:30 BST on the 15th
first = must(run())
assert (first["business_date"], first["reminders"]) == ("2026-07-15", 1)
with on_day("2026-07-15T08:00:00+00:00"): # 09:00 BST: a later UTC date's pass, the same business date
again = must(run())
assert (again["business_date"], again["reminders"], again["skipped_already_sent"]) == ("2026-07-15", 0, 1)
with on_day("2026-07-15T23:30:00+00:00"): # 00:30 BST on the 16th: the next business date
assert must(run())["business_date"] == "2026-07-16"
def test_EXP_17_with_no_mail_provider_a_reminder_is_a_task_for_a_person_and_with_one_it_is_sent(nb, mail):
seed_card("4417", 3301, [("OLD", "2026-11-02", "Shell", Decimal("42.00"))], holder_email="emp3301@uk.example")
with on_day("2026-11-17T09:00:00+00:00"):
got = must(run())
assert [s["status"] for s in got["sent"]] == ["sent"] and len(mail.sent) == 1
asked = mail.sent[0]
assert asked["idempotency_key"] == "card_receipt:card:4417:OLD:2026-11-17" and asked["input"]["to"] == ["emp3301@uk.example"]
assert "Shell" in asked["input"]["subject"] and "no receipt" in asked["input"]["body_text"]
assert must(api("GET", "/reminders", FINANCE, {"status": "sent"}))["reminders"][0]["status"] == "sent"
def test_EXP_17_a_mail_provider_that_is_down_leaves_the_reminder_pending_for_the_next_pass(nb, mail):
seed_card("4417", 3301, [("OLD", "2026-11-02", "Shell", Decimal("42.00"))], holder_email="emp3301@uk.example")
mail.state["status"] = 500
with on_day("2026-11-17T09:00:00+00:00"):
got = must(run())
assert [s["status"] for s in got["sent"]] == ["retry"]
assert [r["status"] for r in must(api("GET", "/reminders", FINANCE))["reminders"]] == ["pending"]
mail.state["status"] = None
again = must(api("POST", "/outbox/drain", SYSTEM)) # the worker's drain sends what is pending
assert [s["status"] for s in again["sent"]] == ["sent"] and len(mail.sent) == 1
def test_EXP_17_without_a_provider_the_reminder_waits_for_a_person(nb):
seed_card("4417", 3301, [("OLD", "2026-11-02", "Shell", Decimal("42.00"))], holder_email="emp3301@uk.example")
with on_day("2026-11-17T09:00:00+00:00"):
got = must(run())
assert [s["status"] for s in got["sent"]] == ["manual"]
(r,) = must(api("GET", "/reminders", FINANCE, {"status": "manual"}))["reminders"]
assert "no provider for mail.send" in r["note"]
def test_EXP_17_the_pass_is_the_workers_alone_and_the_list_is_finances(nb):
refused(run(EMPLOYEE), 403, "exp:journal:post_auto")
refused(run(FINANCE), 403, "exp:journal:post_auto")
refused(api("GET", "/reminders", EMPLOYEE), 403, "exp:report:read_all")
def test_EXP_17_weekends_are_not_business_days(nb):
with on_day("2026-11-11T09:00:00+00:00"): # submitted on a Wednesday
rid = new_report()["report_id"]
add(rid, amount="30.00", expense_date="2026-11-04")
submit(rid)
with on_day("2026-11-17T09:00:00+00:00"): # Tuesday: 6 calendar days but 4 business days (12, 13, 16, 17)
assert must(run())["reminders"] == 0
with on_day("2026-11-18T09:00:00+00:00"): # Wednesday: the fifth
assert must(run())["reminders"] == 1