r"""Orchestrate parsing an X12 835 ERA file into a :class:`ParseResult835`. Single-pass walker over the tokenized segment list: - ISA / GS / ST / BPR / TRN — envelope + financial + trace - Loop 1000A (N1*PR + N3/N4/PER/REF) — payer - Loop 1000B (N1*PE + N3/N4) — payee - Loop 2100 (LX + CLP + CAS + NM1 + REF + DTM + QTY) — claim payment - Loop 2110 (SVC + CAS + REF + DTM + LQ) — service payment Errors at the file level raise :class:`CycloneParseError`. Per-segment deficiencies are recorded in :class:`ValidationReport` and never abort the whole batch. """ from __future__ import annotations import logging from datetime import date from decimal import Decimal from typing import Any from cyclone.parsers.exceptions import CycloneParseError from cyclone.parsers.models import Address, BatchSummary, Envelope from cyclone.parsers.models_835 import ( ClaimAdjustment, ClaimPayment, FinancialInfo, ParseResult835, Payer835, Payee835, ReassociationTrace, ServicePayment, claim_status_label, ) from cyclone.parsers.payer import PayerConfig835 from cyclone.parsers.segments import tokenize log = logging.getLogger(__name__) # --------------------------------------------------------------------------- # # Date parsing # --------------------------------------------------------------------------- # def _parse_ccyymmdd(yymmdd: str) -> date: """Parse a CCYYMMDD (8-char) or YY?? (6-char) date string.""" if not yymmdd: raise ValueError("empty date") if len(yymmdd) == 8: return date(int(yymmdd[0:4]), int(yymmdd[4:6]), int(yymmdd[6:8])) if len(yymmdd) == 6: yy = int(yymmdd[0:2]) mm = int(yymmdd[2:4]) dd = int(yymmdd[4:6]) # Pivot year 30: 00-29 = 2000s, 30-99 = 1900s. year = 2000 + yy if yy < 30 else 1900 + yy return date(year, mm, dd) raise ValueError(f"unexpected date length: {yymmdd!r}") def _safe_date(s: str) -> date | None: try: return _parse_ccyymmdd(s) if s else None except (ValueError, IndexError): return None # --------------------------------------------------------------------------- # # Envelope / BPR / TRN # --------------------------------------------------------------------------- # def _build_envelope(segments: list[list[str]], input_file: str) -> tuple[Envelope, BatchSummary]: """Build the 835 envelope from ISA/GS/ST. Raises on missing/malformed ISA.""" summary = BatchSummary(input_file=input_file) envelope: Envelope | 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(2026, 1, 1), # overwritten by BPR implementation_guide=None, ) except (IndexError, ValueError) as exc: raise CycloneParseError(f"Bad ISA: {exc}") from exc 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") return envelope, summary def _consume_bpr(segments: list[list[str]], idx: int) -> tuple[FinancialInfo, int]: seg = segments[idx] try: handling = seg[1] if len(seg) > 1 else "" paid = Decimal(seg[2]) if len(seg) > 2 and seg[2] else Decimal("0") credit_debit = seg[3] if len(seg) > 3 else "" method = seg[4] if len(seg) > 4 and seg[4] else None # BPR10 = Payer Identifier (Origin Company ID), per the CO Medicaid # guide: "81-1725341" (TXIX) or "84-0644739" (BHA). payer_tax = seg[10] if len(seg) > 10 and seg[10] else None # BPR16 = Effective Entry Date (CCYYMMDD). The segment is sparse — # positions 11..15 (BPR11..BPR15) are usually empty, so we look for # any 8-digit CCYYMMDD token from index 11 onward. payment_date = None for k in range(11, len(seg)): if seg[k] and len(seg[k]) == 8 and seg[k].isdigit(): payment_date = _safe_date(seg[k]) if payment_date is not None: break except (IndexError, ValueError) as exc: raise CycloneParseError(f"Bad BPR: {exc}", segment_index=idx) from exc return ( FinancialInfo( handling_code=handling, paid_amount=paid, credit_debit_flag=credit_debit, payment_method=method, payer_tax_id=payer_tax, payment_date=payment_date, ), idx + 1, ) def _consume_trn(segments: list[list[str]], idx: int) -> tuple[ReassociationTrace, int]: seg = segments[idx] trace_type = seg[1] if len(seg) > 1 else "" trace_number = seg[2] if len(seg) > 2 else "" originating = seg[3] if len(seg) > 3 else "" payer_id = seg[4] if len(seg) > 4 and seg[4] else None return ( ReassociationTrace( trace_type_code=trace_type, trace_number=trace_number, originating_company_id=originating, payer_id=payer_id, ), idx + 1, ) # --------------------------------------------------------------------------- # # Loop 1000A (payer) / 1000B (payee) # --------------------------------------------------------------------------- # def _consume_address(segments: list[list[str]], idx: int) -> tuple[Address | None, int]: """Walk N3/N4 (if present) and return the assembled Address, plus new idx.""" addr: Address | None = None while idx < len(segments) and segments[idx][0] in {"N3", "N4"}: seg = segments[idx] if seg[0] == "N3": line1 = seg[1] if len(seg) > 1 else "" line2 = seg[2] if len(seg) > 2 else None addr = (addr or Address(line1=line1, city="", state="", zip="")).model_copy( update={"line1": line1, "line2": line2} ) elif seg[0] == "N4": city = seg[1] if len(seg) > 1 else "" state = seg[2] if len(seg) > 2 else "" zip_ = seg[3] if len(seg) > 3 else "" addr = (addr or Address(line1="", city=city, state=state, zip=zip_)).model_copy( update={"city": city, "state": state, "zip": zip_} ) idx += 1 return addr, idx def _consume_1000a(segments: list[list[str]], idx: int) -> tuple[Payer835, int]: """Read N1*PR + N3 + N4 + PER* + REF* up to next N1 or CLP/LX/SE.""" name = "" pid: str | None = None contact_url: str | None = None # First segment is N1*PR seg = segments[idx] if seg[0] == "N1" and len(seg) > 1 and seg[1] == "PR": # NM103 is the name (or organization name for non-person entities). name = seg[3] if len(seg) > 3 and seg[3] else (seg[2] if len(seg) > 2 else "") # NM108/NM109 carry the ID code qualifier and value. The CO guide # documents XV for the Health Plan ID, but other payers may use # PI / 24 / etc., so we accept any qualifier. for j, qual in enumerate(seg): if qual in {"XV", "PI", "24"} and j + 1 < len(seg): pid = seg[j + 1] break idx += 1 addr: Address | None = None while idx < len(segments) and segments[idx][0] not in {"N1", "LX", "CLP", "SE"}: seg = segments[idx] if seg[0] in {"N3", "N4"}: addr, idx = _consume_address(segments, idx) continue if seg[0] == "PER": # PER03 is the communication number qualifier; PER04 is the # value. The CO guide documents PER04 as the URL but in real # 835s the URL is tagged with PER03=UR. We scan for the UR # qualifier to be safe. for j in range(1, len(seg) - 1, 2): if seg[j] == "UR" and j + 1 < len(seg): contact_url = seg[j + 1] break # Fall back to PER04 directly if it looks like a URL (matches # the literal CO guide wording). if contact_url is None and len(seg) > 4 and seg[4]: value = seg[4] if "@" in value or "http" in value or "." in value: contact_url = value idx += 1 return Payer835(name=name, id=pid, address=addr, contact_url=contact_url), idx def _consume_1000b(segments: list[list[str]], idx: int) -> tuple[Payee835, int]: """Read N1*PE + N3 + N4 up to next N1 or CLP/LX/SE.""" name = "" npi = "" seg = segments[idx] if seg[0] == "N1" and len(seg) > 1 and seg[1] == "PE": name = seg[3] if len(seg) > 3 and seg[3] else (seg[2] if len(seg) > 2 else "") for j, qual in enumerate(seg): if qual == "XX" and j + 1 < len(seg): npi = seg[j + 1] break idx += 1 addr: Address | None = None while idx < len(segments) and segments[idx][0] not in {"N1", "LX", "CLP", "SE"}: seg = segments[idx] if seg[0] in {"N3", "N4"}: addr, idx = _consume_address(segments, idx) continue idx += 1 return Payee835(name=name, npi=npi, address=addr), idx # --------------------------------------------------------------------------- # # Loop 2100 (claim payment) and 2110 (service payment) # --------------------------------------------------------------------------- # def _cas_segments(segments: list[list[str]], idx: int) -> tuple[list[ClaimAdjustment], int]: """Read consecutive CAS segments starting at ``idx`` and return aggregated adjustments.""" adjustments: list[ClaimAdjustment] = [] while idx < len(segments) and segments[idx][0] == "CAS": seg = segments[idx] try: group = seg[1] if len(seg) > 1 else "" # Each CAS carries up to 6 reason/amount/quantity triplets # (elements 2..19). We expand each one into its own row. # Indexes 2,3,4 | 5,6,7 | 8,9,10 | 11,12,13 | 14,15,16 | 17,18,19 triplet_starts = [2, 5, 8, 11, 14, 17] for start in triplet_starts: if len(seg) <= start: break reason = seg[start] if not reason: continue amount_s = seg[start + 1] if len(seg) > start + 1 else "" qty_s = seg[start + 2] if len(seg) > start + 2 else "" amount = Decimal(amount_s) if amount_s else Decimal("0") quantity = Decimal(qty_s) if qty_s else None adjustments.append( ClaimAdjustment( group_code=group, reason_code=reason, amount=amount, quantity=quantity, ) ) except (IndexError, ValueError) as exc: log.warning("Bad CAS at segment %d: %s", idx, exc) idx += 1 return adjustments, idx def _consume_service_payment(segments: list[list[str]], idx: int, line_no: int) -> tuple[ServicePayment, int, list[list[str]]]: """Read SVC + its child segments. Returns (ServicePayment, new_idx, raw_segments). The third return value is the list of raw segments consumed (SVC itself plus any DTM/REF/CAS children) so the caller can fold them into the claim's raw_segments. :class:`ServicePayment` does not carry a ``raw_segments`` field per the 835 spec, so we surface them at the claim level instead. """ seg = segments[idx] raw: list[list[str]] = [seg] qualifier = "" code = "" modifiers: list[str] = [] if len(seg) > 1 and seg[1]: svc01 = seg[1].split(":") if len(svc01) > 0: qualifier = svc01[0] if len(svc01) > 1: code = svc01[1] modifiers = [m for m in svc01[2:] if m] charge = Decimal(seg[2]) if len(seg) > 2 and seg[2] else Decimal("0") payment = Decimal(seg[3]) if len(seg) > 3 and seg[3] else Decimal("0") units: Decimal | None = None unit_type: str | None = None # X12 005010X221A1: SVC04 = Unit or Basis for Measurement Code # (UN, MJ, DA, ...); SVC05 = Service Unit Count. if len(seg) > 4 and seg[4]: unit_type = seg[4] if len(seg) > 5 and seg[5]: try: units = Decimal(seg[5]) except Exception: units = None # Some payers omit SVC04 but still mean "UN" when SVC05 carries a # count. Be conservative: only default to UN if no other value was # set above. if unit_type is None and units is not None: unit_type = "UN" service_date: date | None = None ref_benefit_plan: str | None = None adjustments: list[ClaimAdjustment] = [] idx += 1 while idx < len(segments) and segments[idx][0] not in {"SVC", "LX", "CLP", "SE"}: s = segments[idx] raw.append(s) if s[0] == "DTM" and len(s) > 2 and s[1] == "472": service_date = _safe_date(s[2]) if s[2] else None elif s[0] == "REF" and len(s) > 2 and s[1] == "0K": ref_benefit_plan = s[2] elif s[0] == "CAS": cas, idx = _cas_segments(segments, idx) raw.extend(c[0] for c in cas if False) # CAS rows are already in adjustments; nothing more to do adjustments.extend(cas) continue idx += 1 return ( ServicePayment( line_number=line_no, procedure_qualifier=qualifier, procedure_code=code, modifiers=modifiers, charge=charge, payment=payment, units=units, unit_type=unit_type, service_date=service_date, adjustments=adjustments, ref_benefit_plan=ref_benefit_plan, ), idx, raw, ) def _consume_claim_payment(segments: list[list[str]], idx: int) -> tuple[ClaimPayment, int]: """Read one CLP and all its child segments (CAS / NM1 / REF / DTM / QTY / SVC).""" clp = segments[idx] raw: list[list[str]] = [clp] try: pcn = clp[1] if len(clp) > 1 else "" status = clp[2] if len(clp) > 2 else "" total_charge = Decimal(clp[3]) if len(clp) > 3 and clp[3] else Decimal("0") total_paid = Decimal(clp[4]) if len(clp) > 4 and clp[4] else Decimal("0") patient_resp = Decimal(clp[5]) if len(clp) > 5 and clp[5] else None claim_filing = clp[6] if len(clp) > 6 and clp[6] else None original_claim_id = clp[7] if len(clp) > 7 and clp[7] else None facility_type = clp[8] if len(clp) > 8 and clp[8] else None # CLP09 is a single element (no composite qualifier on the 835 side). frequency = clp[9] if len(clp) > 9 and clp[9] else None except (IndexError, ValueError) as exc: raise CycloneParseError(f"Bad CLP: {exc}", segment_index=idx) from exc service_payments: list[ServicePayment] = [] ref_benefit_plan: str | None = None per_diem: Decimal | None = None status_label = claim_status_label(status) idx += 1 while idx < len(segments) and segments[idx][0] not in {"CLP", "SE"}: s = segments[idx] # Only consume this CLP's children; an LX starts a new 2100 loop. if s[0] == "LX": # Skip the LX and expect an SVC; if absent, stop. raw.append(s) idx += 1 if idx < len(segments) and segments[idx][0] == "SVC": line_no = ( int(s[1]) if len(s) > 1 and s[1].isdigit() else len(service_payments) + 1 ) sp, idx, svc_raw = _consume_service_payment(segments, idx, line_no) raw.extend(svc_raw) service_payments.append(sp) continue if s[0] == "SVC": sp, idx, svc_raw = _consume_service_payment(segments, idx, len(service_payments) + 1) raw.extend(svc_raw) service_payments.append(sp) continue raw.append(s) if s[0] == "CAS": cas, idx = _cas_segments(segments, idx) # Top-level claim adjustments are aggregated into a synthesized # ServicePayment with line_number=0 if there are no SVC rows. # Per the spec, ClaimPayment doesn't carry adjustments directly, # so we record them on the first service payment if any, # otherwise drop them on the floor (rare in practice). if service_payments: first = service_payments[0] service_payments[0] = first.model_copy( update={"adjustments": first.adjustments + cas} ) continue if s[0] == "REF" and len(s) > 2 and s[1] == "CE": ref_benefit_plan = s[2] elif s[0] == "QTY" and len(s) > 2 and s[1] == "CA": try: per_diem = Decimal(s[2]) if s[2] else None except ValueError: per_diem = None elif s[0] == "NM1": # Patient (QC) / service-provider (1P) — captured in raw_segments. # The 835 spec doesn't require a structured patient model in v1. pass elif s[0] == "DTM": # Claim-level dates — captured in raw_segments. pass idx += 1 return ( ClaimPayment( payer_claim_control_number=pcn, status_code=status, status_label=status_label, total_charge=total_charge, total_paid=total_paid, patient_responsibility=patient_resp, claim_filing_indicator=claim_filing, original_claim_id=original_claim_id, facility_type=facility_type, frequency_code=frequency, per_diem_covered_days=per_diem, ref_benefit_plan=ref_benefit_plan, service_payments=service_payments, raw_segments=raw, ), idx, ) # --------------------------------------------------------------------------- # # Top-level orchestrator # --------------------------------------------------------------------------- # def parse(text: str, payer_config: PayerConfig835, input_file: str = "") -> ParseResult835: # noqa: ARG001 — payer_config reserved for future use """Parse a complete 835 ERA document and return a :class:`ParseResult835`. ``payer_config`` is accepted for symmetry with the 837P parser and for future payer-aware heuristics; in v1 the structural validation runs against the full result via :func:`cyclone.parsers.validator_835.validate`. """ segments = tokenize(text) envelope, summary = _build_envelope(segments, input_file=input_file) financial: FinancialInfo | None = None trace: ReassociationTrace | None = None payer = Payer835(name="", id=None, address=None, contact_url=None) payee = Payee835(name="", npi="", address=None) claims: list[ClaimPayment] = [] i = 0 n_bprs = 0 # CO Medicaid sends multiple BPRs in one 835; we sum them. # Pre-envelope segments (ISA/GS) are already consumed by _build_envelope. # Walk forward looking for the header (BPR/TRN), then loops. while i < len(segments): seg = segments[i] if seg[0] == "ST": # Skip the ST control segment. i += 1 continue if seg[0] == "BPR": new_fin, i = _consume_bpr(segments, i) if financial is None: financial = new_fin else: # CO Medicaid "split payment" pattern: multiple BPRs whose # BPR02 amounts sum to the total paid. X12 835 only allows # one BPR per transaction set; we accept the data and warn. # Keep the first BPR's handling_code, tax_id, payment_date; # only the paid_amount is additive. financial = financial.model_copy(update={ "paid_amount": financial.paid_amount + new_fin.paid_amount, }) n_bprs += 1 continue if seg[0] == "TRN": trace, i = _consume_trn(segments, i) continue if seg[0] == "N1" and len(seg) > 1 and seg[1] == "PR": payer, i = _consume_1000a(segments, i) continue if seg[0] == "N1" and len(seg) > 1 and seg[1] == "PE": payee, i = _consume_1000b(segments, i) continue if seg[0] == "CLP": try: claim, i = _consume_claim_payment(segments, i) claims.append(claim) except CycloneParseError: raise except Exception as exc: # pragma: no cover - safety net log.warning("Claim parse failed at segment %d: %s", i, exc) i += 1 continue if seg[0] == "LX" and not claims and not payee.npi: # Defensive: a stray LX before any CLP (no 1000B seen) is ignored. i += 1 continue i += 1 if financial is None: raise CycloneParseError("No BPR (Financial Information) segment found") if trace is None: raise CycloneParseError("No TRN (Reassociation Trace) segment found") # Update the envelope's transaction date from the BPR payment date if available. if financial.payment_date is not None: envelope = envelope.model_copy(update={"transaction_date": financial.payment_date}) summary = summary.model_copy(update={ "control_number": envelope.control_number, "transaction_date": envelope.transaction_date, "total_claims": len(claims), }) return ParseResult835( envelope=envelope, financial_info=financial, trace=trace, payer=payer, payee=payee, claims=claims, multi_bpr=(n_bprs > 1), summary=summary, validation=None, ) __all__ = ["parse"]