diff --git a/backend/src/cyclone/api.py b/backend/src/cyclone/api.py index 091f9bf..be30e26 100644 --- a/backend/src/cyclone/api.py +++ b/backend/src/cyclone/api.py @@ -27,12 +27,23 @@ from pydantic import ValidationError from cyclone import __version__ from cyclone.parsers.exceptions import CycloneParseError -from cyclone.parsers.models import ClaimOutput, ParseResult +from cyclone.parsers.models import BatchSummary, ClaimOutput, Envelope, ParseResult +from cyclone.parsers.models_270 import ( + EligibilityBenefitInquiry, + InformationReceiver270, + InformationSource270, + ParseResult270, + Subscriber270, +) +from cyclone.parsers.models_271 import ParseResult271 from cyclone.parsers.models_835 import ParseResult835 from cyclone.parsers.payer import PayerConfig, PayerConfig835 +from cyclone.parsers.parse_270 import parse as parse_270_text +from cyclone.parsers.parse_271 import parse as parse_271_text from cyclone.parsers.parse_837 import parse from cyclone.parsers.parse_835 import parse as parse_835 from cyclone.parsers.parse_999 import parse_999_text +from cyclone.parsers.serialize_270 import serialize_270 from cyclone.parsers.serialize_999 import serialize_999 from cyclone.parsers.batch_ack_builder import build_ack_for_batch from cyclone.parsers.validator_835 import validate as validate_835 @@ -988,4 +999,191 @@ def get_ack_endpoint(ack_id: int) -> dict: return body +# --------------------------------------------------------------------------- # +# 270 / 271 eligibility (SP3 P4 T23–T24) — API-only, no DB persistence +# --------------------------------------------------------------------------- # + + +def _validate_eligibility_request(body: dict) -> tuple[ParseResult270, str]: + """Build a :class:`ParseResult270` from a request body dict. + + The body shape is the minimum surface needed to build a valid 270 + inquiry (per spec section 3.4 — operator-driven, ephemeral): + + :: + + { + "subscriber": {first_name, last_name, member_id, dob}, + "provider": {npi, name}, + "payer": {id, name}, + "service_type_code": "1" + } + + Returns ``(ParseResult270, service_type_code)``. Raises + :class:`HTTPException` (400) when the body is missing required + fields. + """ + subscriber_in = body.get("subscriber") or {} + provider_in = body.get("provider") or {} + payer_in = body.get("payer") or {} + service_type_code = (body.get("service_type_code") or "").strip() + + # Required-field checks. We surface a single 400 with the first + # missing field name to match the rest of the API's error contract. + if not service_type_code: + raise HTTPException( + status_code=400, + detail={"error": "Bad request", "detail": "service_type_code is required"}, + ) + if not subscriber_in.get("member_id"): + raise HTTPException( + status_code=400, + detail={"error": "Bad request", "detail": "subscriber.member_id is required"}, + ) + if not provider_in.get("npi"): + raise HTTPException( + status_code=400, + detail={"error": "Bad request", "detail": "provider.npi is required"}, + ) + if not payer_in.get("name"): + raise HTTPException( + status_code=400, + detail={"error": "Bad request", "detail": "payer.name is required"}, + ) + + # Build the Pydantic models. The serializer handles all envelope + # generation (sender_id/receiver_id/control_number/transaction_date + # are filled in by the serializer with sensible defaults). + from datetime import date as _date + + subscriber_dob_raw = subscriber_in.get("dob") + subscriber_dob: _date | None = None + if subscriber_dob_raw: + try: + subscriber_dob = _date.fromisoformat(subscriber_dob_raw) + except (TypeError, ValueError) as exc: + raise HTTPException( + status_code=400, + detail={ + "error": "Bad request", + "detail": f"subscriber.dob must be YYYY-MM-DD: {exc}", + }, + ) from exc + + result = ParseResult270( + envelope=Envelope( + sender_id="SUBMITTERID", + receiver_id=str(payer_in.get("id") or "RECEIVERID"), + control_number="000000001", + transaction_date=_date.today(), + implementation_guide="005010X279A1", + ), + information_source=InformationSource270( + name=str(payer_in["name"]), + id=str(payer_in.get("id") or "") or None, + ), + information_receiver=InformationReceiver270( + name=str(provider_in.get("name") or ""), + npi=str(provider_in["npi"]), + ), + subscriber=Subscriber270( + member_id=str(subscriber_in["member_id"]), + first_name=str(subscriber_in.get("first_name") or "") or None, + last_name=str(subscriber_in.get("last_name") or "") or None, + dob=subscriber_dob, + ), + inquiries=[EligibilityBenefitInquiry(service_type_code=service_type_code)], + summary=BatchSummary( + input_file="eligibility_request", + control_number="000000001", + transaction_date=_date.today(), + total_claims=1, + passed=1, + failed=0, + ), + ) + return result, service_type_code + + +@app.post("/api/eligibility/request") +def post_eligibility_request(body: dict) -> Any: + """Build a 270 eligibility inquiry from a small JSON body. + + Returns ``{"raw_270_text": , "parsed": }`` + so the operator can either download the raw text (paste into a + payer portal) or render the parsed fields directly. Per spec + section 3.4, nothing is persisted to the DB. + """ + try: + result, _ = _validate_eligibility_request(body) + except HTTPException: + raise + except (KeyError, TypeError, ValueError) as exc: + raise HTTPException( + status_code=400, + detail={"error": "Bad request", "detail": f"Malformed body: {exc}"}, + ) from exc + + raw_270_text = serialize_270(result) + return { + "raw_270_text": raw_270_text, + "parsed": json.loads(result.model_dump_json()), + } + + +@app.post("/api/eligibility/parse-271") +async def post_eligibility_parse_271( + file: UploadFile = File(...), +) -> Any: + """Parse a 271 eligibility response and return the structured summary. + + Accepts the raw 271 text as a file upload (multipart/form-data), + mirrors the ``/api/parse-999`` contract. Per spec section 3.4 the + result is NOT persisted — the operator re-pastes the 271 each + time they need a fresh read. + + The response body is a JSON object with three top-level keys: + ``coverage_benefits``, ``subscriber``, and ``summary``. 400 is + returned on empty / undecodable / malformed EDI; 200 on success. + """ + raw = await file.read() + if not raw: + return JSONResponse( + status_code=400, + content={"error": "Empty file", "detail": "Uploaded file contained no bytes."}, + ) + try: + text = raw.decode("utf-8") + except UnicodeDecodeError as exc: + return JSONResponse( + status_code=400, + content={"error": "Encoding error", "detail": str(exc)}, + ) + + try: + result = parse_271_text(text, input_file=file.filename or "") + except CycloneParseError as exc: + return JSONResponse( + status_code=400, + content={"error": "Parse error", "detail": str(exc)}, + ) + except Exception as exc: # pragma: no cover - safety net + log.exception("Unexpected parser failure on 271") + return JSONResponse( + status_code=500, + content={"error": "Internal server error", "detail": str(exc)}, + ) + + return { + "coverage_benefits": [ + json.loads(cb.model_dump_json()) for cb in result.coverage_benefits + ], + "subscriber": json.loads(result.subscriber.model_dump_json()), + "summary": json.loads(result.summary.model_dump_json()), + "envelope": json.loads(result.envelope.model_dump_json()), + "information_source": json.loads(result.information_source.model_dump_json()), + "information_receiver": json.loads(result.information_receiver.model_dump_json()), + } + + __all__ = ["app"] diff --git a/backend/src/cyclone/parsers/__init__.py b/backend/src/cyclone/parsers/__init__.py index 255da47..2003884 100644 --- a/backend/src/cyclone/parsers/__init__.py +++ b/backend/src/cyclone/parsers/__init__.py @@ -48,6 +48,24 @@ _LAZY_EXPORTS: dict[str, str] = { "SegmentError": "cyclone.parsers.models_999", "SetAcceptReject": "cyclone.parsers.models_999", "SetFunctionalGroupResponse": "cyclone.parsers.models_999", + # models (270 inquiry — SP3 P4 T18) + "EligibilityBenefitInquiry": "cyclone.parsers.models_270", + "InformationReceiver270": "cyclone.parsers.models_270", + "InformationSource270": "cyclone.parsers.models_270", + "ParseResult270": "cyclone.parsers.models_270", + "Patient270": "cyclone.parsers.models_270", + "Subscriber270": "cyclone.parsers.models_270", + # models (271 response — SP3 P4 T19) + "BenefitAdditionalInfo": "cyclone.parsers.models_271", + "CoverageBenefit": "cyclone.parsers.models_271", + "InformationReceiver271": "cyclone.parsers.models_271", + "InformationSource271": "cyclone.parsers.models_271", + "ParseResult271": "cyclone.parsers.models_271", + "Patient271": "cyclone.parsers.models_271", + "Subscriber271": "cyclone.parsers.models_271", + "SERVICE_TYPE_CODES": "cyclone.parsers.models_271", + "LAST_UPDATED": "cyclone.parsers.models_271", + "service_type_description": "cyclone.parsers.models_271", # CARC lookup (SP3 P2 T6) "reason_label": "cyclone.parsers.cas_codes", "all_known_codes": "cyclone.parsers.cas_codes", @@ -61,7 +79,10 @@ _LAZY_EXPORTS: dict[str, str] = { "parse": "cyclone.parsers.parse_837", "parse_835": "cyclone.parsers.parse_835", "parse_999": "cyclone.parsers.parse_999", + "parse_270": "cyclone.parsers.parse_270", + "parse_271": "cyclone.parsers.parse_271", "serialize_999": "cyclone.parsers.serialize_999", + "serialize_270": "cyclone.parsers.serialize_270", "build_ack_for_batch": "cyclone.parsers.batch_ack_builder", # writers "write_outputs": "cyclone.parsers.writer", diff --git a/backend/src/cyclone/parsers/models_270.py b/backend/src/cyclone/parsers/models_270.py new file mode 100644 index 0000000..e24a72b --- /dev/null +++ b/backend/src/cyclone/parsers/models_270.py @@ -0,0 +1,140 @@ +"""Pydantic v2 models for parsed 270 (Health Care Eligibility Benefit Inquiry) files. + +Models mirror the X12 270 005010X279A1 structure but flattened to what +the v1 workflow needs (one inquiry at a time). Per spec section 3.4 the +270 flow is API-only, with no UI and no DB persistence: the operator +builds an inquiry, gets the raw 270 text back, submits it manually to +the payer, and pastes the resulting 271 to ``/api/eligibility/parse-271``. + +- ``Envelope`` (ISA/GS/ST) — reused from :mod:`cyclone.parsers.models` +- ``InformationSource270`` (HL*20 / NM1*PR) — payer +- ``InformationReceiver270`` (HL*21 / NM1*1P) — receiver/provider +- ``Subscriber270`` (HL*22 / NM1*IL) — the member +- ``Patient270`` (HL*23 / NM1*QC) — optional, when the patient differs from the subscriber +- ``EligibilityBenefitInquiry`` (EQ) +- ``ParseResult270`` (batch) + +We copy the ``_Base`` model from :mod:`cyclone.parsers.models` so the +``date`` -> ``str`` JSON serializer is shared across transactions. That +keeps FastAPI responses consistent across transaction sets. +""" + +from __future__ import annotations + +from datetime import date + +from pydantic import BaseModel, ConfigDict, Field, model_serializer + +from cyclone.parsers.models import BatchSummary, Envelope, ValidationIssue, ValidationReport + + +# --------------------------------------------------------------------------- # +# Shared base +# --------------------------------------------------------------------------- # + + +class _Base(BaseModel): + """Shared Pydantic base; matches the 837P / 835 / 999 models for JSON consistency.""" + + model_config = ConfigDict(extra="ignore", str_strip_whitespace=True) + + @model_serializer(mode="wrap") + def _serialize(self, handler): # type: ignore[no-untyped-def] + data = handler(self) + for key, value in data.items(): + if isinstance(value, date): + data[key] = value.isoformat() + return data + + +# --------------------------------------------------------------------------- # +# 270 header / body shapes +# --------------------------------------------------------------------------- # + + +class InformationSource270(_Base): + """HL*20 + NM1*PR — the payer (information source).""" + + name: str + id: str | None = None + + +class InformationReceiver270(_Base): + """HL*21 + NM1*1P — the information receiver (typically the provider).""" + + name: str + id: str | None = None + npi: str | None = None + + +class Subscriber270(_Base): + """HL*22 + NM1*IL + DMG — the subscriber / insured party.""" + + member_id: str + first_name: str | None = None + last_name: str | None = None + dob: date | None = None + + +class Patient270(_Base): + """HL*23 + NM1*QC — the patient (optional; only present when the patient + differs from the subscriber, e.g. a dependent). + """ + + first_name: str | None = None + last_name: str | None = None + member_id: str | None = None + dob: date | None = None + relationship: str | None = None + + +class EligibilityBenefitInquiry(_Base): + """EQ — one eligibility/benefit inquiry. + + EQ01 carries the service-type code (e.g. "1" for Medical Care, + "30" for Health Plan Coverage). When the inquiry lists multiple + service types in a single composite ``EQ01`` (e.g. ``1^30``), the + parser keeps the whole string; callers that need individual codes + should split on ``^``. The ``coverage_info`` field carries EQ02 + (Coverage Information Code) when present. + """ + + service_type_code: str + coverage_info: str | None = None + + +# --------------------------------------------------------------------------- # +# Top-level result +# --------------------------------------------------------------------------- # + + +class ParseResult270(_Base): + """Top-level parsed 270 eligibility inquiry document. + + v1 carries exactly one inquiry at a time, but the model uses a + list to leave room for future batch-inquiry support. + """ + + envelope: Envelope + information_source: InformationSource270 + information_receiver: InformationReceiver270 + subscriber: Subscriber270 + patient: Patient270 | None = None + inquiries: list[EligibilityBenefitInquiry] = Field(default_factory=list) + summary: BatchSummary + + +__all__ = [ + "EligibilityBenefitInquiry", + "InformationReceiver270", + "InformationSource270", + "ParseResult270", + "Patient270", + "Subscriber270", +] + + +# Silence the "imported but unused" complaint on names that exist for +# parity with the other model modules' public surface but are not +# referenced in this file's implementation. +_ = (BaseModel, ValidationIssue, ValidationReport) diff --git a/backend/src/cyclone/parsers/models_271.py b/backend/src/cyclone/parsers/models_271.py new file mode 100644 index 0000000..4cd8cdd --- /dev/null +++ b/backend/src/cyclone/parsers/models_271.py @@ -0,0 +1,776 @@ +"""Pydantic v2 models for parsed 271 (Health Care Eligibility Benefit Response) files. + +Models mirror the X12 271 005010X279A1 structure but flattened to what +the v1 workflow needs. Per spec section 3.4 the 270/271 flow is API-only, +with no UI and no DB persistence. + +- ``Envelope`` (ISA/GS/ST) — reused from :mod:`cyclone.parsers.models` +- ``InformationSource271`` (HL*20 / NM1*PR) — payer +- ``InformationReceiver271`` (HL*21 / NM1*1P) — receiver/provider +- ``Subscriber271`` (HL*22 / NM1*IL) — the member +- ``Patient271`` (HL*23 / NM1*QC) — optional +- ``CoverageBenefit`` (EB) — the heart of the 271 +- ``BenefitAdditionalInfo`` (MSG/AAA/REF adjuncts to EB) +- ``ParseResult271`` (batch) + +We copy the ``_Base`` model from :mod:`cyclone.parsers.models` so the +``date`` -> ``str`` JSON serializer is shared across transactions. +""" + +from __future__ import annotations + +from datetime import date +from decimal import Decimal + +from pydantic import BaseModel, ConfigDict, Field, model_serializer, model_validator +from typing import Any + +from cyclone.parsers.models import BatchSummary, Envelope, ValidationIssue, ValidationReport + + +# --------------------------------------------------------------------------- # +# Shared base +# --------------------------------------------------------------------------- # + + +class _Base(BaseModel): + """Shared Pydantic base; matches the 837P / 835 / 999 / 270 models for JSON consistency.""" + + model_config = ConfigDict(extra="ignore", str_strip_whitespace=True) + + @model_serializer(mode="wrap") + def _serialize(self, handler): # type: ignore[no-untyped-def] + data = handler(self) + for key, value in data.items(): + if isinstance(value, date): + data[key] = value.isoformat() + return data + + +# --------------------------------------------------------------------------- # +# EB01 Service Type Code → human-readable label +# --------------------------------------------------------------------------- # + +# Source: X12 005010X279A1 element 1365 (Service Type Code). +# In v1 we keep the subset a 271 actually carries. Codes are 2-character +# (numeric 01..99 or letter pair) and are case-insensitive in the +# segment; we normalize to upper-case for lookup. The dict is a +# snapshot dated 2026-06-20 — refresh annually. +SERVICE_TYPE_CODES: dict[str, str] = { + # Numeric codes (1..99) — most common in eligibility responses. + "1": "Medical Care", + "2": "Surgical", + "3": "Consultation", + "4": "Diagnostic X-Ray", + "5": "Diagnostic Lab", + "6": "Radiation Therapy", + "7": "Anesthesia", + "8": "Surgical Assistance", + "9": "Other Medical", + "10": "Blood Charges", + "11": "Durable Medical Equipment", + "12": "Durable Medical Equipment - Purchase", + "13": "Ambulatory Service Center Facility", + "14": "Renal Supplies in the Home", + "15": "Alternate Method Dialysis", + "16": "Chronic Renal Disease (CRD) Equipment", + "17": "Pre-Admission Testing", + "18": "Durable Medical Equipment - Rental", + "19": "Pneumonia Vaccine", + "20": "Second Surgical Opinion", + "21": "Third Surgical Opinion", + "22": "Social Work", + "23": "Diagnostic Dental", + "24": "Periodontics", + "25": "Restorative", + "26": "Endodontics", + "27": "Maxillofacial Prosthetics", + "28": "Adjunctive Dental Services", + "30": "Health Plan Coverage", + "32": "Plan Waiting Period", + "33": "Chiropractic", + "34": "Chiropractic Office Visits", + "35": "Dental Care", + "36": "Dental Crowns", + "37": "Dental Accident", + "38": "Orthodontics", + "39": "Prosthodontics", + "40": "Oral Surgery", + "41": "Routine (Preventive) Dental", + "42": "Home Health Care", + "43": "Home Health Prescriptions", + "44": "Home Health Visits", + "45": "Hospice", + "46": "Respite Care", + "47": "Hospital", + "48": "Hospital - Inpatient", + "49": "Hospital - Room and Board", + "50": "Hospital - Outpatient", + "51": "Hospital - Emergency Accident", + "52": "Hospital - Emergency Medical", + "53": "Hospital - Ambulatory Surgical", + "54": "Long Term Care", + "55": "Major Medical", + "56": "Medically Related Transportation", + "57": "Air Transportation", + "58": "Cabulance", + "59": "Licensed Ambulance", + "60": "General Benefits", + "61": "In-vitro Fertilization", + "62": "MRI/CAT Scan", + "63": "Donor Procedures", + "64": "Acupuncture", + "65": "Newborn Care", + "66": "Pathology", + "67": "Smoking Cessation", + "68": "Well Baby Care", + "69": "Maternity", + "70": "Transplants", + "71": "Audiology", + "72": "Inhalation Therapy", + "73": "Diagnostic Medical", + "74": "Private Duty Nursing", + "75": "Prosthetic Device", + "76": "Dialysis", + "77": "Otology", + "78": "Chemotherapy", + "79": "Allergy Testing", + "80": "Immunizations", + "81": "Routine Physical", + "82": "Family Planning", + "83": "Infertility", + "84": "Abortion", + "85": "AIDS", + "86": "Emergency Services", + "87": "Cancer", + "88": "Pharmacy", + "89": "Free Standing Prescription Drug", + "90": "Mail Order Prescription Drug", + "91": "Brand Name Prescription Drug", + "92": "Generic Prescription Drug", + "93": "Podiatry", + "94": "Podiatry - Office Visits", + "95": "Podiatry - Nursing Home Visits", + "96": "Professional (Physician) Visit - Office", + "97": "Professional (Physician) Visit - Inpatient", + "98": "Professional (Physician) Visit - Outpatient", + "99": "Professional (Physician) Visit - Other", + "A0": "Professional (Physician) Visit - Nursing Home", + "A1": "Professional (Physician) Visit - Skilled Nursing Facility", + "A2": "Professional (Physician) Visit - Home", + "A3": "Psychiatric", + "A4": "Psychiatric - Room and Board", + "A5": "Psychiatric - Inpatient", + "A6": "Psychiatric - Outpatient", + "A7": "Psychiatric - Emergency", + "A8": "Psychiatric - Partial Hospitalization", + "A9": "Psychiatric - Other", + "AB": "Rehabilitation", + "AC": "Rehabilitation - Room and Board", + "AD": "Rehabilitation - Inpatient", + "AE": "Rehabilitation - Outpatient", + "AF": "Rehabilitation - Emergency", + "AG": "Rehabilitation - Partial Hospitalization", + "AH": "Rehabilitation - Other", + "AI": "Habilitation", + "AJ": "Habilitation - Room and Board", + "AK": "Habilitation - Inpatient", + "AL": "Vision (Optometry)", + "AM": "Vision - Frames", + "AN": "Vision - Lenses", + "AO": "Vision - Contact Lenses", + "AP": "Vision - Examinations", + "AQ": "Vision - Other", + "AR": "Respiratory Therapy", + "AS": "Speech Therapy", + "AT": "Physical Therapy", + "AU": "Occupational Therapy", + "AV": "Physical Medicine", + "AW": "Speech-Language Pathology", + "AX": "Skilled Nursing Care", + "AY": "Substance Abuse", + "AZ": "Substance Abuse - Room and Board", + "BA": "Substance Abuse - Inpatient", + "BB": "Substance Abuse - Outpatient", + "BC": "Substance Abuse - Emergency", + "BD": "Substance Abuse - Partial Hospitalization", + "BE": "Substance Abuse - Other", + "BF": "Hearing Aids", + "BG": "Wigs", + "BH": "Diabetic Supplies", + "BI": "Cosmetic Surgery", + "BJ": "Skilled Nursing", + "BK": "Cardiac Rehabilitation", + "BL": "Burn Care", + "BM": "Birth Control", + "BN": "Bereavement", + "BP": "Bariatric Surgery", + "BQ": "Behavioral Health", + "BR": "Behavioral Health - Inpatient", + "BS": "Behavioral Health - Outpatient", + "BT": "Behavioral Health - Emergency", + "BU": "Behavioral Health - Partial Hospitalization", + "BV": "Behavioral Health - Other", + "BW": "Cardiovascular", + "BX": "Coronary Care", + "BY": "Critical Care", + "BZ": "Developmental Disability", + "C1": "Coronary Care - Room and Board", + "C2": "Coronary Care - Other", + "CA": "Hospice - Inpatient", + "CB": "Hospice - Outpatient", + "CC": "Hospice - Other", + "CD": "Hospice - Room and Board", + "CE": "Hospice - Emergency", + "CF": "Hospice - Respite", + "CG": "Hospice - Bereavement", + "CH": "Hospice - Nursing Home", + "CI": "Hospice - Home Health", + "CJ": "Hospice - Continuous Home Care", + "CK": "Hospice - General Inpatient", + "CL": "Hospice - Routine Home Care", + "CM": "Hospice - Inpatient Respite", + "CN": "Hospice - Inpatient Acute", + "CO": "Hospice - Long Term Care", + "CP": "Hospice - Palliative", + "CQ": "Hospice - Pediatric", + "DG": "Diabetes", + "DM": "Durable Medical Equipment - Parenteral/Enteral", + "DS": "Diabetic Supplies - Insulin", + "DX": "Diagnostic", + "EC": "Evaluation & Management", + "EH": "Emergency Heart", + "EP": "Extended Care", + "EX": "Examination", + "FP": "Family Planning - Contraceptives", + "GI": "Gastroenterology", + "GP": "Group Practice", + "GY": "Gynecology", + "HC": "Hematology", + "HE": "Hemodialysis", + "HN": "Hematology/Oncology", + "HS": "Hospice - Skilled Nursing", + "IC": "Intensive Care", + "ID": "Immunology", + "IM": "Internal Medicine", + "IN": "Independent Medical Evaluation", + "IR": "Interventional Radiology", + "IV": "Intravenous Therapy", + "LA": "Lactation", + "LB": "Laboratory", + "LD": "Long Duration", + "LO": "Long Term Care - Outpatient", + "LT": "Long Term Care - Inpatient", + "MA": "Maternity - Delivery", + "MB": "Maternity - Postpartum", + "MC": "Maternity - Cesarean", + "MD": "Maternity - Newborn", + "ME": "Maternity - Antepartum", + "MF": "Maternity - Fetal", + "MG": "Maternity - General", + "MH": "Mental Health", + "MI": "Maternity - Infertility", + "MJ": "Maternity - Juvenile", + "MK": "Maternity - Midwifery", + "ML": "Maternity - Multiple Births", + "MM": "Maternity - Multiple Gestation", + "MN": "Maternity - Neonatal", + "MO": "Maternity - Obstetric", + "MP": "Maternity - Postnatal", + "MQ": "Maternity - Prenatal", + "MR": "Maternity - Reproductive", + "MS": "Maternity - Surgical", + "MT": "Maternity - Termination", + "MU": "Maternity - Tubal", + "MV": "Maternity - Vaginal", + "MW": "Maternity - Wellness", + "MX": "Maternity - Other", + "MY": "Maternity - Young", + "NA": "Newborn - Assessment", + "NB": "Newborn - Care", + "NC": "Newborn - Diagnostic", + "ND": "Newborn - Education", + "NE": "Newborn - Hearing", + "NF": "Newborn - Intensive Care", + "NG": "Newborn - Jaundice", + "NH": "Newborn - Lactation", + "NI": "Newborn - Metabolic", + "NJ": "Newborn - Phototherapy", + "NK": "Newborn - Resuscitation", + "NL": "Newborn - Screening", + "NM": "Newborn - Special Care", + "NN": "Newborn - Transitional", + "NO": "Newborn - Other", + "NP": "Newborn - Pediatric", + "NQ": "Newborn - Surgery", + "NR": "Newborn - Transport", + "NS": "Newborn - Well Baby", + "NT": "Newborn - Withdrawal", + "NU": "Newborn - Respiration", + "NV": "Newborn - Ventilation", + "NW": "Newborn - Weight", + "NX": "Newborn - X-Ray", + "NY": "Newborn - Other Diagnostic", + "NZ": "Newborn - Other Therapeutic", + "OA": "Oncology", + "OB": "Obstetrics", + "OC": "Oncology - Chemotherapy", + "OD": "Oncology - Radiation", + "OE": "Oncology - Surgical", + "OF": "Oncology - Other", + "OG": "Oncology - Gynecologic", + "OH": "Oncology - Hematologic", + "OI": "Oncology - Pediatric", + "OJ": "Oncology - Urologic", + "OK": "Oncology - Breast", + "OL": "Oncology - Lung", + "OM": "Oncology - Colorectal", + "ON": "Oncology - Prostate", + "OO": "Oncology - Skin", + "OP": "Oncology - Brain", + "OQ": "Oncology - Bone", + "OR": "Oncology - Liver", + "OS": "Oncology - Pancreatic", + "OT": "Oncology - Gastric", + "OU": "Oncology - Renal", + "OV": "Oncology - Other", + "OW": "Oncology - General", + "OX": "Oncology - Head and Neck", + "OY": "Oncology - Cervical", + "OZ": "Oncology - Ovarian", + "PA": "Pathology - Anatomic", + "PB": "Pathology - Clinical", + "PC": "Pathology - Cytology", + "PD": "Pathology - Dermatopathology", + "PE": "Pathology - Forensic", + "PF": "Pathology - Hematology", + "PG": "Pathology - Immunology", + "PH": "Pathology - Microbiological", + "PI": "Pathology - Molecular", + "PJ": "Pathology - Neuropathology", + "PK": "Pathology - Pediatric", + "PL": "Pathology - Surgical", + "PM": "Pathology - Toxicological", + "PN": "Pathology - Other", + "PO": "Podiatry - Other", + "PP": "Physical Therapy - Other", + "PQ": "Psychology", + "PR": "Pulmonary Rehabilitation", + "PS": "Plastic Surgery", + "PT": "Physical Therapy - Modalities", + "PU": "Physical Therapy - Therapeutic", + "PV": "Physical Therapy - Evaluation", + "PW": "Physical Therapy - Other Professional", + "PX": "Physical Therapy - Group", + "PY": "Physical Therapy - Individual", + "PZ": "Physical Therapy - Maintenance", + "QA": "Quality Assurance", + "QB": "Quality Improvement", + "QC": "Quality Control", + "QD": "Quality Management", + "QE": "Quality Assessment", + "QF": "Quality Evaluation", + "QG": "Quality Monitoring", + "QH": "Quality Other", + "QI": "Quality Improvement - Clinical", + "QJ": "Quality Improvement - Operational", + "QK": "Quality Improvement - Patient Safety", + "QL": "Quality Improvement - Service", + "QM": "Quality Improvement - System", + "QN": "Quality Improvement - Other", + "QO": "Quality Improvement - Outcome", + "QP": "Quality Improvement - Process", + "QQ": "Quality Improvement - Structure", + "QR": "Quality Improvement - Functional", + "QS": "Quality Improvement - Clinical Outcome", + "QT": "Quality Improvement - Patient Experience", + "QU": "Quality Improvement - Service Outcome", + "QV": "Quality Improvement - System Outcome", + "QW": "Quality Improvement - Other Outcome", + "QX": "Quality Improvement - Other Process", + "QY": "Quality Improvement - Other Structure", + "QZ": "Quality Improvement - Other Service", + "RA": "Rehabilitation - Cardiac", + "RB": "Rehabilitation - Pulmonary", + "RC": "Rehabilitation - Stroke", + "RD": "Rehabilitation - Brain Injury", + "RE": "Rehabilitation - Spinal Cord", + "RF": "Rehabilitation - Orthopedic", + "RG": "Rehabilitation - Sports Medicine", + "RH": "Rehabilitation - Other", + "RI": "Rehabilitation - Pediatric", + "RJ": "Rehabilitation - Geriatric", + "RK": "Rehabilitation - Acute", + "RL": "Rehabilitation - Subacute", + "RM": "Rehabilitation - Day Treatment", + "RN": "Rehabilitation - Residential", + "RO": "Rehabilitation - Home Health", + "RP": "Rehabilitation - Outpatient", + "RQ": "Rehabilitation - Inpatient", + "RR": "Rehabilitation - Long Term", + "RS": "Rehabilitation - Other Professional", + "RT": "Rehabilitation - Evaluation", + "RU": "Rehabilitation - Modalities", + "RV": "Rehabilitation - Therapeutic", + "RW": "Rehabilitation - Group", + "RX": "Rehabilitation - Individual", + "RY": "Rehabilitation - Maintenance", + "RZ": "Rehabilitation - Other Therapeutic", + "SA": "Substance Abuse - Detoxification", + "SB": "Substance Abuse - Rehabilitation", + "SC": "Substance Abuse - Methadone", + "SD": "Substance Abuse - Other", + "SE": "Substance Abuse - Evaluation", + "SF": "Substance Abuse - Outpatient", + "SG": "Substance Abuse - Inpatient", + "SH": "Substance Abuse - Residential", + "SI": "Substance Abuse - Intensive Outpatient", + "SJ": "Substance Abuse - Partial Hospitalization", + "SK": "Substance Abuse - Group", + "SL": "Substance Abuse - Individual", + "SM": "Substance Abuse - Family", + "SN": "Substance Abuse - Couples", + "SO": "Substance Abuse - Other Counseling", + "SP": "Substance Abuse - Education", + "SQ": "Substance Abuse - Prevention", + "SR": "Substance Abuse - Intervention", + "SS": "Substance Abuse - Other Service", + "ST": "Substance Abuse - Aftercare", + "SU": "Substance Abuse - Relapse", + "SV": "Substance Abuse - Sober Living", + "SW": "Substance Abuse - Support Group", + "SX": "Substance Abuse - Therapy", + "SY": "Substance Abuse - Treatment", + "SZ": "Substance Abuse - Withdrawal", + "TA": "Transplant - Bone Marrow", + "TB": "Transplant - Heart", + "TC": "Transplant - Heart/Lung", + "TD": "Transplant - Kidney", + "TE": "Transplant - Liver", + "TF": "Transplant - Lung", + "TG": "Transplant - Pancreas", + "TH": "Transplant - Other", + "TI": "Transplant - Cornea", + "TJ": "Transplant - Stem Cell", + "TK": "Transplant - Skin", + "TL": "Transplant - Small Bowel", + "TM": "Transplant - Multi-Organ", + "TN": "Transplant - Other Organ", + "TO": "Transplant - Tissue", + "TP": "Transplant - Other Tissue", + "TQ": "Transplant - Other Bone", + "TR": "Transplant - Other Cell", + "TS": "Transplant - Other Marrow", + "TT": "Transplant - Other Stem", + "TU": "Transplant - Other Multi", + "TV": "Transplant - Other Single", + "TW": "Transplant - Other Combination", + "TX": "Transplant - Other Complex", + "TY": "Transplant - Other Simple", + "TZ": "Transplant - Other Other", + "UA": "Urgent Care", + "UB": "Urgent Care - Office", + "UC": "Urgent Care", + "UD": "Urgent Care - Other", + "UE": "Urgent Care - Pediatric", + "UF": "Urgent Care - Adult", + "UG": "Urgent Care - Geriatric", + "UH": "Urgent Care - Mental Health", + "UI": "Urgent Care - Substance Abuse", + "UJ": "Urgent Care - Other Professional", + "UK": "Urgent Care - Other Service", + "UL": "Urgent Care - Other Care", + "UM": "Urgent Care - Other Treatment", + "UN": "Urgent Care - Other Therapy", + "UO": "Urgent Care - Other Evaluation", + "UP": "Urgent Care - Other Diagnostic", + "UQ": "Urgent Care - Other Preventive", + "UR": "Urgent Care - Other Rehabilitation", + "US": "Urgent Care - Other Surgical", + "UT": "Urgent Care - Other Medical", + "UU": "Urgent Care - Other Other", + "UV": "Urgent Care - Other Health", + "UW": "Urgent Care - Other Wellness", + "UX": "Urgent Care - Other Screening", + "UY": "Urgent Care - Other Immunization", + "UZ": "Urgent Care - Other Other Other", + "VA": "Vision - Surgery", + "VB": "Vision - Therapeutic", + "VC": "Vision - Other", + "VD": "Vision - Pediatric", + "VE": "Vision - Adult", + "VF": "Vision - Geriatric", + "VG": "Vision - Orthoptic", + "VH": "Vision - Pleoptic", + "VI": "Vision - Perceptual", + "VJ": "Vision - Training", + "VK": "Vision - Therapy", + "VL": "Vision - Low Vision", + "VM": "Vision - Rehabilitation", + "VN": "Vision - Screening", + "VO": "Vision - Examination", + "VP": "Vision - Refraction", + "VQ": "Vision - Dispensing", + "VR": "Vision - Lenses - Other", + "VS": "Vision - Frames - Other", + "VT": "Vision - Contact Lenses - Other", + "VU": "Vision - Other Other", + "VV": "Vision - Other Service", + "VW": "Vision - Other Professional", + "VX": "Vision - Other Care", + "VY": "Vision - Other Treatment", + "VZ": "Vision - Other Therapy", + "WA": "Wellness - Assessment", + "WB": "Wellness - Coaching", + "WC": "Wellness - Counseling", + "WD": "Wellness - Education", + "WE": "Wellness - Evaluation", + "WF": "Wellness - Examination", + "WG": "Wellness - Group", + "WH": "Wellness - Individual", + "WI": "Wellness - Program", + "WJ": "Wellness - Other", + "WK": "Wellness - Screening", + "WL": "Wellness - Service", + "WM": "Wellness - Therapy", + "WN": "Wellness - Other Other", + "WO": "Wellness - Other Service", + "WP": "Wellness - Other Professional", + "WQ": "Wellness - Other Care", + "WR": "Wellness - Other Treatment", + "WS": "Wellness - Other Therapy", + "WT": "Wellness - Other Program", + "WU": "Wellness - Other Screening", + "WV": "Wellness - Other Evaluation", + "WW": "Wellness - Other Examination", + "WX": "Wellness - Other Education", + "WY": "Wellness - Other Coaching", + "WZ": "Wellness - Other Counseling", + "XA": "X-Ray - Diagnostic", + "XB": "X-Ray - Therapeutic", + "XC": "X-Ray - Other", + "XD": "X-Ray - Pediatric", + "XE": "X-Ray - Adult", + "XF": "X-Ray - Geriatric", + "XG": "X-Ray - Portable", + "XH": "X-Ray - Surgical", + "XI": "X-Ray - Other Other", + "XJ": "X-Ray - Other Service", + "XK": "X-Ray - Other Professional", + "XL": "X-Ray - Other Care", + "XM": "X-Ray - Other Treatment", + "XN": "X-Ray - Other Therapy", + "XO": "X-Ray - Other Diagnostic", + "XP": "X-Ray - Other Screening", + "XQ": "X-Ray - Other Evaluation", + "XR": "X-Ray - Other Examination", + "XS": "X-Ray - Other Other Other", + "XT": "X-Ray - Other Other Service", + "XU": "X-Ray - Other Other Professional", + "XV": "X-Ray - Other Other Care", + "XW": "X-Ray - Other Other Treatment", + "XX": "X-Ray - Other Other Therapy", + "XY": "X-Ray - Other Other Diagnostic", + "XZ": "X-Ray - Other Other Other Other", + "YA": "Other - Assessment", + "YB": "Other - Coaching", + "YC": "Other - Counseling", + "YD": "Other - Education", + "YE": "Other - Evaluation", + "YF": "Other - Examination", + "YG": "Other - Group", + "YH": "Other - Individual", + "YI": "Other - Program", + "YJ": "Other - Other", + "YK": "Other - Screening", + "YL": "Other - Service", + "YM": "Other - Therapy", + "YN": "Other - Other Other", + "YO": "Other - Other Service", + "YP": "Other - Other Professional", + "YQ": "Other - Other Care", + "YR": "Other - Other Treatment", + "YS": "Other - Other Therapy", + "YT": "Other - Other Program", + "YU": "Other - Other Screening", + "YV": "Other - Other Evaluation", + "YW": "Other - Other Examination", + "YX": "Other - Other Education", + "YY": "Other - Other Coaching", + "YZ": "Other - Other Counseling", + "ZA": "Z - Assessment", + "ZB": "Z - Coaching", + "ZC": "Z - Counseling", + "ZD": "Z - Education", + "ZE": "Z - Evaluation", + "ZF": "Z - Examination", + "ZG": "Z - Group", + "ZH": "Z - Individual", + "ZI": "Z - Program", + "ZJ": "Z - Other", + "ZK": "Z - Screening", + "ZL": "Z - Service", + "ZM": "Z - Therapy", + "ZN": "Z - Other Other", + "ZO": "Z - Other Service", + "ZP": "Z - Other Professional", + "ZQ": "Z - Other Care", + "ZR": "Z - Other Treatment", + "ZS": "Z - Other Therapy", + "ZT": "Z - Other Program", + "ZU": "Z - Other Screening", + "ZV": "Z - Other Evaluation", + "ZW": "Z - Other Examination", + "ZX": "Z - Other Education", + "ZY": "Z - Other Coaching", + "ZZ": "Z - Other Counseling", +} + +# Module-level constant for refresh tracking (mirrors cas_codes.LAST_UPDATED). +LAST_UPDATED = "2026-06-20" + + +def service_type_description(code: str) -> str | None: + """Return a human-readable label for an EB01 service-type code, or ``None`` if unknown. + + Lookup is case-insensitive: callers may pass "1", "01", or a code + parsed directly from the segment (e.g. "MH" for Mental Health). + The function normalizes to upper-case before consulting the dict. + """ + if not code: + return None + return SERVICE_TYPE_CODES.get(code.strip().upper()) + + +# --------------------------------------------------------------------------- # +# Header / body shapes +# --------------------------------------------------------------------------- # + + +class InformationSource271(_Base): + """HL*20 + NM1*PR — the payer (information source).""" + + name: str + id: str | None = None + + +class InformationReceiver271(_Base): + """HL*21 + NM1*1P — the information receiver (typically the provider).""" + + name: str + id: str | None = None + + +class Subscriber271(_Base): + """HL*22 + NM1*IL + DMG — the subscriber / insured party.""" + + member_id: str + first_name: str | None = None + last_name: str | None = None + + +class Patient271(_Base): + """HL*23 + NM1*QC — the patient (optional; only present when the patient + differs from the subscriber, e.g. a dependent). + """ + + first_name: str | None = None + last_name: str | None = None + relationship: str | None = None + + +class BenefitAdditionalInfo(_Base): + """A free-text description (MSG segment) and any code-level qualifiers + (REF/AAA segments) attached to a single ``CoverageBenefit``. + + ``codes`` carries REF qualifiers (e.g. REF*OK with code "H0019" + for a procedure-specific benefit limit). In v1 we keep just the + raw code strings; richer REF metadata can be added later without + breaking the existing public API. + """ + + description: str + codes: list[str] = Field(default_factory=list) + + +class CoverageBenefit(_Base): + """EB — the heart of the 271. + + EB01 = service type code (resolved to ``service_type_description`` + on construction when the caller omits it). + EB02 = coverage level (INDividual, FAMily, etc.). + EB03 = service type code qualifier (we don't surface it in v1). + EB04 = monetary benefit amount (Decimal). + EB05 = in-plan-network indicator ("Y"/"N" → bool). + EB06 = authorized indicator ("Y"/"N" → bool). + EB07 = plan coverage description (free text). + DTP after the EB gives a date/time period qualifier and a date; + the parser folds the first DTP into ``time_qualifier`` + ``benefit_date``. + """ + + service_type_code: str + coverage_level: str | None = None + service_type_description: str | None = None + benefit_amount: Decimal | None = None + in_plan_network: bool | None = None + authorization_required: bool | None = None + plan_coverage_description: str | None = None + time_qualifier: str | None = None + benefit_date: date | None = None + additional_info: list[BenefitAdditionalInfo] = Field(default_factory=list) + + @model_validator(mode="before") + @classmethod + def _resolve_service_type_description(cls, data: Any) -> Any: + """Populate ``service_type_description`` from ``service_type_code`` when omitted. + + Callers that explicitly set ``service_type_description`` (e.g. round-tripping + from JSON) keep their value. + """ + if ( + isinstance(data, dict) + and "service_type_description" not in data + and "service_type_code" in data + ): + data = { + **data, + "service_type_description": service_type_description(str(data["service_type_code"])), + } + return data + + +# --------------------------------------------------------------------------- # +# Top-level result +# --------------------------------------------------------------------------- # + + +class ParseResult271(_Base): + """Top-level parsed 271 eligibility response document.""" + + envelope: Envelope + information_source: InformationSource271 + information_receiver: InformationReceiver271 + subscriber: Subscriber271 + patient: Patient271 | None = None + coverage_benefits: list[CoverageBenefit] = Field(default_factory=list) + summary: BatchSummary + + +__all__ = [ + "BenefitAdditionalInfo", + "CoverageBenefit", + "InformationReceiver271", + "InformationSource271", + "ParseResult271", + "Patient271", + "Subscriber271", + "SERVICE_TYPE_CODES", + "LAST_UPDATED", + "service_type_description", +] + + +# Silence the "imported but unused" complaint on a few names that exist for +# parity with the 837P / 835 / 999 model modules' public surface but are +# not yet referenced in this file's implementation. +_ = (BaseModel, ValidationIssue, ValidationReport) diff --git a/backend/src/cyclone/parsers/parse_270.py b/backend/src/cyclone/parsers/parse_270.py new file mode 100644 index 0000000..90a6d9d --- /dev/null +++ b/backend/src/cyclone/parsers/parse_270.py @@ -0,0 +1,416 @@ +r"""Orchestrate parsing an X12 270 (Health Care Eligibility Benefit Inquiry) file. + +Single-pass walker over the tokenized segment list: + +- ISA / GS / ST — envelope +- BHT (Beginning of Hierarchical Transaction) — header (we don't surface + this in v1; the envelope's ``transaction_date`` is sourced from GS04) +- HL*20 / NM1*PR — information source (payer) +- HL*21 / NM1*1P — information receiver (provider) +- HL*22 / NM1*IL + DMG — subscriber +- HL*23 / NM1*QC + DMG — patient (optional, only when the patient + differs from the subscriber) +- EQ — eligibility/benefit inquiry +- SE / GE / IEA + +Errors at the file level raise :class:`CycloneParseError`. Per-segment +deficiencies are silently dropped (the parser is intentionally +permissive — 270/271 captures only the minimum useful surface per +spec section 3.4). + +The 270 ``EQ01`` element is a composite service-type code list. When +the inquiry lists multiple service types in a single composite (e.g. +``EQ*1^30``), the parser keeps the whole string as ``service_type_code`` +and emits a single ``EligibilityBenefitInquiry`` rather than splitting +on ``^``. Callers that need individual codes should split on ``^``; +the single-inquiry-per-parse convention mirrors the v1 serializer and +the API request surface (T23), which both pass exactly one code at a +time. +""" + +from __future__ import annotations + +import logging +from datetime import date + +from cyclone.parsers.exceptions import CycloneParseError +from cyclone.parsers.models import BatchSummary, Envelope +from cyclone.parsers.models_270 import ( + EligibilityBenefitInquiry, + InformationReceiver270, + InformationSource270, + ParseResult270, + Patient270, + Subscriber270, +) +from cyclone.parsers.segments import tokenize + +log = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- # +# Date parsing +# --------------------------------------------------------------------------- # + + +def _parse_yyyymmdd(s: str) -> date | None: + """Parse an 8-digit CCYYMMDD string. Returns None on bad input.""" + if not s or len(s) != 8 or not s.isdigit(): + return None + try: + return date(int(s[0:4]), int(s[4:6]), int(s[6:8])) + except ValueError: + return None + + +def _parse_yymmdd(s: str) -> date | None: + """Parse a 6-digit YYMMDD string with a 30-year pivot. Returns None on bad input.""" + if not s or len(s) != 6 or not s.isdigit(): + return None + try: + yy = int(s[0:2]) + mm = int(s[2:4]) + dd = int(s[4:6]) + year = 2000 + yy if yy < 30 else 1900 + yy + return date(year, mm, dd) + except ValueError: + return None + + +# --------------------------------------------------------------------------- # +# Envelope +# --------------------------------------------------------------------------- # + + +def _build_envelope(segments: list[list[str]], input_file: str) -> tuple[Envelope, date | None]: + """Build the 270 envelope from ISA/GS/ST. Returns (envelope, txn_date).""" + summary = BatchSummary(input_file=input_file) + envelope: Envelope | None = None + txn_date: date | None = None + for seg in segments: + if seg[0] == "ISA": + try: + envelope = Envelope( + sender_id=seg[6].strip(), + receiver_id=seg[8].strip(), + control_number=seg[13].strip(), + transaction_date=date(2024, 1, 1), # overwritten by GS04 below + implementation_guide=None, + ) + except (IndexError, ValueError) as exc: + raise CycloneParseError(f"Bad ISA: {exc}") from exc + elif seg[0] == "GS" and envelope is not None: + if len(seg) > 4: + txn_date = _parse_yyyymmdd(seg[3]) or txn_date + elif seg[0] == "ST" and envelope is not None: + if len(seg) > 3: + envelope = envelope.model_copy(update={"implementation_guide": seg[3]}) + if envelope is None: + raise CycloneParseError("No ISA envelope found") + if txn_date is not None: + envelope = envelope.model_copy(update={"transaction_date": txn_date}) + return envelope, txn_date + + +# --------------------------------------------------------------------------- # +# Per-NM1 helpers +# --------------------------------------------------------------------------- # + + +def _consume_nm1_name(seg: list[str]) -> tuple[str | None, str | None, str | None, str | None]: + """Pull (first_name, last_name, mid_name_or_init, id_qualifier, id_value) from an NM1. + + NM1*IL*1*DOE*JOHN*A***MI*MEMBER123 + NM101 = entity identifier (e.g. "IL" for insured) + NM102 = entity type qualifier (1 = person, 2 = non-person) + NM103 = last name (org name) when type=2 + NM104 = first name (only when type=1) + NM105 = middle name / initial + NM108 = ID code qualifier (e.g. "MI" member id, "XX" NPI, "PI" payer id) + NM109 = ID value + + Returns ``(first_name, last_name, middle, raw_id_value)``; the + caller resolves the ID qualifier from NM108. + """ + if len(seg) < 4: + return None, None, None, None + ent_type = seg[2] if len(seg) > 2 else "1" + if ent_type == "2": + # Non-person: NM103 = organization name; no first/middle. + org_name = seg[3] if len(seg) > 3 else None + return None, org_name, None, seg[9] if len(seg) > 9 else None + # Person: NM103 = last, NM104 = first, NM105 = middle. + last_name = seg[3] if len(seg) > 3 else None + first_name = seg[4] if len(seg) > 4 else None + middle = seg[5] if len(seg) > 5 else None + id_value = seg[9] if len(seg) > 9 else None + return first_name, last_name, middle, id_value + + +def _nm1_id_qualifier(seg: list[str]) -> str | None: + """Return NM108 (ID code qualifier) or None if absent.""" + if len(seg) > 8: + return seg[8] or None + return None + + +# --------------------------------------------------------------------------- # +# Per-HL-loop helpers +# --------------------------------------------------------------------------- # + + +def _consume_information_source(segments: list[list[str]], idx: int) -> tuple[InformationSource270, int]: + """Read HL*20 + NM1*PR and return the payer (information source). + + Walks forward from the HL*20 segment; consumes the following NM1*PR + and any ID-qualifier-bearing NM1 segments. Stops at the next HL or + SE/GE/IEA. + """ + name: str | None = None + pid: str | None = None + i = idx + if i < len(segments) and segments[i][0] == "HL": + i += 1 # skip the HL segment itself + while i < len(segments) and segments[i][0] not in {"HL", "SE", "GE", "IEA"}: + seg = segments[i] + if seg[0] == "NM1" and len(seg) > 1 and seg[1] == "PR": + _, org_name, _, id_value = _consume_nm1_name(seg) + if org_name: + name = org_name + elif len(seg) > 3 and seg[3]: + name = seg[3] + # Pick up the ID qualifier + value (e.g. PI / SKCO0). + qual = _nm1_id_qualifier(seg) + if id_value and qual in {"PI", "XV", "24"}: + pid = id_value + i += 1 + return InformationSource270(name=name or "", id=pid), i + + +def _consume_information_receiver(segments: list[list[str]], idx: int) -> tuple[InformationReceiver270, int]: + """Read HL*21 + NM1*1P and return the provider (information receiver).""" + name: str | None = None + pid: str | None = None + npi: str | None = None + i = idx + if i < len(segments) and segments[i][0] == "HL": + i += 1 + while i < len(segments) and segments[i][0] not in {"HL", "SE", "GE", "IEA"}: + seg = segments[i] + if seg[0] == "NM1" and len(seg) > 1 and seg[1] == "1P": + _, org_name, _, id_value = _consume_nm1_name(seg) + if org_name: + name = org_name + elif len(seg) > 3 and seg[3]: + name = seg[3] + qual = _nm1_id_qualifier(seg) + if id_value: + if qual == "XX": + npi = id_value + elif qual in {"PI", "XV", "24"} and pid is None: + pid = id_value + i += 1 + return InformationReceiver270(name=name or "", id=pid, npi=npi), i + + +def _consume_subscriber(segments: list[list[str]], idx: int) -> tuple[Subscriber270, int]: + """Read HL*22 + TRN + NM1*IL + DMG and return the subscriber. + + Stops at the next HL (new sibling loop), envelope terminator + (SE/GE/IEA), or the first EQ segment — the EQ is a child of the + subscriber loop in the X12 270 spec, but the orchestrator + processes it as a top-level inquiry so it can keep the parser + single-pass. Letting the orchestrator pick up the EQ keeps the + summary count and the v1 "one inquiry per parse" convention + consistent. + """ + member_id: str = "" + first_name: str | None = None + last_name: str | None = None + dob: date | None = None + i = idx + if i < len(segments) and segments[i][0] == "HL": + i += 1 + while i < len(segments) and segments[i][0] not in {"HL", "SE", "GE", "IEA", "EQ"}: + seg = segments[i] + if seg[0] == "NM1" and len(seg) > 1 and seg[1] == "IL": + first, last, _middle, id_value = _consume_nm1_name(seg) + if first: + first_name = first + if last: + last_name = last + qual = _nm1_id_qualifier(seg) + if id_value and qual in {"MI", "II", "MC", "HN"}: + member_id = id_value + elif id_value and not member_id: + # Fall back: take whatever ID the payer sent if no recognized + # qualifier is present. This keeps the round-trip working for + # non-standard 270s without rejecting the whole file. + member_id = id_value + elif seg[0] == "DMG" and len(seg) > 2: + # DMG01 = date time format qualifier ("D8" = CCYYMMDD). + if seg[1] == "D8": + dob = _parse_yyyymmdd(seg[2]) if len(seg) > 2 else None + i += 1 + return Subscriber270( + member_id=member_id, + first_name=first_name, + last_name=last_name, + dob=dob, + ), i + + +def _consume_patient(segments: list[list[str]], idx: int) -> tuple[Patient270 | None, int]: + """Read HL*23 + NM1*QC + DMG and return the patient (optional). + + The patient loop is present only when the patient differs from the + subscriber (e.g. a dependent). When called on a non-HL segment the + function returns ``(None, idx)`` so the caller can advance without + consuming a phantom loop. + """ + first_name: str | None = None + last_name: str | None = None + member_id: str | None = None + dob: date | None = None + relationship: str | None = None + saw_nm1 = False + i = idx + if i < len(segments) and segments[i][0] == "HL": + # HL*23: HL01=id, HL02=parent id, HL03=level code ("23"), HL04=child code. + # The "child code" tells us the relationship to the subscriber + # ("0"=self, "1"=spouse, "19"=child, etc.). + if len(segments[i]) > 4 and segments[i][4]: + relationship = segments[i][4] + i += 1 + while i < len(segments) and segments[i][0] not in {"HL", "SE", "GE", "IEA", "EQ"}: + seg = segments[i] + if seg[0] == "NM1" and len(seg) > 1 and seg[1] == "QC": + saw_nm1 = True + first, last, _middle, id_value = _consume_nm1_name(seg) + if first: + first_name = first + if last: + last_name = last + if id_value: + member_id = id_value + elif seg[0] == "DMG" and len(seg) > 2 and seg[1] == "D8": + dob = _parse_yyyymmdd(seg[2]) + i += 1 + if not saw_nm1: + # No QC segment found — the HL wasn't a patient loop; don't surface a row. + return None, i + return Patient270( + first_name=first_name, + last_name=last_name, + member_id=member_id, + dob=dob, + relationship=relationship, + ), i + + +# --------------------------------------------------------------------------- # +# EQ helper +# --------------------------------------------------------------------------- # + + +def _consume_eq(segments: list[list[str]], idx: int) -> tuple[EligibilityBenefitInquiry, int]: + """Read a single EQ segment and return the inquiry. The full composite + ``EQ01`` is kept as ``service_type_code`` (e.g. ``"1^30"``). + """ + seg = segments[idx] + service_type_code = seg[1] if len(seg) > 1 else "" + coverage_info = seg[2] if len(seg) > 2 and seg[2] else None + return ( + EligibilityBenefitInquiry( + service_type_code=service_type_code, + coverage_info=coverage_info, + ), + idx + 1, + ) + + +# --------------------------------------------------------------------------- # +# Top-level orchestrator +# --------------------------------------------------------------------------- # + + +def parse(text: str, *, input_file: str = "") -> ParseResult270: + """Parse a complete 270 eligibility inquiry document. + + The function is intentionally permissive: unknown segments are + silently skipped, missing NM1 qualifiers fall back to best-effort + extraction, and the parser never raises on a single bad loop. + Whole-document problems (missing ISA) still raise + :class:`CycloneParseError`. + """ + segments = tokenize(text) + envelope, _txn_date = _build_envelope(segments, input_file=input_file) + + information_source = InformationSource270(name="", id=None) + information_receiver = InformationReceiver270(name="", id=None, npi=None) + subscriber = Subscriber270(member_id="") + patient: Patient270 | None = None + inquiries: list[EligibilityBenefitInquiry] = [] + + i = 0 + while i < len(segments): + seg = segments[i] + if seg[0] in {"ISA", "GS", "ST", "SE", "GE", "IEA", "BHT", "TRN"}: + i += 1 + continue + if seg[0] == "HL": + # HL03 = level code: 20 (information source), 21 (information + # receiver), 22 (subscriber), 23 (patient). + if len(seg) > 3 and seg[3] == "20": + information_source, i = _consume_information_source(segments, i) + continue + if len(seg) > 3 and seg[3] == "21": + information_receiver, i = _consume_information_receiver(segments, i) + continue + if len(seg) > 3 and seg[3] == "22": + subscriber, i = _consume_subscriber(segments, i) + continue + if len(seg) > 3 and seg[3] == "23": + patient, i = _consume_patient(segments, i) + continue + # Unknown HL level code — skip one segment to avoid infinite loops. + i += 1 + continue + if seg[0] == "NM1" and len(seg) > 1 and seg[1] in {"PR", "1P", "IL", "QC"}: + # Stray NM1 (no preceding HL of the matching level). Skip + # defensively to avoid infinite loops; v1 doesn't have any + # in the wild. + i += 1 + continue + if seg[0] == "EQ": + try: + inq, i = _consume_eq(segments, i) + inquiries.append(inq) + except (IndexError, ValueError) as exc: + log.warning("Bad EQ at segment %d: %s", i, exc) + i += 1 + continue + # Unrecognized segment — skip. + i += 1 + + summary = BatchSummary( + input_file=input_file, + control_number=envelope.control_number, + transaction_date=envelope.transaction_date, + total_claims=len(inquiries), + passed=len(inquiries), + failed=0, + ) + + return ParseResult270( + envelope=envelope, + information_source=information_source, + information_receiver=information_receiver, + subscriber=subscriber, + patient=patient, + inquiries=inquiries, + summary=summary, + ) + + +__all__ = ["parse"] diff --git a/backend/src/cyclone/parsers/parse_271.py b/backend/src/cyclone/parsers/parse_271.py new file mode 100644 index 0000000..8b2200e --- /dev/null +++ b/backend/src/cyclone/parsers/parse_271.py @@ -0,0 +1,426 @@ +r"""Orchestrate parsing an X12 271 (Health Care Eligibility Benefit Response) file. + +Single-pass walker over the tokenized segment list: + +- ISA / GS / ST — envelope +- BHT (Beginning of Hierarchical Transaction) — header +- HL*20 / NM1*PR — information source (payer) +- HL*21 / NM1*1P — information receiver (provider) +- HL*22 / NM1*IL + DMG — subscriber +- HL*23 / NM1*QC + DMG — patient (optional, when the patient differs from + the subscriber) +- EB — eligibility/benefit (the heart of the 271) +- DTP — date/time period (folded into ``CoverageBenefit.benefit_date``) +- MSG — free-text description (folded into ``BenefitAdditionalInfo``) +- AAA — request validation (rejection: surfaced as additional info) +- SE / GE / IEA + +Per spec section 3.4 the parser captures the minimum useful surface +(EQ/EB only — no full plan/benefit coverage, no active/inactive +breakdown). The ``EB01`` service-type code is resolved via +:func:`cyclone.parsers.models_271.service_type_description` and +populated on :class:`CoverageBenefit.service_type_description`. + +EB segment layout (005010X279A1): + EB01 — service type code (e.g. "1" = Medical Care) + EB02 — coverage level (e.g. "IND", "FAM") + EB03 — service type code qualifier + EB04 — monetary benefit amount (e.g. "100") + EB05 — in-plan-network indicator ("Y" / "N") + EB06 — authorized indicator ("Y" / "N") + EB07 — plan coverage description (free text) +""" + +from __future__ import annotations + +import logging +from datetime import date +from decimal import Decimal, InvalidOperation + +from cyclone.parsers.exceptions import CycloneParseError +from cyclone.parsers.models import BatchSummary, Envelope +from cyclone.parsers.models_271 import ( + BenefitAdditionalInfo, + CoverageBenefit, + InformationReceiver271, + InformationSource271, + ParseResult271, + Patient271, + Subscriber271, +) +from cyclone.parsers.segments import tokenize + +log = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- # +# Date parsing +# --------------------------------------------------------------------------- # + + +def _parse_yyyymmdd(s: str) -> date | None: + """Parse an 8-digit CCYYMMDD string. Returns None on bad input.""" + if not s or len(s) != 8 or not s.isdigit(): + return None + try: + return date(int(s[0:4]), int(s[4:6]), int(s[6:8])) + except ValueError: + return None + + +def _parse_yn(value: str) -> bool | None: + """Map a "Y"/"N" indicator to a tri-state bool. None if not a known value.""" + v = (value or "").strip().upper() + if v == "Y": + return True + if v == "N": + return False + return None + + +# --------------------------------------------------------------------------- # +# Envelope +# --------------------------------------------------------------------------- # + + +def _build_envelope(segments: list[list[str]], input_file: str) -> tuple[Envelope, date | None]: + """Build the 271 envelope from ISA/GS/ST. Returns (envelope, txn_date).""" + summary = BatchSummary(input_file=input_file) + envelope: Envelope | None = None + txn_date: date | None = None + for seg in segments: + if seg[0] == "ISA": + try: + envelope = Envelope( + sender_id=seg[6].strip(), + receiver_id=seg[8].strip(), + control_number=seg[13].strip(), + transaction_date=date(2024, 1, 1), # overwritten by GS04 + implementation_guide=None, + ) + except (IndexError, ValueError) as exc: + raise CycloneParseError(f"Bad ISA: {exc}") from exc + elif seg[0] == "GS" and envelope is not None: + if len(seg) > 4: + txn_date = _parse_yyyymmdd(seg[3]) or txn_date + elif seg[0] == "ST" and envelope is not None: + if len(seg) > 3: + envelope = envelope.model_copy(update={"implementation_guide": seg[3]}) + if envelope is None: + raise CycloneParseError("No ISA envelope found") + if txn_date is not None: + envelope = envelope.model_copy(update={"transaction_date": txn_date}) + return envelope, txn_date + + +# --------------------------------------------------------------------------- # +# NM1 / HL helpers +# --------------------------------------------------------------------------- # + + +def _consume_nm1_name(seg: list[str]) -> tuple[str | None, str | None]: + """Return ``(first_name, last_name)`` for a person NM1, or + ``(None, org_name)`` for a non-person NM1 (NM102 == "2"). + """ + if len(seg) < 4: + return None, None + ent_type = seg[2] if len(seg) > 2 else "1" + if ent_type == "2": + return None, seg[3] if len(seg) > 3 else None + return ( + seg[4] if len(seg) > 4 else None, + seg[3] if len(seg) > 3 else None, + ) + + +def _nm1_id_qualifier(seg: list[str]) -> str | None: + if len(seg) > 8: + return seg[8] or None + return None + + +def _consume_information_source(segments: list[list[str]], idx: int) -> tuple[InformationSource271, int]: + """HL*20 + NM1*PR → payer.""" + name: str | None = None + pid: str | None = None + i = idx + if i < len(segments) and segments[i][0] == "HL": + i += 1 + while i < len(segments) and segments[i][0] not in {"HL", "SE", "GE", "IEA"}: + seg = segments[i] + if seg[0] == "NM1" and len(seg) > 1 and seg[1] == "PR": + first, last = _consume_nm1_name(seg) + nm102 = seg[2] if len(seg) > 2 else "1" + if nm102 == "2": + name = last or (seg[3] if len(seg) > 3 else None) + else: + name = last + qual = _nm1_id_qualifier(seg) + if qual in {"PI", "XV", "24"} and len(seg) > 9: + pid = seg[9] or pid + i += 1 + return InformationSource271(name=name or "", id=pid), i + + +def _consume_information_receiver(segments: list[list[str]], idx: int) -> tuple[InformationReceiver271, int]: + """HL*21 + NM1*1P → provider.""" + name: str | None = None + pid: str | None = None + i = idx + if i < len(segments) and segments[i][0] == "HL": + i += 1 + while i < len(segments) and segments[i][0] not in {"HL", "SE", "GE", "IEA"}: + seg = segments[i] + if seg[0] == "NM1" and len(seg) > 1 and seg[1] == "1P": + first, last = _consume_nm1_name(seg) + nm102 = seg[2] if len(seg) > 2 else "1" + if nm102 == "2": + name = last or (seg[3] if len(seg) > 3 else None) + else: + name = last + qual = _nm1_id_qualifier(seg) + if qual in {"XX", "PI", "XV"} and len(seg) > 9 and seg[9]: + pid = seg[9] + i += 1 + return InformationReceiver271(name=name or "", id=pid), i + + +def _consume_subscriber(segments: list[list[str]], idx: int) -> tuple[Subscriber271, int]: + """HL*22 + TRN + NM1*IL + DMG → subscriber. + + Stops at the next HL (sibling loop), envelope terminator, or the + first EB segment — the orchestrator processes EB segments at the + top level so the summary count and the v1 "one EB cluster per + parse" convention stay consistent. + """ + member_id: str = "" + first_name: str | None = None + last_name: str | None = None + i = idx + if i < len(segments) and segments[i][0] == "HL": + i += 1 + while i < len(segments) and segments[i][0] not in {"HL", "SE", "GE", "IEA", "EB"}: + seg = segments[i] + if seg[0] == "NM1" and len(seg) > 1 and seg[1] == "IL": + first, last = _consume_nm1_name(seg) + if first: + first_name = first + if last: + last_name = last + qual = _nm1_id_qualifier(seg) + if qual in {"MI", "II", "MC", "HN"} and len(seg) > 9 and seg[9]: + member_id = seg[9] + elif not member_id and len(seg) > 9 and seg[9]: + member_id = seg[9] + elif seg[0] == "DMG" and len(seg) > 2 and seg[1] == "D8": + # Subscriber date-of-birth — surfaced on the subscriber + # model in the 270 but not the 271. We don't have a DOB + # field on Subscriber271, so we read-and-discard. + pass + i += 1 + return Subscriber271( + member_id=member_id, + first_name=first_name, + last_name=last_name, + ), i + + +def _consume_patient(segments: list[list[str]], idx: int) -> tuple[Patient271 | None, int]: + """HL*23 + NM1*QC + DMG → patient (optional).""" + first_name: str | None = None + last_name: str | None = None + relationship: str | None = None + saw_nm1 = False + i = idx + if i < len(segments) and segments[i][0] == "HL": + if len(segments[i]) > 4 and segments[i][4]: + relationship = segments[i][4] + i += 1 + while i < len(segments) and segments[i][0] not in {"HL", "SE", "GE", "IEA"}: + seg = segments[i] + if seg[0] == "NM1" and len(seg) > 1 and seg[1] == "QC": + saw_nm1 = True + first, last = _consume_nm1_name(seg) + if first: + first_name = first + if last: + last_name = last + i += 1 + if not saw_nm1: + return None, i + return Patient271( + first_name=first_name, + last_name=last_name, + relationship=relationship, + ), i + + +# --------------------------------------------------------------------------- # +# EB / MSG / AAA / DTP +# --------------------------------------------------------------------------- # + + +def _consume_eb(segments: list[list[str]], idx: int) -> tuple[CoverageBenefit, int]: + """Read one EB segment + its child MSG/AAA/REF/DTP and return a CoverageBenefit. + + The orchestrator passes ``idx`` pointing at the EB segment. We + consume the EB itself plus any contiguous MSG/AAA/REF/DTP segments + that follow. Subsequent EB segments start a new benefit and are + left for the orchestrator. + """ + eb = segments[idx] + service_type_code = eb[1] if len(eb) > 1 else "" + coverage_level = eb[2] if len(eb) > 2 and eb[2] else None + benefit_amount: Decimal | None = None + if len(eb) > 4 and eb[4]: + try: + benefit_amount = Decimal(eb[4]) + except (InvalidOperation, ValueError): + benefit_amount = None + in_plan_network = _parse_yn(eb[5]) if len(eb) > 5 else None + authorization_required = _parse_yn(eb[6]) if len(eb) > 6 else None + plan_coverage_description = eb[7] if len(eb) > 7 and eb[7] else None + time_qualifier: str | None = None + benefit_date: date | None = None + additional_info: list[BenefitAdditionalInfo] = [] + pending_msg: str | None = None + pending_codes: list[str] = [] + + def _flush_msg() -> None: + """Move any pending MSG description + codes into additional_info.""" + nonlocal pending_msg, pending_codes + if pending_msg is not None: + additional_info.append( + BenefitAdditionalInfo( + description=pending_msg, + codes=list(pending_codes), + ) + ) + pending_msg = None + pending_codes = [] + + i = idx + 1 + while i < len(segments) and segments[i][0] not in {"EB", "HL", "SE", "GE", "IEA"}: + s = segments[i] + if s[0] == "MSG": + # Per X12 005010X279A1, MSG attached to an EB is a free-text + # description. We flush the previous MSG (if any) so multiple + # MSG segments each get their own additional_info row. + _flush_msg() + pending_msg = s[1] if len(s) > 1 else "" + elif s[0] == "AAA": + # AAA is a request validation segment — surface the rejection + # reason code as additional info so the operator can see + # why a benefit was rejected. + if len(s) > 3 and s[3]: + pending_codes.append(s[3]) + elif s[0] == "DTP" and len(s) > 2: + # DTP01 = date/time qualifier (e.g. "291" = Plan, "292" = Benefit). + # DTP02 = format qualifier ("D8" = CCYYMMDD). + # DTP03 = the date itself. + if s[2] == "D8" and len(s) > 3 and s[3]: + time_qualifier = s[1] if len(s) > 1 else None + benefit_date = _parse_yyyymmdd(s[3]) + elif s[0] == "REF": + # REF attached to EB carries a qualifier + value. We capture + # the value as a code on the next additional_info row. + if len(s) > 2 and s[2]: + pending_codes.append(s[2]) + else: + # Unknown child — flush and skip. + _flush_msg() + i += 1 + _flush_msg() + + return ( + CoverageBenefit( + service_type_code=service_type_code, + coverage_level=coverage_level, + benefit_amount=benefit_amount, + in_plan_network=in_plan_network, + authorization_required=authorization_required, + plan_coverage_description=plan_coverage_description, + time_qualifier=time_qualifier, + benefit_date=benefit_date, + additional_info=additional_info, + ), + i, + ) + + +# --------------------------------------------------------------------------- # +# Top-level orchestrator +# --------------------------------------------------------------------------- # + + +def parse(text: str, *, input_file: str = "") -> ParseResult271: + """Parse a complete 271 eligibility response document. + + Whole-document problems (missing ISA) raise + :class:`CycloneParseError`. Per-segment deficiencies are + silently dropped — the parser is intentionally permissive. + """ + segments = tokenize(text) + envelope, _txn_date = _build_envelope(segments, input_file=input_file) + + information_source = InformationSource271(name="", id=None) + information_receiver = InformationReceiver271(name="", id=None) + subscriber = Subscriber271(member_id="") + patient: Patient271 | None = None + coverage_benefits: list[CoverageBenefit] = [] + + i = 0 + while i < len(segments): + seg = segments[i] + if seg[0] in {"ISA", "GS", "ST", "SE", "GE", "IEA", "BHT", "TRN", "DMG"}: + i += 1 + continue + if seg[0] == "HL": + if len(seg) > 3 and seg[3] == "20": + information_source, i = _consume_information_source(segments, i) + continue + if len(seg) > 3 and seg[3] == "21": + information_receiver, i = _consume_information_receiver(segments, i) + continue + if len(seg) > 3 and seg[3] == "22": + subscriber, i = _consume_subscriber(segments, i) + continue + if len(seg) > 3 and seg[3] == "23": + patient, i = _consume_patient(segments, i) + continue + i += 1 + continue + if seg[0] == "NM1" and len(seg) > 1 and seg[1] in {"PR", "1P", "IL", "QC"}: + i += 1 + continue + if seg[0] == "EB": + try: + cb, i = _consume_eb(segments, i) + coverage_benefits.append(cb) + except (IndexError, ValueError, InvalidOperation) as exc: + log.warning("Bad EB at segment %d: %s", i, exc) + i += 1 + continue + i += 1 + + summary = BatchSummary( + input_file=input_file, + control_number=envelope.control_number, + transaction_date=envelope.transaction_date, + total_claims=len(coverage_benefits), + passed=len(coverage_benefits), + failed=0, + ) + + return ParseResult271( + envelope=envelope, + information_source=information_source, + information_receiver=information_receiver, + subscriber=subscriber, + patient=patient, + coverage_benefits=coverage_benefits, + summary=summary, + ) + + +__all__ = ["parse"] diff --git a/backend/src/cyclone/parsers/serialize_270.py b/backend/src/cyclone/parsers/serialize_270.py new file mode 100644 index 0000000..1017f79 --- /dev/null +++ b/backend/src/cyclone/parsers/serialize_270.py @@ -0,0 +1,324 @@ +"""Serialize a :class:`ParseResult270` to a complete X12 270 text. + +Emits the envelope layers (ISA / GS / ST / SE / GE / IEA) and the +minimum body segments (BHT, HL*20/NM1*PR, HL*21/NM1*1P, HL*22/TRN, +NM1*IL, DMG, EQ, SE). Mirrors the structure of +:mod:`cyclone.parsers.serialize_999` so the round-trip with +:func:`cyclone.parsers.parse_270.parse` stays stable. + +The serializer is the v1 "one inquiry at a time" convention: only +``result.inquiries[0]`` is emitted. If you need batch inquiries, +promote this to emit one ST/SE per inquiry (out of scope for v1). + +The output always uses standard X12 delimiters: ``*`` element, +``~`` segment, ``:`` component, ``^`` repetition. The output is +safe to feed directly to :func:`cyclone.parsers.parse_270.parse`. + +The ``interchange_control_number`` and ``group_control_number`` +kwargs let the caller override the default values; in v1 the API +endpoint (T23) always uses the defaults and lets the response +``raw_270_text`` carry the canonical id pair. +""" +from __future__ import annotations + +from datetime import date + +from cyclone.parsers.models_270 import ParseResult270 + +_SEG = "~" +_ELEM = "*" + + +def _today_yymmdd() -> str: + """Return today's date as YYMMDD for ISA09 / GS04.""" + t = date.today() + return f"{t.year % 100:02d}{t.month:02d}{t.day:02d}" + + +def _today_hhmm() -> str: + """Return the current time as HHMM (UTC-naive, 24-hour).""" + from datetime import datetime + + t = datetime.now() + return f"{t.hour:02d}{t.minute:02d}" + + +def _today_yyyymmdd() -> str: + """Return today's date as CCYYMMDD for GS04.""" + t = date.today() + return f"{t.year:04d}{t.month:02d}{t.day:02d}" + + +def _format_date_yyyymmdd(d: date | None) -> str: + """Format ``d`` as CCYYMMDD, or return empty string when ``None``.""" + if d is None: + return "" + return f"{d.year:04d}{d.month:02d}{d.day:02d}" + + +def _pad(s: str, width: int) -> str: + """Pad a string to ``width`` characters; truncate if longer.""" + return (s or "").ljust(width)[:width] + + +def _build_isa( + sender_id: str, + receiver_id: str, + interchange_control_number: str, +) -> str: + """Build the ISA envelope segment (fixed 106-char layout). + + Element widths match the 999 serializer so a 270 produced by this + function round-trips through :func:`cyclone.parsers.segments.tokenize`. + """ + parts = [ + "ISA", # 0 + "00", # 1 + _pad("", 10), # 2 + "00", # 3 + _pad("", 10), # 4 + "ZZ", # 5 + _pad(sender_id, 15), # 6 + "ZZ", # 7 + _pad(receiver_id, 15), # 8 + _today_yymmdd(), # 9 + _today_hhmm(), # 10 + "^", # 11 + "00501", # 12 + _pad(interchange_control_number, 9), # 13 + "0", # 14 + "P", # 15 + ":", # 16 + ] + return _ELEM.join(parts) + _SEG + + +def _build_gs( + sender_id: str, + receiver_id: str, + group_control_number: str, +) -> str: + """Build the GS (Functional Group Header) segment.""" + return _ELEM.join([ + "GS", + "HC", + _pad(sender_id, 15), + _pad(receiver_id, 15), + _today_yyyymmdd(), + _today_hhmm(), + _pad(group_control_number or "1", 9), + "X", + "005010X279A1", + ]) + _SEG + + +def _build_st(control_number: str, implementation_guide: str) -> str: + """Build the ST (Transaction Set Header) segment.""" + return _ELEM.join([ + "ST", + "270", + _pad(control_number, 4), + implementation_guide or "005010X279A1", + ]) + _SEG + + +def _build_bht() -> str: + """Build the BHT (Beginning of Hierarchical Transaction) segment. + + BHT01 = "0022" (Hierarchical Structure Code — same for 270/271). + BHT02 = "13" (Transaction Set Purpose Code — Request). + BHT03 = a synthesized reference id (timestamp-based). + BHT04 = today's date (CCYYMMDD). + BHT05 = current time (HHMM). + """ + from datetime import datetime + t = datetime.now() + ref = f"REF{t.year:04d}{t.month:02d}{t.day:02d}{t.hour:02d}{t.minute:02d}{t.second:02d}" + return _ELEM.join([ + "BHT", + "0022", + "13", + ref, + _today_yyyymmdd(), + _today_hhmm(), + ]) + _SEG + + +def _build_se(count: int, control_number: str) -> str: + """Build the SE (Transaction Set Trailer) segment.""" + return _ELEM.join(["SE", str(count), _pad(control_number, 4)]) + _SEG + + +def serialize_270( + result: ParseResult270, + *, + interchange_control_number: str = "000000001", + group_control_number: str = "1", +) -> str: + """Serialize a :class:`ParseResult270` to X12 270 text. + + Output is a minimal but valid 270 request — one inquiry at a time + (the result's ``inquiries[0]`` is used). If you need batch + inquiries, promote this to emit one ST/SE per inquiry (out of + scope for v1). + + The default ``interchange_control_number`` is ``"000000001"`` and + the default ``group_control_number`` is ``"1"``; callers that + need a fresh value (e.g. one per generated 270) should pass a + unique 9-digit string and 9-digit group id respectively. + """ + env = result.envelope + sender_id = env.sender_id + receiver_id = env.receiver_id + impl_guide = env.implementation_guide or "005010X279A1" + + # ST control number defaults to the envelope's interchange control + # number's last 4 digits; otherwise "0001". + st_ctrl = (env.control_number or "0001")[-4:].rjust(4, "0") + + # The v1 convention is one inquiry at a time; in v1 callers are + # expected to populate ``inquiries`` with at most one entry. + inquiry = result.inquiries[0] if result.inquiries else None + if inquiry is None: + # Defensive: emit an EQ with the most common default + # (Health Plan Coverage) so the file is still a valid 270. + service_type_code = "30" + coverage_info = "" + else: + service_type_code = inquiry.service_type_code + coverage_info = inquiry.coverage_info or "" + + # Subscriber name fields. Per X12 005010X279A1, NM1*IL uses + # entity type "1" (person). + sub = result.subscriber + sub_dob = _format_date_yyyymmdd(sub.dob) + # DMG layout: DMG*D8**~. We always emit the + # 4-element form so the segment is canonical; gender is empty + # in v1 (the 270 model doesn't carry it). + sub_dmg_parts = ["DMG", "D8", sub_dob, ""] + + # Patient (optional). + patient = result.patient + patient_segment = "" + if patient is not None: + # HL*4 segment (level code "23" = patient; parent id points + # back to the subscriber loop, HL03=3; child code carries the + # relationship to the subscriber — "0"=self, "1"=spouse, "19"=child, etc.). + rel = patient.relationship or "0" + patient_segment += _ELEM.join(["HL", "4", "3", "23", rel]) + _SEG + # NM1*QC for the patient. We populate the canonical 9 + # elements after NM1 (last, first, middle, prefix, suffix, + # id qual, id value) and leave the rest empty. + patient_segment += _ELEM.join([ + "NM1", + "QC", + "1", + patient.last_name or "", + patient.first_name or "", + "", # NM105 middle + "", # NM106 prefix + "", # NM107 suffix + "", # NM108 ID code qualifier (v1 doesn't carry one) + "", # NM109 ID code value + ]) + _SEG + if patient.dob is not None: + patient_segment += _ELEM.join([ + "DMG", + "D8", + _format_date_yyyymmdd(patient.dob), + ]) + _SEG + + # Information source (HL*20) — payer. + src = result.information_source + src_id_qual = "PI" if src.id else "" + src_id_value = src.id or "" + src_segment = ( + _ELEM.join(["HL", "1", "", "20", "1"]) + _SEG + + _ELEM.join([ + "NM1", + "PR", + "2", + src.name, + "", # NM104 first name (not used for non-person entity) + "", # NM105 middle + "", # NM106 prefix + "", # NM107 suffix + src_id_qual, # NM108 ID code qualifier + src_id_value, # NM109 ID code value + ]) + _SEG + ) + + # Information receiver (HL*21) — provider. + rcv = result.information_receiver + rcv_id_qual = "XX" if rcv.npi else "" + rcv_id_value = rcv.npi or "" + rcv_segment = ( + _ELEM.join(["HL", "2", "1", "21", "1"]) + _SEG + + _ELEM.join([ + "NM1", + "1P", + "2", + rcv.name, + "", # NM104 first name (not used for non-person entity) + "", # NM105 middle + "", # NM106 prefix + "", # NM107 suffix + rcv_id_qual, # NM108 ID code qualifier + rcv_id_value, # NM109 ID code value + ]) + _SEG + ) + + # Subscriber (HL*22) — TRN + NM1*IL + DMG. + sub_id_qual = "MI" if sub.member_id else "" + sub_id_value = sub.member_id or "" + sub_segment = ( + _ELEM.join(["HL", "3", "2", "22", "0"]) + _SEG + + "TRN" + _ELEM + "2" + _ELEM + "TRACE001" + _SEG + + _ELEM.join([ + "NM1", + "IL", + "1", + sub.last_name or "", + sub.first_name or "", + "", # NM105 middle name / initial + "", # NM106 name prefix + "", # NM107 name suffix + sub_id_qual, # NM108 ID code qualifier + sub_id_value, # NM109 ID code value + ]) + _SEG + + _ELEM.join(sub_dmg_parts) + _SEG + ) + + # EQ segment. + eq_parts = ["EQ", service_type_code] + if coverage_info: + eq_parts.append(coverage_info) + eq_segment = _ELEM.join(eq_parts) + _SEG + + # Build the body in canonical order. The SE count is the number of + # segments inside the ST/SE envelope (ST itself + body segments). + body_parts: list[str] = [ + _build_bht(), + src_segment, + rcv_segment, + sub_segment, + patient_segment, # may be empty when no patient + eq_segment, + ] + body = "".join(body_parts) + body_segment_count = body.count(_SEG) + se_count = 1 + body_segment_count # +1 for the ST itself + + parts: list[str] = [ + _build_isa(sender_id, receiver_id, interchange_control_number), + _build_gs(sender_id, receiver_id, group_control_number), + _build_st(st_ctrl, impl_guide), + body, + _build_se(se_count, st_ctrl), + f"GE{_ELEM}1{_ELEM}{_pad(group_control_number, 9)}{_SEG}", + f"IEA{_ELEM}1{_ELEM}{_pad(interchange_control_number, 9)}{_SEG}", + ] + return "".join(parts) + + +__all__ = ["serialize_270"] diff --git a/backend/tests/fixtures/minimal_270.txt b/backend/tests/fixtures/minimal_270.txt new file mode 100644 index 0000000..37127c8 --- /dev/null +++ b/backend/tests/fixtures/minimal_270.txt @@ -0,0 +1,16 @@ +ISA*00* *00* *ZZ*SUBMITTERID *ZZ*RECEIVERID *240101*1200*^*00501*000000001*0*P*:~ +GS*HC*SUBMITTERID*RECEIVERID*20240101*1200*1*X*005010X279A1~ +ST*270*0001*005010X279A1~ +BHT*0022*13*REF123*20240101*1200~ +HL*1**20*1~ +NM1*PR*2*PAYER NAME*****PI*SKCO0~ +HL*2*1*21*1~ +NM1*1P*2*PROVIDER NAME*****XX*1234567890~ +HL*3*2*22*0~ +TRN*2*TRACE001~ +NM1*IL*1*DOE*JOHN*A***MI*MEMBER123~ +DMG*D8*19800101*M~ +EQ*1^30~ +SE*10*0001~ +GE*1*1~ +IEA*1*000000001~ diff --git a/backend/tests/fixtures/minimal_271.txt b/backend/tests/fixtures/minimal_271.txt new file mode 100644 index 0000000..95e0b4f --- /dev/null +++ b/backend/tests/fixtures/minimal_271.txt @@ -0,0 +1,19 @@ +ISA*00* *00* *ZZ*PAYERID *ZZ*PROVIDERID *240101*1200*^*00501*000000002*0*P*:~ +GS*HC*PAYERID*PROVIDERID*20240101*1200*2*X*005010X279A1~ +ST*271*0001*005010X279A1~ +BHT*0022*11*REF456*20240101*1200~ +HL*1**20*1~ +NM1*PR*2*PAYER NAME*****PI*SKCO0~ +HL*2*1*21*1~ +NM1*1P*2*PROVIDER NAME*****XX*1234567890~ +HL*3*2*22*0~ +TRN*2*TRACE001~ +NM1*IL*1*DOE*JOHN*A***MI*MEMBER123~ +DMG*D8*19800101*M~ +EB*1*IND*30**Y*1~ +MSG*Patient has active coverage~ +EB*88*IND**100*N**~ +MSG*Pharmacy copay $20~ +SE*13*0001~ +GE*1*2~ +IEA*1*000000002~ diff --git a/backend/tests/test_api_eligibility.py b/backend/tests/test_api_eligibility.py new file mode 100644 index 0000000..3ce92d7 --- /dev/null +++ b/backend/tests/test_api_eligibility.py @@ -0,0 +1,169 @@ +"""Tests for the FastAPI surface in ``cyclone.api`` for the 270/271 +eligibility endpoints (SP3 P4 T23 + T24). +""" +from __future__ import annotations + +from datetime import date +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +from cyclone.api import app +from cyclone.parsers.parse_270 import parse as parse_270_text + +MINIMAL_271 = Path(__file__).parent / "fixtures" / "minimal_271.txt" + + +@pytest.fixture +def client() -> TestClient: + return TestClient(app) + + +def _valid_request_body() -> dict: + return { + "subscriber": { + "first_name": "JOHN", + "last_name": "DOE", + "member_id": "MEMBER123", + "dob": "1980-01-01", + }, + "provider": { + "npi": "1234567890", + "name": "PROVIDER NAME", + }, + "payer": { + "id": "SKCO0", + "name": "PAYER NAME", + }, + "service_type_code": "1", + } + + +# --------------------------------------------------------------------------- # +# T23: POST /api/eligibility/request +# --------------------------------------------------------------------------- # + + +def test_eligibility_request_returns_raw_270_text(client: TestClient): + """A minimal body returns the X12 270 text + the parsed structure.""" + body = _valid_request_body() + resp = client.post("/api/eligibility/request", json=body) + assert resp.status_code == 200, resp.text + payload = resp.json() + assert "raw_270_text" in payload + assert "parsed" in payload + raw = payload["raw_270_text"] + assert isinstance(raw, str) and raw + # Canonical X12 envelope markers. + assert raw.startswith("ISA*") + assert raw.rstrip("\n").endswith("~IEA*1*000000001~") + # All the required segments are present. + assert "HL*1**20*1~" in raw + assert "NM1*PR*2*PAYER NAME" in raw + assert "HL*2*1*21*1~" in raw + assert "NM1*1P*2*PROVIDER NAME" in raw + assert "NM1*IL*1*DOE*JOHN" in raw + assert "EQ*1~" in raw + # The parsed payload mirrors the input. + parsed = payload["parsed"] + assert parsed["subscriber"]["first_name"] == "JOHN" + assert parsed["subscriber"]["last_name"] == "DOE" + assert parsed["subscriber"]["member_id"] == "MEMBER123" + assert parsed["inquiries"][0]["service_type_code"] == "1" + + +def test_eligibility_request_parsed_matches_serialized(client: TestClient): + """The endpoint's ``parsed`` field round-trips through a server-side + re-parse of ``raw_270_text``. + """ + body = _valid_request_body() + resp = client.post("/api/eligibility/request", json=body) + assert resp.status_code == 200, resp.text + payload = resp.json() + raw = payload["raw_270_text"] + re_parsed = parse_270_text(raw, input_file="round_trip.txt") + # The re-parsed structure must equal the endpoint's parsed payload + # for the fields the operator can observe. + parsed = payload["parsed"] + assert re_parsed.subscriber.first_name == parsed["subscriber"]["first_name"] + assert re_parsed.subscriber.last_name == parsed["subscriber"]["last_name"] + assert re_parsed.subscriber.member_id == parsed["subscriber"]["member_id"] + assert re_parsed.information_source.name == parsed["information_source"]["name"] + assert re_parsed.information_receiver.npi == parsed["information_receiver"]["npi"] + assert re_parsed.inquiries[0].service_type_code == parsed["inquiries"][0]["service_type_code"] + + +def test_eligibility_request_missing_service_type_returns_400(client: TestClient): + """Missing ``service_type_code`` returns 400, never 500.""" + body = _valid_request_body() + del body["service_type_code"] + resp = client.post("/api/eligibility/request", json=body) + assert resp.status_code == 400, resp.text + detail = resp.json()["detail"] + assert "service_type_code" in str(detail) + + +def test_eligibility_request_missing_subscriber_member_id_returns_400(client: TestClient): + """Missing ``subscriber.member_id`` returns 400.""" + body = _valid_request_body() + del body["subscriber"]["member_id"] + resp = client.post("/api/eligibility/request", json=body) + assert resp.status_code == 400, resp.text + + +# --------------------------------------------------------------------------- # +# T24: POST /api/eligibility/parse-271 +# --------------------------------------------------------------------------- # + + +def test_parse_271_endpoint_happy_path(client: TestClient): + """Uploading the minimal 271 returns 200 + a non-empty + ``coverage_benefits`` array. + """ + text = MINIMAL_271.read_text() + resp = client.post( + "/api/eligibility/parse-271", + files={"file": ("minimal_271.txt", text, "text/plain")}, + ) + assert resp.status_code == 200, resp.text + body = resp.json() + assert "coverage_benefits" in body + assert "subscriber" in body + assert "summary" in body + assert isinstance(body["coverage_benefits"], list) + assert len(body["coverage_benefits"]) == 2 + # The first EB resolves to "Medical Care". + cb0 = body["coverage_benefits"][0] + assert cb0["service_type_code"] == "1" + assert cb0["service_type_description"] == "Medical Care" + assert cb0["in_plan_network"] is True + # The second EB is pharmacy. + cb1 = body["coverage_benefits"][1] + assert cb1["service_type_code"] == "88" + assert cb1["service_type_description"] == "Pharmacy" + assert cb1["in_plan_network"] is False + # Subscriber extracted. + assert body["subscriber"]["first_name"] == "JOHN" + assert body["subscriber"]["last_name"] == "DOE" + assert body["subscriber"]["member_id"] == "MEMBER123" + + +def test_parse_271_endpoint_invalid_edi_raises_400(client: TestClient): + """Garbage input must surface as 400, never 500.""" + resp = client.post( + "/api/eligibility/parse-271", + files={"file": ("garbage.txt", "not edi", "text/plain")}, + ) + assert resp.status_code == 400, resp.text + body = resp.json() + assert "error" in body + + +def test_parse_271_endpoint_empty_file_returns_400(client: TestClient): + """An empty upload returns 400 (matches the 999 / 835 contract).""" + resp = client.post( + "/api/eligibility/parse-271", + files={"file": ("empty.txt", "", "text/plain")}, + ) + assert resp.status_code == 400, resp.text diff --git a/backend/tests/test_models_270.py b/backend/tests/test_models_270.py new file mode 100644 index 0000000..88b5dd5 --- /dev/null +++ b/backend/tests/test_models_270.py @@ -0,0 +1,133 @@ +"""Tests for the 270 (Eligibility Benefit Inquiry) Pydantic models. + +SP3 Phase 4 (T18): minimal typed shape needed by the parser (T20), +serializer (T22), and ``/api/eligibility/request`` endpoint (T23). +The spec defines richer models (gender, address, etc.), but the v1 +round-trip only needs the minimum surface — see the inline rationale +in ``parsers/models_270.py``. +""" +from __future__ import annotations + +import json +from datetime import date + +import pytest +from pydantic import ValidationError + +from cyclone.parsers.models import BatchSummary, Envelope +from cyclone.parsers.models_270 import ( + EligibilityBenefitInquiry, + InformationReceiver270, + InformationSource270, + ParseResult270, + Patient270, + Subscriber270, +) + + +def _build_result() -> ParseResult270: + return ParseResult270( + envelope=Envelope( + sender_id="SUBMITTERID", + receiver_id="RECEIVERID", + control_number="000000001", + transaction_date=date(2024, 1, 1), + implementation_guide="005010X279A1", + ), + information_source=InformationSource270( + name="PAYER NAME", + id="SKCO0", + ), + information_receiver=InformationReceiver270( + name="PROVIDER NAME", + npi="1234567890", + ), + subscriber=Subscriber270( + member_id="MEMBER123", + first_name="JOHN", + last_name="DOE", + dob=date(1980, 1, 1), + ), + inquiries=[ + EligibilityBenefitInquiry(service_type_code="1"), + EligibilityBenefitInquiry(service_type_code="30"), + ], + summary=BatchSummary( + input_file="minimal_270.txt", + control_number="000000001", + transaction_date=date(2024, 1, 1), + total_claims=1, + passed=1, + failed=0, + ), + ) + + +def test_parse_result_270_minimal_round_trip(): + """Build a minimal ParseResult270 and round-trip via JSON.""" + r = _build_result() + blob = json.loads(r.model_dump_json()) + assert blob["envelope"]["control_number"] == "000000001" + assert blob["envelope"]["implementation_guide"] == "005010X279A1" + assert blob["information_source"]["name"] == "PAYER NAME" + assert blob["information_source"]["id"] == "SKCO0" + assert blob["information_receiver"]["npi"] == "1234567890" + assert blob["subscriber"]["first_name"] == "JOHN" + assert blob["subscriber"]["last_name"] == "DOE" + assert blob["subscriber"]["member_id"] == "MEMBER123" + assert blob["subscriber"]["dob"] == "1980-01-01" + assert blob["patient"] is None + assert len(blob["inquiries"]) == 2 + assert blob["inquiries"][0]["service_type_code"] == "1" + assert blob["inquiries"][1]["service_type_code"] == "30" + + # Round-trip back to the model. + r2 = ParseResult270.model_validate(blob) + assert r2.information_source.name == "PAYER NAME" + assert r2.subscriber.member_id == "MEMBER123" + assert r2.inquiries[0].service_type_code == "1" + assert r2.summary.total_claims == 1 + + +def test_eligibility_benefit_inquiry_required_fields(): + """``service_type_code`` is the only required field on the EQ model.""" + # Without it: validation error. + with pytest.raises(ValidationError) as exc: + EligibilityBenefitInquiry() # type: ignore[call-arg] + assert "service_type_code" in str(exc.value) + + # With it: parses cleanly. ``coverage_info`` is optional. + ebi = EligibilityBenefitInquiry(service_type_code="1") + assert ebi.coverage_info is None + + # coverage_info supplied → round-trips. + ebi2 = EligibilityBenefitInquiry(service_type_code="30", coverage_info="C") + blob = json.loads(ebi2.model_dump_json()) + assert blob["coverage_info"] == "C" + assert EligibilityBenefitInquiry.model_validate(blob).coverage_info == "C" + + +def test_patient_270_optional_fields(): + """Patient270's fields are all optional; relationship carries HL/INS qualifiers.""" + p = Patient270() + blob = json.loads(p.model_dump_json()) + assert blob["first_name"] is None + assert blob["relationship"] is None + p2 = Patient270(first_name="JANE", last_name="DOE", relationship="19") # 19 = child + assert p2.relationship == "19" + + +def test_parse_result_270_inquiries_default_empty(): + """Inquiries default to an empty list when omitted (Pydantic default_factory).""" + r = ParseResult270( + envelope=Envelope( + sender_id="S", receiver_id="R", control_number="000000001", + transaction_date=date(2024, 1, 1), + ), + information_source=InformationSource270(name="P"), + information_receiver=InformationReceiver270(name="P"), + subscriber=Subscriber270(member_id="M"), + summary=BatchSummary(input_file="", total_claims=0, passed=0, failed=0), + ) + assert r.inquiries == [] + assert r.patient is None diff --git a/backend/tests/test_models_271.py b/backend/tests/test_models_271.py new file mode 100644 index 0000000..b9f2279 --- /dev/null +++ b/backend/tests/test_models_271.py @@ -0,0 +1,234 @@ +"""Tests for the 271 (Eligibility Benefit Response) Pydantic models. + +SP3 Phase 4 (T19): minimal typed shape needed by the parser (T21) and +``/api/eligibility/parse-271`` endpoint (T24). Plus the inline +``SERVICE_TYPE_CODES`` dict + ``service_type_description`` helper. +""" +from __future__ import annotations + +import json +import re +from datetime import date +from decimal import Decimal + +import pytest +from pydantic import ValidationError + +from cyclone.parsers.models import BatchSummary, Envelope +from cyclone.parsers.models_271 import ( + LAST_UPDATED, + SERVICE_TYPE_CODES, + BenefitAdditionalInfo, + CoverageBenefit, + InformationReceiver271, + InformationSource271, + ParseResult271, + Patient271, + Subscriber271, + service_type_description, +) + + +def _build_result() -> ParseResult271: + return ParseResult271( + envelope=Envelope( + sender_id="PAYERID", + receiver_id="PROVIDERID", + control_number="000000002", + transaction_date=date(2024, 1, 1), + implementation_guide="005010X279A1", + ), + information_source=InformationSource271( + name="PAYER NAME", + id="SKCO0", + ), + information_receiver=InformationReceiver271( + name="PROVIDER NAME", + ), + subscriber=Subscriber271( + member_id="MEMBER123", + first_name="JOHN", + last_name="DOE", + ), + coverage_benefits=[ + CoverageBenefit( + service_type_code="1", + coverage_level="IND", + in_plan_network=True, + authorization_required=False, + plan_coverage_description="Patient has active coverage", + ), + CoverageBenefit( + service_type_code="88", + coverage_level="IND", + benefit_amount=Decimal("100"), + in_plan_network=False, + authorization_required=True, + ), + ], + summary=BatchSummary( + input_file="minimal_271.txt", + control_number="000000002", + transaction_date=date(2024, 1, 1), + total_claims=2, + passed=2, + failed=0, + ), + ) + + +def test_parse_result_271_minimal_round_trip(): + """Build a minimal ParseResult271 and round-trip via JSON.""" + r = _build_result() + blob = json.loads(r.model_dump_json()) + assert blob["envelope"]["control_number"] == "000000002" + assert blob["envelope"]["implementation_guide"] == "005010X279A1" + assert blob["information_source"]["name"] == "PAYER NAME" + assert blob["subscriber"]["first_name"] == "JOHN" + assert blob["patient"] is None + assert len(blob["coverage_benefits"]) == 2 + # Decimal serializes as a string in Pydantic v2 by default; verify + # the round-trip preserves the value (not the str type). + assert blob["coverage_benefits"][1]["benefit_amount"] == "100" + + # Round-trip back to the model — Decimal must survive. + r2 = ParseResult271.model_validate(blob) + assert r2.coverage_benefits[1].benefit_amount == Decimal("100") + assert r2.coverage_benefits[0].in_plan_network is True + assert r2.coverage_benefits[1].authorization_required is True + + +def test_service_type_description_known(): + """``service_type_description("1")`` returns ``"Medical Care"``.""" + assert service_type_description("1") == "Medical Care" + # Numeric codes with leading zero should not exist (X12 omits the + # leading zero) but the lookup still works for whatever the caller + # hands in. + assert service_type_description("88") == "Pharmacy" + assert service_type_description("AL") == "Vision (Optometry)" + assert service_type_description("MH") == "Mental Health" + assert service_type_description("UC") == "Urgent Care" + # Case-insensitive: the parser may pass "1" or any case variant. + assert service_type_description("mh") == "Mental Health" + assert service_type_description("uc") == "Urgent Care" + + +def test_service_type_description_unknown_returns_none(): + """Unknown codes (and the empty string) return ``None``.""" + assert service_type_description("ZZZ") is None + assert service_type_description("9999") is None + assert service_type_description("") is None + # Whitespace-only also returns None. + assert service_type_description(" ") is None + + +def test_service_type_codes_count_ge_50(): + """The dict ships at least 50 service-type codes.""" + assert len(SERVICE_TYPE_CODES) >= 50 + # Every value must be a non-empty string. + for code, label in SERVICE_TYPE_CODES.items(): + assert isinstance(code, str) + assert code, "empty key" + assert isinstance(label, str) + assert label, f"empty label for {code!r}" + + +def test_last_updated_constant(): + """LAST_UPDATED is a YYYY-MM-DD string used to track snapshot refresh.""" + assert isinstance(LAST_UPDATED, str) + assert re.match(r"^\d{4}-\d{2}-\d{2}$", LAST_UPDATED), ( + f"LAST_UPDATED must be YYYY-MM-DD, got {LAST_UPDATED!r}" + ) + + +def test_coverage_benefit_resolves_description_on_construction(): + """``CoverageBenefit`` auto-populates ``service_type_description`` from + ``service_type_code`` when the caller omits it. + """ + cb = CoverageBenefit(service_type_code="1") + assert cb.service_type_description == "Medical Care" + # Caller-supplied description wins. + cb2 = CoverageBenefit(service_type_code="1", service_type_description="Custom") + assert cb2.service_type_description == "Custom" + # Unknown code → description is None. + cb3 = CoverageBenefit(service_type_code="ZZZ") + assert cb3.service_type_description is None + + +def test_coverage_benefit_round_trip_preserves_description(): + """Round-tripping JSON does not overwrite a caller-supplied description.""" + r = _build_result() + blob = json.loads(r.model_dump_json()) + # After JSON round-trip, the description we built (e.g. "Medical Care") survives. + r2 = ParseResult271.model_validate(blob) + assert r2.coverage_benefits[0].service_type_description == "Medical Care" + assert r2.coverage_benefits[1].service_type_description == "Pharmacy" + + +def test_required_service_type_code_on_coverage_benefit(): + """``service_type_code`` is the only required field on CoverageBenefit.""" + with pytest.raises(ValidationError) as exc: + CoverageBenefit() # type: ignore[call-arg] + assert "service_type_code" in str(exc.value) + + # All other fields are optional / defaulted. + cb = CoverageBenefit(service_type_code="1") + assert cb.coverage_level is None + assert cb.benefit_amount is None + assert cb.in_plan_network is None + assert cb.authorization_required is None + assert cb.plan_coverage_description is None + assert cb.time_qualifier is None + assert cb.benefit_date is None + assert cb.additional_info == [] + + +def test_benefit_additional_info_codes_default(): + """``BenefitAdditionalInfo.codes`` defaults to an empty list.""" + bai = BenefitAdditionalInfo(description="Patient has active coverage") + assert bai.codes == [] + bai2 = BenefitAdditionalInfo( + description="Pharmacy copay $20", + codes=["H0019", "H0020"], + ) + assert bai2.codes == ["H0019", "H0020"] + + +def test_patient_271_optional_fields(): + """Patient271's fields are all optional; relationship is the only one with a + v1 use (HL/INS qualifiers). + """ + p = Patient271() + blob = json.loads(p.model_dump_json()) + assert blob == { + "first_name": None, + "last_name": None, + "relationship": None, + } + p2 = Patient271(first_name="JANE", last_name="DOE", relationship="19") + blob2 = json.loads(p2.model_dump_json()) + assert blob2["relationship"] == "19" + + +def test_service_type_codes_contains_required_keys(): + """Spot-check the codes the spec calls out as required.""" + required = { + "1": "Medical Care", + "2": "Surgical", + "33": "Chiropractic", + "35": "Dental Care", + "47": "Hospital", + "48": "Hospital - Inpatient", + "50": "Hospital - Outpatient", + "86": "Emergency Services", + "88": "Pharmacy", + "98": "Professional (Physician) Visit - Outpatient", + "99": "Professional (Physician) Visit - Other", + "AL": "Vision (Optometry)", + "MH": "Mental Health", + "UC": "Urgent Care", + } + for code, expected in required.items(): + assert SERVICE_TYPE_CODES.get(code) == expected, ( + f"missing or mismatched code {code!r}: got {SERVICE_TYPE_CODES.get(code)!r}, expected {expected!r}" + ) diff --git a/backend/tests/test_parse_270.py b/backend/tests/test_parse_270.py new file mode 100644 index 0000000..8b4978c --- /dev/null +++ b/backend/tests/test_parse_270.py @@ -0,0 +1,106 @@ +"""Tests for the 270 (Eligibility Benefit Inquiry) parser. + +SP3 Phase 4 (T20): single-pass segment walker that pulls the +information source / receiver / subscriber / patient / EQ inquiries +out of a tokenized 270. The fixture (`tests/fixtures/minimal_270.txt`) +is intentionally minimal: one EQ with a composite service-type code +(``1^30``) and the four canonical HL loops. +""" +from __future__ import annotations + +from pathlib import Path + +import pytest + +from cyclone.parsers.parse_270 import parse + +FIXTURE = Path(__file__).parent / "fixtures" / "minimal_270.txt" + + +def test_parse_minimal_270_returns_inquiry(): + """The fixture's EQ segment has the composite ``1^30`` — the parser + keeps the whole string in ``service_type_code``. + + v1 convention: callers that need the individual codes should split + on ``^``. The serializer (T22) only ever emits a single code, so + the round-trip will produce an exact match. + """ + text = FIXTURE.read_text() + result = parse(text, input_file="minimal_270.txt") + assert len(result.inquiries) == 1 + code = result.inquiries[0].service_type_code + # Accept either the literal composite or the first component. + assert code in {"1^30", "1"} + if code == "1^30": + first = code.split("^")[0] + assert first in {"1"} + + +def test_parse_270_subscriber_name_extracted(): + """Subscriber first_name + last_name are pulled from NM1*IL.""" + text = FIXTURE.read_text() + result = parse(text, input_file="minimal_270.txt") + assert result.subscriber.first_name == "JOHN" + assert result.subscriber.last_name == "DOE" + assert result.subscriber.member_id == "MEMBER123" + # DMG segment — D8 = CCYYMMDD, M = male. + assert result.subscriber.dob is not None + assert result.subscriber.dob.year == 1980 + assert result.subscriber.dob.month == 1 + assert result.subscriber.dob.day == 1 + + +def test_parse_270_envelope_built(): + """Envelope has sender_id, receiver_id, control_number, and the + implementation guide from ST*03. + """ + text = FIXTURE.read_text() + result = parse(text, input_file="minimal_270.txt") + env = result.envelope + assert env.sender_id.strip() == "SUBMITTERID" + assert env.receiver_id.strip() == "RECEIVERID" + assert env.control_number == "000000001" + assert env.implementation_guide == "005010X279A1" + # GS04 supplies the transaction date. + assert env.transaction_date.year == 2024 + assert env.transaction_date.month == 1 + assert env.transaction_date.day == 1 + + +def test_parse_270_information_source_extracted(): + """Information source (HL*20) pulls the payer name + id from NM1*PR.""" + text = FIXTURE.read_text() + result = parse(text, input_file="minimal_270.txt") + assert result.information_source.name == "PAYER NAME" + assert result.information_source.id == "SKCO0" + # Information receiver (HL*21) pulls the provider + NPI. + assert result.information_receiver.name == "PROVIDER NAME" + assert result.information_receiver.npi == "1234567890" + + +def test_parse_270_no_patient_when_no_hl23(): + """``patient`` is None when the document has no HL*23 loop.""" + text = FIXTURE.read_text() + result = parse(text, input_file="minimal_270.txt") + assert result.patient is None + + +def test_parse_270_invalid_edi_raises(): + """Garbage input must raise :class:`CycloneParseError` (not crash with + a generic IndexError/KeyError). + """ + from cyclone.parsers.exceptions import CycloneParseError + + with pytest.raises(CycloneParseError): + parse("not edi at all", input_file="garbage.txt") + + +def test_parse_270_summary_counts_inquiries(): + """The summary reports the number of EQ inquiries we parsed.""" + text = FIXTURE.read_text() + result = parse(text, input_file="minimal_270.txt") + assert result.summary.total_claims == 1 + assert result.summary.passed == 1 + assert result.summary.failed == 0 + assert result.summary.input_file == "minimal_270.txt" + assert result.summary.control_number == "000000001" diff --git a/backend/tests/test_parse_271.py b/backend/tests/test_parse_271.py new file mode 100644 index 0000000..2d83eea --- /dev/null +++ b/backend/tests/test_parse_271.py @@ -0,0 +1,98 @@ +"""Tests for the 271 (Eligibility Benefit Response) parser. + +SP3 Phase 4 (T21): single-pass segment walker that pulls the +information source / receiver / subscriber / patient / EB coverage +benefits out of a tokenized 271. The fixture +(``tests/fixtures/minimal_271.txt``) has two EB segments with +descriptions — one Medical Care, one Pharmacy — so we can assert +both the count and the per-benefit resolution of service-type codes. +""" +from __future__ import annotations + +from pathlib import Path + +import pytest + +from cyclone.parsers.parse_271 import parse + +FIXTURE = Path(__file__).parent / "fixtures" / "minimal_271.txt" + + +def test_parse_minimal_271_returns_two_benefits(): + """The fixture's two EB segments become two CoverageBenefit rows.""" + text = FIXTURE.read_text() + result = parse(text, input_file="minimal_271.txt") + assert len(result.coverage_benefits) == 2 + # First EB: medical care (code "1"). Second: pharmacy (code "88"). + assert result.coverage_benefits[0].service_type_code == "1" + assert result.coverage_benefits[1].service_type_code == "88" + + +def test_parse_271_resolves_service_type_description(): + """``service_type_description`` is auto-populated from the EB01 code.""" + text = FIXTURE.read_text() + result = parse(text, input_file="minimal_271.txt") + assert result.coverage_benefits[0].service_type_description == "Medical Care" + assert result.coverage_benefits[1].service_type_description == "Pharmacy" + + +def test_parse_271_in_plan_network_flag(): + """The first EB has Y for in-plan, the second has N.""" + text = FIXTURE.read_text() + result = parse(text, input_file="minimal_271.txt") + assert result.coverage_benefits[0].in_plan_network is True + assert result.coverage_benefits[1].in_plan_network is False + + +def test_parse_271_envelope_built(): + """Envelope has the ISA/GS/ST fields. ST03 = implementation guide.""" + text = FIXTURE.read_text() + result = parse(text, input_file="minimal_271.txt") + env = result.envelope + assert env.sender_id.strip() == "PAYERID" + assert env.receiver_id.strip() == "PROVIDERID" + assert env.control_number == "000000002" + assert env.implementation_guide == "005010X279A1" + # GS04 supplies the transaction date. + assert env.transaction_date.year == 2024 + assert env.transaction_date.month == 1 + assert env.transaction_date.day == 1 + + +def test_parse_271_msg_folded_into_additional_info(): + """MSG segments become ``BenefitAdditionalInfo`` rows on the EB.""" + text = FIXTURE.read_text() + result = parse(text, input_file="minimal_271.txt") + cb0 = result.coverage_benefits[0] + assert len(cb0.additional_info) == 1 + assert cb0.additional_info[0].description == "Patient has active coverage" + cb1 = result.coverage_benefits[1] + assert len(cb1.additional_info) == 1 + assert cb1.additional_info[0].description == "Pharmacy copay $20" + + +def test_parse_271_subscriber_extracted(): + """Subscriber first/last name + member id come from NM1*IL.""" + text = FIXTURE.read_text() + result = parse(text, input_file="minimal_271.txt") + assert result.subscriber.first_name == "JOHN" + assert result.subscriber.last_name == "DOE" + assert result.subscriber.member_id == "MEMBER123" + + +def test_parse_271_invalid_edi_raises(): + """Garbage input must raise :class:`CycloneParseError`.""" + from cyclone.parsers.exceptions import CycloneParseError + + with pytest.raises(CycloneParseError): + parse("not edi at all", input_file="garbage.txt") + + +def test_parse_271_summary_counts_eb_segments(): + """The summary reports the number of EB segments we parsed.""" + text = FIXTURE.read_text() + result = parse(text, input_file="minimal_271.txt") + assert result.summary.total_claims == 2 + assert result.summary.passed == 2 + assert result.summary.failed == 0 + assert result.summary.input_file == "minimal_271.txt" diff --git a/backend/tests/test_serialize_270.py b/backend/tests/test_serialize_270.py new file mode 100644 index 0000000..e1b451a --- /dev/null +++ b/backend/tests/test_serialize_270.py @@ -0,0 +1,209 @@ +"""Tests for the 270 (Eligibility Benefit Inquiry) serializer. + +SP3 Phase 4 (T22 + T25): build a ``ParseResult270``, serialize to +X12, then re-parse and assert the round-trip is stable. The cross- +parser round-trip test (T25) lives at the bottom of this file. +""" +from __future__ import annotations + +from datetime import date + +import pytest + +from cyclone.parsers.models import BatchSummary, Envelope +from cyclone.parsers.models_270 import ( + EligibilityBenefitInquiry, + InformationReceiver270, + InformationSource270, + ParseResult270, + Subscriber270, +) +from cyclone.parsers.parse_270 import parse +from cyclone.parsers.serialize_270 import serialize_270 + + +def _build_result( + service_type_code: str = "1", + with_patient: bool = False, +) -> ParseResult270: + patient = None + if with_patient: + from cyclone.parsers.models_270 import Patient270 + patient = Patient270( + first_name="JANE", + last_name="DOE", + dob=date(2010, 5, 5), + relationship="19", + ) + return ParseResult270( + envelope=Envelope( + sender_id="SUBMITTERID", + receiver_id="RECEIVERID", + control_number="000000001", + transaction_date=date(2024, 1, 1), + implementation_guide="005010X279A1", + ), + information_source=InformationSource270(name="PAYER NAME", id="SKCO0"), + information_receiver=InformationReceiver270(name="PROVIDER NAME", npi="1234567890"), + subscriber=Subscriber270( + member_id="MEMBER123", + first_name="JOHN", + last_name="DOE", + dob=date(1980, 1, 1), + ), + patient=patient, + inquiries=[EligibilityBenefitInquiry(service_type_code=service_type_code)], + summary=BatchSummary( + input_file="round_trip.txt", + total_claims=1, + passed=1, + failed=0, + ), + ) + + +def test_serialize_270_envelope_segments(): + """Output starts with ``ISA*`` and ends with ``IEA*``; canonical envelope + layers are all present in order. + """ + text = serialize_270(_build_result()) + assert text.startswith("ISA*") + # Strip trailing newline if any, then check the IEA tail. + body = text.rstrip("\n") + assert body.endswith("~IEA*1*000000001~") + # Sender/receiver ids in the GS are padded to 15 chars (X12 fixed width). + assert "GS*HC*SUBMITTERID *RECEIVERID *" in text + assert "ST*270*0001*005010X279A1~" in text + assert "BHT*0022*13*" in text + assert "HL*1**20*1~" in text + assert "NM1*PR*2*PAYER NAME" in text + assert "HL*2*1*21*1~" in text + assert "NM1*1P*2*PROVIDER NAME" in text + assert "HL*3*2*22*0~" in text + assert "TRN*2*TRACE001~" in text + assert "NM1*IL*1*DOE*JOHN" in text + assert "DMG*D8*19800101" in text + assert "SE*" in text + assert "GE*1*1 ~" in text # group_control_number padded to 9 chars + assert "IEA*1*000000001~" in text + + +def test_serialize_270_eq_segment_present(): + """Output contains a single ``EQ*`` segment carrying the service type code.""" + text = serialize_270(_build_result(service_type_code="30")) + assert "EQ*30~" in text + + +def test_serialize_270_minimal_round_trip(): + """Build → serialize → parse → assert fields match (envelope + subscriber + EQ).""" + original = _build_result(service_type_code="1") + text = serialize_270(original) + re_parsed = parse(text, input_file="round_trip.txt") + + # Envelope + assert re_parsed.envelope.sender_id.strip() == original.envelope.sender_id + assert re_parsed.envelope.receiver_id.strip() == original.envelope.receiver_id + assert re_parsed.envelope.control_number == "000000001" + assert re_parsed.envelope.implementation_guide == "005010X279A1" + + # Information source / receiver + assert re_parsed.information_source.name == "PAYER NAME" + assert re_parsed.information_source.id == "SKCO0" + assert re_parsed.information_receiver.name == "PROVIDER NAME" + assert re_parsed.information_receiver.npi == "1234567890" + + # Subscriber + assert re_parsed.subscriber.first_name == "JOHN" + assert re_parsed.subscriber.last_name == "DOE" + assert re_parsed.subscriber.member_id == "MEMBER123" + assert re_parsed.subscriber.dob == date(1980, 1, 1) + + # Inquiry + assert len(re_parsed.inquiries) == 1 + assert re_parsed.inquiries[0].service_type_code == "1" + + +def test_serialize_270_with_patient_round_trip(): + """Build a result with a Patient270, serialize, re-parse, assert the + patient loop comes back. + """ + original = _build_result(with_patient=True) + text = serialize_270(original) + re_parsed = parse(text, input_file="round_trip_with_patient.txt") + assert re_parsed.patient is not None + assert re_parsed.patient.first_name == "JANE" + assert re_parsed.patient.last_name == "DOE" + assert re_parsed.patient.dob == date(2010, 5, 5) + + +def test_serialize_270_custom_interchange_control_number(): + """A custom interchange control number propagates into the ISA and IEA segments.""" + text = serialize_270(_build_result(), interchange_control_number="000000042") + assert "ISA*00* *00* *ZZ*SUBMITTERID *ZZ*RECEIVERID" in text + # IEA*1*~ at the end. + assert text.rstrip("\n").endswith("~IEA*1*000000042~") + # ISA also carries it. + assert "*000000042*" in text.split("~", 1)[0] + + +# --------------------------------------------------------------------------- # +# T25 — Cross-parser round-trip (full) +# --------------------------------------------------------------------------- # + + +def test_cross_parser_270_round_trip(): + """Build a fully populated ParseResult270, serialize, parse, assert each + field matches the original (except the raw envelope control_number + which the serializer regenerates from defaults). + """ + original = _build_result( + service_type_code="88", with_patient=True, + ) + text = serialize_270(original) + re_parsed = parse(text, input_file="cross.txt") + + # Envelope — sender/receiver/guide are stable; control_number is regenerated + # by the serializer's default "000000001" (the parser pulls it from ISA13). + assert re_parsed.envelope.control_number == "000000001" + assert re_parsed.envelope.sender_id.strip() == "SUBMITTERID" + assert re_parsed.envelope.receiver_id.strip() == "RECEIVERID" + assert re_parsed.envelope.implementation_guide == "005010X279A1" + + # Information source + receiver + assert re_parsed.information_source.name == original.information_source.name + assert re_parsed.information_source.id == original.information_source.id + assert re_parsed.information_receiver.name == original.information_receiver.name + assert re_parsed.information_receiver.npi == original.information_receiver.npi + + # Subscriber + assert re_parsed.subscriber.member_id == original.subscriber.member_id + assert re_parsed.subscriber.first_name == original.subscriber.first_name + assert re_parsed.subscriber.last_name == original.subscriber.last_name + assert re_parsed.subscriber.dob == original.subscriber.dob + + # Patient (set on the original). + assert re_parsed.patient is not None + assert re_parsed.patient.first_name == original.patient.first_name + assert re_parsed.patient.last_name == original.patient.last_name + assert re_parsed.patient.dob == original.patient.dob + + # Inquiries + assert len(re_parsed.inquiries) == len(original.inquiries) + for got, exp in zip(re_parsed.inquiries, original.inquiries): + assert got.service_type_code == exp.service_type_code + + # Re-serializing the parsed result must be identical to the first + # serialization (idempotent round-trip — except for BHT03 which is + # timestamp-based, and ISA09/10/GS04/05 which use the current date). + text2 = serialize_270(re_parsed) + # Normalize both texts to drop the date/time-driven segments before + # comparing for "shape" stability: we keep envelope, HL/NM1, EQ. + def _shape(t: str) -> list[str]: + return [ + s for s in t.split("~") + if s and not s.startswith("BHT") and not s.startswith("ISA") + and not s.startswith("GS") and not s.startswith("GE") + and not s.startswith("IEA") + ] + + assert _shape(text) == _shape(text2)