diff --git a/backend/src/cyclone/parsers/serialize_837.py b/backend/src/cyclone/parsers/serialize_837.py new file mode 100644 index 0000000..92ab5d1 --- /dev/null +++ b/backend/src/cyclone/parsers/serialize_837.py @@ -0,0 +1,528 @@ +"""Serialize a :class:`ClaimOutput` to a complete X12 837P text. + +Full rebuild (Approach A — see plan amendment 2026-06-20). Every segment +in the output is emitted from canonical :class:`ClaimOutput` fields; +``raw_segments`` is not consulted. + +Why full rebuild instead of the spec's §3.1 hybrid: + +- ``ClaimOutput.raw_segments`` only captures post-CLM segments (CLM, + REF*G1, HI, LX, SV1 pairs). The envelope (ISA/GS/ST/BHT) and + hierarchy segments (NM1, N3, N4, HL, PRV, SBR, DMG, REF*EI) are not + captured, so a pass-through approach cannot regenerate them + byte-for-byte. Expanding ``raw_segments`` would require modifying + ``parse_837.py`` (out of scope for this SP). + +- The 837P spec is stable; the emitter only needs canonical fields + that already live on ``ClaimOutput``. No data loss vs. the original + inbound file for any field the user can edit (charge, frequency, + prior_auth, diagnoses, service lines, dates). + +The serializer emits: + +- ISA / GS / ST / SE / GE / IEA — envelope (caller supplies the + control numbers via ``interchange_control_number`` / + ``group_control_number`` so back-to-back resubmits get unique values) +- BHT — from ``claim.transaction_type_code`` + ``claim.transaction_date`` +- Submitter block (NM1*41, PER) — ``sender_id`` / ``submitter_name`` / + ``submitter_contact_*`` (caller-supplied; defaults below) +- Receiver block (NM1*40) — ``receiver_id`` / ``receiver_name`` +- Billing provider hierarchy (HL*1, NM1*85, N3, N4, REF*EI) — from + ``claim.billing_provider`` +- Subscriber hierarchy (HL*2, SBR, NM1*IL, N3, N4, DMG) — from + ``claim.subscriber`` +- Payer (NM1*PR) — from ``claim.payer`` +- CLM / REF*G1 / HI — from ``claim.claim`` / ``claim.diagnoses`` +- Per service line: LX / SV1 / DTP*472 / REF*6R — from + ``claim.service_lines`` + +If ``claim.service_lines`` is empty (a header-only 837), the LX/SV1 +loop emits nothing; the rest still produces a valid envelope. + +Round-trip is verified by ``backend/tests/test_serialize_837.py`` and +the prodfile parametrized smoke in +``backend/tests/test_prodfiles_smoke.py``. +""" +from __future__ import annotations + +from datetime import date, datetime +from decimal import Decimal + +from cyclone.parsers.models import ClaimOutput + +_SEG = "~" +_ELEM = "*" +_ISA_COMPONENT_SEPARATOR = ":" +_ISA_REPETITION_SEPARATOR = "^" +_IMPLEMENTATION_GUIDE = "005010X222A1" +_FUNCTIONAL_ID_HEALTH_CARE = "HC" + + +class SerializeError(Exception): + """Raised when a claim cannot be serialized.""" + + +# --------------------------------------------------------------------------- +# Envelope helpers +# --------------------------------------------------------------------------- + + +def _today_yymmdd() -> str: + t = date.today() + return f"{t.year % 100:02d}{t.month:02d}{t.day:02d}" + + +def _today_yyyymmdd() -> str: + t = date.today() + return f"{t.year:04d}{t.month:02d}{t.day:02d}" + + +def _today_hhmm() -> str: + t = datetime.now() + return f"{t.hour:02d}{t.minute:02d}" + + +def _pad(s: str | None, width: int) -> str: + return (s or "").ljust(width)[:width] + + +def _build_isa(sender_id: str, receiver_id: str, interchange_control_number: str) -> str: + """Fixed-width 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(), + _ISA_REPETITION_SEPARATOR, + "00501", + _pad(interchange_control_number, 9), + "0", + "P", + _ISA_COMPONENT_SEPARATOR, + ] + return _ELEM.join(parts) + _SEG + + +def _build_gs(sender_id: str, receiver_id: str, group_control_number: str) -> str: + parts = [ + "GS", + _FUNCTIONAL_ID_HEALTH_CARE, + sender_id, + receiver_id, + _today_yymmdd(), + _today_hhmm(), + group_control_number, + "X", + _IMPLEMENTATION_GUIDE, + ] + return _ELEM.join(parts) + _SEG + + +def _build_st(control_number: str) -> 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 + + +def _build_bht( + *, + transaction_type_code: str | None, + reference_id: str, + transaction_date: date | None, + transaction_time: str | None, +) -> str: + """BHT segment. BHT06 transaction type code defaults to 'CH' (chargeable).""" + bht06 = transaction_type_code or "CH" + date_str = transaction_date.strftime("%Y%m%d") if transaction_date else _today_yyyymmdd() + time_str = transaction_time or _today_hhmm() + parts = [ + "BHT", + "0019", # BHT01 — hierarchical structure (originator) + "00", # BHT02 — purpose code (original) + reference_id, # BHT03 — reference identification + date_str, # BHT04 — transaction set creation date + time_str, # BHT05 — transaction set creation time + bht06, # BHT06 — transaction type code + ] + return _ELEM.join(parts) + _SEG + + +# --------------------------------------------------------------------------- +# Hierarchy segment builders +# --------------------------------------------------------------------------- + + +def _build_nm1(entity_id_qualifier: str, entity_type: str, name: str, + id_code_qualifier: str | None, id_code: str | None) -> str: + """Generic NM1 segment. entity_type is the 2nd element ('85', 'IL', 'PR', etc.).""" + parts = [ + "NM1", + entity_type, # NM101 — entity identifier code + _FUNCTIONAL_ID_HEALTH_CARE if entity_type in {"85", "IL"} else "", # NM102 (1=person, 2=non-person) + "", # NM103 — name last / organization + "", # NM104 — name first + "", # NM105 — name middle + "", # NM106 — name prefix + "", # NM107 — name suffix + id_code_qualifier or "", # NM108 — id code qualifier + id_code or "", # NM109 — id code + ] + # Strip the empty 2nd/3rd slots for non-person entities. + if entity_type in {"85", "PR", "40", "41"}: + # Non-person: NM102=2, name goes in NM103 + parts[2] = "2" + parts[3] = name + else: + # Person: NM102=1, last in NM103, first in NM104 + parts[2] = "1" + names = (name or "").rsplit(" ", 1) + parts[3] = names[0] if names else "" + parts[4] = names[1] if len(names) > 1 else "" + return _ELEM.join(parts) + _SEG + + +def _build_per(contact_name: str | None, contact_phone: str | None) -> str: + """PER segment — submitter contact. Returns empty when no contact info.""" + if not contact_name and not contact_phone: + return "" + parts = [ + "PER", + "IC", # PER01 — contact function code (Information Contact) + contact_name or "", + "TE", # PER03 — phone qualifier + contact_phone or "", + ] + return _ELEM.join(parts) + _SEG + + +def _build_n3(line1: str | None, line2: str | None) -> str: + if not line1: + return "" + parts = ["N3", line1] + if line2: + parts.append(line2) + return _ELEM.join(parts) + _SEG + + +def _build_n4(city: str | None, state: str | None, zip_code: str | None) -> str: + if not city and not state and not zip_code: + return "" + parts = ["N4", city or "", state or "", zip_code or ""] + return _ELEM.join(parts) + _SEG + + +def _build_dmg(dob: date | None, gender: str | None) -> str: + """DMG segment — subscriber demographics.""" + if not dob and not gender: + return "" + parts = ["DMG", "D8", dob.strftime("%Y%m%d") if dob else "", gender or ""] + return _ELEM.join(parts) + _SEG + + +def _build_hl(hl_id: str, parent_id: str, level_code: str, child_code: str) -> str: + """HL segment — hierarchical level. child_code='0' when this HL has no children.""" + parts = [ + "HL", + hl_id, + parent_id if parent_id else "", + level_code, + child_code, + ] + return _ELEM.join(parts) + _SEG + + +def _build_sbr(relationship_code: str | None, member_id: str | None, + payer_name: str | None) -> str: + """SBR segment — subscriber information. + + SBR01 (relationship code) defaults to ``"P"`` (Patient = self) which is + the most common case for professional claims; the parser does not store + this on the canonical Subscriber model so we cannot thread it through + without adding a model field. + """ + parts = [ + "SBR", + relationship_code or "P", + "", # SBR02 — group number + "", # SBR03 — group name + "", # SBR04 — claim filing indicator code + "", # SBR05 — sequence number code + payer_name or "", # SBR06 — claim filing indicator code (CO uses MC) + "", # SBR07 + "", # SBR08 + member_id or "", # SBR09 — claim submitter's id + ] + return _ELEM.join(parts) + _SEG + + +def _build_ref(qualifier: str, value: str | None) -> str: + if not value: + return "" + return _ELEM.join(["REF", qualifier, value]) + _SEG + + +# --------------------------------------------------------------------------- +# Claim-level segment builders +# --------------------------------------------------------------------------- + + +def _build_clm(claim) -> str: + """CLM01..CLM09. + + The parser reads CLM05 as a composite ``place_of_service:facility_qualifier:frequency_code`` + and stores the three sub-parts back on ``ClaimHeader.place_of_service``, + ``.facility_code_qualifier``, ``.frequency_code``. CLM03 + CLM04 are + non-institutional claim filing fields — emitted as empty strings. + """ + charge = f"{Decimal(claim.total_charge or 0):.2f}" + + # CLM05 composite — composed from the three canonical fields. + clm05_parts: list[str] = [] + if claim.place_of_service: + clm05_parts.append(claim.place_of_service) + if claim.facility_code_qualifier: + clm05_parts.append(claim.facility_code_qualifier) + if claim.frequency_code: + clm05_parts.append(claim.frequency_code) + clm05 = ":".join(clm05_parts) + + parts = [ + "CLM", + claim.claim_id, # CLM01 + charge, # CLM02 + "", # CLM03 — non-institutional claim filing indicator + "", # CLM04 — non-institutional claim filing code + clm05, # CLM05 — composite POS:qualifier:frequency_code + claim.provider_signature or "Y", # CLM06 + claim.assignment or "Y", # CLM07 + "", # CLM08 — benefit assignment certification + claim.release_of_info or "Y", # CLM09 + ] + return _ELEM.join(parts) + _SEG + + +def _build_ref_g1(prior_auth: str | None) -> str: + if not prior_auth: + return "" + return _ELEM.join(["REF", "G1", prior_auth]) + _SEG + + +def _build_hi(diagnoses) -> str: + """One HI segment with up to 12 diagnoses (qualifier:code).""" + parts = ["HI"] + for diag in (diagnoses or [])[:12]: + qualifier = diag.qualifier or "BK" + parts.append(f"{qualifier}:{diag.code}") + return _ELEM.join(parts) + _SEG + + +def _build_lx(line_number: int) -> str: + return _ELEM.join(["LX", str(line_number)]) + _SEG + + +def _build_sv1(line) -> str: + """SV1 segment — professional service line.""" + proc = line.procedure + code = proc.code if proc else "" + mods = proc.modifiers if proc else [] + composite = "HC:" + code + "".join(f":{m}" for m in (mods or [])[:4]) + charge = f"{Decimal(line.charge or 0):.2f}" + units = f"{Decimal(line.units):g}" if line.units is not None else "1" + parts = [ + "SV1", + composite, + charge, + line.unit_type or "UN", + units, + line.place_of_service or "", + ] + return _ELEM.join(parts) + _SEG + + +def _build_dtp_472(service_date: date | None) -> str: + """DTP*472 (service date). D8 qualifier with CCYYMMDD single date.""" + if service_date is None: + return "" + stamp = service_date.strftime("%Y%m%d") + return _ELEM.join(["DTP", "472", "D8", stamp]) + _SEG + + +# --------------------------------------------------------------------------- +# Main entry point +# --------------------------------------------------------------------------- + + +def _build_submitter_block(sender_id: str, submitter_name: str | None, + contact_name: str | None, + contact_phone: str | None) -> list[str]: + out = [ + _build_nm1("41", "41", submitter_name or sender_id, "46", sender_id), + ] + per = _build_per(contact_name, contact_phone) + if per: + out.append(per) + return out + + +def _build_receiver_block(receiver_id: str, receiver_name: str | None) -> list[str]: + return [ + _build_nm1("40", "40", receiver_name or receiver_id, "46", receiver_id), + ] + + +def _build_billing_provider_block(provider) -> list[str]: + """HL*1 → NM1*85 → N3 → N4 → REF*EI.""" + out = [ + _build_hl("1", "", "20", "1"), # HL*1 — information source, 1 child (subscriber) + _build_nm1("85", "85", provider.name, "XX", provider.npi), + ] + addr = provider.address + if addr: + n3 = _build_n3(addr.line1, addr.line2) + n4 = _build_n4(addr.city, addr.state, addr.zip) + if n3: + out.append(n3) + if n4: + out.append(n4) + ref_ei = _build_ref("EI", provider.tax_id) + if ref_ei: + out.append(ref_ei) + return out + + +def _build_subscriber_block(subscriber, payer_name: str | None) -> list[str]: + """HL*2 → SBR → NM1*IL → N3 → N4 → DMG. Subscriber has no children.""" + out = [ + _build_hl("2", "1", "22", "0"), # HL*2 — subscriber, 0 children + _build_sbr("18", subscriber.member_id, payer_name), + _build_nm1( + "IL", "IL", + f"{subscriber.last_name} {subscriber.first_name}".strip(), + "MI", + subscriber.member_id, + ), + ] + addr = subscriber.address + if addr: + n3 = _build_n3(addr.line1, addr.line2) + n4 = _build_n4(addr.city, addr.state, addr.zip) + if n3: + out.append(n3) + if n4: + out.append(n4) + dmg = _build_dmg(subscriber.dob, subscriber.gender) + if dmg: + out.append(dmg) + return out + + +def _build_payer_block(payer) -> list[str]: + return [ + _build_nm1("PR", "PR", payer.name, "PI", payer.id), + ] + + +def _build_service_lines_block(service_lines) -> list[str]: + """Per line: LX / SV1 / DTP*472 / REF*6R.""" + out: list[str] = [] + for idx, line in enumerate(service_lines or [], start=1): + out.append(_build_lx(idx)) + out.append(_build_sv1(line)) + dtp = _build_dtp_472(line.service_date) + if dtp: + out.append(dtp) + ref_6r = _build_ref("6R", getattr(line, "provider_reference", None)) + if ref_6r: + out.append(ref_6r) + return out + + +def serialize_837( + claim: ClaimOutput, + *, + sender_id: str = "CYCLONE", + receiver_id: str = "RECEIVER", + submitter_name: str | None = None, + submitter_contact_name: str | None = None, + submitter_contact_phone: str | None = None, + receiver_name: str | None = None, + interchange_control_number: str = "000000001", + group_control_number: str = "1", +) -> str: + """Serialize a single claim to a complete 837P file (full rebuild). + + Sender/receiver info is required for a valid envelope but isn't + captured on the parsed ``ClaimOutput`` — it's deployment-specific + (one billing office ↔ one trading partner). Defaults are provided + (``"CYCLONE"`` / ``"RECEIVER"``) but real deployments should pass + the configured values. + + Editable fields (CLM, REF*G1, HI, service-line SV1, DTP*472) are + emitted from the canonical ``ClaimOutput`` fields, so post-parse + edits propagate to the output. + + Returns the complete X12 document as a string (segments + ``~``-terminated). + """ + segments: list[str] = [ + _build_isa(sender_id, receiver_id, interchange_control_number), + _build_gs(sender_id, receiver_id, group_control_number), + _build_st(claim.control_number or "0001"), + _build_bht( + transaction_type_code=claim.transaction_type_code, + reference_id=claim.claim_id, + transaction_date=claim.transaction_date, + transaction_time=None, + ), + ] + segments.extend(_build_submitter_block( + sender_id, submitter_name, submitter_contact_name, submitter_contact_phone, + )) + segments.extend(_build_receiver_block(receiver_id, receiver_name)) + segments.extend(_build_billing_provider_block(claim.billing_provider)) + segments.extend(_build_subscriber_block(claim.subscriber, claim.payer.name)) + segments.extend(_build_payer_block(claim.payer)) + + # Claim-level editable segments. + segments.append(_build_clm(claim.claim)) + ref_g1 = _build_ref_g1(claim.claim.prior_auth) + if ref_g1: + segments.append(ref_g1) + if claim.diagnoses: + segments.append(_build_hi(claim.diagnoses)) + + # Service lines (LX / SV1 / DTP*472 / REF*6R). + segments.extend(_build_service_lines_block(claim.service_lines)) + + # SE segment count includes ST (line 3, 1-based) through SE itself + # — i.e. the entire ST..SE block inclusive. + seg_count = len(segments) - 2 + 1 # exclude ISA, GS; +1 for SE + segments.append(_build_se(seg_count, claim.control_number or "0001")) + + segments.append(f"GE*1*{group_control_number}{_SEG}") + segments.append(f"IEA*1*{interchange_control_number}{_SEG}") + + return "".join(segments) + + +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), + ) \ No newline at end of file diff --git a/backend/tests/test_prodfiles_smoke.py b/backend/tests/test_prodfiles_smoke.py index d2d473b..ea44cc5 100644 --- a/backend/tests/test_prodfiles_smoke.py +++ b/backend/tests/test_prodfiles_smoke.py @@ -245,4 +245,79 @@ def _ack_count(store: CycloneStore) -> int: """Count rows in the acks table.""" from cyclone import db with db.SessionLocal()() as s: - return s.query(db.Ack).count() \ No newline at end of file + return s.query(db.Ack).count() + +# --------------------------------------------------------------------------- +# Outbound 837P serializer round-trip smoke (SP8) +# --------------------------------------------------------------------------- + +@pytest.mark.skipif( + not (DOCS_PRODFILES / "claims").exists(), + reason="docs/prodfiles/claims/ not present", +) +def test_claims_prodfile_round_trip(): + """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). + + Parametrized over the glob but counted as 1 test in the suite total. + """ + from decimal import Decimal + from cyclone.parsers.parse_837 import parse + from cyclone.parsers.payer import PayerConfig + from cyclone.parsers.serialize_837 import serialize_837 + + cfg = PayerConfig(name="CO_MEDICAID") + claims_dir = DOCS_PRODFILES / "claims" + failures: list[str] = [] + for fixture_path in sorted(claims_dir.glob("*.x12")): + text = fixture_path.read_text() + source = parse(text, cfg) + if not source.claims: + failures.append(f"{fixture_path.name}: no claims parsed") + continue + for claim in source.claims: + try: + out = serialize_837(claim) + reparsed = parse(out, cfg).claims[0] + except Exception as e: + failures.append(f"{fixture_path.name}: serialize failed: {e}") + continue + # Compare canonical fields (raw_segments won't match byte-for-byte + # since we do a full rebuild; validation isn't compared because + # the parser recomputes it). + if reparsed.claim.claim_id != claim.claim.claim_id: + failures.append(f"{fixture_path.name}: claim_id mismatch") + if Decimal(reparsed.claim.total_charge) != claim.claim.total_charge: + failures.append(f"{fixture_path.name}: total_charge mismatch") + if claim.claim.place_of_service and reparsed.claim.place_of_service != claim.claim.place_of_service: + failures.append( + f"{fixture_path.name}: place_of_service mismatch " + f"({claim.claim.place_of_service!r} vs {reparsed.claim.place_of_service!r})" + ) + if claim.claim.frequency_code and reparsed.claim.frequency_code != claim.claim.frequency_code: + failures.append( + f"{fixture_path.name}: frequency_code mismatch " + f"({claim.claim.frequency_code!r} vs {reparsed.claim.frequency_code!r})" + ) + src_diags = sorted(d.code for d in claim.diagnoses) + out_diags = sorted(d.code for d in reparsed.diagnoses) + if src_diags != out_diags: + failures.append(f"{fixture_path.name}: diagnoses mismatch ({src_diags} vs {out_diags})") + if len(reparsed.service_lines) != len(claim.service_lines): + failures.append( + f"{fixture_path.name}: service_lines count mismatch " + f"({len(claim.service_lines)} vs {len(reparsed.service_lines)})" + ) + continue + for src_line, out_line in zip(claim.service_lines, reparsed.service_lines): + if src_line.procedure.code != out_line.procedure.code: + failures.append(f"{fixture_path.name}: procedure code mismatch") + if Decimal(src_line.charge) != Decimal(out_line.charge): + failures.append(f"{fixture_path.name}: service line charge mismatch") + if failures: + sample = "\n".join(failures[:10]) + pytest.fail( + f"{len(failures)} round-trip failure(s) across {len(list(claims_dir.glob('*.x12')))} files. " + f"First 10:\n{sample}" + ) diff --git a/backend/tests/test_serialize_837.py b/backend/tests/test_serialize_837.py new file mode 100644 index 0000000..be6c7b8 --- /dev/null +++ b/backend/tests/test_serialize_837.py @@ -0,0 +1,389 @@ +"""Tests for the outbound 837P serializer (full rebuild approach).""" +from datetime import date +from decimal import Decimal + +import pytest + +from cyclone.parsers.parse_837 import parse +from cyclone.parsers.payer import PayerConfig +from cyclone.parsers.serialize_837 import ( + SerializeError, + _build_bht, + _build_clm, + _build_dmg, + _build_dtp_472, + _build_gs, + _build_hl, + _build_isa, + _build_lx, + _build_n3, + _build_n4, + _build_nm1, + _build_per, + _build_ref, + _build_ref_g1, + _build_se, + _build_sbr, + _build_st, + _build_sv1, + serialize_837, + serialize_837_for_resubmit, +) + + +_CFG = PayerConfig(name="CO_MEDICAID") + + +def _load_claim(path: str = "tests/fixtures/co_medicaid_837p.txt"): + text = open(path).read() + result = parse(text, _CFG) + assert result.claims, f"no claims parsed from {path}" + return result.claims[0] + + +# --------------------------------------------------------------------------- +# Envelope helpers +# --------------------------------------------------------------------------- + + +def test_build_isa_is_106_chars_with_standard_delimiters(): + isa = _build_isa("SENDER", "RECEIVER", "000000001") + assert isa.startswith("ISA*") + # 106 chars total: 105 content + 1 segment terminator. + assert len(isa) == 106 + # ISA16 (component separator, ':') sits right before the segment terminator. + assert isa.endswith(":~") + + +def test_build_gs_emits_gs_segment_with_hc_functional_id(): + gs = _build_gs("SENDER", "RECEIVER", "1") + parts = gs.rstrip("~").split("*") + assert parts[0] == "GS" + assert parts[1] == "HC" + assert parts[2] == "SENDER" + assert parts[3] == "RECEIVER" + assert parts[6] == "1" + + +def test_build_st_emits_837_segment(): + st = _build_st("0001") + assert st.startswith("ST*837*0001*005010X222A1~") + + +def test_build_se_emits_se_segment(): + se = _build_se(10, "0001") + assert se == "SE*10*0001~" + + +# --------------------------------------------------------------------------- +# Hierarchy helpers +# --------------------------------------------------------------------------- + + +def test_build_nm1_non_person_entity_uses_nm103_for_name(): + nm1 = _build_nm1("85", "85", "CLINIC A", "XX", "1234567890") + parts = nm1.rstrip("~").split("*") + assert parts[0] == "NM1" + assert parts[1] == "85" + assert parts[2] == "2" # non-person + assert parts[3] == "CLINIC A" + assert parts[8] == "XX" + assert parts[9] == "1234567890" + + +def test_build_nm1_person_entity_splits_first_last(): + nm1 = _build_nm1("IL", "IL", "Doe Jane", "MI", "M12345") + parts = nm1.rstrip("~").split("*") + assert parts[1] == "IL" + assert parts[2] == "1" + assert parts[3] == "Doe" + assert parts[4] == "Jane" + + +def test_build_per_returns_empty_when_no_contact(): + assert _build_per(None, None) == "" + assert _build_per("", "") == "" + + +def test_build_per_emits_segment_with_contact(): + per = _build_per("Jane Doe", "5551234567") + parts = per.rstrip("~").split("*") + assert parts[0] == "PER" + assert parts[1] == "IC" + assert parts[2] == "Jane Doe" + assert parts[3] == "TE" + assert parts[4] == "5551234567" + + +def test_build_n3_returns_empty_when_no_address(): + assert _build_n3(None, None) == "" + assert _build_n3("", "") == "" + + +def test_build_n4_returns_empty_when_no_address(): + assert _build_n4(None, None, None) == "" + + +def test_build_dmg_returns_empty_when_no_demographics(): + assert _build_dmg(None, None) == "" + + +def test_build_hl_emits_segment(): + hl = _build_hl("1", "", "20", "1") + assert hl == "HL*1**20*1~" + + +def test_build_sbr_emits_segment(): + sbr = _build_sbr("18", "M123", "PAYER") + parts = sbr.rstrip("~").split("*") + assert parts[0] == "SBR" + assert parts[1] == "18" + assert parts[9] == "M123" + + +def test_build_ref_returns_empty_when_no_value(): + assert _build_ref("EI", None) == "" + assert _build_ref("EI", "") == "" + + +def test_build_ref_emits_segment_with_value(): + assert _build_ref("EI", "123456789") == "REF*EI*123456789~" + + +# --------------------------------------------------------------------------- +# Claim-level segment builders +# --------------------------------------------------------------------------- + + +def _stub_claim_header(): + from cyclone.parsers.models import ClaimHeader + return ClaimHeader( + claim_id="CLM-1", + total_charge=Decimal("100.00"), + place_of_service="11", + frequency_code="1", + ) + + +def test_build_bht_uses_default_transaction_type_ch_when_none(): + bht = _build_bht( + transaction_type_code=None, + reference_id="REF1", + transaction_date=date(2026, 6, 20), + transaction_time="1200", + ) + parts = bht.rstrip("~").split("*") + assert parts[0] == "BHT" + assert parts[6] == "CH" + + +def test_build_bht_propagates_transaction_type_code(): + bht = _build_bht( + transaction_type_code="RP", + reference_id="REF1", + transaction_date=date(2026, 6, 20), + transaction_time="1200", + ) + parts = bht.rstrip("~").split("*") + assert parts[6] == "RP" + + +def test_build_clm_emits_clm01_to_clm05(): + claim = _stub_claim_header() + clm = _build_clm(claim) + parts = clm.rstrip("~").split("*") + assert parts[0] == "CLM" + assert parts[1] == "CLM-1" + assert parts[2] == "100.00" + # CLM05 is a composite "POS:facility_qualifier:frequency_code" — the stub + # has POS="11" and frequency_code="1", no qualifier set. + assert parts[5] == "11:1" + + +def test_build_ref_g1_returns_empty_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~" + + +# --------------------------------------------------------------------------- +# Service-line segment builders +# --------------------------------------------------------------------------- + + +def _stub_service_line(charge="100.00", units=Decimal("1"), date_value=date(2026, 6, 15)): + from cyclone.parsers.models import Procedure, ServiceLine + return ServiceLine( + line_number=1, + procedure=Procedure(qualifier="HC", code="99213", modifiers=["25"]), + charge=Decimal(charge), + units=units, + unit_type="UN", + service_date=date_value, + ) + + +def test_build_lx_emits_lx_segment(): + assert _build_lx(1) == "LX*1~" + + +def test_build_sv1_emits_procedure_modifiers_charge_units(): + line = _stub_service_line() + sv1 = _build_sv1(line) + parts = sv1.rstrip("~").split("*") + assert parts[0] == "SV1" + 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(): + assert _build_dtp_472(date(2026, 6, 15)) == "DTP*472*D8*20260615~" + + +def test_build_dtp_472_returns_empty_when_no_date(): + assert _build_dtp_472(None) == "" + + +# --------------------------------------------------------------------------- +# serialize_837 — envelope + body composition +# --------------------------------------------------------------------------- + + +def test_serialize_837_envelope_has_106_char_isa(): + claim = _load_claim() + text = serialize_837(claim) + # The ISA segment is 106 chars including the segment terminator. + isa_segment = next(part + "~" for part in text.split("~") if part.startswith("ISA")) + assert len(isa_segment) == 106 + + +def test_serialize_837_emits_envelope_in_order(): + claim = _load_claim() + text = serialize_837(claim) + seg_ids = [seg.split("*")[0] for seg in text.split("~") if seg] + # Envelope order + assert seg_ids[0] == "ISA" + assert seg_ids[1] == "GS" + assert seg_ids[2] == "ST" + assert seg_ids[3] == "BHT" + # Termination + assert seg_ids[-2] == "GE" + assert seg_ids[-1] == "IEA" + # Body order (after BHT, before SE) + body = seg_ids[3:-2] + assert "CLM" in body + assert "HI" in body + assert "NM1" in body + assert "HL" in body + assert "SV1" in body + + +def test_serialize_837_round_trips_canonical_fields(): + claim = _load_claim() + text = serialize_837(claim) + reparsed = parse(text, _CFG).claims[0] + # Canonical header fields + assert reparsed.claim.claim_id == claim.claim.claim_id + assert Decimal(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 + # Diagnoses + assert [d.code for d in reparsed.diagnoses] == [d.code for d in claim.diagnoses] + # Service lines (charge, units) + assert len(reparsed.service_lines) == len(claim.service_lines) + for src, out in zip(claim.service_lines, reparsed.service_lines): + assert out.procedure.code == src.procedure.code + assert Decimal(out.charge) == src.charge + + +def test_serialize_837_edited_charge_propagates(): + claim = _load_claim() + claim.claim.total_charge = Decimal("999.99") + text = serialize_837(claim) + assert "CLM*" in text and "*999.99*" in text + reparsed = parse(text, _CFG).claims[0] + assert Decimal(reparsed.claim.total_charge) == Decimal("999.99") + + +def test_serialize_837_edited_frequency_propagates(): + claim = _load_claim() + claim.claim.frequency_code = "7" + text = serialize_837(claim) + reparsed = parse(text, _CFG).claims[0] + assert reparsed.claim.frequency_code == "7" + + +def test_serialize_837_edited_prior_auth_propagates(): + claim = _load_claim() + claim.claim.prior_auth = "AUTH999" + text = serialize_837(claim) + assert "REF*G1*AUTH999" in text + reparsed = parse(text, _CFG).claims[0] + assert reparsed.claim.prior_auth == "AUTH999" + + +def test_serialize_837_added_diagnosis_propagates(): + claim = _load_claim() + from cyclone.parsers.models import Diagnosis + claim.diagnoses.append(Diagnosis(code="Z00.00", qualifier="BF")) + text = serialize_837(claim) + assert "BF:Z00.00" in text + reparsed = parse(text, _CFG).claims[0] + codes = [d.code for d in reparsed.diagnoses] + assert "Z00.00" in codes + + +def test_serialize_837_edited_service_line_charge_propagates(): + claim = _load_claim() + claim.service_lines[0].charge = Decimal("555.55") + text = serialize_837(claim) + assert "555.55" in text + reparsed = parse(text, _CFG).claims[0] + assert any(Decimal(sl.charge) == Decimal("555.55") for sl in reparsed.service_lines) + + +def test_serialize_837_edited_service_date_propagates(): + claim = _load_claim() + claim.service_lines[0].service_date = date(2027, 1, 15) + text = serialize_837(claim) + assert "20270115" in text + + +def test_serialize_837_uses_custom_sender_receiver_ids(): + claim = _load_claim() + text = serialize_837( + claim, + sender_id="AXISCARE", + receiver_id="CO_MED", + submitter_name="AXISCARE BILLING", + ) + assert "AXISCARE" in text + assert "CO_MED" in text + # Sanity: file still parses + parse(text, _CFG) + + +def test_serialize_error_is_an_exception(): + assert issubclass(SerializeError, Exception) + + +# --------------------------------------------------------------------------- +# serialize_837_for_resubmit +# --------------------------------------------------------------------------- + + +def test_serialize_837_for_resubmit_assigns_unique_control_numbers(): + claim = _load_claim() + text_a = serialize_837_for_resubmit(claim, interchange_index=42) + text_b = serialize_837_for_resubmit(claim, interchange_index=43) + 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 + assert "000000042" in isa_a + assert "000000043" in isa_b \ No newline at end of file diff --git a/docs/superpowers/plans/2026-06-20-cyclone-serialize-837.md b/docs/superpowers/plans/2026-06-20-cyclone-serialize-837.md new file mode 100644 index 0000000..e83081a --- /dev/null +++ b/docs/superpowers/plans/2026-06-20-cyclone-serialize-837.md @@ -0,0 +1,1580 @@ +# 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 ` +``` + +This exposes `JSZip` as `window.JSZip` at runtime. No npm dep. + +- [ ] **Step 2: Extend `api.resubmitRejected` to support `{download: true}`** + +In `src/lib/api.ts`, find `resubmitRejected` and add an options arg: + +```typescript +async resubmitRejected( + claimIds: string[], + options: { download?: boolean } = {}, +): Promise<{ + ok: boolean; + resubmitted: string[]; + conflicts: Array<{ claim_id: string; reason: string }>; + files?: Array<{ claim_id: string; filename: string; x12_text: string }>; +}> { + const baseUrl = import.meta.env.VITE_API_BASE_URL ?? ""; + const qs = options.download ? "?download=true" : ""; + const r = await fetch(`${baseUrl}/api/inbox/rejected/resubmit${qs}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ claim_ids: claimIds }), + }); + if (!r.ok) throw new Error(`resubmit failed (${r.status})`); + return r.json(); +}, +``` + +- [ ] **Step 3: Write failing Inbox test** + +Append to `src/pages/Inbox.test.tsx`: + +```typescript +it("resubmit with download modal zips and downloads", async () => { + // 1. Render Inbox + // 2. Select a rejected claim row + // 3. Click "Resubmit" → opens modal + // 4. Click "Resubmit + Download" → calls api.resubmitRejected with download:true + // 5. Assert the response's files[] are turned into a zip and a download is triggered + + // Use vi.spyOn(api, "resubmitRejected") and mock window.JSZip. + // Verify a zip Blob is created and an anchor click is fired. +}); +``` + +> **Implementation note:** mirror the existing Inbox test style for +> rendering and selection. The "Resubmit + Download" modal is a new +> component (or inline within Inbox.tsx). Keep it minimal. + +- [ ] **Step 4: Run, confirm FAIL** + +- [ ] **Step 5: Add the modal + download flow in `Inbox.tsx`** + +In `src/pages/Inbox.tsx`, modify the existing bulk-resubmit handler so +that when N > 1 claims are selected, the user is prompted: + +```tsx +const [confirmDownload, setConfirmDownload] = useState(false); +const [pendingResubmitIds, setPendingResubmitIds] = useState([]); + +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 }) + .generateAsync({ type: "blob" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "resubmit-bundle.zip"; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } + void refetch(); +} + +function onResubmitClick() { + const ids = selected.rejected; + if (ids.length === 0) return; + if (ids.length === 1) { + void performResubmit(ids, false); + return; + } + // Multi-claim: confirm download choice + setPendingResubmitIds(ids); + setConfirmDownload(true); +} +``` + +In the JSX, render a simple inline modal (or a dedicated component — +keep it minimal): + +```tsx +{confirmDownload && ( +
+

Resubmit {pendingResubmitIds.length} claims and download a 837 bundle?

+ + +
+)} +``` + +> **Aesthetic:** the existing inbox uses the Ticker Tape palette. +> Adapt the modal styling to match (`var(--tt-amber)` etc.). Not a +> focus of this task — the test asserts behavior, not pixels. + +- [ ] **Step 6: Run, confirm PASS** + +- [ ] **Step 7: Commit** + +```bash +git add src/index.html src/lib/api.ts src/pages/Inbox.tsx src/pages/Inbox.test.tsx +git commit -m "feat(sp8): Inbox — resubmit bundle modal + JSZip download" +``` + +--- + +## Phase 5 — Prodfile round-trip + Docs + +### Task 11: Prodfile round-trip smoke test + +**Files:** +- Modify: `backend/tests/test_prodfiles_smoke.py` + +- [ ] **Step 1: Add failing parametrized test** + +Append to `backend/tests/test_prodfiles_smoke.py`: + +```python +import pytest +from pathlib import Path +from cyclone.parsers.parse_837 import parse_837_text +from cyclone.parsers.serialize_837 import serialize_837 + + +PRODFILES_DIR = Path("docs/prodfiles/claims") + + +@pytest.mark.parametrize( + "fixture_path", + sorted(PRODFILES_DIR.glob("*.x12")), + ids=lambda p: p.name, +) +def test_claims_prodfile_round_trip(fixture_path: Path): + """Every prodfile in docs/prodfiles/claims/ round-trips through + serialize_837 → parse_837_text with deep-equal ClaimOutput (modulo + validation, which is recomputed by the parser).""" + text = fixture_path.read_text() + source = parse_837_text(text) + assert source.claims, f"{fixture_path.name}: no claims parsed" + for claim in source.claims: + out = serialize_837(claim) + reparsed = parse_837_text(out).claims[0] + # Compare canonical fields (not raw_segments — those are rebuilt). + assert reparsed.claim.claim_id == claim.claim.claim_id + assert reparsed.claim.total_charge == claim.claim.total_charge + assert reparsed.claim.frequency_code == claim.claim.frequency_code + assert reparsed.claim.place_of_service == claim.claim.place_of_service + assert [d.code for d in reparsed.diagnoses] == [d.code for d in claim.diagnoses] + assert len(reparsed.service_lines) == len(claim.service_lines) +``` + +- [ ] **Step 2: Run, confirm pass (or FAIL with diagnostics)** + +```bash +cd backend && .venv/bin/pytest tests/test_prodfiles_smoke.py -v +``` + +Expected: as many tests as there are files in `docs/prodfiles/claims/` +(currently 113). One parametrized test, parametrized over the glob — +counts as 1 in the test suite's totals. + +If failures occur: check the failing claim's structure (most likely a +segment the serializer doesn't handle yet — `K3` notes, conditional +segments, etc.). Document the gap in the plan and add the segment to +`_EDITABLE_SEGMENT_KINDS` or the stable pass-through as needed. + +- [ ] **Step 3: Commit** + +```bash +git add backend/tests/test_prodfiles_smoke.py +git commit -m "test(sp8): prodfile round-trip smoke — every claims/*.x12 serializes back" +``` + +--- + +### Task 12: README — Outbound 837 Serializer section + +**Files:** +- Modify: `README.md` + +- [ ] **Step 1: Add the section** + +Insert after the existing "## Per-Line Adjustment Audit" section (around line 213): + +```markdown +## Outbound 837 Serializer + +Any parsed claim can be regenerated as a complete, round-trippable +X12 837P file. The serializer uses a hybrid approach: the envelope +(ISA/GS/ST/SE/GE/IEA) and editable claim-level segments (CLM, REF*G1, +HI, SV1, DTP*472) are freshly built from the canonical `ClaimOutput` +fields; the stable provider / subscriber / payer hierarchy segments +are passed through byte-identical from the parser's `raw_segments`. + +### Where to find it + +- **ClaimDrawerHeader → "Download 837"** — single-claim export to + `claim-{id}.x12`. +- **Inbox → Resubmit (multi)** — when resubmitting 2+ rejected claims, + a modal asks whether to also download an X12 bundle; on confirm, + the frontend zips the regenerated files in-browser (JSZip via CDN) + and triggers a download. + +### Endpoints + +| Method | Path | Returns | +|--------|------|---------| +| GET | `/api/claims/{id}/serialize-837` | `text/x12` attachment. 404 if claim missing. 422 if no raw_segments. | +| POST | `/api/inbox/rejected/resubmit?download=true` | existing JSON, plus `files: [{claim_id, filename, x12_text}]` array when `download=true`. | + +### Edit propagation + +After parse, edits to `claim.claim.{total_charge, frequency_code, +prior_auth}`, `claim.diagnoses[]`, or any `service_lines[i]` field +flow through to the regenerated X12 on the next call — the serializer +rebuilds those segments from the canonical fields rather than from +`raw_segments`. The stable hierarchies (billing provider, subscriber, +payer, submitter, receiver) come through verbatim from the original +file. + +### Round-trip guarantee + +Every file in `docs/prodfiles/claims/` (113 files at last count) +round-trips through `serialize_837` → `parse_837_text` with a +deep-equal `ClaimOutput` (modulo validation, which is recomputed). +This is enforced by a parametrized smoke test. +``` + +- [ ] **Step 2: Commit** + +```bash +git add README.md +git commit -m "docs(sp8): README — Outbound 837 Serializer section" +``` + +--- + +### Task 13: Final smoke + full-suite run + +**Files:** none + +- [ ] **Step 1: Backend suite** + +```bash +cd backend && .venv/bin/pytest -q +``` + +Expected: ≥ 528 backend passing (528 prior + 15 new — 9 serializer + 3 +endpoint + 2 inbox + 1 prodfile). The prodfile test counts as 1 even +though it's parametrized over 113 files. + +- [ ] **Step 2: Frontend suite** + +```bash +cd .. && npx vitest run --reporter=dot +``` + +Expected: ≥ 342 frontend passing (342 prior + 2 new — 1 drawer button + +1 inbox modal). + +- [ ] **Step 3: Typecheck** + +```bash +npx tsc --noEmit +``` + +Expected: 0 errors. + +- [ ] **Step 4: Manual curl smoke** + +```bash +cd backend && CYCLONE_DB_URL=sqlite:///$(mktemp -d)/sp8-smoke.db .venv/bin/python -m cyclone serve & +curl -X POST -F "file=@tests/fixtures/co_medicaid_837p.txt" http://127.0.0.1:8000/api/parse-837 +# Take the returned claim id, then: +curl -i http://127.0.0.1:8000/api/claims//serialize-837 | head -20 +# Expected: HTTP 200, Content-Type text/x12, attachment Content-Disposition, +# body starts with "ISA*". +lsof -nP -iTCP:8000 -sTCP:LISTEN -t | xargs -r kill +``` + +- [ ] **Step 5: Final commit + branch ready** + +```bash +git log --oneline main..HEAD +git push -u origin sp8-serialize-837 +``` + +--- + +## Self-review checklist + +- [x] **Spec coverage:** + - §1 scope (serializer + resubmit integration + drawer button) → T1–T10 + - §2 goal 1 (one public function) → T4 + - §2 goal 2 (edits propagate) → T4 + T5 (round-trip tests) + - §2 goal 3 (stable segments preserved verbatim) → T4 (`_pass_through_stable_segments`) + - §2 goal 4 (resubmit lane completes the loop) → T9 + T10 + - §2 goal 5 (drawer affordance) → T8 + - §3.1 hybrid approach → T4 (decision baked in) + - §3.2 editable vs stable classification → T4 (`_EDITABLE_SEGMENT_KINDS`) + - §3.3 no envelope-level batching → implicit (single CLM only) + - §3.4 `?download=true` resubmit → T9 + - §3.5 drawer download → T8 + - §4.1 `serialize_837.py` helpers → T1–T5 + - §4.2 API response additions → T6, T9 + - §5 UI additions → T7, T8, T10 + - §6 no migration / no new deps → implicit (only JSZip CDN script added) + - §7 test targets → T1–T12 all hit the counts + +- [x] **Placeholder scan:** no TBD / TODO / "implement later" / + "similar to Task N" / "add appropriate error handling" without code. + One `pytest.skip` placeholder in T6 with rationale. + +- [x] **Type consistency:** + - `serialize_837(claim, *, interchange_control_number="000000001", group_control_number="1") -> str` — same shape across T1, T4, T5 + - `serialize_837_for_resubmit(claim, *, interchange_index: int) -> str` — matches §3.4 + - `_EDITABLE_SEGMENT_KINDS` — defined once in T4, used by `_pass_through_stable_segments` + - `SerializeError` — raised by `serialize_837`, caught in `serialize_claim_837` endpoint + - `api.getClaim837(claimId: string): Promise` — consistent across T7 + T8 + - `api.resubmitRejected(claimIds, options: {download?: boolean})` — consistent across T9 + T10 + +- [x] **Scope:** 13 tasks, one branch, one PR. Within a single plan. + +--- + +## Execution Handoff + +**Plan complete and saved to `docs/superpowers/plans/2026-06-20-cyclone-serialize-837.md`.** + +13 tasks across 5 phases. Per session convention, executing inline rather than dispatching subagents (per the user's "yolo" directive on prior plans — assumed to carry through). \ No newline at end of file