f5713640e4
Adds test_serialize_837_patient_loop_default_is_false — the long-term
guard against the SP24 bug coming back via a future refactor:
1. Asserts the module-level PATIENT_LOOP_DEFAULT_INCLUDED constant
is exactly False (not just falsy).
2. Calls _build_subscriber_block with no kwarg and verifies the
output contains no HL*3 segment + HL*2 child_count = 0.
3. The assertion message points at the SP24 spec §2 Decision 2 so
a future maintainer sees the regression story immediately.
Autoreview: confirmed the test fails when the constant is flipped
to True (the broken pattern from 2026-07-08), with the expected
'PATIENT_LOOP_DEFAULT_INCLUDED must be False' assertion message.
807 lines
28 KiB
Python
807 lines
28 KiB
Python
"""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*<name>*EM*<email>."""
|
|
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
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# SP39: _build_payer_block normalizes legacy payer ids to CO_TXIX
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _first_pr_segment(text: str) -> str:
|
|
"""Extract the first NM1*PR segment from a serialized 837P document."""
|
|
for seg in text.split("~"):
|
|
if seg.startswith("NM1*PR"):
|
|
return seg
|
|
raise AssertionError("no NM1*PR segment found in output")
|
|
|
|
|
|
def test_2010bb_normalizes_skco0_to_co_txix(caplog):
|
|
"""SP39: legacy SKCO0 + COHCPF must be normalized to CO_TXIX."""
|
|
import logging
|
|
claim = _load_claim()
|
|
claim.payer.id = "SKCO0"
|
|
claim.payer.name = "COHCPF"
|
|
with caplog.at_level(logging.WARNING, logger="cyclone.parsers.serialize_837"):
|
|
text = serialize_837_for_resubmit(claim, interchange_index=1)
|
|
parts = _first_pr_segment(text).split("*")
|
|
# parts: NM1, PR, 2, NM103, NM104, NM105, NM106, NM107, NM108, NM109
|
|
assert parts[8] == "PI", f"NM108 must be PI, got {parts[8]!r}"
|
|
assert parts[9] == "CO_TXIX", f"NM109 must be CO_TXIX, got {parts[9]!r}"
|
|
assert parts[3] == "CO_TXIX", f"NM103 must be CO_TXIX, got {parts[3]!r}"
|
|
assert "SKCO0" not in text, "SKCO0 must not appear in output bytes"
|
|
assert "COHCPF" not in text, "COHCPF must not appear in output bytes"
|
|
# WARNING log was emitted
|
|
assert any(
|
|
"SP39" in rec.message and "SKCO0" in rec.message
|
|
for rec in caplog.records
|
|
), "expected SP39 substitution WARNING log for SKCO0->CO_TXIX"
|
|
|
|
|
|
def test_2010bb_normalizes_empty_payer_id_to_co_txix(caplog):
|
|
"""SP39: empty payer.id (degenerate parse artifact) -> CO_TXIX."""
|
|
import logging
|
|
claim = _load_claim()
|
|
claim.payer.id = ""
|
|
claim.payer.name = ""
|
|
with caplog.at_level(logging.WARNING, logger="cyclone.parsers.serialize_837"):
|
|
text = serialize_837_for_resubmit(claim, interchange_index=1)
|
|
parts = _first_pr_segment(text).split("*")
|
|
assert parts[8] == "PI"
|
|
assert parts[9] == "CO_TXIX"
|
|
assert parts[3] == "CO_TXIX"
|
|
|
|
|
|
def test_2010bb_normalizes_co_bha_to_co_txix(caplog):
|
|
"""SP39: CO_BHA also gets normalized to CO_TXIX (operator policy)."""
|
|
import logging
|
|
claim = _load_claim()
|
|
claim.payer.id = "CO_BHA"
|
|
claim.payer.name = "CO_BHA"
|
|
with caplog.at_level(logging.WARNING, logger="cyclone.parsers.serialize_837"):
|
|
text = serialize_837_for_resubmit(claim, interchange_index=1)
|
|
parts = _first_pr_segment(text).split("*")
|
|
assert parts[8] == "PI"
|
|
assert parts[9] == "CO_TXIX"
|
|
assert parts[3] == "CO_TXIX"
|
|
assert "CO_BHA" not in text
|
|
|
|
|
|
def test_2010bb_preserves_foreign_payer_id_verbatim():
|
|
"""SP39: helper must NOT corrupt non-CO submits. Foreign payer id
|
|
is emitted verbatim with no substitution log."""
|
|
import logging
|
|
claim = _load_claim()
|
|
claim.payer.id = "OTHER_PAYER"
|
|
claim.payer.name = "OTHER PAYER NAME"
|
|
import io
|
|
log_stream = io.StringIO()
|
|
handler = logging.StreamHandler(log_stream)
|
|
handler.setLevel(logging.WARNING)
|
|
log = logging.getLogger("cyclone.parsers.serialize_837")
|
|
log.addHandler(handler)
|
|
try:
|
|
text = serialize_837_for_resubmit(claim, interchange_index=1)
|
|
finally:
|
|
log.removeHandler(handler)
|
|
parts = _first_pr_segment(text).split("*")
|
|
assert parts[8] == "PI"
|
|
assert parts[9] == "OTHER_PAYER"
|
|
assert parts[3] == "OTHER PAYER NAME"
|
|
# No substitution log for a foreign payer id
|
|
assert "SP39" not in log_stream.getvalue()
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# SP40: PER-02/03/04 + SBR-09 must always be emitted (Edifabric rejects
|
|
# the bare-PER / no-SBR09 shapes). The serializer fall-back fills them
|
|
# with safe placeholders when the caller passes no kwargs.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_per_segment_emits_name_and_phone_qualifier_when_no_contact():
|
|
"""SP40: without any submitter_contact_* kwargs, the submitter
|
|
block must still produce a PER segment with at least PER-02 (Name)
|
|
and a PER-03 (Communication Number Qualifier) + PER-04 (Number)
|
|
pair. Edifabric rejects PER*IC alone."""
|
|
claim = _load_claim()
|
|
text = serialize_837_for_resubmit(claim, interchange_index=42)
|
|
per_line = next(s for s in text.split("~") if s.startswith("PER"))
|
|
parts = per_line.split("*")
|
|
# PER*IC*<Name>*<Qual>*<Number>
|
|
assert parts[1] == "IC"
|
|
assert parts[2], f"PER-02 (Name) is required, got empty; line={per_line!r}"
|
|
assert parts[3] in {"TE", "EM", "FX"}, (
|
|
f"PER-03 must be a Communication Number Qualifier (TE/EM/FX); "
|
|
f"got {parts[3]!r}; line={per_line!r}"
|
|
)
|
|
assert parts[4], f"PER-04 (Number) is required, got empty; line={per_line!r}"
|
|
|
|
|
|
def test_sbr_segment_emits_claim_filing_indicator_default_mc():
|
|
"""SP40: SBR-09 (Claim Filing Indicator Code) must always be
|
|
emitted. Default is 'MC' (Medicaid) when the caller passes no
|
|
claim_filing_indicator_code kwarg. Edifabric rejects the bare
|
|
SBR*P*18******* shape."""
|
|
claim = _load_claim()
|
|
text = serialize_837_for_resubmit(claim, interchange_index=42)
|
|
sbr_line = next(s for s in text.split("~") if s.startswith("SBR"))
|
|
parts = sbr_line.split("*")
|
|
# SBR*P*18*******MC → index 9 is SBR-09
|
|
assert len(parts) >= 10, f"SBR must have 10 elements (with SBR-09), got {len(parts)}: {sbr_line!r}"
|
|
assert parts[9] == "MC", (
|
|
f"SBR-09 must default to 'MC' (Medicaid), got {parts[9]!r}; line={sbr_line!r}"
|
|
)
|
|
|
|
|
|
def test_sbr_segment_respects_explicit_claim_filing_indicator():
|
|
"""SP40: explicit claim_filing_indicator_code kwargs win over the
|
|
default — preserves the existing caller-facing behavior for
|
|
non-CO payers (e.g. '16' for Medicare Part B)."""
|
|
claim = _load_claim()
|
|
text = serialize_837_for_resubmit(
|
|
claim, interchange_index=42,
|
|
claim_filing_indicator_code="16",
|
|
)
|
|
sbr_line = next(s for s in text.split("~") if s.startswith("SBR"))
|
|
parts = sbr_line.split("*")
|
|
assert parts[9] == "16", f"SBR-09 should reflect explicit kwarg, got {parts[9]!r}"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# SP24 — IG-correctness regression test
|
|
#
|
|
# The 2000C Patient Hierarchical Level (HL*3 → PAT → NM1*QC) MUST be
|
|
# absent when SBR02 == "18" (Self-pay, the CO-Medicaid IHSS workflow).
|
|
# This test pins the PATIENT_LOOP_DEFAULT_INCLUDED constant to False so
|
|
# a future refactor cannot silently reintroduce the broken pattern that
|
|
# surfaced in the 2026-07-08 Edifabric 999 audit.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_serialize_837_patient_loop_default_is_false():
|
|
"""SP24 regression: PATIENT_LOOP_DEFAULT_INCLUDED must stay False.
|
|
|
|
Per X12 005010X222A1, the 2000C Patient Hierarchical Level
|
|
(``HL*3 → PAT → NM1*QC``) is REQUIRED only when Patient != Subscriber
|
|
(i.e. ``SBR02 != "18"``). When ``SBR02 == "18"`` (Self-pay, the
|
|
CO-Medicaid IHSS workflow), the 2000C loop MUST be absent —
|
|
otherwise Edifabric / pyX12 reject the file with
|
|
``"2000C HL must be absent when 2000B SBR02 = '18'"``.
|
|
|
|
The serializer encodes this invariant as a module-level constant
|
|
``PATIENT_LOOP_DEFAULT_INCLUDED``. Flipping the default back to
|
|
``True`` would re-introduce the rejection on every self-pay claim
|
|
in the dzinesco pipeline. This test pins the constant + verifies
|
|
the helper actually honors it on a no-kwarg call.
|
|
|
|
Reference: ``docs/superpowers/specs/2026-07-08-cyclone-reissue-claims-design.md``
|
|
"""
|
|
from datetime import date as _date
|
|
from cyclone.parsers.models import Subscriber
|
|
from cyclone.parsers.serialize_837 import (
|
|
PATIENT_LOOP_DEFAULT_INCLUDED,
|
|
_build_subscriber_block,
|
|
)
|
|
|
|
# 1. The constant itself is False — the IG-correct shape.
|
|
assert PATIENT_LOOP_DEFAULT_INCLUDED is False, (
|
|
f"PATIENT_LOOP_DEFAULT_INCLUDED must be False "
|
|
f"(got {PATIENT_LOOP_DEFAULT_INCLUDED!r}); the IG-correct "
|
|
f"serializer shape for SBR02='18' claims is 2000C-absent. "
|
|
f"See the SP24 spec §2 Decision 2 for the regression story."
|
|
)
|
|
|
|
# 2. Calling _build_subscriber_block with no kwarg honors the
|
|
# constant — no HL*3 segment emitted, HL*2 child count = 0.
|
|
sub = Subscriber(
|
|
member_id="TEST123",
|
|
first_name="Test",
|
|
last_name="Patient",
|
|
dob=_date(1990, 1, 1),
|
|
gender="F",
|
|
)
|
|
out = _build_subscriber_block(sub, "MC")
|
|
assert not any(seg.startswith("HL*3") for seg in out), (
|
|
f"HL*3 emitted with the IG-correct default; segments={out!r}"
|
|
)
|
|
# HL*2 child_count must be 0, not 1.
|
|
hl2_line = next(seg for seg in out if seg.startswith("HL*2"))
|
|
hl2_parts = hl2_line.rstrip("~").split("*")
|
|
assert hl2_parts[4] == "0", (
|
|
f"HL*2 child_count must be 0 (no 2000C children); got {hl2_parts[4]!r}. "
|
|
f"Line: {hl2_line!r}"
|
|
)
|