From 2893676c0b574b58510a9c0bc238afcae69e5595 Mon Sep 17 00:00:00 2001 From: Tyler Date: Sat, 20 Jun 2026 20:44:28 -0600 Subject: [PATCH] fix(835): correct SVC04/SVC05 mapping per X12 005010X221A1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit X12 835 SVC segment: SVC01 = composite procedure SVC02 = charge SVC03 = payment SVC04 = Unit or Basis for Measurement Code (UN, MJ, DA, ...) SVC05 = Service Unit Count The parser previously read SVC04 as the units count and SVC05 as the unit type — backwards. On real 835s (and the canonical minimal fixture), SVC04 carries the code 'UN' which fails Decimal parsing, so the units always came out as None and the code string was assigned to unit_type. SP7's line-level matcher couldn't compare units on the SVC side against the claim side because of this. - _consume_service_payment: SVC04 → unit_type, SVC05 → units count - Default unit_type to 'UN' when only the count is present - minimal_835.txt + unbalanced_835.txt: swap positions to match spec - Add 2 regression tests (units-and-unit-type, default-unit-type-to-UN) --- backend/src/cyclone/parsers/parse_835.py | 21 +++++---- backend/tests/fixtures/minimal_835.txt | 4 +- backend/tests/fixtures/unbalanced_835.txt | 4 +- backend/tests/test_parse_835.py | 54 +++++++++++++++++++++++ 4 files changed, 68 insertions(+), 15 deletions(-) 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