diff --git a/backend/src/cyclone/inbox_lanes.py b/backend/src/cyclone/inbox_lanes.py index fcce9be..5d47ef7 100644 --- a/backend/src/cyclone/inbox_lanes.py +++ b/backend/src/cyclone/inbox_lanes.py @@ -175,6 +175,82 @@ def _line_count_lookup(session: Session, claims: list[Claim]) -> tuple[dict, dic return matched_counts, total_lines_by_claim +def _ack_summary_for_claims( + session: Session, claim_ids: list[str], +) -> dict[str, dict]: + """Build a {claim_id: {total, rejected, items: [...]}} map for 999 acks. + + SP29: the Inbox `rejected` lane needs to render AK2 evidence + inline per row, plus a per-row Resubmit button. The data lives + in ``claim_acks`` (SP28) — we don't want to N+1 fetch per row, + so the whole rejected-claim set is summarized in one batched + query here. + + Filters to ``ack_kind='999'`` because the rejected lane is the + 999 envelope reject lane; the 277CA STC A4/A6/A7 evidence flows + through the ``payer_rejected_*`` fields on a separate lane and + isn't part of this scope (see SP29 spec D4 / scope). + + Returns: + ``{claim_id: {"total": int, "rejected": int, "items": [...]}, ...}`` + Claims with zero linked 999 acks are NOT in the returned + dict — the caller maps via ``.get(cid)`` and treats absence + as "no 999 acks linked" (renders as ``null`` in the + payload, ``999 not linked`` in the UI). + + Args: + session: SQLAlchemy session the caller owns. + claim_ids: list of claim.id values to summarize. Typically + the rejected-lane claim ids. Empty list → empty dict. + """ + if not claim_ids: + return {} + from cyclone.db import ClaimAck # late import — DB model registered + rows = ( + session.query( + ClaimAck.claim_id, + ClaimAck.ack_id, + ClaimAck.set_control_number, + ClaimAck.set_accept_reject_code, + ClaimAck.ak2_index, + ClaimAck.linked_at, + ) + .filter( + ClaimAck.claim_id.in_(claim_ids), + ClaimAck.ack_kind == "999", + ) + .order_by(ClaimAck.linked_at.desc(), ClaimAck.id.desc()) + .all() + ) + grouped: dict[str, list[tuple]] = {} + for cid, aid, scn, code, ak2i, lat in rows: + grouped.setdefault(cid, []).append((aid, scn, code, ak2i, lat)) + + rejected_codes = {"R", "E", "X"} + out: dict[str, dict] = {} + for cid, items in grouped.items(): + total = len(items) + rejected_count = sum(1 for it in items if (it[2] or "") in rejected_codes) + # Keep 5 most recent items for the chip column. The full count + # is in ``total`` so the UI can show ``+N more`` honestly. + trimmed = items[:5] + out[cid] = { + "total": total, + "rejected": rejected_count, + "items": [ + { + "ack_id": aid, + "set_control_number": scn, + "set_accept_reject_code": code or "", + "ak2_index": ak2i, + "linked_at": _isoformat(lat), + } + for (aid, scn, code, ak2i, lat) in trimmed + ], + } + return out + + def compute_lanes(session: Session, *, dismissed_pairs: Iterable[frozenset]) -> Lanes: lanes = Lanes() dismissed = set(dismissed_pairs) @@ -192,6 +268,17 @@ def compute_lanes(session: Session, *, dismissed_pairs: Iterable[frozenset]) -> ), )) + # SP29: attach the 999 ack-evidence summary (total / rejected / + # 5 most recent AK2 set_responses) to every rejected row so the + # Inbox can render AK2 chips inline + a per-row Resubmit button + # without an extra round-trip. One batched query, keyed off the + # rejected-claim id set. + rejected_ack_summary = _ack_summary_for_claims( + session, [r["id"] for r in lanes.rejected] + ) + for row in lanes.rejected: + row["claim_acks"] = rejected_ack_summary.get(row["id"]) + # --- Payer-Rejected (SP10) --- # Distinct from the 999 envelope "rejected" lane above. A claim # lands here when a 277CA STC category code is A4/A6/A7 (rejected diff --git a/backend/tests/test_inbox_lanes.py b/backend/tests/test_inbox_lanes.py index 776d895..e140cfb 100644 --- a/backend/tests/test_inbox_lanes.py +++ b/backend/tests/test_inbox_lanes.py @@ -12,7 +12,7 @@ import pytest from cyclone import db from cyclone.db import ( - Base, Batch, Claim, ClaimState, Remittance, init_db, + Base, Batch, Claim, Ack, ClaimAck, ClaimState, Remittance, init_db, ) from cyclone.inbox_lanes import compute_lanes @@ -142,3 +142,111 @@ def test_done_today_includes_recent_terminal_states(): ids = {r["id"] for r in lanes.done_today} assert "C1" in ids assert "C2" not in ids + + +# --------------------------------------------------------------------------- +# SP29: rejected-lane rows must carry the per-claim 999 ack-evidence summary +# so the Inbox can render AK2 chips inline + a per-row Resubmit button +# without an extra round-trip. +# --------------------------------------------------------------------------- + +def _add_999_ack(*, ack_id: int, parsed_at=None) -> None: + """Persist a minimal Ack row (the 999 envelope).""" + with db.SessionLocal()() as s: + if s.get(Ack, ack_id) is not None: + return + s.add(Ack( + id=ack_id, source_batch_id="B-1", + accepted_count=0, rejected_count=0, received_count=1, + ack_code="A", + parsed_at=parsed_at or datetime.now(timezone.utc), + raw_json={"set_responses": []}, + )) + s.commit() + + +def _link_claim_ack( + *, ack_id: int, claim_id: str, ak2_index: int, + set_accept_reject_code: str, + linked_at=None, set_control_number: str = "991102989", +) -> None: + """Persist a single ClaimAck row linking an Ack to a Claim.""" + with db.SessionLocal()() as s: + s.add(ClaimAck( + claim_id=claim_id, ack_id=ack_id, ack_kind="999", + ak2_index=ak2_index, + set_control_number=set_control_number, + set_accept_reject_code=set_accept_reject_code, + linked_at=linked_at or datetime.now(timezone.utc), + linked_by="auto", + )) + s.commit() + + +def test_inbox_lanes_attaches_claim_acks_summary_for_rejected(): + """A rejected-lane row gets a `claim_acks` field with total / + rejected counts + 5 most recent AK2 set_responses. Newest first. + """ + _add_claim(claim_id="REJ-1", state=ClaimState.REJECTED) + # 3 linked 999 acks: 2 accepted, 1 rejected + _add_999_ack(ack_id=1) + _add_999_ack(ack_id=2) + _add_999_ack(ack_id=3) + t0 = datetime.now(timezone.utc) + _link_claim_ack(ack_id=1, claim_id="REJ-1", ak2_index=0, + set_accept_reject_code="A", + linked_at=t0 - timedelta(minutes=10)) + _link_claim_ack(ack_id=2, claim_id="REJ-1", ak2_index=1, + set_accept_reject_code="R", + linked_at=t0 - timedelta(minutes=5)) + _link_claim_ack(ack_id=3, claim_id="REJ-1", ak2_index=2, + set_accept_reject_code="A", + linked_at=t0 - timedelta(minutes=1)) + + with db.SessionLocal()() as s: + lanes = compute_lanes(s, dismissed_pairs=set()) + + rows = [r for r in lanes.rejected if r["id"] == "REJ-1"] + assert len(rows) == 1 + row = rows[0] + assert "claim_acks" in row + summary = row["claim_acks"] + assert summary is not None + assert summary["total"] == 3 + assert summary["rejected"] == 1 + assert len(summary["items"]) == 3 + # Newest first (descending linked_at) + assert summary["items"][0]["set_accept_reject_code"] == "A" # ack 3 (most recent) + assert summary["items"][1]["set_accept_reject_code"] == "R" # ack 2 (middle) + assert summary["items"][2]["set_accept_reject_code"] == "A" # ack 1 (oldest) + # The R code is the one rejected entry + codes = [it["set_accept_reject_code"] for it in summary["items"]] + assert codes.count("R") == 1 + # ak2_index is preserved + assert summary["items"][0]["ak2_index"] == 2 + assert summary["items"][1]["ak2_index"] == 1 + + +def test_inbox_lanes_claim_acks_is_null_when_no_links(): + """A rejected claim with zero linked 999 acks renders + `claim_acks: null` (the UI shows '999 not linked').""" + _add_claim(claim_id="REJ-2", state=ClaimState.REJECTED) + with db.SessionLocal()() as s: + lanes = compute_lanes(s, dismissed_pairs=set()) + rows = [r for r in lanes.rejected if r["id"] == "REJ-2"] + assert len(rows) == 1 + assert rows[0]["claim_acks"] is None + + +def test_inbox_lanes_claim_acks_does_not_attach_to_other_lanes(): + """The ack summary is only on `rejected`, not `payer_rejected` / + `unmatched` / `done_today` — those lanes have their own evidence + shape (payer_rejected_*, no linked 999s by construction).""" + _add_claim(claim_id="SUB-1", state=ClaimState.SUBMITTED) + _add_claim(claim_id="DEN-1", state=ClaimState.DENIED) + with db.SessionLocal()() as s: + lanes = compute_lanes(s, dismissed_pairs=set()) + for r in lanes.unmatched: + assert "claim_acks" not in r + for r in lanes.done_today: + assert "claim_acks" not in r diff --git a/docs/superpowers/plans/2026-07-02-cyclone-999-rejected-drill.md b/docs/superpowers/plans/2026-07-02-cyclone-999-rejected-drill.md new file mode 100644 index 0000000..0027e41 --- /dev/null +++ b/docs/superpowers/plans/2026-07-02-cyclone-999-rejected-drill.md @@ -0,0 +1,460 @@ +# SP29 — Inbox 999-rejected claim drill implementation plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Surface the SP28-linkable 999 AK2 set-response evidence inline on each `rejected`-lane row in the Inbox and add a per-row `Resubmit` button that downloads the single-claim corrected 837. The operator can identify and act on each rejected claim without opening the drawer or running the bulk modal. + +**Architecture:** Add a `claim_acks` summary field to the rejected-lane row payload from `/api/inbox/lanes` (one batched query for the whole lane — no N+1). Extend `InboxClaimRow` in `inbox-api.ts` with the new field. Extend `InboxRow` to render up to 3 AK2 chips inline plus a per-row Resubmit button that calls the existing `serializeClaim837` client and writes via `downloadTextFile`. No new endpoints, no new dependencies. + +**Tech Stack:** Python 3.11+, FastAPI, SQLAlchemy 2.x, SQLite (encrypted via SQLCipher), React 18 + TypeScript + Vite, TanStack Query, Zustand, Radix UI primitives. + +**Spec:** [`docs/superpowers/specs/2026-07-02-cyclone-999-rejected-drill-design.md`](../specs/2026-07-02-cyclone-999-rejected-drill-design.md) + +--- + +## File structure + +``` +backend/ +├── src/cyclone/ +│ └── inbox_lanes.py # ~ attach claim_acks summary to rejected rows +└── tests/ + └── test_inbox_lanes.py # + 1 test for the new field (extend if file exists) + +src/ +├── lib/ +│ └── inbox-api.ts # + InboxClaimRow.claim_acks type +├── components/inbox/ +│ ├── InboxRow.tsx # + AK2 chip sub-row + per-row Resubmit button +│ └── InboxRow.test.tsx # + 1 test for chips +└── pages/ + ├── Inbox.tsx # + onResubmitOne handler threading + └── Inbox.test.tsx # + 1 test for per-row download +``` + +--- + +## Phase 1 — Backend: lane row payload + +### Task 1: Attach `claim_acks` summary to each rejected-lane row + +- [ ] **Step 1.1: Read `backend/src/cyclone/inbox_lanes.py` and locate the rejected-lane loop.** + + Around `inbox_lanes.py:180-195` — the `rejected_claims = (...)` query, the `_line_count_lookup(...)` call, and the `_claim_to_row(...)` loop. The new `claim_acks` attach should sit AFTER the loop (so it operates on the dict rows), pulling from a single batched SQL query keyed by `claim_id IN (...)`. + +- [ ] **Step 1.2: Add a batched query helper inside `compute_lanes`.** + + After the rejected loop, before the function returns, add: + + ```python + rejected_ids = [r["id"] for r in lanes.rejected] + rejected_ack_summary = _ack_summary_for_claims(session, rejected_ids) + for row in lanes.rejected: + row["claim_acks"] = rejected_ack_summary.get(row["id"]) # None when no 999 acks linked + ``` + + The helper lives at module scope (above `compute_lanes`): + + ```python + def _ack_summary_for_claims(session: Session, claim_ids: list[str]) -> dict[str, dict]: + """Attach a `claim_acks` summary to every rejected-lane row. + + SP29: per rejected claim, summarize the linked 999 acks so the + Inbox row can render AK2 chips + a per-row Resubmit button + without an extra fetch. Newest 5 acks + total + rejected count. + + Returns: + {claim_id: {"total": int, "rejected": int, "items": [...]}, ...} + (claims with zero 999 acks are NOT in the returned dict, so + the caller can map.get(...) and treat absence as null.) + """ + if not claim_ids: + return {} + from cyclone.db import ClaimAck # late import — mirrors DB module shape + # Fetch newest 5 linked 999 acks per claim in one round-trip + from sqlalchemy import select + # Subquery to pick the 5 newest per claim + # Simpler: fetch ALL then trim in Python (claim_id count is low — lanes page + # only carries rejected claims, typically a few dozen) + rows = ( + session.query( + ClaimAck.claim_id, + ClaimAck.ack_id, + ClaimAck.set_control_number, + ClaimAck.set_accept_reject_code, + ClaimAck.ak2_index, + ClaimAck.linked_at, + ) + .filter( + ClaimAck.claim_id.in_(claim_ids), + ClaimAck.ack_kind == "999", + ) + .order_by(ClaimAck.linked_at.desc(), ClaimAck.id.desc()) + .all() + ) + grouped: dict[str, list[tuple]] = {} + for cid, aid, scn, code, ak2i, lat in rows: + grouped.setdefault(cid, []).append((aid, scn, code, ak2i, lat)) + + rejected_codes = {"R", "E", "X"} + out: dict[str, dict] = {} + for cid, items in grouped.items(): + total = len(items) + rejected_count = sum(1 for it in items if it[2] in rejected_codes) + trimmed = items[:5] + out[cid] = { + "total": total, + "rejected": rejected_count, + "items": [ + { + "ack_id": aid, + "set_control_number": scn, + "set_accept_reject_code": code or "", + "ak2_index": ak2i, + "linked_at": _isoformat(lat), + } + for (aid, scn, code, ak2i, lat) in trimmed + ], + } + return out + ``` + + **Performance note:** the lanes page only carries rejected claims (a few dozen at most on this codebase; the prod count of `rejected` lane rows can be confirmed with a count query but is small by construction). A bounded fetch + Python trim is fine for v1. If a future SP finds lane payloads blowing up, add `claim_acks(claim_id, ack_kind, linked_at)` composite index in a follow-up migration. + +- [ ] **Step 1.3: Wire the helper into `compute_lanes`.** + + Place the two lines from Step 1.2 immediately after the rejected-lane loop, before any return statement. Do not touch the `payer_rejected` loop — that lane has its own `payer_rejected_*` fields and doesn't need `claim_acks` (its rejections come from 277CA, not 999). + +- [ ] **Step 1.4: Add a test in `backend/tests/test_inbox_lanes.py`.** + + ```python + def test_inbox_lanes_attaches_claim_acks_summary_for_rejected(): + # Seed: 1 rejected claim, with 3 linked 999 acks (2 accepted, 1 rejected) + # Call compute_lanes (or /api/inbox/lanes via TestClient) + # Assert: rejected[0]["claim_acks"]["total"] == 3 + # Assert: rejected[0]["claim_acks"]["rejected"] == 1 + # Assert: items has len <= 5 and is sorted by linked_at DESC + # Assert: items contain ack_id + set_control_number + set_accept_reject_code + ``` + + If `test_inbox_lanes.py` doesn't exist, create it and put the test there. The test should: + - Create a `Claim` with `state=ClaimState.REJECTED` + - Create 3 `Ack` rows (no specific kind — they're stored in the `acks` table) + - Create 3 `ClaimAck` rows linking the claim to those acks via the `claim_acks` table + - Set 2 of the claim_acks rows' `set_accept_reject_code` to `A` and 1 to `R` + - Call `/api/inbox/lanes` via TestClient + - Assert the `rejected[0].claim_acks` shape matches + +- [ ] **Step 1.5: Run the new test — should PASS.** + + ```bash + cd backend && .venv/bin/pytest tests/test_inbox_lanes.py -v + ``` + +--- + +## Phase 2 — Frontend types + render + +### Task 2: Extend `InboxClaimRow` type + +- [ ] **Step 2.1: Edit `src/lib/inbox-api.ts` (around line 54 where `InboxClaimRow` is declared).** + + Add the new optional field: + + ```typescript + export type InboxClaimAckItem = { + ack_id: number; + set_control_number: string; + set_accept_reject_code: string; + ak2_index: number; + linked_at: string; + }; + + export type InboxClaimRow = { + // ... existing fields ... + /** + * SP29: present on `rejected`-lane rows (999 envelope rejects). + * Newest 5 AK2 set-responses for this claim plus a `total` / + * `rejected` summary. `null` when the claim has zero linked + * 999 acks. + */ + claim_acks?: { + total: number; + rejected: number; + items: InboxClaimAckItem[]; + } | null; + }; + ``` + +### Task 3: Extend `InboxRow` with chip column + per-row Resubmit button + +- [ ] **Step 3.1: Locate the row render in `src/components/inbox/InboxRow.tsx`.** + + Around the 7-column flexbox (lines 73-119). The new chips live in a sub-row beneath the existing row content; the per-row Resubmit button slots in at the right edge of the row. + +- [ ] **Step 3.2: Add an `onResubmitOne?: (claimId: string) => void` prop.** + + Update the function signature to accept the new optional callback. + +- [ ] **Step 3.3: Render the chips sub-row for `rejected` claim rows.** + + Pseudocode: + + ```tsx + {row.kind === "claim" && row.state === "rejected" && ( +
+ {row.claim_acks && row.claim_acks.items.length > 0 ? ( + <> + {row.claim_acks.items.slice(0, 3).map((it) => ( + + ))} + {row.claim_acks.total > 3 && ( + onOpenClaim?.(row.id)} /> + )} + + ) : ( + 999 not linked + )} +
+ )} + ``` + + Chip styling mirrors the existing AcksPage `AckCodeBadge`: + + - `A` → muted check tint (muted foreground, transparent bg) + - `R`/`E`/`X` → oxblood tint + - Display: `ST02 · AK2_CODE` (e.g. `991102989 · A`) + +- [ ] **Step 3.4: Add the per-row Resubmit button.** + + At the right edge of the row, after the existing `payer` cell: + + ```tsx + {row.kind === "claim" && row.state === "rejected" && onResubmitOne && ( + + )} + ``` + + `e.stopPropagation()` is critical — the row is currently wired to navigate to `/claims?claim=ID` on click. Without stopPropagation, both gestures would fire. + +### Task 4: Wire `onResubmitOne` through the page + +- [ ] **Step 4.1: Add the handler in `src/pages/Inbox.tsx`.** + + Near the existing `performResubmit(...)` helper (~line 87): + + ```typescript + const performResubmitOne = useCallback(async (claimId: string) => { + try { + const { text, filename } = await api.serializeClaim837(claimId); + downloadTextFile(filename, "text/x12", text); + } catch (e) { + toast.error(`Resubmit failed: ${String(e)}`); + } + }, []); + ``` + + Import `serializeClaim837` from `@/lib/api` and `downloadTextFile` from `@/lib/download`. + +- [ ] **Step 4.2: Thread the prop through `` → ``.** + + `Lane.tsx` needs a new optional `onResubmitOne` prop passed through to `InboxRow`. `InboxRow` already accepts it (Step 3.2). Wire in `pages/Inbox.tsx` at the existing `` call site. + +- [ ] **Step 4.3: Gate on `RoleGate` for `admin` + `user`.** + + Mirror the existing bulk Resubmit gating (`pages/Inbox.tsx:529-538` — ``). The per-row button can live inside the same `RoleGate` wrapper. (If `RoleGate` only wraps `` but not ``, restructure to wrap the whole `rejected` lane block.) + +--- + +## Phase 3 — Frontend tests + +### Task 5: Test the chip rendering + per-row Resubmit + +- [ ] **Step 5.1: `src/components/inbox/InboxRow.test.tsx` — add 1 test.** + + ```tsx + it("SP29: rejected-lane row renders inline 999 ack evidence chips", () => { + const row: InboxClaimRow = { + id: "REJ1", kind: "claim", state: "rejected", + patient_control_number: "991102989o...", + charge_amount: 100, payer_id: "CO_MEDICAID", + provider_npi: "1234567893", + rejection_reason: "999 AK5 set-level R; 1 segment error(s)", + rejected_at: new Date().toISOString(), + service_date_from: null, + claim_acks: { + total: 4, + rejected: 2, + items: [ + { ack_id: 1, set_control_number: "991102989", set_accept_reject_code: "A", ak2_index: 0, linked_at: "..." }, + { ack_id: 2, set_control_number: "991102989", set_accept_reject_code: "R", ak2_index: 1, linked_at: "..." }, + { ack_id: 3, set_control_number: "991102989", set_accept_reject_code: "E", ak2_index: 2, linked_at: "..." }, + ], + }, + }; + const onResubmitOne = vi.fn(); + render(); + // Assert 3 chips render with codes A/R/E + expect(screen.getByText(/A/)).toBeInTheDocument(); + expect(screen.getByText(/R/)).toBeInTheDocument(); + expect(screen.getByText(/E/)).toBeInTheDocument(); + // Assert +1 more chip (total 4, only 3 rendered, +1 indicator) + expect(screen.getByText(/\+1/)).toBeInTheDocument(); + // Click the Resubmit button — assert onResubmitOne fires + fireEvent.click(screen.getByTestId("resubmit-REJ1")); + expect(onResubmitOne).toHaveBeenCalledWith("REJ1"); + }); + ``` + +- [ ] **Step 5.2: `src/pages/Inbox.test.tsx` — add 1 test.** + + ```tsx + it("SP29: per-row Resubmit button downloads the single-claim 837", async () => { + // Mock fetchInboxLanes to return 1 rejected row REJ1 with claim_acks + // Mock api.serializeClaim837 to return { text: "ISA*...", filename: "claim-REJ1.x12" } + // Mock downloadTextFile + // Render + // Click the per-row Resubmit button + // Assert serializeClaim837 was called with "REJ1" + // Assert downloadTextFile was called with ("claim-REJ1.x12", "text/x12", "ISA*...") + }); + ``` + + Follow the existing `vi.mock("@/lib/api", ...)` + `vi.mock("@/lib/download", ...)` pattern in the file. Use the same `vi.mocked(...)` harness style. + +- [ ] **Step 5.3: Run the new tests — should PASS.** + + ```bash + npx vitest run src/components/inbox/InboxRow.test.tsx src/pages/Inbox.test.tsx -v + ``` + +--- + +## Phase 4 — Verification + +### Task 6: Run full verification + +- [ ] **Step 6.1: Backend pytest (one new file or extension, no others touched).** + + ```bash + cd backend && .venv/bin/pytest tests/test_inbox_lanes.py -v + ``` + + Pass criteria: new test passes; no new regressions elsewhere (run the full backend suite once). + +- [ ] **Step 6.2: Frontend vitest.** + + ```bash + npx vitest run src/components/inbox/InboxRow.test.tsx src/pages/Inbox.test.tsx -v + ``` + + Pass criteria: 2 new tests pass; no new regressions (run full vitest once to confirm the pre-existing baseline unchanged). + +- [ ] **Step 6.3: Typecheck.** + + ```bash + npm run typecheck + ``` + + Pass criteria: 0 new errors (the pre-existing 17 errors documented in SP28 deviations log remain; if any new errors are introduced they're a deviation to log). + +- [ ] **Step 6.4: Lint.** + + ```bash + npm run lint + ``` + + Pass criteria: same pre-existing posture (eslint not installed — non-functional). + +- [ ] **Step 6.5: Build (vite build via tsc -b).** + + ```bash + npm run build + ``` + + Pass criteria: same as SP28 build baseline. + +--- + +## Phase 5 — Merge + deploy + +### Task 7: Atomic merge + prod deploy + +- [ ] **Step 7.1: Commit on the SP29 branch.** + + Separate commits per concern: + - `feat(sp29): attach 999 ack summary to rejected-lane rows` + - `feat(sp29): per-row Resubmit button + ack-evidence chips in Inbox` + - `test(sp29): inbox row + page tests` + - `docs(plan): SP29 deviations log (if any)` + +- [ ] **Step 7.2: Atomic merge into main.** + + ```bash + git checkout main + git merge --no-ff sp29-rejected-row-ack-drill -m "merge: SP29 Inbox 999-rejected drill into main + + ..." + ``` + + **DO NOT SQUASH. DO NOT REBASE.** + +- [ ] **Step 7.3: Rebuild + restart prod containers.** + + ```bash + docker compose build backend frontend + docker compose up -d + ``` + +- [ ] **Step 7.4: Smoke test on prod.** + + 1. Hit `/api/inbox/lanes` as admin; confirm at least one rejected row has a non-null `claim_acks` field (if any rejected claims exist on prod). + 2. Open `/inbox` in the browser; confirm the rejected lane rows now show the AK2 chips + a `Resubmit` button per row. + 3. Click the `Resubmit` button; confirm a `claim-{id}.x12` file downloads. + +- [ ] **Step 7.5: Push to origin.** + + ```bash + git push origin main + ``` + +--- + +## Decisions + +- **D1 — per-row Resubmit is download-only (not state-flipping).** Mirrors the operator's actual workflow (download, fix in their editor, resubmit via the bulk path). v1 small. v2 may add state-flipping variant if needed. +- **D2 — lane surface, not a new page.** Reuse existing chrome. Cheaper. +- **D3 — `claim_acks.items` is 5 most recent, sorted DESC.** Matches eye-flow (newest first). `total` + `rejected` cover the rest. +- **D4 — per-row Resubmit visible to `admin` AND `user` roles.** Mirrors bulk Resubmit gating. + +## Out of scope (deferred) + +- `payer_rejected` lane (277CA STC A4/A6/A7) — distinct drill. +- Missing-999 alarm surfacing (claims SUBMITTED >24h with zero 999 acks). +- Hoisting ack counts into the ClaimDrawer header. +- Orphan-ack triage bulk actions. +- Audit-log entry on single-claim resubmit (the bulk path also has no audit — closing the gap is its own SP). + +## Notes for the implementer + +- **Don't bump the schema.** The `claim_acks` data is already there (SP28 + backfill). +- **Don't re-fetch per row.** One batched query in `compute_lanes` for the whole lane. +- **`e.stopPropagation()` on the Resubmit button is mandatory** — row click navigates to `/claims?claim=ID`, and both gestures firing would be a regression. +- **The `+N more` chip should also navigate to the ClaimDrawer** (same as the row click) — gives the operator a way to see all 5+ acks without opening via the row body. +- **No backend bus publish** — no live-tail event for SP29 (read-only shape change). +- **Visual chrome:** reuse the existing `--tt-oxblood` (reject) and `--tt-muted` (accept / "not linked") CSS variables; no new tokens needed. + +## Deviations log + +(Add any deviations discovered during implementation to this section and to the merge commit body. Pre-existing baselines from SP28 deviations log: 5 frontend test failures + 17 frontend typecheck errors. Document anything new here.) diff --git a/docs/superpowers/specs/2026-07-02-cyclone-999-rejected-drill-design.md b/docs/superpowers/specs/2026-07-02-cyclone-999-rejected-drill-design.md new file mode 100644 index 0000000..4917b11 --- /dev/null +++ b/docs/superpowers/specs/2026-07-02-cyclone-999-rejected-drill-design.md @@ -0,0 +1,89 @@ +# SP29 — Inbox 999-rejected claim drill: inline ack evidence + per-row Resubmit + +> **Status:** Approved, 2026-07-02. +> **Branch:** `sp29-rejected-row-ack-drill` +> **Spec → plan → implement → atomic merge into main.** + +## Goal + +Make the Inbox `rejected` lane (the 999 envelope rejects — the resubmittable kind) **actionable per claim**: the operator can see why a claim was rejected (which 999 AK2 set_response, what code, what ST02) without opening the drawer, and they can resubmit a single rejected claim with one click instead of having to select its checkbox + open the bulk modal. + +The SP28 → SP29 chain: SP28 gave us the `claim_acks` join table so we can now surface the 999 evidence inline on the rejected row. SP29 is the operator-facing surface that makes the rebill workflow usable. + +## Scope + +### Backend — extend `/api/inbox/lanes` + +`backend/src/cyclone/inbox_lanes.py` (`compute_lanes`): for each `rejected`-lane claim row, attach `claim_acks: {total, rejected, items}` summarizing the 999 acks linked to that claim by SP28. + +New field on `InboxClaimRow`: + +```python +{ + "total": int, # total claim_acks rows linked to this claim (ack_kind='999') + "rejected": int, # subset where set_accept_reject_code in ('R', 'E', 'X') + "items": [ # up to 5 most recent, newest first; for UI chip rendering + {"ack_id": int, "set_control_number": str, "set_accept_reject_code": str, "ak2_index": int, "linked_at": iso8601} + ], +} +``` + +The field is `null` when the claim has zero linked 999 acks. Filtered to `ack_kind='999'` — the 277CA ack evidence already lives on the `payer_rejected_*` fields on `payer_rejected` rows (out of scope for this SP; not touched). + +Implementation: one batched query inside `compute_lanes`: + +```sql +SELECT claim_id, ack_id, set_control_number, set_accept_reject_code, ak2_index, linked_at +FROM claim_acks +WHERE claim_id IN (...) AND ack_kind = '999' +ORDER BY linked_at DESC, id DESC +``` + +(Indexes sufficient — `ix_claim_acks_claim_id` already exists from `0018_claim_acks.sql`. The `(claim_id, linked_at)` composite for sorted fetch is not added in v1; the LIMIT-by-claim is done in Python after the batched fetch. If latency grows on the inbox endpoint we add a `(claim_id, linked_at)` composite index in a follow-up SP.) + +### Frontend — render + per-row Resubmit + +**`src/lib/inbox-api.ts`** — extend `InboxClaimRow` with the new `claim_acks` field (additive, non-breaking). + +**`src/components/inbox/InboxRow.tsx`** — add an inline sub-row for `rejected`-state claim rows showing up to 3 AK2 chips + a `+N more` chip that opens the ClaimDrawer. Reject codes (`R`/`E`/`X`) get the oxblood tint; accept (`A`) gets a muted check tint. When `claim_acks` is null or empty, render `999 not linked` in muted text. + +**`src/components/inbox/InboxRow.tsx`** + **`src/pages/Inbox.tsx`** — a per-row `Resubmit` button at the right edge of each `rejected` claim row. Click triggers `serializeClaim837(row.id)` → `downloadTextFile(...)` (operator gets the corrected 837 on disk; they reconcile via their own workflow or via the existing bulk Resubmit modal). + +**Auth posture:** per-row Resubmit is visible to `admin` and `user` roles. Mirrors the bulk Resubmit gating in `pages/Inbox.tsx:529-538`. Manual-only SP24 posture unchanged. + +### Out of scope (deferred) + +- `payer_rejected` lane (277CA STC A4/A6/A7) — distinct drill, owns the Acknowledge gesture. +- A missing-999 alarm surfacing (claims SUBMITTED >24h with zero 999 acks). +- Hoisting ack counts into the ClaimDrawer header. +- Orphan-ack triage bulk actions. +- Audit-log entry on single-claim resubmit (the bulk path doesn't audit either; closing that gap is its own SP). + +## Decisions + +- **D1 — per-row Resubmit is download-only (not state-flipping).** Mirrors the operator's actual workflow (download, fix in their editor, resubmit via the bulk path or the existing single-claim modal flow). Keeps v1 small. v2 can add the "submit + flip state" path if needed. +- **D2 — lane surface, not a new page.** The Inbox already has the `rejected` lane with chrome cost paid. Adding the per-row affordance inside the existing lane is lower-friction than a new drill page. +- **D3 — `claim_acks.items` is 5 most recent, sorted DESC.** Matches what the eye scans (newest rejections first). `total` and `rejected` summary counts cover the rest of the story. No pagination. +- **D4 — per-row Resubmit visible to `admin` AND `user` roles.** Mirrors the existing bulk Resubmit gating. + +## Risks + +- **N+1 fetch via the inbox endpoint.** Could grow `/api/inbox/lanes` latency by O(N-rejected) without a batched query. Mitigation: one batched query inside `compute_lanes` (`claim_id IN (...)`). +- **Backwards compatibility of `InboxClaimRow`.** Adding a nullable `claim_acks` field is non-breaking. Existing UI (and the CLAUDE.md-documented "5 lanes" assumption) continues to work; only the rejected row gets new chips. +- **Per-row Resubmit file may not match the operator's edits.** Out of scope; the operator is responsible for editing. The download is the same `serialize_837` content the bulk path produces. Trade-off documented in D1. + +## Implementation shape + +- Branch: `sp29-rejected-row-ack-drill` +- Plan path: `docs/superpowers/plans/2026-07-02-cyclone-999-rejected-drill.md` +- Commit prefixes: `feat(sp29): …`, `docs(spec): …`, `docs(plan): …`, `merge: SP29 …` +- PR title: `SP29 Inbox 999-rejected drill` +- Merge shape: single atomic merge, no squash, no rebase. + +## File footprint (per phase) + +- Backend modified (1): `backend/src/cyclone/inbox_lanes.py`. +- Backend test added (1): `backend/tests/test_inbox_lanes.py` (extend or new). +- Frontend modified (3): `src/lib/inbox-api.ts`, `src/components/inbox/InboxRow.tsx`, `src/pages/Inbox.tsx`. +- Frontend test added (2): `src/components/inbox/InboxRow.test.tsx`, `src/pages/Inbox.test.tsx`. +- Estimated 70-100 LOC total. diff --git a/src/components/inbox/InboxRow.test.tsx b/src/components/inbox/InboxRow.test.tsx index 67c735c..7785e29 100644 --- a/src/components/inbox/InboxRow.test.tsx +++ b/src/components/inbox/InboxRow.test.tsx @@ -87,4 +87,115 @@ describe("InboxRow", () => { ); expect(container.textContent).toContain("92"); }); + + // --------------------------------------------------------------------- + // SP29: per-AK2 chip evidence + per-row Resubmit gesture for the + // rejected-lane drill. The page renders 999 AK2 set-response codes + // inline on the row so the operator can spot "this batch had R/E/X + // rejects" without opening the drawer. The per-row Resubmit button + // downloads a single corrected 837. + // --------------------------------------------------------------------- + it("SP29: rejected row renders inline 999 ack evidence chips + Resubmit button", () => { + // 4 linked 999 AK2 set-responses — 3 will render as chips, the + // 4th triggers the "+N more" overflow indicator. + const rejectedRow: InboxClaimRow = { + ...baseClaim, + id: "REJ1", + claim_acks: { + total: 4, + rejected: 2, + items: [ + { + ack_id: 11, + set_control_number: "991102989", + set_accept_reject_code: "A", + ak2_index: 0, + linked_at: "2026-07-02T10:03:00Z", + }, + { + ack_id: 12, + set_control_number: "991102989", + set_accept_reject_code: "R", + ak2_index: 1, + linked_at: "2026-07-02T10:02:00Z", + }, + { + ack_id: 13, + set_control_number: "991102989", + set_accept_reject_code: "E", + ak2_index: 2, + linked_at: "2026-07-02T10:01:00Z", + }, + ], + }, + }; + const onResubmitOne = vi.fn(); + const { container } = render( + + + {}} + onResubmitOne={onResubmitOne} + /> + +
, + ); + + // Chip visible — the operator can read at a glance that the most + // recent AK2 from this batch was an Accept, then a Reject, then + // an Error. The "999 · A" / "999 · R" / "999 · E" text comes from + // rendering "·". + expect(container.textContent).toContain("991102989·A"); + expect(container.textContent).toContain("991102989·R"); + expect(container.textContent).toContain("991102989·E"); + + // Overflow "+1 more" — total=4 but only 3 chips fit. + expect(container.textContent).toContain("+1 more"); + + // Resubmit button rendered with the per-row testid. + const btn = screen.getByTestId("resubmit-REJ1") as HTMLButtonElement; + expect(btn).toBeTruthy(); + expect(btn.textContent).toBe("Resubmit"); + + // Click fires the per-row handler with the claim id; click does + // NOT bubble to onClick (the row-onClick is for drilldown, the + // button is a separate download gesture). + fireEvent.click(btn); + expect(onResubmitOne).toHaveBeenCalledTimes(1); + expect(onResubmitOne).toHaveBeenCalledWith("REJ1"); + }); + + it("SP29: rejected row with no linked 999 acks shows the '999 not linked' orphan marker", () => { + // A rejected claim whose 999 ack didn't match any ST02 in the + // batch — the operator needs to know the drill lacked ack + // evidence. The "999 not linked" italic line is the marker. + const rejectedRow: InboxClaimRow = { + ...baseClaim, + id: "REJ-ORPH", + claim_acks: { + total: 0, + rejected: 0, + items: [], + }, + }; + const onResubmitOne = vi.fn(); + const { container } = render( + + + {}} + onResubmitOne={onResubmitOne} + /> + +
, + ); + expect(container.textContent).toContain("999 not linked"); + // The Resubmit button still renders so the operator can fix the + // claim without first manually linking the orphan. + expect(screen.getByTestId("resubmit-REJ-ORPH")).toBeTruthy(); + }); }); diff --git a/src/components/inbox/InboxRow.tsx b/src/components/inbox/InboxRow.tsx index 7bb2c52..9791f84 100644 --- a/src/components/inbox/InboxRow.tsx +++ b/src/components/inbox/InboxRow.tsx @@ -10,6 +10,8 @@ const ACCENT_VAR: Record = { muted: "var(--tt-muted)", }; +const REJECT_CODES = new Set(["R", "E", "X"]); + function fmtMoney(n: number | null | undefined): string { if (n == null) return "—"; return n.toLocaleString("en-US", { style: "currency", currency: "USD" }); @@ -55,12 +57,54 @@ function Sparkline({ breakdown }: { breakdown: ScoreBreakdown | null }) { ); } -type Props = - | { row: InboxClaimRow; accent: Accent; onClick: () => void } - | { row: InboxCandidateRow; accent: Accent; onClick: () => void }; +// --------------------------------------------------------------------------- +// SP29: per-AK2 chip (compact evidence row for the rejected-lane drill) +// --------------------------------------------------------------------------- +function AckCodeChip({ + setControlNumber, + code, +}: { + setControlNumber: string; + code: string; +}) { + const isReject = REJECT_CODES.has(code); + return ( + + {setControlNumber} + · + {code || "—"} + + ); +} -export function InboxRow({ row, accent, onClick }: Props) { +type Props = { + row: InboxClaimRow | InboxCandidateRow; + accent: Accent; + onClick: () => void; + /** SP29: per-row Resubmit gesture. Only meaningful for rejected + * claim rows; the component no-ops on remit rows or other states. */ + onResubmitOne?: (claimId: string) => void; +}; + +export function InboxRow({ row, accent, onClick, onResubmitOne }: Props) { const isCandidate = row.kind === "remit"; + const isRejected = + !isCandidate && (row as InboxClaimRow).state === "rejected"; const id = isCandidate ? (row as InboxCandidateRow).payer_claim_control_number : row.id; const payer = (isCandidate ? (row as InboxCandidateRow).payer_id : (row as InboxClaimRow).payer_id) ?? ""; const charge = fmtMoney(row.charge_amount); @@ -68,6 +112,7 @@ export function InboxRow({ row, accent, onClick }: Props) { const rejectedAt = !isCandidate ? (row as InboxClaimRow).rejected_at : null; const topScore = isCandidate && row.candidates.length > 0 ? row.candidates[0].score : null; const filled = isCandidate && row.candidates.length > 0 ? row.candidates[0].breakdown : null; + const claimAcks = !isCandidate ? (row as InboxClaimRow).claim_acks : null; return ( - {reason ?? (topScore != null ? `score ${topScore}` : "")} +
+ {reason ?? (topScore != null ? `score ${topScore}` : "")} + {/* SP29: rejected-lane AK2 chip evidence (newest 3 + overflow). */} + {isRejected && claimAcks && claimAcks.items.length > 0 && ( + + {claimAcks.items.slice(0, 3).map((it) => ( + + ))} + {claimAcks.total > 3 && ( + + +{claimAcks.total - 3} more + + )} + + )} + {/* SP29: rejected claim but no linked 999 acks (orphan). */} + {isRejected && (!claimAcks || claimAcks.items.length === 0) && ( + + 999 not linked + + )} +
+ {/* SP29: per-row Resubmit button (rejected-lane only). */} + {isRejected && onResubmitOne ? ( + e.stopPropagation()} + > + + + ) : ( + + )} ); } diff --git a/src/components/inbox/Lane.tsx b/src/components/inbox/Lane.tsx index 365603d..9fcbcd4 100644 --- a/src/components/inbox/Lane.tsx +++ b/src/components/inbox/Lane.tsx @@ -75,12 +75,16 @@ export function Lane({ rows, onRowClick, onSelectionChange, + onResubmitOne, }: { name: string; accent: Accent; rows: LaneRow[]; onRowClick: (row: LaneRow) => void; onSelectionChange?: (ids: string[]) => void; + /** SP29: per-row Resubmit gesture for the rejected-lane drill. + * Only claim rows render the button; remit rows ignore it. */ + onResubmitOne?: (claimId: string) => void; }) { const [selected, setSelected] = useState>(new Set()); @@ -188,7 +192,17 @@ export function Lane({ onToggle={() => toggle(id)} />
- onRowClick(row)} /> + onRowClick(row)} + // SP29: only meaningful on claim rows. Remit rows + // narrow the union to the `onResubmitOne?: never` + // branch so this is a no-op for them. + {...(row.kind === "claim" && onResubmitOne + ? { onResubmitOne } + : {})} + />
); diff --git a/src/lib/inbox-api.ts b/src/lib/inbox-api.ts index 9e72895..042c344 100644 --- a/src/lib/inbox-api.ts +++ b/src/lib/inbox-api.ts @@ -54,6 +54,28 @@ export type InboxClaimRow = { payer_rejected_by_277ca_id?: string | null; payer_rejected_acknowledged_at?: string | null; payer_rejected_acknowledged_actor?: string | null; + // SP29: present on `rejected`-lane rows (999 envelope rejects). + // Newest 5 AK2 set-responses for this claim plus a `total` / + // `rejected` summary. `null` when the claim has zero linked + // 999 acks. Filtered to ack_kind='999' only — the + // `payer_rejected` lane carries 277CA evidence separately. + claim_acks?: InboxClaimAckSummary | null; +}; + +/** + * SP29: per-claim 999 ack-evidence summary for the Inbox + * `rejected`-lane drill. Newest-first; up to 5 items. + */ +export type InboxClaimAckSummary = { + total: number; + rejected: number; + items: Array<{ + ack_id: number; + set_control_number: string; + set_accept_reject_code: string; + ak2_index: number; + linked_at: string; + }>; }; export type InboxCandidateRow = { diff --git a/src/pages/Inbox.test.tsx b/src/pages/Inbox.test.tsx index ffe6b28..0794ef2 100644 --- a/src/pages/Inbox.test.tsx +++ b/src/pages/Inbox.test.tsx @@ -6,6 +6,7 @@ import { MemoryRouter, useLocation, useNavigate } from "react-router-dom"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import Inbox from "./Inbox"; import * as inboxApi from "@/lib/inbox-api"; +import * as apiModule from "@/lib/api"; import * as downloadModule from "@/lib/download"; // The Inbox's write-affordance BulkBars (resubmit / acknowledge / @@ -769,4 +770,112 @@ describe("Inbox page", () => { expect(options[0]?.getAttribute("data-claim-id")).toBe("CLM-A"); expect(options[1]?.getAttribute("data-claim-id")).toBe("CLM-B"); }); + + // ------------------------------------------------------------------------- + // SP29: per-row Resubmit button on rejected-lane claim rows downloads + // the single-claim corrected 837. The bulk resubmit path (multi-select) + // is exercised above in the "SP8 multi-select resubmit" test; this + // covers the dedicated single-claim gesture and the operator's + // most-click flow ("I see one reject in the lane, I click Resubmit + // next to it"). + // ------------------------------------------------------------------------- + it("SP29: per-row Resubmit button downloads the single-claim 837", async () => { + // Lane returns one rejected claim. The page is responsible for + // calling api.serializeClaim837(id) and handing the returned + // text to downloadTextFile as .x12. + vi.stubGlobal( + "fetch", + vi.fn().mockImplementation(async (url: string) => { + if (url.includes("/api/inbox/lanes")) { + return { + ok: true, + json: async () => ({ + rejected: [ + { + id: "REJ1", + kind: "claim", + patient_control_number: "REJ1", + charge_amount: 175, + payer_id: "P1", + provider_npi: "1234567890", + state: "rejected", + rejection_reason: "999 AK9 R", + rejected_at: "2026-07-02T09:00:00Z", + service_date_from: null, + claim_acks: { + total: 2, + rejected: 1, + items: [ + { + ack_id: 50, + set_control_number: "991102989", + set_accept_reject_code: "R", + ak2_index: 0, + linked_at: "2026-07-02T09:00:00Z", + }, + { + ack_id: 51, + set_control_number: "991102989", + set_accept_reject_code: "A", + ak2_index: 1, + linked_at: "2026-07-02T08:59:00Z", + }, + ], + }, + }, + ], + payer_rejected: [], + candidates: [], + unmatched: [], + done_today: [], + }), + }; + } + return { ok: true, json: async () => ({}) }; + }), + ); + + // Spy on the api call AND the download sink. The page calls + // `api.serializeClaim837(id)` (namespace import from @/lib/api), + // and downloadTextFile is on the download module. + const serializeSpy = vi + .spyOn(apiModule.api, "serializeClaim837") + .mockResolvedValue({ + text: "ISA*00*~...", + filename: "claim-REJ1.x12", + }); + const downloadSpy = vi + .spyOn(downloadModule, "downloadTextFile") + .mockImplementation(() => {}); + + const view = renderInbox(); + await waitFor(() => { + expect(view.container.textContent).toContain("REJ1"); + }); + + // Click the per-row Resubmit button — its testid includes the + // claim id so the operator can target one row at a time. + const btn = view.container.querySelector( + '[data-testid="resubmit-REJ1"]', + ) as HTMLButtonElement | null; + expect(btn).not.toBeNull(); + expect(btn!.textContent).toBe("Resubmit"); + + await act(async () => { + fireEvent.click(btn!); + }); + + // The api was called with the row's claim id, and the resulting + // .x12 was handed to downloadTextFile with text/x12 mime so the + // browser saves it as a download. + await waitFor(() => { + expect(serializeSpy).toHaveBeenCalledTimes(1); + expect(serializeSpy).toHaveBeenCalledWith("REJ1"); + }); + expect(downloadSpy).toHaveBeenCalledTimes(1); + const [filename, mime, text] = downloadSpy.mock.calls[0]; + expect(filename).toBe("claim-REJ1.x12"); + expect(mime).toBe("text/x12"); + expect(text).toBe("ISA*00*~..."); + }); }); diff --git a/src/pages/Inbox.tsx b/src/pages/Inbox.tsx index 33eefcf..015eec5 100644 --- a/src/pages/Inbox.tsx +++ b/src/pages/Inbox.tsx @@ -30,7 +30,8 @@ import { resubmitRejectedWithDownload, acknowledgePayerRejected, } from "@/lib/inbox-api"; -import { downloadBlob } from "@/lib/download"; +import { api } from "@/lib/api"; +import { downloadBlob, downloadTextFile } from "@/lib/download"; import { RoleGate } from "@/auth/RoleGate"; type LaneKey = @@ -107,6 +108,24 @@ export default function Inbox() { await refetch(); } + // SP29: per-row Resubmit gesture for the rejected lane. Downloads + // the single-claim corrected 837 (read-only — see spec §D1). The + // operator reconciles via their own workflow, then resubmits via + // the bulk path or the existing single-claim modal flow. + async function performResubmitOne(claimId: string) { + try { + const { text, filename } = await api.serializeClaim837(claimId); + downloadTextFile(filename, "text/x12", text); + } catch (err) { + console.error("resubmit single failed:", claimId, err); + // Surface the failure — the Inbox doesn't have a toast yet, so + // we fall back to an alert. v2 swaps in a sonner toast. + window.alert( + `Failed to download 837 for ${claimId}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + async function onResubmit() { const ids = selected.rejected; if (ids.length === 0) return; @@ -298,6 +317,10 @@ export default function Inbox() { } }} onSelectionChange={(ids) => setLaneSelected("rejected", ids)} + // SP29: per-row Resubmit button. Downloads the single-claim + // corrected 837 (read-only). InboxRow renders the button only + // on rejected claim rows; remit rows ignore the prop. + onResubmitOne={(claimId) => void performResubmitOne(claimId)} /> {/* SP14: payer-rejected (277CA STC A4/A6/A7). Distinct accent