diff --git a/backend/src/cyclone/parsers/parse_835.py b/backend/src/cyclone/parsers/parse_835.py index 557f5b7..e9974bb 100644 --- a/backend/src/cyclone/parsers/parse_835.py +++ b/backend/src/cyclone/parsers/parse_835.py @@ -304,21 +304,20 @@ def _consume_service_payment(segments: list[list[str]], idx: int, line_no: int) payment = Decimal(seg[3]) if len(seg) > 3 and seg[3] else Decimal("0") units: Decimal | None = None unit_type: str | None = None + # X12 005010X221A1: SVC04 = Unit or Basis for Measurement Code + # (UN, MJ, DA, ...); SVC05 = Service Unit Count. if len(seg) > 4 and seg[4]: - # In X12 835, SVC04 is "units paid" and SVC05 is the unit-of-measure. - # SVC05 may carry a value (UN, MJ, etc.) or be empty. + unit_type = seg[4] + if len(seg) > 5 and seg[5]: try: - units = Decimal(seg[4]) + units = Decimal(seg[5]) except Exception: units = None - if len(seg) > 5 and seg[5]: - unit_type = seg[5] - elif len(seg) > 4 and seg[4]: - # Some payers omit SVC05 but still mean "UN" when the units value - # is present. Be conservative: only default to UN if no other value - # was set above. - if unit_type is None: - unit_type = "UN" + # Some payers omit SVC04 but still mean "UN" when SVC05 carries a + # count. Be conservative: only default to UN if no other value was + # set above. + if unit_type is None and units is not None: + unit_type = "UN" service_date: date | None = None ref_benefit_plan: str | None = None adjustments: list[ClaimAdjustment] = [] diff --git a/backend/tests/fixtures/minimal_835.txt b/backend/tests/fixtures/minimal_835.txt index 223b875..5e30e9a 100644 --- a/backend/tests/fixtures/minimal_835.txt +++ b/backend/tests/fixtures/minimal_835.txt @@ -12,9 +12,9 @@ N4*Montrose*CO*814014063~ LX*1~ CLP*CLM001*1*100.00*85.00*15.00*MC*CLM001*12*1~ CAS*CO*45*15.00~ -SVC*HC:S5130:U2*60.00*50.00*7.73*UN*20260602~ +SVC*HC:S5130:U2*60.00*50.00*UN*7.73*20260602~ DTM*472*20260602~ -SVC*HC:S5130:U2*40.00*35.00*7.73*UN*20260603~ +SVC*HC:S5130:U2*40.00*35.00*UN*7.73*20260603~ DTM*472*20260603~ SE*22*991102984~ GE*1*991102984~ diff --git a/backend/tests/fixtures/unbalanced_835.txt b/backend/tests/fixtures/unbalanced_835.txt index f247df4..4e52516 100644 --- a/backend/tests/fixtures/unbalanced_835.txt +++ b/backend/tests/fixtures/unbalanced_835.txt @@ -12,9 +12,9 @@ N4*Montrose*CO*814014063~ LX*1~ CLP*CLM001*1*100.00*85.00*15.00*MC*CLM001*12*1~ CAS*CO*45*15.00~ -SVC*HC:S5130:U2*60.00*50.00*7.73*UN*20260602~ +SVC*HC:S5130:U2*60.00*50.00*UN*7.73*20260602~ DTM*472*20260602~ -SVC*HC:S5130:U2*40.00*35.00*7.73*UN*20260603~ +SVC*HC:S5130:U2*40.00*35.00*UN*7.73*20260603~ DTM*472*20260603~ SE*22*991102985~ GE*1*991102985~ diff --git a/backend/tests/test_parse_835.py b/backend/tests/test_parse_835.py index 3898184..2c78acf 100644 --- a/backend/tests/test_parse_835.py +++ b/backend/tests/test_parse_835.py @@ -124,3 +124,57 @@ def test_parse_no_bpr_raises(): ) 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