The Reconciliation page crashed with 'c.chargeAmount.toFixed is undefined' because the TS types declared fields the backend never emitted (chargeAmount vs billedAmount, patientControlNumber vs patientName, statusCode vs status, totalPaid vs paidAmount). - UnmatchedClaim: chargeAmount -> billedAmount, patientControlNumber -> patientName, payerId/serviceDate now optional (backend omits them) - UnmatchedRemittance: statusCode -> status, totalPaid -> paidAmount, totalCharge/serviceDate/isReversal now optional - Reconciliation page + test fixtures updated to use the wire shape - Tests: 124/124 pass
Cyclone
A self-hosted EDI claims management suite for a single billing office. Parses 837P professional claims and 835 ERA remittances (X12 005010X222A1 and 005010X221A1), with a local-only FastAPI backend and a React UI for browsing, filtering, and inspecting the parsed data.
Local-only on purpose: binds to 127.0.0.1, no auth, no internet exposure.
Built for one operator, one machine, one trading partner (Colorado
Medicaid, currently).
Install
# Backend (Python 3.11+)
cd backend
python -m venv .venv
.venv/bin/pip install -e '.[dev]'
# Frontend (Node 20+)
cd ..
npm install
Dev
Two terminals:
# Terminal 1 — backend
cd backend
.venv/bin/python -m cyclone serve
# (defaults to 127.0.0.1:8000; override with CYCLONE_PORT=...; reload with CYCLONE_RELOAD=1)
# Terminal 2 — frontend
npm run dev
# (Vite on http://localhost:5173)
Then open http://localhost:5173. Drop an .txt 837P or 835 file on the
Upload page; navigate to Claims, Remittances, Providers, or Activity to
see the parsed data.
The frontend reads its backend URL from VITE_API_BASE_URL (default
empty). Create a .env.local at the repo root with:
VITE_API_BASE_URL=http://127.0.0.1:8000
Without that, the UI falls back to its in-memory sample store via the
existing data adapter (parses are disabled).
Test
# Backend
cd backend && .venv/bin/pytest
# Frontend type-check + build
npm run typecheck
npm run build
# Frontend unit tests
npm test
Persistence
Parsed batches, claims, remittances, matches, and activity events are
stored in a SQLite file at ~/.local/share/cyclone/cyclone.db by
default. The directory is auto-created on first run.
To use a different location, set CYCLONE_DB_URL:
export CYCLONE_DB_URL=sqlite:///path/to/cyclone.db
# or
export CYCLONE_DB_URL=postgresql://user:pass@host:5432/cyclone
Backup
sqlite3 ~/.local/share/cyclone/cyclone.db ".backup /path/to/backup.db"
This is safe to run while the backend is running (uses SQLite's online backup API).
Project layout
.
├── backend/
│ ├── src/cyclone/
│ │ ├── api.py # FastAPI app, GET + parse routes
│ │ ├── store.py # InMemoryStore, mappers
│ │ ├── __main__.py # `python -m cyclone serve`
│ │ ├── cli.py # click CLI
│ │ └── parsers/ # X12 tokenizer, models, validator, writers
│ └── tests/
│ ├── fixtures/ # co_medicaid_*.txt, minimal_*.txt
│ ├── test_api.py # parse-837/835 round-trip
│ ├── test_api_gets.py # 6 GET endpoints
│ ├── test_store.py # store + mappers + iterators
│ ├── test_api_streaming.py
│ └── test_api_parse_persists.py
├── src/ # React + Vite + TypeScript UI
│ ├── components/
│ │ └── ui/ # Skeleton, EmptyState, ErrorState, FilterChips, Pagination, …
│ ├── pages/ # Claims, Remittances, Providers, Activity, Upload
│ ├── hooks/ # useBatches, useClaims, useRemittances, useProviders, useActivity, useParse
│ ├── lib/ # api.ts (6 GET + parse837/parse835/health), format.ts, utils.ts
│ ├── store/ # zustand sample-data + parsed-batches store
│ └── types/ # shared TS types
├── docs/
│ ├── reference/ # condensed 837P/835/X12/CO Medicaid notes
│ └── superpowers/plans/ # implementation plan
├── tailwind.config.js # shimmer, scan, row-flash keyframes
└── package.json
Roadmap
Sub-projects 2, 3, and the first half of 4 are shipped. Next up:
- Sub-project 3 (shipped) — More 837P/835 features.
- 837P validation rules: R034 enforces
REF*G1on frequency-code 7/8 claims; R035 enforcesBHT06transaction-type-code is in the allowed set per payer config. - 835 CAS deep-parsing: every CAS adjustment now surfaces with its
CARC reason code + label (e.g.
CO-29: The time limit for filing has expired), surfaced viaGET /api/remittances/{id}and rendered as an expansion row on the Remittances page. - 999 ACK transaction set: full inbound parser + outbound
serializer. Auto-generated on 837 ingest when
?ack=trueis passed toPOST /api/parse-837. Persisted to theackstable, browsable on a new/ackspage, and downloadable as the regenerated raw 999 text. - 270/271 eligibility (API-only):
POST /api/eligibility/requestbuilds a 270 from a JSON payload (subscriber, provider, payer, service type);POST /api/eligibility/parse-271ingests the response and returns structuredcoverage_benefits.
- 837P validation rules: R034 enforces
- Sub-project 4 (partially shipped) — Frontend features.
- Per-claim detail drawer (shipped): click any row on the Claims
page to open a side-panel drawer with the full claim context —
state + amount header, validation panel, service lines, diagnoses,
parties (billing provider, subscriber, payer), raw X12 segments,
matched remittance summary, and a vertical state-history timeline.
URL is synced (
?claim=...) so links and back-button restore the drawer. Keyboard nav:j/kmove between claims,esccloses,?opens the cheatsheet overlay. Skeleton + error + not-found (404) states are all distinct. Powered by a new backend endpointGET /api/claims/{claim_id}that returns the full drawer payload in a single round-trip. - Remaining SP4: batch diff view, real-time streaming of NDJSON GET responses, advanced filters (date range, multi-status, saved filter sets).
- Per-claim detail drawer (shipped): click any row on the Claims
page to open a side-panel drawer with the full claim context —
state + amount header, validation panel, service lines, diagnoses,
parties (billing provider, subscriber, payer), raw X12 segments,
matched remittance summary, and a vertical state-history timeline.
URL is synced (
SP3 endpoints
POST /api/parse-837?ack=true— existing 837 parse, plus optional auto-generated 999 ACK persisted alongside the batch.POST /api/parse-999— parse an inbound 999 ACK and persist it.GET /api/acks— list ACKs.GET /api/acks/{id}— ACK detail, including the regeneratedraw_999_text.POST /api/eligibility/request— build a 270 from JSON.POST /api/eligibility/parse-271— ingest a 271 and return parsed coverage benefits.
The UI gains a new Acks page (sidebar entry) that lists persisted ACKs and lets you download the regenerated 999 text.
SP4 endpoints (claim drawer)
GET /api/claims/{claim_id}— full claim context for the drawer payload: header fields, diagnoses, service lines, parties (billing provider / subscriber / payer), validation result, raw X12 segments, matched remittance summary (ornull), and the claim's state history (ordered newest-first). 404 with a structured{ "error": "Not found", "detail": "Claim {id} not found" }body when the claim doesn't exist — distinct from a transient fetch failure so the UI can render a dedicated not-found state.
License
No license file yet; this is internal-use software. Add a LICENSE file
when one is decided.