Bidirectional 837
ASC X12 837 translator · governed both ways
Since I've been working in insurance, I always thought it would be fun to try to make my own 837 translator. This one was built entirely from free and open-source material: providers come from the public NPI registry, and medical codes and prices from published CMS fee schedules. Patient names are synthetic, so no real person appears anywhere in it.
An 837 is the electronic claim a provider sends a payer. This goes both ways: it generates synthetic bills and serialises them to ASC X12 837 Professional, and it reads 837 files back into the same schema. The requirement joining those two halves is that a file read in and written back out is the file that went in, byte for byte.
At the very bottom of this is just a simple format translation, and translation between formats is a classic problem in computer science. The domain is what makes it interesting to get wrong: a parser that drops a trailing zero produces a file that is still well-formed, still passes every downstream check, and is off by a cent. Nothing tells you. That is why the round-trip is the requirement rather than a nice property — it is the only assertion that catches a translation which lost something and stayed plausible.
I built it against a governance document written by a different model, and treated it as a binding contract rather than a suggestion — partly to see whether I could stand working that way. Where I had to break its letter, the departure is an entry in a decision register with the reason, and a test that fails if the code and the register drift apart. Thirty-two decisions and twenty-three findings later, that register is the thing I would keep if I had to throw the rest away.
The same ten bills, twice — taken out of one session through the application's own two
buttons. Open the CSV to read them, then open an interchange from the archive and find the
same claim on its CLM segment. The archive
is what a clearinghouse would receive.
Example data. Real providers and real published charges, invented patients, and no bill in either file describes anything that happened.
Nothing in it is invented that could have been real. I downloaded the 1.1 GB NPPES public file to avoid making a network call per claim, and distilled 3,120 providers out of it, each with a check-digit-valid NPI. 980 procedure codes, every one priced by a published CMS fee schedule — which is the ordering that matters: a code enters the catalogue because a schedule prices it, so there is no list of codes to discover holes in later. CPT is excluded throughout, because it is AMA copyright, and a test fails the build if a five-digit code ever reaches the catalogue.
// Translator.Edi/X12Number.cs — the two directions of a governed amount.
//
// X12 carries no scale: a charge of 1.00 is written "1". So the text cannot say whether that was
// a quantity of one or an amount of 1.0000, and the scale has to come from somewhere else.
/// <summary>Renders a governed decimal as an X12 R-type element.</summary>
public static string Render(decimal value)
{
var text = value.ToString(CultureInfo.InvariantCulture);
if (!text.Contains('.')) return text;
text = text.TrimEnd('0').TrimEnd('.');
// Trimming every digit of -0.00 or 0.00 leaves a sign or nothing at all.
return text.Length == 0 || text == "-" ? "0" : text;
}
/// <summary>Reads an X12 R-type element back at the governed scale it was declared with.</summary>
/// <exception cref="FormatException">
/// The element is not a number, or carries more precision than the governed column can hold.
/// Rounding it instead would store an amount the file does not state, which is the corruption
/// FIND-001 recorded arriving through a different door.
/// </exception>
public static decimal Parse(string element, int governedScale)
{
if (element is null || !NumericElement().IsMatch(element))
throw new FormatException($"'{element}' is not an X12 numeric element.");
var point = element.IndexOf('.');
var suppliedScale = point < 0 ? 0 : element.Length - point - 1;
if (suppliedScale > governedScale)
throw new FormatException(
$"'{element}' carries {suppliedScale} decimal places; the governed column holds " +
$"{governedScale}. Reading it would silently change the value.");
// Addition takes the larger of the two scales, so adding a zero of the governed scale restores
// the trailing zeros rendering suppressed. Rounding would not: decimal.Round(1m, 2) is 1, not
// 1.00, and the difference is exactly what FIND-002 recorded in the store.
return decimal.Parse(element, NumberStyles.AllowLeadingSign | NumberStyles.AllowDecimalPoint,
CultureInfo.InvariantCulture)
+ new decimal(0, 0, 0, false, (byte)governedScale);
}
Why this one: forty lines, and the whole byte-for-byte guarantee rests on them. X12 suppresses trailing zeros, so a charge of 1.00 goes out as "1" and the text can no longer say what scale it was. The scale has to come back from the governed column — and the obvious way to do that is wrong.
decimal.Round(1m, 2) returns
1, not
1.00. Adding a zero of the target
scale does work, because decimal addition takes the larger of its operands' scales. Reach
for Round here and every amount silently loses its trailing zeros on the way back in, the
next file written differs from the one read, and nothing anywhere reports an error — the
claim round-trips, the export is well-formed, and the money is wrong.
Tests were written first and observed failing before the code existed, in every one of
eleven sections. Invariants are xUnit Theories and Vitest
it.each tables rather than single examples,
so a failure names the input that broke it. The clearest thing that bought: a persistence test,
written before there was a persistence layer, caught the worst defect in the project. The governed
schema says
decimal(18,2); SQLite reads a type name it
does not implement, gives the column NUMERIC affinity, and coerces the value to a float.
Written
9999999999999999.99, the store handed back
10000000000000000. The annotation was correct,
the entity was correct, and the money was wrong — so amounts are stored as integer minor
units now.
I broke that protocol once. Building the client I wrote the helper tests, watched forty-six of them fail, implemented against them — and only then noticed I had never committed the failing tree. So I set the implementations aside, restored the stubs, confirmed seventy-three genuine failures, and committed that. It cost an hour and changed no behaviour at all. It is in the evidence log under the word disclosed, because a protocol you quietly patch when nobody is looking was never a protocol.
The suite was green the whole way, and the findings I would actually take to a team are the ones it was green through. Two are worth anyone's time:
FIND-017 The live provider-registry call had never once worked in production. The API refuses a query carrying only a state — and refuses it with HTTP 200 and an error body, so the status check passed, no results came back, and the client fell back to synthetic data permanently. It survived three sections because I had written the stub to return what I expected the service to return. Every test proved the client could read a good answer and none proved the question was one the service would answer. The fix asserts the request, which nothing had ever done.
FIND-015 Twelve tests removed a required segment from a valid 837 and asserted the reader refused it. All twelve passed. None passed for the reason in its name: removing any segment also makes the trailer's segment count untrue, and that check fires first. The suite proved the reader could count, twelve times over, while the checks it was written for went entirely unexercised.
Both say the same thing: a passing test proves the assertion held, not that the scenario was the one in the test's name. The habit that came out of it is cheap enough to hand to anyone — assert the request as well as the response, and make a negative test fail for the reason it claims before trusting it. The register has twenty-three of these written up, including the two that only appeared once a single test carried a claim across a host boundary and looked back at it.
governance Every departure from the document's letter is an entry stating which clause is affected, why it could not be kept, and what preserves the intent. The register is enforced rather than filed: a test fails the build if a code-bearing decision has no marker in the source, or if a marker cites a decision the register does not define. Governance nobody can trace back to the code decays into a document nobody reads.
purity Serialising a claim is a pure function of that claim. No clock, no counter and no random source touches an 837, because an export that varied between calls could never reproduce the file it came from. A claim's database identity never reaches the file either — an importer cannot recover a Guid.
refusal The reader refuses anything it cannot map exactly rather than salvaging part of it. A half-read claim reaches the store indistinguishable from one that arrived whole, and re-exports as a well-formed 837 saying something the sender never said. So a file whose CLM02 contradicts its own line amounts is rejected outright — whichever number got stored, the other one would be wrong.
honesty The data is good enough to mislead, so the page says so where a user can see it — the charges are real published CMS figures, which is exactly why the caveat is needed. The reversibility verdict reports two booleans separately for the same reason: it compares a stored record against its own re-export and never sees the bytes you uploaded, so a claim can be perfectly preserved while the text differs. One tick would hide that.
limits I chose an in-memory store because nothing here needs to outlive the process, and wrote the consequence down at the time rather than discovering it later: a restart is a clean slate, and a scaled-out deployment would give each instance its own claims. Deploying made that real. Always On moves the loss from twenty minutes of inactivity to a restart, which is not durability and is not recorded as durability.