diff --git a/backend/README.md b/backend/README.md index 6663183..46f9d32 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,8 +1,9 @@ -# Cyclone Backend — X12 837P Parser +# Cyclone Backend — X12 837P / 835 ERA Parser -Python module + CLI for parsing X12 837P professional claim files into one -validated JSON per claim. Output is consumed by the Cyclone Vite/React -frontend via a FastAPI service (see **REST API** below). +Python module + CLI for parsing X12 837P professional claim files and X12 +835 ERA (Health Care Claim Payment/Advice) files into one validated JSON +per claim / payout. Output is consumed by the Cyclone Vite/React frontend +via a FastAPI service (see **REST API** below). ## Install @@ -80,7 +81,8 @@ python -m cyclone serve | Method | Path | Purpose | | ------ | ---------------- | -------------------------------------------------- | | GET | `/api/health` | Liveness probe: `{"status": "ok", "version": ...}` | -| POST | `/api/parse-837` | Upload an X12 file, get parsed claims back | +| POST | `/api/parse-837` | Upload an X12 837P file, get parsed claims back | +| POST | `/api/parse-835` | Upload an X12 835 ERA file, get parsed payouts back | `POST /api/parse-837` accepts `multipart/form-data` with a single `file` field. Optional query parameters: @@ -152,3 +154,90 @@ while (true) { - Design: `docs/superpowers/specs/2026-06-19-cyclone-837p-parser-design.md` - Plan: `docs/superpowers/plans/2026-06-19-cyclone-837p-parser.md` + +## 835 ERA parser + +The 835 ERA parser mirrors the 837P architecture but is a separate +module (`cyclone.parsers.parse_835`) with its own models +(`cyclone.parsers.models_835`), writer (`cyclone.parsers.writer_835`), +validator (`cyclone.parsers.validator_835`), and `PayerConfig835` preset. + +It parses one 835 batch into: + +- `envelope` — ISA / GS / ST control segments +- `financial_info` — BPR (total paid, handling code, payer tax id, date) +- `trace` — TRN (reassociation trace number + originating company id) +- `payer` — Loop 1000A (NM1*PR + N3/N4/PER contact URL) +- `payee` — Loop 1000B (NM1*PE + N3/N4) +- `claims` — one `ClaimPayment` per Loop 2100 CLP, with embedded + `ServicePayment` rows for each Loop 2110 SVC +- `summary` — same `BatchSummary` used by 837P + +### 835 CLI + +```bash +python -m cyclone.cli parse-835 path/to/835.txt --output-dir ./payouts +``` + +Options: +- `--payer {co_medicaid_835,generic_835}` — default `co_medicaid_835` +- `--strict` — promote warnings to errors +- `--include-raw-segments / --no-raw-segments` — default: include +- `--log-level {DEBUG,INFO,WARNING,ERROR}` + +Output: + +``` +payouts/ +├── payout-991102984-20260617-CLM001.json # one per claim, with envelope/financial/trace/payer/payee embedded +├── payout-991102984-20260617-CLM002.json +└── summary.json +``` + +### 835 programmatic use + +```python +from pathlib import Path +from cyclone.parsers.parse_835 import parse +from cyclone.parsers.payer import PayerConfig835 +from cyclone.parsers.validator_835 import validate +from cyclone.parsers.writer_835 import write_outputs_835 + +result = parse(Path("input.txt").read_text(), PayerConfig835.co_medicaid_835()) +report = validate(result, PayerConfig835.co_medicaid_835()) +print(f"passed={report.passed} errors={len(report.errors)}") +write_outputs_835(result, Path("./payouts")) +``` + +### 835 payout balancing + +The 835 validator enforces two monetary-balancing rules: + +- `R835_BAL_BPR_vs_CLP04` — the BPR02 total paid must equal the sum of + `CLP04` (total claim paid) across all claims in the batch. Tolerance + is **$0.01**. +- `R835_BAL_CLP04_vs_SVC03` — each `CLP04` must equal the sum of its + embedded `SVC03` service payment amounts. Tolerance is **$0.01**. + +These rules catch the common "BPR doesn't match the sum of the claims" +mismatch (often caused by a missing or duplicated claim in the batch). + +### 835 REST API + +`POST /api/parse-835` mirrors `POST /api/parse-837` exactly: + +- `multipart/form-data` with a single `file` field +- Query params: `payer` (default `co_medicaid_835`), `include_raw_segments` + (default `true`), `strict` (default `false`) +- Content negotiation: `Accept: application/json` → single `ParseResult835` + object; default → NDJSON stream with `envelope → financial_info → trace + → payer → payee → claim_payment (×N) → summary` lines +- `400` on `CycloneParseError` or unknown `payer` +- `422` when `validate()` reports errors (body still includes the full + `ParseResult835` so the UI can show the issues) + +```bash +curl -X POST http://localhost:8000/api/parse-835 \ + -F "file=@./samples/co_835.txt" \ + -H "Accept: application/json" +``` diff --git a/backend/src/cyclone/api.py b/backend/src/cyclone/api.py index 3835168..875fea4 100644 --- a/backend/src/cyclone/api.py +++ b/backend/src/cyclone/api.py @@ -1,13 +1,13 @@ -"""FastAPI HTTP surface for the Cyclone 837P parser. +"""FastAPI HTTP surface for the Cyclone 837P and 835 ERA parsers. The frontend (Vite/React on http://localhost:5173) uploads an X12 file via -``POST /api/parse-837`` and receives either: +``POST /api/parse-837`` or ``POST /api/parse-835`` and receives either: -* a single ``ParseResult`` JSON object when it sends ``Accept: application/json``, - or -* a stream of newline-delimited JSON objects (``application/x-ndjson``) — one - envelope, one line per claim, then one summary — so the UI can render - claims incrementally as they're produced. +* a single ``ParseResult`` (or ``ParseResult835``) JSON object when it + sends ``Accept: application/json``, or +* a stream of newline-delimited JSON objects (``application/x-ndjson``) + — one envelope, one line per claim/payout, then one summary — so the + UI can render records incrementally as they're produced. CORS is configured to allow the Vite dev origin (``http://localhost:5173``) plus GET/POST with any header. @@ -27,8 +27,11 @@ from pydantic import ValidationError from cyclone import __version__ from cyclone.parsers.exceptions import CycloneParseError from cyclone.parsers.models import ClaimOutput, ParseResult -from cyclone.parsers.payer import PayerConfig +from cyclone.parsers.models_835 import ParseResult835 +from cyclone.parsers.payer import PayerConfig, PayerConfig835 from cyclone.parsers.parse_837 import parse +from cyclone.parsers.parse_835 import parse as parse_835 +from cyclone.parsers.validator_835 import validate as validate_835 log = logging.getLogger(__name__) @@ -40,12 +43,17 @@ PAYER_FACTORIES: dict[str, Any] = { "generic_837p": PayerConfig.generic_837p, } +PAYER_FACTORIES_835: dict[str, Any] = { + "co_medicaid_835": PayerConfig835.co_medicaid_835, + "generic_835": PayerConfig835.generic_835, +} + VITE_DEV_ORIGIN = "http://localhost:5173" app = FastAPI( - title="Cyclone 837P Parser API", + title="Cyclone 837P / 835 Parser API", version=__version__, - description="Upload X12 837P files and receive parsed claims as JSON or NDJSON.", + description="Upload X12 837P and 835 files and receive parsed records as JSON or NDJSON.", ) app.add_middleware( @@ -69,6 +77,18 @@ def _resolve_payer(name: str) -> PayerConfig: return PAYER_FACTORIES[name]() +def _resolve_payer_835(name: str) -> PayerConfig835: + if name not in PAYER_FACTORIES_835: + raise HTTPException( + status_code=400, + detail={ + "error": "Unknown payer", + "detail": f"Unknown payer {name!r}. Choose from: {', '.join(PAYER_FACTORIES_835)}", + }, + ) + return PAYER_FACTORIES_835[name]() + + def _strict_rewrite(result: ParseResult) -> ParseResult: """Promote warnings to errors (mirrors the CLI's --strict).""" claims: list[ClaimOutput] = [] @@ -201,4 +221,126 @@ def _ndjson_stream(result: ParseResult) -> Iterator[bytes]: yield (json.dumps({"type": "summary", "data": json.loads(result.summary.model_dump_json())}) + "\n").encode("utf-8") +# --------------------------------------------------------------------------- # +# 835 ERA (Health Care Claim Payment/Advice) +# --------------------------------------------------------------------------- # + + +def _strict_rewrite_835(result: ParseResult835) -> ParseResult835: + """Promote warnings to errors (mirrors the CLI's --strict).""" + if result.validation is None: + return result + report = result.validation + promoted = [i.model_copy(update={"severity": "error"}) for i in report.warnings] + new_errors = report.errors + promoted + new_report = report.model_copy(update={"errors": new_errors, "passed": not new_errors}) + passed = 1 if new_report.passed else 0 + failed = 1 if not new_report.passed else 0 + new_summary = result.summary.model_copy( + update={"passed": passed, "failed": failed} + ) + return result.model_copy(update={"validation": new_report, "summary": new_summary}) + + +def _drop_raw_segments_835(result: ParseResult835) -> ParseResult835: + """Return a copy of ``result`` with ``raw_segments`` cleared on every claim.""" + claims = [c.model_copy(update={"raw_segments": []}) for c in result.claims] + return result.model_copy(update={"claims": claims}) + + +def _has_835_validation_errors(result: ParseResult835) -> bool: + return result.validation is not None and not result.validation.passed + + +@app.post("/api/parse-835") +async def parse_835_endpoint( + request: Request, + file: UploadFile = File(...), + payer: str = Query("co_medicaid_835"), + include_raw_segments: bool = Query(True), + strict: bool = Query(False), +) -> Any: + 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)}, + ) + + config = _resolve_payer_835(payer) + + try: + result = parse_835(text, config, 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") + return JSONResponse( + status_code=500, + content={"error": "Internal server error", "detail": str(exc)}, + ) + + # Always run the validator; attach the report so the JSON path can + # surface it and the NDJSON path can fold the counts into the summary. + # 835 validation is batch-level, so pass/fail applies uniformly to every + # claim payment in the batch (passed=N or 0, failed=0 or N). + report = validate_835(result, config) + n = len(result.claims) + claim_ids = [c.payer_claim_control_number for c in result.claims] + if report.passed: + passed, failed, failed_claim_ids = n, 0, [] + else: + passed, failed, failed_claim_ids = 0, n, claim_ids + result = result.model_copy(update={ + "validation": report, + "summary": result.summary.model_copy(update={ + "passed": passed, + "failed": failed, + "failed_claim_ids": failed_claim_ids, + }), + }) + + if strict: + result = _strict_rewrite_835(result) + if not include_raw_segments: + result = _drop_raw_segments_835(result) + + if _has_835_validation_errors(result): + return JSONResponse( + status_code=422, + content=json.loads(result.model_dump_json()), + ) + + if _client_wants_json(request): + return JSONResponse(content=json.loads(result.model_dump_json())) + + # Default: NDJSON stream. + return StreamingResponse( + _ndjson_stream_835(result), + media_type="application/x-ndjson", + ) + + +def _ndjson_stream_835(result: ParseResult835) -> Iterator[bytes]: + """Yield one JSON object per line: envelope → financial → trace → payer → payee → claim_payments → summary.""" + yield (json.dumps({"type": "envelope", "data": json.loads(result.envelope.model_dump_json())}) + "\n").encode("utf-8") + yield (json.dumps({"type": "financial_info", "data": json.loads(result.financial_info.model_dump_json())}) + "\n").encode("utf-8") + yield (json.dumps({"type": "trace", "data": json.loads(result.trace.model_dump_json())}) + "\n").encode("utf-8") + yield (json.dumps({"type": "payer", "data": json.loads(result.payer.model_dump_json())}) + "\n").encode("utf-8") + yield (json.dumps({"type": "payee", "data": json.loads(result.payee.model_dump_json())}) + "\n").encode("utf-8") + for claim in result.claims: + yield (json.dumps({"type": "claim_payment", "data": json.loads(claim.model_dump_json())}) + "\n").encode("utf-8") + yield (json.dumps({"type": "summary", "data": json.loads(result.summary.model_dump_json())}) + "\n").encode("utf-8") + + __all__ = ["app"] diff --git a/backend/src/cyclone/cli.py b/backend/src/cyclone/cli.py index 6516aac..b43f087 100644 --- a/backend/src/cyclone/cli.py +++ b/backend/src/cyclone/cli.py @@ -1,4 +1,4 @@ -"""Click-based CLI for the Cyclone 837P parser.""" +"""Click-based CLI for the Cyclone 837P and 835 ERA parsers.""" from __future__ import annotations @@ -9,9 +9,12 @@ from pathlib import Path import click from cyclone.parsers.exceptions import CycloneParseError, CycloneValidationError -from cyclone.parsers.payer import PayerConfig -from cyclone.parsers.parse_837 import parse +from cyclone.parsers.payer import PayerConfig, PayerConfig835 +from cyclone.parsers.parse_837 import parse as parse_837_text +from cyclone.parsers.parse_835 import parse as parse_835_text +from cyclone.parsers.validator_835 import validate as validate_835 from cyclone.parsers.writer import write_outputs +from cyclone.parsers.writer_835 import write_outputs_835 _PAYER_FACTORIES = { @@ -19,6 +22,11 @@ _PAYER_FACTORIES = { "generic_837p": PayerConfig.generic_837p, } +_PAYER_FACTORIES_835 = { + "co_medicaid_835": PayerConfig835.co_medicaid_835, + "generic_835": PayerConfig835.generic_835, +} + def _payer(name: str) -> PayerConfig: if name not in _PAYER_FACTORIES: @@ -26,6 +34,12 @@ def _payer(name: str) -> PayerConfig: return _PAYER_FACTORIES[name]() +def _payer_835(name: str) -> PayerConfig835: + if name not in _PAYER_FACTORIES_835: + raise click.BadParameter(f"Unknown payer {name!r}. Choose from: {', '.join(_PAYER_FACTORIES_835)}") + return _PAYER_FACTORIES_835[name]() + + @click.group() def main() -> None: """Cyclone EDI suite — X12 parser.""" @@ -55,7 +69,7 @@ def parse_837( config = _payer(payer) try: - result = parse(text, config, input_file=str(input_file)) + result = parse_837_text(text, config, input_file=str(input_file)) except CycloneParseError as exc: click.echo(f"PARSE ERROR: {exc}", err=True) sys.exit(2) @@ -103,5 +117,83 @@ def parse_837( ) +@main.command("parse-835") +@click.argument("input_file", type=click.Path(exists=True, dir_okay=False, path_type=Path)) +@click.option("--output-dir", required=True, type=click.Path(file_okay=False, path_type=Path)) +@click.option("--payer", default="co_medicaid_835", show_default=True, help="Payer configuration preset") +@click.option("--strict", is_flag=True, help="Promote warnings to errors") +@click.option("--include-raw-segments / --no-raw-segments", default=True, help="Embed raw segments in output JSON") +@click.option("--log-level", default="INFO", show_default=True, type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR"])) +def parse_835( + input_file: Path, + output_dir: Path, + payer: str, + strict: bool, + include_raw_segments: bool, + log_level: str, +) -> None: + """Parse an X12 835 ERA file into one JSON per claim payment.""" + logging.basicConfig(level=getattr(logging, log_level)) + + text = input_file.read_text() + config = _payer_835(payer) + + try: + result = parse_835_text(text, config, input_file=str(input_file)) + except CycloneParseError as exc: + click.echo(f"PARSE ERROR: {exc}", err=True) + sys.exit(2) + + if not result.claims: + raise click.UsageError("No claim payments found in file") from None + + # Run validation; populate summary.passed/failed from the report. + # 835 validation is batch-level (BPR/CLP/SVC balancing is cross-cutting), + # so pass/fail applies uniformly to every claim payment in the batch: + # when the batch passes, all N claims "passed" (passed=N, failed=0); + # when the batch fails, all N claims "failed" (passed=0, failed=N). + report = validate_835(result, config) + if strict: + promoted = [i.model_copy(update={"severity": "error"}) for i in report.warnings] + new_errors = report.errors + promoted + report = report.model_copy(update={"errors": new_errors, "passed": not new_errors}) + n = len(result.claims) + claim_ids = [c.payer_claim_control_number for c in result.claims] + if report.passed: + passed, failed, failed_claim_ids = n, 0, [] + else: + passed, failed, failed_claim_ids = 0, n, claim_ids + result = result.model_copy(update={"validation": report}) + result.summary = result.summary.model_copy(update={ + "passed": passed, + "failed": failed, + "failed_claim_ids": failed_claim_ids, + "issues_by_rule": _count_issues(report), + }) + + # Drop raw_segments if disabled + if not include_raw_segments: + result = result.model_copy(update={"claims": [c.model_copy(update={"raw_segments": []}) for c in result.claims]}) + + write_outputs_835(result, output_dir) + result.summary = result.summary.model_copy(update={"output_dir": str(output_dir)}) + (output_dir / "summary.json").write_text(result.summary.model_dump_json(indent=2)) + + click.echo( + f"parsed={result.summary.total_claims} " + f"passed={result.summary.passed} " + f"failed={result.summary.failed} " + f"output={output_dir}" + ) + + +def _count_issues(report) -> dict[str, int]: + """Build an issues_by_rule dict from a ValidationReport (for the summary).""" + counts: dict[str, int] = {} + for issue in report.errors + report.warnings: + counts[issue.rule] = counts.get(issue.rule, 0) + 1 + return counts + + if __name__ == "__main__": main() diff --git a/backend/src/cyclone/parsers/__init__.py b/backend/src/cyclone/parsers/__init__.py index dcd37e4..b94fd3a 100644 --- a/backend/src/cyclone/parsers/__init__.py +++ b/backend/src/cyclone/parsers/__init__.py @@ -15,7 +15,7 @@ _LAZY_EXPORTS: dict[str, str] = { # exceptions "CycloneParseError": "cyclone.parsers.exceptions", "CycloneValidationError": "cyclone.parsers.exceptions", - # models + # models (837P) "Address": "cyclone.parsers.models", "BatchSummary": "cyclone.parsers.models", "BillingProvider": "cyclone.parsers.models", @@ -30,14 +30,28 @@ _LAZY_EXPORTS: dict[str, str] = { "Subscriber": "cyclone.parsers.models", "ValidationIssue": "cyclone.parsers.models", "ValidationReport": "cyclone.parsers.models", + # models (835) + "ClaimAdjustment": "cyclone.parsers.models_835", + "ClaimPayment": "cyclone.parsers.models_835", + "FinancialInfo": "cyclone.parsers.models_835", + "ParseResult835": "cyclone.parsers.models_835", + "Payer835": "cyclone.parsers.models_835", + "Payee835": "cyclone.parsers.models_835", + "ReassociationTrace": "cyclone.parsers.models_835", + "ServicePayment": "cyclone.parsers.models_835", + "claim_status_label": "cyclone.parsers.models_835", # payer "PayerConfig": "cyclone.parsers.payer", + "PayerConfig835": "cyclone.parsers.payer", # segments "Delimiters": "cyclone.parsers.segments", "tokenize": "cyclone.parsers.segments", - # orchestrator + # orchestrators "parse": "cyclone.parsers.parse_837", + "parse_835": "cyclone.parsers.parse_835", + # writers "write_outputs": "cyclone.parsers.writer", + "write_outputs_835": "cyclone.parsers.writer_835", } _cache: dict[str, object] = {} diff --git a/backend/src/cyclone/parsers/models_835.py b/backend/src/cyclone/parsers/models_835.py new file mode 100644 index 0000000..ccee3db --- /dev/null +++ b/backend/src/cyclone/parsers/models_835.py @@ -0,0 +1,218 @@ +"""Pydantic v2 models for parsed 835 ERA (Health Care Claim Payment/Advice) files. + +Models mirror the X12 835 005010X221A1 structure: + +- ``Envelope`` (ISA/GS/ST) — reused from :mod:`cyclone.parsers.models` +- ``FinancialInfo`` (BPR) +- ``ReassociationTrace`` (TRN) +- ``Payer835`` / ``Payee835`` (Loop 1000A / 1000B) +- ``ClaimPayment`` (Loop 2100) with embedded ``ServicePayment`` (Loop 2110) +- ``ParseResult835`` (batch) + +We copy the ``_Base`` model from :mod:`cyclone.parsers.models` because it +carries the custom ``@model_serializer`` that converts ``date`` to ``str`` +on JSON output — a behavior the 835 models must match so the FastAPI +output is consistent across transactions. +""" + +from __future__ import annotations + +from datetime import date +from decimal import Decimal +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field, model_serializer, model_validator + +from cyclone.parsers.models import Address, BatchSummary, Envelope, ValidationIssue, ValidationReport + + +# --------------------------------------------------------------------------- # +# Shared base +# --------------------------------------------------------------------------- # + + +class _Base(BaseModel): + """Shared Pydantic base; matches the 837P 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 + + +# --------------------------------------------------------------------------- # +# CLP02 Claim Status Code → human-readable label +# --------------------------------------------------------------------------- # + +# Source: X12 005010X221A1 element 1029 (Claim Status Code). +# In v1 we keep the subset a 835 ERA actually carries; status_code "25" +# (predetermination pricing only — no payment) is included per the spec. +_CLP_STATUS_LABELS: dict[str, str] = { + "1": "Processed as Primary", + "2": "Processed as Secondary", + "3": "Pending", + "4": "Denied", + "19": "Processed Primary, Forwarded", + "20": "Processed Secondary, Forwarded", + "21": "Reversal of Previous Payment", + "22": "Reversal of Previous", + "23": "Not Our Claim, Forwarded", + "25": "Predetermination Pricing Only", +} + + +def claim_status_label(code: str) -> str | None: + """Return a human-readable label for a CLP02 code, or ``None`` if unknown.""" + return _CLP_STATUS_LABELS.get(code) + + +# --------------------------------------------------------------------------- # +# Header +# --------------------------------------------------------------------------- # + + +class FinancialInfo(_Base): + """BPR — Financial Information.""" + + handling_code: str + paid_amount: Decimal + credit_debit_flag: str + payment_method: str | None = None + payer_tax_id: str | None = None + payment_date: date | None = None + + +class ReassociationTrace(_Base): + """TRN — Reassociation Trace Number.""" + + trace_type_code: str + trace_number: str + originating_company_id: str + payer_id: str | None = None + + +class Payer835(_Base): + """Loop 1000A — Payer Identification (NM1*PR).""" + + name: str + id: str | None = None + address: Address | None = None + contact_url: str | None = None # PER04 per the CO Medicaid guide + + +class Payee835(_Base): + """Loop 1000B — Payee Identification (NM1*PE).""" + + name: str + npi: str + address: Address | None = None + + +# --------------------------------------------------------------------------- # +# Claim / Service payment +# --------------------------------------------------------------------------- # + + +class ClaimAdjustment(_Base): + """CAS — Claim Adjustment (one row per reason/amount pair).""" + + group_code: str + reason_code: str + amount: Decimal + quantity: Decimal | None = None + + +class ServicePayment(_Base): + """Loop 2110 — Service Payment Information (SVC composite).""" + + line_number: int + procedure_qualifier: str + procedure_code: str + modifiers: list[str] = Field(default_factory=list) + charge: Decimal + payment: Decimal + units: Decimal | None = None + unit_type: str | None = None + service_date: date | None = None + adjustments: list[ClaimAdjustment] = Field(default_factory=list) + ref_benefit_plan: str | None = None # REF*0K per the CO guide + + +class ClaimPayment(_Base): + """Loop 2100 — Claim Payment Information (CLP composite).""" + + payer_claim_control_number: str + status_code: str + status_label: str | None = None + total_charge: Decimal + total_paid: Decimal + patient_responsibility: Decimal | None = None + claim_filing_indicator: str | None = None # CLP06 (e.g. "MC" for Medicaid) + original_claim_id: str | None = None # CLP07 — original 837P CLM01 + facility_type: str | None = None # CLP08 — original 837P CLM05-1 + frequency_code: str | None = None # CLP09 — original 837P CLM05-3 + per_diem_covered_days: Decimal | None = None # QTY*CA per the CO guide + ref_benefit_plan: str | None = None # REF*CE per the CO guide + service_payments: list[ServicePayment] = Field(default_factory=list) + raw_segments: list[list[str]] = Field(default_factory=list) + + @model_validator(mode="before") + @classmethod + def _decode_status_label(cls, data: Any) -> Any: + """Populate ``status_label`` from ``status_code`` when caller omitted it. + + Callers that explicitly set ``status_label`` (e.g. round-tripping from + JSON) keep their value. + """ + if isinstance(data, dict) and "status_label" not in data and "status_code" in data: + data = {**data, "status_label": claim_status_label(str(data["status_code"]))} + return data + + +# --------------------------------------------------------------------------- # +# Top-level result +# --------------------------------------------------------------------------- # + + +class ParseResult835(_Base): + envelope: Envelope + financial_info: FinancialInfo + trace: ReassociationTrace + payer: Payer835 + payee: Payee835 + claims: list[ClaimPayment] = Field(default_factory=list) + summary: BatchSummary + validation: ValidationReport | None = None # populated by the orchestrator + + +# --------------------------------------------------------------------------- # +# Re-exports +# --------------------------------------------------------------------------- # + +__all__ = [ + "Address", + "BatchSummary", + "ClaimAdjustment", + "ClaimPayment", + "Envelope", + "FinancialInfo", + "ParseResult835", + "Payer835", + "Payee835", + "ReassociationTrace", + "ServicePayment", + "ValidationIssue", + "ValidationReport", + "claim_status_label", +] + + +# Silence the "imported but unused" complaint on a few names that exist for +# parity with the 837P module's public surface but are not yet referenced +# in this file's implementation. +_ = (Literal, BaseModel) diff --git a/backend/src/cyclone/parsers/parse_835.py b/backend/src/cyclone/parsers/parse_835.py new file mode 100644 index 0000000..215eced --- /dev/null +++ b/backend/src/cyclone/parsers/parse_835.py @@ -0,0 +1,540 @@ +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 + if len(seg) > 4 and seg[4]: + # In X12 835, SVC04 is "units paid" and SVC05 is the unit-of-measure. + # SVC05 may carry a value (UN, MJ, etc.) or be empty. + try: + units = Decimal(seg[4]) + except Exception: + units = None + if len(seg) > 5 and seg[5]: + unit_type = seg[5] + elif len(seg) > 4 and seg[4]: + # Some payers omit SVC05 but still mean "UN" when the units value + # is present. Be conservative: only default to UN if no other value + # was set above. + if unit_type is None: + unit_type = "UN" + 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 + # 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": + financial, i = _consume_bpr(segments, i) + 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, + summary=summary, + validation=None, + ) + + +__all__ = ["parse"] diff --git a/backend/src/cyclone/parsers/payer.py b/backend/src/cyclone/parsers/payer.py index ada1502..86f1bf8 100644 --- a/backend/src/cyclone/parsers/payer.py +++ b/backend/src/cyclone/parsers/payer.py @@ -1,13 +1,16 @@ -"""Payer-specific configuration for the 837P parser. +"""Payer-specific configuration for the Cyclone parsers. -The same parser module can ingest 837P files from any payer, but the -validation rules differ. ``PayerConfig`` carries the differences so -``validator.validate(claim, config)`` can be payer-aware without branching -in the parser. +The same parser module can ingest 837P / 835 files from any payer, but +the validation rules differ. ``PayerConfig`` (837P) and ``PayerConfig835`` +(835 ERA) carry the differences so the validator functions can be +payer-aware without branching in the parser. """ from __future__ import annotations +import re +from typing import Pattern + from pydantic import BaseModel, ConfigDict, Field @@ -26,6 +29,11 @@ CMS_PLACE_OF_SERVICE_CODES: set[str] = { } +# --------------------------------------------------------------------------- # +# 837P (Professional Claim) +# --------------------------------------------------------------------------- # + + class PayerConfig(BaseModel): model_config = ConfigDict(frozen=True) @@ -82,3 +90,64 @@ class PayerConfig(BaseModel): allowed_facility_qualifiers={"B"}, allowed_place_of_service_codes=set(CMS_PLACE_OF_SERVICE_CODES), ) + + +# --------------------------------------------------------------------------- # +# 835 ERA (Health Care Claim Payment/Advice) +# --------------------------------------------------------------------------- # + + +class PayerConfig835(BaseModel): + """Payer-specific configuration for the 835 ERA parser. + + Mirrors :class:`PayerConfig` but carries the 835-specific fields + (handling codes, status codes, CO Medicaid tax IDs / health plan ID). + Kept in a separate class — not a subclass — because the 835 and 837P + validation surfaces don't share enough structure to make inheritance + pay off. + """ + + model_config = ConfigDict(frozen=True, extra="ignore") + + name: str + allowed_handling_codes: set[str] = Field( + default_factory=lambda: {"C", "D", "I", "X"} + ) + allowed_status_codes: set[str] = Field( + default_factory=lambda: {"1", "2", "3", "4", "19", "20", "21", "22", "23", "25"} + ) + # CO Medicaid uses "81-1725341" (TXIX) and "84-0644739" (BHA). + expected_payer_tax_ids: set[str] = Field(default_factory=set) + # CO uses "7912900843" as the Health Plan ID on N104. + expected_payer_health_plan_id: str = "" + # Regex matched against Payer.name (N102). Example: r"^CO_(TXIX|BHA)$". + payer_name_pattern: str = "" + + def payer_name_regex(self) -> Pattern[str] | None: + """Compile ``payer_name_pattern`` (or return ``None`` if unset/blank).""" + if not self.payer_name_pattern: + return None + return re.compile(self.payer_name_pattern) + + @classmethod + def co_medicaid_835(cls) -> "PayerConfig835": + """Defaults for Colorado Medical Assistance Program (835). + + Source: ``docs/companionguides/835.md``. + """ + return cls( + name="Colorado Medical Assistance Program (835)", + expected_payer_tax_ids={"81-1725341", "84-0644739"}, + expected_payer_health_plan_id="7912900843", + payer_name_pattern=r"^CO_(TXIX|BHA)$", + ) + + @classmethod + def generic_835(cls) -> "PayerConfig835": + """Relaxed defaults for an unknown payer. Structural rules only.""" + return cls( + name="Generic 835", + expected_payer_tax_ids=set(), + expected_payer_health_plan_id="", + payer_name_pattern="", + ) diff --git a/backend/src/cyclone/parsers/validator_835.py b/backend/src/cyclone/parsers/validator_835.py new file mode 100644 index 0000000..cb0e412 --- /dev/null +++ b/backend/src/cyclone/parsers/validator_835.py @@ -0,0 +1,218 @@ +"""Structural and payer-specific validation rules for 835 ERA payments. + +Each rule is a pure function that takes a :class:`ParseResult835` and a +:class:`PayerConfig835` and yields zero or more :class:`ValidationIssue`. +The top-level :func:`validate` function runs every rule and aggregates +results into a single :class:`ValidationReport` for the batch. + +Unlike 837P (where each claim has its own report), the 835 ERA validates +the whole batch as one unit because the BPR/CLP/SVC balancing rules are +cross-cutting. +""" + +from __future__ import annotations + +import re +from collections.abc import Iterable +from decimal import Decimal +from typing import Callable + +from cyclone.parsers.models import ValidationIssue, ValidationReport +from cyclone.parsers.models_835 import ParseResult835 +from cyclone.parsers.payer import PayerConfig835 + +NPI_RE = re.compile(r"^\d{10}$") + +Rule = Callable[[ParseResult835, PayerConfig835], Iterable[ValidationIssue]] + +# Tolerance for monetary balancing (USD; the 835 standard's smallest +# representable currency unit is 0.01). +TOLERANCE = Decimal("0.01") + + +# --------------------------------------------------------------------------- # +# BPR rules +# --------------------------------------------------------------------------- # + + +def _r001_bpr01_handling_code_allowed(result: ParseResult835, cfg: PayerConfig835) -> Iterable[ValidationIssue]: + if result.financial_info.handling_code not in cfg.allowed_handling_codes: + yield ValidationIssue( + rule="R835_BPR01_handling_code_allowed", + severity="error", + message=f"BPR01 {result.financial_info.handling_code!r} not in {sorted(cfg.allowed_handling_codes)} for payer {cfg.name}", + ) + + +def _r002_bpr02_positive(result: ParseResult835, _: PayerConfig835) -> Iterable[ValidationIssue]: + if result.financial_info.paid_amount <= 0: + yield ValidationIssue( + rule="R835_BPR02_positive", + severity="error", + message=f"BPR02 (paid amount) must be > 0, got {result.financial_info.paid_amount}", + ) + + +def _r003_bal_bpr_vs_clp04(result: ParseResult835, _: PayerConfig835) -> Iterable[ValidationIssue]: + """BPR02 must equal the sum of CLP04 across all claims in the batch.""" + if not result.claims: + return + clp_sum = sum((c.total_paid for c in result.claims), start=Decimal("0")) + diff = (result.financial_info.paid_amount - clp_sum).copy_abs() + if diff > TOLERANCE: + yield ValidationIssue( + rule="R835_BAL_BPR_vs_CLP04", + severity="error", + message=( + f"BPR02 ({result.financial_info.paid_amount}) does not match " + f"sum of CLP04 ({clp_sum}); diff={diff}" + ), + ) + + +# --------------------------------------------------------------------------- # +# Per-claim CLP / SVC rules +# --------------------------------------------------------------------------- # + + +def _r004_bal_clp04_vs_svc03(result: ParseResult835, _: PayerConfig835) -> Iterable[ValidationIssue]: + """Each CLP04 must equal the sum of its SVC03 service payments.""" + for c in result.claims: + if not c.service_payments: + # No service lines — cannot balance, but R835_CLP03_present + # already covers a zero-charge claim. + continue + svc_sum = sum((sp.payment for sp in c.service_payments), start=Decimal("0")) + diff = (c.total_paid - svc_sum).copy_abs() + if diff > TOLERANCE: + yield ValidationIssue( + rule="R835_BAL_CLP04_vs_SVC03", + severity="error", + message=( + f"[claim {c.payer_claim_control_number}] CLP04 ({c.total_paid}) " + f"does not match sum of SVC03 ({svc_sum}); diff={diff}" + ), + ) + + +def _r005_clp01_present(result: ParseResult835, _: PayerConfig835) -> Iterable[ValidationIssue]: + for c in result.claims: + if not c.payer_claim_control_number or not c.payer_claim_control_number.strip(): + yield ValidationIssue( + rule="R835_CLP01_present", + severity="error", + message="CLP01 (payer claim control number) is empty", + ) + + +def _r006_clp02_status_allowed(result: ParseResult835, cfg: PayerConfig835) -> Iterable[ValidationIssue]: + for c in result.claims: + if c.status_code and c.status_code not in cfg.allowed_status_codes: + yield ValidationIssue( + rule="R835_CLP02_status_allowed", + severity="error", + message=( + f"[claim {c.payer_claim_control_number}] CLP02 status " + f"{c.status_code!r} not in {sorted(cfg.allowed_status_codes)} for payer {cfg.name}" + ), + ) + + +def _r007_clp03_present(result: ParseResult835, _: PayerConfig835) -> Iterable[ValidationIssue]: + for c in result.claims: + if c.total_charge <= 0: + yield ValidationIssue( + rule="R835_CLP03_present", + severity="error", + message=( + f"[claim {c.payer_claim_control_number}] CLP03 (total claim charge) " + f"must be > 0, got {c.total_charge}" + ), + ) + + +# --------------------------------------------------------------------------- # +# Payer / payee rules +# --------------------------------------------------------------------------- # + + +def _r008_payer_tax_id_matches(result: ParseResult835, cfg: PayerConfig835) -> Iterable[ValidationIssue]: + if not cfg.expected_payer_tax_ids: + return + tax = result.financial_info.payer_tax_id + if tax and tax not in cfg.expected_payer_tax_ids: + yield ValidationIssue( + rule="R835_PAYER_TAX_ID_matches", + severity="warning", + message=( + f"BPR10 (payer tax id) {tax!r} not in " + f"{sorted(cfg.expected_payer_tax_ids)} for payer {cfg.name}" + ), + ) + + +def _r009_payer_health_plan_id_matches(result: ParseResult835, cfg: PayerConfig835) -> Iterable[ValidationIssue]: + if not cfg.expected_payer_health_plan_id: + return + pid = result.payer.id + if pid and pid != cfg.expected_payer_health_plan_id: + yield ValidationIssue( + rule="R835_PAYER_HEALTH_PLAN_ID_matches", + severity="warning", + message=( + f"N104 (payer health plan id) {pid!r} != configured " + f"{cfg.expected_payer_health_plan_id!r} for payer {cfg.name}" + ), + ) + + +def _r010_payee_npi_format(result: ParseResult835, _: PayerConfig835) -> Iterable[ValidationIssue]: + npi = result.payee.npi + if npi and not NPI_RE.match(npi): + yield ValidationIssue( + rule="R835_PAYEE_NPI_format", + severity="error", + message=f"Payee NPI must be 10 digits, got {npi!r}", + ) + + +def _r011_trn02_present(result: ParseResult835, _: PayerConfig835) -> Iterable[ValidationIssue]: + if not result.trace.originating_company_id or not result.trace.originating_company_id.strip(): + yield ValidationIssue( + rule="R835_REGN01_trn02_present", + severity="error", + message="TRN02 (originating company id / payee TPID) is empty", + ) + + +_RULES: list[Rule] = [ + _r001_bpr01_handling_code_allowed, + _r002_bpr02_positive, + _r003_bal_bpr_vs_clp04, + _r004_bal_clp04_vs_svc03, + _r005_clp01_present, + _r006_clp02_status_allowed, + _r007_clp03_present, + _r008_payer_tax_id_matches, + _r009_payer_health_plan_id_matches, + _r010_payee_npi_format, + _r011_trn02_present, +] + + +def validate(result: ParseResult835, config: PayerConfig835) -> ValidationReport: + """Run every rule against the batch and return a single report. + + Never raises — all issues are accumulated into the report. The + orchestrator is responsible for promoting warnings to errors under + ``--strict`` and for setting ``BatchSummary.passed``. + """ + errors: list[ValidationIssue] = [] + warnings: list[ValidationIssue] = [] + for rule in _RULES: + for issue in rule(result, config): + (errors if issue.severity == "error" else warnings).append(issue) + return ValidationReport(passed=not errors, errors=errors, warnings=warnings) + + +__all__ = ["validate", "TOLERANCE"] diff --git a/backend/src/cyclone/parsers/writer_835.py b/backend/src/cyclone/parsers/writer_835.py new file mode 100644 index 0000000..4766ef0 --- /dev/null +++ b/backend/src/cyclone/parsers/writer_835.py @@ -0,0 +1,80 @@ +"""Write a :class:`ParseResult835` to disk as one JSON file per claim + summary.json. + +Parallel to :mod:`cyclone.parsers.writer` for the 837P parser. Kept +separate because the 835 writer is not just a duck-typed copy of the +837P one — the ``payout`` payload includes the BPR/TRN/1000A/1000B +headers alongside each claim file (so the UI can render the full ERA +context per claim), and we want to be free to evolve the layout +independently. +""" + +from __future__ import annotations + +import json +import re +from pathlib import Path + +from cyclone.parsers.models_835 import ParseResult835 + +_UNSAFE = re.compile(r"[^A-Za-z0-9._-]") + + +def _sanitize(s: str) -> str: + return _UNSAFE.sub("-", s).strip("-") or "claim" + + +def _filename(control_number: str, transaction_date_iso: str, claim_id: str, used: set[str]) -> str: + base = f"payout-{_sanitize(control_number)}-{_sanitize(transaction_date_iso.replace('-', ''))}-{_sanitize(claim_id)}.json" + if base not in used: + return base + stem = base[:-5] + n = 2 + while True: + cand = f"{stem}-{n}.json" + if cand not in used: + return cand + n += 1 + + +def write_outputs_835(result: ParseResult835, output_dir: Path) -> list[Path]: + """Write one JSON file per claim payment + a ``summary.json``. + + Each claim file is enriched with the batch-level envelope/financial/ + trace/payer/payee so the UI can render the full ERA context without + cross-referencing the summary. The ``summary.json`` is the canonical + counts file. + """ + output_dir = Path(output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + + txn_date = result.envelope.transaction_date.isoformat() if result.envelope else "unknown" + control = result.envelope.control_number if result.envelope else "unknown" + + used: set[str] = set() + paths: list[Path] = [] + # Pre-serialize the batch-level fields so each claim file embeds them. + envelope_obj = json.loads(result.envelope.model_dump_json()) + financial_obj = json.loads(result.financial_info.model_dump_json()) + trace_obj = json.loads(result.trace.model_dump_json()) + payer_obj = json.loads(result.payer.model_dump_json()) + payee_obj = json.loads(result.payee.model_dump_json()) + + for claim in result.claims: + name = _filename(control, txn_date, claim.payer_claim_control_number, used) + used.add(name) + path = output_dir / name + claim_obj = json.loads(claim.model_dump_json()) + payload = { + "envelope": envelope_obj, + "financial_info": financial_obj, + "trace": trace_obj, + "payer": payer_obj, + "payee": payee_obj, + "claim": claim_obj, + } + path.write_text(json.dumps(payload, indent=2)) + paths.append(path) + + summary_path = output_dir / "summary.json" + summary_path.write_text(result.summary.model_dump_json(indent=2)) + return paths diff --git a/backend/tests/fixtures/co_medicaid_835.txt b/backend/tests/fixtures/co_medicaid_835.txt new file mode 100644 index 0000000..5b9deac --- /dev/null +++ b/backend/tests/fixtures/co_medicaid_835.txt @@ -0,0 +1,42 @@ +ISA*00* *00* *ZZ*COMEDASSISTPROG*ZZ*TPID001 *260617*1937*^*00501*991102986*1*P*:~ST*835*991102986*005010X221A1~ +BPR*C*241.16*C*ACH*CCP*01*021000021*DA*123456789*81-1725341******20260617~ +TRN*1*TPID001*81-1725341~ +DTM*405*20260617~ +N1*PR*2*CO_TXIX*****XV*7912900843~ +N3*PO BOX 11000~ +N4*DENVER*CO*802110000~ +PER*BL*Customer Service*TE*8001234567*UR*hcpf.colorado.gov~ +N1*PE*2*TOC, Inc.*****XX*1881068062~ +N3*1100 East Main St*Suite A~ +N4*Montrose*CO*814014063~ +REF*TJ*123456789~ +LX*1~ +CLP*t991102986o1c1d*1*85.40*85.40*0.00*MC*t991102986o1c1d*12*1~ +CAS*CO*45*0.00~ +REF*CE*PLAN_A~ +NM1*QC*1*Balliache*Marianela~ +SVC*HC:T1019:U2*42.70*42.70*UN*6.00*20260602~ +DTM*472*20260602~ +SVC*HC:T1019:U2*42.70*42.70*UN*6.00*20260603~ +DTM*472*20260603~ +LX*2~ +CLP*t991102986o1c2d*1*155.76*155.76*0.00*MC*t991102986o1c2d*12*1~ +CAS*CO*45*0.00~ +REF*CE*PLAN_A~ +QTY*CA*4~ +NM1*QC*1*Barella*Victoria~ +SVC*HC:S5130:U2*38.94*38.94*UN*6.00*20260602~ +DTM*472*20260602~ +REF*0K*PLAN_A~ +SVC*HC:S5130:U2*38.94*38.94*UN*6.00*20260603~ +DTM*472*20260603~ +REF*0K*PLAN_A~ +SVC*HC:S5130:U2*38.94*38.94*UN*6.00*20260604~ +DTM*472*20260604~ +REF*0K*PLAN_A~ +SVC*HC:S5130:U2*38.94*38.94*UN*6.00*20260605~ +DTM*472*20260605~ +REF*0K*PLAN_A~ +SE*45*991102986~ +GE*1*991102986~ +IEA*1*991102986~ diff --git a/backend/tests/fixtures/minimal_835.txt b/backend/tests/fixtures/minimal_835.txt new file mode 100644 index 0000000..223b875 --- /dev/null +++ b/backend/tests/fixtures/minimal_835.txt @@ -0,0 +1,21 @@ +ISA*00* *00* *ZZ*COMEDASSISTPROG*ZZ*TPID001 *260617*1937*^*00501*991102984*1*P*:~ST*835*991102984*005010X221A1~ +BPR*C*85.00*C*ACH*CCP*01*021000021*DA*123456789*81-1725341******20260617~ +TRN*1*TPID001*81-1725341~ +DTM*405*20260617~ +N1*PR*2*CO_TXIX*****XV*7912900843~ +N3*PO BOX 11000~ +N4*DENVER*CO*802110000~ +PER*BL*Customer Service*TE*8001234567*UR*hcpf.colorado.gov~ +N1*PE*2*TOC, Inc.*****XX*1881068062~ +N3*1100 East Main St*Suite A~ +N4*Montrose*CO*814014063~ +LX*1~ +CLP*CLM001*1*100.00*85.00*15.00*MC*CLM001*12*1~ +CAS*CO*45*15.00~ +SVC*HC:S5130:U2*60.00*50.00*7.73*UN*20260602~ +DTM*472*20260602~ +SVC*HC:S5130:U2*40.00*35.00*7.73*UN*20260603~ +DTM*472*20260603~ +SE*22*991102984~ +GE*1*991102984~ +IEA*1*991102984~ diff --git a/backend/tests/fixtures/unbalanced_835.txt b/backend/tests/fixtures/unbalanced_835.txt new file mode 100644 index 0000000..f247df4 --- /dev/null +++ b/backend/tests/fixtures/unbalanced_835.txt @@ -0,0 +1,21 @@ +ISA*00* *00* *ZZ*COMEDASSISTPROG*ZZ*TPID001 *260617*1937*^*00501*991102985*1*P*:~ST*835*991102985*005010X221A1~ +BPR*C*85.50*C*ACH*CCP*01*021000021*DA*123456789*81-1725341******20260617~ +TRN*1*TPID001*81-1725341~ +DTM*405*20260617~ +N1*PR*2*CO_TXIX*****XV*7912900843~ +N3*PO BOX 11000~ +N4*DENVER*CO*802110000~ +PER*BL*Customer Service*TE*8001234567*UR*hcpf.colorado.gov~ +N1*PE*2*TOC, Inc.*****XX*1881068062~ +N3*1100 East Main St*Suite A~ +N4*Montrose*CO*814014063~ +LX*1~ +CLP*CLM001*1*100.00*85.00*15.00*MC*CLM001*12*1~ +CAS*CO*45*15.00~ +SVC*HC:S5130:U2*60.00*50.00*7.73*UN*20260602~ +DTM*472*20260602~ +SVC*HC:S5130:U2*40.00*35.00*7.73*UN*20260603~ +DTM*472*20260603~ +SE*22*991102985~ +GE*1*991102985~ +IEA*1*991102985~ diff --git a/backend/tests/test_api_835.py b/backend/tests/test_api_835.py new file mode 100644 index 0000000..571522b --- /dev/null +++ b/backend/tests/test_api_835.py @@ -0,0 +1,171 @@ +"""Tests for the FastAPI surface in ``cyclone.api`` for the 835 ERA endpoint.""" +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +from cyclone.api import app + + +FIXTURE = Path(__file__).parent / "fixtures" / "co_medicaid_835.txt" +UNBALANCED = Path(__file__).parent / "fixtures" / "unbalanced_835.txt" + + +@pytest.fixture +def client() -> TestClient: + return TestClient(app) + + +# --------------------------------------------------------------------------- # +# JSON response path +# --------------------------------------------------------------------------- # + + +def test_parse_835_endpoint_returns_json(client: TestClient): + text = FIXTURE.read_text() + resp = client.post( + "/api/parse-835", + files={"file": ("co_medicaid_835.txt", text, "text/plain")}, + headers={"Accept": "application/json"}, + ) + assert resp.status_code == 200, resp.text + body = resp.json() + assert "envelope" in body + assert "financial_info" in body + assert "trace" in body + assert "payer" in body + assert "payee" in body + assert "claims" in body and len(body["claims"]) == 2 + assert "summary" in body + assert body["summary"]["total_claims"] == 2 + assert body["summary"]["passed"] == 2 + assert body["summary"]["failed"] == 0 + + +# --------------------------------------------------------------------------- # +# NDJSON streaming path +# --------------------------------------------------------------------------- # + + +def test_parse_835_endpoint_streams_ndjson(client: TestClient): + text = FIXTURE.read_text() + resp = client.post( + "/api/parse-835", + files={"file": ("co_medicaid_835.txt", text, "text/plain")}, + ) + assert resp.status_code == 200, resp.text + assert resp.headers["content-type"].startswith("application/x-ndjson") + + lines = list(resp.iter_lines()) + # 1 envelope + 1 financial_info + 1 trace + 1 payer + 1 payee + 2 claims + 1 summary = 8 + assert len(lines) == 8 + + parsed = [json.loads(line) for line in lines] + assert parsed[0]["type"] == "envelope" + assert parsed[1]["type"] == "financial_info" + assert parsed[2]["type"] == "trace" + assert parsed[3]["type"] == "payer" + assert parsed[4]["type"] == "payee" + assert parsed[5]["type"] == "claim_payment" + assert parsed[6]["type"] == "claim_payment" + assert parsed[7]["type"] == "summary" + + # When include_raw_segments defaults to True, each claim carries raw segments. + for obj in parsed[5:7]: + assert "raw_segments" in obj["data"] + assert isinstance(obj["data"]["raw_segments"], list) + + # Summary numbers match the JSON path. + assert parsed[7]["data"]["total_claims"] == 2 + assert parsed[7]["data"]["passed"] == 2 + + +def test_parse_835_endpoint_streams_ndjson_without_raw_segments(client: TestClient): + text = FIXTURE.read_text() + resp = client.post( + "/api/parse-835?include_raw_segments=false", + files={"file": ("co_medicaid_835.txt", text, "text/plain")}, + ) + assert resp.status_code == 200 + assert resp.headers["content-type"].startswith("application/x-ndjson") + + claims = [ + json.loads(line) + for line in resp.iter_lines() + if json.loads(line)["type"] == "claim_payment" + ] + assert len(claims) == 2 + for c in claims: + assert c["data"]["raw_segments"] == [] + + +# --------------------------------------------------------------------------- # +# Validation / error paths +# --------------------------------------------------------------------------- # + + +def test_parse_835_endpoint_rejects_missing_file(client: TestClient): + # FastAPI's `File(...)` (no default) → 422 Unprocessable Entity. + resp = client.post("/api/parse-835") + assert resp.status_code == 422 + + +def test_parse_835_endpoint_handles_payer_query_param(client: TestClient): + text = FIXTURE.read_text() + for payer in ("co_medicaid_835", "generic_835"): + resp = client.post( + f"/api/parse-835?payer={payer}", + files={"file": ("co_medicaid_835.txt", text, "text/plain")}, + headers={"Accept": "application/json"}, + ) + assert resp.status_code == 200, (payer, resp.text) + body = resp.json() + assert body["summary"]["total_claims"] == 2 + assert body["summary"]["passed"] == 2 + + +def test_parse_835_endpoint_rejects_unknown_payer(client: TestClient): + text = FIXTURE.read_text() + resp = client.post( + "/api/parse-835?payer=does_not_exist", + files={"file": ("co_medicaid_835.txt", text, "text/plain")}, + ) + assert resp.status_code == 400 + + +def test_parse_835_endpoint_unbalanced_returns_422(client: TestClient): + text = UNBALANCED.read_text() + resp = client.post( + "/api/parse-835", + files={"file": ("unbalanced_835.txt", text, "text/plain")}, + headers={"Accept": "application/json"}, + ) + assert resp.status_code == 422, resp.text + body = resp.json() + assert body["summary"]["failed"] == 1 + # The R835_BAL_BPR_vs_CLP04 rule should have fired. + assert any( + issue["rule"] == "R835_BAL_BPR_vs_CLP04" + for issue in body["validation"]["errors"] + ) + + +# --------------------------------------------------------------------------- # +# CORS +# --------------------------------------------------------------------------- # + + +def test_cors_headers_present_for_835(client: TestClient): + resp = client.options( + "/api/parse-835", + headers={ + "Origin": "http://localhost:5173", + "Access-Control-Request-Method": "POST", + "Access-Control-Request-Headers": "content-type", + }, + ) + assert resp.headers.get("access-control-allow-origin") == "http://localhost:5173" + assert "POST" in resp.headers.get("access-control-allow-methods", "").upper() diff --git a/backend/tests/test_cli_835.py b/backend/tests/test_cli_835.py new file mode 100644 index 0000000..549f6d2 --- /dev/null +++ b/backend/tests/test_cli_835.py @@ -0,0 +1,92 @@ +"""Tests for the `parse-835` Click subcommand.""" +import json +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from cyclone.cli import main + +FIXTURE = Path(__file__).parent / "fixtures" / "minimal_835.txt" +UNBALANCED = Path(__file__).parent / "fixtures" / "unbalanced_835.txt" +CO_FIXTURE = Path(__file__).parent / "fixtures" / "co_medicaid_835.txt" + + +def test_cli_parse_835_writes_outputs(tmp_path: Path): + runner = CliRunner() + result = runner.invoke(main, ["parse-835", str(FIXTURE), "--output-dir", str(tmp_path)]) + assert result.exit_code == 0, result.output + written = list(tmp_path.glob("payout-*.json")) + assert len(written) == 1 + summary = json.loads((tmp_path / "summary.json").read_text()) + assert summary["total_claims"] == 1 + assert summary["passed"] == 1 + assert summary["failed"] == 0 + + +def test_cli_parse_835_strict_promotes_warnings(tmp_path: Path): + runner = CliRunner() + # Strict mode: even warnings cause failed=1. Minimal fixture is fully + # passing so this is a smoke test that the flag is wired. + result = runner.invoke(main, ["parse-835", str(FIXTURE), "--output-dir", str(tmp_path), "--strict"]) + assert result.exit_code == 0 + summary = json.loads((tmp_path / "summary.json").read_text()) + assert summary["passed"] == 1 + + +def test_cli_parse_835_no_raw_segments(tmp_path: Path): + runner = CliRunner() + result = runner.invoke(main, ["parse-835", str(FIXTURE), "--output-dir", str(tmp_path), "--no-raw-segments"]) + assert result.exit_code == 0 + data = json.loads(next(tmp_path.glob("payout-*.json")).read_text()) + # The claim lives under the "claim" key in each payout file. + assert data["claim"]["raw_segments"] == [] + + +def test_cli_parse_835_unbalanced_strict_fails(tmp_path: Path): + runner = CliRunner() + result = runner.invoke(main, ["parse-835", str(UNBALANCED), "--output-dir", str(tmp_path), "--strict"]) + assert result.exit_code == 0, result.output + summary = json.loads((tmp_path / "summary.json").read_text()) + assert summary["passed"] == 0 + assert summary["failed"] == 1 + # R835_BAL_BPR_vs_CLP04 is the rule that fired. + assert "R835_BAL_BPR_vs_CLP04" in summary["issues_by_rule"] + + +def test_cli_parse_835_unknown_payer_errors(tmp_path: Path): + runner = CliRunner() + result = runner.invoke(main, ["parse-835", str(FIXTURE), "--output-dir", str(tmp_path), "--payer", "no_such_payer"]) + assert result.exit_code != 0 + + +def test_cli_parse_835_missing_file_exits_2(tmp_path: Path): + runner = CliRunner() + result = runner.invoke(main, ["parse-835", "/no/such/file.txt", "--output-dir", str(tmp_path)]) + assert result.exit_code == 2 + + +def test_cli_parse_835_generic_payer(tmp_path: Path): + runner = CliRunner() + result = runner.invoke(main, ["parse-835", str(FIXTURE), "--output-dir", str(tmp_path), "--payer", "generic_835"]) + assert result.exit_code == 0, result.output + summary = json.loads((tmp_path / "summary.json").read_text()) + assert summary["passed"] == 1 + + +def test_cli_parse_835_summary_counts_consistent(tmp_path: Path): + """Regression: passed + failed must equal total_claims for any batch size. + + The CO Medicaid fixture has 2 claim payments; if the batch validates + cleanly, both must show as passed (passed=2, failed=0). A pre-fix + implementation set passed=1 regardless of claim count. + """ + runner = CliRunner() + result = runner.invoke(main, ["parse-835", str(CO_FIXTURE), "--output-dir", str(tmp_path)]) + assert result.exit_code == 0, result.output + summary = json.loads((tmp_path / "summary.json").read_text()) + assert summary["total_claims"] == 2 + assert summary["passed"] + summary["failed"] == summary["total_claims"] + assert summary["passed"] == 2 + assert summary["failed"] == 0 + assert summary["failed_claim_ids"] == [] diff --git a/backend/tests/test_co_medicaid_835_fixture.py b/backend/tests/test_co_medicaid_835_fixture.py new file mode 100644 index 0000000..88ddcdc --- /dev/null +++ b/backend/tests/test_co_medicaid_835_fixture.py @@ -0,0 +1,60 @@ +"""Integration test: load the realistic CO Medicaid 835 fixture and validate it end-to-end.""" +from decimal import Decimal +from pathlib import Path + +from cyclone.parsers.parse_835 import parse +from cyclone.parsers.payer import PayerConfig835 +from cyclone.parsers.validator_835 import validate + +FIXTURE = Path(__file__).parent / "fixtures" / "co_medicaid_835.txt" + + +def test_co_fixture_two_claims_pass(): + result = parse(FIXTURE.read_text(), PayerConfig835.co_medicaid_835()) + cfg = PayerConfig835.co_medicaid_835() + report = validate(result, cfg) + assert result.summary.total_claims == 2 + assert report.passed is True + assert report.errors == [] + + +def test_first_claim_has_two_service_lines(): + result = parse(FIXTURE.read_text(), PayerConfig835.co_medicaid_835()) + c1 = result.claims[0] + assert c1.payer_claim_control_number == "t991102986o1c1d" + assert len(c1.service_payments) == 2 + assert c1.service_payments[0].procedure_code == "T1019" + assert c1.service_payments[0].modifiers == ["U2"] + assert c1.service_payments[1].service_date is not None + + +def test_second_claim_has_four_service_lines(): + result = parse(FIXTURE.read_text(), PayerConfig835.co_medicaid_835()) + c2 = result.claims[1] + assert c2.payer_claim_control_number == "t991102986o1c2d" + assert len(c2.service_payments) == 4 + assert c2.per_diem_covered_days == Decimal("4") + + +def test_payer_and_payee_match_co_config(): + result = parse(FIXTURE.read_text(), PayerConfig835.co_medicaid_835()) + assert result.payer.name == "CO_TXIX" + assert result.payer.id == "7912900843" + assert result.financial_info.payer_tax_id in {"81-1725341", "84-0644739"} + assert result.payee.npi == "1881068062" + assert result.payee.name == "TOC, Inc." + + +def test_bpr_vs_clp_balance_holds(): + """BPR02 (241.16) = CLP04 sum (85.40 + 155.76).""" + result = parse(FIXTURE.read_text(), PayerConfig835.co_medicaid_835()) + clp_sum = sum((c.total_paid for c in result.claims), start=Decimal("0")) + assert result.financial_info.paid_amount == clp_sum + assert result.financial_info.paid_amount == Decimal("241.16") + + +def test_per_claim_clp_vs_svc_balance_holds(): + result = parse(FIXTURE.read_text(), PayerConfig835.co_medicaid_835()) + for c in result.claims: + svc_sum = sum((sp.payment for sp in c.service_payments), start=Decimal("0")) + assert c.total_paid == svc_sum, f"claim {c.payer_claim_control_number}: CLP04={c.total_paid} != sum(SVC03)={svc_sum}" diff --git a/backend/tests/test_models_835.py b/backend/tests/test_models_835.py new file mode 100644 index 0000000..38d121d --- /dev/null +++ b/backend/tests/test_models_835.py @@ -0,0 +1,171 @@ +"""Tests for the 835 ERA Pydantic models.""" +import json +from datetime import date +from decimal import Decimal + +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, +) + + +def _build_result() -> ParseResult835: + return ParseResult835( + envelope=Envelope( + sender_id="COMEDASSISTPROG", + receiver_id="TPID001", + control_number="991102984", + transaction_date=date(2026, 6, 17), + implementation_guide="005010X221A1", + ), + financial_info=FinancialInfo( + handling_code="C", + paid_amount=Decimal("85.00"), + credit_debit_flag="C", + payment_method="ACH", + payer_tax_id="81-1725341", + payment_date=date(2026, 6, 17), + ), + trace=ReassociationTrace( + trace_type_code="1", + trace_number="991102984", + originating_company_id="TPID001", + payer_id="81-1725341", + ), + payer=Payer835( + name="CO_TXIX", + id="7912900843", + contact_url="hcpf.colorado.gov", + address=Address(line1="PO BOX 11000", city="DENVER", state="CO", zip="802110000"), + ), + payee=Payee835( + name="TOC, Inc.", + npi="1881068062", + address=Address(line1="1100 East Main St", line2="Suite A", city="Montrose", state="CO", zip="814014063"), + ), + claims=[ + ClaimPayment( + payer_claim_control_number="CLM001", + status_code="1", + status_label="Processed as Primary", + total_charge=Decimal("100.00"), + total_paid=Decimal("85.00"), + patient_responsibility=Decimal("15.00"), + claim_filing_indicator="MC", + original_claim_id="CLM001", + facility_type="12", + frequency_code="1", + per_diem_covered_days=None, + ref_benefit_plan=None, + service_payments=[ + ServicePayment( + line_number=1, + procedure_qualifier="HC", + procedure_code="S5130", + modifiers=["U2"], + charge=Decimal("60.00"), + payment=Decimal("50.00"), + units=Decimal("7.73"), + unit_type="UN", + service_date=date(2026, 6, 2), + adjustments=[ + ClaimAdjustment(group_code="CO", reason_code="45", amount=Decimal("10.00"), quantity=None), + ], + ref_benefit_plan=None, + ), + ], + raw_segments=[["CLP", "CLM001", "1", "100.00", "85.00"]], + ) + ], + summary=BatchSummary( + input_file="f", + control_number="991102984", + transaction_date=date(2026, 6, 17), + total_claims=1, + passed=1, + failed=0, + ), + ) + + +def test_financial_info_decimal_paid_amount(): + f = FinancialInfo(handling_code="C", paid_amount=Decimal("85.00"), credit_debit_flag="C") + assert f.paid_amount == Decimal("85.00") + assert f.payer_tax_id is None + assert f.payment_date is None + + +def test_reassociation_trace_required(): + t = ReassociationTrace(trace_type_code="1", trace_number="X", originating_company_id="Y") + assert t.payer_id is None + + +def test_payer835_optional_fields(): + p = Payer835(name="X") + assert p.id is None + assert p.address is None + assert p.contact_url is None + + +def test_payee835_required_npi(): + p = Payee835(name="TOC", npi="1881068062") + assert p.npi == "1881068062" + + +def test_claim_adjustment_quantity_optional(): + a = ClaimAdjustment(group_code="CO", reason_code="45", amount=Decimal("10.00")) + assert a.quantity is None + + +def test_service_payment_modifiers_default_empty(): + sp = ServicePayment( + line_number=1, + procedure_qualifier="HC", + procedure_code="99213", + charge=Decimal("100.00"), + payment=Decimal("85.00"), + ) + assert sp.modifiers == [] + assert sp.adjustments == [] + + +def test_claim_payment_status_label_decoded(): + c = ClaimPayment( + payer_claim_control_number="X", + status_code="1", + total_charge=Decimal("100"), + total_paid=Decimal("85"), + ) + assert c.status_label == "Processed as Primary" + assert claim_status_label("4") == "Denied" + assert claim_status_label("25") == "Predetermination Pricing Only" + assert claim_status_label("99") is None # unknown → None + + +def test_parse_result835_date_serializes_to_iso(): + r = _build_result() + blob = r.model_dump() + assert blob["envelope"]["transaction_date"] == "2026-06-17" + assert blob["financial_info"]["payment_date"] == "2026-06-17" + assert blob["claims"][0]["service_payments"][0]["service_date"] == "2026-06-02" + + +def test_parse_result835_full_round_trip(): + r = _build_result() + blob = json.loads(r.model_dump_json()) + assert blob["envelope"]["control_number"] == "991102984" + assert blob["payer"]["name"] == "CO_TXIX" + assert blob["payee"]["npi"] == "1881068062" + assert blob["claims"][0]["payer_claim_control_number"] == "CLM001" + # Round-trip back to a model + r2 = ParseResult835.model_validate(blob) + assert r2.claims[0].total_paid == Decimal("85.00") + assert r2.payer.contact_url == "hcpf.colorado.gov" diff --git a/backend/tests/test_parse_835.py b/backend/tests/test_parse_835.py new file mode 100644 index 0000000..3898184 --- /dev/null +++ b/backend/tests/test_parse_835.py @@ -0,0 +1,126 @@ +"""Tests for the 835 ERA parser orchestrator.""" +from datetime import date +from pathlib import Path + +import pytest + +from cyclone.parsers.exceptions import CycloneParseError +from cyclone.parsers.parse_835 import parse +from cyclone.parsers.payer import PayerConfig835 + +FIXTURE = Path(__file__).parent / "fixtures" / "minimal_835.txt" + + +def test_parse_minimal_fixture_returns_one_claim(): + text = FIXTURE.read_text() + result = parse(text, PayerConfig835.co_medicaid_835()) + assert len(result.claims) == 1 + c = result.claims[0] + assert c.payer_claim_control_number == "CLM001" + assert c.status_code == "1" + assert c.status_label == "Processed as Primary" + assert c.total_charge == 100.00 + assert c.total_paid == 85.00 + assert c.patient_responsibility == 15.00 + assert c.claim_filing_indicator == "MC" + assert c.facility_type == "12" + assert c.frequency_code == "1" + + +def test_parse_envelope_captured(): + result = parse(FIXTURE.read_text(), PayerConfig835.co_medicaid_835()) + assert result.envelope is not None + assert result.envelope.control_number == "991102984" + assert result.envelope.transaction_date == date(2026, 6, 17) + assert result.envelope.sender_id == "COMEDASSISTPROG" + assert result.envelope.implementation_guide == "005010X221A1" + + +def test_parse_financial_info(): + result = parse(FIXTURE.read_text(), PayerConfig835.co_medicaid_835()) + f = result.financial_info + assert f.handling_code == "C" + assert f.paid_amount == 85.00 + assert f.credit_debit_flag == "C" + assert f.payment_method == "ACH" + assert f.payer_tax_id == "81-1725341" + assert f.payment_date == date(2026, 6, 17) + + +def test_parse_trace(): + result = parse(FIXTURE.read_text(), PayerConfig835.co_medicaid_835()) + t = result.trace + assert t.trace_type_code == "1" + # X12 standard: TRN02 = Reference Identification (trace number), + # TRN03 = Originating Company Identifier. The CO guide labels + # TRN03 as the Payer Tax ID, so for CO Medicaid 835s the + # originating_company_id is "81-1725341" and the trace number is + # the provider's TPID. + assert t.trace_number == "TPID001" + assert t.originating_company_id == "81-1725341" + assert t.payer_id is None # TRN04 is not present in this fixture + + +def test_parse_payer_and_payee(): + result = parse(FIXTURE.read_text(), PayerConfig835.co_medicaid_835()) + p = result.payer + assert p.name == "CO_TXIX" + assert p.id == "7912900843" + assert p.contact_url == "hcpf.colorado.gov" + assert p.address is not None + assert p.address.city == "DENVER" + pe = result.payee + assert pe.name == "TOC, Inc." + assert pe.npi == "1881068062" + assert pe.address is not None + assert pe.address.zip == "814014063" + + +def test_parse_claim_has_two_service_lines(): + result = parse(FIXTURE.read_text(), PayerConfig835.co_medicaid_835()) + c = result.claims[0] + assert len(c.service_payments) == 2 + s1, s2 = c.service_payments + assert s1.line_number == 1 + assert s1.procedure_code == "S5130" + assert s1.procedure_qualifier == "HC" + assert s1.modifiers == ["U2"] + assert s1.payment == 50.00 + assert s1.service_date == date(2026, 6, 2) + assert s2.line_number == 2 + assert s2.payment == 35.00 + assert s2.service_date == date(2026, 6, 3) + + +def test_parse_summary_counts(): + result = parse(FIXTURE.read_text(), PayerConfig835.co_medicaid_835()) + assert result.summary.total_claims == 1 + assert result.summary.input_file == "" + # The orchestrator sets passed/failed from the validation report. + # Without running validation it remains 0 — the CLI / API does that. + assert result.summary.passed == 0 + + +def test_parse_preserves_raw_segments(): + result = parse(FIXTURE.read_text(), PayerConfig835.co_medicaid_835()) + c = result.claims[0] + assert any(s[0] == "CLP" for s in c.raw_segments) + assert any(s[0] == "SVC" for s in c.raw_segments) + + +def test_parse_uses_generic_config(): + """Generic config shouldn't suppress parser errors but accepts any payer.""" + result = parse(FIXTURE.read_text(), PayerConfig835.generic_835()) + assert len(result.claims) == 1 + + +def test_parse_no_bpr_raises(): + text = ( + "ISA*00* *00* *ZZ*COMEDASSISTPROG*ZZ*TPID001 *260617*1937*^*00501*991102990*1*P*:~" + "ST*835*991102990*005010X221A1~" + "SE*1*991102990~" + "GE*1*991102990~" + "IEA*1*991102990~" + ) + with pytest.raises(CycloneParseError, match="No BPR"): + parse(text, PayerConfig835.co_medicaid_835()) diff --git a/backend/tests/test_validator_835.py b/backend/tests/test_validator_835.py new file mode 100644 index 0000000..a13e537 --- /dev/null +++ b/backend/tests/test_validator_835.py @@ -0,0 +1,260 @@ +"""Tests for the 835 ERA validator rules.""" +from datetime import date +from decimal import Decimal +from pathlib import Path + +import pytest + +from cyclone.parsers.models import Address, BatchSummary, Envelope +from cyclone.parsers.models_835 import ( + ClaimAdjustment, + ClaimPayment, + FinancialInfo, + ParseResult835, + Payer835, + Payee835, + ReassociationTrace, + ServicePayment, +) +from cyclone.parsers.payer import PayerConfig835 +from cyclone.parsers.validator_835 import validate + + +# --------------------------------------------------------------------------- # +# Builders +# --------------------------------------------------------------------------- # + + +def _build_result(**overrides) -> ParseResult835: + """Build a passing CO Medicaid 835 result; override fields to break rules.""" + financial = FinancialInfo( + handling_code="C", + paid_amount=Decimal("85.00"), + credit_debit_flag="C", + payment_method="ACH", + payer_tax_id="81-1725341", + payment_date=date(2026, 6, 17), + ) + trace = ReassociationTrace( + trace_type_code="1", + trace_number="TPID001", + originating_company_id="TPID001", + payer_id="81-1725341", + ) + payer = Payer835( + name="CO_TXIX", + id="7912900843", + address=Address(line1="PO BOX 11000", city="DENVER", state="CO", zip="802110000"), + ) + payee = Payee835( + name="TOC, Inc.", + npi="1881068062", + address=Address(line1="1100 East Main St", city="Montrose", state="CO", zip="814014063"), + ) + claim = ClaimPayment( + payer_claim_control_number="CLM001", + status_code="1", + status_label="Processed as Primary", + total_charge=Decimal("100.00"), + total_paid=Decimal("85.00"), + patient_responsibility=Decimal("15.00"), + claim_filing_indicator="MC", + original_claim_id="CLM001", + facility_type="12", + frequency_code="1", + per_diem_covered_days=None, + ref_benefit_plan=None, + service_payments=[ + ServicePayment( + line_number=1, + procedure_qualifier="HC", + procedure_code="S5130", + modifiers=["U2"], + charge=Decimal("60.00"), + payment=Decimal("50.00"), + units=Decimal("7.73"), + unit_type="UN", + service_date=date(2026, 6, 2), + adjustments=[], + ref_benefit_plan=None, + ), + ServicePayment( + line_number=2, + procedure_qualifier="HC", + procedure_code="S5130", + modifiers=["U2"], + charge=Decimal("40.00"), + payment=Decimal("35.00"), + units=Decimal("7.73"), + unit_type="UN", + service_date=date(2026, 6, 3), + adjustments=[], + ref_benefit_plan=None, + ), + ], + raw_segments=[], + ) + summary = BatchSummary(input_file="f", total_claims=1, passed=1, failed=0) + result = ParseResult835( + envelope=Envelope( + sender_id="COMEDASSISTPROG", + receiver_id="TPID001", + control_number="991102984", + transaction_date=date(2026, 6, 17), + ), + financial_info=financial, + trace=trace, + payer=payer, + payee=payee, + claims=[claim], + summary=summary, + ) + if overrides: + result = result.model_copy(update=overrides) + return result + + +# --------------------------------------------------------------------------- # +# Sanity: balanced claim passes +# --------------------------------------------------------------------------- # + + +def test_balanced_claim_passes_all_rules(): + cfg = PayerConfig835.co_medicaid_835() + report = validate(_build_result(), cfg) + assert report.passed is True + assert report.errors == [] + + +# --------------------------------------------------------------------------- # +# BPR rules +# --------------------------------------------------------------------------- # + + +def test_r835_bpr01_handling_code_disallowed(): + cfg = PayerConfig835.co_medicaid_835() + result = _build_result(financial_info=_build_result().financial_info.model_copy(update={"handling_code": "Z"})) + report = validate(result, cfg) + assert any(i.rule == "R835_BPR01_handling_code_allowed" for i in report.errors) + assert report.passed is False + + +def test_r835_bpr02_positive(): + cfg = PayerConfig835.co_medicaid_835() + result = _build_result(financial_info=_build_result().financial_info.model_copy(update={"paid_amount": Decimal("0.00")})) + report = validate(result, cfg) + assert any(i.rule == "R835_BPR02_positive" for i in report.errors) + + +def test_r835_bal_bpr_vs_clp04_unbalanced(): + """BPR02 (85.00) must equal sum of CLP04 (85.00). Off by 0.50 → error.""" + cfg = PayerConfig835.co_medicaid_835() + result = _build_result(financial_info=_build_result().financial_info.model_copy(update={"paid_amount": Decimal("85.50")})) + report = validate(result, cfg) + assert any(i.rule == "R835_BAL_BPR_vs_CLP04" for i in report.errors) + assert report.passed is False + + +def test_r835_bal_bpr_vs_clp04_within_tolerance_passes(): + cfg = PayerConfig835.co_medicaid_835() + result = _build_result(financial_info=_build_result().financial_info.model_copy(update={"paid_amount": Decimal("85.005")})) + report = validate(result, cfg) + # 0.005 difference is below tolerance (0.01) — no error. + assert not any(i.rule == "R835_BAL_BPR_vs_CLP04" for i in report.errors) + + +# --------------------------------------------------------------------------- # +# CLP rules +# --------------------------------------------------------------------------- # + + +def test_r835_bal_clp04_vs_svc03_unbalanced(): + cfg = PayerConfig835.co_medicaid_835() + # The default fixture has CLP04=85.00 and SVC03 sum = 50+35 = 85.00. + # Change one SVC payment to 40 → sum = 40+35 = 75, off by 10. + base = _build_result() + svc = base.claims[0].service_payments[0].model_copy(update={"payment": Decimal("40.00")}) + result = base.model_copy(update={"claims": [base.claims[0].model_copy(update={"service_payments": [svc, base.claims[0].service_payments[1]]})]}) + report = validate(result, cfg) + assert any(i.rule == "R835_BAL_CLP04_vs_SVC03" for i in report.errors) + assert "CLM001" in [i.message for i in report.errors if i.rule == "R835_BAL_CLP04_vs_SVC03"][0] + + +def test_r835_clp01_present(): + cfg = PayerConfig835.co_medicaid_835() + result = _build_result(claims=[_build_result().claims[0].model_copy(update={"payer_claim_control_number": ""})]) + report = validate(result, cfg) + assert any(i.rule == "R835_CLP01_present" for i in report.errors) + + +def test_r835_clp02_status_disallowed(): + cfg = PayerConfig835.co_medicaid_835() + result = _build_result(claims=[_build_result().claims[0].model_copy(update={"status_code": "9", "status_label": None})]) + report = validate(result, cfg) + assert any(i.rule == "R835_CLP02_status_allowed" for i in report.errors) + + +def test_r835_clp03_present(): + cfg = PayerConfig835.co_medicaid_835() + result = _build_result(claims=[_build_result().claims[0].model_copy(update={"total_charge": Decimal("0.00")})]) + report = validate(result, cfg) + assert any(i.rule == "R835_CLP03_present" for i in report.errors) + + +# --------------------------------------------------------------------------- # +# Payer / payee rules +# --------------------------------------------------------------------------- # + + +def test_r835_payer_tax_id_mismatch_warning(): + cfg = PayerConfig835.co_medicaid_835() + result = _build_result(financial_info=_build_result().financial_info.model_copy(update={"payer_tax_id": "99-9999999"})) + report = validate(result, cfg) + assert any(i.rule == "R835_PAYER_TAX_ID_matches" and i.severity == "warning" for i in report.warnings) + # Warning only — report still passes. + assert report.passed is True + + +def test_r835_payer_tax_id_skipped_when_set_empty(): + cfg = PayerConfig835.co_medicaid_835() + result = _build_result(financial_info=_build_result().financial_info.model_copy(update={"payer_tax_id": None})) + report = validate(result, cfg) + # When BPR10 is empty, the rule is skipped silently (per spec wording). + assert not any(i.rule == "R835_PAYER_TAX_ID_matches" for i in report.errors + report.warnings) + + +def test_r835_payer_health_plan_id_mismatch_warning(): + cfg = PayerConfig835.co_medicaid_835() + result = _build_result(payer=_build_result().payer.model_copy(update={"id": "12345"})) + report = validate(result, cfg) + assert any(i.rule == "R835_PAYER_HEALTH_PLAN_ID_matches" and i.severity == "warning" for i in report.warnings) + assert report.passed is True + + +def test_r835_payee_npi_format(): + cfg = PayerConfig835.co_medicaid_835() + result = _build_result(payee=_build_result().payee.model_copy(update={"npi": "12345"})) + report = validate(result, cfg) + assert any(i.rule == "R835_PAYEE_NPI_format" for i in report.errors) + assert report.passed is False + + +def test_r835_trn02_originating_company_id_present(): + cfg = PayerConfig835.co_medicaid_835() + result = _build_result(trace=_build_result().trace.model_copy(update={"originating_company_id": ""})) + report = validate(result, cfg) + assert any(i.rule == "R835_REGN01_trn02_present" for i in report.errors) + + +# --------------------------------------------------------------------------- # +# Optional: warnings don't fail the report +# --------------------------------------------------------------------------- # + + +def test_warnings_do_not_set_passed_false(): + cfg = PayerConfig835.co_medicaid_835() + # A wrong payer tax id is a warning; report should still pass. + result = _build_result(financial_info=_build_result().financial_info.model_copy(update={"payer_tax_id": "BAD"})) + report = validate(result, cfg) + assert report.passed is True + assert any(i.severity == "warning" for i in report.warnings)