Post an 835 remittance
You have a remittance advice (the electronic EOB) and need to post the cash: walk each claim and service line, read the CARC and RARC adjustment reasons, and refuse to post a remittance that does not balance.
get835(delimiters, tx) returns a typed
X12Remittance, or undefined when the transaction set is not an 835. Every amount is an X12Decimal, exact to the digits the payer
sent; never parseFloat an EDI amount. Decimal-exact money says why.
Every interchange below is synthetic: fabricated names, invented ids, made-up control numbers. X12 healthcare data is PHI; a fixture must never hold a real one.
Read the payment, the claims and the adjustments
import { parseX12, get835, lookupCarc, lookupRarc } from "@cosyte/x12";
const raw =
"ISA*00* *00* *ZZ*MEDPAY *ZZ*CLINIC001 " +
"*260601*1200*^*00501*000000001*0*P*:~" +
"GS*HP*MEDPAY*CLINIC001*20260601*1200*1*X*005010X221A1~" +
"ST*835*0001~" +
"BPR*I*450.00*C*ACH*CCP*01*021000021*DA*1234567*1512345678**01*021000021*DA*98765*20260601~" +
"TRN*1*0012345*1512345678~" +
"N1*PR*MEDICARE PART A~" +
"N1*PE*SAMPLE CLINIC INC*XX*1234567890~" +
"LX*1~" +
"CLP*PT-ACCT-001*1*500.00*450.00*50.00*MB*CLAIMREF001*11~" +
"NM1*QC*1*PATIENT*TEST****MI*MEMBER001~" +
"SVC*HC:99213*500.00*450.00~" +
"CAS*PR*1*50.00~" +
"LQ*HE*N4~" +
"SE*12*0001~GE*1*1~IEA*1*000000001~";
const ix = parseX12(raw);
const tx = ix.groups[0]?.transactions.find((t) => t.st.elements[1] === "835");
const remit = tx ? get835(ix.delimiters, tx) : undefined;
if (remit === undefined) throw new Error("not an 835");
// The payment header: the money movement itself.
remit.payment.totalActualPayment?.toString(); // => "450.00"
remit.payment.creditDebitFlag; // => "C"
remit.payment.method; // => "ACH"
remit.traces[0]?.referenceId; // => "0012345"
remit.payee?.name; // => "SAMPLE CLINIC INC"
// Each claim: your account number echoed back, and the money split.
const claim = remit.claims[0];
claim?.patientControlNumber; // => "PT-ACCT-001"
claim?.totalChargeAmount?.toString(); // => "500.00"
claim?.totalPaymentAmount?.toString(); // => "450.00"
claim?.patientResponsibilityAmount?.toString(); // => "50.00"
// Each service line, and the adjustments (CAS) and remarks (LQ) on it.
const line = claim?.serviceLines[0];
line?.productServiceId; // => "99213"
line?.paymentAmount?.toString(); // => "450.00"
const adjustment = line?.adjustments[0];
adjustment?.groupCode; // => "PR"
adjustment?.reasonCode; // => "1"
adjustment?.amount?.toString(); // => "50.00"
adjustment?.reasonDescription; // => "Deductible Amount"
const remark = line?.remarks[0];
remark?.code; // => "N4"
remark?.description; // => "Missing/incomplete/invalid prior insurance carrier(s) EOB."
// The descriptions come from the bundled snapshots, which you can query yourself.
lookupCarc("1")?.description; // => "Deductible Amount"
lookupRarc("N4")?.description === remark?.description; // => true
The groupCode (PR patient responsibility, CO contractual obligation, and so on) is what tells
you who owes the money. Read it; never infer it. A CARC or RARC code outside the bundled snapshot
is kept verbatim with no description, and raises X12_UNKNOWN_CARC or X12_UNKNOWN_RARC: a stale
snapshot gives a missing description, never a wrong code.
Refuse to post a remittance that does not balance
The reader runs the three balance equations of the 835 implementation guide (005010X221A1,
section 1.10.2) and warns on a mismatch. It never rebalances; the payer's values stand.
- Claim:
CLP-04 + Σ(claim CAS + line CAS) == CLP-03. - Service line:
SVC-03 + Σ(line CAS) == SVC-02. - Remittance:
BPR-02 == Σ(CLP-04) - Σ(PLB).
Gate on two codes, not one. X12_835_REMIT_BALANCE_MISMATCH says the numbers do not add up.
X12_835_BALANCE_NOT_EVALUABLE says a term of an equation is undefined on the model, so the
equation was not run at all; that is equally a remittance you must not post automatically.
Through 0.0.12 that case collapsed to zero and raised the mismatch, so a gate written against the
mismatch alone stops firing on it when you upgrade.
import { parseX12, get835, WARNING_CODES } from "@cosyte/x12";
// The claim says 500.00 charged and 400.00 paid, and its one adjustment is 50.00.
const raw =
"ISA*00* *00* *ZZ*MEDPAY *ZZ*CLINIC001 " +
"*260601*1200*^*00501*000000001*0*P*:~" +
"GS*HP*MEDPAY*CLINIC001*20260601*1200*1*X*005010X221A1~" +
"ST*835*0001~" +
"BPR*I*400.00*C*ACH*CCP*01*021000021*DA*1234567*1512345678**01*021000021*DA*98765*20260601~" +
"TRN*1*0012345*1512345678~" +
"LX*1~" +
"CLP*PT-ACCT-001*1*500.00*400.00*50.00*MB*CLAIMREF001*11~" +
"CAS*PR*1*50.00~" +
"SE*7*0001~GE*1*1~IEA*1*000000001~";
const ix = parseX12(raw);
const tx = ix.groups[0]?.transactions[0];
const remit = tx ? get835(ix.delimiters, tx) : undefined;
if (remit === undefined) throw new Error("not an 835");
const doNotPost = remit.warnings.some(
(w) =>
w.code === WARNING_CODES.X12_835_REMIT_BALANCE_MISMATCH ||
w.code === WARNING_CODES.X12_835_BALANCE_NOT_EVALUABLE,
);
doNotPost; // => true
// The payer's values stand: nothing was rebalanced.
remit.claims[0]?.totalPaymentAmount?.toString(); // => "400.00"
When doNotPost is true, route the remittance to a person: either the payer's numbers do not add
up, or an amount one of the equations needs did not decode. The message names the equation and no
amount; the values are on the model.
Check an adjustment against the CORE Code Combinations table you supply
CAQH CORE Operating Rule 360 conveys why a claim was adjusted by the combined use of a group code,
a reason code and optionally one or more remark codes, and its CORE Code Combinations table lists
the combinations it expects for each of four CORE-defined business scenarios.
checkCoreCodeCombination answers whether one
adjustment's combination is in your copy of that table for a scenario you name. It answers that
one question about that one adjustment, and nothing about the remittance as a whole.
You supply the table and name its version. No CORE table, excerpt, or default ships with this package. CAQH CORE revises the table several times a year and publishes it under its own notice, so the version your posting system follows is yours to load, and the label you give it comes back beside every answer.
import {
CORE_BUSINESS_SCENARIOS,
checkCoreCodeCombination,
type CoreCodeCombinationTable,
} from "@cosyte/x12";
// YOUR table, loaded from the CORE Code Combinations version you follow. These
// rows are invented for the example: no CORE table ships with this package.
const table: CoreCodeCombinationTable = {
version: "our-transcription-2026-02",
rows: [
{ scenario: "scenario-3", groupCode: "CO", reasonCode: "ZZ901" },
{ scenario: "scenario-3", groupCode: "CO", reasonCode: "ZZ901", remarkCode: "ZZ-R1" },
],
};
// You name the scenario; nothing here infers one.
const scenario = CORE_BUSINESS_SCENARIOS.SERVICE_NOT_COVERED;
// In a posting loop these are an adjustment and the remarks you pair with it.
const adjustment = { groupCode: "CO", reasonCode: "ZZ901" };
const remarks = [{ system: "HE", code: "ZZ-R1" }];
const listed = checkCoreCodeCombination({ table, scenario, adjustment, remarks });
listed.outcome; // => "in-table"
listed.tableVersion; // => "our-transcription-2026-02"
// A remark the table does not list with this pair.
const unlisted = checkCoreCodeCombination({
table,
scenario,
adjustment,
remarks: [{ system: "HE", code: "ZZ-R2" }],
});
unlisted.outcome; // => "not-in-table"
// No table at all: unevaluated, which is NOT permission to post.
const noTable = checkCoreCodeCombination({ scenario, adjustment });
noTable; // => { outcome: "unevaluated", reason: "no-table", tableVersion: undefined }
Three answers, and unevaluated is not permitted. in-table means the table has a row for that
scenario, group code, and reason code, and lists every remark you passed with them; with no
remarks, a row for the pair suffices. not-in-table means the table has rows for the scenario and
this combination is not among them, including a combination the table lists only under another
scenario. unevaluated means the check could not decide, and reason says why: no table, a
scenario outside the four, no rows for that scenario, an empty group or reason code, or a remark
that is not an HE remark code (an LQ RX reject reason, for instance). Gate on in-table; a
gate written as "post unless not-in-table" posts every combination the check could not judge.
What it leaves to you. You choose the scenario. The 835 carries remarks per claim and per
service line rather than per adjustment, so you choose which remarks accompany the adjustment.
Codes are compared exactly as written, with no case folding and no trimming. It judges one
adjustment per call, does not say which remark was missing from the table, does not check a code's
effective dates, and does not evaluate a scenario a health plan defines for itself or the retail
pharmacy variant that pairs a reason code with an NCPDP reject code. A malformed table (no version
label, a row with an empty code or an unknown scenario) throws CoreCodeCombinationTableError with
the code X12_CORE_COMBINATION_TABLE_INVALID, and its message names the field and the row index,
never a value from your table.
Related
- Decimal-exact money: the balance equations, and why an amount that does
not decode reads
undefinedrather than zero. - Handle warnings: triage everything else the reader tolerated.
- Warning and error codes: the balance codes' messages.
- Reference:
get835,X12Remittance,lookupCarc,lookupRarc.