diff --git a/src/lib/x12-detect.test.ts b/src/lib/x12-detect.test.ts new file mode 100644 index 0000000..653f311 --- /dev/null +++ b/src/lib/x12-detect.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from "vitest"; +import { + detectKindFromText, + detectKindFromFile, + detectedKindToParsedBatchKind, +} from "./x12-detect"; + +// Minimal X12 envelopes — just enough to exercise the ST01 match. +// Real fixtures live in backend/tests/fixtures; we use synthetic ones +// here because the frontend doesn't need the full file content, just +// the first ~4KB. + +const ISA_837P = ( + "ISA*00* *00* *ZZ*SENDER *ZZ*RECEIVER " + + "*260617*1937*^*00501*000000001*0*P*:~" + + "GS*HC*SENDER*RECEIVER*20260706*1937*1*X*005010X222A1~" + + "ST*837*0001*005010X222A1~" +); +const ISA_835 = ( + "ISA*00* *00* *ZZ*SENDER *ZZ*RECEIVER " + + "*260617*1937*^*00501*000000001*0*P*:~" + + "GS*HP*SENDER*RECEIVER*20260706*1937*1*X*005010X221A1~" + + "ST*835*0001~" +); +const ISA_999 = ( + "ISA*00* *00* *ZZ*SENDER *ZZ*RECEIVER " + + "*260617*1937*^*00501*000000001*0*P*:~" + + "GS*FA*SENDER*RECEIVER*20260706*1937*1*X*005010X231A1~" + + "ST*999*0001~" +); +const ISA_277CA = ( + "ISA*00* *00* *ZZ*SENDER *ZZ*RECEIVER " + + "*260617*1937*^*00501*000000001*0*P*:~" + + "GS*HN*SENDER*RECEIVER*20260706*1937*1*X*005010X214~" + + "ST*277*0001*005010X214~" +); +const ISA_277CA_QUALIFIED = ( + "ISA*00* *00* *ZZ*SENDER *ZZ*RECEIVER " + + "*260617*1937*^*00501*000000001*0*P*:~" + + "GS*HN*SENDER*RECEIVER*20260706*1937*1*X*005010X214~" + + "ST*277CA*0001*005010X214~" +); +const TA1_FILE = ( + "ISA*00* *00* *ZZ*SENDER *ZZ*RECEIVER " + + "*260520*1750*^*00501*000000001*0*P*:~" + + "TA1*000000001*20260520*1750*A*000*20260520~" + + "IEA*1*000000001~" +); + +describe("detectKindFromText", () => { + it("detects ST*837* as 837p", () => { + expect(detectKindFromText(ISA_837P)).toBe("837p"); + }); + + it("detects ST*837P* as 837p (professional qualifier)", () => { + const withQualifier = ISA_837P.replace("ST*837*", "ST*837P*"); + expect(detectKindFromText(withQualifier)).toBe("837p"); + }); + + it("detects ST*835* as 835", () => { + expect(detectKindFromText(ISA_835)).toBe("835"); + }); + + it("detects ST*999* as 999", () => { + expect(detectKindFromText(ISA_999)).toBe("999"); + }); + + it("detects ST*277* as 277ca", () => { + expect(detectKindFromText(ISA_277CA)).toBe("277ca"); + }); + + it("detects ST*277CA* as 277ca (qualified form)", () => { + expect(detectKindFromText(ISA_277CA_QUALIFIED)).toBe("277ca"); + }); + + it("detects TA1 envelope as ta1 (no ST, bare TA1 segment)", () => { + expect(detectKindFromText(TA1_FILE)).toBe("ta1"); + }); + + it("returns unknown for garbage input", () => { + expect(detectKindFromText("not edi at all")).toBe("unknown"); + }); + + it("returns unknown for an empty string", () => { + expect(detectKindFromText("")).toBe("unknown"); + }); + + it("is case-insensitive on ST segment", () => { + // Real X12 is uppercase, but be lenient — files from various tools + // sometimes have mixed case. + expect(detectKindFromText(ISA_835.toLowerCase())).toBe("835"); + }); + + it("does not false-positive on ST*8370 (5 chars after ST*)", () => { + // Catches a regression where the matcher would substring-match too + // eagerly (e.g. matching ST*837 against ST*8370*). The require is + // that the char after the digits is `*`, not another digit. + const noise = "ISA*00*...*ST*8370*0001~"; + expect(detectKindFromText(noise)).toBe("unknown"); + }); +}); + +describe("detectedKindToParsedBatchKind", () => { + it("maps 837p → 837p", () => { + expect(detectedKindToParsedBatchKind("837p")).toBe("837p"); + }); + + it("maps 835 → 835", () => { + expect(detectedKindToParsedBatchKind("835")).toBe("835"); + }); + + it("returns null for 999 (Upload page doesn't ingest 999s)", () => { + expect(detectedKindToParsedBatchKind("999")).toBeNull(); + }); + + it("returns null for 277ca", () => { + expect(detectedKindToParsedBatchKind("277ca")).toBeNull(); + }); + + it("returns null for ta1", () => { + expect(detectedKindToParsedBatchKind("ta1")).toBeNull(); + }); + + it("returns null for unknown", () => { + expect(detectedKindToParsedBatchKind("unknown")).toBeNull(); + }); +}); + +describe("detectKindFromFile", () => { + it("detects 837p from a File blob", async () => { + const file = new File([ISA_837P], "test.x12", { type: "text/plain" }); + expect(await detectKindFromFile(file)).toBe("837p"); + }); + + it("detects 835 from a File blob (the original SP35 repro)", async () => { + // The exact scenario the user hit: drop an 835 file on the Upload + // page while the dropdown still says "837p". The helper must + // return "835" so the UI can switch the dropdown before the user + // hits Parse. + const file = new File([ISA_835], "tp11525703-835.x12", { type: "text/plain" }); + expect(await detectKindFromFile(file)).toBe("835"); + }); + + it("returns unknown on an empty file (no crash)", async () => { + const file = new File([""], "empty.x12", { type: "text/plain" }); + expect(await detectKindFromFile(file)).toBe("unknown"); + }); +}); \ No newline at end of file diff --git a/src/lib/x12-detect.ts b/src/lib/x12-detect.ts new file mode 100644 index 0000000..2ed0bb3 --- /dev/null +++ b/src/lib/x12-detect.ts @@ -0,0 +1,98 @@ +/** + * X12 file-kind auto-detection helper. + * + * SP35: the Upload page used to default to "837p" silently — dropping an 835 + * file on the page while the dropdown still said "837p" routed the file to + * /api/parse-837, which silently persisted an empty batch. Layer A of the + * defense-in-depth fix is to detect the kind from the file's first few KB + * and switch the dropdown automatically before the user clicks Parse. + * + * Layer B is the server-side envelope check (cyclone.api._transaction_set_id_from_segments). + * This helper is purely advisory: if it returns null, the UI keeps whatever + * the user picked and the server guard rejects with a clean 400 if it's wrong. + * + * Detection is intentionally cheap: read the first ~4KB and look for the ST + * segment (or the bare TA1 segment, which has no ST envelope). The full file + * is never loaded — X12 envelopes are always within the first ISA segment. + */ + +import type { ParsedBatchKind } from "@/types"; + +/** Number of bytes to read from the start of the file for detection. */ +const DETECT_BYTES = 4096; + +export type DetectedKind = ParsedBatchKind | "999" | "277ca" | "ta1" | "unknown"; + +export function detectKindFromText(text: string): DetectedKind { + // Strip whitespace and look for the ST segment. + // X12 envelopes are always within the first segment, so we don't need + // to scan far. The segment terminator is `~` (per the ISA segment, + // which we also don't need to parse here — we only care which kind of + // payload follows). + // + // We do a simple substring search rather than full tokenization because: + // - Detection must run before the user even knows if their file is + // valid X12. If we ran `tokenize()` and the file has a malformed + // ISA, we'd throw and the UI couldn't show a helpful message. + // - A simple prefix match on `ST**` is enough to disambiguate + // every kind the Upload page might receive. False positives are + // caught by the server-side guard. + const upper = text.toUpperCase(); + + // 277CA uses ST*277* or ST*277CA* (per parse_277ca.py line 13 comment). + // Check 277CA before 277 because ST*277CA contains "277". + if (upper.includes("ST*277CA*")) return "277ca"; + if (upper.includes("ST*277*")) return "277ca"; + + // 837P accepts ST*837* or ST*837P* (the trailing P is the professional + // claim qualifier, but the ISA envelope alone tells you it's a + // professional file). Check 837P before 837 for the same reason. + if (upper.includes("ST*837P*")) return "837p"; + if (upper.includes("ST*837*")) return "837p"; + + // 835 has ST*835* — no other qualifier in common use. + if (upper.includes("ST*835*")) return "835"; + + // 999 ACK has ST*999* — no qualifier. + if (upper.includes("ST*999*")) return "999"; + + // TA1 has no ST envelope. The interchange-ack segment is the bare TA1* + // immediately after ISA/IEA. Match the segment header. + if (/\bTA1\*/.test(upper)) return "ta1"; + + return "unknown"; +} + +/** + * Browser-side helper: read the first DETECT_BYTES from a File and detect + * its kind. Returns a Promise so it composes naturally with FileReader / + * Blob.slice. Returns "unknown" on read error so the caller can keep the + * user's current selection and let the server guard surface a clean 400. + */ +export async function detectKindFromFile(file: File): Promise { + try { + const blob = file.slice(0, DETECT_BYTES); + const text = await blob.text(); + return detectKindFromText(text); + } catch { + return "unknown"; + } +} + +/** + * Map a DetectedKind to the ParsedBatchKind the Upload dropdown supports. + * Returns null for kinds the Upload page can't ingest (999, 277CA, TA1) — + * the UI then surfaces a "this file isn't supported here" hint instead of + * silently misrouting it. The server-side guards in cyclone.api still + * catch any escape. + */ +export function detectedKindToParsedBatchKind(kind: DetectedKind): ParsedBatchKind | null { + switch (kind) { + case "837p": + return "837p"; + case "835": + return "835"; + default: + return null; + } +} \ No newline at end of file diff --git a/src/pages/Upload.tsx b/src/pages/Upload.tsx index 64e82f3..9ec8c11 100644 --- a/src/pages/Upload.tsx +++ b/src/pages/Upload.tsx @@ -33,6 +33,10 @@ import { StatPill, ValidationDot } from "@/components/ClaimCard/shared"; import { api, ApiError, type BatchSummary, type ParseProgress } from "@/lib/api"; import { downloadBlob } from "@/lib/download"; import { fmt, toNum } from "@/lib/format"; +import { + detectKindFromFile, + detectedKindToParsedBatchKind, +} from "@/lib/x12-detect"; import { useAppStore } from "@/store"; import { useParse } from "@/hooks/useParse"; import { useBatchExport } from "@/hooks/useBatchExport"; @@ -437,9 +441,37 @@ export function Upload() { // hook call above. Keeping the comment as a breadcrumb for future // readers who grep "Auto-select".) - function pickFile(f: File | null) { + async function pickFile(f: File | null) { setFile(f); setStream({ items: [], expectedTotal: null, passed: 0, failed: 0 }); + if (!f) return; + // SP35: auto-detect the kind from the file's first few KB and switch + // the dropdown before the user hits Parse. This is layer A of the + // defense-in-depth fix (layer B is the server-side envelope guard + // in cyclone.api). Before SP35 the dropdown defaulted to "837p" + // silently — dropping an 835 file on the page routed it to + // /api/parse-837 and produced a bogus empty batch. Detection runs + // asynchronously on a 4KB slice; if it can't determine the kind + // (unknown file, read error) we keep the user's current selection + // and the server guard will surface a clean 400 if it's wrong. + const detected = await detectKindFromFile(f); + const matched = detectedKindToParsedBatchKind(detected); + if (matched && matched !== kind) { + setKind(matched); + setPayer(matched === "837p" ? PAYERS_837[0]!.value : PAYERS_835[0]!.value); + toast.message( + `Detected ${matched === "837p" ? "837P" : "835"} file — switched the dropdown.`, + { description: f.name }, + ); + } else if (detected === "999" || detected === "277ca" || detected === "ta1") { + // The Upload page only supports 837P and 835; for the other X12 + // kinds (which have their own endpoints), surface a hint instead + // of silently leaving the dropdown on whatever the user picked. + toast.error( + `Detected ${detected.toUpperCase()} file — the Upload page only accepts 837P or 835.`, + { description: "Use the matching endpoint from the History tab or the API." }, + ); + } } async function onParse() {