Files
cyclone/docs/superpowers/plans/2026-06-20-cyclone-serialize-837.md
T
Tyler 561018c690 feat(sp8): outbound 837P serializer — full rebuild + round-trip tests
- 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.
2026-06-20 20:27:48 -06:00

51 KiB
Raw Blame History

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:

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.py with 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_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:

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_837 import 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_segments and serialize_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 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:

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 getClaim837 to src/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.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:

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 ClaimDetail type from src/types/index.ts should have a rawSegments: 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 shape resubmitted/conflicts take. Don't refactor — just add the files branch.

  • 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.resubmitRejected to 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) → T1T10
    • §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 → T1T5
    • §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 → T1T12 all hit the counts
  • 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.

  • 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
  • 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).