Files
cyclone/backend
Nora d8841834dc fix(sp27): Claim.patient_control_number populated from CLM01 (claim_id), not 2010BA NM109 (member_id)
The 837 ingest path (_claim_837_row in store.py:194) populated
Claim.patient_control_number from claim.subscriber.member_id — the
subscriber's 2010BA NM109 Medicaid ID — instead of from
claim.claim_id (CLM01, the claim submitter's identifier the 837
actually sent).

That silently broke every downstream join that uses this column as a
cross-reference key:

  * reconcile.match() (reconcile.py:74) — joins
    Claim.patient_control_number against
    Remittance.payer_claim_control_number (which is parsed from CLP01,
    the 835's echo of CLM01 per X12 spec). Member_id never matches
    CLP01, so auto-match always fails.
  * apply_999_rejections — same lookup, same broken key.
  * apply_277ca_rejections — same lookup, same broken key.
  * scoring.score_pair — same broken key.

Live DB probe (1,739 remits, 337 claims):
  * Claim.id == Remit.payer_claim_control_number    → 0 matches
  * Claim.patient_control_number == Remit.payer_claim_control_number
    → 9 matches (substring coincidences; synthetic PCN strings share
    alphanumeric characters with the human-readable member_ids)
  * Claim.matched_remittance_id NOT NULL             → 0 claims

This commit changes _claim_837_row to write
Claim.patient_control_number = claim.claim_id (= CLM01). The
reconcile matcher's existing join now hits the row the 835 echoes
back. Companion migration 0017 backfills the 337 pre-fix rows so
they're on equal footing with new ingests (UPDATE claims SET
patient_control_number = id WHERE patient_control_number IS DISTINCT
FROM id — idempotent).

Tests:
  * 2 new RED→GREEN tests in test_store_reconcile.py:
    - test_837_ingest_populates_patient_control_number_from_claim_id
      pins the field semantics directly
    - test_837_then_835_with_echoed_pcn_auto_pairs proves the full
      end-to-end auto-match now fires
  * 3 existing manual_match tests that relied on the bug (had setup
    using member_id != claim_id to deliberately prevent auto-match)
    updated to use distinct PCNs explicitly so the tests still
    exercise the manual-match path with a real orphan pair.
  * 3 store_claim_detail / api_gets tests adjusted for the same
    reason.

Verified: 1,176 / 1,177 backend tests pass; the one failure is the
pre-existing flake in test_provider_extended_response.py noted before
this work started.

Caveat for the existing dev DB: the 1,739 remits already in the DB
were ingested from 835 fixtures whose CLP01 is a different synthetic
identifier than the 837's CLM01 (the test fixtures never echoed
CLM01 — a fixture-data limitation, not a code bug). After this fix,
new 837+835 ingest pairs whose payer echoes CLM01 in CLP01 will
auto-match as expected. The pre-existing 1,739 remits will continue
to land in the unmatched bucket; that can only be fixed by
regenerating the test fixtures (out of scope for this SP).
2026-06-29 14:32:10 -06:00
..

Cyclone Backend — X12 837P / 835 ERA Parser

Python module + CLI for parsing X12 837P professional claim files and X12 835 ERA (Health Care Claim Payment/Advice) files into one validated JSON per claim / payout. Output is consumed by the Cyclone Vite/React frontend via a FastAPI service (see REST API below).

Install

cd backend
python -m venv .venv
. .venv/bin/activate
pip install -e ".[dev]"

CLI

python -m cyclone.cli parse-837 path/to/837p.txt --output-dir ./claims

Options:

  • --payer {co_medicaid,generic_837p} — default co_medicaid
  • --strict — promote warnings to errors
  • --max-retries N — stub in v1 (no auto-fix yet)
  • --include-raw-segments / --no-raw-segments — default: include
  • --log-level {DEBUG,INFO,WARNING,ERROR}

Exit codes:

  • 0 — every claim parsed
  • 2 — file-level failure (no envelope, no claims)
  • 1 — unexpected exception

Output

claims/
├── claim-991102977-20260611-CLM001.json   # one per claim
├── claim-991102977-20260611-CLM002.json
└── summary.json                            # batch summary

Programmatic use

from pathlib import Path
from cyclone.parsers.parse_837 import parse
from cyclone.parsers.payer import PayerConfig
from cyclone.parsers.writer import write_outputs

result = parse(Path("input.txt").read_text(), PayerConfig.co_medicaid())
write_outputs(result, Path("./claims"))
print(f"passed={result.summary.passed} failed={result.summary.failed}")

Tests

pytest -v

The CLI smoke test against docs/prodfiles/837p-from-axiscare/*.txt is auto-skipped if no production files are present.

REST API

The Cyclone Vite/React frontend (default dev origin http://localhost:5173) talks to the parser over HTTP. The API module is cyclone.api:app.

Run the server

uvicorn cyclone.api:app --reload --port 8000
# or, equivalently:
python -m cyclone serve

Endpoints

Method Path Purpose
GET /api/health Liveness probe: {"status": "ok", "version": ...}
POST /api/parse-837 Upload an X12 837P file, get parsed claims back
POST /api/parse-835 Upload an X12 835 ERA file, get parsed payouts back
POST /api/parse-999 Upload an inbound 999 ACK and persist it
POST /api/parse-ta1 Upload an inbound TA1 envelope ACK and persist it
POST /api/parse-277ca Upload a 277CA claim acknowledgment and stamp payer_rejected claims
POST /api/eligibility/request Build a 270 from JSON (subscriber / provider / payer)
POST /api/eligibility/parse-271 Ingest a 271 and return structured coverage_benefits
GET /api/clearhouse The clearhouse singleton (SP9)
POST /api/clearhouse/submit Push a batch of generated 837 files via SFTP (SP9 stub, SP13 real)
POST /api/admin/reload-config Re-read config/payers.yaml and refresh the in-process cache
GET /api/admin/audit-log Paginated tamper-evident audit log (SP11)
GET /api/admin/audit-log/verify Walk the audit_log hash chain (SP11)

The full surface — claim / remittance / batch / inbox / stream endpoints, config lookups, and the 270/271 builder — is enumerated in the root README.

POST /api/parse-837 accepts multipart/form-data with a single file field. Optional query parameters:

  • payerco_medicaid (default) or generic_837p
  • include_raw_segmentstrue (default) or false
  • strictfalse (default) or true (promote warnings to errors)

The response shape depends on the request's Accept header:

  • Accept: application/json → a single ParseResult-shaped JSON object (envelope, claims, summary).
  • No Accept header (or anything that isn't application/json) → application/x-ndjson streaming, one JSON object per line in this order: {"type": "envelope", "data": {...}}, one {"type": "claim", "data": {...}} per claim, then {"type": "summary", "data": {...}}.

Error responses are JSON of the form {"error": "...", "detail": "..."}:

  • 400 — file-level parse error (CycloneParseError) or unknown payer
  • 422 — at least one claim failed validation (body still includes the full ParseResult so the client can render the errors)
  • 500 — unexpected internal error

CORS is configured to allow http://localhost:5173 with GET and POST and any headers.

Example: upload + JSON response

curl -X POST http://localhost:8000/api/parse-837 \
  -F "file=@./samples/co_837p.txt" \
  -H "Accept: application/json"
{
  "envelope": { "sender_id": "...", "receiver_id": "...", ... },
  "claims":   [ { "claim_id": "C1", ... }, { "claim_id": "C2", ... } ],
  "summary":  { "total_claims": 2, "passed": 2, "failed": 0, ... }
}

Example: stream NDJSON from the browser

const resp = await fetch("http://localhost:8000/api/parse-837", {
  method: "POST",
  body: formData,                  // FormData with a "file" entry
});
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
  const { value, done } = await reader.read();
  if (done) break;
  buffer += decoder.decode(value, { stream: true });
  for (const line of buffer.split("\n")) {
    if (!line) continue;
    const evt = JSON.parse(line);
    if (evt.type === "claim") renderClaim(evt.data);
    else if (evt.type === "summary") showSummary(evt.data);
  }
  buffer = buffer.slice(buffer.lastIndexOf("\n") + 1);
}

Spec & plan

  • Design: docs/superpowers/specs/2026-06-19-cyclone-837p-parser-design.md
  • Plan: docs/superpowers/plans/2026-06-19-cyclone-837p-parser.md

835 ERA parser

The 835 ERA parser mirrors the 837P architecture but is a separate module (cyclone.parsers.parse_835) with its own models (cyclone.parsers.models_835), writer (cyclone.parsers.writer_835), validator (cyclone.parsers.validator_835), and PayerConfig835 preset.

It parses one 835 batch into:

  • envelope — ISA / GS / ST control segments
  • financial_info — BPR (total paid, handling code, payer tax id, date)
  • trace — TRN (reassociation trace number + originating company id)
  • payer — Loop 1000A (NM1*PR + N3/N4/PER contact URL)
  • payee — Loop 1000B (NM1*PE + N3/N4)
  • claims — one ClaimPayment per Loop 2100 CLP, with embedded ServicePayment rows for each Loop 2110 SVC
  • summary — same BatchSummary used by 837P

835 CLI

python -m cyclone.cli parse-835 path/to/835.txt --output-dir ./payouts

Options:

  • --payer {co_medicaid_835,generic_835} — default co_medicaid_835
  • --strict — promote warnings to errors
  • --include-raw-segments / --no-raw-segments — default: include
  • --log-level {DEBUG,INFO,WARNING,ERROR}

Output:

payouts/
├── payout-991102984-20260617-CLM001.json  # one per claim, with envelope/financial/trace/payer/payee embedded
├── payout-991102984-20260617-CLM002.json
└── summary.json

835 programmatic use

from pathlib import Path
from cyclone.parsers.parse_835 import parse
from cyclone.parsers.payer import PayerConfig835
from cyclone.parsers.validator_835 import validate
from cyclone.parsers.writer_835 import write_outputs_835

result = parse(Path("input.txt").read_text(), PayerConfig835.co_medicaid_835())
report = validate(result, PayerConfig835.co_medicaid_835())
print(f"passed={report.passed} errors={len(report.errors)}")
write_outputs_835(result, Path("./payouts"))

835 payout balancing

The 835 validator enforces two monetary-balancing rules:

  • R835_BAL_BPR_vs_CLP04 — the BPR02 total paid must equal the sum of CLP04 (total claim paid) across all claims in the batch. Tolerance is $0.01.
  • R835_BAL_CLP04_vs_SVC03 — each CLP04 must equal the sum of its embedded SVC03 service payment amounts. Tolerance is $0.01.

These rules catch the common "BPR doesn't match the sum of the claims" mismatch (often caused by a missing or duplicated claim in the batch).

835 REST API

POST /api/parse-835 mirrors POST /api/parse-837 exactly:

  • multipart/form-data with a single file field
  • Query params: payer (default co_medicaid_835), include_raw_segments (default true), strict (default false)
  • Content negotiation: Accept: application/json → single ParseResult835 object; default → NDJSON stream with envelope → financial_info → trace → payer → payee → claim_payment (×N) → summary lines
  • 400 on CycloneParseError or unknown payer
  • 422 when validate() reports errors (body still includes the full ParseResult835 so the UI can show the issues)
curl -X POST http://localhost:8000/api/parse-835 \
  -F "file=@./samples/co_835.txt" \
  -H "Accept: application/json"