feat(sp35): Upload page auto-detects 837P vs 835 from file content
Layer A of the SP35 defense-in-depth fix. Before SP35 the dropdown
silently defaulted to '837p' and never changed when a file was dropped
on the page — uploading an 835 file routed it to /api/parse-837 which
(prior to SP35 Task 2) silently persisted an empty batch.
The change:
1. New pure helper src/lib/x12-detect.ts:
- detectKindFromText(text) reads the first ~4KB and returns the
DetectedKind ('837p' | '835' | '999' | '277ca' | 'ta1' | 'unknown')
by matching the ST01 segment (or the bare TA1 segment for the
no-ST envelope). Cheap substring scan; never invokes tokenize().
- detectKindFromFile(file) is the File-aware wrapper used by the UI.
- detectedKindToParsedBatchKind maps the DetectedKind to the kind
the Upload dropdown supports. Returns null for 999/277CA/TA1 so
the UI can surface a clean 'this file isn't supported here' hint.
2. Upload.tsx: pickFile is now async and reads the file before storing
it. If the detected kind differs from the dropdown's current value,
it switches the dropdown and toasts a hint. If the detected kind is
999/277CA/TA1 (Upload doesn't ingest those), it shows an error toast.
20 new tests in src/lib/x12-detect.test.ts cover the 6 DetectedKind
paths, the File wrapper, case-insensitivity, garbage input, the
ST*8370 false-positive guard, and the detectedKindToParsedBatchKind
mapping.
This commit is contained in:
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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*<kind>*` 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<DetectedKind> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+33
-1
@@ -33,6 +33,10 @@ import { StatPill, ValidationDot } from "@/components/ClaimCard/shared";
|
|||||||
import { api, ApiError, type BatchSummary, type ParseProgress } from "@/lib/api";
|
import { api, ApiError, type BatchSummary, type ParseProgress } from "@/lib/api";
|
||||||
import { downloadBlob } from "@/lib/download";
|
import { downloadBlob } from "@/lib/download";
|
||||||
import { fmt, toNum } from "@/lib/format";
|
import { fmt, toNum } from "@/lib/format";
|
||||||
|
import {
|
||||||
|
detectKindFromFile,
|
||||||
|
detectedKindToParsedBatchKind,
|
||||||
|
} from "@/lib/x12-detect";
|
||||||
import { useAppStore } from "@/store";
|
import { useAppStore } from "@/store";
|
||||||
import { useParse } from "@/hooks/useParse";
|
import { useParse } from "@/hooks/useParse";
|
||||||
import { useBatchExport } from "@/hooks/useBatchExport";
|
import { useBatchExport } from "@/hooks/useBatchExport";
|
||||||
@@ -437,9 +441,37 @@ export function Upload() {
|
|||||||
// hook call above. Keeping the comment as a breadcrumb for future
|
// hook call above. Keeping the comment as a breadcrumb for future
|
||||||
// readers who grep "Auto-select".)
|
// readers who grep "Auto-select".)
|
||||||
|
|
||||||
function pickFile(f: File | null) {
|
async function pickFile(f: File | null) {
|
||||||
setFile(f);
|
setFile(f);
|
||||||
setStream({ items: [], expectedTotal: null, passed: 0, failed: 0 });
|
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() {
|
async function onParse() {
|
||||||
|
|||||||
Reference in New Issue
Block a user