Files
cyclone/backend
Nora 61e90b64b3 test(driver): prove validate_edi path works against Edifabric response shape
The live /v2/x12/validate endpoint is quota-blocked; until it resets
at next UTC midnight, the driver can't make real network calls. This
test wires the shipped cyclone.edifabric MockTransport seam to
return canned OperationResult payloads and asserts the driver's
passed() classifier returns the expected verdict.

Covers 4 scenarios:
  1. Status='success' → passed=True
  2. Status='warning' → passed=True (advisory notices don't block)
  3. Status='error' → passed=False (with error details in message)
  4. HTTP 403 quota → EdifabricError raised (driver falls back to pyX12)

Plus 1 wire-shape test that pins the validate call body is the
X12Interchange JSON (not raw bytes) per the /v2/x12/validate contract.
2026-07-08 03:34:00 -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"