dodil/erp-brick-payments

DODIL ERP brick: payments

tests/test_v4_slice_c.py

"""Payments v4, slice C - PAY-33 (the realised FX difference), PAY-34 (a statement balances and is reconciled to the ledger), PAY-35 (the
open-banking feed), PAY-36 (a payment request), PAY-42 (the ledger posting), PAY-45 (CIS), PAY-46 (the reads tax and ar make), PAY-47 (the
default receiving account), PAY-48 (supplier payments). Through the real app on SQLite; the neighbours are tests/fakes.py, each call checked
against its callee's signed spec."""

from decimal import Decimal  # noqa: F401
from unittest import mock  # noqa: F401

from conftest import (  # noqa: F401
    APPROVER,
    APPROVER2,
    CASHIER,
    NOBODY,
    SALES,
    SYSTEM,
    allocate,
    bank,
    must,
    on_day,
    receipt,
    refused,
    settle,
)
from conftest import api as _api
from test_v4_slice_b import D, Snapshot, journals, table  # noqa: F401

from brick_payments.infrastructure import tables as t

api = _api


def fx_pair(nb, inv_rate="0.8500", rcp_rate="0.8600", amount="1000.00", currency="EUR"):
    bank()
    nb.invoice("INV-EUR-1", 7311, amount, currency=currency, fx_rate=inv_rate)
    r = receipt(amount=amount, currency=currency, fx_rate=rcp_rate, bank_account_id="BANK1", reference="EUR-1")
    return r["payment_id"]


# ═══ PAY-33 / PAY-42: the realised FX difference ═════════════════════════════════════════════════════════════

def test_pay33_a_receipt_at_a_better_rate_posts_the_gain_once_and_the_undo_reverses_it(nb):
    pid = fx_pair(nb)
    assert journals(nb, "receipt")[0]["fx_rate"] == "0.8600"                           # the receipt posts at its own rate (PAY-42)
    got = must(allocate(pid, [("INV-EUR-1", "1000.00")]))
    assert got["fx"][0]["posted"] and got["fx"][0]["direction"] == "gain" and got["fx"][0]["amount"] == "10.00"
    j = nb.journals[f"fx:alloc:{pid}:INV-EUR-1:1"]
    assert (j["currency"], j["journal_date"], j["source_document"]) == ("GBP", "2026-11-20", f"alloc:{pid}:INV-EUR-1:1")
    assert j["lines"][0]["debit"] == "10.00" and j["lines"][0]["purpose"] == "receivable" and j["lines"][0]["against_document"] == "INV-EUR-1"
    assert j["lines"][1] == {"purpose": "fx_gain", "credit": "10.00"}
    assert settle("INV-EUR-1")["INV-EUR-1"]["outstanding"] == "0.00"
    undone = must(api("POST", f"/receipts/{pid}/unallocate", CASHIER, {"invoice_id": "INV-EUR-1", "seq": 1, "reason": "wrong invoice"}))
    assert undone["fx"][0]["event"] == "fx_reversal"
    back = nb.journals[f"fx_reversal:alloc:{pid}:INV-EUR-1:2"]
    assert back["lines"][0] == {"purpose": "fx_gain", "debit": "10.00"} and back["lines"][1]["credit"] == "10.00"
    again = must(allocate(pid, [("INV-EUR-1", "1000.00")]))
    assert again["fx"][0]["amount"] == "10.00" and f"fx:alloc:{pid}:INV-EUR-1:3" in nb.journals
    rows = {(r["seq"], r["event"]) for r in table(t.pay_fx_posting)}
    assert rows == {(1, "fx"), (2, "fx_reversal"), (3, "fx")}


def test_pay33_a_loss_posts_the_other_way_and_a_same_rate_allocation_posts_nothing(nb):
    pid = fx_pair(nb, inv_rate="0.8600", rcp_rate="0.8500")
    must(allocate(pid, [("INV-EUR-1", "400.00")]))
    j = nb.journals[f"fx:alloc:{pid}:INV-EUR-1:1"]
    assert j["lines"][0] == {"purpose": "fx_loss", "debit": "4.00"} and j["lines"][1]["credit"] == "4.00"
    nb2 = nb
    nb2.invoice("INV-SAME", 7311, "100.00", currency="EUR", fx_rate="0.8500")
    r2 = receipt(amount="100.00", currency="EUR", fx_rate="0.8500", bank_account_id="BANK1", reference="EUR-2")["payment_id"]
    out = must(allocate(r2, [("INV-SAME", "100.00")]))
    assert "fx" not in out and not [j for j in journals(nb, "fx") if r2 in j["source_document"]]
    nb.invoice("INV-NORATE", 7311, "100.00")                                               # no rate on the invoice: nothing to compare
    r3 = receipt(amount="100.00", currency="GBP", bank_account_id="BANK1", reference="GBP-3")["payment_id"]
    assert "fx" not in must(allocate(r3, [("INV-NORATE", "100.00")]))


def test_pay33_a_returned_payment_gives_its_fx_difference_back_and_a_refusing_ledger_refuses_the_allocation(nb):
    pid = fx_pair(nb)
    must(allocate(pid, [("INV-EUR-1", "1000.00")]))
    must(api("POST", f"/receipts/{pid}/reverse", CASHIER, {"bank_return_ref": "RTN-1"}))
    assert any(j["source_document"].startswith(f"alloc:{pid}:INV-EUR-1") for j in journals(nb, "fx_reversal"))
    nb.invoice("INV-EUR-2", 7311, "50.00", currency="EUR", fx_rate="0.8000")
    p2 = receipt(amount="50.00", currency="EUR", fx_rate="0.8600", bank_account_id="BANK1", reference="EUR-9")["payment_id"]
    nb.companies.pop("UK01")                                                               # the ledger no longer knows the company
    status, _ = allocate(p2, [("INV-EUR-2", "50.00")])
    assert status in (404, 409, 502, 503)
    assert settle("INV-EUR-2")["INV-EUR-2"]["outstanding"] == "50.00"                      # nothing was allocated


# ═══ PAY-36: a receipt against a down-payment request ════════════════════════════════════════════════════════

def request(nb, rid="DPR-2026-0004", total="6000.00", bp=7311):
    nb.requests[rid] = {"remaining": D(total), "bp_id": bp, "currency": "GBP", "n": 0}
    return rid


def test_pay36_a_receipt_settles_the_request_into_a_deposit_invoice_and_is_allocated_to_it_once(nb):
    rid = request(nb)
    pid = receipt(amount="6000.00", reference="DEP-1")["payment_id"]
    got = must(api("POST", f"/receipts/{pid}/allocate-to-request", CASHIER, {"request_id": rid, "amount": "6000.00"}))
    assert got["deposit_invoice_id"] == f"dep:{rid}:1" and got["allocated"] == "6000.00" and got["request_status"] == "settled"
    assert settle(got["deposit_invoice_id"])[got["deposit_invoice_id"]]["outstanding"] == "0.00"
    assert [a["invoice_id"] for a in table(t.pay_allocation)] == [got["deposit_invoice_id"]]           # the request itself carries no allocation
    again = must(api("POST", f"/receipts/{pid}/allocate-to-request", CASHIER, {"request_id": rid, "amount": "6000.00"}))
    assert again["replay"] is True and len(table(t.pay_allocation)) == 1


def test_pay36_a_part_payment_settles_a_part_and_more_than_the_request_is_refused_by_invoicing(nb):
    rid = request(nb)
    p1 = receipt(amount="2500.00", reference="DEP-2")["payment_id"]
    got = must(api("POST", f"/receipts/{p1}/allocate-to-request", CASHIER, {"request_id": rid, "amount": "2500.00"}))
    assert got["request_status"] == "issued" and got["request_remaining"] == "3500.00"
    p2 = receipt(amount="9000.00", reference="DEP-3")["payment_id"]
    refused(api("POST", f"/receipts/{p2}/allocate-to-request", CASHIER, {"request_id": rid, "amount": "9000.00"}), 422, "more than the 3500.00 left")
    refused(api("POST", f"/receipts/{p2}/allocate-to-request", CASHIER, {"request_id": "DPR-NONE", "amount": "10.00"}), 404)
    rid2 = request(nb, "DPR-2026-0005", "500.00")
    refused(api("POST", f"/receipts/{p1}/allocate-to-request", CASHIER, {"request_id": rid2, "amount": "100.00"}), 409, "left")
    assert len(table(t.pay_allocation)) == 1


# ═══ PAY-45: the CIS deduction ═══════════════════════════════════════════════════════════════════════════════

def cis_case(nb):
    nb.invoice("INV-LAB-1", 7311, "2000.00")
    pid = receipt(amount="1600.00", reference="CIS-1")["payment_id"]
    must(allocate(pid, [("INV-LAB-1", "1600.00")]))
    return pid


CIS = {"invoice_id": "INV-LAB-1", "statement_ref": "CIS-2026-12-7311", "tax_month": "2026-12-05", "gross": "2000.00", "materials": "0.00", "deduction": "400.00"}


def test_pay45_tax_accepts_then_the_deduction_is_allocated_and_the_invoice_is_paid_and_a_repeat_adds_nothing(nb):
    pid = cis_case(nb)
    got = must(api("POST", f"/receipts/{pid}/cis-deduction", CASHIER, CIS))
    assert got["allocated"] == "400.00" and nb.cis_calls[0]["deduction"] == "400.00" and nb.cis_calls[0]["payment_id"] == pid
    assert settle("INV-LAB-1")["INV-LAB-1"]["outstanding"] == "0.00"
    rows = [a for a in table(t.pay_allocation) if a["source_kind"] == "cis_deduction"]
    assert [(a["source_id"], a["amount"]) for a in rows] == [("CIS-2026-12-7311", D("400.00"))]
    again = must(api("POST", f"/receipts/{pid}/cis-deduction", CASHIER, CIS))
    assert again["replay"] is True and len(nb.cis_calls) == 1 and len(table(t.pay_allocation)) == 2


def test_pay45_tax_refusing_leaves_the_deduction_outstanding_with_its_reason(nb):
    nb.invoice("INV-LAB-1", 7311, "2000.00")
    pid = receipt(amount="1000.00", reference="CIS-1")["payment_id"]
    must(allocate(pid, [("INV-LAB-1", "1000.00")]))
    body = refused(api("POST", f"/receipts/{pid}/cis-deduction", CASHIER, {**CIS, "deduction": "700.00", "gross": "2000.00"}), 422, "more than 30% of the labour")
    assert "stays outstanding" in str(body)
    assert settle("INV-LAB-1")["INV-LAB-1"]["outstanding"] == "1000.00"
    assert [a for a in table(t.pay_allocation) if a["source_kind"] == "cis_deduction"] == []
    refused(api("POST", f"/receipts/{pid}/cis-deduction", CASHIER, {**CIS, "deduction": "1500.00", "gross": "9000.00"}), 409, "outstanding")
    nb.invoice("INV-OTHER", 8001, "100.00")
    refused(api("POST", f"/receipts/{pid}/cis-deduction", CASHIER, {**CIS, "invoice_id": "INV-OTHER", "deduction": "10.00"}), 409, "belongs to customer")


# ═══ PAY-46 / PAY-47: what tax and invoicing read ════════════════════════════════════════════════════════════

def test_pay46_the_allocation_reads_carry_received_on_and_include_reversing_rows(nb):
    three_inv = nb.invoice("INV-A", 7311, "500.00")
    pid = receipt(amount="500.00", received_on="2026-10-30", reference="CB-1")["payment_id"]
    must(allocate(pid, [(three_inv, "500.00")]))
    must(api("POST", f"/receipts/{pid}/unallocate", CASHIER, {"invoice_id": three_inv, "seq": 1, "reason": "oops"}))
    nov = must(api("GET", "/cash-basis", SYSTEM, {"from": "2026-11-01", "to": "2026-11-30"}))["allocations"]
    assert [(a["seq"], a["amount"], a["received_on"], a["allocated_on"]) for a in nov] == [
        (1, "500.00", "2026-10-30", "2026-11-20"), (2, "-500.00", "2026-10-30", "2026-11-20")]      # an earlier receipt, allocated in the window
    assert must(api("GET", "/cash-basis", SYSTEM, {"from": "2026-12-01", "to": "2026-12-31"}))["allocations"] == []
    refused(api("GET", "/cash-basis", SYSTEM, {"from": "2026-12-01", "to": "2026-11-01"}), 422)
    rows = must(api("GET", "/allocations", SYSTEM, {"invoice_ids": "INV-A,INV-NONE", "from": "2026-11-20"}))["allocations"]
    assert [(a["source_kind"], a["seq"], a["reverses_seq"], a["received_on"]) for a in rows] == [("receipt", 1, None, "2026-10-30"), ("receipt", 2, 1, "2026-10-30")]
    assert must(api("GET", "/allocations", SYSTEM, {"invoice_ids": "INV-A", "from": "2026-11-21"}))["allocations"] == []
    refused(api("GET", "/allocations", SYSTEM, {"invoice_ids": ",".join(f"I{i}" for i in range(201))}), 422)
    assert api("GET", "/allocations", NOBODY, {"invoice_ids": "INV-A"})[0] in (403, 404)


def test_pay47_the_default_receiving_account_in_full_and_a_404_naming_the_fix(nb):
    bank()
    must(api("POST", "/default-bank-accounts", APPROVER, {"company_code": "UK01", "currency": "GBP", "bank_account_id": "BANK1", "valid_from": "2026-11-21"}))
    status, body = api("GET", "/bank-accounts/default", SYSTEM, {"company_code": "UK01", "currency": "GBP"})
    assert status == 404 and "receiving_account" in body["detail"] and "Payments > Settings > Bank accounts" in body["detail"]      # from tomorrow
    with on_day("2026-11-21T09:00:00+00:00"):
        got = must(api("GET", "/bank-accounts/default", SYSTEM, {"company_code": "UK01", "currency": "GBP"}))
    assert got["account_number"] == "12345678" and got["bank_account_id"] == "BANK1" and "sort_code" in got and "iban" in got and "bic" in got
    assert api("GET", "/bank-accounts/default", SALES, {"company_code": "UK01", "currency": "GBP"})[0] in (403, 404)


def test_pay45_two_writers_of_one_statement_collide_instead_of_allocating_twice(nb):
    pid = cis_case(nb)
    must(api("POST", f"/receipts/{pid}/cis-deduction", CASHIER, CIS))
    with mock.patch.object(t.AllocationRepository, "for_source", lambda self, kind, sid: []), \
            mock.patch.object(t.AllocationRepository, "for_invoices", lambda self, ids: []):          # the second writer read before the first committed
        status, body = api("POST", f"/receipts/{pid}/cis-deduction", CASHIER, CIS)
    assert status == 409 and "Someone else wrote" in str(body)
    assert len([a for a in table(t.pay_allocation) if a["source_kind"] == "cis_deduction"]) == 1


def test_slice_c_use_cases_are_agent_tools_too(nb):
    from conftest import tool, tools
    names = tools(SYSTEM)
    assert {"allocate_to_payment_request", "record_cis_deduction", "cash_basis", "list_allocations", "default_receiving_account"} <= set(names)
    rid = request(nb)
    pid = receipt(amount="100.00", reference="T-1")["payment_id"]
    kind, got = tool(CASHIER, "allocate_to_payment_request", {"payment_id": pid, "request_id": rid, "amount": "100.00"})
    assert kind == "ok" and got["deposit_invoice_id"].startswith("dep:")
    kind, got = tool(SYSTEM, "cash_basis", {"from": "2026-11-01", "to": "2026-11-30"})
    assert kind == "ok" and len(got["allocations"]) == 1


# ═══ PAY-34: a statement balances, follows the previous one, and is reconciled to the ledger ═════════════════

def stmt(entries, sid, opening, closing, **kw):
    from test_v4_statements import camt, imp
    return imp(camt(entries, stmt_id=sid, opening=opening, closing=closing), **kw)


def test_pay34_a_statement_that_does_not_add_up_is_refused_naming_the_difference_and_writes_nothing(nb):
    from test_v4_statements import camt, e, lines
    bank()
    body = camt([e("C1", "CRDT", "50.00")], stmt_id="BAD", opening="100.00", closing="200.00")
    got = refused(api("POST", "/statements/import", CASHIER, {"format": "camt053", "content": body, "bank_account_id": "BANK1"}), 409, "statement_balances")
    assert "50.00" in got["detail"] and "missing from the lines" in got["detail"]
    assert lines() == {} and table(t.pay_bank_statement) == [] and table(t.pay_receipt) == []


def test_pay34_the_opening_must_be_the_previous_closing_or_the_gap_is_named_and_the_statement_stays_imported(nb):
    from test_v4_statements import e
    bank()
    first = stmt([e("A1", "CRDT", "1000.00", day="2026-10-20")], "OCT", "51400.10", "52400.10")
    assert first["statement_lifecycle"] == "balanced"
    ok = stmt([e("A2", "CRDT", "8635.67", day="2026-11-10")], "NOV", "52400.10", "61035.77")
    assert ok["statement_lifecycle"] == "balanced" and "continuity" not in ok
    gap = stmt([e("A3", "CRDT", "10.00", day="2026-12-10")], "DEC", "61000.00", "61010.00")
    assert gap["statement_lifecycle"] == "imported" and gap["continuity"]["gate"] == "statement_continuous" and gap["continuity"]["gap"] == "-35.77"
    body = refused(api("POST", "/statements/BANK1:DEC/reconcile", CASHIER), 409, "statement_continuous")
    assert "61,035.77" in body["detail"] and "missing" in body["detail"]
    assert [s["lifecycle"] for s in table(t.pay_bank_statement) if s["statement_id"] == "BANK1:DEC"] == ["imported"]


def reconciled_setup(nb):
    from test_v4_statements import e
    bank()
    nb.book_opening["1200"] = D("1000.00")
    stmt([e("C1", "CRDT", "250.00", name="Acme Ltd"), e("D1", "DBIT", "40.00", name="Bank", text="CHARGE")], "NOV", "1000.00", "1210.00")
    return "/statements/BANK1:NOV"


def test_pay34_reconciled_only_when_every_line_is_resolved_and_the_ledger_equals_the_closing_balance(nb):
    base = reconciled_setup(nb)
    refused(api("POST", f"{base}/reconcile", CASHIER), 409, "lines_resolved")                          # the debit is still open
    must(api("POST", "/statement-lines/bank:BANK1:D1/post", CASHIER, {"account_id": "7900", "tax_code": "E"}))
    nb.book_opening["1200"] += D("5.00")                                                                 # the books carry something the bank does not
    nb.invoice("X", 7311, "1.00")
    body = refused(api("POST", f"{base}/reconcile", CASHIER), 409, "book_equals_bank")
    assert "5.00 more in the books" in body["detail"] and "Unreconciled items" in body["detail"]
    shown = must(api("GET", f"{base}/reconciliation", CASHIER))
    assert (shown["book_balance"], shown["difference"], shown["lifecycle"]) == ("1215.00", "5.00", "balanced")
    nb.book_opening["1200"] -= D("5.00")
    done = must(api("POST", f"{base}/reconcile", CASHIER))
    assert done["lifecycle"] == "reconciled" and done["book_balance"] == "1210.00" and done["replay"] is False
    assert must(api("POST", f"{base}/reconcile", CASHIER))["replay"] is True
    refused(api("POST", "/statement-lines/bank:BANK1:D1/unmatch", CASHIER, {"reason": "late change"}), 409, "reconciled")


def test_pay34_a_reconcile_and_an_unmatch_of_the_same_statement_take_one_guard_key(nb):
    base = reconciled_setup(nb)
    must(api("POST", "/statement-lines/bank:BANK1:D1/post", CASHIER, {"account_id": "7900", "tax_code": "E"}))
    before = Snapshot()
    must(api("POST", f"{base}/reconcile", CASHIER))
    real = t.BankRepository.statement
    with before, mock.patch.object(t.BankRepository, "statement", lambda self, i: {**real(self, i), "lifecycle": "balanced"}):
        status, body = api("POST", "/statement-lines/bank:BANK1:D1/unmatch", CASHIER, {"reason": "raced"})
    assert status == 409 and "Someone else wrote" in str(body)
    assert [s["lifecycle"] for s in table(t.pay_bank_statement)] == ["reconciled"]
    assert next(ln for ln in table(t.pay_statement_line) if ln["line_id"] == "bank:BANK1:D1")["lifecycle"] == "posted"


def test_pay34_without_a_ledger_balance_the_statement_cannot_be_reconciled(nb):
    base = reconciled_setup(nb)
    must(api("POST", "/statement-lines/bank:BANK1:D1/post", CASHIER, {"account_id": "7900", "tax_code": "E"}))
    nb.book_opening.pop("1200")
    nb.journals.clear()                                                                                  # the ledger no longer knows the account
    body = refused(api("POST", f"{base}/reconcile", CASHIER), 409, "book_equals_bank")
    assert "does not know account 1200" in body["detail"]


# ═══ PAY-35: the open-banking feed ═══════════════════════════════════════════════════════════════════════════

def feed(lines, as_of="2026-11-20", balance="1100.00", who=None):
    return api("POST", "/statements/ingest", who or SYSTEM, {"bank_account_id": "BANK1", "as_of": as_of, "booked_balance": balance, "lines": lines})


def tx(i, amount, direction="credit", day="2026-11-20", name="Acme Ltd"):
    return {"transaction_id": i, "booked_on": day, "amount": amount, "direction": direction, "counterparty_name": name, "remittance": f"ref {i}"}


def test_pay35_the_feed_needs_the_owners_consent_and_lines_arrive_once_by_the_banks_transaction_id(nb):
    bank()
    refused(feed([tx("T1", "100.00")]), 409, "feed_consent")
    must(api("POST", "/bank-accounts/BANK1/feed-consent", APPROVER, {"provider": "truelayer"}))
    got = must(feed([tx("T1", "100.00"), tx("T1", "100.00"), tx("T2", "30.00", "debit")], balance="1070.00"))
    assert (got["lines_created"], got["created"], got["debits_kept"]) == (2, 1, 1) and got["consent"]["days_left"] == 90
    assert got["statement_lifecycle"] == "balanced"                                                      # opening = booked balance less what was delivered
    again = must(feed([tx("T1", "100.00"), tx("T2", "30.00", "debit"), tx("T3", "5.00")], balance="1075.00"))
    assert (again["lines_created"], again["skipped_duplicate"]) == (1, 2)                                 # a later delivery repeats two: one line each
    assert sorted(ln["line_id"] for ln in table(t.pay_statement_line)) == ["bank:BANK1:T1", "bank:BANK1:T2", "bank:BANK1:T3"]
    assert len(table(t.pay_receipt)) == 2 and {r["method"] for r in table(t.pay_receipt)} == {"bank_statement"}
    refused(feed([tx("T9", "1.00")], who=CASHIER), 403)                                                  # a machine permission: a person cannot post a feed


def test_pay35_the_consent_is_90_days_warns_7_days_ahead_and_ends(nb):
    bank()
    with on_day("2026-08-29T09:00:00+00:00"):
        must(api("POST", "/bank-accounts/BANK1/feed-consent", APPROVER, {"provider": "truelayer"}))      # expires 2026-11-27
    got = must(feed([tx("T1", "100.00")]))
    assert got["consent"]["days_left"] == 7 and got["consent"]["expires_soon"] and "ask the owner to renew" in got["consent"]["warning"]
    listed = must(api("GET", "/feed-consents", APPROVER))
    assert [c["bank_account_id"] for c in listed["expiring"]] == ["BANK1"]
    with on_day("2026-11-28T09:00:00+00:00"):
        refused(feed([tx("T2", "1.00")], as_of="2026-11-28"), 409, "ended on 2026-11-27")
    refused(api("POST", "/bank-accounts/BANK1/feed-consent", APPROVER, {"provider": "x", "days": 91}), 422)
    refused(api("POST", "/bank-accounts/BANK1/feed-consent", APPROVER, {"provider": "x", "granted_on": "2026-12-01"}), 409, "future")


# ═══ PAY-48: a statement debit matched to purchasing's supplier payment ══════════════════════════════════════

def debit_line(nb, ref="D1", amount="4200.00"):
    from test_v4_statements import camt as _camt
    from test_v4_statements import e, imp
    bank()
    imp(_camt([e(ref, "DBIT", amount, name="Acme Supplies", text="PAY-RUN-0045-03")]))
    return f"/statement-lines/bank:BANK1:{ref}"


def test_pay48_a_debit_is_matched_to_the_sent_payment_and_confirms_it_with_the_banks_reference_and_date(nb):
    line = debit_line(nb)
    nb.supplier_payment("SP-118", "4200.00")
    nb.supplier_payment("SP-119", "4200.00", reference="OTHER")
    cands = must(api("GET", f"{line}/supplier-payment-candidates", CASHIER))["candidates"]
    assert sorted(c["payment_id"] for c in cands) == ["SP-118", "SP-119"]
    got = must(api("POST", f"{line}/match", CASHIER, {"kind": "supplier_payment", "ref": "SP-118"}))
    assert (got["lifecycle"], got["matched_kind"], got["journal_id"]) == ("matched", "supplier_payment", "")               # purchasing posts; payments posts nothing
    assert nb.confirmations == [{"payment_id": "SP-118", "bank_ref": "D1", "paid_on": "2026-11-18"}]
    assert not journals(nb, "bank_line")
    refused(api("POST", "/statement-lines/bank:BANK1:D1/match", CASHIER, {"kind": "supplier_payment", "ref": "SP-119"}), 409, "already")
    must(api("POST", f"{line}/unmatch", CASHIER, {"reason": "wrong payment"}))
    assert nb.supplier_payments["SP-118"]["status"] == "confirmed"                                       # payments never unconfirms purchasing's payment
    must(api("POST", f"{line}/match", CASHIER, {"kind": "supplier_payment", "ref": "SP-118"}))          # matched again: no second confirmation
    assert len(nb.confirmations) == 1


def test_pay48_a_payment_the_clerk_confirmed_is_matched_without_a_second_confirmation_and_a_wrong_amount_or_account_is_refused(nb):
    line = debit_line(nb)
    nb.supplier_payment("SP-1", "4200.00", status="confirmed")
    must(api("POST", f"{line}/match", CASHIER, {"kind": "supplier_payment", "ref": "SP-1"}))
    assert nb.confirm_calls == []                                                                        # not even asked
    line2 = debit_line(nb, "D2", "100.00")
    nb.supplier_payment("SP-2", "4200.00")
    refused(api("POST", f"{line2}/match", CASHIER, {"kind": "supplier_payment", "ref": "SP-2"}), 409, "same amount")
    nb.supplier_payment("SP-3", "100.00", bank_account_id="BANK2")
    refused(api("POST", f"{line2}/match", CASHIER, {"kind": "supplier_payment", "ref": "SP-3"}), 404, "no payment SP-3")


def test_pay48_a_rejected_payment_is_matched_to_the_credit_that_returned_it(nb):
    from test_v4_statements import camt, e, imp
    line = debit_line(nb)
    nb.supplier_payment("SP-9", "4200.00", status="rejected")
    refused(api("POST", f"{line}/match", CASHIER, {"kind": "supplier_payment", "ref": "SP-9"}), 409, "was rejected")
    with on_day("2026-11-18T09:00:00+00:00"):                       # a rule names the bank's "RETURNED" credits, so they are not customer receipts
        must(api("POST", "/reconciliation-rules/RET", APPROVER, {"direction": "credit", "text_contains": "RETURNED", "account_id": "7000",
                                                                  "tax_code": "E", "mode": "suggest"}))
    imp(camt([e("R1", "CRDT", "4200.00", name="Acme Supplies", text="RETURNED PAYMENT")]))
    got = must(api("POST", "/statement-lines/bank:BANK1:R1/match", CASHIER, {"kind": "supplier_payment", "ref": "SP-9:returned"}))
    assert got["matched_ref"] == "SP-9:returned" and nb.confirmations == []
    nb.supplier_payment("SP-8", "4200.00")
    imp(camt([e("R2", "CRDT", "4200.00", name="Acme Supplies", text="RETURNED PAYMENT 2")]))
    refused(api("POST", "/statement-lines/bank:BANK1:R2/match", CASHIER, {"kind": "supplier_payment", "ref": "SP-8:returned"}), 409, "not rejected")


def test_pay48_two_lines_cannot_hold_one_payment_and_a_refused_confirmation_leaves_the_line_open(nb):
    from test_v4_statements import camt, e, imp

    from brick_payments.infrastructure import seams
    line = debit_line(nb)
    nb.supplier_payment("SP-5", "4200.00")
    imp(camt([e("D7", "DBIT", "4200.00", name="Acme Supplies", text="again")]))
    must(api("POST", f"{line}/match", CASHIER, {"kind": "supplier_payment", "ref": "SP-5"}))
    refused(api("POST", "/statement-lines/bank:BANK1:D7/match", CASHIER, {"kind": "supplier_payment", "ref": "SP-5"}), 409, "already matched")
    assert len(nb.confirmations) == 1
    nb.supplier_payment("SP-6", "4200.00")
    with mock.patch.object(seams, "confirm_supplier_payment", lambda *a, **k: {"ok": False, "status": 409, "gate": "payment_open",
                                                                              "reason": "cancelled by the clerk", "fix": "Check the run"}):
        body = refused(api("POST", "/statement-lines/bank:BANK1:D7/match", CASHIER, {"kind": "supplier_payment", "ref": "SP-6"}), 409, "payment_open")
    assert "cancelled by the clerk" in body["detail"]
    assert next(ln for ln in table(t.pay_statement_line) if ln["line_id"] == "bank:BANK1:D7")["lifecycle"] == "open"