Files

258 lines
9.1 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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"
```