- 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.
51 KiB
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 testsbackend/tests/test_api_serialize_837.py— 3 endpoint testsbackend/tests/test_inbox_resubmit_download.py— 2 endpoint tests for?download=true
Modify:
backend/src/cyclone/api.py— addGET /api/claims/{id}/serialize-837, extendPOST /api/inbox/rejected/resubmitwith?download=truebackend/tests/test_prodfiles_smoke.py— addtest_claims_prodfile_round_tripparametrized overdocs/prodfiles/claims/*.x12src/lib/api.ts— addapi.getClaim837(claimId), extendapi.resubmitRejectedwith{download: true}optionsrc/components/ClaimDrawer/ClaimDrawerHeader.tsx— add "Download 837" buttonsrc/components/ClaimDrawer/ClaimDrawerHeader.test.tsx— add 1 testsrc/pages/Inbox.tsx— extend Resubmit action with a "download bundle?" modal (JSZip via CDN)src/pages/Inbox.test.tsx— add 1 testREADME.md— add "Outbound 837 Serializer" sectionsrc/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:
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
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.pywith the helpers
Create backend/src/cyclone/parsers/serialize_837.py:
"""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
cd backend && .venv/bin/pytest tests/test_serialize_837.py -v
Expected: 5 passed.
- Step 5: Commit
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:
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_bhtsignature: theEnvelopemodel needstransaction_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; iftransaction_type_codeisNone, 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:
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
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:
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:
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
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_837import test
Append:
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_segmentsandserialize_837
Add to serialize_837.py:
# (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
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:
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:
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
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:
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:
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 existingapi.pyuses for loading aClaimOutputfrom the DB. Look at howget_claim_detailretrieves 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:
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
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:
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
npx vitest run src/lib/api.test.ts
- Step 3: Add
getClaim837tosrc/lib/api.ts
In src/lib/api.ts, near the other claim methods, add:
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
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:
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.tsxrenders and asserts (it likely usesrenderfrom@testing-library/reactanduserEventfrom@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:
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:
<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
ClaimDetailtype fromsrc/types/index.tsshould have arawSegments: string[]or similar.
-
Step 4: Run, confirm PASS
-
Step 5: Commit
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:
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:
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 shaperesubmitted/conflictstake. Don't refactor — just add thefilesbranch.
-
Step 4: Run, confirm PASS
-
Step 5: Commit
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>:
<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.resubmitRejectedto support{download: true}
In src/lib/api.ts, find resubmitRejected and add an options arg:
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:
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:
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):
{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
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:
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)
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
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):
## 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
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
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
cd .. && npx vitest run --reporter=dot
Expected: ≥ 342 frontend passing (342 prior + 2 new — 1 drawer button + 1 inbox modal).
- Step 3: Typecheck
npx tsc --noEmit
Expected: 0 errors.
- Step 4: Manual curl smoke
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
git log --oneline main..HEAD
git push -u origin sp8-serialize-837
Self-review checklist
-
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=trueresubmit → T9 - §3.5 drawer download → T8
- §4.1
serialize_837.pyhelpers → 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
-
Placeholder scan: no TBD / TODO / "implement later" / "similar to Task N" / "add appropriate error handling" without code. One
pytest.skipplaceholder in T6 with rationale. -
Type consistency:
serialize_837(claim, *, interchange_control_number="000000001", group_control_number="1") -> str— same shape across T1, T4, T5serialize_837_for_resubmit(claim, *, interchange_index: int) -> str— matches §3.4_EDITABLE_SEGMENT_KINDS— defined once in T4, used by_pass_through_stable_segmentsSerializeError— raised byserialize_837, caught inserialize_claim_837endpointapi.getClaim837(claimId: string): Promise<Blob>— consistent across T7 + T8api.resubmitRejected(claimIds, options: {download?: boolean})— consistent across T9 + T10
-
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).