diff --git a/README.md b/README.md
index f7fcdb4..54fa898 100644
--- a/README.md
+++ b/README.md
@@ -50,12 +50,22 @@ is a browser app and not a service.
or `BHT-06` and read for what they mean: `2010BB` is the data receiver rather than a
payer, the payer that actually paid is the `2330B` inside each `2320`, and the Claims
tab gains eight columns for what was paid, adjusted and by whom.
+- **835 remittance advice** — `005010X221A1` files are recognised from `ST-01` and read
+ as a different transaction, not a variant of the 837: `N1*PR`/`N1*PE` open the payer
+ and payee, `LX` opens an optional grouping of claims rather than a service line, `CLP`
+ opens a claim and `SVC` a service line's payment, and `PLB` a transaction-level
+ provider balance. The Claims tab stays empty for an 835 by design — there is no
+ submission to flatten — and the Limited Data Set does not reach its patient
+ identifiers, since they sit outside the loops the rule targets.
- **Checks** — control counts and envelope integrity (`SE-01`, `GE-01`, `IEA-01` and
matching control numbers), plus claim balance against service-line charges, compared
as whole cents so a balanced file is never reported as off by a rounding error. For a
post-adjudicated report, also whether each payer's claim total matches its own service
lines, whether each line's adjudication accounts for the charge, and whether every
- `SVD-01` names a payer the claim identifies.
+ `SVD-01` names a payer the claim identifies. For an 835, whether each line's payment
+ reconciles against its adjustments, whether each claim's total does, and whether the
+ stated payment equals every claim's payment minus every provider-level balance
+ adjustment — under the same rules for a reversed claim as any other.
- **Mask** — hides names, addresses, dates of birth and member IDs on screen, for
screen-sharing a real file. Display only; the file you download is untouched.
@@ -111,6 +121,7 @@ The parser is generic X12; the claim-aware features are 837-specific.
| --- | --- |
| **837P** professional claims (005010X222A1) | Full — loop resolution, claim table, validation, Limited Data Set |
| **837 PACDR** professional post-adjudicated reports (005010X298) | Full — the above, plus adjudication columns and the three balancing checks |
+| **835** remittance advice (005010X221A1) | Reading — loop resolution and the three balancing checks; no claim table, no Limited Data Set, no CSV export |
| **Any X12 interchange** | Parse, browse, inspect, find & replace, control-count checks |
Delimiters are read from the `ISA` header rather than assumed, so a file using `|` and
@@ -125,6 +136,7 @@ newlines parses as readily as `*` and `~`.
| `web/bench/` | Benchmark over synthetic interchanges up to 150 MB. |
| `web/PERFORMANCE.md` | Measured limits and the ordered backlog for improving them. |
| `docs/pacdr-validation.md` | What the post-adjudicated checks do, what they deliberately don't, and why. |
+| `docs/835-validation.md` | What the 835 balancing checks do, what they deliberately don't, and why. |
| `edi_engine/`, `ui/`, `app.py`, `tests/` | The original Python/Tkinter prototype. |
The Python program is not the backend for the browser app — the two are independent
@@ -141,7 +153,7 @@ python -m unittest # Python prototype
No dependencies for either. The parity suite shells out to Python to regenerate its
reference output, so run it with both available.
-Sample 837 files for manual testing live in `web/tests/samples/`, with a README
+Sample 837 and 835 files for manual testing live in `web/tests/samples/`, with a README
tabulating what each one exercises. All of them are synthetic — deliberately so, since
they are committed.
diff --git a/docs/835-validation.md b/docs/835-validation.md
new file mode 100644
index 0000000..5259645
--- /dev/null
+++ b/docs/835-validation.md
@@ -0,0 +1,173 @@
+# 835 remittance advice: what the checks do, and why
+
+Why the three 835 checks in `web/EDIWorkbench.html` are shaped the way they
+are — in particular why one line in the transaction check reads backwards on
+purpose, why `Σ SVC-03 = CLP-04` is not implemented even though it looks like
+the obvious fourth check, and why a reversal claim is not treated as a special
+case anywhere in the arithmetic.
+
+The code states what each check does. This states what it refuses to do,
+which is the part that reads as an oversight from the code alone.
+
+## The governing rule here is not PACDR's
+
+PACDR's rule was *"detected → do more, never do differently"*, and it held
+because `005010X298` is structurally the same transaction as the 837P
+submission the app was built around. **It does not hold for an 835.** An 835
+is a different transaction, not a variant of one already supported, and the
+probe that shaped this work found the default path actively wrong on one:
+`LX` opened a group labelled "Line 1" — in an 837 `LX` anchors a service line,
+but in an 835 it opens loop 2000, a grouping of *claims*. `NM1*82` opened a
+group labelled "Rendering provider" and then swallowed both service lines,
+their adjustments, and `PLB` — a transaction-level segment that must never sit
+inside anything.
+
+So for an 835 transaction the 837 anchors (`LX`, `CLM`, the `NM1` entity
+loops) are switched off and replaced with 835 loop resolution, gated on
+`ST-01 === "835"` per transaction. A file with no 835 in it runs exactly the
+code it ran before this existed, and the gate being per-`ST` is what lets a
+mixed interchange resolve an 837P and an 835 side by side, each correctly.
+
+`005010X221A1` is detected directly from `ST-01`, which names the transaction
+set outright — there is no guide-detection problem the way PACDR has one, and
+this is deliberately not folded into `guideOf`/`GUIDE_PACDR`, which answers a
+different question ("which 837 implementation guide").
+
+## Everything here is a warning, not an error
+
+All three findings are warnings, same posture as PACDR's. They test whether
+the numbers an 835 states agree with each other — never conformance to any
+particular recipient's companion guide, which the app has no way to know a
+file is bound for.
+
+## The three checks
+
+**Line** — `SVC-02` minus the `CAS` adjustments inside that `SVC`'s own 2110
+should equal `SVC-03`.
+
+**Claim** — `CLP-03` minus every `CAS` on the claim, claim-level *and*
+line-level together, should equal `CLP-04`. This is where a stray CAS repeat
+between claims would be attributed wrong, so accumulation is scoped to
+whichever claim is currently open, the same discipline PACDR's line
+reconciliation already uses for its own `CAS` sums.
+
+**Transaction** — `BPR-02` should equal every claim's `CLP-04` minus every
+`PLB-04` on the transaction. Stands down unless `BPR-03` states a credit
+(`"C"`) and the transaction carries at least one claim; a debit transaction or
+one reporting nothing paid is a different shape this pass does not assert
+anything about.
+
+## The PLB sign convention — the thing most likely to be "fixed" into being wrong
+
+**A *positive* `PLB-04` amount *reduces* what the payer pays; a negative one
+increases it.** The check is therefore `BPR-02 = Σ CLP-04 − Σ PLB-04`, not
+`+`. It reads backwards, which is exactly why it will look like a bug to
+whoever reads the code next — a positive number in an amount field usually
+means *more* money, and here it means less.
+
+This is 835's equivalent of PACDR's "the line check is a disjunction, not a
+chain" lesson: the version that looks obviously right is wrong, so it gets
+three separate treatments rather than one, because any one of them alone gets
+silently dropped in a later edit:
+
+1. A comment directly at the subtraction in `validateDocument`, naming the
+ convention and pointing here.
+2. This document, explaining *why* the sign runs this way — a `PLB` records a
+ provider-level adjustment such as a prior overpayment or a withheld
+ amount, which comes *out of* what would otherwise be paid.
+3. A test in `validate.mjs`, named for the convention itself — "a positive
+ PLB reduces the payment" — that asserts the *detail text* of the finding,
+ not just that one fires. A flipped sign still produces a finding on the
+ fixture (the numbers no longer match either way), so a test that only
+ checked pass/fail would not catch the regression; the detail text names
+ the specific total the check computed, and that total is different under
+ `+` than under `−`. This is the actual enforcement. It was verified by
+ hand: flipping the subtraction to addition in the engine and re-running
+ `validate.mjs` turns that assertion — and several others whose fixtures
+ happen to still disagree under the wrong sign — red; flipping it back
+ turns the suite green again.
+
+## Reversals balance under the same three rules — there is no `CLP-02` gate
+
+`CLP-02 = 22` marks a claim as a reversal of a previously reported payment.
+**Nothing in any of the three checks reads `CLP-02` at all.** A reversal is
+the original claim with every amount's sign flipped, and the arithmetic above
+already handles a negative amount correctly: `toCents` parses a leading `-`,
+and subtraction does not care which side of zero its operands sit on.
+
+This was tested deliberately rather than assumed. The committed fixture's
+second claim is a reversal carrying negative `CLP-03`/`CLP-04`/`SVC-02`/
+`SVC-03`/`CAS` values throughout, and it is required to validate cleanly
+alongside the first, ordinary claim. Breaking the reversal's own `CAS` is
+caught exactly the way breaking the original claim's would be — same finding,
+same shape, only the sign of the numbers in the detail differs.
+
+## What was cut, and why
+
+**`Σ SVC-03 = CLP-04`.** Believed to hold wherever line-level detail exists —
+if every 2110 under a claim states what was paid on it, the sum should equal
+what the claim states was paid overall — but the loop inventory marks this
+`[?]`, unconfirmed against any public source, and the user-set posture for
+this feature (matching PACDR's) is that nothing ships on an unconfirmed fact.
+It costs nothing to add later: it does not interact with the three checks
+above, since none of them sums `SVC-03` across a claim.
+
+**Debit (`BPR-03 = "D"`) and zero-payment transactions.** Also `[?]`. The
+transaction check simply stands down for both rather than guessing at what
+either shape's arithmetic should look like.
+
+**`TS3`/`TS2` element semantics, and the exact repeat count/shapes `PLB`
+takes in practice.** Neither is asserted anywhere; `TS3` and `TS2` are named
+leaves only (segment names for the inspector), and `PLB` is read generically
+as up to six repeating reason/amount pairs, which is what the segment's
+definition allows rather than what any specific sender is known to send.
+
+**CARC / RARC code-list validity, and any `PLB-03` reason-code table.** Same
+argument as PACDR's `CN1-01`/`CN1-04`: those lists change quarterly, and a
+stale copy embedded in a single-file app is worse than no check. `LQ` — the
+segment that carries an 835's remark codes — keeps its generic 837 name,
+"Form Identification", rather than being renamed for 835's use: it is also
+the opener of 837 loop 2440, and renaming it here would misdescribe that row.
+
+**Any individual payer's submission conventions**, `BPR` banking-detail
+validation (routing/account number formats), and NPI check digits — the same
+reasoning the app already applies to 837P and PACDR: this is a structural
+reader, not a conformance checker for one recipient's rules.
+
+## What else the 835 support does and does not do
+
+**The Claims tab is empty for an 835 by design, not by accident.**
+`buildClaimIndex` needed no code change for this — audited, not assumed: its
+`emitLine` returns early whenever `clm === -1`, and an 835 never carries a
+`CLM` segment, so `clm` never leaves `-1` for the whole walk. `CLP`, `SVC`,
+`N1` and `PLB` all match none of that function's cases and are silently
+skipped. The Claims tab flattens a provider's *submission* into one row per
+service line; an 835 is not a submission, so there is nothing to flatten, and
+an empty tab is the honest answer rather than a bug.
+
+**The Limited Data Set does not reach an 835's patient identifiers.** Also no
+code change, audited the same way: the rule's identifier-bearing loops are
+`PERSON_LOOPS` — 2010BA, 2010CA, 2330A — reached through `buildTree`'s
+`NM1`-keyed entity-loop resolution. An 835's own `NM1` (inside loop 2100:
+`NM1*QC` patient, `NM1*IL` insured, `NM1*82` rendering provider, and so on)
+opens no loop at all under this feature — see "the governing rule" above —
+so `scrubLoop` never reaches one of these NM1s, and `PERSON_LOOPS` itself
+never names an 835 loop id to begin with. An 835 handed through Limited Data
+Set comes back byte-identical to what went in, which is worth stating loudly
+in the app's own UI copy: silence here would hand a user a file they believe
+is scrubbed.
+
+## Sources, and what could not be used
+
+The X12 TR3 for `005010X221A1` is paywalled and was **not read**. Nothing
+here or in the code derives from it, and — unlike PACDR, which cross-read two
+published state companion guides — no companion guide was used for this pass
+either; the user's posture, set explicitly for this feature, was to proceed
+on model knowledge and cut or stand down anything not certain, rather than
+wait on sourcing. What is asserted here is limited to loop ids, segment ids,
+element positions and the balancing arithmetic the 835 transaction's own
+definition implies — functional facts about a data format, the same posture
+the app has always taken for 837P and PACDR.
+
+The fixture (`tests/fixtures/sample_835.edi`) is **wholly invented**: payer
+and provider names, member ids, control numbers, and every dollar amount.
diff --git a/tests/fixtures/sample_835.edi b/tests/fixtures/sample_835.edi
new file mode 100644
index 0000000..6d229ec
--- /dev/null
+++ b/tests/fixtures/sample_835.edi
@@ -0,0 +1,35 @@
+ISA*00* *00* *ZZ*PAYERID *ZZ*PROVIDERID *230101*1200*^*00501*000000001*0*T*:~
+GS*HP*PAYERID*PROVIDERID*20230101*1200*1*X*005010X221A1~
+ST*835*0001~
+BPR*I*135*C*ACH*CCP*01*111000025*DA*987654321*1234567890**01*111000025*DA*555555555*20230105~
+TRN*1*12345678901*1234567890~
+DTM*405*20230103~
+N1*PR*QUILLFEATHER HEALTH PLAN~
+N3*100 PAYER WAY~
+N4*ANYTOWN*CA*90210~
+PER*BL*CLAIMS DEPT*TE*5551234567~
+N1*PE*BILLING PROVIDER NAME*XX*1234567893~
+N3*123 MAIN ST~
+N4*ANYTOWN*CA*90210~
+REF*TJ*123456789~
+LX*1~
+CLP*PATIENTACCTNUM*1*300*240*60*CI*QF98001234*11~
+NM1*QC*1*NETHERFIELD*ORLANDO****MI*QF4820731~
+NM1*82*1*RENDERINGLAST*RENDERINGFIRST****XX*1234567893~
+DTM*232*20230101~
+SVC*HC:99213*200*160**1~
+DTM*472*20230101~
+CAS*PR*2*40~
+AMT*B6*200~
+SVC*HC:87070*100*80**1~
+DTM*472*20230101~
+CAS*PR*2*20~
+CLP*PATIENTACCTNUM*22*-100*-80*-20*CI*QF98001234*12~
+NM1*QC*1*NETHERFIELD*ORLANDO****MI*QF4820731~
+SVC*HC:99213*-100*-80**1~
+DTM*472*20230101~
+CAS*PR*2*-20~
+PLB*1234567893*20231231*L6:12345*25~
+SE*31*0001~
+GE*1*1~
+IEA*1*000000001~
diff --git a/web/EDIWorkbench.html b/web/EDIWorkbench.html
index 0ea685b..08a46a6 100644
--- a/web/EDIWorkbench.html
+++ b/web/EDIWorkbench.html
@@ -1390,6 +1390,7 @@
EDI* WORKBENCH
Load a sample 837P
…or a sample PACDR 837
+ …or a sample 835
@@ -1560,7 +1561,7 @@ No interchange loaded
-
Read an 837, or turn one into test data.
+
Read an 837 or an 835, or turn an 837 into test data.
Load an interchange and it is decoded, indexed and checked in this tab. Change it and you get a byte-faithful copy back — whitespace, delimiters, and every segment you didn't touch preserved exactly as they came in.
@@ -1595,6 +1596,14 @@ Post-adjudicated reports
Which payer is primary comes from SBR-01, never from the order the loops appear in: a report is free to emit the secondary payer's block first, and real ones do.
+
+ 835 remittance advice
+ 005010X221A1 is a different transaction from the 837, not a variant of it: a payer telling a provider what it paid, adjusted and why, rather than a provider submitting a claim. It is recognised from ST-01, which names the transaction set outright, so there is no guide-detection question the way PACDR has one.
+ Its loops replace the 837's rather than reusing them. N1*PR and N1*PE — not NM1 — open the payer and payee. LX opens an optional grouping of claims (loop 2000), not a service line. CLP opens a claim (2100) and SVC a service line's payment (2110). PLB, provider-level balance, sits after every claim and before SE — a transaction-level adjustment, never inside one.
+ The Claims tab stays empty for an 835 by design: it flattens a provider's submission into one row per service line, and there is no submission here to flatten. Checks reads the payment instead — see below.
+ Reading only, in this version. Nothing here decodes a CARC or RARC reason code, and the Limited Data Set does not reach an 835's patient identifiers — NM1*QC/NM1*IL sit directly inside loop 2100, outside the loops that rule targets, so an 835 handed through Limited Data Set comes back byte-identical, not scrubbed.
+
+
What Checks looks at
Three kinds of problem, all of which are invisible until someone else rejects the file.
@@ -1606,6 +1615,8 @@ What Checks looks at
Post-adjudicated claims
Only for a PACDR transaction, and all warnings rather than errors. Each payer's 2320 AMT*D is compared against the SVD-02 amounts on its own 2430s — but only where that payer reported every line, since adjudicating at claim level alone is legal. Each 2430 has to account for something: its SVD-02 plus its CAS adjustments must come to either the line charge or a patient responsibility another payer left on the same line. And every SVD-01 must name a payer the claim identifies in a 2330B.
Deliberately a choice of two rather than a chain from payer to payer: two payers can each adjudicate the whole charge independently, and where the only adjustment is contractual there is no patient responsibility to chain from at all. What is checked is arithmetic and cross-referencing inside the file. Nothing here validates a file against a particular recipient's companion guide — contract codes, trading partner id formats, remark codes and which payer must appear are that recipient's policy, they differ between them, and encoding one would make the app wrong for everyone else.
+ 835 balancing
+ Only for an 835, and all warnings. Per line, SVC-02 minus its CAS adjustments should equal SVC-03. Per claim, CLP-03 minus every CAS on it — claim-level and line-level together — should equal CLP-04. And once BPR-03 states a credit, the total payment should equal every claim's CLP-04 minus every PLB-04 adjustment on the transaction — a positive PLB-04 reduces what was paid. A reversed claim (CLP-02 = 22) balances under the same three rules; nothing here assumes a positive amount. A blank amount stands the relevant check down silently; one that is present but unreadable is reported on its own and stands it down as well.
Selecting a finding jumps to the segment it came from. Nothing here changes the file — but if a rule ever adds or removes a segment, the control counts are corrected automatically on the way out, since every count above the change would otherwise be wrong.
@@ -1662,6 +1673,10 @@ Loops
Each loop, the segment that opens it, and where it sits. Generated from the loop tables the Tree tab resolves against.
+ 835 loops
+ The remittance advice's own five loops, listed separately: several loop ids mean something different here than they do in an 837 above — 1000A is the payer, not the submitter — so relabelling under an alternate name would misdescribe the relationship rather than clarify it.
+
+
Segments
What each segment id stands for. These are the names shown beside every row in the Document tab.
@@ -2158,6 +2173,31 @@ Using it with real data
return segments.some((seg, i) => seg.id === "ST" && guideOf(segments, i) === GUIDE_PACDR);
}
+/* ---- 835 remittance advice --------------------------------------------
+ *
+ * 835 Health Care Claim Payment/Advice, 005010X221A1. Detection is
+ * unconditional: ST-01 names the transaction set outright ("835"), so there
+ * is no guide-detection problem the way PACDR has one. This is a different
+ * question from guideOf/GUIDE_PACDR -- "which 837 implementation guide" --
+ * and is deliberately not folded into that machinery.
+ *
+ * An 835 is a different transaction from the 837 the rest of this file is
+ * built around, not a variant of it: LX opens loop 2000 (an optional
+ * grouping of claims), not a service line; CLP/SVC/N1/PLB replace
+ * CLM/SV1/NM1's entity loops; and PLB sits at the transaction level, never
+ * inside a claim. So the 837 anchors are switched off for an 835 transaction
+ * and replaced, rather than added to -- see buildTree and validateDocument,
+ * both gated on this constant per ST, which is what lets a mixed
+ * interchange resolve each transaction correctly.
+ */
+const TXN_835 = "835";
+
+// True when any transaction in the file is an 835, for the parts of the UI
+// that only make sense once one is present.
+function has835(segments) {
+ return segments.some((seg) => seg.id === "ST" && seg.elements[0] === TXN_835);
+}
+
// Read-only structural checks. Nothing here mutates the document; the
// result is a list of findings for the UI to render.
//
@@ -2297,6 +2337,88 @@ Using it with real data
claim = null;
};
+ // 835 remittance advice, tracked only while a transaction identifies as
+ // one from ST-01. clp835 and svc835 hold the currently open claim and
+ // line while their CAS adjustments accumulate against them; PLB never
+ // opens either, since it is transaction-level and sits after every claim.
+ let txn835 = false, bpr02 = "", bpr03 = "", bprAt = -1;
+ let clp835 = null, svc835 = null;
+ let clpTotal = 0, clpTotalBad = false, plbTotal = 0, plbTotalBad = false, sawClp = false;
+
+ // Reads one 835 money element for the three checks below. "" is absent,
+ // which is situational and stands the check down without a warning; a
+ // value that is present but unreadable gets its own warning and stands
+ // the check down too -- absent is not the same fault as garbled, and
+ // toCents alone can't tell them apart, since it returns null for both.
+ const readAmount835 = (value, title, detail, at) => {
+ if (value === "") return null;
+ const cents = toCents(value);
+ if (cents === null) add("warning", title, detail, at);
+ return cents;
+ };
+
+ // 1. Line -- SVC-02 minus its CAS adjustments should equal SVC-03.
+ const closeSvc835 = () => {
+ if (!svc835) return;
+ const label = clp835 ? (clp835.id || "(no id)") : "(no claim)";
+ const charge = readAmount835(svc835.charge, "SVC-02 is not a number",
+ `SVC-02 for claim ${label} reads "${svc835.charge}".`, svc835.at);
+ const paid = readAmount835(svc835.paid, "SVC-03 is not a number",
+ `SVC-03 for claim ${label} reads "${svc835.paid}".`, svc835.at);
+ if (charge !== null && paid !== null && !svc835.casBad) {
+ const expected = charge - svc835.casSum;
+ if (expected !== paid) {
+ add("warning", "Line payment does not reconcile",
+ `SVC-02 minus its adjustments on claim ${label} is ${centsToAmount(expected)}, ` +
+ `but SVC-03 states ${centsToAmount(paid)}.`, svc835.at);
+ }
+ }
+ svc835 = null;
+ };
+
+ // 2. Claim -- CLP-03 minus every CAS on the claim, claim-level and line-
+ // level together, should equal CLP-04. Feeds clpTotal for the transaction
+ // check below; an unreadable CLP-04 poisons that sum rather than being
+ // silently dropped from it.
+ const closeClp835 = () => {
+ if (!clp835) return;
+ const label = clp835.id || "(no id)";
+ const charge = readAmount835(clp835.charge, "CLP-03 is not a number",
+ `CLP-03 on claim ${label} reads "${clp835.charge}".`, clp835.at);
+ const paid = readAmount835(clp835.statedPaid, "CLP-04 is not a number",
+ `CLP-04 on claim ${label} reads "${clp835.statedPaid}".`, clp835.at);
+ if (charge !== null && paid !== null && !clp835.casBad) {
+ const expected = charge - clp835.casSum;
+ if (expected !== paid) {
+ add("warning", "Claim payment does not reconcile",
+ `CLP-03 minus its adjustments on claim ${label} is ${centsToAmount(expected)}, ` +
+ `but CLP-04 states ${centsToAmount(paid)}.`, clp835.at);
+ }
+ }
+ if (paid === null) clpTotalBad = true;
+ else clpTotal += paid;
+ clp835 = null;
+ };
+
+ // 3. Transaction -- BPR-02 should equal every claim's CLP-04 minus every
+ // PLB-04 on the transaction. A *positive* PLB-04 *reduces* what was paid,
+ // not increases it -- it reads backwards, which is exactly why the next
+ // reader will "correct" it into a bug. docs/835-validation.md explains why
+ // the sign runs this way. Stands down unless BPR-03 states a credit and
+ // the transaction carries at least one claim; a debit or a zero-payment
+ // transaction is [?] and not asserted here.
+ const checkTransaction835 = () => {
+ if (bpr03 !== "C" || !sawClp || clpTotalBad || plbTotalBad) return;
+ const stated = readAmount835(bpr02, "BPR-02 is not a number", `BPR-02 reads "${bpr02}".`, bprAt);
+ if (stated === null) return;
+ const expected = clpTotal - plbTotal; // not clpTotal + plbTotal -- see above
+ if (expected !== stated) {
+ add("warning", "Total payment does not reconcile",
+ `BPR-02 states ${centsToAmount(stated)}, but every claim's CLP-04 minus every PLB-04 ` +
+ `on the transaction comes to ${centsToAmount(expected)}.`, bprAt);
+ }
+ };
+
segments.forEach((seg, i) => {
switch (seg.id) {
case "ISA":
@@ -2314,6 +2436,10 @@ Using it with real data
st = { at: i, control: seg.elements[1] };
setsInGroup++;
guide = guideOf(segments, i);
+ txn835 = seg.elements[0] === TXN_835;
+ bpr02 = ""; bpr03 = ""; bprAt = -1;
+ clp835 = null; svc835 = null;
+ clpTotal = 0; clpTotalBad = false; plbTotal = 0; plbTotalBad = false; sawClp = false;
break;
case "CLM":
@@ -2372,6 +2498,11 @@ Using it with real data
break;
case "LX":
+ // On an 835, LX opens loop 2000 (an optional grouping of claims),
+ // not a service line, and a new grouping closes whatever claim/line
+ // the previous one left open -- the same boundary CLP and PLB close
+ // on, just one level higher.
+ if (txn835) { closeSvc835(); closeClp835(); break; }
// The line is opened here, by the segment that opens loop 2400,
// rather than by the SV1 inside it. A line missing its SV1 still
// holds 2430s, and those still name payers worth checking -- opening
@@ -2407,6 +2538,30 @@ Using it with real data
break;
case "CAS":
+ // 835's claim/line adjustments: accumulates into whichever of
+ // clp835 or svc835 is currently open -- both, when a line is open,
+ // since the claim total needs every CAS the claim carries. Same
+ // triple-walk as the PACDR block below, reused rather than
+ // rewritten: CAS repeats identically in both formats.
+ if (txn835) {
+ if (clp835 || svc835) {
+ const el = seg.elements;
+ for (let at = 1; at + 1 < el.length; at += 3) {
+ if (!el[at] && !el[at + 1]) continue;
+ const cents = toCents(el[at + 1] || "");
+ if (cents === null) {
+ add("warning", "CAS amount is not a number",
+ `CAS on claim ${clp835 ? (clp835.id || "(no id)") : "(no claim)"} reads "${el[at + 1] || ""}".`, i);
+ if (svc835) svc835.casBad = true;
+ if (clp835) clp835.casBad = true;
+ continue;
+ }
+ if (svc835) svc835.casSum += cents;
+ if (clp835) clp835.casSum += cents;
+ }
+ }
+ break;
+ }
// Only the 2430 currently open, so a claim-level 2320 adjustment is
// never counted against a service line. CAS repeats within itself:
// one group code, then a reason/amount/quantity triple per repeat.
@@ -2431,11 +2586,54 @@ Using it with real data
}
break;
+ /* 835 remittance advice. Nothing below records anything unless the
+ transaction identified as one from ST-01 -- BPR, CLP, SVC and PLB
+ are not 837 segments, so on an 837P or a PACDR report none of these
+ cases can match a real segment id at all. */
+ case "BPR":
+ if (txn835) { bpr02 = seg.elements[1] || ""; bpr03 = seg.elements[2] || ""; bprAt = i; }
+ break;
+
+ case "CLP":
+ if (!txn835) break;
+ closeSvc835();
+ closeClp835();
+ sawClp = true;
+ clp835 = { at: i, id: seg.elements[0] || "", charge: seg.elements[2] || "",
+ statedPaid: seg.elements[3] || "", casSum: 0, casBad: false };
+ break;
+
+ case "SVC":
+ if (!txn835) break;
+ closeSvc835();
+ svc835 = { at: i, charge: seg.elements[1] || "", paid: seg.elements[2] || "",
+ casSum: 0, casBad: false };
+ break;
+
+ case "PLB":
+ if (!txn835) break;
+ closeSvc835();
+ closeClp835();
+ // PLB01 provider id, PLB02 fiscal period end, then up to six
+ // repeating pairs of PLB03 (adjustment reason composite) and PLB04
+ // (amount) -- the amount sits at every odd position from 3 on.
+ for (let at = 3; at < seg.elements.length; at += 2) {
+ const raw = seg.elements[at] || "";
+ if (raw === "") continue;
+ const cents = readAmount835(raw, "PLB-04 is not a number",
+ `PLB-04 on provider ${seg.elements[0] || "(no id)"} reads "${raw}".`, i);
+ if (cents === null) plbTotalBad = true;
+ else plbTotal += cents;
+ }
+ break;
+
case "SE": {
closeClaim();
closeAdjudication();
inClaim = false;
guide = "";
+ if (txn835) { closeSvc835(); closeClp835(); checkTransaction835(); }
+ txn835 = false;
if (!st) { add("error", "SE with no matching ST", "A transaction trailer appeared before any header.", i); break; }
const counted = i - st.at + 1; // ST through SE inclusive
@@ -2658,6 +2856,52 @@ Using it with real data
"IEA*1*000000001~",
].join("\n") + "\n";
+// An 835 remittance advice, 005010X221A1: a payer telling a provider what it
+// paid on two service lines of one claim, plus a second claim carrying a
+// reversal (CLP-02 = 22) of an earlier payment -- amounts negative
+// throughout, balancing under the same three checks as any other claim. A
+// PLB closes the transaction, so BPR-02 is every CLP-04 minus it.
+//
+// Wholly invented, like every sample here, and kept byte-identical to
+// tests/fixtures/sample_835.edi -- lint.mjs compares the two.
+const SAMPLE_835 = [
+ "ISA*00* *00* *ZZ*PAYERID *ZZ*PROVIDERID *230101*1200*^*00501*000000001*0*T*:~",
+ "GS*HP*PAYERID*PROVIDERID*20230101*1200*1*X*005010X221A1~",
+ "ST*835*0001~",
+ "BPR*I*135*C*ACH*CCP*01*111000025*DA*987654321*1234567890**01*111000025*DA*555555555*20230105~",
+ "TRN*1*12345678901*1234567890~",
+ "DTM*405*20230103~",
+ "N1*PR*QUILLFEATHER HEALTH PLAN~",
+ "N3*100 PAYER WAY~",
+ "N4*ANYTOWN*CA*90210~",
+ "PER*BL*CLAIMS DEPT*TE*5551234567~",
+ "N1*PE*BILLING PROVIDER NAME*XX*1234567893~",
+ "N3*123 MAIN ST~",
+ "N4*ANYTOWN*CA*90210~",
+ "REF*TJ*123456789~",
+ "LX*1~",
+ "CLP*PATIENTACCTNUM*1*300*240*60*CI*QF98001234*11~",
+ "NM1*QC*1*NETHERFIELD*ORLANDO****MI*QF4820731~",
+ "NM1*82*1*RENDERINGLAST*RENDERINGFIRST****XX*1234567893~",
+ "DTM*232*20230101~",
+ "SVC*HC:99213*200*160**1~",
+ "DTM*472*20230101~",
+ "CAS*PR*2*40~",
+ "AMT*B6*200~",
+ "SVC*HC:87070*100*80**1~",
+ "DTM*472*20230101~",
+ "CAS*PR*2*20~",
+ "CLP*PATIENTACCTNUM*22*-100*-80*-20*CI*QF98001234*12~",
+ "NM1*QC*1*NETHERFIELD*ORLANDO****MI*QF4820731~",
+ "SVC*HC:99213*-100*-80**1~",
+ "DTM*472*20230101~",
+ "CAS*PR*2*-20~",
+ "PLB*1234567893*20231231*L6:12345*25~",
+ "SE*31*0001~",
+ "GE*1*1~",
+ "IEA*1*000000001~",
+].join("\n") + "\n";
+
// Segment names for the 837 professional/institutional families. Anything
// not listed still renders; it just shows without a plain-English name.
const SEGMENT_NAMES = {
@@ -2675,6 +2919,14 @@ Using it with real data
LIN: "Drug Identification", CTP: "Drug Pricing", PWK: "Paperwork", LQ: "Form Identification",
SVD: "Service Line Adjudication", TOO: "Tooth Identification", CUR: "Currency",
SE: "Transaction Set Trailer", GE: "Functional Group Trailer", IEA: "Interchange Control Trailer",
+ // 835 remittance advice. N1 is 835's own party segment, not NM1's -- see
+ // N1_LOOPS_835. DTM is the 835 counterpart of DTP; 837 uses DTP only.
+ BPR: "Financial Information", TRN: "Trace", N1: "Name",
+ CLP: "Claim Payment Information", SVC: "Service Payment Information",
+ PLB: "Provider Level Balance", TS3: "Provider Summary Information",
+ TS2: "Provider Supplemental Summary Information",
+ MIA: "Medicare Inpatient Adjudication", MOA: "Medicare Outpatient Adjudication",
+ DTM: "Date/Time Reference",
};
// Qualifier codes worth decoding inline: these are the ones that tell you
@@ -2706,6 +2958,20 @@ Using it with real data
} },
SBR: { 0: { P: "Primary", S: "Secondary", T: "Tertiary" } },
SV1: {},
+ // 835 remittance advice. Structural code lists only -- no CARC/RARC or
+ // PLB03 reason-code tables; those change quarterly and are a recipient's
+ // policy, not a fact about the transaction.
+ N1: { 0: { PR: "Payer", PE: "Payee" } },
+ CLP: { 1: {
+ "1": "Primary", "2": "Secondary", "3": "Tertiary", "4": "Denied",
+ "19": "Processed as primary, forwarded", "20": "Processed as secondary, forwarded",
+ "21": "Processed as tertiary, forwarded", "22": "Reversal of previous payment",
+ "23": "Not our claim, forwarded",
+ } },
+ DTM: { 0: {
+ "405": "Production date", "232": "Statement from", "233": "Statement to",
+ "050": "Received", "472": "Service date", "150": "Period start", "151": "Period end",
+ } },
};
// Composite elements: the ones where a single element packs several values
@@ -2764,6 +3030,20 @@ Using it with real data
parts: ["Qualifier", "Procedure code", "Modifier", "Modifier", "Modifier", "Modifier", "Description"],
codes: { 0: { HC: "HCPCS/CPT", HP: "HIPPS rate code", N4: "NDC (11-digit)" } },
} },
+ // 835 SVC-01 (as submitted) and SVC-06 (as changed by the payer), same
+ // composite shape as SVD's -- reused rather than redefined.
+ SVC: {
+ 0: {
+ label: "Procedure",
+ parts: ["Qualifier", "Procedure code", "Modifier", "Modifier", "Modifier", "Modifier", "Description"],
+ codes: { 0: { HC: "HCPCS/CPT", HP: "HIPPS rate code", N4: "NDC (11-digit)" } },
+ },
+ 5: {
+ label: "Original procedure",
+ parts: ["Qualifier", "Procedure code", "Modifier", "Modifier", "Modifier", "Modifier", "Description"],
+ codes: { 0: { HC: "HCPCS/CPT", HP: "HIPPS rate code", N4: "NDC (11-digit)" } },
+ },
+ },
};
// The composite spec for one element, or null if that element isn't one.
@@ -2842,16 +3122,38 @@ Using it with real data
};
let claimNo = 0;
+ let txn835 = false; // ST-01 == "835": LX means something else there, see below
+ let lx835 = false; // whether this 835 opened a 2000 for its claims to sit in
segments.forEach((seg, i) => {
if (seg.id === "ST") {
closeFrom(0, i);
claimNo = 0;
+ txn835 = seg.elements[0] === TXN_835;
+ lx835 = false;
push(0, `Transaction ${seg.elements[1] || ""}`.trim(), `ST*${seg.elements[0] || ""}`, i);
} else if (seg.id === "CLM") {
closeFrom(1, i);
claimNo++;
push(1, `Claim ${claimNo}`, seg.elements[0] || "", i);
+ } else if (seg.id === "CLP" && txn835) {
+ // 835's claims, so the rail can scope find & replace to one of them the
+ // way it can for an 837's. Depth mirrors buildTree: a 2100 nests inside
+ // an open 2000, but the no-LX remittance is legal and its claims sit
+ // directly under the transaction rather than indented under nothing.
+ const depth = lx835 ? 2 : 1;
+ closeFrom(depth, i);
+ claimNo++;
+ push(depth, `Claim ${claimNo}`, seg.elements[0] || "", i);
} else if (seg.id === "LX") {
+ if (txn835) {
+ // 835's LX opens loop 2000, an optional grouping of claims directly
+ // under the transaction -- not a service line, so unlike the 837
+ // case below it belongs beside "Claim", not two levels under it.
+ closeFrom(1, i);
+ lx835 = true;
+ push(1, `Header number ${seg.elements[0] || ""}`.trim(), "", i);
+ return;
+ }
closeFrom(2, i);
push(2, `Line ${seg.elements[0] || ""}`.trim(), "", i);
} else if (seg.id === "SE") {
@@ -2970,6 +3272,25 @@ Using it with real data
*/
const REPEATED_ANCHORS = { "2320": ANCHOR_LOOPS["2300"] };
+/* Loop identifiers, 835 Health Care Claim Payment/Advice, 005010X221A1.
+ *
+ * Deliberately kept out of ENTITY_LOOPS/ANCHOR_LOOPS: those are what
+ * enclosingLoop and the 837 anchored-loop lookup key off, and an 835 loop id
+ * never satisfying an 837 lookup is what makes the 837 fallback inert by
+ * construction (see buildTree). 835 uses N1, not NM1, for its two header
+ * parties -- everything on the 837 side keys on NM1, so no existing
+ * entity-loop code reaches these even by accident.
+ *
+ * CLP and SVC are context-free openers -- CLP is always 2100, SVC always
+ * 2110 -- so unlike the 837 side there is no qualifier to resolve against an
+ * enclosing loop, and a flat table is enough.
+ */
+const N1_LOOPS_835 = { PR: ["1000A", "Payer"], PE: ["1000B", "Payee"] };
+const LOOPS_835 = {
+ CLP: ["2100", "Claim payment information"],
+ SVC: ["2110", "Service payment information"],
+};
+
// The loop a segment is currently sitting in, which decides what its own
// loop id would be. Entity loops are skipped: 2010BA is somewhere a segment
// can be, but it never qualifies another entity.
@@ -3010,6 +3331,7 @@ Using it with real data
let hlById = new Map(); // HL-01 -> the stack entry it opened, within one transaction
let claimNo = 0;
let guide = ""; // resolved per transaction, since one file can mix them
+ let txn = ""; // TXN_835 or "", also resolved per transaction
segments.forEach((seg, i) => {
const el = seg.elements;
@@ -3028,6 +3350,7 @@ Using it with real data
hlById = new Map(); // HL ids are only unique within a transaction
claimNo = 0;
guide = guideOf(segments, i);
+ txn = el[0] === TXN_835 ? TXN_835 : "";
// The detail line only grows when a guide was recognised, so an
// ordinary 837P transaction still reads as just its set identifier.
const node = open(GROUP_RANK.ST, i, seg, `Transaction ${el[1] || ""}`.trim(),
@@ -3036,15 +3359,30 @@ Using it with real data
return;
}
case "CLM":
+ // 835 carries no CLM at all; explicit rather than relying on that.
+ if (txn === TXN_835) break;
closeTo(GROUP_RANK.CLM);
claimNo++;
open(GROUP_RANK.CLM, i, seg, `Claim ${claimNo}`, el[0] || "", "2300");
return;
case "LX":
+ if (txn === TXN_835) {
+ // 2000 Header Number: an optional grouping of claims directly
+ // under the transaction, not a service line -- 835's own LX opens
+ // no line at all. HL_RANK, not GROUP_RANK.LX, so a claim (2100)
+ // still nests beneath it: GROUP_RANK.CLM > HL_RANK.
+ closeTo(HL_RANK);
+ open(HL_RANK, i, seg, `Header number ${el[0] || ""}`.trim(), "", "2000");
+ return;
+ }
closeTo(GROUP_RANK.LX);
open(GROUP_RANK.LX, i, seg, `Line ${el[0] || ""}`.trim(), "", "2400");
return;
case "NM1": {
+ // 835's NM1 (2100's QC/82/TT/PR/...) opens no loop of its own --
+ // flat segments only, per the probe: an unrelated qualifier must not
+ // reuse an 837 entity loop it happens to share a code with.
+ if (txn === TXN_835) break;
// A new entity closes the previous one, whether or not this NM1
// turns out to name a loop the guide recognises.
closeTo(ENTITY_RANK);
@@ -3056,7 +3394,49 @@ Using it with real data
}
break; // an unrecognised qualifier is just a segment
}
+ case "N1": {
+ // 1000A payer / 1000B payee. They close each other, hence the same
+ // entity rank NM1's 837 entity loops use -- an 835 never opens both
+ // from this case, so there is no ordering to get wrong.
+ if (txn !== TXN_835) break;
+ closeTo(ENTITY_RANK);
+ const loop = N1_LOOPS_835[el[0]];
+ if (loop) {
+ open(ENTITY_RANK, i, seg, loop[1], "", loop[0]);
+ return;
+ }
+ break; // an unrecognised qualifier is just a segment, as for NM1
+ }
+ case "CLP": {
+ // 2100 Claim payment information. Context-free: CLP always opens
+ // 2100, whether or not an LX (2000) is open above it -- the legal
+ // no-LX shape.
+ if (txn !== TXN_835) break;
+ closeTo(GROUP_RANK.CLM);
+ const loop = LOOPS_835.CLP;
+ open(GROUP_RANK.CLM, i, seg, loop[1], el[0] || "", loop[0]);
+ return;
+ }
+ case "SVC": {
+ // 2110 Service payment information. The next SVC closes the
+ // previous one, same as an 837 service line closing its sibling.
+ if (txn !== TXN_835) break;
+ closeTo(GROUP_RANK.LX);
+ const loop = LOOPS_835.SVC;
+ open(GROUP_RANK.LX, i, seg, loop[1], "", loop[0]);
+ return;
+ }
+ case "PLB":
+ // Transaction-level, never inside a claim: pops all the way back to
+ // whatever HL_RANK closes, which lands PLB as a leaf of ST directly
+ // -- it opens no loop of its own.
+ if (txn !== TXN_835) break;
+ closeTo(HL_RANK);
+ top().node.children.push(leaf(i, seg));
+ return;
case "HL": {
+ // 835 carries no HL at all; explicit rather than relying on that.
+ if (txn === TXN_835) break;
// An HL naming a parent nests inside it. One with no parent, or with
// a pointer to an HL that was never opened -- which the Checks tab
// reports on its own -- starts a fresh hierarchy under the transaction.
@@ -3085,14 +3465,19 @@ Using it with real data
// Loops anchored by something other than an NM1: the other-subscriber
// block inside a claim, and the drug, adjudication and form loops inside
- // a service line.
- const enclosing = enclosingLoop(stack);
- const anchors = ANCHOR_LOOPS[enclosing] || REPEATED_ANCHORS[enclosing];
- const anchored = anchors ? anchors[seg.id] : null;
- if (anchored) {
- closeTo(anchored[2]);
- open(anchored[2], i, seg, loopLabel(guide, anchored[0], anchored[1]), "", anchored[0]);
- return;
+ // a service line. 835 loop ids are not keys of ANCHOR_LOOPS/
+ // REPEATED_ANCHORS, so this stays inert for an 835 transaction even
+ // without the guard below -- explicit anyway, so an 835 LQ can never
+ // fabricate loop 2440 even if that inertness is disturbed later.
+ if (txn !== TXN_835) {
+ const enclosing = enclosingLoop(stack);
+ const anchors = ANCHOR_LOOPS[enclosing] || REPEATED_ANCHORS[enclosing];
+ const anchored = anchors ? anchors[seg.id] : null;
+ if (anchored) {
+ closeTo(anchored[2]);
+ open(anchored[2], i, seg, loopLabel(guide, anchored[0], anchored[1]), "", anchored[0]);
+ return;
+ }
}
top().node.children.push(leaf(i, seg));
@@ -3623,6 +4008,22 @@ Using it with real data
return [...groups.entries()].sort((a, b) => rank(a[0]) - rank(b[0]));
}
+// The 835's five loops, listed flat rather than merged into referenceLoops():
+// loop ids collide across formats with different meanings -- 1000A is the
+// Submitter in 837, the Payer in 835 -- so relabelling the same loop under an
+// alternate name, what `alt` does for PACDR, is the wrong tool here. This is
+// a different set of loops that happen to share ids, not the same loop
+// renamed, so it gets its own flat table instead.
+function referenceLoops835() {
+ return [
+ { loop: "1000A", name: "Payer", opener: "N1*PR" },
+ { loop: "1000B", name: "Payee", opener: "N1*PE" },
+ { loop: "2000", name: "Header number", opener: "LX" },
+ { loop: "2100", name: "Claim payment information", opener: "CLP" },
+ { loop: "2110", name: "Service payment information", opener: "SVC" },
+ ];
+}
+
function renderReference() {
$("refLoops").innerHTML = referenceLoops().map(([parent, rows]) => `
@@ -3640,6 +4041,16 @@
Using it with real data
`).join("");
+ $("ref835Loops").innerHTML = `
+
+ ${referenceLoops835().map((r) => `
+ ${escapeHtml(r.loop)}
+ ${escapeHtml(r.opener)}
+ ${escapeHtml(r.name)}
+ `).join("")}
+
+
`;
+
const segments = Object.entries(SEGMENT_NAMES).sort((a, b) => a[0].localeCompare(b[0]));
$("refSegments").innerHTML = `
${segments.map(([id, name]) => `
@@ -3756,6 +4167,7 @@ Using it with real data
});
$("sampleBtn").addEventListener("click", () => loadFile("sample_837p.edi", SAMPLE_837P));
$("samplePacdrBtn").addEventListener("click", () => loadFile("sample_837_pacdr.edi", SAMPLE_PACDR));
+$("sample835Btn").addEventListener("click", () => loadFile("sample_835.edi", SAMPLE_835));
function loadFile(name, raw) {
state.fileName = name;
@@ -4307,11 +4719,17 @@ Using it with real data
// Loop ids grouped by what they describe, so the column reads as four bands
// -- header parties, hierarchy, claim, service line -- rather than nineteen
// unrelated numbers. Reuses the existing themed accents; no new tokens.
+//
+// 835's 1000A/1000B and 2000 already land in the right band, by luck rather
+// than design: the prefixes below were written for 837's own 1000A/1000B
+// (submitter/receiver) and 2000A/2000B/2000C (HL hierarchy), and 835's
+// payer/payee and header-number loops happen to share those same prefixes.
function loopTint(loop) {
if (!loop) return "";
if (loop.startsWith("1000")) return " lp-head";
if (loop.startsWith("2000") || loop.startsWith("2010")) return " lp-hier";
- if (loop.startsWith("23")) return " lp-claim";
+ if (loop === "2110") return " lp-line"; // 835 service payment information
+ if (loop.startsWith("23") || loop.startsWith("21")) return " lp-claim"; // 837 2300s, 835 2100
if (loop.startsWith("24")) return " lp-line";
return "";
}
@@ -4986,11 +5404,23 @@ Using it with real data
for the line charge or for a patient responsibility left on that line, and every
SVD-01 names a payer the claim identifies.`
: "";
+ // Same rule for a remittance. The claim-balance sentence below is driven
+ // by CLM, which an 835 does not carry, so naming it on a file with no CLM
+ // in it would report a check that never ran.
+ const remit = has835(state.doc.segments)
+ ? `In the remittance advice, each service line's SVC-03 payment
+ reconciles against its own adjustments, each claim's CLP-04 does the
+ same across the claim, and BPR-02 equals every claim's payment minus
+ every provider-level balance adjustment.
`
+ : "";
+ const claimTotals = state.doc.segments.some((s) => s.id === "CLM")
+ ? `, and each claim's CLM-02 total matches the charges on its service lines`
+ : "";
$("paneChecks").innerHTML = `
No problems found.
Control numbers and segment counts agree at every level
- (SE, GE, IEA), every envelope is closed, and each
- claim's CLM-02 total matches the charges on its service lines.
+ (
SE,
GE,
IEA), every envelope is closed${claimTotals}.
+ ${remit}
${pacdr}
`;
return;
diff --git a/web/tests/claims.mjs b/web/tests/claims.mjs
index 71367c1..f022f25 100644
--- a/web/tests/claims.mjs
+++ b/web/tests/claims.mjs
@@ -2,7 +2,7 @@
// read through it, PHI masking, and CSV export quoting.
import { readFileSync } from "node:fs";
-import { APP, FIXTURE, PACDR_FIXTURE } from "./paths.mjs";
+import { APP, FIXTURE, PACDR_FIXTURE, X221_FIXTURE } from "./paths.mjs";
const html = readFileSync(APP, "utf8");
@@ -419,5 +419,21 @@ check("nor its line adjudication",
{ "Claim paid": "", "Line paid": "", "Line adjustments": "" });
m.state.doc = doc;
+console.log("\n[15] an 835 produces no claim rows at all");
+// No code change was made for this -- audited, not assumed. emitLine
+// returns early on clm === -1, and an 835 never carries a CLM, so clm never
+// leaves -1 for the whole walk: CLP, SVC, N1 and PLB all match none of
+// buildClaimIndex's cases and are silently skipped.
+const x221Raw = readFileSync(X221_FIXTURE, "utf8");
+const x221Doc = m.parse(x221Raw);
+m.state.doc = x221Doc;
+const x221Rows = m.buildClaimIndex(x221Doc.segments);
+check("zero rows, not a throw", x221Rows.length, 0);
+check("the 837P header stays the 13 labels it has always had -- an 835 does not add columns",
+ m.claimColumns(x221Rows).map((c) => c.label),
+ ["Claim", "Patient", "Line", "Service date", "Procedure", "Modifiers", "Units",
+ "Charge", "Diagnoses", "Place of service", "Billing provider", "Payer", "Claim total"]);
+m.state.doc = doc;
+
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);
diff --git a/web/tests/deid.mjs b/web/tests/deid.mjs
index 1a9d80c..640ac25 100644
--- a/web/tests/deid.mjs
+++ b/web/tests/deid.mjs
@@ -14,7 +14,7 @@
import { readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
-import { APP, PACDR_FIXTURE, SAMPLES_DIR } from "./paths.mjs";
+import { APP, PACDR_FIXTURE, X221_FIXTURE, SAMPLES_DIR } from "./paths.mjs";
const html = readFileSync(APP, "utf8");
@@ -67,6 +67,8 @@ const src = [
lift("SEGMENT_NAMES", "const SEGMENT_NAMES = {") + ";",
lift("QUALIFIERS", "const QUALIFIERS = {") + ";",
liftLine("REPEATED_ANCHORS"),
+ liftLine("N1_LOOPS_835"),
+ lift("LOOPS_835", "const LOOPS_835 = {") + ";",
lift("enclosingLoop", "function enclosingLoop(stack)"),
lift("segmentRole", "function segmentRole(seg)"),
lift("buildTree", "function buildTree(segments)"),
@@ -551,5 +553,18 @@ check("awkward_values: the RD8 range is still a range",
check("awkward_values: the impossible date is left alone",
awkwardDtps.some((e) => e[2] === "20231301"), true);
+console.log("\n[17] an 835 remittance advice");
+// No code change was made for this -- audited, not assumed. The rule
+// reaches identifiers only through PERSON_LOOPS (2010BA, 2010CA, 2330A),
+// which buildTree resolves through NM1; an 835's NM1 (2100's QC/IL/82/...)
+// opens no loop at all, so scrubLoop never finds one of these to scrub, and
+// PERSON_LOOPS itself carries no 835 loop id to begin with.
+const x221Raw = readFileSync(X221_FIXTURE, "utf8");
+const x221 = runDeid(31, x221Raw);
+check("nothing was marked as changed", x221.result.marks, []);
+check("the output is byte-identical to the input", m.serialize(x221.doc), x221Raw);
+check("the fixture still validates cleanly after the run",
+ m.validateDocument(x221.doc).map((f) => f.title), []);
+
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);
diff --git a/web/tests/lint.mjs b/web/tests/lint.mjs
index 3db4688..8b28fbe 100644
--- a/web/tests/lint.mjs
+++ b/web/tests/lint.mjs
@@ -3,7 +3,7 @@
import { readFileSync, writeFileSync } from "node:fs";
import { execFileSync } from "node:child_process";
import { join } from "node:path";
-import { APP, FIXTURE, PACDR_FIXTURE, TESTS_DIR, appVersion } from "./paths.mjs";
+import { APP, FIXTURE, PACDR_FIXTURE, X221_FIXTURE, TESTS_DIR, appVersion } from "./paths.mjs";
const html = readFileSync(APP, "utf8");
let problems = 0;
@@ -145,7 +145,7 @@ try {
// disk, so a button that loads something else makes all of them meaningless.
// The declarations are evaluated rather than pattern-matched, which is what
// makes this compare the string the button actually hands to loadFile.
-for (const [name, path] of [["SAMPLE_837P", FIXTURE], ["SAMPLE_PACDR", PACDR_FIXTURE]]) {
+for (const [name, path] of [["SAMPLE_837P", FIXTURE], ["SAMPLE_PACDR", PACDR_FIXTURE], ["SAMPLE_835", X221_FIXTURE]]) {
const decl = html.match(new RegExp(`^const ${name} = \\[[\\s\\S]*?^\\]\\.join\\("\\\\n"\\) \\+ "\\\\n";$`, "m"));
if (!decl) { bad(`${name} is not declared as an array of segments joined with newlines`); continue; }
const embedded = new Function(`${decl[0]}\nreturn ${name};`)();
diff --git a/web/tests/make_835_samples.mjs b/web/tests/make_835_samples.mjs
new file mode 100644
index 0000000..c425aa5
--- /dev/null
+++ b/web/tests/make_835_samples.mjs
@@ -0,0 +1,46 @@
+// Writes 835 remittance advice files (005010X221A1) for testing by hand.
+//
+// node web/tests/make_835_samples.mjs [outputDir]
+//
+// Defaults to web/tests/samples/835/, which is committed. The clean report is
+// a copy of the fixture, and each of the others is derived from it by one
+// edit, so each trips exactly one of the three balancing findings. Every
+// automated equivalent lives in validate.mjs -- these exist for the Tree and
+// Checks tabs, which no suite looks at.
+//
+// The mutations are the same ones validate.mjs makes, deliberately: a sample
+// that trips a different finding from the one its name claims is worse than
+// no sample. Everything here is invented; nothing derives from a real file.
+import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
+import { join } from "node:path";
+
+import { X221_FIXTURE, TESTS_DIR } from "./paths.mjs";
+
+const outDir = process.argv[2] || join(TESTS_DIR, "samples", "835");
+mkdirSync(outDir, { recursive: true });
+
+const raw = readFileSync(X221_FIXTURE, "utf8");
+
+const SAMPLES = [
+ ["clean_remittance.edi", "two lines, a reversal claim, and a PLB -- everything reconciles",
+ (s) => s],
+
+ ["line_does_not_reconcile.edi", "claim 1 line 1's SVC-03 states 150.00; SVC-02 minus its CAS comes to 160.00",
+ (s) => s.replace("SVC*HC:99213*200*160**1~", "SVC*HC:99213*200*150**1~")],
+
+ ["claim_does_not_reconcile.edi",
+ "claim 1's CLP-04 states 250.00 (CLP-03 minus its CAS comes to 240.00) -- and since BPR-02 is " +
+ "computed from CLP-04, the transaction total is now wrong too",
+ (s) => s.replace("*300*240*60*CI*QF98001234*11~", "*300*250*60*CI*QF98001234*11~")],
+
+ ["total_does_not_reconcile.edi", "BPR-02 states 145.00; every CLP-04 minus every PLB-04 comes to 135.00",
+ (s) => s.replace("BPR*I*135*C*", "BPR*I*145*C*")],
+];
+
+console.log(`Writing ${SAMPLES.length} samples to ${outDir}\n`);
+for (const [name, why, transform] of SAMPLES) {
+ writeFileSync(join(outDir, name), transform(raw), "utf8");
+ console.log(` ${name.padEnd(28)} ${why}`);
+}
+console.log(`\nclean_remittance.edi is a copy of ${X221_FIXTURE}, which the app also ships` +
+ "\nas its 835 sample button -- lint.mjs holds those two byte-identical.");
diff --git a/web/tests/outline.mjs b/web/tests/outline.mjs
index 62286f5..8829820 100644
--- a/web/tests/outline.mjs
+++ b/web/tests/outline.mjs
@@ -2,7 +2,7 @@
// which lives in the UI half of the file and so isn't covered by parity.mjs.
import { readFileSync } from "node:fs";
-import { APP, FIXTURE } from "./paths.mjs";
+import { APP, FIXTURE, X221_FIXTURE } from "./paths.mjs";
const html = readFileSync(APP, "utf8");
@@ -18,11 +18,20 @@ function lift(name, opener) {
throw new Error("unbalanced: " + name);
}
+// Scalar consts have no braces to match on, so take the declaration up to
+// its semicolon -- these carry trailing comments, so the line doesn't end there.
+function liftLine(name) {
+ const m = html.match(new RegExp("^const " + name + " = [^\\n]*?;", "m"));
+ if (!m) throw new Error("not found: " + name);
+ return m[0];
+}
+
const src = [
lift("SEGMENT_NAMES", "const SEGMENT_NAMES = {") + ";",
lift("QUALIFIERS", "const QUALIFIERS = {") + ";",
lift("COMPOSITES", "const COMPOSITES = {") + ";",
lift("compositeSpec", "function compositeSpec(segId, index)"),
+ liftLine("TXN_835"),
lift("buildOutline", "function buildOutline(segments)"),
lift("claimStarts", "function claimStarts(outline)"),
lift("segmentRole", "function segmentRole(seg)"),
@@ -37,7 +46,7 @@ const src = [
// componentHtml reads the live document off `state`; the app builds that
// at load time, so the harness supplies one it can point at a fixture.
"const state = { doc: null };",
- "export { buildOutline, claimStarts, segmentRole, parse, SEGMENT_NAMES, COMPOSITES, compositeSpec, componentHtml, versionLabel, state };",
+ "export { buildOutline, claimStarts, segmentRole, parse, SEGMENT_NAMES, COMPOSITES, compositeSpec, componentHtml, versionLabel, state, TXN_835 };",
].join("\n");
const m = await import("data:text/javascript;base64," + Buffer.from(src).toString("base64"));
@@ -171,5 +180,35 @@ check("4010 code", m.versionLabel("00401"), "4010");
check("unrecognised code shown raw", m.versionLabel("00301"), "00301");
check("missing code", m.versionLabel(""), "unknown");
+console.log("\n[10] 835 remittance advice");
+const x221Raw = readFileSync(X221_FIXTURE, "utf8");
+const x221Doc = m.parse(x221Raw);
+// The cheapest complete test of the inspector naming step: every segment id
+// the fixture carries has a plain-English name, the same assertion the 837P
+// fixture gets above.
+check("names cover the 835 sample's segments",
+ [...new Set(x221Doc.segments.map((s) => s.id))].filter((id) => !m.SEGMENT_NAMES[id]), []);
+// Step 10: the outline used to repeat the Tree tab's LX fabrication --
+// pushing "Line 1" for a segment that opens loop 2000, a grouping of claims,
+// not a service line.
+const x221Outline = m.buildOutline(x221Doc.segments);
+check("LX reads as a header number, not a line",
+ x221Outline.map((n) => n.label),
+ ["Transaction 0001", "Header number 1", "Claim 1", "Claim 2"]);
+// The rail is also the find & replace scoping control, so an 835's claims
+// have to be selectable there and not just resolvable in the tree.
+check("an 835's claims nest inside the header grouping",
+ x221Outline.map((n) => n.depth), [0, 1, 2, 2]);
+check("each claim carries its patient control number",
+ x221Outline.filter((n) => n.label.startsWith("Claim")).map((n) => n.detail),
+ ["PATIENTACCTNUM", "PATIENTACCTNUM"]);
+// The no-LX remittance is legal -- validateDocument supports it -- and its
+// claims have nothing to indent under, so they sit at the transaction's own
+// level rather than under an absent 2000.
+const noLx = m.buildOutline(m.parse(x221Raw.replace("LX*1~\n", "")).segments);
+check("a remittance with no LX still lists its claims, one level up",
+ noLx.map((n) => [n.label, n.depth]),
+ [["Transaction 0001", 0], ["Claim 1", 1], ["Claim 2", 1]]);
+
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);
diff --git a/web/tests/paths.mjs b/web/tests/paths.mjs
index 9a477de..1d98a77 100644
--- a/web/tests/paths.mjs
+++ b/web/tests/paths.mjs
@@ -17,6 +17,11 @@ export const FIXTURE = join(PROJECT, "tests", "fixtures", "sample_837p.edi");
// variant of the one above: the 837P fixture is what parity and half the
// assertions in every other suite are pinned to, and it has to stay put.
export const PACDR_FIXTURE = join(PROJECT, "tests", "fixtures", "sample_837_pacdr.edi");
+// The 835 remittance advice, 005010X221A1. Own fixture, same reasoning as
+// PACDR_FIXTURE: the 837P fixture is what parity and half of every other
+// suite's assertions are pinned to, and an 835 is a different transaction
+// entirely, not a variant of it.
+export const X221_FIXTURE = join(PROJECT, "tests", "fixtures", "sample_835.edi");
export const MAKE_REF = join(TESTS_DIR, "make_ref.py");
// Committed manual-test samples. Generated, but kept in the repo so a UI
diff --git a/web/tests/samples/835/claim_does_not_reconcile.edi b/web/tests/samples/835/claim_does_not_reconcile.edi
new file mode 100644
index 0000000..3ccece3
--- /dev/null
+++ b/web/tests/samples/835/claim_does_not_reconcile.edi
@@ -0,0 +1,35 @@
+ISA*00* *00* *ZZ*PAYERID *ZZ*PROVIDERID *230101*1200*^*00501*000000001*0*T*:~
+GS*HP*PAYERID*PROVIDERID*20230101*1200*1*X*005010X221A1~
+ST*835*0001~
+BPR*I*135*C*ACH*CCP*01*111000025*DA*987654321*1234567890**01*111000025*DA*555555555*20230105~
+TRN*1*12345678901*1234567890~
+DTM*405*20230103~
+N1*PR*QUILLFEATHER HEALTH PLAN~
+N3*100 PAYER WAY~
+N4*ANYTOWN*CA*90210~
+PER*BL*CLAIMS DEPT*TE*5551234567~
+N1*PE*BILLING PROVIDER NAME*XX*1234567893~
+N3*123 MAIN ST~
+N4*ANYTOWN*CA*90210~
+REF*TJ*123456789~
+LX*1~
+CLP*PATIENTACCTNUM*1*300*250*60*CI*QF98001234*11~
+NM1*QC*1*NETHERFIELD*ORLANDO****MI*QF4820731~
+NM1*82*1*RENDERINGLAST*RENDERINGFIRST****XX*1234567893~
+DTM*232*20230101~
+SVC*HC:99213*200*160**1~
+DTM*472*20230101~
+CAS*PR*2*40~
+AMT*B6*200~
+SVC*HC:87070*100*80**1~
+DTM*472*20230101~
+CAS*PR*2*20~
+CLP*PATIENTACCTNUM*22*-100*-80*-20*CI*QF98001234*12~
+NM1*QC*1*NETHERFIELD*ORLANDO****MI*QF4820731~
+SVC*HC:99213*-100*-80**1~
+DTM*472*20230101~
+CAS*PR*2*-20~
+PLB*1234567893*20231231*L6:12345*25~
+SE*31*0001~
+GE*1*1~
+IEA*1*000000001~
diff --git a/web/tests/samples/835/clean_remittance.edi b/web/tests/samples/835/clean_remittance.edi
new file mode 100644
index 0000000..6d229ec
--- /dev/null
+++ b/web/tests/samples/835/clean_remittance.edi
@@ -0,0 +1,35 @@
+ISA*00* *00* *ZZ*PAYERID *ZZ*PROVIDERID *230101*1200*^*00501*000000001*0*T*:~
+GS*HP*PAYERID*PROVIDERID*20230101*1200*1*X*005010X221A1~
+ST*835*0001~
+BPR*I*135*C*ACH*CCP*01*111000025*DA*987654321*1234567890**01*111000025*DA*555555555*20230105~
+TRN*1*12345678901*1234567890~
+DTM*405*20230103~
+N1*PR*QUILLFEATHER HEALTH PLAN~
+N3*100 PAYER WAY~
+N4*ANYTOWN*CA*90210~
+PER*BL*CLAIMS DEPT*TE*5551234567~
+N1*PE*BILLING PROVIDER NAME*XX*1234567893~
+N3*123 MAIN ST~
+N4*ANYTOWN*CA*90210~
+REF*TJ*123456789~
+LX*1~
+CLP*PATIENTACCTNUM*1*300*240*60*CI*QF98001234*11~
+NM1*QC*1*NETHERFIELD*ORLANDO****MI*QF4820731~
+NM1*82*1*RENDERINGLAST*RENDERINGFIRST****XX*1234567893~
+DTM*232*20230101~
+SVC*HC:99213*200*160**1~
+DTM*472*20230101~
+CAS*PR*2*40~
+AMT*B6*200~
+SVC*HC:87070*100*80**1~
+DTM*472*20230101~
+CAS*PR*2*20~
+CLP*PATIENTACCTNUM*22*-100*-80*-20*CI*QF98001234*12~
+NM1*QC*1*NETHERFIELD*ORLANDO****MI*QF4820731~
+SVC*HC:99213*-100*-80**1~
+DTM*472*20230101~
+CAS*PR*2*-20~
+PLB*1234567893*20231231*L6:12345*25~
+SE*31*0001~
+GE*1*1~
+IEA*1*000000001~
diff --git a/web/tests/samples/835/line_does_not_reconcile.edi b/web/tests/samples/835/line_does_not_reconcile.edi
new file mode 100644
index 0000000..b833296
--- /dev/null
+++ b/web/tests/samples/835/line_does_not_reconcile.edi
@@ -0,0 +1,35 @@
+ISA*00* *00* *ZZ*PAYERID *ZZ*PROVIDERID *230101*1200*^*00501*000000001*0*T*:~
+GS*HP*PAYERID*PROVIDERID*20230101*1200*1*X*005010X221A1~
+ST*835*0001~
+BPR*I*135*C*ACH*CCP*01*111000025*DA*987654321*1234567890**01*111000025*DA*555555555*20230105~
+TRN*1*12345678901*1234567890~
+DTM*405*20230103~
+N1*PR*QUILLFEATHER HEALTH PLAN~
+N3*100 PAYER WAY~
+N4*ANYTOWN*CA*90210~
+PER*BL*CLAIMS DEPT*TE*5551234567~
+N1*PE*BILLING PROVIDER NAME*XX*1234567893~
+N3*123 MAIN ST~
+N4*ANYTOWN*CA*90210~
+REF*TJ*123456789~
+LX*1~
+CLP*PATIENTACCTNUM*1*300*240*60*CI*QF98001234*11~
+NM1*QC*1*NETHERFIELD*ORLANDO****MI*QF4820731~
+NM1*82*1*RENDERINGLAST*RENDERINGFIRST****XX*1234567893~
+DTM*232*20230101~
+SVC*HC:99213*200*150**1~
+DTM*472*20230101~
+CAS*PR*2*40~
+AMT*B6*200~
+SVC*HC:87070*100*80**1~
+DTM*472*20230101~
+CAS*PR*2*20~
+CLP*PATIENTACCTNUM*22*-100*-80*-20*CI*QF98001234*12~
+NM1*QC*1*NETHERFIELD*ORLANDO****MI*QF4820731~
+SVC*HC:99213*-100*-80**1~
+DTM*472*20230101~
+CAS*PR*2*-20~
+PLB*1234567893*20231231*L6:12345*25~
+SE*31*0001~
+GE*1*1~
+IEA*1*000000001~
diff --git a/web/tests/samples/835/total_does_not_reconcile.edi b/web/tests/samples/835/total_does_not_reconcile.edi
new file mode 100644
index 0000000..fb30bf2
--- /dev/null
+++ b/web/tests/samples/835/total_does_not_reconcile.edi
@@ -0,0 +1,35 @@
+ISA*00* *00* *ZZ*PAYERID *ZZ*PROVIDERID *230101*1200*^*00501*000000001*0*T*:~
+GS*HP*PAYERID*PROVIDERID*20230101*1200*1*X*005010X221A1~
+ST*835*0001~
+BPR*I*145*C*ACH*CCP*01*111000025*DA*987654321*1234567890**01*111000025*DA*555555555*20230105~
+TRN*1*12345678901*1234567890~
+DTM*405*20230103~
+N1*PR*QUILLFEATHER HEALTH PLAN~
+N3*100 PAYER WAY~
+N4*ANYTOWN*CA*90210~
+PER*BL*CLAIMS DEPT*TE*5551234567~
+N1*PE*BILLING PROVIDER NAME*XX*1234567893~
+N3*123 MAIN ST~
+N4*ANYTOWN*CA*90210~
+REF*TJ*123456789~
+LX*1~
+CLP*PATIENTACCTNUM*1*300*240*60*CI*QF98001234*11~
+NM1*QC*1*NETHERFIELD*ORLANDO****MI*QF4820731~
+NM1*82*1*RENDERINGLAST*RENDERINGFIRST****XX*1234567893~
+DTM*232*20230101~
+SVC*HC:99213*200*160**1~
+DTM*472*20230101~
+CAS*PR*2*40~
+AMT*B6*200~
+SVC*HC:87070*100*80**1~
+DTM*472*20230101~
+CAS*PR*2*20~
+CLP*PATIENTACCTNUM*22*-100*-80*-20*CI*QF98001234*12~
+NM1*QC*1*NETHERFIELD*ORLANDO****MI*QF4820731~
+SVC*HC:99213*-100*-80**1~
+DTM*472*20230101~
+CAS*PR*2*-20~
+PLB*1234567893*20231231*L6:12345*25~
+SE*31*0001~
+GE*1*1~
+IEA*1*000000001~
diff --git a/web/tests/samples/README.md b/web/tests/samples/README.md
index 79b8c70..891cc67 100644
--- a/web/tests/samples/README.md
+++ b/web/tests/samples/README.md
@@ -17,12 +17,14 @@ node web/tests/make_samples.mjs # -> checks/
node web/tests/make_tree_samples.mjs # -> tree/
node web/tests/make_deid_samples.mjs # -> deid/
node web/tests/make_pacdr_samples.mjs # -> pacdr/
+node web/tests/make_835_samples.mjs # -> 835/
```
-The clean baselines are `tests/fixtures/sample_837p.edi` and
-`tests/fixtures/sample_837_pacdr.edi`, which should report no problems in any
-tab. Both are also what the app's two sample buttons load — `lint.mjs` holds
-the embedded copies byte-identical to the files on disk.
+The clean baselines are `tests/fixtures/sample_837p.edi`,
+`tests/fixtures/sample_837_pacdr.edi` and `tests/fixtures/sample_835.edi`,
+which should report no problems in any tab. All three are also what the app's
+three sample buttons load — `lint.mjs` holds the embedded copies
+byte-identical to the files on disk.
## `checks/` — deliberately broken
@@ -95,3 +97,25 @@ All three findings are warnings rather than errors. What is checked is
arithmetic inside the file, never conformance to a particular recipient's
companion guide — those differ between recipients, so encoding one would make
the app wrong for every other submitter.
+
+## `835/` — remittance advice (005010X221A1)
+
+A different transaction from the 837, not a variant of it: two service lines
+on one claim, a second claim carrying a reversal (`CLP-02 = 22`, amounts
+negative throughout), and a `PLB` closing the transaction. `N1*PR`/`N1*PE`
+open the payer and payee, `LX` opens an optional grouping of claims rather
+than a line, and `CLP`/`SVC` replace `CLM`/`SV1` — none of which the 837
+tables reach, by construction. The Claims tab stays empty for these files;
+Checks reads the payment instead.
+
+| File | What it exercises |
+| --- | --- |
+| `clean_remittance.edi` | The baseline, a copy of the fixture — nothing should be reported in any tab |
+| `line_does_not_reconcile.edi` | `SVC-02` minus its `CAS` adjustments does not equal `SVC-03` |
+| `claim_does_not_reconcile.edi` | `CLP-03` minus every `CAS` on the claim does not equal `CLP-04` — and, since `BPR-02` is computed *from* `CLP-04`, the transaction total is now wrong too. The one sample that trips two findings, both genuinely present |
+| `total_does_not_reconcile.edi` | `BPR-02` does not equal every claim's `CLP-04` minus every `PLB-04` |
+
+All three findings are warnings. The transaction check is the one worth
+reading closely if you're comparing numbers by hand: a **positive** `PLB-04`
+**reduces** what was paid, so it is `Σ CLP-04 − Σ PLB-04`, not `+`. See
+`docs/835-validation.md`.
diff --git a/web/tests/tree.mjs b/web/tests/tree.mjs
index 1b67af1..6d20f20 100644
--- a/web/tests/tree.mjs
+++ b/web/tests/tree.mjs
@@ -7,7 +7,7 @@
// row per element rather than a taller card.
import { readFileSync } from "node:fs";
-import { APP, FIXTURE, PACDR_FIXTURE } from "./paths.mjs";
+import { APP, FIXTURE, PACDR_FIXTURE, X221_FIXTURE } from "./paths.mjs";
const html = readFileSync(APP, "utf8");
@@ -50,6 +50,10 @@ const src = [
lift("loopLabel", "function loopLabel(guide, loop, name)"),
lift("ANCHOR_LOOPS", "const ANCHOR_LOOPS = {") + ";",
liftLine("REPEATED_ANCHORS"),
+ liftLine("TXN_835"),
+ lift("has835", "function has835(segments)"),
+ liftLine("N1_LOOPS_835"),
+ lift("LOOPS_835", "const LOOPS_835 = {") + ";",
lift("enclosingLoop", "function enclosingLoop(stack)"),
lift("compositeSpec", "function compositeSpec(segId, index)"),
lift("segmentRole", "function segmentRole(seg)"),
@@ -68,7 +72,8 @@ const src = [
lift("LOOP_CONTEXTS", "const LOOP_CONTEXTS = [", "[", "]") + ";",
lift("contextLabel", "function contextLabel(key)"),
lift("referenceLoops", "function referenceLoops()"),
- "export { buildTree, defaultExpanded, allGroupKeys, treeMatches, flattenTree, loopTint, parse, referenceLoops, contextLabel, LOOP_CONTEXTS, ROW_H, HL_RANK, HL_RANK_MAX, GROUP_RANK, guideOf, GUIDE_PACDR, GUIDE_LOOP_LABELS };",
+ lift("referenceLoops835", "function referenceLoops835()"),
+ "export { buildTree, defaultExpanded, allGroupKeys, treeMatches, flattenTree, loopTint, parse, referenceLoops, referenceLoops835, contextLabel, LOOP_CONTEXTS, ROW_H, HL_RANK, HL_RANK_MAX, GROUP_RANK, guideOf, GUIDE_PACDR, GUIDE_LOOP_LABELS, TXN_835, has835, N1_LOOPS_835, LOOPS_835 };",
].join("\n");
const m = await import("data:text/javascript;base64," + Buffer.from(src).toString("base64"));
@@ -416,6 +421,15 @@ check("everything hanging off the service line",
["2400", "2410", "2420A", "2420H", "2430", "2440"].map(tint),
["lp-line", "lp-line", "lp-line", "lp-line", "lp-line", "lp-line"]);
check("the envelope has no loop and so no tint", m.loopTint(""), "");
+// 835's own bands: 1000A/1000B and 2000 land right by sharing 837's
+// prefixes; 2100 and 2110 need their own rules, and 2110 has to be checked
+// before the "21" catch-all or it would tint as a claim instead of a line.
+check("835 header parties share 837's header-party band",
+ [tint("1000A"), tint("1000B")], ["lp-head", "lp-head"]);
+check("835's header-number grouping shares 837's hierarchy band",
+ tint("2000"), "lp-hier");
+check("835's claim and line get their own bands",
+ [tint("2100"), tint("2110")], ["lp-claim", "lp-line"]);
// Every loop the builder can actually produce must land in a band, or it
// renders in the default grey and looks like an oversight.
const everyLoop = new Set();
@@ -541,5 +555,104 @@ check("ROW_H is the fixed height every tree row is drawn at", m.ROW_H, 24);
check("HL ranks sit between the transaction and the claim",
m.HL_RANK > m.GROUP_RANK.ST && m.HL_RANK_MAX < m.GROUP_RANK.CLM, true);
+console.log("\n[22] 835 remittance advice loop resolution");
+// The 837 anchors are switched off for an 835, not merely unreached: this is
+// the finding that shaped the design, so it is pinned directly rather than
+// left to fall out of the 837 assertions above.
+const x221Raw = readFileSync(X221_FIXTURE, "utf8");
+const x221Doc = m.parse(x221Raw);
+const x221Root = m.buildTree(x221Doc.segments);
+const x221St = child(child(child(x221Root, "ISA"), "GS"), "ST");
+check("the transaction carries no PACDR guide -- 835 is not a 'which 837 guide' question",
+ x221St.guide, "");
+check("its detail is just the set identifier", x221St.detail, "835");
+
+const parties = groups(x221St).filter((g) => g.id === "N1");
+check("N1*PR and N1*PE open 1000A and 1000B, not an NM1 entity loop",
+ parties.map((g) => [g.loop, g.label]), [["1000A", "Payer"], ["1000B", "Payee"]]);
+
+const header = child(x221St, "LX");
+check("LX opens loop 2000, a header-number grouping of claims -- not a line",
+ [header.loop, header.label], ["2000", "Header number 1"]);
+
+const claims835 = groups(header).filter((g) => g.id === "CLP");
+check("two claims resolve as siblings under the header grouping",
+ claims835.map((g) => g.loop), ["2100", "2100"]);
+check("the reversal is the same claim id as the original, just a second CLP",
+ claims835.map((g) => g.detail), ["PATIENTACCTNUM", "PATIENTACCTNUM"]);
+
+const lines1 = groups(claims835[0]).filter((g) => g.id === "SVC");
+check("claim 1's two service lines are siblings, not nested in each other",
+ lines1.map((g) => g.loop), ["2110", "2110"]);
+const lines2 = groups(claims835[1]).filter((g) => g.id === "SVC");
+check("the reversal claim keeps its own line", lines2.map((g) => g.loop), ["2110"]);
+
+check("NM1*82 (rendering provider) opens no group inside an 835 claim",
+ groups(claims835[0]).filter((g) => g.id === "NM1").length, 0);
+check("but the segment itself is still a leaf, not dropped",
+ leaves(claims835[0]).map((l) => x221Doc.segments[l.seg].id).includes("NM1"), true);
+
+// The every-segment-is-exactly-one-leaf invariant, asserted against an 835
+// as well as the 837P fixture in [2]. It is asserted here because the N1
+// case is the one place in the 835 path that can open a loop *or* not: an
+// unrecognised qualifier has to fall through to a plain leaf the way NM1's
+// does, and an early return there drops the segment from the tree and from
+// the interchange's own count without failing anything else.
+const everyLeafOnce = (raw) => {
+ const d = m.parse(raw);
+ const seen = [];
+ (function collect(node) {
+ if (node.kind === "segment") { seen.push(node.seg); return; }
+ (node.children || []).forEach(collect);
+ })(m.buildTree(d.segments));
+ return [seen.length, d.segments.length];
+};
+check("every segment in the 835 appears exactly once as a leaf",
+ everyLeafOnce(x221Raw), [x221Doc.segments.length, x221Doc.segments.length]);
+check("an N1 qualifier that opens no 835 loop is still kept as a segment",
+ everyLeafOnce(x221Raw.replace("N1*PE*BILLING PROVIDER NAME*XX*1234567893~", "N1*40*RECEIVER NAME~")),
+ [x221Doc.segments.length, x221Doc.segments.length]);
+check("and so is an N1 with no qualifier at all",
+ everyLeafOnce(x221Raw.replace("N1*PE*BILLING PROVIDER NAME*XX*1234567893~", "N1**RECEIVER NAME~")),
+ [x221Doc.segments.length, x221Doc.segments.length]);
+
+check("PLB is a direct leaf child of ST, never inside a claim",
+ leaves(x221St).map((l) => x221Doc.segments[l.seg].id).includes("PLB"), true);
+check("and it is not swallowed by the header grouping either",
+ leaves(header).map((l) => x221Doc.segments[l.seg].id).includes("PLB"), false);
+
+check("referenceLoops() is untouched by 835 -- no 21xx/2000/1000 rows creep in",
+ m.referenceLoops().flatMap(([, rows]) => rows).map((r) => r.loop)
+ .filter((l) => l === "2100" || l === "2110" || l === "2000"), []);
+
+console.log("\n[23] the 835's own five-row loop reference");
+check("referenceLoops835 lists exactly the five 835 loops",
+ m.referenceLoops835().map((r) => r.loop), ["1000A", "1000B", "2000", "2100", "2110"]);
+check("every row names the segment that opens it",
+ m.referenceLoops835().filter((r) => !r.opener || !r.name).length, 0);
+
+console.log("\n[24] a mixed 837P + 835 interchange resolves each correctly");
+// One ISA, two functional groups: an 835 transaction followed by the 837P
+// fixture's own. Built from segments rather than text, reusing each
+// fixture's own envelope pieces around a shared ISA/IEA pair.
+const mixedSegs = [
+ x221Doc.segments[0], // ISA (835's)
+ ...x221Doc.segments.slice(1, -1), // GS..GE, the 835 transaction
+ ...doc.segments.slice(1, -1), // GS..GE, the 837P fixture
+ x221Doc.segments[x221Doc.segments.length - 1], // IEA (835's)
+];
+const mixedRoot = m.buildTree(mixedSegs);
+const mixedGroups = groups(child(mixedRoot, "ISA")).filter((g) => g.id === "GS");
+check("both functional groups resolve", mixedGroups.length, 2);
+const mixedSt835 = child(mixedGroups[0], "ST");
+const mixedSt837 = child(mixedGroups[1], "ST");
+check("the 835 transaction still resolves its own loops",
+ groups(mixedSt835).filter((g) => g.id === "N1").map((g) => g.loop), ["1000A", "1000B"]);
+check("the 837P transaction beside it is completely unaffected",
+ groups(child(mixedSt837, "HL")).filter((g) => g.id === "NM1").map((g) => g.loop), ["2010AA"]);
+check("and its claim still resolves as 2300, not swept up by the 835's tables",
+ child(child(mixedSt837, "HL"), "HL") && groups(child(child(mixedSt837, "HL"), "HL"))
+ .filter((g) => g.id === "CLM").map((g) => g.loop), ["2300"]);
+
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);
diff --git a/web/tests/validate.mjs b/web/tests/validate.mjs
index bfda72d..9270abf 100644
--- a/web/tests/validate.mjs
+++ b/web/tests/validate.mjs
@@ -5,7 +5,7 @@
// claim, and the failure is invisible locally.
import { readFileSync } from "node:fs";
-import { APP, FIXTURE, PACDR_FIXTURE } from "./paths.mjs";
+import { APP, FIXTURE, PACDR_FIXTURE, X221_FIXTURE } from "./paths.mjs";
const html = readFileSync(APP, "utf8");
const start = html.indexOf("/* ---- document ---");
@@ -283,5 +283,104 @@ check("an AMT after LX does not overwrite the payer's claim-level amount",
titles(pacdr.replace("SVD*QFHP0001*160*HC:99213**1~", "AMT*D*999~\nSVD*QFHP0001*160*HC:99213**1~")
.replace("SE*55*0001~", "SE*56*0001~")), []);
+console.log("\n[14] 835 remittance advice (005010X221A1)");
+// The three checks an 835 adds: line (SVC-02 - CAS = SVC-03), claim (CLP-03 -
+// every CAS on it = CLP-04), and transaction (BPR-02 = every CLP-04 - every
+// PLB-04) -- plus the two shapes that must NOT be reported, exactly as the
+// PACDR block above does for its own three.
+const x221 = readFileSync(X221_FIXTURE, "utf8");
+const titles835 = (text) => m.validateDocument(m.parse(text)).map((f) => f.title);
+
+check("the 835 fixture is clean, reversal claim included", titles835(x221), []);
+
+check("a mutated SVC-03 is caught",
+ titles835(x221.replace("SVC*HC:99213*200*160**1~", "SVC*HC:99213*200*150**1~")),
+ ["Line payment does not reconcile"]);
+check("the detail names the computed total and what was stated",
+ m.validateDocument(m.parse(x221.replace("SVC*HC:99213*200*160**1~", "SVC*HC:99213*200*150**1~")))[0].detail,
+ "SVC-02 minus its adjustments on claim PATIENTACCTNUM is 160.00, but SVC-03 states 150.00.");
+
+// A mutated CLP-04 is caught by the claim check directly, and by the
+// transaction check too -- BPR-02 is computed FROM CLP-04, so a claim that
+// misstates its own payment legitimately misstates the transaction's as
+// well. This is the one mutation that cascades; the other two below do not.
+check("a mutated CLP-04 is caught at both the claim and the transaction",
+ titles835(x221.replace("*300*240*60*CI*QF98001234*11~", "*300*250*60*CI*QF98001234*11~")),
+ ["Claim payment does not reconcile", "Total payment does not reconcile"]);
+
+check("a mutated BPR-02 is caught, and only there",
+ titles835(x221.replace("BPR*I*135*C*", "BPR*I*145*C*")),
+ ["Total payment does not reconcile"]);
+
+console.log("\n[15] the PLB sign convention: a positive PLB reduces the payment");
+// Raising PLB-04 by 10 (25 -> 35) must LOWER the expected total by 10, to
+// 125.00, not raise it to 195.00 -- the check is BPR-02 = every CLP-04 minus
+// every PLB-04, and the minus sign is the one line in this feature most
+// likely to be "corrected" into a "+". The detail text is the actual pin: a
+// flipped sign still produces a finding here, but names the wrong total.
+const raisedPlb = x221.replace("L6:12345*25~", "L6:12345*35~");
+check("a positive PLB reduces the payment", titles835(raisedPlb), ["Total payment does not reconcile"]);
+check("the detail shows the LOWER expected total, not a higher one",
+ m.validateDocument(m.parse(raisedPlb))[0].detail,
+ "BPR-02 states 135.00, but every claim's CLP-04 minus every PLB-04 on the transaction comes to 125.00.");
+
+console.log("\n[16] reversals balance under the same rules -- no CLP-02 gate");
+// The reversal (CLP-02 = 22) is checked exactly like any other claim: an
+// unbalanced CAS on it is caught the same way an unbalanced CAS on the
+// original claim would be, sign and all.
+check("breaking the reversal's own CAS is caught, same as any other claim",
+ titles835(x221.replace("CAS*PR*2*-20~", "CAS*PR*2*-15~")),
+ ["Line payment does not reconcile", "Claim payment does not reconcile"]);
+
+console.log("\n[17] claim-level-only: the claim check runs, the line check is silent");
+// Legal 835 shape: adjudication reported with no 2110 at all. Built as its
+// own small file rather than a mutation of the shared fixture, since it is
+// a different shape (no SVC anywhere) rather than a broken value.
+const claimLevelOnly = [
+ "ISA*00* *00* *ZZ*PAYERID *ZZ*PROVIDERID *230101*1200*^*00501*000000001*0*T*:",
+ "GS*HP*PAYERID*PROVIDERID*20230101*1200*1*X*005010X221A1",
+ "ST*835*0001",
+ "BPR*I*80*C*ACH*CCP*01*111000025*DA*987654321*1234567890**01*111000025*DA*555555555*20230105",
+ "N1*PR*PAYER",
+ "N1*PE*PROVIDER",
+ "CLP*ACCT1*1*100*80*20*CI*CTRL1*11",
+ "CAS*CO*45*20",
+ "SE*7*0001",
+ "GE*1*1",
+ "IEA*1*000000001",
+].join("~") + "~";
+check("a claim with no service lines validates cleanly", titles835(claimLevelOnly), []);
+check("but the claim check genuinely ran -- breaking its CAS is still caught",
+ titles835(claimLevelOnly.replace("CAS*CO*45*20", "CAS*CO*45*30")),
+ ["Claim payment does not reconcile"]);
+
+console.log("\n[18] the transaction check's stand-down gates");
+check("BPR-03 = D stands the transaction check down, even when nothing reconciles",
+ titles835(x221.replace("BPR*I*135*C*", "BPR*I*999*D*")), []);
+
+console.log("\n[19] non-numeric amounts get their own warning and stand the check down");
+check("a non-numeric SVC-02", titles835(x221.replace("SVC*HC:99213*200*160**1~", "SVC*HC:99213*ABC*160**1~")),
+ ["SVC-02 is not a number"]);
+check("the detail names the claim and the value",
+ m.validateDocument(m.parse(x221.replace("SVC*HC:99213*200*160**1~", "SVC*HC:99213*ABC*160**1~")))[0].detail,
+ `SVC-02 for claim PATIENTACCTNUM reads "ABC".`);
+check("a non-numeric CLP-03", titles835(x221.replace("*300*240*60*CI*QF98001234*11~", "*XYZ*240*60*CI*QF98001234*11~")),
+ ["CLP-03 is not a number"]);
+check("a non-numeric BPR-02", titles835(x221.replace("BPR*I*135*C*", "BPR*I*XYZ*C*")),
+ ["BPR-02 is not a number"]);
+check("a non-numeric CAS amount", titles835(x221.replace("CAS*PR*2*40~", "CAS*PR*2*XYZ~")),
+ ["CAS amount is not a number"]);
+
+console.log("\n[20] absent is not the same fault as garbled");
+// A blank amount is situational and stands the check down without a
+// warning -- contrast with [19] above, where the same field holding
+// something unreadable warns on its own.
+check("a blank SVC-03 stands the line check down silently",
+ titles835(x221.replace("SVC*HC:99213*200*160**1~", "SVC*HC:99213*200***1~")), []);
+
+console.log("\n[21] an 835's checks never fire on an 837P or a PACDR file");
+check("the 837P fixture carries none of the 835 titles", titles(raw), []);
+check("the PACDR fixture carries none of the 835 titles", titles(pacdr), []);
+
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);