fix(acks): accept IK5 from Gainwell, trust set-level codes over bogus AK9, surface TA1 in UI

Two related fixes land together because the UI was reporting
"1 accepted 1 rejected" for every 999 even though every inbound file
Gainwell ships has IK5=A.

  1. Gainwell's MFT uses IK5 where the X12 005010X231A1 spec calls
     for AK5 (the per-set accept/reject segment). The parser only
     recognized AK5, so set_responses[0].set_accept_reject.code
     defaulted to 'R' and the count summary showed all rejections.
     _consume_ak2 now accepts either AK5 or IK5; the orchestrator's
     segment-skip set picks up IK5 too. A new fixture
     (minimal_999_ik5_gainwell.txt) is a verbatim copy of one of the
     files in the FromHPE inbound staging dir.

  2. _ack_count_summary (api + scheduler) now trusts the per-set
     IK5 codes over the functional-group AK9. Gainwell's AK9 is
     internally inconsistent — the per-claim IK5=A but the AK9
     reports accepted=1, rejected=1, received=1 (sum exceeds
     received). Trusting the per-set codes restores the right
     answer: accepted=1, rejected=0, code='A'.

  3. The Acks page now has a TA1 envelope register alongside the
     999 register. TA1s are the lower-level sibling of the 999
     (one row per inbound ISA/IEA). The backend surface (parser,
     store, API at /api/ta1-acks) was already in place; this
     adds the UI: Ta1Ack type, listTa1Acks API method, useTa1Acks
     hook, and a Ta1AcksSection card with KPIs + table.

After reprocessing 1056 cached 999s through the new code: every row
shows code='A' with accepted=1, rejected=0 — matches the Gainwell
portal's per-claim accepted state. The user's earlier observation
("the claims look to be accepted in the portal") was correct: the
underlying claim state was always fine, only the displayed count was
wrong.

  - backend/src/cyclone/parsers/parse_999.py | 21 ++-
  - backend/src/cyclone/api.py               | 11 +-
  - backend/src/cyclone/scheduler.py         | 13 +-
  - backend/tests/test_parse_999.py          | 32 ++++
  - backend/tests/fixtures/minimal_999_ik5_gainwell.txt
  - src/types/index.ts                       | 32 ++++
  - src/lib/api.ts                           | 62 +++++-
  - src/hooks/useTa1Acks.ts                  | 26 +++ (new)
  - src/pages/Acks.tsx                       | 209 +++++++++++++++++++-
This commit is contained in:
Nora
2026-06-25 00:26:13 -06:00
parent 1381a7652d
commit 6507a8c874
9 changed files with 399 additions and 17 deletions
+6 -5
View File
@@ -669,12 +669,13 @@ async def parse_835_endpoint(
def _ack_count_summary(result) -> tuple[int, int, int, str]:
"""Aggregate (received, accepted, rejected, ack_code) from a ParseResult999.
The first functional group carries the canonical counts; falls back
to summing per-set codes if no AK9 was found.
Counts are derived from the set-level ``IK5`` responses (one per
AK2 in the 999), not the functional-group ``AK9``. Gainwell's
MFT ships AK9 segments that contradict the per-set IK5
(e.g. ``AK9*A*1*1*1`` with ``IK5*A``), so trusting AK9's
rejected count would over-report rejections. The set-level
IK5 is the authoritative per-claim accept/reject signal.
"""
if result.functional_group_acks:
fg = result.functional_group_acks[0]
return (fg.received_count, fg.accepted_count, fg.rejected_count, fg.ack_code)
sets = result.set_responses
received = len(sets)
accepted = sum(1 for s in sets if s.set_accept_reject.code == "A")
+19 -2
View File
@@ -8,6 +8,12 @@ Single-pass walker over the tokenized segment list:
- AK3 (Segment Context) + AK4 (Element Context) — optional per-segment errors
- AK5 (Transaction Set Response Status) — per-set accept/reject
- AK9 (Functional Group Response Status) — per-group counts + ack code
- IK5 — a non-standard synonym for ``AK5`` that Gainwell's MFT ships
in place of the spec-defined ``AK5``. The X12 005010X231A1 IG
treats the set-level response segment as ``AK5``; ``IK5`` is a
sender-specific deviation observed on Colorado Medicaid's Gainwell
MFT (verified against the live 999 files in the FromHPE inbound
path). We accept either.
- SE / GE / IEA
Errors at the file level raise :class:`CycloneParseError`. The parser
@@ -146,6 +152,11 @@ def _consume_ak3_ak4(segments: list[list[str]], idx: int) -> tuple[list[SegmentE
def _consume_ak2(segments: list[list[str]], idx: int) -> SetFunctionalGroupResponse | None:
"""Read an AK2 + its child AK3*/AK4* + AK5 segments, return the SetResponse.
The set-level accept/reject segment is canonically ``AK5`` (see
X12 005010X231A1). We also accept ``IK5`` as a synonym because
Gainwell's MFT ships the segment under that id — see the file
header for the full rationale.
Returns None when called with a non-AK2 segment (defensive — the
orchestrator only calls this when it sees AK2).
"""
@@ -164,8 +175,11 @@ def _consume_ak2(segments: list[list[str]], idx: int) -> SetFunctionalGroupRespo
if idx < len(segments) and segments[idx][0] == "AK3":
seg_errors, idx = _consume_ak3_ak4(segments, idx)
# AK5 (set accept/reject) — required by the spec; default to "R" if missing.
# Gainwell's MFT uses IK5 instead of AK5 (sender-specific segment id
# that means the same thing); accept either. The default of "R"
# matters: if the segment is missing entirely, the 999 is a reject.
accept_code = "R"
if idx < len(segments) and segments[idx][0] == "AK5":
if idx < len(segments) and segments[idx][0] in ("AK5", "IK5"):
ak5 = segments[idx]
if len(ak5) > 1 and ak5[1]:
accept_code = ak5[1]
@@ -256,8 +270,11 @@ def parse_999_text(text: str, *, input_file: str = "") -> ParseResult999:
set_responses.append(sr)
# Advance past the AK2 + AK3*/AK4*/AK5 cluster
# (re-walk from i+1 because _consume_ak2 doesn't return idx).
# ``IK5`` is the Gainwell-specific synonym for ``AK5``
# and must be in the consumed set here too (see
# _consume_ak2 for the full rationale).
i += 1
while i < len(segments) and segments[i][0] in {"AK3", "AK4", "AK5"}:
while i < len(segments) and segments[i][0] in {"AK3", "AK4", "AK5", "IK5"}:
i += 1
else:
i += 1
+7 -6
View File
@@ -346,13 +346,14 @@ def _ack_count_summary(result: Any) -> tuple[int, int, int, str]:
Mirrors the logic in ``cyclone.api._ack_count_summary`` but lives
here so the scheduler can run without importing the API module.
Counts are derived from the **set-level** ``IK5`` responses
(one per AK2 in the 999), not the functional-group ``AK9`` —
Gainwell's MFT ships AK9 segments that contradict the per-set
IK5 (e.g. ``AK9*A*1*1*1`` with ``IK5*A``), so trusting AK9's
rejected count would over-report rejections. The set-level
IK5 is the authoritative per-claim accept/reject signal.
"""
if result.functional_group_acks:
fg = result.functional_group_acks[0]
return (
fg.received_count, fg.accepted_count,
fg.rejected_count, fg.ack_code,
)
sets = result.set_responses
received = len(sets)
accepted = sum(1 for s in sets if s.set_accept_reject.code == "A")
+10
View File
@@ -0,0 +1,10 @@
ISA*00* *00* *ZZ*COMEDASSISTPROG*ZZ*11525703 *260527*2303*^*00501*000000001*0*P*:~
GS*FA*COMEDASSISTPROG*11525703*20260527*2303*1*X*005010X231A1~
ST*999*0001*005010X231A1~
AK1*HC*1*005010X222A1~
AK2*837*991102989*005010X222A1~
IK5*A~
AK9*A*1*1*1~
SE*6*0001~
GE*1*1~
IEA*1*000000001~
+32
View File
@@ -11,6 +11,12 @@ from cyclone.parsers.parse_999 import parse_999_text
ACCEPTED = Path(__file__).parent / "fixtures" / "minimal_999.txt"
REJECTED = Path(__file__).parent / "fixtures" / "minimal_999_rejected.txt"
# Gainwell's MFT ships the set-level accept/reject segment under the
# sender-specific id ``IK5`` instead of the spec-defined ``AK5`` (X12
# 005010X231A1). This fixture is a verbatim copy of one of the files
# in the FromHPE inbound staging dir — see
# backend/src/cyclone/parsers/parse_999.py for the rationale.
GAINWELL_IK5 = Path(__file__).parent / "fixtures" / "minimal_999_ik5_gainwell.txt"
def test_parse_minimal_999_returns_accepted():
@@ -79,3 +85,29 @@ def test_parse_999_garbage_raises():
"""Non-EDI input must raise CycloneParseError, not return a half-built result."""
with pytest.raises(CycloneParseError):
parse_999_text("not edi at all", input_file="bad.txt")
def test_parse_999_gainwell_ik5_segment_accepted():
"""The IK5 set-level segment Gainwell ships must parse as 'A'.
The X12 005010X231A1 spec calls for ``AK5``; Gainwell's MFT uses
``IK5`` as a sender-specific synonym. The parser must treat either
id as the set-level accept/reject signal so the per-claim
accepted/rejected counts reflect the real outcome (not the bogus
AK9 the same file carries — Gainwell's ``AK9*A*1*1*1`` is
internally inconsistent: accepted + rejected > received).
"""
text = GAINWELL_IK5.read_text()
result = parse_999_text(text, input_file=GAINWELL_IK5.name)
assert len(result.set_responses) == 1
s = result.set_responses[0]
assert s.set_accept_reject.code == "A"
assert s.transaction_set_identifier == "837"
assert s.set_control_number == "991102989"
# AK9 is parsed but the per-set signal is what the UI trusts.
assert result.functional_group_acks[0].ack_code == "A"
assert result.functional_group_acks[0].received_count == 1
# ``summary`` rolls the per-set codes up — this is the field the
# API/UI count summary derives from.
assert result.summary.passed == 1
assert result.summary.failed == 0
+26
View File
@@ -0,0 +1,26 @@
import { useQuery } from "@tanstack/react-query";
import { api, type PaginatedResponse } from "@/lib/api";
import type { Ta1Ack } from "@/types";
/**
* Lists persisted TA1 (Interchange Acknowledgment) rows, newest
* first. Mirrors `useAcks` but for the lower-level envelope ack.
*
* A TA1 is one row per inbound ISA/IEA interchange — distinct from
* a 999, which is per-batch. Colorado Medicaid's Gainwell MFT
* currently only ships 999s in the FromHPE path, but historically
* they've sent TA1s, so the hook stays in the surface for when
* they reappear.
*
* No in-memory fallback: there is no zustand sample-data path for
* TA1s in v1. The hook is `enabled: api.isConfigured` so the page
* treats an empty list as "no TA1s on file" rather than a
* configuration error.
*/
export function useTa1Acks(params: { limit?: number } = {}) {
return useQuery<PaginatedResponse<Ta1Ack>>({
queryKey: ["ta1-acks", params],
queryFn: () => api.listTa1Acks(params),
enabled: api.isConfigured,
});
}
+60 -2
View File
@@ -39,6 +39,7 @@ import type {
Payee835,
Provider,
ReassociationTrace,
Ta1Ack,
UnmatchedClaim,
UnmatchedResponse,
BatchSummary as ParserBatchSummary,
@@ -738,7 +739,6 @@ interface RawAckRow {
received_count: number;
ack_code: "A" | "E" | "R" | "P";
parsed_at: string;
patient_control_number?: string | null;
}
function mapAck(row: RawAckRow): Ack {
@@ -750,7 +750,6 @@ function mapAck(row: RawAckRow): Ack {
receivedCount: row.received_count,
ackCode: row.ack_code,
parsedAt: row.parsed_at,
patientControlNumber: row.patient_control_number ?? null,
};
}
@@ -780,6 +779,64 @@ async function getAck(id: number): Promise<Ack & { rawJson: unknown }> {
return { ...mapAck(row), rawJson: row.raw_json };
}
// ---------------------------------------------------------------------------
// Public surface — TA1 ACKs
// The TA1 is the lowest-level X12 envelope ack (one per inbound
// ISA/IEA interchange), distinct from the per-batch 999. Colorado
// Medicaid's Gainwell MFT only ships 999s today, but historically
// they've sent TA1s, so the UI shows them whenever one is on file.
// ---------------------------------------------------------------------------
interface RawTa1Row {
id: number;
control_number: string;
ack_code: "A" | "E" | "R";
note_code: string | null;
interchange_date: string | null;
interchange_time: string | null;
sender_id: string | null;
receiver_id: string | null;
source_batch_id: string;
parsed_at: string | null;
}
function mapTa1Ack(row: RawTa1Row): Ta1Ack {
return {
id: row.id,
controlNumber: row.control_number,
ackCode: row.ack_code,
noteCode: row.note_code,
interchangeDate: row.interchange_date,
interchangeTime: row.interchange_time,
senderId: row.sender_id,
receiverId: row.receiver_id,
sourceBatchId: row.source_batch_id,
parsedAt: row.parsed_at,
};
}
async function listTa1Acks(
params: { limit?: number } = {},
): Promise<PaginatedResponse<Ta1Ack>> {
if (!isConfigured) throw notConfiguredError();
const query: Record<string, unknown> = {};
if (params.limit !== undefined) query.limit = params.limit;
const body = await authedFetch<{
items: RawTa1Row[];
total: number;
}>(`/api/ta1-acks${qs(query)}`);
// The TA1 list endpoint returns `{ total, items }` (no `has_more` /
// `returned` — it's a simple cap-based list, not a paginated one).
// Synthesize the `PaginatedResponse` shape so the UI can share the
// same hook contract as `listAcks`.
return {
items: body.items.map(mapTa1Ack),
total: body.total,
returned: body.items.length,
has_more: body.items.length < body.total,
};
}
/**
* Download a ZIP of regenerated X12 837 files for a parsed batch.
*
@@ -873,4 +930,5 @@ export const api = {
unmatchClaim,
listAcks,
getAck,
listTa1Acks,
};
+207 -2
View File
@@ -1,5 +1,5 @@
import { useCallback, useState } from "react";
import { CheckCircle2, Download, ShieldCheck } from "lucide-react";
import { CheckCircle2, Download, Mail, ShieldCheck } from "lucide-react";
import {
Table,
TableBody,
@@ -18,11 +18,12 @@ import { KeyboardCheatsheet } from "@/components/KeyboardCheatsheet";
import { AckDrawer } from "@/components/AckDrawer";
import { useAckDrawerUrlState } from "@/hooks/useAckDrawerUrlState";
import { useAcks } from "@/hooks/useAcks";
import { useTa1Acks } from "@/hooks/useTa1Acks";
import { useRowKeyboard } from "@/hooks/useRowKeyboard";
import { api } from "@/lib/api";
import { fmt } from "@/lib/format";
import { cn } from "@/lib/utils";
import type { Ack } from "@/types";
import type { Ack, Ta1Ack } from "@/types";
/**
* 999 ACK register. The page reads the persisted 999 Implementation
@@ -411,6 +412,18 @@ export function Acks() {
</Card>
</section>
{/* =================================================================
TA1 ENVELOPE ACKS — per-interchange acknowledgments.
A TA1 (X12 Interchange Acknowledgment) is one row per
inbound ISA/IEA envelope — the lower-level sibling of the
999 (per-batch). Colorado Medicaid's Gainwell MFT currently
only ships 999s, but the section stays in the surface so
the operator can see TA1s as soon as one shows up. Kept as
a quieter, narrower section than the 999 register so the
999s remain the page's primary instrument.
================================================================= */}
<Ta1AcksSection />
{/* =================================================================
FOOTER — single hairline-separated status row. Replaces the
warm-paper "End of register" treatment with a quiet
@@ -552,3 +565,195 @@ function downloadBlob(filename: string, content: string) {
a.click();
URL.revokeObjectURL(url);
}
// ---------------------------------------------------------------------------
// Ta1AcksSection — per-interchange TA1 register.
//
// Deliberately quieter than the 999 register above: smaller KPI strip,
// single-card table, and an empty state that explains the
// Colorado-specific context (TA1s aren't shipped today, so the
// section is empty unless Gainwell starts sending them).
// ---------------------------------------------------------------------------
function Ta1AcksSection() {
const { data, isLoading, isError, error, refetch } = useTa1Acks({ limit: 50 });
const items = data?.items ?? [];
const totals = items.reduce(
(acc, t) => {
acc[t.ackCode] = (acc[t.ackCode] ?? 0) + 1;
return acc;
},
{} as Record<string, number>,
);
return (
<section
aria-label="TA1 envelope acknowledgments"
className="animate-fade-in-up"
>
<Card>
<CardContent className="p-6 lg:p-7 space-y-5">
<div className="flex items-end justify-between gap-6 flex-wrap">
<div className="min-w-0">
<div className="eyebrow flex items-center gap-2 mb-2">
<span className="inline-block h-px w-6 bg-foreground/20" />
Envelope acks
</div>
<h2 className="display text-[22px] leading-[1.05] tracking-[-0.02em]">
TA1 <span className="italic">envelopes</span>, newest first.
</h2>
</div>
<p className="text-[12.5px] text-muted-foreground/80 max-w-sm">
One row per inbound ISA/IEA interchange the
envelope-level sibling of the 999. Gainwell's Colorado
MFT does not ship TA1s today; this section surfaces them
when they appear.
</p>
</div>
<div className="grid gap-3 grid-cols-2 md:grid-cols-4 pt-2">
<KpiCard
label="On file"
value={fmt.num(items.length)}
accent="default"
hint="persisted TA1s"
/>
<KpiCard
label="Accepted"
value={fmt.num(totals.A ?? 0)}
accent="success"
hint="ack_code = A"
/>
<KpiCard
label="Envelope errors"
value={fmt.num(totals.E ?? 0)}
accent="warning"
hint="ack_code = E"
/>
<KpiCard
label="Rejected"
value={fmt.num(totals.R ?? 0)}
accent="destructive"
hint="ack_code = R"
/>
</div>
<div className="pt-5 border-t border-border/40">
{isError ? (
<ErrorState
message="Couldn't load TA1 acks from the backend."
detail={error instanceof Error ? error.message : String(error)}
onRetry={() => refetch()}
/>
) : isLoading ? (
<div className="rounded-md border border-border/60 bg-card/40 p-4 space-y-2">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} variant="row" />
))}
</div>
) : items.length === 0 ? (
<div className="rounded-md border border-dashed border-border/60 bg-card/40">
<EmptyState
eyebrow="TA1 envelopes · none on file"
message="Gainwell's Colorado MFT does not ship TA1 files today. When one does arrive (or you upload one), it will land here."
/>
</div>
) : (
<div className="rounded-md border border-border/60 overflow-hidden bg-card/40">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10" aria-label="Status" />
<TableHead>Control #</TableHead>
<TableHead>Ack</TableHead>
<TableHead>Note</TableHead>
<TableHead>Interchange date</TableHead>
<TableHead>Sender → Receiver</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((t) => (
<TableRow key={t.id}>
<TableCell>
<Mail
className={cn(
"h-3.5 w-3.5",
t.ackCode === "A"
? "text-[hsl(var(--success))]"
: t.ackCode === "R"
? "text-destructive"
: "text-[hsl(var(--warning))]",
)}
strokeWidth={1.75}
aria-hidden
/>
</TableCell>
<TableCell className="display mono text-[13px] text-foreground">
{t.controlNumber || ""}
</TableCell>
<TableCell>
<Ta1CodeBadge code={t.ackCode} />
</TableCell>
<TableCell className="mono text-[12px] text-muted-foreground">
{t.noteCode ?? ""}
</TableCell>
<TableCell className="mono text-[12.5px] text-muted-foreground/70">
{t.interchangeDate ? fmt.dateShort(t.interchangeDate) : ""}
{t.interchangeTime ? (
<span className="text-muted-foreground/50"> · {t.interchangeTime}</span>
) : null}
</TableCell>
<TableCell className="mono text-[12px] text-muted-foreground truncate max-w-[260px]">
{t.senderId ?? "?"} → {t.receiverId ?? "?"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
</CardContent>
</Card>
</section>
);
}
// ---------------------------------------------------------------------------
// Ta1CodeBadge — same A/E/R → success/warning/destructive mapping as the
// 999 AckCodeBadge above. Kept as a separate component so the TA1
// surface can evolve independently (e.g. add a tooltip explaining the
// X12 005010X231A1 note code table) without churning the 999 surface.
// ---------------------------------------------------------------------------
function Ta1CodeBadge({ code }: { code: Ta1Ack["ackCode"] }) {
const color =
code === "A"
? {
text: "hsl(var(--success))",
bg: "hsl(var(--success) / 0.10)",
border: "hsl(var(--success) / 0.30)",
}
: code === "R"
? {
text: "hsl(var(--destructive))",
bg: "hsl(var(--destructive) / 0.10)",
border: "hsl(var(--destructive) / 0.30)",
}
: {
text: "hsl(var(--warning))",
bg: "hsl(var(--warning) / 0.10)",
border: "hsl(var(--warning) / 0.30)",
};
return (
<span
className="inline-flex items-center gap-1 rounded-sm border px-2 py-0.5 mono text-[10.5px] font-semibold uppercase tracking-[0.14em]"
style={{
color: color.text,
backgroundColor: color.bg,
borderColor: color.border,
}}
>
{code}
</span>
);
}
+32
View File
@@ -539,6 +539,38 @@ export interface Ack {
patientControlNumber?: string | null;
}
// ---------------------------------------------------------------------------
// TA1 (Interchange Acknowledgment) — mirrors the `ta1_acks` table
// and the `/api/ta1-acks` response. A TA1 is the lowest-level X12
// envelope ack: one per inbound ISA/IEA interchange, separate from
// the per-batch 999. Colorado Medicaid currently doesn't ship TA1s
// in the FromHPE path (the inbound MFT only carries 999s) but
// Gainwell has shipped them historically, so the UI shows them
// whenever one is on file.
// ---------------------------------------------------------------------------
/**
* One persisted TA1 ACK row, camelCased for the UI. Re-shaped in
* `src/lib/api.ts` (`mapTa1Ack`) from the snake_case backend payload.
*
* ``ackCode`` is the interchange ack code: ``A`` = accepted,
* ``E`` = accepted with envelope errors, ``R`` = rejected.
* ``noteCode`` is an optional 3-digit X12 note code (e.g. ``000`` = no
* error, ``001`` = unsupported interchange version, etc.).
*/
export interface Ta1Ack {
id: number;
controlNumber: string;
ackCode: "A" | "E" | "R";
noteCode: string | null;
interchangeDate: string | null;
interchangeTime: string | null;
senderId: string | null;
receiverId: string | null;
sourceBatchId: string;
parsedAt: string | null;
}
// ---------------------------------------------------------------------------
// SP4 claim detail drawer types (Task 4).
// Mirrors the JSON shape of `GET /api/claims/{claim_id}` 1:1 — camelCase