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