dodil/erp-brick-payments

DODIL ERP brick: payments

tests/test_audit_musts.py

"""Audit of payments v4 (2026-10-23): each MUST as a test that FAILS on the current code for the stated reason. No fix in src/."""
import pathlib
import re

import pytest
from conftest import APPROVER, CASHIER, SYSTEM, api, bank, must, on_day, person, refused
from test_v4_directdebit import PREP, active_mandates, setup
from test_v4_directdebit import run as dd_run
from test_v4_slice_b import D, day_of_cash, entry, journals, table
from test_v4_slice_c import feed, tx

from brick_payments.infrastructure import tables as t

AR_SRC = pathlib.Path(__file__).resolve().parents[2] / "erp-brick-ar" / "src" / "brick_ar" / "application" / "use_cases.py"


# ── M1: the seam - ar's REAL GET /invoice-totals never returns the fields payments reads (PAY-33 rate, PAY-16 discount) ──────────

@pytest.mark.skipif(not AR_SRC.exists(), reason="erp-brick-ar is not checked out beside payments")
def test_M1_ar_invoice_totals_returns_the_invoice_rate_and_the_settlement_discount_payments_reads():
    """payments' seams._invoice_doc reads fx_rate, settlement_discount_pct and settlement_discount_until from /invoice-totals (and the
    signed spec says ar v4 returns them). tests/fakes.py returns them; ar's real route builds its row WITHOUT them, so against the real
    ar every FX difference is 0 (difference() returns ZERO on a None rate) and no settlement-discount window is ever seen."""
    src = AR_SRC.read_text()
    body = src[src.index('route="GET /invoice-totals"'):]
    body = body[:body.index("@AR.use_case", 10)]
    row = re.search(r"out\[iid\] = \{(.*?)\}", body, re.S).group(1)
    missing = [k for k in ("fx_rate", "settlement_discount_pct", "settlement_discount_until") if f'"{k}"' not in row]
    assert missing == [], f"ar's invoice_totals row lacks {missing}: payments' FX (PAY-33) and discount (PAY-16) silently see None"


# ── M2: the feed - a second delivery on the same day adds a line to the statement but not the new booked balance ──────────────────

def test_M2_a_second_feed_delivery_the_same_day_moves_the_statement_closing_to_the_new_booked_balance(nb):
    bank()
    must(api("POST", "/bank-accounts/BANK1/feed-consent", APPROVER, {"provider": "truelayer"}))
    must(feed([tx("T1", "100.00")], balance="1100.00"))
    must(feed([tx("T1", "100.00"), tx("T3", "5.00")], balance="1105.00"))                     # same as_of -> same statement FEED-2026-11-20
    st = next(s for s in table(t.pay_bank_statement) if s["statement_id"].endswith("FEED-2026-11-20"))
    total = sum(D(str(ln["amount"])) for ln in table(t.pay_statement_line) if ln["statement_id"] == st["statement_id"])
    # the statement now holds T1 + T3 (105.00) on an opening of 1000.00, yet keeps the first delivery's closing 1100.00 and stays
    # "balanced": opening + lines != closing, and the reconcile compares the ledger (1105.00) with 1100.00 for ever
    assert D(str(st["opening_balance"])) + total == D(str(st["closing_balance"])), (st["opening_balance"], total, st["closing_balance"])


# ── M3: the feed - a later day's delivery that repeats a transaction already delivered names a continuity gap that is not there ──

def test_M3_a_repeated_transaction_in_the_next_days_delivery_is_not_a_continuity_gap(nb):
    bank()
    must(api("POST", "/bank-accounts/BANK1/feed-consent", APPROVER, {"provider": "truelayer"}))
    must(feed([tx("T1", "100.00")], balance="1100.00"))                                           # closing 1100.00
    with on_day("2026-11-21T09:00:00+00:00"):
        got = must(feed([tx("T1", "100.00"), tx("T2", "50.00", day="2026-11-21")], as_of="2026-11-21", balance="1150.00"))
    # opening = 1150.00 - (100 + 50) = 1000.00, though T1 was already in yesterday's 1100.00: the repeat (kept as one line, per PAY-35)
    # is subtracted again, so a complete feed is named a -100.00 gap and the statement can never be reconciled
    assert "continuity" not in got and got["statement_lifecycle"] == "balanced", got.get("continuity")


# ── M4: cash - an entry after the approval changes the difference the approver accepted, and the day closes on the new one ────────

def test_M4_an_entry_after_the_approval_withdraws_it_like_a_new_count_does(nb):
    day_of_cash(nb)                                                                               # opening 200.00
    must(api("POST", "/cash-journals/PETTY-UK01/count", CASHIER, {"counted_amount": "190.00"}))  # 10.00 short: above the threshold
    must(api("POST", "/cash-journals/PETTY-UK01/approve-difference", APPROVER, {"reason": "10 short, checked"}))
    must(entry("expense", "500.00", tax_code="E", account_id="6100"))                            # expected now -300: the count says 190
    # count_cash withdraws the approval on a new figure; record_cash_entry does not, so a 490.00 over is closed on a 10.00 short's approval
    refused(api("POST", "/cash-journals/PETTY-UK01/close-day", CASHIER), 409, "approver")
    assert not journals(nb, "cash_difference")


# ── M5: direct debit - a mandate cancelled after the run was approved is still collected in the submitted Bacs file ──────────────

def test_M5_a_mandate_cancelled_after_approval_is_not_in_the_submitted_file(nb):
    setup(nb, (7311, 7312))
    active_mandates((7311, 7312))
    with on_day(PREP):
        dd_run()
        must(api("POST", "/dd-runs/dd:BARC-01:2026-12-01/approve", APPROVER))
        must(api("POST", "/dd-runs/dd:BARC-01:2026-12-01/notices", CASHIER))
        must(api("POST", "/dd-mandates/DOD-7312-01/cancel", CASHIER, {"reason": "customer cancelled the instruction"}))
        must(api("POST", "/dd-runs/dd:BARC-01:2026-12-01/submit", CASHIER, {"submission_ref": "BACS-77"}))
    recs = [r for r in must(api("GET", "/dd-runs/dd:BARC-01:2026-12-01/file", CASHIER))["content"].split("\n")
            if r and not r.startswith(("UHL1", "UTL1"))]
    collected = [r for r in recs if r[15:17] in ("01", "17", "18", "19")]
    # submit takes no mandate claim for the collections and never re-reads their mandates: the cancelled payer is debited (and 0C'd
    # in the same file), while the collect step later skips it, so the bank's credit has no receipt
    assert len(collected) == 1, [r[15:17] for r in recs]


# ── PAY-27: a role NAMED like the permission is not the permission ─────────────────────────────────────────────────────────────

def test_PAY27_a_role_named_like_the_permission_does_not_read_unmasked(nb):
    must(api("POST", "/house-banks/BARC", APPROVER, {"name": "Barclays", "sort_code": "20-00-00", "sun": "123456", "bic": "BARCGB22"}))
    bank("BARC-01", gl="1200", account_number="31926819", purposes="receive,pay", house_bank_id="BARC", name="Dodil Trading Ltd")
    who = person("w@x", [], roles=["pay:bank_account:read_unmasked"])
    assert api("GET", "/bank-accounts/BARC-01/account-details", who)[0] == 403
    assert "31926819" not in str(must(api("GET", "/bank-accounts", SYSTEM)))


# ── M6: PAY-36 - invoicing settles the request BEFORE payments checks the receipt's customer: a refused allocation leaves it settled ──

def test_M6_a_receipt_of_another_customer_is_refused_before_invoicing_settles_the_request(nb):
    from conftest import receipt
    from test_v4_slice_c import request
    rid = request(nb, bp=7311)
    pid = receipt(bp_id=7312, amount="6000.00", reference="DEP-X")["payment_id"]
    refused(api("POST", f"/receipts/{pid}/allocate-to-request", CASHIER, {"request_id": rid, "amount": "6000.00"}), 409, "belongs to customer")
    # sources.allocate_to_payment_request calls ar's settle (which keys on payment_id and never sees the payer) and only THEN
    # _allocate_core refuses the customer mismatch: ar has raised and issued a 6000.00 deposit invoice, the request reads settled, no
    # money is allocated, and every retry replays ar and is refused again - the two ledgers disagree for good
    assert nb.requests[rid]["remaining"] == D("6000.00") and not nb.settled