"""Tests for the 835 ERA parser orchestrator.""" from datetime import date from pathlib import Path import pytest from cyclone.parsers.exceptions import CycloneParseError from cyclone.parsers.parse_835 import parse from cyclone.parsers.payer import PayerConfig835 FIXTURE = Path(__file__).parent / "fixtures" / "minimal_835.txt" def test_parse_minimal_fixture_returns_one_claim(): text = FIXTURE.read_text() result = parse(text, PayerConfig835.co_medicaid_835()) assert len(result.claims) == 1 c = result.claims[0] assert c.payer_claim_control_number == "CLM001" assert c.status_code == "1" assert c.status_label == "Processed as Primary" assert c.total_charge == 100.00 assert c.total_paid == 85.00 assert c.patient_responsibility == 15.00 assert c.claim_filing_indicator == "MC" assert c.facility_type == "12" assert c.frequency_code == "1" def test_parse_envelope_captured(): result = parse(FIXTURE.read_text(), PayerConfig835.co_medicaid_835()) assert result.envelope is not None assert result.envelope.control_number == "991102984" assert result.envelope.transaction_date == date(2026, 6, 17) assert result.envelope.sender_id == "COMEDASSISTPROG" assert result.envelope.implementation_guide == "005010X221A1" def test_parse_financial_info(): result = parse(FIXTURE.read_text(), PayerConfig835.co_medicaid_835()) f = result.financial_info assert f.handling_code == "C" assert f.paid_amount == 85.00 assert f.credit_debit_flag == "C" assert f.payment_method == "ACH" assert f.payer_tax_id == "81-1725341" assert f.payment_date == date(2026, 6, 17) def test_parse_trace(): result = parse(FIXTURE.read_text(), PayerConfig835.co_medicaid_835()) t = result.trace assert t.trace_type_code == "1" # X12 standard: TRN02 = Reference Identification (trace number), # TRN03 = Originating Company Identifier. The CO guide labels # TRN03 as the Payer Tax ID, so for CO Medicaid 835s the # originating_company_id is "81-1725341" and the trace number is # the provider's TPID. assert t.trace_number == "TPID001" assert t.originating_company_id == "81-1725341" assert t.payer_id is None # TRN04 is not present in this fixture def test_parse_payer_and_payee(): result = parse(FIXTURE.read_text(), PayerConfig835.co_medicaid_835()) p = result.payer assert p.name == "CO_TXIX" assert p.id == "7912900843" assert p.contact_url == "hcpf.colorado.gov" assert p.address is not None assert p.address.city == "DENVER" pe = result.payee assert pe.name == "TOC, Inc." assert pe.npi == "1881068062" assert pe.address is not None assert pe.address.zip == "814014063" def test_parse_claim_has_two_service_lines(): result = parse(FIXTURE.read_text(), PayerConfig835.co_medicaid_835()) c = result.claims[0] assert len(c.service_payments) == 2 s1, s2 = c.service_payments assert s1.line_number == 1 assert s1.procedure_code == "S5130" assert s1.procedure_qualifier == "HC" assert s1.modifiers == ["U2"] assert s1.payment == 50.00 assert s1.service_date == date(2026, 6, 2) assert s2.line_number == 2 assert s2.payment == 35.00 assert s2.service_date == date(2026, 6, 3) def test_parse_summary_counts(): result = parse(FIXTURE.read_text(), PayerConfig835.co_medicaid_835()) assert result.summary.total_claims == 1 assert result.summary.input_file == "" # The orchestrator sets passed/failed from the validation report. # Without running validation it remains 0 — the CLI / API does that. assert result.summary.passed == 0 def test_parse_preserves_raw_segments(): result = parse(FIXTURE.read_text(), PayerConfig835.co_medicaid_835()) c = result.claims[0] assert any(s[0] == "CLP" for s in c.raw_segments) assert any(s[0] == "SVC" for s in c.raw_segments) def test_parse_uses_generic_config(): """Generic config shouldn't suppress parser errors but accepts any payer.""" result = parse(FIXTURE.read_text(), PayerConfig835.generic_835()) assert len(result.claims) == 1 def test_parse_no_bpr_raises(): text = ( "ISA*00* *00* *ZZ*COMEDASSISTPROG*ZZ*TPID001 *260617*1937*^*00501*991102990*1*P*:~" "ST*835*991102990*005010X221A1~" "SE*1*991102990~" "GE*1*991102990~" "IEA*1*991102990~" ) with pytest.raises(CycloneParseError, match="No BPR"): parse(text, PayerConfig835.co_medicaid_835()) def test_parse_service_payment_units_and_unit_type(): """X12 005010X221A1: SVC04 = unit-of-measure, SVC05 = service unit count. Bug history: the parser previously read SVC04 as ``units`` and SVC05 as ``unit_type`` (with the comment stating the opposite of the spec). On real-world 835s — and the canonical minimal fixture — this left ``units=None`` because SVC04 carries the string "UN" (which fails ``Decimal`` parsing) and SVC05 (a count like "7.73") got assigned to ``unit_type``. This test pins down the correct mapping so SP7's line-level matcher can compare units on the SVC side against the claim side. """ from decimal import Decimal result = parse(FIXTURE.read_text(), PayerConfig835.co_medicaid_835()) c = result.claims[0] s1, s2 = c.service_payments # minimal_835.txt fixture: SVC*HC:S5130:U2*60.00*50.00*UN*7.73*date* assert s1.units == Decimal("7.73"), s1.units assert s1.unit_type == "UN" assert s2.units == Decimal("7.73"), s2.units assert s2.unit_type == "UN" def test_parse_service_payment_units_default_unit_type_to_un(): """If SVC04 is empty but SVC05 carries a count, default unit_type to UN. Some payers omit the unit-of-measure code when the units are obvious (e.g. always "UN"). The parser should still parse the units and surface a reasonable default for ``unit_type`` so downstream consumers (validation, SP7 line matcher) have a non-None value. """ from decimal import Decimal base = FIXTURE.read_text() text = ( base .replace( "SVC*HC:S5130:U2*60.00*50.00*UN*7.73*20260602~", "SVC*HC:S5130:U2*60.00*50.00**3*20260602~", ) .replace( "SVC*HC:S5130:U2*40.00*35.00*UN*7.73*20260603~", "SVC*HC:S5130:U2*40.00*35.00**4*20260603~", ) ) result = parse(text, PayerConfig835.co_medicaid_835()) s1, s2 = result.claims[0].service_payments assert s1.units == Decimal("3"), s1.units assert s2.units == Decimal("4"), s2.units # Default to UN when SVC04 missing but units present. assert s1.unit_type == "UN", s1.unit_type assert s2.unit_type == "UN", s2.unit_type def test_parse_835_extracts_service_provider_npi_from_nm1_1p(): """SP32: NM1*1P in Loop 2100 populates ClaimPayment.service_provider_npi.""" from cyclone.parsers.parse_835 import parse as parse_835 text = ( "ISA*00* *00* *ZZ*RECEIVERID *ZZ*SENDERID *250101*1200*^*00501*000000001*0*P*:~" "GS*HP*RECEIVER*SENDER*20250101*1200*1*X*005010X221A1~" "ST*835*0001~" "BPR*I*85.40*C*NON*****01*021000021*DA*123456*1511111**01*031302955*DA*9876543~" "TRN*1*1511111*1511111~" "DTM*405*20250101~" "N1*PR*COLORADO MEDICAID*XV*CO MEDICAID~" "N3*PO BOX 1100*~" "N4*DENVER*CO*80203~" "REF*2U*12345~" "PER*BL*SUPPORT*TE*8005551212~" "N1*PE*ACME CLINIC*XX*1111111111~" "LX*1~" "CLP*CLM001*1*85.40*85.40*0.00*MC*CLM001*11*1~" "CAS*PR*1*0.00~" "NM1*1P*2*RENDERING PROVIDER*****XX*2222222222~" "SVC*HC:99213*85.40*85.40**1~" "DTM*472*20250101~" "SE*18*0001~" "GE*1*1~" "IEA*1*000000001~" ) result = parse_835(text, payer_config=None) # type: ignore[arg-type] assert len(result.claims) == 1 assert result.claims[0].service_provider_npi == "2222222222"