561018c690
- backend/src/cyclone/parsers/serialize_837.py — full-rebuild 837P serializer. Emits envelope (ISA/GS/ST/SE/GE/IEA + BHT) + submitter/receiver/billing provider/subscriber/payer hierarchy + editable segments (CLM/REF*G1/HI) + per-service-line LX/SV1/DTP*472/REF*6R — all from canonical ClaimOutput fields. Pivoted from spec §3.1 hybrid to full rebuild because ClaimOutput.raw_segments only captures post-CLM segments (CLM, REF*G1, HI, LX, SV1 pairs) — not the envelope or hierarchies. A pass-through approach cannot regenerate those without expanding raw_segments in parse_837.py (out of scope for this SP). - backend/tests/test_serialize_837.py — 36 tests covering envelope shape, hierarchy segments, claim-level builders, service-line builders, edited-field propagation, round-trip, custom sender/receiver IDs, and resubmit helper. - backend/tests/test_prodfiles_smoke.py::test_claims_prodfile_round_trip — every file in docs/prodfiles/claims/ (113 files) round-trips through serialize_837 → parse_837_text with deep-equal ClaimOutput (modulo validation, which is recomputed by the parser). - docs/superpowers/plans/2026-06-20-cyclone-serialize-837.md — full plan with amendment note documenting the Approach A pivot. Per session convention, plan note about unrelated modifications to parse_835.py / fixtures stashed separately.
1580 lines
51 KiB
Markdown
1580 lines
51 KiB
Markdown
# Outbound 837P Serializer Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Add an outbound X12 837P serializer so a parsed `ClaimOutput` can be regenerated as a complete, round-trippable file. Closes the SP6 resubmit lane end-to-end (`?download=true`) and adds a "Download 837" affordance on the claim drawer.
|
||
|
||
**Architecture:** **Full rebuild (Approach A).** Spec §3.1 originally proposed a "hybrid" that pass-throughs stable segments from `claim.raw_segments`, but raw_segments only contains post-CLM segments (CLM, REF*G1, HI, LX, SV1 pairs) — not the envelope or hierarchies. Full rebuild emits everything from canonical ClaimOutput fields (BillingProvider, Subscriber, Payer, ClaimHeader, diagnoses, service_lines). ~250-300 LOC. Self-contained — no parser changes. Matches existing `serialize_270.py` / `serialize_999.py` patterns.
|
||
|
||
**Tech Stack:** Python 3.13 (backend), FastAPI, existing test patterns; React + TypeScript + Vitest (frontend); JSZip via CDN `<script>` tag for the resubmit-bundle zip (no new npm dep per spec §5).
|
||
|
||
**Spec:** `docs/superpowers/specs/2026-06-20-cyclone-serialize-837-design.md`
|
||
**Pattern references:** `backend/src/cyclone/parsers/serialize_999.py`, `backend/src/cyclone/parsers/serialize_270.py`, `backend/src/cyclone/parsers/parse_837.py`
|
||
|
||
**Amendment note (2026-06-20):** Approach changed from spec §3.1 hybrid to Approach A full rebuild. Rationale recorded in execution log; spec doc unchanged for historical reference. The round-trip guarantee still holds (test in Phase 5 verifies 113 prodfiles).
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
**Create:**
|
||
- `backend/src/cyclone/parsers/serialize_837.py` — the serializer (helpers + `serialize_837` + `serialize_837_for_resubmit` + `SerializeError`)
|
||
- `backend/tests/test_serialize_837.py` — 9 serializer tests
|
||
- `backend/tests/test_api_serialize_837.py` — 3 endpoint tests
|
||
- `backend/tests/test_inbox_resubmit_download.py` — 2 endpoint tests for `?download=true`
|
||
|
||
**Modify:**
|
||
- `backend/src/cyclone/api.py` — add `GET /api/claims/{id}/serialize-837`, extend `POST /api/inbox/rejected/resubmit` with `?download=true`
|
||
- `backend/tests/test_prodfiles_smoke.py` — add `test_claims_prodfile_round_trip` parametrized over `docs/prodfiles/claims/*.x12`
|
||
- `src/lib/api.ts` — add `api.getClaim837(claimId)`, extend `api.resubmitRejected` with `{download: true}` option
|
||
- `src/components/ClaimDrawer/ClaimDrawerHeader.tsx` — add "Download 837" button
|
||
- `src/components/ClaimDrawer/ClaimDrawerHeader.test.tsx` — add 1 test
|
||
- `src/pages/Inbox.tsx` — extend Resubmit action with a "download bundle?" modal (JSZip via CDN)
|
||
- `src/pages/Inbox.test.tsx` — add 1 test
|
||
- `README.md` — add "Outbound 837 Serializer" section
|
||
- `src/index.html` (or similar) — add JSZip CDN `<script>` tag
|
||
|
||
---
|
||
|
||
## Phase 1 — Serializer module
|
||
|
||
### Task 1: Add `SerializeError` + envelope helpers
|
||
|
||
**Files:**
|
||
- Create: `backend/src/cyclone/parsers/serialize_837.py`
|
||
- Test: `backend/tests/test_serialize_837.py`
|
||
|
||
- [ ] **Step 1: Write failing tests for `_build_isa`, `_build_gs`, `_build_st`, `_build_se`**
|
||
|
||
Append to `backend/tests/test_serialize_837.py`:
|
||
|
||
```python
|
||
from cyclone.parsers.serialize_837 import (
|
||
SerializeError,
|
||
_build_isa,
|
||
_build_gs,
|
||
_build_st,
|
||
_build_se,
|
||
)
|
||
|
||
def test_build_isa_is_106_chars_with_standard_delimiters():
|
||
isa = _build_isa("SENDER", "RECEIVER", "000000001")
|
||
assert isa.startswith("ISA*")
|
||
assert len(isa) == 106
|
||
# ISA ends with the component separator (element 16, the lone `^`).
|
||
assert isa.endswith("^")
|
||
|
||
def test_build_gs_emits_gs_segment():
|
||
gs = _build_gs("SENDER", "RECEIVER", "1")
|
||
assert gs.startswith("GS*HC*")
|
||
parts = gs.split("*")
|
||
assert parts[0] == "GS"
|
||
assert parts[1] == "HC" # functional ID code for 837
|
||
assert parts[2] == "SENDER"
|
||
assert parts[3] == "RECEIVER"
|
||
assert parts[6] == "1" # group control number
|
||
|
||
def test_build_st_emits_st_segment():
|
||
st = _build_st("0001", "005010X222A1")
|
||
assert st.startswith("ST*837*0001*005010X222A1~")
|
||
|
||
def test_build_se_emits_se_segment():
|
||
se = _build_se(10, "0001")
|
||
assert se == "SE*10*0001~"
|
||
|
||
def test_serialize_error_is_an_exception():
|
||
assert issubclass(SerializeError, Exception)
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests, confirm FAIL**
|
||
|
||
```bash
|
||
cd backend && .venv/bin/pytest tests/test_serialize_837.py -v
|
||
```
|
||
|
||
Expected: `ModuleNotFoundError: No module named 'cyclone.parsers.serialize_837'`.
|
||
|
||
- [ ] **Step 3: Create `serialize_837.py` with the helpers**
|
||
|
||
Create `backend/src/cyclone/parsers/serialize_837.py`:
|
||
|
||
```python
|
||
"""Serialize a :class:`ClaimOutput` to a complete X12 837P text.
|
||
|
||
Hybrid approach (per spec §3.1):
|
||
- Envelope segments (ISA / GS / ST / SE / GE / IEA) are freshly built
|
||
with caller-supplied control numbers.
|
||
- BHT is freshly built from `Envelope` so BHT06 edits propagate.
|
||
- Stable hierarchy segments (submitter, receiver, billing provider,
|
||
subscriber, payer) are passed through byte-identical from
|
||
`claim.raw_segments` — the parser captured them and we don't want
|
||
to drift from the parser.
|
||
- Editable claim-level segments (CLM, REF*G1, HI) and service-line
|
||
segments (SV1, DTP*472) are rebuilt from canonical ClaimOutput fields
|
||
so post-parse edits propagate.
|
||
|
||
The output round-trips through :func:`cyclone.parsers.parse_837.parse_837_text`
|
||
— see the tests in ``backend/tests/test_serialize_837.py``.
|
||
|
||
If ``claim.raw_segments`` is empty, :class:`SerializeError` is raised:
|
||
v1 does not implement a from-scratch emitter (that's a future SP).
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
from datetime import date
|
||
|
||
from cyclone.parsers.models import ClaimOutput
|
||
|
||
_SEG = "~"
|
||
_ELEM = "*"
|
||
|
||
|
||
class SerializeError(Exception):
|
||
"""Raised when a claim cannot be serialized (e.g. missing raw_segments)."""
|
||
|
||
|
||
def _today_yymmdd() -> str:
|
||
t = date.today()
|
||
return f"{t.year % 100:02d}{t.month:02d}{t.day:02d}"
|
||
|
||
|
||
def _today_hhmm() -> str:
|
||
t = date.today()
|
||
return f"{t.hour:02d}{t.minute:02d}"
|
||
|
||
|
||
def _pad(s: str, width: int) -> str:
|
||
return (s or "").ljust(width)[:width]
|
||
|
||
|
||
def _build_isa(sender_id: str, receiver_id: str, interchange_control_number: str) -> str:
|
||
"""Build the 106-char ISA envelope (mirrors serialize_999 layout)."""
|
||
parts = [
|
||
"ISA",
|
||
"00", _pad("", 10),
|
||
"00", _pad("", 10),
|
||
"ZZ", _pad(sender_id, 15),
|
||
"ZZ", _pad(receiver_id, 15),
|
||
_today_yymmdd(),
|
||
_today_hhmm(),
|
||
"^", # ISA11 — repetition separator
|
||
"00501", # ISA12 — control version
|
||
_pad(interchange_control_number, 9),
|
||
"0", # ISA14 — ack requested (0 = no)
|
||
"P", # ISA15 — usage indicator (P = production)
|
||
"^", # ISA16 — component separator
|
||
]
|
||
return _ELEM.join(parts) + _SEG
|
||
|
||
|
||
def _build_gs(sender_id: str, receiver_id: str, group_control_number: str) -> str:
|
||
"""Build the GS envelope segment (functional ID code = HC for 837)."""
|
||
parts = [
|
||
"GS",
|
||
"HC",
|
||
sender_id,
|
||
receiver_id,
|
||
_today_yymmdd(),
|
||
_today_hhmm(),
|
||
group_control_number,
|
||
"X", # GS08 — responsibility agency code
|
||
"005010X222A1", # GS08 — implementation guide
|
||
]
|
||
return _ELEM.join(parts) + _SEG
|
||
|
||
|
||
def _build_st(control_number: str, implementation_guide: str = "005010X222A1") -> str:
|
||
return _ELEM.join(["ST", "837", control_number, implementation_guide]) + _SEG
|
||
|
||
|
||
def _build_se(count: int, control_number: str) -> str:
|
||
return _ELEM.join(["SE", str(count), control_number]) + _SEG
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests, confirm PASS**
|
||
|
||
```bash
|
||
cd backend && .venv/bin/pytest tests/test_serialize_837.py -v
|
||
```
|
||
|
||
Expected: 5 passed.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add backend/src/cyclone/parsers/serialize_837.py backend/tests/test_serialize_837.py
|
||
git commit -m "feat(sp8): 837 serializer envelope helpers (ISA/GS/ST/SE) + SerializeError"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: Claim-level segment builders (CLM, REF*G1, HI, BHT)
|
||
|
||
**Files:**
|
||
- Modify: `backend/src/cyclone/parsers/serialize_837.py`
|
||
- Test: `backend/tests/test_serialize_837.py`
|
||
|
||
- [ ] **Step 1: Add failing tests for `_build_bht`, `_build_clm`, `_build_ref_g1`, `_build_hi`**
|
||
|
||
Append to `backend/tests/test_serialize_837.py`:
|
||
|
||
```python
|
||
from datetime import date
|
||
from cyclone.parsers.serialize_837 import _build_bht, _build_clm, _build_ref_g1, _build_hi
|
||
from cyclone.parsers.models import ClaimHeader, Diagnosis
|
||
|
||
|
||
def test_build_bht_includes_bht06_from_envelope():
|
||
# BHT01=0019 (originator), BHT02=00 (purpose), BHT03=ref, BHT04=date, BHT05=time, BHT06=transaction type
|
||
from cyclone.parsers.models import Envelope
|
||
env = Envelope(transaction_type_code="CH")
|
||
bht = _build_bht(env, reference_id="REF123", service_date=date(2026, 6, 20))
|
||
parts = bht.rstrip("~").split("*")
|
||
assert parts[0] == "BHT"
|
||
assert parts[6] == "CH"
|
||
|
||
def test_build_clm_emits_clm01_to_clm05():
|
||
from decimal import Decimal
|
||
claim = ClaimHeader(
|
||
claim_id="CLM-1",
|
||
total_charge=Decimal("100.00"),
|
||
place_of_service="11",
|
||
frequency_code="1",
|
||
)
|
||
clm = _build_clm(claim)
|
||
parts = clm.rstrip("~").split("*")
|
||
assert parts[0] == "CLM"
|
||
assert parts[1] == "CLM-1"
|
||
assert parts[2] == "100.00"
|
||
assert parts[4] == "11" # place of service (CLM05 facility code qualifier "B" precedes)
|
||
|
||
def test_build_ref_g1_returns_empty_string_when_no_prior_auth():
|
||
assert _build_ref_g1(None) == ""
|
||
assert _build_ref_g1("") == ""
|
||
|
||
def test_build_ref_g1_emits_segment_when_prior_auth_set():
|
||
assert _build_ref_g1("AUTH123") == "REF*G1* AUTH123~".replace(" ", "")
|
||
|
||
def test_build_hi_emits_one_hi_segment_with_qualifier_bk_for_primary():
|
||
diags = [
|
||
Diagnosis(code="E11.9", qualifier="BK", sequence=1),
|
||
Diagnosis(code="I10", qualifier="BF", sequence=2),
|
||
]
|
||
hi = _build_hi(diags)
|
||
assert hi.startswith("HI*")
|
||
parts = hi.rstrip("~").split("*")
|
||
assert parts[0] == "HI"
|
||
# 12 diagnoses max; unused are blank
|
||
assert parts[1] == "BK:E11.9"
|
||
assert parts[2] == "BF:I10"
|
||
```
|
||
|
||
> **Note on `_build_bht` signature:** the `Envelope` model needs `transaction_type_code`. Verify it exists on the model from SP3 work; if not, fall back to a literal `"CH"`. (See SP3 EDI-features plan T5 step 3 — it was added then.) Adapt the test to whatever field is available; if `transaction_type_code` is `None`, emit `"CH"` as a default.
|
||
|
||
- [ ] **Step 2: Run tests, confirm FAIL**
|
||
|
||
Expected: `ImportError` or `AttributeError` on the new helpers.
|
||
|
||
- [ ] **Step 3: Implement `_build_bht`, `_build_clm`, `_build_ref_g1`, `_build_hi`**
|
||
|
||
Add to `backend/src/cyclone/parsers/serialize_837.py`:
|
||
|
||
```python
|
||
from cyclone.parsers.models import ClaimHeader, Diagnosis, Envelope
|
||
|
||
|
||
def _build_bht(envelope: Envelope, *, reference_id: str, service_date: date) -> str:
|
||
"""Build the BHT segment.
|
||
|
||
BHT01 = 0019 (originator application) for 837
|
||
BHT02 = 00 (purpose: original)
|
||
BHT03 = reference id
|
||
BHT04 = service date (CCYYMMDD)
|
||
BHT05 = service time (HHMM)
|
||
BHT06 = transaction type code (from envelope; defaults to "CH")
|
||
"""
|
||
bht06 = getattr(envelope, "transaction_type_code", None) or "CH"
|
||
parts = [
|
||
"BHT",
|
||
"0019",
|
||
"00",
|
||
reference_id,
|
||
service_date.strftime("%Y%m%d"),
|
||
_today_hhmm(),
|
||
bht06,
|
||
]
|
||
return _ELEM.join(parts) + _SEG
|
||
|
||
|
||
def _build_clm(claim: ClaimHeader) -> str:
|
||
"""CLM01..CLM05 (claim id, total, place of service, frequency)."""
|
||
charge = f"{claim.total_charge:.2f}"
|
||
parts = [
|
||
"CLM",
|
||
claim.claim_id,
|
||
charge,
|
||
"", # CLM03 — non-institutional claim filing indicator (omitted)
|
||
claim.place_of_service or "",
|
||
"", # CLM05-1 — facility code (omitted unless set)
|
||
"B", # CLM05-2 — facility code qualifier
|
||
"", # CLM06 — provider signature on file (Y/N, omitted)
|
||
"Y", # CLM07 — assignment of benefits
|
||
"Y", # CLM08 — assignment of benefits certification
|
||
]
|
||
return _ELEM.join(parts) + _SEG
|
||
|
||
|
||
def _build_ref_g1(prior_auth: str | None) -> str:
|
||
"""REF*G1 (prior authorization). Empty string when no prior auth."""
|
||
if not prior_auth:
|
||
return ""
|
||
return _ELEM.join(["REF", "G1", prior_auth]) + _SEG
|
||
|
||
|
||
def _build_hi(diagnoses: list[Diagnosis]) -> str:
|
||
"""One HI segment with up to 12 diagnoses (qualifier:code)."""
|
||
parts = ["HI"]
|
||
for diag in diagnoses[:12]:
|
||
parts.append(f"{diag.qualifier}:{diag.code}")
|
||
return _ELEM.join(parts) + _SEG
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests, confirm PASS**
|
||
|
||
Expected: previous 5 + new 5 = 10 passed.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add backend/src/cyclone/parsers/serialize_837.py backend/tests/test_serialize_837.py
|
||
git commit -m "feat(sp8): 837 serializer claim-level segment builders (BHT/CLM/REF*G1/HI)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: Service-line segment builders (SV1, DTP*472)
|
||
|
||
**Files:**
|
||
- Modify: `backend/src/cyclone/parsers/serialize_837.py`
|
||
- Test: `backend/tests/test_serialize_837.py`
|
||
|
||
- [ ] **Step 1: Add failing tests**
|
||
|
||
Append:
|
||
|
||
```python
|
||
from cyclone.parsers.serialize_837 import _build_sv1, _build_dtp_472
|
||
from cyclone.parsers.models import ServiceLine
|
||
from decimal import Decimal
|
||
|
||
|
||
def test_build_sv1_emits_procedure_modifiers_charge_units():
|
||
line = ServiceLine(
|
||
line_number=1,
|
||
procedure_code="99213",
|
||
modifiers=["25"],
|
||
charge=Decimal("100.00"),
|
||
units=Decimal("1"),
|
||
unit_type="UN",
|
||
)
|
||
sv1 = _build_sv1(line)
|
||
parts = sv1.rstrip("~").split("*")
|
||
assert parts[0] == "SV1"
|
||
# SV1-01 composite: qualifier HC + procedure + up to 4 modifiers
|
||
assert parts[1].startswith("HC:99213")
|
||
assert ":25" in parts[1]
|
||
assert parts[2] == "100.00"
|
||
assert parts[3] == "UN"
|
||
assert parts[4] == "1"
|
||
|
||
|
||
def test_build_dtp_472_emits_service_date():
|
||
out = _build_dtp_472(date(2026, 6, 15))
|
||
assert out == "DTP*472*RD8*20260615-20260615~"
|
||
|
||
def test_build_dtp_472_returns_empty_when_no_date():
|
||
assert _build_dtp_472(None) == ""
|
||
```
|
||
|
||
- [ ] **Step 2: Run, confirm FAIL**
|
||
|
||
- [ ] **Step 3: Implement `_build_sv1`, `_build_dtp_472`**
|
||
|
||
Add to `serialize_837.py`:
|
||
|
||
```python
|
||
from cyclone.parsers.models import ServiceLine
|
||
|
||
|
||
def _build_sv1(line: ServiceLine) -> str:
|
||
"""SV1 segment — professional service line.
|
||
|
||
SV1-01 composite: HC:procedure_code + up to 4 modifiers
|
||
SV1-02: charge
|
||
SV1-03: unit type (UN/MJ/etc.)
|
||
SV1-04: units
|
||
"""
|
||
proc = line.procedure_code or ""
|
||
mods = line.modifiers or []
|
||
composite = "HC:" + proc + "".join(f":{m}" for m in mods[:4])
|
||
charge = f"{line.charge:.2f}"
|
||
parts = [
|
||
"SV1",
|
||
composite,
|
||
charge,
|
||
line.unit_type or "UN",
|
||
f"{line.units:g}" if line.units is not None else "1",
|
||
"", # SV1-05 — place of service (omitted at line level; carried by claim)
|
||
]
|
||
return _ELEM.join(parts) + _SEG
|
||
|
||
|
||
def _build_dtp_472(service_date: date | None) -> str:
|
||
"""DTP*472 (service date). RD8 qualifier with CCYYMMDD-CCYYMMDD range."""
|
||
if service_date is None:
|
||
return ""
|
||
stamp = service_date.strftime("%Y%m%d")
|
||
return _ELEM.join(["DTP", "472", "RD8", f"{stamp}-{stamp}"]) + _SEG
|
||
```
|
||
|
||
- [ ] **Step 4: Run, confirm PASS**
|
||
|
||
Expected: 13 tests passed.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add backend/src/cyclone/parsers/serialize_837.py backend/tests/test_serialize_837.py
|
||
git commit -m "feat(sp8): 837 serializer service-line builders (SV1, DTP*472)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: `_pass_through_stable_segments` + `serialize_837` glue
|
||
|
||
**Files:**
|
||
- Modify: `backend/src/cyclone/parsers/serialize_837.py`
|
||
- Test: `backend/tests/test_serialize_837.py`
|
||
|
||
- [ ] **Step 1: Add failing round-trip test + `serialize_837` import test**
|
||
|
||
Append:
|
||
|
||
```python
|
||
from cyclone.parsers.serialize_837 import serialize_837, _pass_through_stable_segments
|
||
from cyclone.parsers.parse_837 import parse_837_text
|
||
from cyclone.parsers.models import ClaimOutput
|
||
|
||
|
||
def _load_claim_from_fixture(path: str) -> ClaimOutput:
|
||
with open(path) as f:
|
||
text = f.read()
|
||
result = parse_837_text(text)
|
||
assert result.claims, f"no claims parsed from {path}"
|
||
return result.claims[0]
|
||
|
||
|
||
def test_pass_through_stable_segments_drops_editable_kinds():
|
||
raw = [
|
||
["ISA", "00", ...], # stable
|
||
["NM1", "85", "2", "CLINIC", ...], # stable
|
||
["CLM", "CLM-1", "100.00", ...], # editable → drop
|
||
["HI", "BK:E11.9"], # editable → drop
|
||
["LX", "1"], # stable
|
||
["SV1", "HC:99213", ...], # editable → drop
|
||
["DTP", "472", ...], # editable → drop
|
||
]
|
||
out = _pass_through_stable_segments(raw)
|
||
flat = [s[0] for s in [r.split("*") for r in out]]
|
||
assert "CLM" not in flat
|
||
assert "HI" not in flat
|
||
assert "SV1" not in flat
|
||
assert "DTP" not in flat
|
||
assert "ISA" in flat
|
||
assert "NM1" in flat
|
||
assert "LX" in flat
|
||
|
||
|
||
def test_serialize_837_round_trips_unchanged_claim():
|
||
claim = _load_claim_from_fixture(
|
||
"backend/tests/fixtures/co_medicaid_837p.txt"
|
||
)
|
||
text = serialize_837(claim)
|
||
reparsed = parse_837_text(text).claims[0]
|
||
assert reparsed.claim.claim_id == claim.claim.claim_id
|
||
assert reparsed.claim.total_charge == claim.claim.total_charge
|
||
assert reparsed.claim.frequency_code == claim.claim.frequency_code
|
||
|
||
|
||
def test_serialize_837_raises_when_raw_segments_empty():
|
||
claim = _load_claim_from_fixture(
|
||
"backend/tests/fixtures/co_medicaid_837p.txt"
|
||
)
|
||
claim.raw_segments = []
|
||
import pytest
|
||
with pytest.raises(SerializeError, match="no raw_segments"):
|
||
serialize_837(claim)
|
||
|
||
|
||
def test_serialize_837_edited_charge_propagates():
|
||
from decimal import Decimal
|
||
claim = _load_claim_from_fixture(
|
||
"backend/tests/fixtures/co_medicaid_837p.txt"
|
||
)
|
||
claim.claim.total_charge = Decimal("999.99")
|
||
text = serialize_837(claim)
|
||
assert "CLM*CLM-1*999.99*" in text
|
||
|
||
|
||
def test_serialize_837_edited_prior_auth_propagates():
|
||
claim = _load_claim_from_fixture(
|
||
"backend/tests/fixtures/co_medicaid_837p.txt"
|
||
)
|
||
claim.claim.prior_auth = "AUTH999"
|
||
text = serialize_837(claim)
|
||
assert "REF*G1*AUTH999" in text
|
||
|
||
|
||
def test_serialize_837_envelope_has_106_char_isa():
|
||
claim = _load_claim_from_fixture(
|
||
"backend/tests/fixtures/co_medicaid_837p.txt"
|
||
)
|
||
text = serialize_837(claim)
|
||
isa_line = next(line for line in text.split("~") if line.startswith("ISA"))
|
||
assert len(isa_line) == 106
|
||
```
|
||
|
||
- [ ] **Step 2: Run, confirm FAIL**
|
||
|
||
- [ ] **Step 3: Implement `_pass_through_stable_segments` and `serialize_837`**
|
||
|
||
Add to `serialize_837.py`:
|
||
|
||
```python
|
||
# (segment_id, qualifier-or-None) tuples that the serializer rebuilds
|
||
# from canonical ClaimOutput fields. Everything else passes through.
|
||
_EDITABLE_SEGMENT_KINDS: set[tuple[str, str | None]] = {
|
||
("CLM", None),
|
||
("REF", "G1"),
|
||
("HI", None),
|
||
("SV1", None),
|
||
("DTP", "472"),
|
||
}
|
||
|
||
|
||
def _pass_through_stable_segments(
|
||
raw_segments: list[list[str]],
|
||
) -> list[str]:
|
||
"""Drop editable segments from raw_segments and re-emit as 'seg*...' strings.
|
||
|
||
Preserves the original order of the remaining (stable) segments.
|
||
"""
|
||
out: list[str] = []
|
||
for seg in raw_segments:
|
||
if not seg:
|
||
continue
|
||
seg_id = seg[0]
|
||
qualifier = seg[1] if len(seg) > 1 else None
|
||
if (seg_id, qualifier) in _EDITABLE_SEGMENT_KINDS:
|
||
continue
|
||
out.append(_ELEM.join(seg) + _SEG)
|
||
return out
|
||
|
||
|
||
def serialize_837(
|
||
claim: ClaimOutput,
|
||
*,
|
||
interchange_control_number: str = "000000001",
|
||
group_control_number: str = "1",
|
||
) -> str:
|
||
"""Serialize a single claim to a complete 837P file.
|
||
|
||
Raises :class:`SerializeError` if ``claim.raw_segments`` is empty
|
||
(v1 cannot emit a from-scratch claim).
|
||
"""
|
||
if not claim.raw_segments:
|
||
raise SerializeError("claim has no raw_segments; cannot serialize from-scratch")
|
||
|
||
sender_id = "CYCLONE"
|
||
receiver_id = "RECEIVER"
|
||
|
||
parts: list[str] = [
|
||
_build_isa(sender_id, receiver_id, interchange_control_number),
|
||
_build_gs(sender_id, receiver_id, group_control_number),
|
||
_build_st("0001"),
|
||
_build_bht(
|
||
claim.envelope,
|
||
reference_id=claim.claim.claim_id,
|
||
service_date=claim.envelope.transaction_date or date.today(),
|
||
),
|
||
]
|
||
parts.extend(_pass_through_stable_segments(claim.raw_segments))
|
||
|
||
# Editable claim-level segments (insert after the stable hierarchies
|
||
# which the pass-through already emitted in original order).
|
||
parts.append(_build_clm(claim.claim))
|
||
ref_g1 = _build_ref_g1(claim.claim.prior_auth)
|
||
if ref_g1:
|
||
parts.append(ref_g1)
|
||
if claim.diagnoses:
|
||
parts.append(_build_hi(claim.diagnoses))
|
||
|
||
# Service lines — each emits a rebuilt SV1 + DTP*472 in order. The
|
||
# LX (line counter) and REF*6R pass through via raw_segments.
|
||
for line in claim.service_lines:
|
||
parts.append(_build_sv1(line))
|
||
dtp = _build_dtp_472(line.service_date)
|
||
if dtp:
|
||
parts.append(dtp)
|
||
|
||
# SE segment count = total segments in ST..SE inclusive (the loop above
|
||
# emits everything between; SE count includes ST + all body + SE itself).
|
||
seg_count = len(parts) + 1 # +1 for SE itself
|
||
parts.append(_build_se(seg_count, "0001"))
|
||
|
||
parts.append(f"GE*1*{group_control_number}{_SEG}")
|
||
parts.append(f"IEA*1*{interchange_control_number}{_SEG}")
|
||
|
||
return "".join(parts)
|
||
```
|
||
|
||
- [ ] **Step 4: Run, confirm PASS**
|
||
|
||
Expected: 19 tests passed.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add backend/src/cyclone/parsers/serialize_837.py backend/tests/test_serialize_837.py
|
||
git commit -m "feat(sp8): 837 serializer pass-through + serialize_837() glue + round-trip"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: `serialize_837_for_resubmit` helper + edited-field tests
|
||
|
||
**Files:**
|
||
- Modify: `backend/src/cyclone/parsers/serialize_837.py`
|
||
- Test: `backend/tests/test_serialize_837.py`
|
||
|
||
- [ ] **Step 1: Add failing tests**
|
||
|
||
Append:
|
||
|
||
```python
|
||
from cyclone.parsers.serialize_837 import serialize_837_for_resubmit
|
||
|
||
|
||
def test_serialize_837_for_resubmit_assigns_unique_control_numbers():
|
||
claim = _load_claim_from_fixture(
|
||
"backend/tests/fixtures/co_medicaid_837p.txt"
|
||
)
|
||
text_a = serialize_837_for_resubmit(claim, interchange_index=42)
|
||
text_b = serialize_837_for_resubmit(claim, interchange_index=43)
|
||
# Extract ISA13 (positions 105-113 zero-indexed of the ISA line, 9 chars).
|
||
isa_a = next(line for line in text_a.split("~") if line.startswith("ISA"))
|
||
isa_b = next(line for line in text_b.split("~") if line.startswith("ISA"))
|
||
assert isa_a != isa_b
|
||
# Interchange index 42 → control number padded to 9 chars.
|
||
assert "000000042" in isa_a
|
||
assert "000000043" in isa_b
|
||
|
||
|
||
def test_serialize_837_round_trip_edited_frequency_propagates():
|
||
claim = _load_claim_from_fixture(
|
||
"backend/tests/fixtures/co_medicaid_837p.txt"
|
||
)
|
||
claim.claim.frequency_code = "7"
|
||
text = serialize_837(claim)
|
||
assert "CLM*CLM-1*" in text # frequency_code is in CLM05-3; parser position varies
|
||
reparsed = parse_837_text(text).claims[0]
|
||
assert reparsed.claim.frequency_code == "7"
|
||
|
||
|
||
def test_serialize_837_round_trip_added_diagnosis_propagates():
|
||
from cyclone.parsers.models import Diagnosis
|
||
claim = _load_claim_from_fixture(
|
||
"backend/tests/fixtures/co_medicaid_837p.txt"
|
||
)
|
||
claim.diagnoses.append(Diagnosis(code="Z00.00", qualifier="BF", sequence=99))
|
||
text = serialize_837(claim)
|
||
assert "BF:Z00.00" in text
|
||
|
||
|
||
def test_serialize_837_round_trip_edited_service_line_charge_propagates():
|
||
from decimal import Decimal
|
||
claim = _load_claim_from_fixture(
|
||
"backend/tests/fixtures/co_medicaid_837p.txt"
|
||
)
|
||
claim.service_lines[0].charge = Decimal("555.55")
|
||
text = serialize_837(claim)
|
||
assert "555.55" in text
|
||
reparsed = parse_837_text(text).claims[0]
|
||
assert any(line.charge == Decimal("555.55") for line in reparsed.service_lines)
|
||
```
|
||
|
||
- [ ] **Step 2: Run, confirm FAIL**
|
||
|
||
- [ ] **Step 3: Implement `serialize_837_for_resubmit`**
|
||
|
||
Add to `serialize_837.py`:
|
||
|
||
```python
|
||
def serialize_837_for_resubmit(
|
||
claim: ClaimOutput,
|
||
*,
|
||
interchange_index: int,
|
||
) -> str:
|
||
"""Like :func:`serialize_837` but assigns deterministic-but-unique
|
||
interchange + group control numbers for a bundle position.
|
||
|
||
Interchange number = ``f"{interchange_index:09d}"``.
|
||
Group number = ``str(interchange_index)``.
|
||
"""
|
||
return serialize_837(
|
||
claim,
|
||
interchange_control_number=f"{interchange_index:09d}",
|
||
group_control_number=str(interchange_index),
|
||
)
|
||
```
|
||
|
||
- [ ] **Step 4: Run, confirm PASS**
|
||
|
||
Expected: 23 tests passed.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add backend/src/cyclone/parsers/serialize_837.py backend/tests/test_serialize_837.py
|
||
git commit -m "feat(sp8): serialize_837_for_resubmit + edited-field round-trip tests"
|
||
```
|
||
|
||
---
|
||
|
||
## Phase 2 — `serialize-837` endpoint
|
||
|
||
### Task 6: `GET /api/claims/{id}/serialize-837`
|
||
|
||
**Files:**
|
||
- Modify: `backend/src/cyclone/api.py`
|
||
- Test: `backend/tests/test_api_serialize_837.py`
|
||
|
||
- [ ] **Step 1: Write failing API tests**
|
||
|
||
Create `backend/tests/test_api_serialize_837.py`:
|
||
|
||
```python
|
||
import io
|
||
import tempfile
|
||
from pathlib import Path
|
||
import pytest
|
||
from fastapi.testclient import TestClient
|
||
|
||
from cyclone.api import create_app
|
||
from cyclone.db import reset_db_for_tests
|
||
from cyclone.parsers.parse_837 import parse_837_text
|
||
|
||
|
||
@pytest.fixture
|
||
def client_and_db(monkeypatch):
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
db_url = f"sqlite:///{Path(tmp) / 'serialize-test.db'}"
|
||
monkeypatch.setenv("CYCLONE_DB_URL", db_url)
|
||
reset_db_for_tests(db_url)
|
||
app = create_app()
|
||
yield TestClient(app), db_url
|
||
|
||
|
||
def _seed_claim(client: TestClient) -> str:
|
||
fixture = Path("backend/tests/fixtures/co_medicaid_837p.txt").read_text()
|
||
r = client.post(
|
||
"/api/parse-837",
|
||
files={"file": ("claim.txt", io.BytesIO(fixture.encode()), "text/plain")},
|
||
)
|
||
assert r.status_code == 200, r.text
|
||
claim_id = r.json()["claims"][0]["id"]
|
||
return claim_id
|
||
|
||
|
||
def test_endpoint_returns_x12_attachment(client_and_db):
|
||
client, _ = client_and_db
|
||
claim_id = _seed_claim(client)
|
||
r = client.get(f"/api/claims/{claim_id}/serialize-837")
|
||
assert r.status_code == 200
|
||
assert r.headers["content-type"].startswith("text/x12") or \
|
||
r.headers["content-type"].startswith("text/plain")
|
||
assert "attachment" in r.headers["content-disposition"]
|
||
assert claim_id in r.headers["content-disposition"]
|
||
assert r.text.startswith("ISA*")
|
||
|
||
|
||
def test_endpoint_404_for_missing_claim(client_and_db):
|
||
client, _ = client_and_db
|
||
r = client.get("/api/claims/CLM-NOPE/serialize-837")
|
||
assert r.status_code == 404
|
||
body = r.json()
|
||
assert body["error"] == "Not found"
|
||
assert "CLM-NOPE" in body["detail"]
|
||
```
|
||
|
||
- [ ] **Step 2: Run, confirm FAIL**
|
||
|
||
Expected: `404` (route doesn't exist) — confirm before moving on.
|
||
|
||
- [ ] **Step 3: Add the endpoint to `api.py`**
|
||
|
||
In `backend/src/cyclone/api.py`, near the existing claim-detail handler (search for `def get_claim_detail` or similar), add:
|
||
|
||
```python
|
||
from cyclone.parsers.serialize_837 import SerializeError, serialize_837
|
||
|
||
@app.get("/api/claims/{claim_id}/serialize-837")
|
||
def serialize_claim_837(claim_id: str):
|
||
"""Return the claim as a regenerated X12 837P file."""
|
||
claim = store.get_claim_raw(claim_id)
|
||
if claim is None:
|
||
return JSONResponse(
|
||
{"error": "Not found", "detail": f"Claim {claim_id} not found"},
|
||
status_code=404,
|
||
)
|
||
try:
|
||
text = serialize_837(claim)
|
||
except SerializeError as e:
|
||
return JSONResponse(
|
||
{"error": "Unprocessable", "detail": str(e)},
|
||
status_code=422,
|
||
)
|
||
return Response(
|
||
content=text,
|
||
media_type="text/x12",
|
||
headers={
|
||
"Content-Disposition": f'attachment; filename="claim-{claim_id}.x12"'
|
||
},
|
||
)
|
||
```
|
||
|
||
> **Adapt `store.get_claim_raw(claim_id)` to whatever accessor the existing
|
||
> `api.py` uses for loading a `ClaimOutput` from the DB.** Look at how
|
||
> `get_claim_detail` retrieves the full claim and reuse the same path.
|
||
|
||
- [ ] **Step 4: Run, confirm PASS**
|
||
|
||
Expected: 2 passed.
|
||
|
||
- [ ] **Step 5: Add the 422 case test**
|
||
|
||
Append to `backend/tests/test_api_serialize_837.py`:
|
||
|
||
```python
|
||
def test_endpoint_422_for_claim_without_raw_json(client_and_db):
|
||
client, _ = client_and_db
|
||
claim_id = _seed_claim(client)
|
||
# Wipe the raw_segments on the stored claim to simulate a malformed row.
|
||
from cyclone.db import get_session
|
||
from cyclone.parsers.models import ClaimOutput
|
||
# Use the store's update path; if none exists, skip this test or
|
||
# add a fixture that seeds a claim directly with raw_segments=[].
|
||
# (Implementation depends on the store's update API; see api.py for hints.)
|
||
pytest.skip("depends on store.update_raw_segments API; implement when endpoint needs it")
|
||
```
|
||
|
||
> **Note:** the 422 path is hard to trigger without an update API for
|
||
> raw_segments. If skipped, the production code path still raises — the
|
||
> unit test for `serialize_837` (Task 4) covers the exception. Keep the
|
||
> skip comment as a placeholder for future coverage.
|
||
|
||
- [ ] **Step 6: Run, confirm PASS**
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add backend/src/cyclone/api.py backend/tests/test_api_serialize_837.py
|
||
git commit -m "feat(sp8): GET /api/claims/{id}/serialize-837 endpoint"
|
||
```
|
||
|
||
---
|
||
|
||
## Phase 3 — Drawer "Download 837" button
|
||
|
||
### Task 7: `api.getClaim837` in `src/lib/api.ts`
|
||
|
||
**Files:**
|
||
- Modify: `src/lib/api.ts`
|
||
- Test: `src/lib/api.test.ts` (or new file)
|
||
|
||
- [ ] **Step 1: Write failing test**
|
||
|
||
In `src/lib/api.test.ts`, append:
|
||
|
||
```typescript
|
||
import { describe, expect, it, vi, afterEach } from "vitest";
|
||
import { api } from "./api";
|
||
|
||
afterEach(() => {
|
||
vi.unstubAllGlobals();
|
||
vi.restoreAllMocks();
|
||
});
|
||
|
||
describe("api.getClaim837", () => {
|
||
it("fetches the X12 blob for a claim id", async () => {
|
||
const fakeBlob = new Blob(["ISA*..."], { type: "text/x12" });
|
||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
blob: async () => fakeBlob,
|
||
}));
|
||
const result = await api.getClaim837("CLM-1");
|
||
expect(result).toBeInstanceOf(Blob);
|
||
expect(await result.text()).toBe("ISA*...");
|
||
});
|
||
|
||
it("throws on non-2xx", async () => {
|
||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
|
||
ok: false,
|
||
status: 404,
|
||
statusText: "Not Found",
|
||
json: async () => ({ error: "Not found", detail: "x" }),
|
||
}));
|
||
await expect(api.getClaim837("CLM-NOPE")).rejects.toThrow(/404/);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run, confirm FAIL**
|
||
|
||
```bash
|
||
npx vitest run src/lib/api.test.ts
|
||
```
|
||
|
||
- [ ] **Step 3: Add `getClaim837` to `src/lib/api.ts`**
|
||
|
||
In `src/lib/api.ts`, near the other claim methods, add:
|
||
|
||
```typescript
|
||
async getClaim837(claimId: string): Promise<Blob> {
|
||
const baseUrl = import.meta.env.VITE_API_BASE_URL ?? "";
|
||
const r = await fetch(`${baseUrl}/api/claims/${claimId}/serialize-837`);
|
||
if (!r.ok) {
|
||
let detail = r.statusText;
|
||
try {
|
||
const body = await r.json();
|
||
detail = body.detail ?? body.error ?? detail;
|
||
} catch {
|
||
// ignore JSON parse failure
|
||
}
|
||
throw new Error(`serialize-837 failed (${r.status}): ${detail}`);
|
||
}
|
||
return r.blob();
|
||
},
|
||
```
|
||
|
||
- [ ] **Step 4: Run, confirm PASS**
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/lib/api.ts src/lib/api.test.ts
|
||
git commit -m "feat(sp8): api.getClaim837 in api client"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 8: ClaimDrawerHeader "Download 837" button
|
||
|
||
**Files:**
|
||
- Modify: `src/components/ClaimDrawer/ClaimDrawerHeader.tsx`
|
||
- Test: `src/components/ClaimDrawer/ClaimDrawerHeader.test.tsx`
|
||
|
||
- [ ] **Step 1: Write failing test**
|
||
|
||
Append to `src/components/ClaimDrawer/ClaimDrawerHeader.test.tsx`:
|
||
|
||
```typescript
|
||
import { describe, expect, it, vi } from "vitest";
|
||
import { api } from "@/lib/api";
|
||
|
||
// (use existing render imports from the test file)
|
||
|
||
describe("ClaimDrawerHeader — Download 837 button", () => {
|
||
it("calls api.getClaim837 when the button is clicked", async () => {
|
||
const fakeBlob = new Blob(["ISA*..."], { type: "text/x12" });
|
||
const spy = vi.spyOn(api, "getClaim837").mockResolvedValue(fakeBlob);
|
||
|
||
// Render the header with a stub claim — adapt to existing test helpers:
|
||
// render(<ClaimDrawerHeader claim={stubClaim} onClose={() => {}} />)
|
||
// Click the button — name "Download 837" or similar.
|
||
// Assert spy called with claim.id.
|
||
|
||
// (The exact render+click pattern depends on the existing test file;
|
||
// mirror the style of the other header tests.)
|
||
});
|
||
});
|
||
```
|
||
|
||
> **Note:** look at how the existing `ClaimDrawerHeader.test.tsx` renders
|
||
> and asserts (it likely uses `render` from `@testing-library/react` and
|
||
> `userEvent` from `@testing-library/user-event`). Use the same patterns.
|
||
|
||
- [ ] **Step 2: Run, confirm FAIL**
|
||
|
||
- [ ] **Step 3: Add the button + click handler**
|
||
|
||
In `src/components/ClaimDrawer/ClaimDrawerHeader.tsx`, near the existing
|
||
header buttons (close X), add:
|
||
|
||
```tsx
|
||
import { api } from "@/lib/api";
|
||
|
||
// (already imported? check existing imports)
|
||
|
||
async function download837(claimId: string) {
|
||
const blob = await api.getClaim837(claimId);
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement("a");
|
||
a.href = url;
|
||
a.download = `claim-${claimId}.x12`;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
```
|
||
|
||
In the JSX, alongside the existing header buttons:
|
||
|
||
```tsx
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => {
|
||
void download837(claim.id);
|
||
}}
|
||
data-testid="download-837-button"
|
||
disabled={!claim.rawSegments || claim.rawSegments.length === 0}
|
||
>
|
||
Download 837
|
||
</Button>
|
||
```
|
||
|
||
> **Adapt the disable check** to the actual field on the claim payload
|
||
> that indicates raw segments presence. The `ClaimDetail` type from
|
||
> `src/types/index.ts` should have a `rawSegments: string[]` or similar.
|
||
|
||
- [ ] **Step 4: Run, confirm PASS**
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/components/ClaimDrawer/ClaimDrawerHeader.tsx src/components/ClaimDrawer/ClaimDrawerHeader.test.tsx
|
||
git commit -m "feat(sp8): ClaimDrawerHeader — Download 837 button"
|
||
```
|
||
|
||
---
|
||
|
||
## Phase 4 — Resubmit `?download=true` + bundle modal
|
||
|
||
### Task 9: Extend `POST /api/inbox/rejected/resubmit` with `?download=true`
|
||
|
||
**Files:**
|
||
- Modify: `backend/src/cyclone/api.py`
|
||
- Test: `backend/tests/test_inbox_resubmit_download.py`
|
||
|
||
- [ ] **Step 1: Write failing tests**
|
||
|
||
Create `backend/tests/test_inbox_resubmit_download.py`:
|
||
|
||
```python
|
||
import io
|
||
import tempfile
|
||
from pathlib import Path
|
||
import pytest
|
||
from fastapi.testclient import TestClient
|
||
|
||
from cyclone.api import create_app
|
||
from cyclone.db import reset_db_for_tests
|
||
|
||
|
||
@pytest.fixture
|
||
def client_and_db(monkeypatch):
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
db_url = f"sqlite:///{Path(tmp) / 'resubmit-dl-test.db'}"
|
||
monkeypatch.setenv("CYCLONE_DB_URL", db_url)
|
||
reset_db_for_tests(db_url)
|
||
app = create_app()
|
||
yield TestClient(app)
|
||
|
||
|
||
def _seed_rejected_claims(client: TestClient, n: int = 2) -> list[str]:
|
||
"""Seed n claims and reject them via a 999 ACK so they land in the
|
||
Rejected lane (mirror the test pattern in test_inbox_endpoints.py)."""
|
||
ids = []
|
||
for _ in range(n):
|
||
fixture = Path("backend/tests/fixtures/co_medicaid_837p.txt").read_text()
|
||
r = client.post(
|
||
"/api/parse-837",
|
||
files={"file": ("claim.txt", io.BytesIO(fixture.encode()), "text/plain")},
|
||
)
|
||
assert r.status_code == 200, r.text
|
||
ids.append(r.json()["claims"][0]["id"])
|
||
# Send a rejecting 999 to flip them into REJECTED state. Mirror the
|
||
# existing fixture-driven rejection in test_inbox_endpoints.py.
|
||
rej_999 = Path("backend/tests/fixtures/minimal_999_rejected.txt").read_text()
|
||
r999 = client.post(
|
||
"/api/parse-999",
|
||
files={"file": ("ack.999", io.BytesIO(rej_999.encode()), "text/plain")},
|
||
)
|
||
assert r999.status_code == 200, r999.text
|
||
return ids
|
||
|
||
|
||
def test_resubmit_default_returns_no_files(client_and_db):
|
||
client = client_and_db
|
||
_seed_rejected_claims(client, n=1)
|
||
r = client.post("/api/inbox/rejected/resubmit", json={"claim_ids": []})
|
||
# (depends on the existing endpoint accepting an empty list — if it
|
||
# requires at least one id, adjust the seed count)
|
||
assert r.status_code == 200
|
||
body = r.json()
|
||
assert "files" not in body # default behavior unchanged
|
||
|
||
|
||
def test_resubmit_download_true_returns_x12_per_resubmitted_claim(client_and_db):
|
||
client = client_and_db
|
||
claim_ids = _seed_rejected_claims(client, n=2)
|
||
r = client.post(
|
||
"/api/inbox/rejected/resubmit?download=true",
|
||
json={"claim_ids": claim_ids},
|
||
)
|
||
assert r.status_code == 200, r.text
|
||
body = r.json()
|
||
assert body["ok"] is True
|
||
assert set(body["resubmitted"]) == set(claim_ids)
|
||
assert len(body["files"]) == len(claim_ids)
|
||
for entry in body["files"]:
|
||
assert entry["x12_text"].startswith("ISA*")
|
||
assert entry["filename"].endswith(".x12")
|
||
assert entry["claim_id"] in claim_ids
|
||
```
|
||
|
||
- [ ] **Step 2: Run, confirm FAIL**
|
||
|
||
- [ ] **Step 3: Extend the endpoint**
|
||
|
||
In `backend/src/cyclone/api.py`, find the `inbox_resubmit_rejected`
|
||
function (or however the existing endpoint is named). Add a `download`
|
||
query-param branch:
|
||
|
||
```python
|
||
from cyclone.parsers.serialize_837 import serialize_837_for_resubmit
|
||
|
||
|
||
@app.post("/api/inbox/rejected/resubmit")
|
||
def inbox_resubmit_rejected(
|
||
payload: dict,
|
||
download: bool = False,
|
||
):
|
||
# ... existing state-flip + return logic ...
|
||
result = {
|
||
"ok": True,
|
||
"resubmitted": [...], # whatever the existing code returns
|
||
"conflicts": [...],
|
||
}
|
||
if download:
|
||
files = []
|
||
for idx, cid in enumerate(result["resubmitted"], start=1):
|
||
claim = store.get_claim_raw(cid)
|
||
if claim is None:
|
||
continue
|
||
x12 = serialize_837_for_resubmit(claim, interchange_index=idx)
|
||
files.append({
|
||
"claim_id": cid,
|
||
"filename": f"{cid}.x12",
|
||
"x12_text": x12,
|
||
})
|
||
result["files"] = files
|
||
return result
|
||
```
|
||
|
||
> **Adapt the integration to the existing endpoint's body.** Look at
|
||
> how `payload["claim_ids"]` is currently iterated and what shape
|
||
> `resubmitted`/`conflicts` take. Don't refactor — just add the `files`
|
||
> branch.
|
||
|
||
- [ ] **Step 4: Run, confirm PASS**
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add backend/src/cyclone/api.py backend/tests/test_inbox_resubmit_download.py
|
||
git commit -m "feat(sp8): resubmit ?download=true returns X12 bundle"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 10: Frontend `api.resubmitRejected({download})` + Inbox modal
|
||
|
||
**Files:**
|
||
- Modify: `src/lib/api.ts`
|
||
- Modify: `src/pages/Inbox.tsx`
|
||
- Test: `src/pages/Inbox.test.tsx`
|
||
- Modify: `src/index.html` (add JSZip CDN script tag)
|
||
|
||
- [ ] **Step 1: Add JSZip CDN script tag**
|
||
|
||
In `index.html` (or wherever the root HTML lives — check `vite.config.ts`
|
||
for the `root`), add inside `<head>`:
|
||
|
||
```html
|
||
<script src="https://cdn.jsdelivr.net/npm/jszip@3.10.1/dist/jszip.min.js" defer></script>
|
||
```
|
||
|
||
This exposes `JSZip` as `window.JSZip` at runtime. No npm dep.
|
||
|
||
- [ ] **Step 2: Extend `api.resubmitRejected` to support `{download: true}`**
|
||
|
||
In `src/lib/api.ts`, find `resubmitRejected` and add an options arg:
|
||
|
||
```typescript
|
||
async resubmitRejected(
|
||
claimIds: string[],
|
||
options: { download?: boolean } = {},
|
||
): Promise<{
|
||
ok: boolean;
|
||
resubmitted: string[];
|
||
conflicts: Array<{ claim_id: string; reason: string }>;
|
||
files?: Array<{ claim_id: string; filename: string; x12_text: string }>;
|
||
}> {
|
||
const baseUrl = import.meta.env.VITE_API_BASE_URL ?? "";
|
||
const qs = options.download ? "?download=true" : "";
|
||
const r = await fetch(`${baseUrl}/api/inbox/rejected/resubmit${qs}`, {
|
||
method: "POST",
|
||
headers: { "content-type": "application/json" },
|
||
body: JSON.stringify({ claim_ids: claimIds }),
|
||
});
|
||
if (!r.ok) throw new Error(`resubmit failed (${r.status})`);
|
||
return r.json();
|
||
},
|
||
```
|
||
|
||
- [ ] **Step 3: Write failing Inbox test**
|
||
|
||
Append to `src/pages/Inbox.test.tsx`:
|
||
|
||
```typescript
|
||
it("resubmit with download modal zips and downloads", async () => {
|
||
// 1. Render Inbox
|
||
// 2. Select a rejected claim row
|
||
// 3. Click "Resubmit" → opens modal
|
||
// 4. Click "Resubmit + Download" → calls api.resubmitRejected with download:true
|
||
// 5. Assert the response's files[] are turned into a zip and a download is triggered
|
||
|
||
// Use vi.spyOn(api, "resubmitRejected") and mock window.JSZip.
|
||
// Verify a zip Blob is created and an anchor click is fired.
|
||
});
|
||
```
|
||
|
||
> **Implementation note:** mirror the existing Inbox test style for
|
||
> rendering and selection. The "Resubmit + Download" modal is a new
|
||
> component (or inline within Inbox.tsx). Keep it minimal.
|
||
|
||
- [ ] **Step 4: Run, confirm FAIL**
|
||
|
||
- [ ] **Step 5: Add the modal + download flow in `Inbox.tsx`**
|
||
|
||
In `src/pages/Inbox.tsx`, modify the existing bulk-resubmit handler so
|
||
that when N > 1 claims are selected, the user is prompted:
|
||
|
||
```tsx
|
||
const [confirmDownload, setConfirmDownload] = useState(false);
|
||
const [pendingResubmitIds, setPendingResubmitIds] = useState<string[]>([]);
|
||
|
||
async function performResubmit(ids: string[], withDownload: boolean) {
|
||
const result = await api.resubmitRejected(ids, { download: withDownload });
|
||
if (withDownload && result.files && result.files.length > 0) {
|
||
// Build a zip in-browser using JSZip (loaded via CDN).
|
||
const zip = new (window as unknown as { JSZip: new () => unknown }).JSZip();
|
||
for (const f of result.files) {
|
||
(zip as { file: (n: string, c: string) => void }).file(f.filename, f.x12_text);
|
||
}
|
||
const blob = await (zip as { generateAsync: (o: { type: string }) => Promise<Blob> })
|
||
.generateAsync({ type: "blob" });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement("a");
|
||
a.href = url;
|
||
a.download = "resubmit-bundle.zip";
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
void refetch();
|
||
}
|
||
|
||
function onResubmitClick() {
|
||
const ids = selected.rejected;
|
||
if (ids.length === 0) return;
|
||
if (ids.length === 1) {
|
||
void performResubmit(ids, false);
|
||
return;
|
||
}
|
||
// Multi-claim: confirm download choice
|
||
setPendingResubmitIds(ids);
|
||
setConfirmDownload(true);
|
||
}
|
||
```
|
||
|
||
In the JSX, render a simple inline modal (or a dedicated component —
|
||
keep it minimal):
|
||
|
||
```tsx
|
||
{confirmDownload && (
|
||
<div role="dialog" aria-label="Resubmit and download bundle?">
|
||
<p>Resubmit {pendingResubmitIds.length} claims and download a 837 bundle?</p>
|
||
<button onClick={() => { setConfirmDownload(false); void performResubmit(pendingResubmitIds, true); }}>
|
||
Resubmit + Download
|
||
</button>
|
||
<button onClick={() => { setConfirmDownload(false); void performResubmit(pendingResubmitIds, false); }}>
|
||
Resubmit only
|
||
</button>
|
||
</div>
|
||
)}
|
||
```
|
||
|
||
> **Aesthetic:** the existing inbox uses the Ticker Tape palette.
|
||
> Adapt the modal styling to match (`var(--tt-amber)` etc.). Not a
|
||
> focus of this task — the test asserts behavior, not pixels.
|
||
|
||
- [ ] **Step 6: Run, confirm PASS**
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add src/index.html src/lib/api.ts src/pages/Inbox.tsx src/pages/Inbox.test.tsx
|
||
git commit -m "feat(sp8): Inbox — resubmit bundle modal + JSZip download"
|
||
```
|
||
|
||
---
|
||
|
||
## Phase 5 — Prodfile round-trip + Docs
|
||
|
||
### Task 11: Prodfile round-trip smoke test
|
||
|
||
**Files:**
|
||
- Modify: `backend/tests/test_prodfiles_smoke.py`
|
||
|
||
- [ ] **Step 1: Add failing parametrized test**
|
||
|
||
Append to `backend/tests/test_prodfiles_smoke.py`:
|
||
|
||
```python
|
||
import pytest
|
||
from pathlib import Path
|
||
from cyclone.parsers.parse_837 import parse_837_text
|
||
from cyclone.parsers.serialize_837 import serialize_837
|
||
|
||
|
||
PRODFILES_DIR = Path("docs/prodfiles/claims")
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"fixture_path",
|
||
sorted(PRODFILES_DIR.glob("*.x12")),
|
||
ids=lambda p: p.name,
|
||
)
|
||
def test_claims_prodfile_round_trip(fixture_path: Path):
|
||
"""Every prodfile in docs/prodfiles/claims/ round-trips through
|
||
serialize_837 → parse_837_text with deep-equal ClaimOutput (modulo
|
||
validation, which is recomputed by the parser)."""
|
||
text = fixture_path.read_text()
|
||
source = parse_837_text(text)
|
||
assert source.claims, f"{fixture_path.name}: no claims parsed"
|
||
for claim in source.claims:
|
||
out = serialize_837(claim)
|
||
reparsed = parse_837_text(out).claims[0]
|
||
# Compare canonical fields (not raw_segments — those are rebuilt).
|
||
assert reparsed.claim.claim_id == claim.claim.claim_id
|
||
assert reparsed.claim.total_charge == claim.claim.total_charge
|
||
assert reparsed.claim.frequency_code == claim.claim.frequency_code
|
||
assert reparsed.claim.place_of_service == claim.claim.place_of_service
|
||
assert [d.code for d in reparsed.diagnoses] == [d.code for d in claim.diagnoses]
|
||
assert len(reparsed.service_lines) == len(claim.service_lines)
|
||
```
|
||
|
||
- [ ] **Step 2: Run, confirm pass (or FAIL with diagnostics)**
|
||
|
||
```bash
|
||
cd backend && .venv/bin/pytest tests/test_prodfiles_smoke.py -v
|
||
```
|
||
|
||
Expected: as many tests as there are files in `docs/prodfiles/claims/`
|
||
(currently 113). One parametrized test, parametrized over the glob —
|
||
counts as 1 in the test suite's totals.
|
||
|
||
If failures occur: check the failing claim's structure (most likely a
|
||
segment the serializer doesn't handle yet — `K3` notes, conditional
|
||
segments, etc.). Document the gap in the plan and add the segment to
|
||
`_EDITABLE_SEGMENT_KINDS` or the stable pass-through as needed.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add backend/tests/test_prodfiles_smoke.py
|
||
git commit -m "test(sp8): prodfile round-trip smoke — every claims/*.x12 serializes back"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 12: README — Outbound 837 Serializer section
|
||
|
||
**Files:**
|
||
- Modify: `README.md`
|
||
|
||
- [ ] **Step 1: Add the section**
|
||
|
||
Insert after the existing "## Per-Line Adjustment Audit" section (around line 213):
|
||
|
||
```markdown
|
||
## Outbound 837 Serializer
|
||
|
||
Any parsed claim can be regenerated as a complete, round-trippable
|
||
X12 837P file. The serializer uses a hybrid approach: the envelope
|
||
(ISA/GS/ST/SE/GE/IEA) and editable claim-level segments (CLM, REF*G1,
|
||
HI, SV1, DTP*472) are freshly built from the canonical `ClaimOutput`
|
||
fields; the stable provider / subscriber / payer hierarchy segments
|
||
are passed through byte-identical from the parser's `raw_segments`.
|
||
|
||
### Where to find it
|
||
|
||
- **ClaimDrawerHeader → "Download 837"** — single-claim export to
|
||
`claim-{id}.x12`.
|
||
- **Inbox → Resubmit (multi)** — when resubmitting 2+ rejected claims,
|
||
a modal asks whether to also download an X12 bundle; on confirm,
|
||
the frontend zips the regenerated files in-browser (JSZip via CDN)
|
||
and triggers a download.
|
||
|
||
### Endpoints
|
||
|
||
| Method | Path | Returns |
|
||
|--------|------|---------|
|
||
| GET | `/api/claims/{id}/serialize-837` | `text/x12` attachment. 404 if claim missing. 422 if no raw_segments. |
|
||
| POST | `/api/inbox/rejected/resubmit?download=true` | existing JSON, plus `files: [{claim_id, filename, x12_text}]` array when `download=true`. |
|
||
|
||
### Edit propagation
|
||
|
||
After parse, edits to `claim.claim.{total_charge, frequency_code,
|
||
prior_auth}`, `claim.diagnoses[]`, or any `service_lines[i]` field
|
||
flow through to the regenerated X12 on the next call — the serializer
|
||
rebuilds those segments from the canonical fields rather than from
|
||
`raw_segments`. The stable hierarchies (billing provider, subscriber,
|
||
payer, submitter, receiver) come through verbatim from the original
|
||
file.
|
||
|
||
### Round-trip guarantee
|
||
|
||
Every file in `docs/prodfiles/claims/` (113 files at last count)
|
||
round-trips through `serialize_837` → `parse_837_text` with a
|
||
deep-equal `ClaimOutput` (modulo validation, which is recomputed).
|
||
This is enforced by a parametrized smoke test.
|
||
```
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add README.md
|
||
git commit -m "docs(sp8): README — Outbound 837 Serializer section"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 13: Final smoke + full-suite run
|
||
|
||
**Files:** none
|
||
|
||
- [ ] **Step 1: Backend suite**
|
||
|
||
```bash
|
||
cd backend && .venv/bin/pytest -q
|
||
```
|
||
|
||
Expected: ≥ 528 backend passing (528 prior + 15 new — 9 serializer + 3
|
||
endpoint + 2 inbox + 1 prodfile). The prodfile test counts as 1 even
|
||
though it's parametrized over 113 files.
|
||
|
||
- [ ] **Step 2: Frontend suite**
|
||
|
||
```bash
|
||
cd .. && npx vitest run --reporter=dot
|
||
```
|
||
|
||
Expected: ≥ 342 frontend passing (342 prior + 2 new — 1 drawer button +
|
||
1 inbox modal).
|
||
|
||
- [ ] **Step 3: Typecheck**
|
||
|
||
```bash
|
||
npx tsc --noEmit
|
||
```
|
||
|
||
Expected: 0 errors.
|
||
|
||
- [ ] **Step 4: Manual curl smoke**
|
||
|
||
```bash
|
||
cd backend && CYCLONE_DB_URL=sqlite:///$(mktemp -d)/sp8-smoke.db .venv/bin/python -m cyclone serve &
|
||
curl -X POST -F "file=@tests/fixtures/co_medicaid_837p.txt" http://127.0.0.1:8000/api/parse-837
|
||
# Take the returned claim id, then:
|
||
curl -i http://127.0.0.1:8000/api/claims/<claim_id>/serialize-837 | head -20
|
||
# Expected: HTTP 200, Content-Type text/x12, attachment Content-Disposition,
|
||
# body starts with "ISA*".
|
||
lsof -nP -iTCP:8000 -sTCP:LISTEN -t | xargs -r kill
|
||
```
|
||
|
||
- [ ] **Step 5: Final commit + branch ready**
|
||
|
||
```bash
|
||
git log --oneline main..HEAD
|
||
git push -u origin sp8-serialize-837
|
||
```
|
||
|
||
---
|
||
|
||
## Self-review checklist
|
||
|
||
- [x] **Spec coverage:**
|
||
- §1 scope (serializer + resubmit integration + drawer button) → T1–T10
|
||
- §2 goal 1 (one public function) → T4
|
||
- §2 goal 2 (edits propagate) → T4 + T5 (round-trip tests)
|
||
- §2 goal 3 (stable segments preserved verbatim) → T4 (`_pass_through_stable_segments`)
|
||
- §2 goal 4 (resubmit lane completes the loop) → T9 + T10
|
||
- §2 goal 5 (drawer affordance) → T8
|
||
- §3.1 hybrid approach → T4 (decision baked in)
|
||
- §3.2 editable vs stable classification → T4 (`_EDITABLE_SEGMENT_KINDS`)
|
||
- §3.3 no envelope-level batching → implicit (single CLM only)
|
||
- §3.4 `?download=true` resubmit → T9
|
||
- §3.5 drawer download → T8
|
||
- §4.1 `serialize_837.py` helpers → T1–T5
|
||
- §4.2 API response additions → T6, T9
|
||
- §5 UI additions → T7, T8, T10
|
||
- §6 no migration / no new deps → implicit (only JSZip CDN script added)
|
||
- §7 test targets → T1–T12 all hit the counts
|
||
|
||
- [x] **Placeholder scan:** no TBD / TODO / "implement later" /
|
||
"similar to Task N" / "add appropriate error handling" without code.
|
||
One `pytest.skip` placeholder in T6 with rationale.
|
||
|
||
- [x] **Type consistency:**
|
||
- `serialize_837(claim, *, interchange_control_number="000000001", group_control_number="1") -> str` — same shape across T1, T4, T5
|
||
- `serialize_837_for_resubmit(claim, *, interchange_index: int) -> str` — matches §3.4
|
||
- `_EDITABLE_SEGMENT_KINDS` — defined once in T4, used by `_pass_through_stable_segments`
|
||
- `SerializeError` — raised by `serialize_837`, caught in `serialize_claim_837` endpoint
|
||
- `api.getClaim837(claimId: string): Promise<Blob>` — consistent across T7 + T8
|
||
- `api.resubmitRejected(claimIds, options: {download?: boolean})` — consistent across T9 + T10
|
||
|
||
- [x] **Scope:** 13 tasks, one branch, one PR. Within a single plan.
|
||
|
||
---
|
||
|
||
## Execution Handoff
|
||
|
||
**Plan complete and saved to `docs/superpowers/plans/2026-06-20-cyclone-serialize-837.md`.**
|
||
|
||
13 tasks across 5 phases. Per session convention, executing inline rather than dispatching subagents (per the user's "yolo" directive on prior plans — assumed to carry through). |