dodil/erp-brick-payments
DODIL ERP brick: payments
tests/test_stories.py
"""Stories PAY-09 ... PAY-15, PAY-31, PAY-32 and the access rules: collections, statements, credit, the controls on the
cashier, and the AI's suggestion that a person decides."""
import json
from conftest import (
APPROVER,
APPROVER2,
CASHIER,
COLLECTOR,
SALES,
SYSTEM,
allocate,
api,
must,
on_day,
receipt,
refused,
set_control,
settle,
three_invoices,
)
def levels(*specs):
for level, days, mode, template in specs:
must(api("POST", "/reminder-levels", APPROVER, {"level": {"level": level, "days_after_due": days, "mode": mode, "template": template}}))
# ── PAY-09: the overdue list ─────────────────────────────────────────────────────────────────────
def test_PAY_09_the_overdue_list_ages_into_the_configured_buckets(nb):
nb.invoice("A", 1, "100.00", due_date="2026-11-10") # 10 days
nb.invoice("B", 2, "200.00", due_date="2026-10-10") # 41 days
nb.invoice("C", 3, "300.00", due_date="2026-09-01") # 80 days
nb.invoice("D", 4, "400.00", due_date="2026-06-01") # 172 days
nb.invoice("E", 5, "500.00", due_date="2026-12-01") # not due
got = must(api("GET", "/collections/overdue", COLLECTOR))
assert [(i["invoice_id"], i["bucket"], i["age_days"]) for i in got["invoices"]] == [
("D", "90+", 172), ("C", "61-90", 80), ("B", "31-60", 41), ("A", "0-30", 10)]
assert [b["bucket"] for b in got["buckets"]] == ["0-30", "31-60", "61-90", "90+"]
must(api("POST", "/settings/aging_buckets", APPROVER, {"value": "0-15,16+"}))
assert {i["invoice_id"]: i["bucket"] for i in must(api("GET", "/collections/overdue", COLLECTOR))["invoices"]} == {
"A": "0-15", "B": "16+", "C": "16+", "D": "16+"}
def test_PAY_09_a_paid_invoice_is_not_on_the_list_and_risk_is_shown_when_on(nb):
nb.invoice("A", 1, "100.00", due_date="2026-11-10")
nb.invoice("B", 1, "100.00", due_date="2026-10-10")
r = receipt(bp_id=1, amount="100.00", reference="b")
must(allocate(r["payment_id"], [("B", "100.00")]))
got = must(api("GET", "/collections/overdue", COLLECTOR))["invoices"]
assert [i["invoice_id"] for i in got] == ["A"]
assert got[0]["late_payment_risk"]["reasons"][1] == "1 of 1 earlier invoices paid late" # B was paid after its due date
must(api("POST", "/settings/late_payment_risk", APPROVER, {"value": "off"}))
assert "late_payment_risk" not in must(api("GET", "/collections/overdue", COLLECTOR))["invoices"][0]
# ── PAY-10: promises and disputes ────────────────────────────────────────────────────────────────
def test_PAY_10_a_promise_is_recorded_once_for_the_customer_and_shown_on_each_invoice(nb):
nb.invoice("A", 7311, "100.00", due_date="2026-11-01")
nb.invoice("B", 7311, "200.00", due_date="2026-11-05")
refused(api("POST", "/collections/promises", COLLECTOR, {"bp_id": 7311, "promise_date": "2026-11-20"}), 409,
"The promise date 2026-11-20 is today or earlier")
refused(api("POST", "/collections/promises", COLLECTOR, {"bp_id": 7311, "promise_date": "2026-11-01"}), 409, "today or earlier")
must(api("POST", "/collections/promises", COLLECTOR, {"bp_id": 7311, "promise_date": "2026-12-05", "note": "paying on the 5th"}))
shown = must(api("GET", "/collections/overdue", COLLECTOR))["invoices"]
assert {i["invoice_id"]: (i["state"], i["promise_until"]) for i in shown} == {"A": ("promised", "2026-12-05"), "B": ("promised", "2026-12-05")}
def test_PAY_10_a_dispute_suppresses_reminders_until_it_is_resolved(nb):
nb.invoice("A", 7311, "100.00", due_date="2026-11-01")
levels((1, 7, "suggest", ""))
must(api("POST", "/collections/disputes", COLLECTOR, {"bp_id": 7311, "invoice_id": "A", "reason": "goods damaged"}))
refused(api("POST", "/collections/disputes", COLLECTOR, {"bp_id": 7311, "invoice_id": "A", "reason": "again"}), 409, "already has an open dispute")
assert must(api("POST", "/collections/tick", SYSTEM, {}))["suppressed"] == 1
assert must(api("GET", "/collections/overdue", COLLECTOR))["invoices"][0]["state"] == "disputed"
must(api("POST", "/collections/disputes/resolve", COLLECTOR, {"bp_id": 7311, "invoice_id": "A", "outcome": "credit note raised"}))
assert must(api("POST", "/collections/tick", SYSTEM, {}))["drafted"] == 1 # reminders resume
assert must(api("POST", "/collections/disputes/resolve", COLLECTOR, {"bp_id": 7311, "invoice_id": "A", "outcome": "x"}))["unchanged"] is True
# ── PAY-31 / SC-PAY-05: reminders ────────────────────────────────────────────────────────────────
def test_PAY_31_one_draft_per_level_however_often_the_pass_runs(nb):
nb.invoice("A", 7311, "100.00", due_date="2026-11-13") # 7 days overdue today
levels((1, 7, "suggest", ""), (2, 14, "suggest", ""), (3, 30, "suggest", ""))
first = must(api("POST", "/collections/tick", SYSTEM, {}))
second = must(api("POST", "/collections/tick", SYSTEM, {}))
assert (first["drafted"], second["drafted"]) == (1, 0)
drafts = must(api("GET", "/collections/reminders", COLLECTOR))["drafts"]
assert [(d["invoice_id"], d["level"]) for d in drafts] == [("A", 1)] and "invoice A" in drafts[0]["draft_text"]
sent = must(api("POST", "/collections/reminders/send", COLLECTOR, {"bp_id": 7311, "invoice_id": "A", "level": 1}))
assert sent["status"] == "sent"
assert must(api("POST", "/collections/reminders/send", COLLECTOR, {"bp_id": 7311, "invoice_id": "A", "level": 1}))["unchanged"] is True
assert must(api("GET", "/collections/reminders", COLLECTOR))["drafts"] == []
def test_PAY_31_in_mode_auto_it_is_sent_and_in_mode_off_nothing_happens(nb):
nb.invoice("A", 7311, "100.00", due_date="2026-11-13")
levels((1, 7, "auto", "Dear customer, invoice {invoice} for {amount} {currency} is overdue."))
must(api("POST", "/settings/reminder_mode", APPROVER, {"value": "off"}))
assert must(api("POST", "/collections/tick", SYSTEM, {}))["mode"] == "off"
assert must(api("GET", "/collections/reminders", COLLECTOR))["drafts"] == []
must(api("POST", "/settings/reminder_mode", APPROVER, {"value": "auto"}))
got = must(api("POST", "/collections/tick", SYSTEM, {}))
assert (got["sent"], got["drafted"]) == (1, 0)
assert must(api("GET", "/collections/reminders", COLLECTOR))["drafts"] == [] # nothing waiting: it went
def test_SC_PAY_05_a_promise_holds_level_two_until_it_lapses(nb):
nb.invoice("A", 7311, "100.00", due_date="2026-11-13")
levels((1, 7, "suggest", ""), (2, 14, "suggest", ""))
tick = lambda: must(api("POST", "/collections/tick", SYSTEM, {})) # noqa: E731
assert tick()["drafted"] == 1 and tick()["drafted"] == 0 # day 7: one level-1 draft, twice
must(api("POST", "/collections/promises", COLLECTOR, {"bp_id": 7311, "promise_date": "2026-12-03"})) # promise "by day 20"
with on_day("2026-11-27T09:00:00+00:00"): # day 14: level 2 is due...
assert tick()["drafted"] == 0 # ...but the promise suppresses it
with on_day("2026-12-03T09:00:00+00:00"):
assert tick()["drafted"] == 0 # promised until the 3rd inclusive
with on_day("2026-12-04T09:00:00+00:00"): # day 21: still unpaid
got = tick()
assert got["drafted"] == 1 and got["lapsed_promises"] == 1
assert sorted(d["level"] for d in must(api("GET", "/collections/reminders", COLLECTOR))["drafts"]) == [1, 2]
def test_PAY_31_a_missed_pass_sends_the_highest_due_level_not_three_letters(nb):
nb.invoice("A", 7311, "100.00", due_date="2026-10-01") # 50 days overdue
levels((1, 7, "suggest", ""), (2, 14, "suggest", ""), (3, 30, "suggest", ""))
must(api("POST", "/collections/tick", SYSTEM, {}))
assert [d["level"] for d in must(api("GET", "/collections/reminders", COLLECTOR))["drafts"]] == [3]
def test_PAY_31_only_level_one_with_an_approved_template_may_be_automatic(nb):
refused(api("POST", "/reminder-levels", APPROVER, {"level": {"level": 2, "days_after_due": 14, "mode": "auto", "template": "x"}}), 409, "only for level 1")
refused(api("POST", "/reminder-levels", APPROVER, {"level": {"level": 1, "days_after_due": 7, "mode": "auto", "template": ""}}), 409, "own approved template")
# ── PAY-11 / SC-PAY-07: the open-item statement ──────────────────────────────────────────────────
def test_PAY_11_a_statement_lists_what_was_unsettled_at_the_end_of_the_day(nb):
nb.invoice("A", 7311, "4000.00", issued_on="2026-11-01")
nb.invoice("B", 7311, "500.00", issued_on="2026-11-10")
nb.invoice("LATE", 7311, "999.00", issued_on="2026-11-19")
got = must(api("POST", "/statements", COLLECTOR, {"bp_id": 7311, "as_at": "2026-11-12"}))
assert [i["invoice_id"] for i in got["invoices"]] == ["A", "B"] and got["closing_balance"] == {"GBP": "4500.00"}
assert got["sent"] == [{"currency": "GBP", "closing_balance": "4500.00", "recorded": True, "matches_recorded": True}]
r = receipt(amount="4000.00", reference="late-pay")
must(allocate(r["payment_id"], [("A", "4000.00")])) # allocated since
again = must(api("POST", "/statements", COLLECTOR, {"bp_id": 7311, "as_at": "2026-11-12"}))
assert again["closing_balance"] == {"GBP": "4500.00"} and again["sent"][0]["matches_recorded"] is True and again["sent"][0]["recorded"] is False
now = must(api("POST", "/statements", COLLECTOR, {"bp_id": 7311, "as_at": "2026-11-20"}))["closing_balance"]
assert now == {"GBP": "1499.00"} # 500 + 999, the 4,000 receipt settled A
refused(api("POST", "/statements", COLLECTOR, {"bp_id": 7311, "as_at": "2026-11-21"}), 409, "after today")
def test_PAY_11_a_statement_shows_credit_notes_and_unallocated_receipts_as_negatives(nb):
nb.invoice("A", 7311, "1000.00", issued_on="2026-11-01")
nb.credit_note("CN-1", 7311, "100.00", issued_on="2026-11-02")
with on_day("2026-11-03T09:00:00+00:00"): # recorded when it arrived
receipt(amount="250.00", reference="adv", received_on="2026-11-03")
got = must(api("POST", "/statements", COLLECTOR, {"bp_id": 7311, "as_at": "2026-11-05"}))
assert sorted(c["amount"] for c in got["credits"]) == ["-100.00", "-250.00"] and got["closing_balance"] == {"GBP": "650.00"}
def test_SC_PAY_07_no_back_dating_and_corrections_are_reversals(nb):
nb.invoice("A", 7311, "4500.00", issued_on="2026-11-01")
nb.invoice("B", 7311, "300.00", issued_on="2026-11-02")
with on_day("2026-12-03T09:00:00+00:00"):
sent = must(api("POST", "/statements", COLLECTOR, {"bp_id": 7311, "as_at": "2026-11-30"}))
assert sent["closing_balance"] == {"GBP": "4800.00"}
refused(api("POST", "/receipts", CASHIER, {"bp_id": 7311, "amount": "10.00", "currency": "GBP", "received_on": "2026-12-04"}),
409, "Received on 2026-12-04, which is after today (2026-12-03, Europe/London)")
refused(api("POST", "/customers/7311/credit-limit", APPROVER, {"amount": "9000.00", "currency": "GBP", "valid_from": "2026-12-03"}),
409, "takes effect from tomorrow (2026-12-04) at the earliest")
r = receipt(amount="300.00", reference="w", received_on="2026-12-02")
must(allocate(r["payment_id"], [("B", "300.00")]))
undone = must(api("POST", f"/receipts/{r['payment_id']}/unallocate", CASHIER, {"invoice_id": "B", "seq": 1, "reason": "wrong invoice"}))
assert undone["reversal"]["allocated_on"] == "2026-12-03" and undone["reversal"]["amount"] == "-300.00"
again = must(api("POST", "/statements", COLLECTOR, {"bp_id": 7311, "as_at": "2026-11-30"}))
assert again["closing_balance"] == {"GBP": "4800.00"} and again["sent"][0]["matches_recorded"] is True
# ── PAY-12 / SC-PAY-06: credit holds ─────────────────────────────────────────────────────────────
def test_PAY_12_a_customer_61_days_overdue_is_on_hold_with_the_reason(nb):
nb.invoice("OLD", 7311, "100.00", due_date="2026-09-20") # 61 days
got = must(api("GET", "/customers/7311/credit-status", SALES))
assert got["hold"] is True and got["hold_source"] == "rule" and "61 days overdue" in got["reason"] and got["hold_mode"] == "warn"
gate = must(api("GET", "/customers/7311/credit-ok", SALES))
assert gate["gate"] == "credit_ok" and gate["met"] is False and "credit hold" in gate["reason"] and gate["fix"] and gate["hold_mode"] == "warn"
nb.invoice("FINE", 7312, "100.00", due_date="2026-09-21") # 60 days: the rule holds at 60 too (>=)
assert must(api("GET", "/customers/7312/credit-status", SALES))["hold"] is True
nb.invoice("OK", 7313, "100.00", due_date="2026-09-22") # 59 days
ok = must(api("GET", "/customers/7313/credit-ok", SALES))
assert ok["met"] is True and ok["reason"] == ""
def test_PAY_12_the_hold_clears_when_the_invoice_is_paid_and_over_the_limit_also_holds(nb):
nb.invoice("OLD", 7311, "100.00", due_date="2026-09-01")
r = receipt(amount="100.00", reference="p")
must(allocate(r["payment_id"], [("OLD", "100.00")]))
assert must(api("GET", "/customers/7311/credit-status", SALES))["hold"] is False
nb.invoice("BIG", 7311, "30000.00", due_date="2026-12-31")
must(api("POST", "/customers/7311/credit-limit", APPROVER, {"amount": "20000.00", "currency": "GBP", "valid_from": "2026-11-21"}))
assert must(api("GET", "/customers/7311/credit-status", SALES))["hold"] is False # the limit starts tomorrow
with on_day("2026-11-21T09:00:00+00:00"):
got = must(api("GET", "/customers/7311/credit-status", SALES))
assert got["hold"] is True and "over the credit limit 20,000.00" in got["reason"] and got["credit_limit"]["amount"] == "20000.00"
must(api("POST", "/settings/ai_draft_reminder", APPROVER, {"value": "off"}))
set_control("credit_hold_over_limit", False)
with on_day("2026-11-21T09:00:00+00:00"):
assert must(api("GET", "/customers/7311/credit-status", SALES))["hold"] is False
def test_PAY_12_a_new_limit_takes_effect_from_tomorrow_and_the_past_stays_answerable(nb):
for start in ("2026-11-20", "2026-11-19"):
refused(api("POST", "/customers/7311/credit-limit", APPROVER, {"amount": "5000.00", "currency": "GBP", "valid_from": start}), 409,
"takes effect from tomorrow (2026-11-21) at the earliest")
first = must(api("POST", "/customers/7311/credit-limit", APPROVER, {"amount": "5000.00", "currency": "GBP"}))
assert first["valid_from"] == "2026-11-21" and first["limit_today"] is None
must(api("POST", "/customers/7311/credit-limit", APPROVER, {"amount": "8000.00", "currency": "GBP", "valid_from": "2026-12-01"}))
refused(api("POST", "/customers/7311/credit-limit", APPROVER, {"amount": "9000.00", "currency": "GBP", "valid_from": "2026-12-01"}), 409, "never edited")
hist = must(api("GET", "/customers/7311/credit", APPROVER))["limits"]
assert [(h["valid_from"], h["amount"]) for h in hist] == [("2026-11-21", "5000.00"), ("2026-12-01", "8000.00")]
with on_day("2026-11-25T09:00:00+00:00"):
assert must(api("GET", "/customers/7311/credit-status", SALES))["credit_limit"]["amount"] == "5000.00"
with on_day("2026-12-02T09:00:00+00:00"):
assert must(api("GET", "/customers/7311/credit-status", SALES))["credit_limit"]["amount"] == "8000.00"
def test_PAY_12_an_early_release_is_the_owners_same_day_act_and_a_manual_hold_holds(nb):
nb.invoice("OLD", 7311, "100.00", due_date="2026-09-01")
refused(api("POST", "/customers/7311/credit-override", APPROVER, {"kind": "release", "ends_on": "2026-11-20", "reason": "r"}), 409,
"is today or earlier")
got = must(api("POST", "/customers/7311/credit-override", APPROVER, {"kind": "release", "ends_on": "2026-12-20", "reason": "paid by cheque"}))
assert got["effective_from"].startswith("2026-11-20") and got["credit_status"]["hold"] is False # same day
assert got["credit_status"]["hold_source"] == "released" and "paid by cheque" in got["credit_status"]["reason"]
with on_day("2026-12-21T09:00:00+00:00"): # the release ended: the rule holds again
assert must(api("GET", "/customers/7311/credit-status", SALES))["hold"] is True
nb.invoice("FINE", 7312, "10.00", due_date="2026-12-30")
held = must(api("POST", "/customers/7312/credit-override", APPROVER, {"kind": "hold", "ends_on": "2026-12-01", "reason": "fraud query"}))
assert held["credit_status"]["hold"] is True and held["credit_status"]["hold_source"] == "manual"
def test_SC_PAY_06_credit_status_reaches_sales_order_as_a_gate(nb):
nb.invoice("OLD", 7311, "100.00", due_date="2026-09-19") # 62 days
set_control("credit_hold_mode", "block")
gate = must(api("GET", "/customers/7311/credit-ok", SALES))
assert gate["met"] is False and gate["hold_mode"] == "block" and "the order is refused" in gate["reason"]
def test_PAY_12_credit_status_shows_a_balance_net_of_unallocated_cash_per_currency(nb):
nb.invoice("A", 7311, "1000.00", due_date="2026-12-01")
nb.invoice("E", 7311, "50.00", currency="EUR", due_date="2026-12-01")
receipt(amount="400.00", reference="cash")
got = must(api("GET", "/customers/7311/credit-status", SALES))
assert got["balance"] == {"EUR": "50.00", "GBP": "600.00"} and got["unallocated_credit"] == {"GBP": "400.00"}
# ── PAY-13: the controls are the owner's ─────────────────────────────────────────────────────────
def test_PAY_13_the_cashier_cannot_widen_their_own_tolerance_the_owner_can(nb):
cashier_admin = __import__("conftest").person("cash@shop.example", ["pay:settings:write", "pay:receipt:read"])
set_control("match_tolerance", "2.00")
body = refused(api("POST", "/settings/match_tolerance", cashier_admin, {"value": "50.00"}), 403, "pay:settings:controls")
assert "set_pay_control" in json.dumps(body)
assert api("POST", "/controls/match_tolerance", cashier_admin, {"value": "50.00"})[0] == 403
assert [s["value"] for s in must(api("GET", "/settings", CASHIER))["settings"] if s["setting_key"] == "match_tolerance"] == ["2.00"]
must(api("POST", "/controls/match_tolerance", APPROVER, {"value": "50.00"}))
hist = must(api("GET", "/settings-history", CASHIER, {"setting_key": "match_tolerance"}))["history"]
assert [(h["from"], h["to"], h["changed_by"]) for h in hist] == [(None, "2.00", "approver@shop.example"), ("2.00", "50.00", "approver@shop.example")]
assert hist[1]["changed_at"].startswith("2026-11-20")
assert must(api("POST", "/controls/match_tolerance", APPROVER, {"value": "50.00"}))["unchanged"] is True # again: nothing
def test_PAY_13_a_change_applies_to_allocations_made_from_now_not_before(nb):
nb.invoice("A", 7311, "100.00")
nb.invoice("B", 7311, "100.00")
r1, r2 = receipt(amount="95.00", reference="a"), receipt(amount="95.00", reference="b")
assert must(allocate(r1["payment_id"], [("A", "95.00")]))["write_off_proposals"] == []
set_control("match_tolerance", "10.00")
assert nb.writeoffs == [] # nothing already made is touched
assert len(must(allocate(r2["payment_id"], [("B", "95.00")]))["write_off_proposals"]) == 1
def test_PAY_13_each_control_is_refused_to_settings_write_and_a_day_to_day_setting_is_not(nb):
cashier_admin = __import__("conftest").person("cash@shop.example", ["pay:settings:write"])
for key, value in (("match_tolerance", "1.00"), ("credit_hold_days_overdue", 30), ("credit_hold_over_limit", False),
("credit_hold_mode", "block"), ("business_timezone", "UTC"), ("statement_import_from", "2026-01-01"),
("ai_suggest_match", "auto-with-review")):
assert api("POST", f"/settings/{key}", cashier_admin, {"value": value})[0] == 403, key
assert api("POST", "/settings/ai_suggest_match", cashier_admin, {"value": "off"})[0] == 200
assert api("POST", "/settings/aging_buckets", cashier_admin, {"value": "0-10,11+"})[0] == 200
refused(api("POST", "/controls/aging_buckets", APPROVER, {"value": "0-10,11+"}), 422, "not a control")
refused(api("POST", "/settings/match_tolerance_x", APPROVER, {"value": 1}), 404, "no setting")
refused(api("POST", "/controls/match_tolerance", APPROVER, {"value": "-1.00"}), 422, "zero or more")
refused(api("POST", "/controls/business_timezone", APPROVER, {"value": "Mars/Base"}), 422, "IANA")
# ── PAY-32 / PAY-14 / SC-PAY-01 / SC-PAY-08: the AI suggests, the cashier decides ────────────────
def test_PAY_32_a_receipt_gets_a_suggestion_with_evidence_and_allocates_nothing(nb):
nb.invoice("I41", 7311, "4000.00", number="INV-2026-0041", due_date="2026-11-10")
nb.invoice("I42", 7311, "3500.00", number="INV-2026-0042", due_date="2026-11-12")
r = receipt(amount="7500.00", reference="INV-2026-0041 + INV-2026-0042")
sug = r["suggestion"]
assert [(ln["invoice_id"], ln["amount"]) for ln in sug["proposed_lines"]] == [("I41", "4000.00"), ("I42", "3500.00")]
assert sug["confidence"] == "0.97" and sug["model"] == "baseline-reference-v1" and sug["exact"] is True and sug["status"] == "open"
assert sug["suggestion_id"] == f"{r['payment_id']}#1"
assert [h["reference_text"] for h in sug["evidence"]["named_by_reference"]] == ["INV-2026-0041", "INV-2026-0042"] and sug["evidence"]["exact_sum"] is True
assert r["unallocated"] == "7500.00" and settle("I41")["I41"]["outstanding"] == "4000.00" # nothing allocated until a person decides
def test_PAY_32_asking_again_changes_nothing_and_a_new_situation_supersedes_the_open_one(nb):
nb.invoice("I41", 7311, "4000.00", number="INV-2026-0041")
r = receipt(amount="4000.00", reference="INV41")
again = must(api("POST", f"/receipts/{r['payment_id']}/suggest-match", SYSTEM, {}))
assert again["unchanged"] is True and len(must(api("GET", f"/receipts/{r['payment_id']}", CASHIER))["suggestion"]["proposed_lines"]) == 1
nb.invoice("I99", 7311, "4000.00", number="INV-2026-0099", due_date="2026-10-01")
nb.invoices["I41"]["total"] = "3000.00" # the invoice changed under it
new = must(api("POST", f"/receipts/{r['payment_id']}/suggest-match", SYSTEM, {}))
assert new["suggestion"]["suggestion_id"].endswith("#2")
assert must(api("GET", f"/receipts/{r['payment_id']}", CASHIER))["suggestion"]["suggestion_id"] == new["suggestion"]["suggestion_id"] # only the newest is open
def test_PAY_14_accept_change_and_reject_each_leave_their_mark(nb):
nb.invoice("I41", 7311, "4000.00", number="INV-2026-0041")
nb.invoice("I42", 7311, "3500.00", number="INV-2026-0042")
nb.invoice("J1", 7312, "100.00", number="INV-2026-0100")
nb.invoice("J2", 7312, "200.00", number="INV-2026-0200")
nb.invoice("K1", 7313, "50.00", number="INV-2026-0500")
a = receipt(amount="7500.00", reference="INV41 INV42")
got = must(api("POST", f"/receipts/{a['payment_id']}/decide-match", CASHIER, {"decision": "accept"}))
assert got["decision"] == "accepted" and {x["decided_how"] for x in got["allocations"]} == {"accepted"}
assert {x["suggestion_id"] for x in got["allocations"]} == {a["suggestion"]["suggestion_id"]}
assert all(x["allocated_by"] == "cashier@shop.example" for x in got["allocations"])
one = must(api("GET", f"/receipts/{a['payment_id']}", CASHIER))
assert one["suggestion"] is None and one["unallocated"] == "0.00"
# changed: the suggestion proposed J1+J2 (300) but the cashier chooses J1 only
b = receipt(bp_id=7312, amount="300.00", reference="INV-2026-0100 0200")
changed = must(api("POST", f"/receipts/{b['payment_id']}/decide-match", CASHIER,
{"decision": "change", "lines": [{"invoice_id": "J1", "amount": "100.00"}]}))
assert changed["decision"] == "changed" and [x["decided_how"] for x in changed["allocations"]] == ["changed"]
refused(api("POST", f"/receipts/{b['payment_id']}/decide-match", CASHIER, {"decision": "accept", "suggestion_id": f"{b['payment_id']}#1"}), 409, "already changed")
# rejected: nothing allocated, still unallocated
c = receipt(bp_id=7313, amount="50.00", reference="INV-2026-0500")
rej = must(api("POST", f"/receipts/{c['payment_id']}/decide-match", CASHIER, {"decision": "reject"}))
assert rej["decision"] == "rejected" and rej["receipt"]["unallocated"] == "50.00" and rej["receipt"]["allocations"] == []
def test_PAY_14_the_changed_suggestion_keeps_what_was_proposed_and_what_was_chosen(nb):
nb.invoice("J1", 7312, "100.00", number="INV-2026-0100")
nb.invoice("J2", 7312, "200.00", number="INV-2026-0200")
b = receipt(bp_id=7312, amount="300.00", reference="INV-2026-0100 0200")
must(api("POST", f"/receipts/{b['payment_id']}/decide-match", CASHIER, {"decision": "change", "lines": [{"invoice_id": "J2", "amount": "200.00"}]}))
from brick_payments.application import use_cases
with use_cases.repository() as repo:
s = repo.suggestions.get(f"{b['payment_id']}#1")
assert s["status"] == "changed" and s["decided_by"] == "cashier@shop.example"
assert [ln["invoice_id"] for ln in s["proposed_lines"]] == ["J1", "J2"] and s["decided_lines"] == [{"invoice_id": "J2", "amount": "200.00"}]
def test_PAY_14_a_stale_suggestion_cannot_be_accepted(nb):
nb.invoice("I41", 7311, "4000.00", number="INV-2026-0041")
r = receipt(amount="4000.00", reference="INV41")
other = receipt(amount="4000.00", reference="elsewhere")
must(allocate(other["payment_id"], [("I41", "4000.00")])) # the invoice was settled meanwhile
refused(api("POST", f"/receipts/{r['payment_id']}/decide-match", CASHIER, {"decision": "accept"}), 409,
"Since the suggestion, I41 was settled or the receipt allocated elsewhere")
def test_SC_PAY_01_one_transfer_three_invoices(nb):
three_invoices(nb)
r = receipt(amount="10000.00", reference="INV-2026-0041 0042")
assert sorted(ln["invoice_id"] for ln in r["suggestion"]["proposed_lines"]) == ["INV-2026-0041", "INV-2026-0042"] # the match is suggested
must(api("POST", f"/receipts/{r['payment_id']}/decide-match", CASHIER, {"decision": "accept"})) # confirmed
must(allocate(r["payment_id"], [("INV-2026-0043", "2500.00")])) # the remainder to the third
s = settle("INV-2026-0041", "INV-2026-0042", "INV-2026-0043")
assert [v["outstanding"] for v in s.values()] == ["0.00", "0.00", "500.00"]
assert must(api("GET", f"/receipts/{r['payment_id']}", CASHIER))["unallocated"] == "0.00"
def test_SC_PAY_08_the_ai_suggests_the_cashier_decides_and_auto_is_the_owners_switch(nb):
nb.invoice("I41", 7311, "100.00", number="INV-2026-0041")
nb.invoice("I42", 7311, "200.00", number="INV-2026-0042")
nb.invoice("I43", 7311, "300.00", number="INV-2026-0043")
a, b, c = (receipt(amount=x, reference=f"INV{n}") for x, n in (("100.00", 41), ("200.00", 42), ("300.00", 43)))
must(api("POST", f"/receipts/{a['payment_id']}/decide-match", CASHIER, {"decision": "accept"}))
must(api("POST", f"/receipts/{b['payment_id']}/decide-match", CASHIER, {"decision": "change", "lines": [{"invoice_id": "I42", "amount": "150.00"}]}))
must(api("POST", f"/receipts/{c['payment_id']}/decide-match", CASHIER, {"decision": "reject"}))
got = {r["payment_id"]: r for r in must(api("GET", "/receipts", CASHIER))["receipts"]}
assert [x["decided_how"] for x in got[a["payment_id"]]["allocations"]] == ["accepted"]
assert [x["decided_how"] for x in got[b["payment_id"]]["allocations"]] == ["changed"]
assert got[c["payment_id"]]["allocations"] == [] and got[c["payment_id"]]["unallocated"] == "300.00"
assert api("POST", "/settings/ai_suggest_match", CASHIER, {"value": "auto-with-review"})[0] == 403 # the cashier's switch is refused
assert must(api("POST", "/controls/ai_suggest_match", APPROVER, {"value": "auto-with-review"}))["value"] == "auto-with-review"
assert must(api("GET", "/settings-history", CASHIER, {"setting_key": "ai_suggest_match"}))["history"][0]["to"] == "auto-with-review"
def test_PAY_14_auto_with_review_allocates_only_an_exact_match_and_lists_it_for_a_person(nb):
set_control("ai_suggest_match", "auto-with-review")
nb.invoice("I41", 7311, "4000.00", number="INV-2026-0041")
nb.invoice("I42", 7311, "3500.00", number="INV-2026-0042")
exact = receipt(amount="7500.00", reference="INV-2026-0041 + INV-2026-0042")
assert exact["unallocated"] == "0.00" and exact["suggestion_result"]["auto_allocated"] is not None
one = must(api("GET", f"/receipts/{exact['payment_id']}", CASHIER))
assert {a["decided_how"] for a in one["allocations"]} == {"auto"} and {a["allocated_by"] for a in one["allocations"]} == {"system:ai_suggest_match"}
listed = must(api("GET", "/receipts/auto-matches", CASHIER))["auto_matches"]
assert len(listed) == 2 and all(x["to_review"] and x["confidence"] == "0.97" for x in listed)
must(api("POST", f"/receipts/{exact['payment_id']}/confirm-auto-match", CASHIER, {}))
assert not any(x["to_review"] for x in must(api("GET", "/receipts/auto-matches", CASHIER))["auto_matches"])
# a partial match (the receipt is less than the named invoices) stays a suggestion
nb.invoice("P1", 7312, "1000.00", number="INV-2026-0900")
part = receipt(bp_id=7312, amount="600.00", reference="INV-2026-0900")
assert part["unallocated"] == "600.00" and part["suggestion"]["exact"] is False and part["suggestion"]["status"] == "open"
# undoing an automatic allocation is PAY-06
must(api("POST", f"/receipts/{exact['payment_id']}/unallocate", CASHIER, {"invoice_id": "I41", "seq": 1, "reason": "not theirs"}))
assert settle("I41")["I41"]["outstanding"] == "4000.00"
def test_PAY_14_with_the_step_off_there_is_no_suggestion(nb):
nb.invoice("I41", 7311, "4000.00", number="INV-2026-0041")
must(api("POST", "/settings/ai_suggest_match", APPROVER, {"value": "off"}))
r = receipt(amount="4000.00", reference="INV41")
assert r["suggestion"] is None and r["suggestion_result"]["suggested"] is False
# ── access ───────────────────────────────────────────────────────────────────────────────────────
def test_a_cashier_records_and_allocates_but_cannot_approve_a_refund(nb):
nb.invoice("A", 7311, "10.00")
r = receipt(amount="10.00", reference="x")
assert api("POST", f"/receipts/{r['payment_id']}/allocate", CASHIER, {"lines": [{"invoice_id": "A", "amount": "10.00"}]})[0] == 200
assert api("POST", "/refunds/anything/approve", CASHIER, {})[0] == 403
def test_sales_reads_credit_status_only_never_receipts(nb):
nb.invoice("A", 7311, "10.00")
receipt(amount="10.00", reference="x")
assert api("GET", "/customers/7311/credit-status", SALES)[0] == 200
for path in ("/receipts", "/customers/7311/open-items", "/collections/overdue", "/refunds", "/settings", "/settlement?invoice_ids=A"):
assert api("GET", path.split("?")[0], SALES, {"invoice_ids": "A"} if "settlement" in path else None)[0] == 403, path
body = json.dumps(must(api("GET", "/customers/7311/credit-status", SALES)))
assert "payment_id" not in body and "bank_reference" not in body
def test_no_wildcard_grant_reaches_a_machine_permission_and_a_person_cannot_hold_one(nb):
from conftest import person
star = person("star@shop.example", ["pay:*:*"])
everything = person("all@shop.example", ["*:*:*"])
for who in (star, everything):
assert api("POST", "/collections/tick", who, {})[0] == 403
assert api("POST", "/allocations/from-source", who, {"source_kind": "writeoff", "source_id": "W", "invoice_id": "I", "amount": "1.00"})[0] == 403
assert api("POST", "/receipts/ingest", who, {})[0] in (403, 422)
assert api("POST", "/migration/odoo/run", who, {})[0] in (403, 404, 422)
assert api("GET", "/receipts", star)[0] == 200 # the wildcard still reaches a person's reads
assert api("POST", "/refunds/x/approve", APPROVER2, {})[0] == 404 # the approver is a person: they reach person routes
assert api("POST", "/collections/tick", APPROVER, {})[0] == 403 # ...and not the machine's tick
def test_nobody_with_no_permission_is_refused_everywhere(nb):
from conftest import NOBODY
body = {"bp_id": 7311, "amount": "1.00", "currency": "GBP", "received_on": "2026-11-20"}
assert api("GET", "/receipts", NOBODY)[0] == 403 and api("POST", "/receipts", NOBODY, body)[0] == 403
assert api("GET", "/receipts", {})[0] == 401