# 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"
```
