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:
Nora
2026-07-06 09:58:26 -06:00
parent b0e06a2dd0
commit 3bc5740e8b
3 changed files with 279 additions and 1 deletions
+148
View File
@@ -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");
});
});
+98
View File
@@ -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
View File
@@ -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() {