"""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_gs_uses_gs04_yyyymmdd_8_digits(): """GS-04 must be CCYYMMDD (8 digits) per X12; ISA uses YYMMDD (6). A 6-digit value like '260622' is rejected by EDI validators with 'Element GS-04 must use CCYYMMDD date format'. """ gs = _build_gs("SENDER", "RECEIVER", "1") parts = gs.rstrip("~").split("*") # parts[4] = GS-04 date assert len(parts[4]) == 8, f"expected 8-digit CCYYMMDD, got {parts[4]!r}" # Must parse as a CCYYMMDD date from datetime import datetime datetime.strptime(parts[4], "%Y%m%d") 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_emits_per01_even_with_no_contact(): """PER is required by X12 Loop 1000A — at least PER01 must be present.""" per = _build_per(None, None) assert per == "PER*IC~" per = _build_per("", "") assert per == "PER*IC~" def test_build_per_emits_segment_with_phone_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_per_emits_segment_with_email_contact(): """When email is given, it wins over phone (HCPF expects email).""" per = _build_per("Tyler Martinez", None, contact_email="tyler@dzinesco.com") parts = per.rstrip("~").split("*") assert parts[0] == "PER" assert parts[1] == "IC" assert parts[2] == "Tyler Martinez" assert parts[3] == "EM" assert parts[4] == "tyler@dzinesco.com" def test_build_per_email_takes_precedence_over_phone(): """If both phone and email are given, email is emitted (PER04).""" per = _build_per("Tyler", "555-1234", contact_email="t@example.com") parts = per.rstrip("~").split("*") assert parts[3] == "EM" assert parts[4] == "t@example.com" 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_with_correct_slots(): """SBR01=Payer Responsibility Seq Code (default 'P'), SBR02=Individual Relationship Code (e.g. '18' for self), SBR09=Claim Filing Indicator Code (e.g. 'MC' for Medicaid).""" sbr = _build_sbr("18", "MC") parts = sbr.rstrip("~").split("*") assert parts[0] == "SBR" # SBR01 — primary assert parts[1] == "P" # SBR02 — individual relationship (self = 18) assert parts[2] == "18" # SBR09 — claim filing indicator assert parts[9] == "MC" # Member ID and payer name do NOT belong in SBR — they live in # NM109 and NM1*PR.NM103 respectively. assert "M123" not in sbr assert "PAYER" not in sbr def test_build_sbr_defaults_relationship_to_self_and_filing_to_empty(): """When called with all-None, SBR01/02 fall back to safe defaults and SBR09 is left empty (the validator's R202 rule will then skip).""" sbr = _build_sbr(None, None) parts = sbr.rstrip("~").split("*") assert parts[1] == "P" assert parts[2] == "18" assert parts[9] == "" 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_clm_emits_clm08_defaulting_to_y(): """CLM-08 (Benefits Assignment Certification) is required by X12 837P when CLM-07 = 'Y'. Default to 'Y' when the source didn't capture one (matches what 99% of HCPF files look like). """ claim = _stub_claim_header() clm = _build_clm(claim) parts = clm.rstrip("~").split("*") # parts[8] = CLM-08 assert parts[8] == "Y", f"CLM-08 should default to 'Y', got {parts[8]!r}" def test_build_clm_propagates_captured_clm08(): from cyclone.parsers.models import ClaimHeader claim = ClaimHeader( claim_id="CLM-1", total_charge=Decimal("100.00"), place_of_service="11", frequency_code="1", assignment="Y", benefits_assignment_certification="N", release_of_info="Y", ) clm = _build_clm(claim) parts = clm.rstrip("~").split("*") assert parts[8] == "N" 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_sv1_emits_sv1_06_and_sv1_07_when_dx_pointer_given(): """SV1-07 (Diagnosis Code Pointer) is required by X12/HCPF when the parent claim has an HI segment (i.e. has at least one diagnosis). Per 005010X222A1, SV1-06 is "Not Used" by the guide and MUST be empty. Unit basis (UN/MJ/...) goes only in SV1-03. """ line = _stub_service_line() sv1 = _build_sv1(line, dx_pointer="1") parts = sv1.rstrip("~").split("*") # parts layout: SV1, comp(SV1-01), charge(02), unit_basis(03), # units(04), pos(05), ""(06 NOT USED), sv1_07(07) assert len(parts) == 8, f"expected 8 elements, got {parts}" # SV1-03 = unit basis assert parts[3] == "UN" # SV1-06 = "" (Not Used by 837P guide) assert parts[6] == "", f"SV1-06 must be empty (Not Used by 837P), got {parts[6]!r}" # SV1-07 = pointer assert parts[7] == "1" def test_build_sv1_omits_sv1_07_when_no_dx_pointer(): """When the claim has no HI segment, SV1-07 should be empty.""" line = _stub_service_line() sv1 = _build_sv1(line) # no dx_pointer kwarg parts = sv1.rstrip("~").split("*") assert len(parts) == 8 assert parts[6] == "" # SV1-06 still empty assert parts[7] == "" # SV1-07 empty def test_build_sv1_matches_goodclaim_layout(): """Layout must match the known-good reference at docs/goodclaim.x12: SV1*HC:T1019:U1:KX*125.40*UN*19.00***1~ i.e. 8 fields total: comp, charge, UN, units, '', '', '1'. """ from cyclone.parsers.models import Procedure, ServiceLine line = ServiceLine( line_number=1, procedure=Procedure(qualifier="HC", code="T1019", modifiers=["U1", "KX"]), charge=Decimal("125.40"), units=Decimal("19.00"), unit_type="UN", place_of_service=None, ) sv1 = _build_sv1(line, dx_pointer="1") assert sv1 == "SV1*HC:T1019:U1:KX*125.40*UN*19.00***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_837_emits_per_segment_in_submitter_block(): """X12 Loop 1000A (Submitter Name) requires a PER segment after NM1*41. The serializer must emit one even with no contact info (PER01='IC' is the only required element).""" claim = _load_claim() text = serialize_837(claim) # The first NM1*41 should be followed immediately by a PER segment. seg_ids = [seg.split("*")[0] for seg in text.split("~") if seg] nm1_41_idx = seg_ids.index("NM1") # first NM1 is the submitter assert nm1_41_idx >= 0 # The very next segment must be PER (PER01='IC' is required by spec). assert seg_ids[nm1_41_idx + 1] == "PER" per_line = next(seg for seg in text.split("~") if seg.startswith("PER*IC")) assert per_line.startswith("PER*IC") def test_serialize_837_per_segment_includes_email_from_kwargs(): """Passing submitter_contact_email should emit PER*IC**EM*.""" claim = _load_claim() text = serialize_837( claim, sender_id="DZINESCO", submitter_name="Dzinesco", submitter_contact_name="Tyler Martinez", submitter_contact_email="tyler@dzinesco.com", ) assert "PER*IC*Tyler Martinez*EM*tyler@dzinesco.com" in text # And the ISA sender id should be the clearhouse TPID, not "CYCLONE". assert "ZZ*DZINESCO" in text assert "ZZ*CYCLONE" not in text def test_serialize_837_sbr09_uses_claim_filing_indicator_code_kwarg(): """SBR09 must be the claim filing indicator (e.g. 'MC' for Medicaid), not the member id. The serializer takes it from the kwarg.""" claim = _load_claim() text = serialize_837(claim, claim_filing_indicator_code="MC") sbr_line = next(seg for seg in text.split("~") if seg.startswith("SBR*")) parts = sbr_line.rstrip("~").split("*") # SBR01 = P (primary), SBR02 = 18 (self), SBR09 = MC assert parts[1] == "P" assert parts[2] == "18" assert parts[9] == "MC" # And the member id should NOT be in SBR. assert claim.subscriber.member_id not in sbr_line def test_serialize_837_for_resubmit_forwards_kwargs_to_serialize_837(): """serialize_837_for_resubmit is a thin wrapper — it must forward clearhouse + payer kwargs so the export endpoint can use it.""" claim = _load_claim() text = serialize_837_for_resubmit( claim, interchange_index=7, sender_id="DZINESCO", submitter_name="Dzinesco", submitter_contact_name="Tyler Martinez", submitter_contact_email="tyler@dzinesco.com", receiver_id="COMEDASSISTPROG", receiver_name="COLORADO MEDICAL ASSISTANCE PROGRAM", claim_filing_indicator_code="MC", ) assert "ZZ*DZINESCO" in text assert "ZZ*COMEDASSISTPROG" in text assert "PER*IC*Tyler Martinez*EM*tyler@dzinesco.com" in text # Control numbers reflect the resubmit index. isa = next(seg for seg in text.split("~") if seg.startswith("ISA*")) assert "000000007" in isa 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