dodil/erp-brick-expenses

DODIL ERP brick: expenses

tests/test_cards.py

"""Slice B item 2: company cards - the feed (EXP-15), matching and personal spend (EXP-06), the journal's card liability (EXP-12), recovery by
netting or repayment (EXP-13, EXP-14) and the leaver's cards (EXP-19). Each test is written from the story's Given/When/Then (module.yaml)."""
from decimal import Decimal

from conftest import (
    EMPLOYEE,
    FINANCE,
    FINANCE2,
    HR_CLIENT,
    MANAGER,
    PAYMENTS_CLIENT,
    SYSTEM,
    add,
    api,
    approve,
    must,
    new_report,
    refused,
    report,
    seed_card,
    submit,
)
from erp_platform import seam

from brick_expenses.application import use_cases

HEADER = "card_id,ref,posted_on,merchant,amount,currency,original_amount,original_currency"


def feed(*rows, header=HEADER):
    return "\n".join([header, *rows])


def card(card_id="4417", holder=3301, who=FINANCE):
    return must(api("POST", "/cards", who, {"card_id": card_id, "holder_person_id": str(holder), "provider": "Visa", "currency": "GBP"}), 201)


def import_feed(content, total, who=FINANCE, ref="2026-11"):
    return api("POST", "/card-feeds", who, {"statement_ref": ref, "total": total, "content": content})


def txns(who=EMPLOYEE, **kw):
    return must(api("GET", "/card-txns", who, kw))["card_txns"]


def claim_with_hotel_on_card(hotel_amount="264.00"):
    """EXP-12's claim: train 86.40 and dinner 96.00 and a 105.00 trip on own money; the Manchester hotel on card 4417 with VAT 44.00."""
    rid = new_report()["report_id"]
    add(rid, category_code="TRAVEL_RAIL", merchant="Northern Rail", amount="86.40")
    add(rid, category_code="ACCOMMODATION", merchant="Manchester Hotel", amount=hotel_amount, vat_amount="44.00", expense_date="2026-11-05", paid_with="card")
    add(rid, category_code="MEALS_CLIENT", merchant="The Ivy", amount="96.00", vat_amount="16.00")
    add(rid, category_code="TRAVEL_RAIL", merchant="Trains to Leeds", amount="105.00")
    return rid


# ── EXP-15: the feed ────────────────────────────────────────────────────────────────────────────

def test_EXP_15_a_feed_is_imported_per_card_to_its_holder_and_importing_it_again_imports_none(nb):
    nb.employee(5520, "holder5520@uk.example")
    card("4417", 3301)
    card("5520", 5520)
    rows = [f"4417,TX-{k:03},2026-11-0{1 + k % 7},Shop {k},10.00,GBP" for k in range(23)] + [f"5520,TX-{k:03},2026-11-0{1 + k % 7},Shop {k},{k + 1}.50,GBP" for k in range(23)]
    total = Decimal("230.00") + sum(Decimal(f"{k + 1}.50") for k in range(23))
    got = must(import_feed(feed(*rows), str(total)), 201)
    assert (got["transactions"], got["imported"], got["skipped"], got["total"]) == (46, 46, 0, f"{total:.2f}")
    mine = txns(EMPLOYEE)
    assert len(mine) == 23 and {t["card_id"] for t in mine} == {"4417"} and mine[0]["card_txn_id"].startswith("card:4417:TX-")      # each to its holder
    again = must(import_feed(feed(*rows), str(total)), 201)
    assert (again["imported"], again["skipped"]) == (0, 46) and len(txns(EMPLOYEE)) == 23


def test_EXP_15_a_file_whose_total_is_not_its_lines_imports_nothing():
    card()
    body = refused(import_feed(feed("4417,A,2026-11-01,Shop,10.00,GBP", "4417,B,2026-11-02,Shop,5.00,GBP"), "16.00"), 422, "add up to 15.00")
    assert body["gate"] == "feed_total" and txns() == []


def test_EXP_15_a_card_nobody_holds_is_refused_with_the_card_named_and_nothing_is_imported():
    card()
    body = refused(import_feed(feed("4417,A,2026-11-01,Shop,10.00,GBP", "9999,B,2026-11-02,Shop,5.00,GBP"), "15.00"), 409, "9999")
    assert body["gate"] == "card_holder" and txns() == []


def test_EXP_15_a_file_not_in_the_documented_shape_is_a_422_never_a_500():
    card()
    for content, words in ((feed(), "no transactions"), ("", "at least 1"), ("a,b,c\n1,2,3", "header"),
                           (feed("4417,A,2026-13-45,Shop,10.00,GBP"), "a date"), (feed("4417,A,2026-11-01,Shop,-10.00,GBP"), "more than zero"),
                           (feed("4417,A,2026-11-01,Shop,1e30,GBP"), "more than zero"), (feed("4417,A,2026-11-01,Shop,1.001,GBP"), "two decimal"),
                           (feed("4417,A,2026-11-01,Shop,1.00,POUNDS"), "three letters"), (feed("4417,A,2026-11-01,Shop"), "columns"),
                           (feed("4417,A,2026-11-01,Shop,1.00,GBP", "4417,A,2026-11-02,Shop,1.00,GBP"), "twice"),
                           (feed("4417,A,2026-11-01,Shop,1.00,GBP,5.00,"), "go together")):
        refused(import_feed(content, "1.00"), 422, words)
    refused(import_feed(feed("4417,A,2026-11-01,Shop,1.00,GBP"), "0"), 422)
    big = feed(*[f"4417,T{k},2026-11-01,Shop,1.00,GBP" for k in range(5001)])
    refused(import_feed(big, "5001.00"), 422, "at most 5000")


def test_EXP_15_a_feed_of_five_thousand_transactions_imports_in_one_request():
    card()
    rows = [f"4417,T{k:05},2026-11-01,Shop,1.00,GBP" for k in range(5000)]
    got = must(import_feed(feed(*rows), "5000.00"), 201)
    assert got["imported"] == 5000


def test_EXP_15_importing_is_finances_or_the_systems_and_a_card_needs_an_employee_holder(nb):
    card()
    refused(import_feed(feed("4417,A,2026-11-01,Shop,1.00,GBP"), "1.00", EMPLOYEE), 403, "exp:card_feed:import")
    must(import_feed(feed("4417,A,2026-11-01,Shop,1.00,GBP"), "1.00", SYSTEM), 201)                 # the worker's own client may ingest too
    refused(api("POST", "/cards", FINANCE, {"card_id": "4417", "holder_person_id": "3301"}), 409, "already registered")
    refused(api("POST", "/cards", FINANCE, {"card_id": "7777", "holder_person_id": "1"}), 409, "employed")
    refused(api("POST", "/cards", FINANCE, {"card_id": "a:b", "holder_person_id": "3301"}), 422, "colon")
    refused(api("POST", "/cards", EMPLOYEE, {"card_id": "8888", "holder_person_id": "3301"}), 403, "exp:card:write")
    nb.left_on[2280] = "2026-01-01"
    refused(api("POST", "/cards", FINANCE, {"card_id": "8889", "holder_person_id": "2280"}), 409, "employed")


def test_a_card_feed_in_a_foreign_billed_currency_keeps_the_original_amount():
    card()
    must(import_feed(feed("4417,PARIS-1,2026-11-03,Paris Bistro,56.12,GBP,64.00,EUR"), "56.12"), 201)
    (t,) = txns()
    assert (t["billed_amount"], t["billed_currency"], t["original_amount"], t["original_currency"]) == ("56.12", "GBP", "64.00", "EUR")


# ── EXP-06: match and personal ─────────────────────────────────────────────────────────────────

def seeded_period():
    """EXP-06's given: card 4417 (3301), 5 transactions, one 264.00 from the Manchester hotel on 11-05, one 18.00 at a cinema on 11-06."""
    seed_card("4417", 3301, [("TX-88213", "2026-11-06", "Odeon Cinema", Decimal("18.00")), ("TX-88200", "2026-11-05", "Manchester Hotel", Decimal("264.00")),
                             ("TX-88201", "2026-11-01", "Costa", Decimal("4.20")), ("TX-88202", "2026-11-02", "Tesco", Decimal("12.00")),
                             ("TX-88203", "2026-11-03", "Shell", Decimal("40.00"))])


def test_EXP_06_the_hotel_transaction_is_suggested_matched_to_the_line_and_the_cinema_marked_personal(nb):
    seeded_period()
    rid = claim_with_hotel_on_card()
    sug = must(api("GET", f"/reports/{rid}/card-suggestions", EMPLOYEE))["suggestions"]
    assert [(s["card_txn_id"], s["line_no"]) for s in sug] == [("card:4417:TX-88200", 2)]               # same amount, within 3 days, merchant alike
    got = must(api("POST", "/card-txns/card:4417:TX-88200/match", EMPLOYEE, {"report_id": rid, "line_no": 2}))
    line = got["lines"][1]
    assert (line["paid_with"], line["card_txn_id"], line["amount"]) == ("card", "card:4417:TX-88200", "264.00")
    cinema = must(api("POST", "/card-txns/card:4417:TX-88213/personal", EMPLOYEE))
    assert cinema["status"] == "personal"
    status = {t["card_txn_id"]: t["status"] for t in txns()}
    assert status["card:4417:TX-88200"] == "matched" and status["card:4417:TX-88213"] == "personal"
    assert [t["card_txn_id"] for t in txns(status="imported")] == ["card:4417:TX-88201", "card:4417:TX-88202", "card:4417:TX-88203"]    # what still waits
    assert [t["matched_line_no"] for t in txns() if t["status"] == "matched"] == [2]


def test_EXP_06_a_line_for_a_card_with_no_matched_transaction_stops_submission(nb):
    rid = claim_with_hotel_on_card()
    body = refused(api("POST", f"/reports/{rid}/submit", EMPLOYEE), 409, "card_resolved")
    assert "line 2 (paid with the card, no transaction matched)" in next(g for g in body["gates"] if g["gate"] == "card_resolved")["reason"]


def test_EXP_06_a_transaction_matches_one_line_only_and_a_line_one_transaction(nb):
    seeded_period()
    seed_card("4418", 3301, [("TX-2", "2026-11-05", "Manchester Hotel", Decimal("264.00"))])
    rid = claim_with_hotel_on_card()
    other = add(rid, category_code="ACCOMMODATION", merchant="Manchester Hotel", amount="264.00", expense_date="2026-11-05", paid_with="card")["lines"][-1]["line_no"]
    must(api("POST", "/card-txns/card:4417:TX-88200/match", EMPLOYEE, {"report_id": rid, "line_no": 2}))
    refused(api("POST", "/card-txns/card:4417:TX-88200/match", EMPLOYEE, {"report_id": rid, "line_no": other}), 409, "matched")      # the transaction is taken
    refused(api("POST", "/card-txns/card:4418:TX-2/match", EMPLOYEE, {"report_id": rid, "line_no": 2}), 409, "already matched")     # the line is taken
    must(api("POST", "/card-txns/card:4418:TX-2/match", EMPLOYEE, {"report_id": rid, "line_no": other}))


def test_EXP_06_two_racing_matches_of_one_transaction_one_wins_through_the_claim_row(nb):
    seeded_period()
    rid = claim_with_hotel_on_card()
    other = add(rid, category_code="ACCOMMODATION", merchant="Manchester Hotel", amount="264.00", expense_date="2026-11-05", paid_with="card")["lines"][-1]["line_no"]
    with use_cases.repository() as repo:                              # the racer that got there first took (txn, match) and has not saved its status yet
        repo.cards.claim("card:4417:TX-88200", "match", use_cases._now(), "racer", report_id=rid)
    refused(api("POST", "/card-txns/card:4417:TX-88200/match", EMPLOYEE, {"report_id": rid, "line_no": other}), 409, "a moment ago")
    assert report(rid)["lines"][other - 1]["card_txn_id"] is None                                       # the loser changed nothing


def test_EXP_06_only_a_card_in_the_claims_currency_matches_and_only_the_holders_own_transaction_is_theirs(nb):
    seed_card("4417", 3301, [("TX-USD", "2026-11-05", "Hotel", Decimal("100.00"))])
    seed_card("9999", 2280, [("TX-M", "2026-11-05", "Hotel", Decimal("264.00"))])
    with use_cases.repository() as repo:
        t = repo.cards.txn("card:4417:TX-USD")
        repo.cards.save_txn({**t, "billed_currency": "USD"}, use_cases._now())
    rid = claim_with_hotel_on_card()
    refused(api("POST", "/card-txns/card:4417:TX-USD/match", EMPLOYEE, {"report_id": rid, "line_no": 2}), 409, "same currency billed")
    refused(api("POST", "/card-txns/card:9999:TX-M/match", EMPLOYEE, {"report_id": rid, "line_no": 2}), 404, "no card transaction")        # another holder's
    refused(api("POST", "/card-txns/card:9999:TX-M/personal", EMPLOYEE), 404)
    assert txns(MANAGER)[0]["card_txn_id"] == "card:9999:TX-M" and len(txns(MANAGER)) == 1               # a holder sees only their own card
    refused(api("POST", "/card-txns/card:4417:TX-USD/match", MANAGER, {"report_id": rid, "line_no": 2}), 404)           # nor another person's claim
    refused(api("GET", "/card-txns", EMPLOYEE, {"scope": "all"}), 403, "exp:card_txn:resolve")


def test_a_matched_line_cannot_be_removed_or_given_another_amount_and_its_claim_cannot_be_cancelled(nb):
    seeded_period()
    rid = claim_with_hotel_on_card()
    must(api("POST", "/card-txns/card:4417:TX-88200/match", EMPLOYEE, {"report_id": rid, "line_no": 2}))
    refused(api("DELETE", f"/reports/{rid}/lines/2", EMPLOYEE), 409, "match is final")
    refused(api("POST", f"/reports/{rid}/lines/2", EMPLOYEE, {"amount": "10.00"}), 409, "billed amount")
    refused(api("POST", f"/reports/{rid}/cancel", EMPLOYEE), 409, "cannot be cancelled")
    must(api("POST", f"/reports/{rid}/lines/2", EMPLOYEE, {"description": "two nights"}))


def test_a_personal_mark_posts_one_recovery_journal_and_a_second_mark_is_a_409(nb):
    seeded_period()
    must(api("POST", "/card-txns/card:4417:TX-88213/personal", EMPLOYEE))
    (j,) = nb.journals.values()
    assert (j["source_module"], j["event"], j["source_document"], j["journal_date"], j["journal_id"]) == (
        "expenses", "personal", "card:4417:TX-88213", "2026-11-07", str(seam.derived_id("card:4417:TX-88213", "personal")))
    assert j["lines"] == [{"purpose": "employee_receivable", "bp_id": "73301", "debit": "18.00"}, {"purpose": "card_liability", "memo": "card 4417", "credit": "18.00"}]
    refused(api("POST", "/card-txns/card:4417:TX-88213/personal", EMPLOYEE), 409, "needs it to be imported")
    must(api("POST", "/outbox/drain", SYSTEM))
    assert nb.journal_posts == 1                                                                           # sent once; the drain finds nothing pending


def test_a_recovery_journal_the_ledger_refused_waits_pending_and_is_sent_by_the_next_drain(nb):
    seeded_period()
    nb.closed_through = "2026-11-30"
    must(api("POST", "/card-txns/card:4417:TX-88213/personal", EMPLOYEE))
    assert nb.journals == {}
    nb.closed_through = None
    out = must(api("POST", "/outbox/drain", SYSTEM))
    assert [s["status"] for s in out["sent"]] == ["posted"] and len(nb.journals) == 1


# ── EXP-12: the journal carries the card's liability ───────────────────────────────────────────────

def test_EXP_12_a_claim_with_a_card_line_posts_the_cost_and_credits_the_employee_and_the_card(nb):
    seeded_period()
    rid = claim_with_hotel_on_card()
    must(api("POST", "/card-txns/card:4417:TX-88200/match", EMPLOYEE, {"report_id": rid, "line_no": 2}))
    must(api("POST", "/card-txns/card:4417:TX-88213/personal", EMPLOYEE))
    submit(rid)
    must(approve(rid, MANAGER))
    got = must(approve(rid, FINANCE))
    assert got["status"] == "posted" and got["reimbursable_amount"] == "287.40" and got["total_amount"] == "551.40"
    j = next(x for x in nb.journals.values() if x["event"] == "approve")
    assert j["lines"][-2:] == [{"purpose": "employee_payable", "bp_id": "73301", "credit": "287.40"},
                               {"purpose": "card_liability", "memo": "card 4417", "credit": "264.00"}]
    assert sum(Decimal(x.get("debit", 0)) for x in j["lines"]) == sum(Decimal(x.get("credit", 0)) for x in j["lines"]) == Decimal("551.40")


# ── EXP-13 / EXP-14: personal spend recovered once ─────────────────────────────────────────────────────

def _resolved_period():
    """Mark every other card transaction of the period matched, so only the personal one is in play."""
    for ref in ("TX-88201", "TX-88202", "TX-88203"):
        with use_cases.repository() as repo:
            t = repo.cards.txn(f"card:4417:{ref}")
            repo.cards.save_txn({**t, "status": "matched"}, use_cases._now())


def test_EXP_13_the_payout_nets_the_personal_spend_and_the_recovery_has_its_own_claim_row(nb):
    seeded_period()
    rid = claim_with_hotel_on_card()
    must(api("POST", "/card-txns/card:4417:TX-88200/match", EMPLOYEE, {"report_id": rid, "line_no": 2}))
    must(api("POST", "/card-txns/card:4417:TX-88213/personal", EMPLOYEE))                                      # 18.00 owed
    _resolved_period()                                                                                         # the rest of the period is resolved too
    submit(rid)
    must(approve(rid, MANAGER))
    got = must(approve(rid, FINANCE))
    assert got["reimbursable_amount"] == "287.40" and got["netted_amount"] == "18.00" and got["payout_amount"] == "269.40"
    (p,) = nb.payouts.values()
    assert p["amount"] == "269.40" and p["payout_ref"] == f"exp:{rid}:r1:pay"
    with use_cases.repository() as repo:
        (c,) = repo.cards.claims_of_report(rid, "recover")
        assert (c["card_txn_id"], c["payout_ref"]) == ("card:4417:TX-88213", f"exp:{rid}:r1:pay")             # its own key: never the payout's
        assert repo.cards.txn("card:4417:TX-88213")["status"] == "personal"                                   # not recovered until the bank pays
    refused(api("POST", f"/reports/{rid}/paid", PAYMENTS_CLIENT, {"payout_ref": f"exp:{rid}:r1:pay", "payment_id": "BANK-1", "paid_on": "2026-11-12",
                                                                   "amount": "287.40"}), 409, "payout is 269.40")
    must(api("POST", f"/reports/{rid}/paid", PAYMENTS_CLIENT, {"payout_ref": f"exp:{rid}:r1:pay", "payment_id": "BANK-1", "paid_on": "2026-11-12", "amount": "269.40"}))
    assert [t["status"] for t in txns() if t["card_txn_id"].endswith("TX-88213")] == ["recovered"]
    assert report(rid)["status"] == "paid"


def test_EXP_14_netting_and_repayment_of_one_recovery_collide_on_one_claim_row_and_only_one_wins(nb):
    seeded_period()
    _resolved_period()
    rid = claim_with_hotel_on_card()
    must(api("POST", "/card-txns/card:4417:TX-88200/match", EMPLOYEE, {"report_id": rid, "line_no": 2}))
    must(api("POST", "/card-txns/card:4417:TX-88213/personal", EMPLOYEE))
    must(api("POST", "/card-txns/card:4417:TX-88213/recover", FINANCE, {"payment_id": "RECEIPT-9"}))        # the employee repaid first
    assert [t["status"] for t in txns() if t["card_txn_id"].endswith("TX-88213")] == ["recovered"]
    submit(rid)
    must(approve(rid, MANAGER))
    got = must(approve(rid, FINANCE))
    assert got["netted_amount"] is None and got["payout_amount"] == "287.40"                                  # nothing left to net: never netted twice
    refused(api("POST", "/card-txns/card:4417:TX-88213/recover", FINANCE2, {"payment_id": "RECEIPT-10"}), 409, "recovered")


def test_EXP_14_a_repayment_after_netting_lost_the_claim_row_and_is_refused(nb):
    seeded_period()
    _resolved_period()
    rid = claim_with_hotel_on_card()
    must(api("POST", "/card-txns/card:4417:TX-88200/match", EMPLOYEE, {"report_id": rid, "line_no": 2}))
    must(api("POST", "/card-txns/card:4417:TX-88213/personal", EMPLOYEE))
    submit(rid)
    must(approve(rid, MANAGER))
    must(approve(rid, FINANCE))                                                                               # netted, not yet paid
    refused(api("POST", "/card-txns/card:4417:TX-88213/recover", FINANCE, {"payment_id": "RECEIPT-9"}), 409, "a moment ago")
    with use_cases.repository() as repo:
        assert repo.cards.txn("card:4417:TX-88213")["status"] == "personal"


def test_EXP_14_only_a_personal_transaction_is_recovered_by_finance(nb):
    seeded_period()
    refused(api("POST", "/card-txns/card:4417:TX-88213/recover", FINANCE, {"payment_id": "R"}), 409, "needs it to be personal")
    refused(api("POST", "/card-txns/card:4417:NOPE/recover", FINANCE, {"payment_id": "R"}), 404)
    refused(api("POST", "/card-txns/card:4417:TX-88213/recover", EMPLOYEE, {"payment_id": "R"}), 403, "exp:card_txn:resolve")


def test_EXP_13_when_the_personal_spend_is_more_than_the_claim_the_surplus_stays_owed(nb):
    seed_card("4417", 3301, [("TX-A", "2026-11-05", "Spa", Decimal("500.00")), ("TX-B", "2026-11-05", "Cinema", Decimal("18.00"))])
    rid = new_report()["report_id"]
    add(rid, amount="100.00")
    for ref in ("TX-A", "TX-B"):
        must(api("POST", f"/card-txns/card:4417:{ref}/personal", EMPLOYEE))
    submit(rid)
    must(approve(rid, MANAGER))
    got = report(rid)
    assert got["netted_amount"] == "18.00" and got["payout_amount"] == "82.00"                              # 500.00 does not fit inside 100.00: still owed
    assert [t["status"] for t in txns()] == ["personal", "personal"]


def test_EXP_13_a_claim_wholly_netted_has_nothing_to_pay_and_is_settled(nb):
    seed_card("4417", 3301, [("TX-B", "2026-11-05", "Cinema", Decimal("18.00"))])
    rid = new_report()["report_id"]
    add(rid, amount="18.00")
    must(api("POST", "/card-txns/card:4417:TX-B/personal", EMPLOYEE))
    submit(rid)
    must(approve(rid, MANAGER))
    got = report(rid)
    assert (got["status"], got["payout_amount"], got["paid_on"]) == ("paid", "0.00", "2026-11-07") and nb.payouts == {}
    assert txns()[0]["status"] == "recovered"


def test_EXP_13_reimburse_with_separate_nets_nothing(nb):
    seed_card("4417", 3301, [("TX-B", "2026-11-05", "Cinema", Decimal("18.00"))])
    with use_cases.repository() as repo:
        repo.settings.put("reimburse_with", "separate", use_cases._today(repo), "owner", use_cases._now())
    rid = new_report()["report_id"]
    add(rid, amount="100.00")
    must(api("POST", "/card-txns/card:4417:TX-B/personal", EMPLOYEE))
    submit(rid)
    must(approve(rid, MANAGER))
    assert report(rid)["payout_amount"] == "100.00" and report(rid)["netted_amount"] is None and txns()[0]["status"] == "personal"


# ── EXP-19: a leaver's cards ───────────────────────────────────────────────────────────────────────

def test_EXP_19_a_leavers_cards_stop_from_the_day_after_and_finance_lists_what_is_unmatched(nb):
    seeded_period()
    got = must(api("POST", "/employees/3301/leaver", HR_CLIENT, {"last_day": "2026-11-06", "source_ref": "hr:left:3301:1"}))
    assert got["cards_stopped"] == 1
    with use_cases.repository() as repo:
        c = repo.cards.get_card("4417")
        assert (c["status"], c["stopped_from"].isoformat()) == ("stopped", "2026-11-07")
    unmatched = must(api("GET", "/card-txns", FINANCE, {"scope": "unmatched"}))["card_txns"]
    assert len(unmatched) == 5 and {t["holder_person_id"] for t in unmatched} == {"3301"}
    again = must(api("POST", "/employees/3301/leaver", HR_CLIENT, {"last_day": "2026-11-06", "source_ref": "hr:left:3301:1"}))
    assert again["cards_stopped"] == 1                                                                      # the repeat answers the same


def test_EXP_19_the_unmatched_list_filters_by_card_and_never_holds_other_cards_rows(nb):
    seeded_period()
    seed_card("9999", 2280, [("TX-M", "2026-11-05", "Hotel", Decimal("1.00"))])
    only = must(api("GET", "/card-txns", FINANCE, {"scope": "unmatched", "card_id": "9999"}))["card_txns"]
    assert [t["card_txn_id"] for t in only] == ["card:9999:TX-M"]