From d8707ba874fdd93b97efcd15e9844f2719146f85 Mon Sep 17 00:00:00 2001 From: Nora Date: Thu, 2 Jul 2026 16:56:01 -0600 Subject: [PATCH] feat(sp32): extract 835 NM1*1P (service provider NPI) per ClaimPayment --- backend/src/cyclone/parsers/models_835.py | 1 + backend/src/cyclone/parsers/parse_835.py | 15 ++++++++--- backend/tests/test_parse_835.py | 32 +++++++++++++++++++++++ 3 files changed, 45 insertions(+), 3 deletions(-) diff --git a/backend/src/cyclone/parsers/models_835.py b/backend/src/cyclone/parsers/models_835.py index 1a05b6a..5f218a1 100644 --- a/backend/src/cyclone/parsers/models_835.py +++ b/backend/src/cyclone/parsers/models_835.py @@ -160,6 +160,7 @@ class ClaimPayment(_Base): ref_benefit_plan: str | None = None # REF*CE per the CO guide service_payments: list[ServicePayment] = Field(default_factory=list) raw_segments: list[list[str]] = Field(default_factory=list) + service_provider_npi: str | None = None # NM1*1P NM109 (Loop 2100 service provider) @model_validator(mode="before") @classmethod diff --git a/backend/src/cyclone/parsers/parse_835.py b/backend/src/cyclone/parsers/parse_835.py index e9974bb..0f3a2e9 100644 --- a/backend/src/cyclone/parsers/parse_835.py +++ b/backend/src/cyclone/parsers/parse_835.py @@ -376,6 +376,7 @@ def _consume_claim_payment(segments: list[list[str]], idx: int) -> tuple[ClaimPa service_payments: list[ServicePayment] = [] ref_benefit_plan: str | None = None + service_provider_npi: str | None = None per_diem: Decimal | None = None status_label = claim_status_label(status) @@ -422,9 +423,16 @@ def _consume_claim_payment(segments: list[list[str]], idx: int) -> tuple[ClaimPa except ValueError: per_diem = None elif s[0] == "NM1": - # Patient (QC) / service-provider (1P) — captured in raw_segments. - # The 835 spec doesn't require a structured patient model in v1. - pass + # SP32: capture service-provider NPI from NM1*1P (Loop 2100). + # NM108 (idx 8) carries the ID qualifier (typically "XX"); + # NM109 (idx 9) is the value. Some senders omit NM108; accept + # both forms but require a 10-digit ID. + if len(s) > 9 and s[1] == "1P": + if len(s) > 8 and s[8] == "XX" and s[9]: + if s[9].isdigit() and len(s[9]) == 10: + service_provider_npi = s[9] + elif s[9] and s[9].isdigit() and len(s[9]) == 10: + service_provider_npi = s[9] elif s[0] == "DTM": # Claim-level dates — captured in raw_segments. pass @@ -446,6 +454,7 @@ def _consume_claim_payment(segments: list[list[str]], idx: int) -> tuple[ClaimPa ref_benefit_plan=ref_benefit_plan, service_payments=service_payments, raw_segments=raw, + service_provider_npi=service_provider_npi, ), idx, ) diff --git a/backend/tests/test_parse_835.py b/backend/tests/test_parse_835.py index 2c78acf..ec35af7 100644 --- a/backend/tests/test_parse_835.py +++ b/backend/tests/test_parse_835.py @@ -178,3 +178,35 @@ def test_parse_service_payment_units_default_unit_type_to_un(): # 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"