dodil/erp-brick-expenses

DODIL ERP brick: expenses

tests/test_payout.py

"""Slice B item 5: cancelling and reissuing the payout (EXP-13): a payout is cancelled through payments until it is exported, then the payment stands."""
from conftest import FINANCE, FINANCE2, MANAGER, PAYMENTS_CLIENT, SYSTEM, add, api, approve, must, new_report, refused, report, submit

from brick_expenses.application import use_cases


def posted(nb, amount="100.00"):
    rid = new_report()["report_id"]
    add(rid, amount=amount)
    submit(rid)
    must(approve(rid, MANAGER))
    return rid


def cancel(rid, who=FINANCE, reason="wrong bank details"):
    return api("POST", f"/reports/{rid}/payout/cancel", who, {"reason": reason})


def test_EXP_13_a_payout_not_yet_exported_is_cancelled_through_payments_and_the_claim_has_none(nb):
    rid = posted(nb)
    ref = f"exp:{rid}:r1:pay"
    got = must(cancel(rid))
    assert got["status"] == "posted" and got["payout_ref"] is None and nb.cancelled == {ref: "wrong bank details"}
    assert [(o["target"], o["status"]) for o in got["outbox"] if o["ref"].endswith(":cancel")] == [("payments", "sent")]
    refused(api("POST", f"/reports/{rid}/paid", PAYMENTS_CLIENT, {"payout_ref": ref, "payment_id": "B1", "paid_on": "2026-11-12", "amount": "100.00"}), 409, "not the payout")
    assert must(cancel(rid))["payout_ref"] is None and nb.cancel_posts == 1                       # a second cancel is no second call


def test_EXP_13_after_export_the_payment_stands_and_the_claim_keeps_its_payout(nb):
    rid = posted(nb)
    nb.exported.add(f"exp:{rid}:r1:pay")
    body = refused(cancel(rid), 409, "the payment stands")
    assert body["gate"] == "not_exported" and "recover it from the employee" in body["fix"]
    assert report(rid)["payout_ref"] == f"exp:{rid}:r1:pay"
    got = must(api("POST", f"/reports/{rid}/paid", PAYMENTS_CLIENT, {"payout_ref": f"exp:{rid}:r1:pay", "payment_id": "B1", "paid_on": "2026-11-12", "amount": "100.00"}))
    assert got["status"] == "paid"
    refused(cancel(rid), 409, "is paid")


def test_EXP_13_payments_down_leaves_the_cancel_pending_and_the_next_drain_sends_it(nb):
    rid = posted(nb)
    nb.pay_status = 500
    got = must(cancel(rid))
    assert got["payout_ref"] == f"exp:{rid}:r1:pay" and nb.cancelled == {}                          # not cancelled yet: it waits as a pending row
    (box,) = [o for o in got["outbox"] if o["ref"].endswith(":cancel")]
    assert box["status"] == "pending" and "failed (500" in box["last_error"]
    nb.pay_status = None
    out = must(api("POST", "/outbox/drain", SYSTEM))
    assert [s["status"] for s in out["sent"]] == ["cancelled"] and report(rid)["payout_ref"] is None


def test_EXP_13_a_payout_payments_was_never_told_about_is_dropped_here_without_a_call(nb):
    nb.pay_status = 500
    rid = posted(nb)                                                                              # the payout row is still pending
    nb.pay_status = None
    got = must(cancel(rid))
    assert got["payout_ref"] is None and nb.cancel_posts == 0 and nb.payouts == {}
    assert must(api("POST", "/outbox/drain", SYSTEM))["sent"] == []                              # and the dropped request is never sent


def test_EXP_13_reissue_asks_for_a_new_reference_and_the_old_one_is_never_reused(nb):
    rid = posted(nb)
    refused(api("POST", f"/reports/{rid}/payout/reissue", FINANCE), 409, "cancel it first")
    must(cancel(rid))
    got = must(api("POST", f"/reports/{rid}/payout/reissue", FINANCE))
    assert got["payout_ref"] == f"exp:{rid}:r1:pay2" and set(nb.payouts) == {f"exp:{rid}:r1:pay", f"exp:{rid}:r1:pay2"}
    assert nb.payouts[f"exp:{rid}:r1:pay2"]["amount"] == "100.00"
    assert must(api("POST", f"/reports/{rid}/paid", PAYMENTS_CLIENT, {"payout_ref": f"exp:{rid}:r1:pay2", "payment_id": "B2", "paid_on": "2026-11-12",
                                                                      "amount": "100.00"}))["status"] == "paid"


def test_EXP_13_only_a_posted_claim_has_a_payout_to_cancel_or_reissue_and_only_finance_does_it(nb):
    rid = new_report()["report_id"]
    add(rid, amount="20.00")
    refused(cancel(rid), 409, "only a posted claim")
    refused(api("POST", f"/reports/{rid}/payout/reissue", FINANCE), 409, "only a posted claim")
    rid = posted(nb)
    refused(cancel(rid, MANAGER), 403, "exp:report:post")
    refused(cancel(rid, reason="  "), 422)
    refused(api("POST", f"/reports/{rid}/payout/reissue", MANAGER), 403, "exp:report:post")
    refused(api("POST", "/reports/NOPE/payout/cancel", FINANCE, {"reason": "x"}), 404)
    assert FINANCE2


def test_a_claim_with_nothing_to_pay_has_no_payout_to_cancel_and_no_payee_means_no_reissue(nb):
    nb.persons["emp3301@uk.example"]["bp_id"] = None
    rid = posted(nb)
    assert must(cancel(rid))["payout_ref"] is None and nb.cancel_posts == 0
    refused(api("POST", f"/reports/{rid}/payout/reissue", FINANCE), 409, "person partner")


def test_two_racing_cancels_take_one_claim_row_and_the_loser_is_a_409(nb):
    rid = posted(nb)
    ref = f"exp:{rid}:r1:pay"
    with use_cases.repository() as repo:                                                          # the other finance user is mid-cancel: they hold the key
        repo.claims.once("payout_cancel", ref, use_cases._now(), "fin2@uk.example")
    refused(cancel(rid), 409, "a moment ago")
    assert nb.cancel_posts == 0 and report(rid)["payout_ref"] == ref