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