258 lines
9.1 KiB
Markdown
258 lines
9.1 KiB
Markdown
# 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
|
||
|
||
```bash
|
||
cd backend
|
||
python -m venv .venv
|
||
. .venv/bin/activate
|
||
pip install -e ".[dev]"
|
||
```
|
||
|
||
## CLI
|
||
|
||
```bash
|
||
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
|
||
|
||
```python
|
||
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
|
||
|
||
```bash
|
||
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
|
||
|
||
```bash
|
||
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](../README.md#multi-payer-multi-npi--clearhouse).
|
||
|
||
`POST /api/parse-837` accepts `multipart/form-data` with a single `file`
|
||
field. Optional query parameters:
|
||
|
||
- `payer` — `co_medicaid` (default) or `generic_837p`
|
||
- `include_raw_segments` — `true` (default) or `false`
|
||
- `strict` — `false` (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
|
||
|
||
```bash
|
||
curl -X POST http://localhost:8000/api/parse-837 \
|
||
-F "file=@./samples/co_837p.txt" \
|
||
-H "Accept: application/json"
|
||
```
|
||
|
||
```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
|
||
|
||
```js
|
||
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
|
||
|
||
```bash
|
||
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
|
||
|
||
```python
|
||
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)
|
||
|
||
```bash
|
||
curl -X POST http://localhost:8000/api/parse-835 \
|
||
-F "file=@./samples/co_835.txt" \
|
||
-H "Accept: application/json"
|
||
```
|