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