Files
cyclone/backend/tests/test_serialize_837.py
T
Nora c5604fdfa0 feat(sp40): 837P serializer always emits PER-02/03/04 + SBR-09
Edifabric /v2/x12/validate rejects the SP39-regenerated 837P files
with 'Element PER-03 is required / Element PER-04 is required /
Element SBR-09 is required'. Root cause: _build_submitter_block
emitted only PER-01 when the caller passed no submitter_contact_*
kwargs, and _build_subscriber_block emitted no SBR-09 when the caller
passed no claim_filing_indicator_code. SP33-era callers and the SP39
regen script both bypass the kwargs.

Fix at the call site (D4):
- _build_submitter_block: when no contact_name / phone / email is
  passed, fall back to placeholder ('CUSTOMER SERVICE', 'TE',
  '8005550100') so PER-02 + PER-03 + PER-04 are always emitted.
  Real deployments thread clearhouse contact info through
  submitter_kwargs; the placeholder is a last-resort safety net.
- _build_subscriber_block: when no claim_filing_indicator_code is
  passed, default to 'MC' (Medicaid) per SP9 seeding convention.
  Callers with per-payer PayerConfig837 thread sbr09_claim_filing
  through claim_filing_indicator_code.

3 regression tests in test_serialize_837.py:
- PER always emits Name + Comm-Qualifier + Comm-Number
- SBR always emits SBR-09 default 'MC'
- Explicit claim_filing_indicator_code kwarg wins over default
2026-07-07 18:04:45 -06:00

741 lines
26 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}"