From 2c0afbe9c5e24c2841a02d57eb024c5cbf05002f Mon Sep 17 00:00:00 2001 From: Tyler Date: Sat, 20 Jun 2026 23:41:01 -0600 Subject: [PATCH] feat(sp10): 277CA parser + Payer-Rejected Inbox lane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add cyclone.parsers.models_277ca + parse_277ca (X12 005010X214) - Per-Patient HL ClaimStatus with REF*1K (PCN), REF*EJ (tax ID), STC category code, amount, service date - STC classifier: A1-A3 accepted, A4/A6/A7 rejected, A8/A9 pended, P1-P5 paid, anything else unknown - Multiple STCs per Patient HL: last wins (canonical pattern for 'first pended, then paid' acknowledgments) - Subscriber-level STCs surface in unscoped_statuses - Add apply_277ca_rejections — stamps Claim.payer_rejected_* fields on matching rows (lookup by patient_control_number, mirrors 999 path) - New /api/parse-277ca, /api/277ca-acks, /api/277ca-acks/{id} endpoints - New two77ca_acks table + Two77caAck ORM model - New Claim columns: payer_rejected_at, _reason, _status_code, _by_277ca_id - New payer_rejected lane in /api/inbox/lanes (distinct from 999 envelope rejected lane) - New PayerConfig277CA block in config/payers.yaml + Pydantic model - Migration 0008 bumps user_version to 8 Tests: 654 -> 688 (parser 22 + apply 6 + API 8 + config 6 + adjustments). All 688 backend tests pass; 1 pre-existing skipped test class unaffected. --- backend/src/cyclone/api.py | 172 ++++++++- backend/src/cyclone/db.py | 51 +++ backend/src/cyclone/inbox_lanes.py | 40 +- backend/src/cyclone/inbox_state_277ca.py | 108 ++++++ .../0008_payer_rejected_columns.sql | 36 ++ backend/src/cyclone/parsers/__init__.py | 10 + backend/src/cyclone/parsers/models_277ca.py | 161 ++++++++ backend/src/cyclone/parsers/parse_277ca.py | 355 ++++++++++++++++++ backend/src/cyclone/payers.py | 8 +- backend/src/cyclone/providers.py | 21 ++ backend/src/cyclone/store.py | 49 +++ backend/tests/fixtures/minimal_277ca.txt | 42 +++ .../fixtures/minimal_277ca_rejected_only.txt | 14 + .../tests/fixtures/minimal_277ca_st277.txt | 15 + backend/tests/test_acks.py | 10 +- backend/tests/test_api_277ca.py | 169 +++++++++ backend/tests/test_apply_277ca_rejections.py | 247 ++++++++++++ backend/tests/test_inbox_endpoints.py | 7 +- backend/tests/test_parse_277ca.py | 247 ++++++++++++ backend/tests/test_payer_config_277ca.py | 104 +++++ config/payers.yaml | 17 + docs/reference/co-medicaid.md | 32 ++ 22 files changed, 1899 insertions(+), 16 deletions(-) create mode 100644 backend/src/cyclone/inbox_state_277ca.py create mode 100644 backend/src/cyclone/migrations/0008_payer_rejected_columns.sql create mode 100644 backend/src/cyclone/parsers/models_277ca.py create mode 100644 backend/src/cyclone/parsers/parse_277ca.py create mode 100644 backend/tests/fixtures/minimal_277ca.txt create mode 100644 backend/tests/fixtures/minimal_277ca_rejected_only.txt create mode 100644 backend/tests/fixtures/minimal_277ca_st277.txt create mode 100644 backend/tests/test_api_277ca.py create mode 100644 backend/tests/test_apply_277ca_rejections.py create mode 100644 backend/tests/test_parse_277ca.py create mode 100644 backend/tests/test_payer_config_277ca.py diff --git a/backend/src/cyclone/api.py b/backend/src/cyclone/api.py index a21cab6..ec23894 100644 --- a/backend/src/cyclone/api.py +++ b/backend/src/cyclone/api.py @@ -34,6 +34,7 @@ from pydantic import ValidationError from cyclone import __version__, db from cyclone.db import Claim, ClaimState, Remittance from cyclone.inbox_state import apply_999_rejections +from cyclone.inbox_state_277ca import apply_277ca_rejections from cyclone.parsers.exceptions import CycloneParseError from cyclone.parsers.models import BatchSummary, ClaimOutput, Envelope, ParseResult from cyclone.parsers.models_270 import ( @@ -48,6 +49,7 @@ from cyclone.parsers.models_835 import ParseResult835 from cyclone.parsers.payer import PayerConfig, PayerConfig835 from cyclone.parsers.parse_270 import parse as parse_270_text from cyclone.parsers.parse_271 import parse as parse_271_text +from cyclone.parsers.parse_277ca import parse_277ca_text from cyclone.parsers.parse_837 import parse from cyclone.parsers.parse_835 import parse as parse_835 from cyclone.parsers.parse_999 import parse_999_text @@ -817,6 +819,171 @@ def _ta1_to_ui(row: db.Ta1Ack) -> dict: } +# --------------------------------------------------------------------------- # +# 277CA (Claim Acknowledgment) — SP10 +# --------------------------------------------------------------------------- # + + +def _277ca_synthetic_source_batch_id(interchange_control_number: str) -> str: + """Return a synthetic ``batches.id`` for a received 277CA with no source batch. + + Mirrors :func:`_ack_synthetic_source_batch_id`. The 277CA row's + ``source_batch_id`` FK requires a row in batches; for received + 277CAs we synthesize an id of the form ``277CA-``. The row + is NOT created in batches — same FK-is-no-op convention as the 999 + path. + """ + return f"277CA-{(interchange_control_number or '').strip() or '000000001'}" + + +@app.post("/api/parse-277ca") +async def parse_277ca_endpoint( + request: Request, + file: UploadFile = File(...), +) -> Any: + """Parse a 277CA Claim Acknowledgment file, persist a row, and stamp rejections. + + Behavior mirrors ``/api/parse-999``: + - 400 on empty / undecodable / malformed EDI (never 500). + - 200 on success with ``{"ack": {id, control_number, accepted_count, + rejected_count, payer_claim_control_numbers, raw_277ca_text}, + "parsed": }``. + + After parse, runs :func:`apply_277ca_rejections` to stamp the + payer-rejected fields on each matching claim row. The Inbox + Payer-Rejected lane lights up as a side-effect of this call. + """ + raw = await file.read() + if not raw: + return JSONResponse( + status_code=400, + content={"error": "Empty file", "detail": "Uploaded file contained no bytes."}, + ) + try: + text = raw.decode("utf-8") + except UnicodeDecodeError as exc: + return JSONResponse( + status_code=400, + content={"error": "Encoding error", "detail": str(exc)}, + ) + + try: + result = parse_277ca_text(text, input_file=file.filename or "") + except CycloneParseError as exc: + return JSONResponse( + status_code=400, + content={"error": "Parse error", "detail": str(exc)}, + ) + except Exception as exc: # pragma: no cover - safety net + log.exception("Unexpected parser failure on 277CA") + return JSONResponse( + status_code=500, + content={"error": "Internal server error", "detail": str(exc)}, + ) + + icn = result.envelope.control_number + synthetic_id = _277ca_synthetic_source_batch_id(icn) + + accepted = sum(1 for s in result.claim_statuses if s.classification == "accepted") + paid = sum(1 for s in result.claim_statuses if s.classification == "paid") + rejected = sum(1 for s in result.claim_statuses if s.classification == "rejected") + pended = sum(1 for s in result.claim_statuses if s.classification == "pended") + + # Persist the 277CA row first so we have an id to attach to claims. + row = store.add_277ca_ack( + source_batch_id=synthetic_id, + control_number=icn, + accepted_count=accepted, + rejected_count=rejected, + paid_count=paid, + pended_count=pended, + raw_json=json.loads(result.model_dump_json()), + ) + + # Stamp payer-rejection fields on matching claims. The 277CA's + # REF*1K carries the patient's claim control number we sent in + # CLM01 — same convention the 999 ACK uses, so the lookup hits + # Claim.patient_control_number (mirrors apply_999_rejections). + with db.SessionLocal()() as session: + def _lookup(pcn: str): + return ( + session.query(Claim) + .filter(Claim.patient_control_number == pcn) + .first() + ) + apply_result = apply_277ca_rejections( + session, result, claim_lookup=_lookup, two77ca_id=row.id, + ) + if apply_result.matched: + bus = request.app.state.event_bus + for cid in apply_result.matched: + await bus.publish("claim.payer_rejected", {"claim_id": cid}) + if apply_result.orphans: + log.warning( + "277CA had %d orphan status entries (no matching claim): %s", + len(apply_result.orphans), + apply_result.orphans[:5], + ) + + return JSONResponse(content={ + "ack": { + "id": row.id, + "control_number": icn, + "accepted_count": accepted, + "rejected_count": rejected, + "paid_count": paid, + "pended_count": pended, + "source_batch_id": synthetic_id, + "matched_claim_ids": apply_result.matched, + "orphan_status_codes": apply_result.orphans, + }, + "parsed": json.loads(result.model_dump_json()), + }) + + +@app.get("/api/277ca-acks") +def list_277ca_acks_endpoint( + limit: int = Query(100, ge=1, le=1000), +) -> Any: + """Return the list of persisted 277CA ACKs, newest first.""" + rows = store.list_277ca_acks() + items = [_277ca_to_ui(r) for r in rows[:limit]] + return {"total": len(rows), "items": items} + + +@app.get("/api/277ca-acks/{ack_id}") +def get_277ca_ack_endpoint(ack_id: int) -> dict: + """Return one persisted 277CA ACK row with its parsed detail.""" + row = store.get_277ca_ack(ack_id) + if row is None: + raise HTTPException(status_code=404, detail=f"277CA ACK {ack_id} not found") + return { + "id": row.id, + "control_number": row.control_number, + "accepted_count": row.accepted_count, + "rejected_count": row.rejected_count, + "paid_count": row.paid_count, + "pended_count": row.pended_count, + "source_batch_id": row.source_batch_id, + "parsed_at": row.parsed_at.isoformat() if row.parsed_at else None, + "raw_json": row.raw_json, + } + + +def _277ca_to_ui(row) -> dict: + """Render a 277caAck row for the UI (list endpoint shape).""" + return { + "id": row.id, + "control_number": row.control_number, + "accepted_count": row.accepted_count, + "rejected_count": row.rejected_count, + "paid_count": row.paid_count, + "pended_count": row.pended_count, + "source_batch_id": row.source_batch_id, + "parsed_at": row.parsed_at.isoformat() if row.parsed_at else None, + } + + def _serialize_ta1(result) -> str: """Render a TA1 file from a ParseResultTa1 for the ``raw_ta1_text`` field. @@ -850,13 +1017,16 @@ def _serialize_ta1_from_row(row: db.Ta1Ack) -> str: @app.get("/api/inbox/lanes") def inbox_lanes(): - """Return all four Inbox lanes in one call.""" + """Return all Inbox lanes in one call.""" dismissed_pairs = getattr(app.state, "dismissed_pairs", set()) with db.SessionLocal()() as session: from cyclone.inbox_lanes import compute_lanes lanes = compute_lanes(session, dismissed_pairs=dismissed_pairs) return { "rejected": lanes.rejected, + # SP10: payer-rejected lane (277CA STC A4/A6/A7). Distinct from + # the 999 envelope rejection in ``rejected`` above. + "payer_rejected": lanes.payer_rejected, "candidates": lanes.candidates, "unmatched": lanes.unmatched, "done_today": lanes.done_today, diff --git a/backend/src/cyclone/db.py b/backend/src/cyclone/db.py index 96bf0e5..7e2893f 100644 --- a/backend/src/cyclone/db.py +++ b/backend/src/cyclone/db.py @@ -205,6 +205,21 @@ class Claim(Base): rejected_at: Mapped[Optional[datetime]] = mapped_column( DateTime(timezone=True), nullable=True ) + # SP10: payer-side rejection (277CA STC A4/A6/A7) — distinct from + # the 999 envelope rejection above. A claim can be rejected at the + # envelope level (bad file) or at the payer-adjudication level + # (good file, bad claim). We track them separately so the Inbox + # Payer-Rejected lane can distinguish. + payer_rejected_at: Mapped[Optional[datetime]] = mapped_column( + DateTime(timezone=True), nullable=True + ) + payer_rejected_reason: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + payer_rejected_status_code: Mapped[Optional[str]] = mapped_column( + String(8), nullable=True + ) + payer_rejected_by_277ca_id: Mapped[Optional[str]] = mapped_column( + String(64), nullable=True + ) resubmit_count: Mapped[int] = mapped_column( Integer, nullable=False, default=0, server_default=text("0") ) @@ -519,6 +534,42 @@ class Ta1Ack(Base): ) +class Two77caAck(Base): + """277CA (Claim Acknowledgment) row — one per parsed 277CA file. + + Mirrors :class:`Ta1Ack` but for the *semantic* claim-level ack. + A 277CA acknowledges individual claims by REF*1K + (payer_claim_control_number) rather than the whole envelope. + + Per X12 005010X214 a single 277CA can carry many claim statuses; + we keep the per-claim detail in ``raw_json`` and promote only the + counts + ICN here so the list endpoint stays fast. + + ``source_batch_id`` uses the synthetic ``277CA-`` id — same + FK-is-no-op convention as the 999 / TA1 paths. The 277CA itself + never has an inbound Cyclone batch to point at. + """ + + __tablename__ = "two77ca_acks" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + source_batch_id: Mapped[str] = mapped_column( + String(32), ForeignKey("batches.id", ondelete="CASCADE"), nullable=False, + ) + control_number: Mapped[str] = mapped_column(String(32), nullable=False) + accepted_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + rejected_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + paid_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + pended_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + parsed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + raw_json: Mapped[Optional[dict]] = mapped_column(JSONText, nullable=True) + + __table_args__ = ( + Index("ix_two77ca_acks_source_batch_id", "source_batch_id"), + Index("ix_two77ca_acks_control_number", "control_number"), + ) + + # --------------------------------------------------------------------------- # SP9: providers, payers, payer_configs, clearhouse # --------------------------------------------------------------------------- diff --git a/backend/src/cyclone/inbox_lanes.py b/backend/src/cyclone/inbox_lanes.py index 6da30e0..1a8a9d4 100644 --- a/backend/src/cyclone/inbox_lanes.py +++ b/backend/src/cyclone/inbox_lanes.py @@ -1,12 +1,15 @@ -"""Compute the four Inbox lanes from the DB on read. +"""Compute the Inbox lanes from the DB on read. SP6 T6. Lanes: - - rejected: claims whose 999 AK5 set-level response was R/E - - candidates: remits without a matched claim, with scoreable claims - - unmatched: claims still SUBMITTED with no remittance in flight - - done_today: claims that reached a terminal state in the last 24h + - rejected: claims whose 999 AK5 set-level response was R/E + - payer_rejected: claims whose 277CA STC category is A4/A6/A7 + (SP10: payer-side rejection, distinct from 999 + envelope rejection) + - candidates: remits without a matched claim, with scoreable claims + - unmatched: claims still SUBMITTED with no remittance in flight + - done_today: claims that reached a terminal state in the last 24h """ from __future__ import annotations @@ -23,6 +26,7 @@ from cyclone.scoring import score_pair, ScoreBreakdown @dataclass class Lanes: rejected: list[dict] = field(default_factory=list) + payer_rejected: list[dict] = field(default_factory=list) candidates: list[dict] = field(default_factory=list) unmatched: list[dict] = field(default_factory=list) done_today: list[dict] = field(default_factory=list) @@ -63,6 +67,12 @@ def _claim_to_row( "provider": score.provider, } if score else None, "matched_remittance": matched_remittance, + # SP10: payer-side rejection fields. Populated when a 277CA + # acknowledged the claim with STC A4/A6/A7. + "payer_rejected_at": _isoformat(c.payer_rejected_at), + "payer_rejected_reason": c.payer_rejected_reason, + "payer_rejected_status_code": c.payer_rejected_status_code, + "payer_rejected_by_277ca_id": c.payer_rejected_by_277ca_id, } @@ -176,6 +186,26 @@ def compute_lanes(session: Session, *, dismissed_pairs: Iterable[frozenset]) -> ), )) + # --- Payer-Rejected (SP10) --- + # Distinct from the 999 envelope "rejected" lane above. A claim + # lands here when a 277CA STC category code is A4/A6/A7 (rejected + # by the payer after we submitted a syntactically-valid file). + # We don't filter by Claim.state here because the claim may still + # be in SUBMITTED state — the payer just hasn't paid it yet. + payer_rejected_claims = ( + session.query(Claim).filter(Claim.payer_rejected_at.is_not(None)).all() + ) + pr_matched, pr_total = _line_count_lookup(session, payer_rejected_claims) + matched_counts.update(pr_matched) + total_lines_by_claim.update(pr_total) + for c in payer_rejected_claims: + lanes.payer_rejected.append(_claim_to_row( + c, kind="claim", + matched_remittance=_matched_remittance_block( + session, c, matched_counts, total_lines_by_claim, + ), + )) + # --- Done today --- cutoff = datetime.now(timezone.utc) - timedelta(hours=24) terminal_states = { diff --git a/backend/src/cyclone/inbox_state_277ca.py b/backend/src/cyclone/inbox_state_277ca.py new file mode 100644 index 0000000..221a33c --- /dev/null +++ b/backend/src/cyclone/inbox_state_277ca.py @@ -0,0 +1,108 @@ +"""277CA Claim Acknowledgment → claim payer-rejection state transitions. + +SP10 T2. + +For each ``ClaimStatus`` in a parsed 277CA whose ``classification`` +is ``"rejected"`` (STC category codes A4 / A6 / A7), look up the +matching Cyclone claim row by ``payer_claim_control_number`` and stamp +``payer_rejected_at`` + ``payer_rejected_reason`` + the STC category +code + the originating 277CA row id. + +Distinct from :func:`cyclone.inbox_state.apply_999_rejections` (which +handles envelope-level 999 AK5 R/E rejections). A claim can be: + * rejected at the envelope level only (999 R, no 277CA yet) + * payer-rejected only (999 A, then 277CA STC A6 — file was fine, + claim was denied) + * both (rare — envelope retry after payer rejection) + +We never overwrite a previous ``payer_rejected_at`` with ``NULL`` +(empty STC list) — that prevents a later, looser 277CA from +accidentally clearing the rejection flag. +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Callable + +from sqlalchemy.orm import Session + +from cyclone.db import Claim + + +@dataclass +class Apply277CAResult: + matched: list[str] = field(default_factory=list) + orphans: list[str] = field(default_factory=list) + already_rejected: list[str] = field(default_factory=list) + + +def _build_reason(status_code: str, status_description: str | None) -> str: + parts = [f"277CA STC {status_code}"] + if status_description: + parts.append(f"({status_description})") + return " ".join(parts) + + +def apply_277ca_rejections( + session: Session, + parsed_277ca, + *, + claim_lookup: Callable[[str], Claim | None], + two77ca_id: str, +) -> Apply277CAResult: + """For each rejected ``ClaimStatus``, look up the matching claim and stamp it. + + Args: + session: SQLAlchemy session. + parsed_277ca: a :class:`cyclone.parsers.models_277ca.ParseResult277CA`. + claim_lookup: callable from ``payer_claim_control_number`` → + ``Claim`` or ``None``. Use REF*1K (the payer claim control + number) — that's the canonical cross-reference. + two77ca_id: the id of the persisted 277CA row that produced + this parsed result. Stored on each claim so an operator + can trace the rejection back to the source file. + + Returns: + Apply277CAResult with matched/orphan/already-rejected lists. + """ + result = Apply277CAResult() + now = datetime.now(timezone.utc) + + for status in parsed_277ca.claim_statuses: + if status.classification != "rejected": + continue + pcn = status.payer_claim_control_number + if not pcn: + # No REF*1K — we can't tie this rejection to a Cyclone claim. + # Surface it as an orphan so the operator knows we saw it. + result.orphans.append(status.status_code or "") + continue + + claim = claim_lookup(pcn) + if claim is None: + result.orphans.append(pcn) + continue + + if claim.payer_rejected_at is not None: + # Idempotent — already stamped. Update only if this is a + # newer status code (e.g. A6 → A7 escalation). + if (claim.payer_rejected_status_code or "") == status.status_code: + result.already_rejected.append(claim.id) + continue + + claim.payer_rejected_at = now + claim.payer_rejected_reason = _build_reason( + status.status_code, status.status_description, + ) + claim.payer_rejected_status_code = status.status_code + claim.payer_rejected_by_277ca_id = two77ca_id + result.matched.append(claim.id) + + if result.matched or result.already_rejected: + session.commit() + + return result + + +__all__ = ["Apply277CAResult", "apply_277ca_rejections"] diff --git a/backend/src/cyclone/migrations/0008_payer_rejected_columns.sql b/backend/src/cyclone/migrations/0008_payer_rejected_columns.sql new file mode 100644 index 0000000..0f590a0 --- /dev/null +++ b/backend/src/cyclone/migrations/0008_payer_rejected_columns.sql @@ -0,0 +1,36 @@ +-- version: 8 +-- SP10: 277CA Payer-Rejected lane +-- Adds columns to capture the payer-side rejection state separate from +-- the existing 999-envelope rejection (file-level). A claim can be: +-- * REJECTED via 999 ACK AK5 R/E (envelope rejection, already covered +-- by rejected_at + rejection_reason from migration 0004) +-- * PAYER_REJECTED via 277CA STC A4/A6/A7 (claim-level rejection by +-- the payer after envelope acceptance) +-- We keep these distinct so operators can tell *why* a claim isn't +-- paid: was it our file (999), or was it the payer's adjudication? +-- +-- Also creates two77ca_acks to persist parsed 277CA files (one row per +-- inbound 277CA file, with the per-claim status detail in raw_json). + +ALTER TABLE claims ADD COLUMN payer_rejected_at TEXT; +ALTER TABLE claims ADD COLUMN payer_rejected_reason TEXT; +ALTER TABLE claims ADD COLUMN payer_rejected_status_code TEXT; +ALTER TABLE claims ADD COLUMN payer_rejected_by_277ca_id TEXT; + +CREATE INDEX idx_claims_payer_rejected_at ON claims(payer_rejected_at); + +CREATE TABLE two77ca_acks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source_batch_id TEXT NOT NULL REFERENCES batches(id) ON DELETE CASCADE, + control_number TEXT NOT NULL, + accepted_count INTEGER NOT NULL DEFAULT 0, + rejected_count INTEGER NOT NULL DEFAULT 0, + paid_count INTEGER NOT NULL DEFAULT 0, + pended_count INTEGER NOT NULL DEFAULT 0, + parsed_at TEXT NOT NULL, + raw_json TEXT +); + +CREATE INDEX ix_two77ca_acks_source_batch_id ON two77ca_acks(source_batch_id); +CREATE INDEX ix_two77ca_acks_control_number ON two77ca_acks(control_number); + diff --git a/backend/src/cyclone/parsers/__init__.py b/backend/src/cyclone/parsers/__init__.py index 2003884..e6b1de4 100644 --- a/backend/src/cyclone/parsers/__init__.py +++ b/backend/src/cyclone/parsers/__init__.py @@ -66,6 +66,15 @@ _LAZY_EXPORTS: dict[str, str] = { "SERVICE_TYPE_CODES": "cyclone.parsers.models_271", "LAST_UPDATED": "cyclone.parsers.models_271", "service_type_description": "cyclone.parsers.models_271", + # models (277CA — SP10) + "ACCEPTED_CODES": "cyclone.parsers.models_277ca", + "AcknowledgmentHeader277": "cyclone.parsers.models_277ca", + "ClaimStatus": "cyclone.parsers.models_277ca", + "PAID_CODES": "cyclone.parsers.models_277ca", + "PENDED_CODES": "cyclone.parsers.models_277ca", + "ParseResult277CA": "cyclone.parsers.models_277ca", + "REJECTED_CODES": "cyclone.parsers.models_277ca", + "classify_status_code": "cyclone.parsers.models_277ca", # CARC lookup (SP3 P2 T6) "reason_label": "cyclone.parsers.cas_codes", "all_known_codes": "cyclone.parsers.cas_codes", @@ -81,6 +90,7 @@ _LAZY_EXPORTS: dict[str, str] = { "parse_999": "cyclone.parsers.parse_999", "parse_270": "cyclone.parsers.parse_270", "parse_271": "cyclone.parsers.parse_271", + "parse_277ca": "cyclone.parsers.parse_277ca", "serialize_999": "cyclone.parsers.serialize_999", "serialize_270": "cyclone.parsers.serialize_270", "build_ack_for_batch": "cyclone.parsers.batch_ack_builder", diff --git a/backend/src/cyclone/parsers/models_277ca.py b/backend/src/cyclone/parsers/models_277ca.py new file mode 100644 index 0000000..ad09518 --- /dev/null +++ b/backend/src/cyclone/parsers/models_277ca.py @@ -0,0 +1,161 @@ +"""Pydantic v2 models for parsed 277CA (Claim Acknowledgment) files. + +Mirrors the X12 005010X214 segment shape (HCPF sends this transaction +back after we submit an 837P batch): + +- ``AcknowledgmentHeader`` (BHT) — beginning of hierarchical transaction +- ``ClaimStatus`` (STC + REF*1K + REF*EJ + AMT*YU + DTP*472) — one per + payer-acknowledged claim. Carries the category/status code and the + payer claim control number used to match back to a Cyclone claim row. +- ``ParseResult277CA`` — top-level envelope + per-claim status list. + +Why this lean shape: the 277CA is a status-reporting transaction, not a +claim. We surface just enough information to (a) match each STC row back +to a Cyclone claim by ``payer_claim_control_number`` (REF*1K) and +(b) classify it as accepted / pended / rejected / paid so the Inbox +Payer-Rejected lane can light up. + +Per HCPF X12 File Naming Standards, the inbound filename uses ``277`` +in the file_type slot (the transaction-set id ``277CA`` is conveyed in +the ST segment itself, not in the filename). +""" +from __future__ import annotations + +from datetime import date +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, model_serializer + +from cyclone.parsers.models import BatchSummary, Envelope, ValidationIssue, ValidationReport + + +# --------------------------------------------------------------------------- # +# Shared base +# --------------------------------------------------------------------------- # + + +class _Base(BaseModel): + """Shared Pydantic base; matches the 999 / 835 / 270 / 271 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 + + +# --------------------------------------------------------------------------- # +# 277CA segment shapes +# --------------------------------------------------------------------------- # + + +# Status category codes per X12 005010X214 §1.4.2 STC01-1. +# We categorize each STC into a small set of lanes the Inbox cares about. +ACCEPTED_CODES = {"A1", "A2", "A3"} +REJECTED_CODES = {"A4", "A6", "A7"} +PENDED_CODES = {"A8", "A9"} +PAID_CODES = {"P1", "P2", "P3", "P4", "P5"} + + +def classify_status_code(code: str) -> Literal["accepted", "rejected", "pended", "paid", "unknown"]: + """Map an STC01-1 category code to an Inbox lane label. + + Unknown codes (rare; new payer-specific codes) become ``"unknown"`` + so the parser doesn't raise — operators can still see them in the + 277CA detail view. + """ + code = (code or "").strip().upper() + if code in ACCEPTED_CODES: + return "accepted" + if code in REJECTED_CODES: + return "rejected" + if code in PENDED_CODES: + return "pended" + if code in PAID_CODES: + return "paid" + return "unknown" + + +class AcknowledgmentHeader(_Base): + """BHT — Beginning of Hierarchical Transaction. + + BHT01 = hierarchical_structure_code (e.g. "0085" for Claim Ack). + BHT02 = transaction_set_purpose_code (e.g. "08" for Status). + BHT03 = reference_identification (often the submitter batch id). + BHT04 = transaction_set_creation_date (CCYYMMDD). + BHT05 = transaction_set_creation_time (HHMM). + BHT06 = transaction_type_code (e.g. "TH"). + """ + + hierarchical_structure_code: str + transaction_set_purpose_code: str + reference_identification: str | None = None + transaction_set_creation_date: date | None = None + transaction_set_creation_time: str | None = None + transaction_type_code: str | None = None + + +class ClaimStatus(_Base): + """STC + REF*1K + REF*EJ + AMT*YU + DTP*472 — one payer-acknowledged claim. + + STC01 is a composite of three elements: ``category_code:status_code:entity_identifier``. + HCPF only populates the category code (e.g. ``A3:19:PR``); the parser + surfaces ``category_code`` and ``status_code`` separately. + + ``payer_claim_control_number`` is the REF*1K value the payer assigned + (or echoed back) for the claim. This is what matches back to a + Cyclone claim row (we store it on Claim.payer_claim_control_number + during 837 serialize). + + ``amount`` is the AMT*YU value (total claim charge amount + acknowledged by the payer). Optional — HCPF omits it for non-claim + statuses (e.g. subscriber-level pends). + """ + + status_code: str + status_description: str | None = None + entity_identifier: str | None = None + status_effective_date: date | None = None + status_action_code: str | None = None + total_claim_charge_amount: float | None = None + payer_claim_control_number: str | None = None + billing_provider_tax_id: str | None = None + service_date: date | None = None + classification: Literal["accepted", "rejected", "pended", "paid", "unknown"] = "unknown" + + +class ParseResult277CA(_Base): + """Top-level parsed 277CA document. + + Multiple claims can be acknowledged in a single 277CA; the parser + flattens the HL hierarchy (20→21→19→PT) into one ClaimStatus per + Patient-level HL. Subscriber- and provider-level statuses (when no + Patient HL exists) are also captured under ``unscoped_statuses`` + for operator visibility. + """ + + envelope: Envelope + bht: AcknowledgmentHeader + claim_statuses: list[ClaimStatus] = Field(default_factory=list) + unscoped_statuses: list[ClaimStatus] = Field(default_factory=list) + summary: BatchSummary + + +__all__ = [ + "ACCEPTED_CODES", + "AcknowledgmentHeader", + "ClaimStatus", + "PAID_CODES", + "PENDED_CODES", + "ParseResult277CA", + "REJECTED_CODES", + "classify_status_code", +] + + +# Silence unused-import lints. +_ = (BaseModel, ValidationIssue, ValidationReport) diff --git a/backend/src/cyclone/parsers/parse_277ca.py b/backend/src/cyclone/parsers/parse_277ca.py new file mode 100644 index 0000000..9c57bf3 --- /dev/null +++ b/backend/src/cyclone/parsers/parse_277ca.py @@ -0,0 +1,355 @@ +"""Parse an X12 277CA (Claim Acknowledgment) file. + +The 277CA is the *semantic* ack a payer (or its clearinghouse) sends +back after they accept our 837P batch. It contrasts with the 999 ACK +which only reports the *syntactic* envelope status. A 999 "Accepted" +plus a 277CA with STC*A6 means: the file was syntactically valid, but +one or more specific claims were rejected by the payer. + +Layout (simplified, single claim):: + + ISA*…~ + GS*HN*…*…*20240620*1200*1*X*005010X214~ + ST*277*0001*005010X214~ (HCPF sometimes sends ST*277CA*0001*005010X214) + BHT*0085*08*REFNUM*20240620*1200*TH~ + HL*1**20*1~ (Information Source — the payer) + NM1*PR*2*COLORADO MEDICAL ASSISTANCE PROGRAM*****PI*COMEDASSISTPROG~ + HL*2*1*21*1~ (Information Receiver — us) + NM1*41*2*DZINESCO*****46*DZINESCO~ + HL*3*2*19*1~ (Subscriber) + NM1*IL*1*DOE*JOHN****MI*MEMBERID~ + HL*4*3*PT~ (Patient — a claim follows) + NM1*QC*1*DOE*JANE~ + REF*1K*PAYERCLAIMID123~ + REF*EJ*721587149~ + STC*A6:19:PR*20240620*U*150.00~ (rejected) + QTY*90*1~ + AMT*YU*150.00~ + DTP*472*RD8*20240601-20240601~ + SE*XX*0001~ + GE*1*1~ + IEA*1*000000001~ + +The parser walks the HL hierarchy and, for each ``HL*…*PT`` segment, +collects the trailing ``STC*``, ``REF*1K``, ``REF*EJ``, ``AMT*YU`` and +``DTP*472`` siblings into a :class:`ClaimStatus`. STC segments under +non-Patient HLs (provider- or subscriber-level) go into +``unscoped_statuses`` — HCPF sometimes sends a blanket reject at the +subscriber level which we want to surface even though we can't tie it +to a specific Cyclone claim row. +""" +from __future__ import annotations + +import logging +from datetime import date + +from cyclone.parsers.exceptions import CycloneParseError +from cyclone.parsers.models import BatchSummary, Envelope +from cyclone.parsers.models_277ca import ( + AcknowledgmentHeader, + ClaimStatus, + ParseResult277CA, + classify_status_code, +) +from cyclone.parsers.segments import tokenize + +log = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- # +# Date / amount parsing +# --------------------------------------------------------------------------- # + + +def _parse_yyyymmdd(s: str) -> date | None: + if not s or len(s) != 8 or not s.isdigit(): + return None + try: + return date(int(s[0:4]), int(s[4:6]), int(s[6:8])) + except ValueError: + return None + + +def _parse_yymmdd(s: str) -> date | None: + if not s or len(s) != 6 or not s.isdigit(): + return None + try: + return date(2000 + int(s[0:2]), int(s[2:4]), int(s[4:6])) + except ValueError: + return None + + +def _parse_date_loose(s: str) -> date | None: + """Parse an X12 date — accept 8-digit CCYYMMDD or 6-digit YYMMDD.""" + if not s or not s.isdigit(): + return None + if len(s) == 8: + return _parse_yyyymmdd(s) + if len(s) == 6: + return _parse_yymmdd(s) + return None + + +def _parse_service_date(s: str) -> date | None: + """Parse DTP*472 values. HCPF sends ``RD8*20240601-20240601`` (range) or ``D8*20240601``. + + For ranges we return the start date — that's what matches the + Cyclone claim's ``service_date_from``. + """ + if not s: + return None + # RD8 = range of dates (CCYYMMDD-CCYYMMDD); take the start. + if "-" in s: + start = s.split("-", 1)[0] + return _parse_date_loose(start) + return _parse_date_loose(s) + + +def _parse_amount(s: str) -> float | None: + """Parse an X12 monetary value. Returns None on bad input.""" + if not s: + return None + try: + return float(s) + except ValueError: + return None + + +# --------------------------------------------------------------------------- # +# Envelope +# --------------------------------------------------------------------------- # + + +def _build_envelope(segments: list[list[str]], input_file: str) -> tuple[Envelope, str]: + """Build the envelope from ISA/GS/ST. Returns ``(envelope, tx_set_id)``. + + ``tx_set_id`` carries the ST01 value (``"277"`` or ``"277CA"``) so + the rest of the parser doesn't have to re-scan the segment list. + """ + summary = BatchSummary(input_file=input_file) + envelope: Envelope | None = None + tx_set_id: str = "" + txn_date: date | None = None + for seg in segments: + if seg[0] == "ISA": + try: + envelope = Envelope( + sender_id=seg[6].strip(), + receiver_id=seg[8].strip(), + control_number=seg[13].strip(), + transaction_date=date(2024, 1, 1), + implementation_guide=None, + ) + except (IndexError, ValueError) as exc: + raise CycloneParseError(f"Bad ISA: {exc}") from exc + elif seg[0] == "GS" and envelope is not None: + if len(seg) > 4: + txn_date = _parse_date_loose(seg[3]) or txn_date + elif seg[0] == "ST" and envelope is not None: + if len(seg) > 1: + tx_set_id = seg[1] + if len(seg) > 3: + envelope = envelope.model_copy(update={"implementation_guide": seg[3]}) + if envelope is None: + raise CycloneParseError("No ISA envelope found") + if txn_date is not None: + envelope = envelope.model_copy(update={"transaction_date": txn_date}) + return envelope, tx_set_id + + +def _build_bht(segments: list[list[str]]) -> AcknowledgmentHeader: + """Find the BHT segment and return it. Falls back to an empty header.""" + for seg in segments: + if seg[0] == "BHT": + return AcknowledgmentHeader( + hierarchical_structure_code=seg[1] if len(seg) > 1 else "", + transaction_set_purpose_code=seg[2] if len(seg) > 2 else "", + reference_identification=seg[3] if len(seg) > 3 and seg[3] else None, + transaction_set_creation_date=_parse_date_loose(seg[4]) if len(seg) > 4 else None, + transaction_set_creation_time=seg[5] if len(seg) > 5 and seg[5] else None, + transaction_type_code=seg[6] if len(seg) > 6 and seg[6] else None, + ) + return AcknowledgmentHeader( + hierarchical_structure_code="", + transaction_set_purpose_code="", + ) + + +# --------------------------------------------------------------------------- # +# HL-walking and per-claim status construction +# --------------------------------------------------------------------------- # + + +def _parse_stc(seg: list[str]) -> ClaimStatus: + """Convert an STC segment into a partial ClaimStatus. + + STC is a composite segment: ``STC*cat:stat:entity*date*action*amount``. + HCPF only populates ``cat`` (``A6:19:PR``); the rest are optional. + """ + composite = seg[1] if len(seg) > 1 else "" + parts = composite.split(":") if composite else [] + category = parts[0] if parts else "" + status = parts[1] if len(parts) > 1 and parts[1] else None + entity = parts[2] if len(parts) > 2 and parts[2] else None + effective = _parse_date_loose(seg[2]) if len(seg) > 2 else None + action = seg[3] if len(seg) > 3 and seg[3] else None + amount = _parse_amount(seg[4]) if len(seg) > 4 else None + return ClaimStatus( + status_code=category, + status_description=status, + entity_identifier=entity, + status_effective_date=effective, + status_action_code=action, + total_claim_charge_amount=amount, + classification=classify_status_code(category), + ) + + +def _consume_patient_block(segments: list[list[str]], idx: int) -> tuple[ClaimStatus | None, int]: + """Read all segments under an HL*…*PT block (until the next HL or SE). + + Returns ``(status, next_idx)``. ``status`` is ``None`` when the block + contained no STC segments (a Patient HL with no status is unusual + but not illegal — surface it as ``None`` so the caller can log). + """ + parts: dict[str, object] = {} + stc: ClaimStatus | None = None + i = idx + while i < len(segments): + seg = segments[i] + if seg[0] in {"HL", "SE"}: + break + if seg[0] == "REF": + qualifier = seg[1] if len(seg) > 1 else "" + value = seg[2] if len(seg) > 2 else "" + if qualifier == "1K": + parts["payer_claim_control_number"] = value + elif qualifier == "EJ": + parts["billing_provider_tax_id"] = value + elif seg[0] == "STC": + # Per X12 005010X214 a Patient HL can have multiple STC + # segments (one per status). We take the last one as + # authoritative — that's typically the most recent action + # (e.g. STC*A1 then STC*A3 means "first pended, then paid"). + stc = _parse_stc(seg) + elif seg[0] == "AMT": + qualifier = seg[1] if len(seg) > 1 else "" + value = _parse_amount(seg[2]) if len(seg) > 2 else None + if qualifier == "YU" and value is not None: + parts["total_claim_charge_amount"] = value + elif seg[0] == "DTP": + qualifier = seg[1] if len(seg) > 1 else "" + fmt = seg[2] if len(seg) > 2 else "" + value = seg[3] if len(seg) > 3 else "" + if qualifier == "472" and fmt in ("D8", "RD8"): + parts["service_date"] = _parse_service_date(value) + i += 1 + + if stc is None: + return None, i + + # Merge the REF/AMT/DTP capture onto the last STC. + merged = stc.model_copy(update=parts) + return merged, i + + +def _consume_subscriber_block(segments: list[list[str]], idx: int) -> tuple[list[ClaimStatus], int]: + """Read STC segments under a Subscriber (HL*…*19*1) block. + + Subscriber-level STCs have no REF*1K — they apply to the whole + subscriber's claim batch. Surface them in + :attr:`ParseResult277CA.unscoped_statuses`. + """ + out: list[ClaimStatus] = [] + i = idx + while i < len(segments): + seg = segments[i] + if seg[0] in {"HL", "SE"}: + break + if seg[0] == "STC": + out.append(_parse_stc(seg)) + i += 1 + return out, i + + +# --------------------------------------------------------------------------- # +# Top-level orchestrator +# --------------------------------------------------------------------------- # + + +def parse_277ca_text(text: str, *, input_file: str = "") -> ParseResult277CA: + """Parse a complete 277CA document and return a :class:`ParseResult277CA`. + + Both ``ST*277*`` and ``ST*277CA*`` shapes are accepted. Per X12 + 005010X214 a 277CA can have multiple Patient HLs — we surface them + all in ``claim_statuses``. STC segments under Subscriber HLs go + into ``unscoped_statuses`` because we can't tie them to a Cyclone + claim row without an explicit REF*1K. + + Whole-document problems (missing ISA, no ST) raise + :class:`CycloneParseError`. Per-segment quirks are surfaced on the + result and never raised, matching the 999 parser's contract. + """ + segments = tokenize(text) + envelope, tx_set_id = _build_envelope(segments, input_file=input_file) + + if tx_set_id not in ("277", "277CA"): + raise CycloneParseError( + f"Expected ST*277 or ST*277CA, got ST*{tx_set_id!r}" + ) + + bht = _build_bht(segments) + + claim_statuses: list[ClaimStatus] = [] + unscoped_statuses: list[ClaimStatus] = [] + + # Walk all HL segments, in order. + for i, seg in enumerate(segments): + if seg[0] != "HL": + continue + # HL04 carries the level code: "20" (info source), "21" (info + # receiver), "19" (subscriber), "PT" (patient). Only PT and 19 + # carry claim-level STCs. + level_code = seg[3] if len(seg) > 3 else "" + if level_code == "PT": + status, _next = _consume_patient_block(segments, i + 1) + if status is not None: + claim_statuses.append(status) + elif level_code == "19": + # Subscriber-level: only surface when there's actually an + # STC under it (otherwise it's a blank frame). + inner, _next = _consume_subscriber_block(segments, i + 1) + if inner: + unscoped_statuses.extend(inner) + + # Summary counts — for the Inbox lane counts. + total = len(claim_statuses) + accepted = sum(1 for s in claim_statuses if s.classification == "accepted") + rejected = sum(1 for s in claim_statuses if s.classification == "rejected") + pended = sum(1 for s in claim_statuses if s.classification == "pended") + paid = sum(1 for s in claim_statuses if s.classification == "paid") + # "failed" is what the Inbox cares about: rejected + pended. + # Pended claims aren't final (the payer may still pay them) but we + # surface them under a different lane (Payer-Pended, added later). + # For the SP10 Payer-Rejected lane only "rejected" counts. + failed = rejected + summary = BatchSummary( + input_file=input_file, + control_number=envelope.control_number, + transaction_date=envelope.transaction_date, + total_claims=total, + passed=accepted + paid, + failed=failed, + ) + _ = pended # surfaced separately in future SPs; tracked but unused here + + return ParseResult277CA( + envelope=envelope, + bht=bht, + claim_statuses=claim_statuses, + unscoped_statuses=unscoped_statuses, + summary=summary, + ) + + +__all__ = ["parse_277ca_text"] diff --git a/backend/src/cyclone/payers.py b/backend/src/cyclone/payers.py index 38b7308..6d7906f 100644 --- a/backend/src/cyclone/payers.py +++ b/backend/src/cyclone/payers.py @@ -20,7 +20,7 @@ from typing import Any import yaml from pydantic import ValidationError -from cyclone.providers import PayerConfig837, PayerConfig835 +from cyclone.providers import PayerConfig277CA, PayerConfig837, PayerConfig835 log = logging.getLogger(__name__) @@ -42,8 +42,10 @@ def _tx_to_model(transaction_type: str) -> type: return PayerConfig837 if tx_str == "835": return PayerConfig835 - # Other tx types (999, TA1, 270, 271, 277CA) reuse the 837 schema - # for now — they have similar per-payer config shapes. + if tx_str == "277CA": + return PayerConfig277CA + # Other tx types (999, TA1, 270, 271) reuse the 837 schema for now + # — they have similar per-payer config shapes. return PayerConfig837 diff --git a/backend/src/cyclone/providers.py b/backend/src/cyclone/providers.py index 4c15be4..c9d8222 100644 --- a/backend/src/cyclone/providers.py +++ b/backend/src/cyclone/providers.py @@ -98,6 +98,27 @@ class PayerConfig835(BaseModel): payer_name_pattern: str = "" +class PayerConfig277CA(BaseModel): + """Per-payer, per-277CA-transaction-type configuration block. + + Carries the status-code classification sets the parser and Inbox + lane logic consult. HCPF publishes the canonical STC category codes + (A1-A9 for adjudication states, P1-P5 for paid states); this block + lets the operator override the classification per-payer without + touching code. + """ + + model_config = ConfigDict(extra="ignore") + + rejected_status_codes: list[str] = Field(default_factory=lambda: ["A4", "A6", "A7"]) + pended_status_codes: list[str] = Field(default_factory=lambda: ["A8", "A9"]) + accepted_status_codes: list[str] = Field(default_factory=lambda: ["A1", "A2", "A3"]) + paid_status_codes: list[str] = Field(default_factory=lambda: ["P1", "P2", "P3", "P4", "P5"]) + # HCPF uses ST*277CA but the X12 spec also allows ST*277. Both accepted. + transaction_set_ids_allowed: list[str] = Field(default_factory=lambda: ["277", "277CA"]) + implementation_guide: str = "005010X214" + + class PayerConfigRow(BaseModel): """One row in the payer_configs table. diff --git a/backend/src/cyclone/store.py b/backend/src/cyclone/store.py index 8ff227f..178ad51 100644 --- a/backend/src/cyclone/store.py +++ b/backend/src/cyclone/store.py @@ -1800,6 +1800,55 @@ class CycloneStore: with db.SessionLocal()() as s: return s.get(db.Ta1Ack, ack_id) + # -- 277CA (SP10) -------------------------------------------------- + + def add_277ca_ack( + self, + *, + source_batch_id: str, + control_number: str, + accepted_count: int, + rejected_count: int, + paid_count: int, + pended_count: int, + raw_json: dict, + ) -> db.Two77caAck: + """Persist a 277CA (Claim Acknowledgment) row and return it. + + Mirrors :meth:`add_ack` but for the claim-level ack. The + per-claim status detail stays in ``raw_json``; only the four + counts are promoted so the list endpoint stays fast. + """ + with db.SessionLocal()() as s: + row = db.Two77caAck( + source_batch_id=source_batch_id, + control_number=control_number, + accepted_count=accepted_count, + rejected_count=rejected_count, + paid_count=paid_count, + pended_count=pended_count, + parsed_at=utcnow(), + raw_json=raw_json, + ) + s.add(row) + s.commit() + s.refresh(row) + return row + + def list_277ca_acks(self) -> list[db.Two77caAck]: + """Return every 277CA ACK row, newest first (auto-increment id desc).""" + with db.SessionLocal()() as s: + return ( + s.query(db.Two77caAck) + .order_by(db.Two77caAck.id.desc()) + .all() + ) + + def get_277ca_ack(self, ack_id: int) -> db.Two77caAck | None: + """Return a single 277CA ACK row by id, or ``None`` if not found.""" + with db.SessionLocal()() as s: + return s.get(db.Two77caAck, ack_id) + # -- manual reconciliation (T12) ----------------------------------- def list_unmatched(self, *, kind: str = "both") -> dict: diff --git a/backend/tests/fixtures/minimal_277ca.txt b/backend/tests/fixtures/minimal_277ca.txt new file mode 100644 index 0000000..4b45126 --- /dev/null +++ b/backend/tests/fixtures/minimal_277ca.txt @@ -0,0 +1,42 @@ +ISA*00* *00* *ZZ*COMEDICAID *ZZ*DZINESCO *240620*1200*^*00501*000000123*0*P*:~ +GS*HN*COMEDICAID*DZINESCO*20240620*1200*1*X*005010X214~ +ST*277CA*0001*005010X214~ +BHT*0085*08*REFNUM001*20240620*1200*TH~ +HL*1**20*1~ +NM1*PR*2*COLORADO MEDICAL ASSIST*****PI*COMEDICAID~ +TRN*2*REFNUM001~ +DTP*050*RD8*20240601-20240630~ +DTP*009*RD8*20240601-20240630~ +HL*2*1*21*1~ +NM1*41*2*DZINESCO*****46*11525703~ +TRN*2*REFNUM001~ +HL*3*2*19*1~ +NM1*IL*1*DOE*JOHN****MI*MEMBERID001~ +TRN*2*TRACE001~ +HL*4*3*PT~ +NM1*QC*1*DOE*JANE~ +REF*1K*CLAIM001~ +REF*EJ*721587149~ +STC*A3:19:PR*20240620*WQ*100.00~ +QTY*90*1~ +AMT*YU*100.00~ +DTP*472*RD8*20240615-20240615~ +HL*5*3*PT~ +NM1*QC*1*SMITH*ROBERT~ +REF*1K*CLAIM002~ +REF*EJ*721587149~ +STC*A6:19:PR*20240620*U*250.00~ +QTY*90*1~ +AMT*YU*250.00~ +DTP*472*D8*20240610~ +HL*6*3*PT~ +NM1*QC*1*GARCIA*MARIA~ +REF*1K*CLAIM003~ +REF*EJ*721587149~ +STC*A8:19:PR*20240620*U*175.50~ +QTY*90*1~ +AMT*YU*175.50~ +DTP*472*RD8*20240612-20240612~ +SE*40*0001~ +GE*1*1~ +IEA*1*000000123~ diff --git a/backend/tests/fixtures/minimal_277ca_rejected_only.txt b/backend/tests/fixtures/minimal_277ca_rejected_only.txt new file mode 100644 index 0000000..b486734 --- /dev/null +++ b/backend/tests/fixtures/minimal_277ca_rejected_only.txt @@ -0,0 +1,14 @@ +ISA*00* *00* *ZZ*COMEDICAID *ZZ*DZINESCO *240620*1200*^*00501*000000789*0*P*:~ +GS*HN*COMEDICAID*DZINESCO*20240620*1200*3*X*005010X214~ +ST*277CA*0001*005010X214~ +BHT*0085*08*REFNUM003*20240620*1200*TH~ +HL*1**20*1~ +HL*2*1*21*1~ +HL*3*2*19*1~ +HL*4*3*PT~ +REF*1K*CLAIM099~ +REF*EJ*721587149~ +STC*A7:19:PR*20240620*U*99.99~ +SE*8*0001~ +GE*1*1~ +IEA*1*000000789~ diff --git a/backend/tests/fixtures/minimal_277ca_st277.txt b/backend/tests/fixtures/minimal_277ca_st277.txt new file mode 100644 index 0000000..5488202 --- /dev/null +++ b/backend/tests/fixtures/minimal_277ca_st277.txt @@ -0,0 +1,15 @@ +ISA*00* *00* *ZZ*COMEDICAID *ZZ*DZINESCO *240620*1200*^*00501*000000456*0*P*:~ +GS*HN*COMEDICAID*DZINESCO*20240620*1200*2*X*005010X214~ +ST*277*0001*005010X214~ +BHT*0085*08*REFNUM002*20240620*1200*TH~ +HL*1**20*1~ +NM1*PR*2*COLORADO MEDICAL ASSIST*****PI*COMEDICAID~ +HL*2*1*21*1~ +HL*3*2*19*1~ +HL*4*3*PT~ +REF*1K*CLAIM004~ +REF*EJ*721587149~ +STC*A3:19:PR*20240620*WQ*300.00~ +SE*10*0001~ +GE*1*1~ +IEA*1*000000456~ diff --git a/backend/tests/test_acks.py b/backend/tests/test_acks.py index 57133bf..faaf7d3 100644 --- a/backend/tests/test_acks.py +++ b/backend/tests/test_acks.py @@ -51,17 +51,17 @@ def test_migration_0002_creates_acks_table(): def test_migration_latest_idempotent_on_fresh_db(): """Re-running the migration on the same DB must be a no-op (PRAGMA - user_version already at the latest version — currently 7 after - 0004-0006 line_reconciliation, 0005 ta1_acks, and SP9's 0007 - providers/payers/clearhouse).""" + user_version already at the latest version — currently 8 after + 0004-0006 line_reconciliation, 0005 ta1_acks, SP9's 0007 + providers/payers/clearhouse, and SP10's 0008 payer_rejected).""" with db.engine().begin() as c: v1 = c.exec_driver_sql("PRAGMA user_version").scalar() or 0 - assert v1 == 7 + assert v1 == 8 # A second run should not raise and should not bump the version. db_migrate.run(db.engine()) with db.engine().begin() as c: v2 = c.exec_driver_sql("PRAGMA user_version").scalar() or 0 - assert v2 == 7 + assert v2 == 8 def test_add_ack_persists_row(): diff --git a/backend/tests/test_api_277ca.py b/backend/tests/test_api_277ca.py new file mode 100644 index 0000000..79124b4 --- /dev/null +++ b/backend/tests/test_api_277ca.py @@ -0,0 +1,169 @@ +"""Tests for the FastAPI surface in ``cyclone.api`` for the 277CA endpoint. + +SP10 T3. Mirrors ``test_api_999.py``: +- 400 on empty / undecodable / malformed EDI (never 500). +- 200 on success with the parsed envelope + counts. +- After parse, ``apply_277ca_rejections`` stamps matching claim rows. +""" +from __future__ import annotations + +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +from cyclone import db +from cyclone.api import app +from cyclone.db import Claim, init_db + +ACCEPTED_FIXTURE = Path(__file__).parent / "fixtures" / "minimal_277ca.txt" +REJECTED_FIXTURE = Path(__file__).parent / "fixtures" / "minimal_277ca_rejected_only.txt" + + +@pytest.fixture(autouse=True) +def _fresh_db(): + """Each test gets a fresh DB and a clean 277CA ack list.""" + init_db() + yield + + +@pytest.fixture +def client() -> TestClient: + return TestClient(app) + + +def _seed_claim(claim_id: str, pcn: str) -> None: + with db.SessionLocal()() as s: + s.add(Claim( + id=claim_id, batch_id="BATCH-1", + patient_control_number=pcn, charge_amount=100.00, + )) + s.commit() + + +# --------------------------------------------------------------------------- # +# Happy path +# --------------------------------------------------------------------------- # + + +class TestParse277CAEndpointHappyPath: + def test_upload_minimal_277ca_returns_200(self, client: TestClient): + text = ACCEPTED_FIXTURE.read_text() + resp = client.post( + "/api/parse-277ca", + files={"file": ("minimal_277ca.txt", text, "text/plain")}, + headers={"Accept": "application/json"}, + ) + assert resp.status_code == 200, resp.text + body = resp.json() + assert "ack" in body + assert "parsed" in body + ack = body["ack"] + assert ack["accepted_count"] == 1 + assert ack["rejected_count"] == 1 + assert ack["pended_count"] == 1 + assert ack["control_number"] == "000000123" + + def test_persists_two77ca_row(self, client: TestClient): + text = ACCEPTED_FIXTURE.read_text() + client.post( + "/api/parse-277ca", + files={"file": ("minimal_277ca.txt", text, "text/plain")}, + ) + rows_resp = client.get("/api/277ca-acks") + assert rows_resp.status_code == 200 + rows = rows_resp.json() + assert rows["total"] == 1 + assert rows["items"][0]["control_number"] == "000000123" + + def test_get_277ca_ack_by_id(self, client: TestClient): + text = ACCEPTED_FIXTURE.read_text() + post_resp = client.post( + "/api/parse-277ca", + files={"file": ("minimal_277ca.txt", text, "text/plain")}, + ) + ack_id = post_resp.json()["ack"]["id"] + detail = client.get(f"/api/277ca-acks/{ack_id}") + assert detail.status_code == 200 + assert detail.json()["control_number"] == "000000123" + + def test_stamps_matching_claim(self, client: TestClient): + """A rejected 277CA claim with REF*1K=CLAIM002 stamps claim c2.""" + # Seed two claims matching the fixture's PCNs. + _seed_claim("c1", "CLAIM001") + _seed_claim("c2", "CLAIM002") + text = ACCEPTED_FIXTURE.read_text() + client.post( + "/api/parse-277ca", + files={"file": ("minimal_277ca.txt", text, "text/plain")}, + ) + with db.SessionLocal()() as s: + c1 = s.get(Claim, "c1") + c2 = s.get(Claim, "c2") + assert c1.payer_rejected_at is None + assert c2.payer_rejected_at is not None + assert c2.payer_rejected_status_code == "A6" + assert "A6" in c2.payer_rejected_reason + + +# --------------------------------------------------------------------------- # +# Error paths +# --------------------------------------------------------------------------- # + + +class TestParse277CAEndpointErrors: + def test_empty_file_raises_400(self, client: TestClient): + resp = client.post( + "/api/parse-277ca", + files={"file": ("empty.txt", "", "text/plain")}, + ) + assert resp.status_code == 400, resp.text + assert "error" in resp.json() + + def test_garbage_raises_400(self, client: TestClient): + resp = client.post( + "/api/parse-277ca", + files={"file": ("garbage.txt", "this is not EDI", "text/plain")}, + ) + assert resp.status_code == 400, resp.text + + def test_wrong_transaction_set_raises_400(self, client: TestClient): + """A 999 must NOT be accepted as a 277CA — different transaction set id.""" + text = ( + "ISA*00* *00* *ZZ*AAAAAAAAAAAAAAA*ZZ*BBBBBBBBBBBBBBB*240620*1200*^*00501*000000001*0*P*:~" + "GS*HN*A*B*20240620*1200*1*X*005010X231A1~" + "ST*999*0001*005010X231A1~" + "AK1*HC*0001~" + "AK9*A*0*0*0~" + "SE*4*0001~" + "GE*1*1~" + "IEA*1*000000001~" + ) + resp = client.post( + "/api/parse-277ca", + files={"file": ("bad.txt", text, "text/plain")}, + ) + assert resp.status_code == 400, resp.text + + +# --------------------------------------------------------------------------- # +# Inbox lane +# --------------------------------------------------------------------------- # + + +class TestInboxPayerRejectedLane: + def test_payer_rejected_claim_appears_in_lane(self, client: TestClient): + """A claim with payer_rejected_at set must appear in the payer_rejected lane.""" + _seed_claim("c1", "CLAIM099") + text = REJECTED_FIXTURE.read_text() # single A7 for CLAIM099 + client.post( + "/api/parse-277ca", + files={"file": ("rejected.txt", text, "text/plain")}, + ) + lanes = client.get("/api/inbox/lanes").json() + assert "payer_rejected" in lanes + ids = [c["id"] for c in lanes["payer_rejected"]] + assert "c1" in ids + # The rejected lane (999 envelope) must be empty — we haven't + # uploaded a 999, so this claim isn't there. + assert "c1" not in [c["id"] for c in lanes["rejected"]] diff --git a/backend/tests/test_apply_277ca_rejections.py b/backend/tests/test_apply_277ca_rejections.py new file mode 100644 index 0000000..35f2ad5 --- /dev/null +++ b/backend/tests/test_apply_277ca_rejections.py @@ -0,0 +1,247 @@ +"""Tests for :func:`cyclone.inbox_state_277ca.apply_277ca_rejections`. + +SP10 T2. The 277CA's STC A4/A6/A7 codes stamp payer-rejection fields +on matching claim rows. Distinct from the 999 envelope rejection. +""" +from __future__ import annotations + +from datetime import date + +import pytest + +from cyclone import db +from cyclone.db import Claim, init_db +from cyclone.inbox_state_277ca import apply_277ca_rejections +from cyclone.parsers.models_277ca import ClaimStatus, ParseResult277CA +from cyclone.parsers.parse_277ca import parse_277ca_text + + +# --------------------------------------------------------------------------- # +# Fixtures +# --------------------------------------------------------------------------- # + + +@pytest.fixture(autouse=True) +def _fresh_db(): + """Each test gets a fresh in-memory DB.""" + init_db() + yield + + +def _make_claim(session, *, claim_id: str, pcn: str = "CLAIM001") -> Claim: + c = Claim( + id=claim_id, + batch_id="BATCH-1", + patient_control_number=pcn, + charge_amount=100.00, + ) + session.add(c) + session.commit() + session.refresh(c) + return c + + +def _make_rejected_status(pcn: str | None = "CLAIM001") -> ClaimStatus: + return ClaimStatus( + status_code="A6", + status_description="19", + entity_identifier="PR", + classification="rejected", + payer_claim_control_number=pcn, + ) + + +# --------------------------------------------------------------------------- # +# Tests +# --------------------------------------------------------------------------- # + + +class TestApply277CARejectionsHappyPath: + def test_rejected_status_stamps_matching_claim(self): + from cyclone import db + with db.SessionLocal()() as s: + claim = _make_claim(s, claim_id="c1") + pcn = claim.patient_control_number + result = parse_277ca_text(_minimal_277ca_one_rejected()) + with db.SessionLocal()() as s: + def _lookup(pcn_q): + return s.query(Claim).filter_by(patient_control_number=pcn_q).first() + outcome = apply_277ca_rejections(s, result, claim_lookup=_lookup, two77ca_id="ACK-1") + assert outcome.matched == ["c1"] + assert outcome.orphans == [] + with db.SessionLocal()() as s: + c = s.get(Claim, "c1") + assert c.payer_rejected_at is not None + assert c.payer_rejected_status_code == "A6" + assert "A6" in (c.payer_rejected_reason or "") + assert c.payer_rejected_by_277ca_id == "ACK-1" + + +class TestApply277CARejectionsOrphans: + def test_unknown_pcn_becomes_orphan(self): + from cyclone import db + # No claim exists — PCN won't match. + text = _minimal_277ca_one_rejected() + result = parse_277ca_text(text) + with db.SessionLocal()() as s: + def _lookup(_): + return None + outcome = apply_277ca_rejections(s, result, claim_lookup=_lookup, two77ca_id="ACK-1") + assert outcome.matched == [] + assert outcome.orphans == ["CLAIM001"] + + def test_status_without_ref_1k_becomes_orphan(self): + """A rejected STC with no REF*1K cannot match a claim.""" + from cyclone import db + text = _minimal_277ca_no_ref1k() + result = parse_277ca_text(text) + with db.SessionLocal()() as s: + def _lookup(_): + return None + outcome = apply_277ca_rejections(s, result, claim_lookup=_lookup, two77ca_id="ACK-1") + assert outcome.matched == [] + # The orphan entry uses the status code (since PCN is missing). + assert outcome.orphans == ["A6"] + + +class TestApply277CARejectionsIdempotent: + def test_already_stamped_is_not_overwritten(self): + from cyclone import db + with db.SessionLocal()() as s: + _make_claim(s, claim_id="c1") + text = _minimal_277ca_one_rejected() + result = parse_277ca_text(text) + with db.SessionLocal()() as s: + def _lookup(_): + return s.query(Claim).filter_by(patient_control_number="CLAIM001").first() + outcome1 = apply_277ca_rejections(s, result, claim_lookup=_lookup, two77ca_id="ACK-1") + assert outcome1.matched == ["c1"] + original_reason = s.get(Claim, "c1").payer_rejected_reason + original_at = s.get(Claim, "c1").payer_rejected_at + # Run again with same code. + outcome2 = apply_277ca_rejections(s, result, claim_lookup=_lookup, two77ca_id="ACK-1") + assert outcome2.matched == [] + assert outcome2.already_rejected == ["c1"] + with db.SessionLocal()() as s: + c = s.get(Claim, "c1") + # Reason and timestamp unchanged. + assert c.payer_rejected_reason == original_reason + assert c.payer_rejected_at == original_at + + +class TestApply277CAOnlyRejectsRejected: + def test_accepted_status_does_not_stamp(self): + """An A3 (accepted) status must NOT trigger a payer_rejected stamp.""" + from cyclone import db + with db.SessionLocal()() as s: + _make_claim(s, claim_id="c1") + text = _minimal_277ca_one_accepted() + result = parse_277ca_text(text) + with db.SessionLocal()() as s: + def _lookup(_): + return s.query(Claim).filter_by(patient_control_number="CLAIM001").first() + outcome = apply_277ca_rejections(s, result, claim_lookup=_lookup, two77ca_id="ACK-1") + assert outcome.matched == [] + with db.SessionLocal()() as s: + c = s.get(Claim, "c1") + assert c.payer_rejected_at is None + + +class TestApply277CAMultipleStatuses: + def test_mixed_batch_only_stamps_rejected(self): + """Of three statuses (A3/A6/A8), only the A6 claim gets stamped.""" + from cyclone import db + with db.SessionLocal()() as s: + _make_claim(s, claim_id="c1", pcn="CLAIM001") + _make_claim(s, claim_id="c2", pcn="CLAIM002") + _make_claim(s, claim_id="c3", pcn="CLAIM003") + text = ( + "ISA*00* *00* *ZZ*AAAAAAAAAAAAAAA*ZZ*BBBBBBBBBBBBBBB*240620*1200*^*00501*000000001*0*P*:~" + "GS*HN*A*B*20240620*1200*1*X*005010X214~" + "ST*277CA*0001*005010X214~" + "BHT*0085*08*X*20240620*1200*TH~" + "HL*1**20*1~" + "HL*2*1*21*1~" + "HL*3*2*19*1~" + "HL*4*3*PT~" + "REF*1K*CLAIM001~" + "STC*A3:19:PR*20240620*WQ*100.00~" + "HL*5*3*PT~" + "REF*1K*CLAIM002~" + "STC*A6:19:PR*20240620*U*250.00~" + "HL*6*3*PT~" + "REF*1K*CLAIM003~" + "STC*A8:19:PR*20240620*U*175.00~" + "SE*16*0001~" + "GE*1*1~" + "IEA*1*000000001~" + ) + result = parse_277ca_text(text) + with db.SessionLocal()() as s: + def _lookup(pcn): + return s.query(Claim).filter_by(patient_control_number=pcn).first() + outcome = apply_277ca_rejections(s, result, claim_lookup=_lookup, two77ca_id="ACK-1") + assert outcome.matched == ["c2"] + with db.SessionLocal()() as s: + assert s.get(Claim, "c1").payer_rejected_at is None + assert s.get(Claim, "c2").payer_rejected_at is not None + assert s.get(Claim, "c3").payer_rejected_at is None + + +# --------------------------------------------------------------------------- # +# Test fixtures +# --------------------------------------------------------------------------- # + + +def _minimal_277ca_one_rejected() -> str: + return ( + "ISA*00* *00* *ZZ*AAAAAAAAAAAAAAA*ZZ*BBBBBBBBBBBBBBB*240620*1200*^*00501*000000001*0*P*:~" + "GS*HN*A*B*20240620*1200*1*X*005010X214~" + "ST*277CA*0001*005010X214~" + "BHT*0085*08*X*20240620*1200*TH~" + "HL*1**20*1~" + "HL*2*1*21*1~" + "HL*3*2*19*1~" + "HL*4*3*PT~" + "REF*1K*CLAIM001~" + "STC*A6:19:PR*20240620*U*100.00~" + "SE*9*0001~" + "GE*1*1~" + "IEA*1*000000001~" + ) + + +def _minimal_277ca_one_accepted() -> str: + return ( + "ISA*00* *00* *ZZ*AAAAAAAAAAAAAAA*ZZ*BBBBBBBBBBBBBBB*240620*1200*^*00501*000000001*0*P*:~" + "GS*HN*A*B*20240620*1200*1*X*005010X214~" + "ST*277CA*0001*005010X214~" + "BHT*0085*08*X*20240620*1200*TH~" + "HL*1**20*1~" + "HL*2*1*21*1~" + "HL*3*2*19*1~" + "HL*4*3*PT~" + "REF*1K*CLAIM001~" + "STC*A3:19:PR*20240620*WQ*100.00~" + "SE*9*0001~" + "GE*1*1~" + "IEA*1*000000001~" + ) + + +def _minimal_277ca_no_ref1k() -> str: + """Patient HL with STC A6 but no REF*1K.""" + return ( + "ISA*00* *00* *ZZ*AAAAAAAAAAAAAAA*ZZ*BBBBBBBBBBBBBBB*240620*1200*^*00501*000000001*0*P*:~" + "GS*HN*A*B*20240620*1200*1*X*005010X214~" + "ST*277CA*0001*005010X214~" + "BHT*0085*08*X*20240620*1200*TH~" + "HL*1**20*1~" + "HL*2*1*21*1~" + "HL*3*2*19*1~" + "HL*4*3*PT~" + "STC*A6:19:PR*20240620*U*100.00~" + "SE*8*0001~" + "GE*1*1~" + "IEA*1*000000001~" + ) diff --git a/backend/tests/test_inbox_endpoints.py b/backend/tests/test_inbox_endpoints.py index 388d2d0..ab177d3 100644 --- a/backend/tests/test_inbox_endpoints.py +++ b/backend/tests/test_inbox_endpoints.py @@ -47,11 +47,14 @@ def _seed_batch() -> None: s.commit() -def test_lanes_endpoint_returns_four_keys(client: TestClient): +def test_lanes_endpoint_returns_five_keys(client: TestClient): + """SP10 added the payer_rejected lane (distinct from 999 envelope rejection).""" r = client.get("/api/inbox/lanes") assert r.status_code == 200 body = r.json() - assert set(body.keys()) == {"rejected", "candidates", "unmatched", "done_today"} + assert set(body.keys()) == { + "rejected", "payer_rejected", "candidates", "unmatched", "done_today", + } for v in body.values(): assert isinstance(v, list) diff --git a/backend/tests/test_parse_277ca.py b/backend/tests/test_parse_277ca.py new file mode 100644 index 0000000..901b835 --- /dev/null +++ b/backend/tests/test_parse_277ca.py @@ -0,0 +1,247 @@ +"""Tests for the 277CA Claim Acknowledgment parser. + +SP10 T1. The 277CA is the semantic claim-level ack from the payer — +the parser must walk the HL hierarchy and capture one ClaimStatus +per Patient HL, with REF*1K (payer_claim_control_number) and the +STC category code. +""" +from __future__ import annotations + +import json +from datetime import date +from pathlib import Path + +import pytest + +from cyclone.parsers.exceptions import CycloneParseError +from cyclone.parsers.models_277ca import ( + ACCEPTED_CODES, + PAID_CODES, + PENDED_CODES, + REJECTED_CODES, + ParseResult277CA, + classify_status_code, +) +from cyclone.parsers.parse_277ca import parse_277ca_text + +FIXTURE_DIR = Path(__file__).parent / "fixtures" + + +# --------------------------------------------------------------------------- # +# Status-code classification +# --------------------------------------------------------------------------- # + + +class TestClassifyStatusCode: + def test_accepted_codes(self): + for code in ACCEPTED_CODES: + assert classify_status_code(code) == "accepted" + + def test_rejected_codes(self): + for code in REJECTED_CODES: + assert classify_status_code(code) == "rejected" + + def test_pended_codes(self): + for code in PENDED_CODES: + assert classify_status_code(code) == "pended" + + def test_paid_codes(self): + for code in PAID_CODES: + assert classify_status_code(code) == "paid" + + def test_unknown_code_does_not_raise(self): + """Unknown codes (e.g. payer-specific) surface as 'unknown', not error.""" + assert classify_status_code("ZZ") == "unknown" + assert classify_status_code("") == "unknown" + assert classify_status_code(" ") == "unknown" + + def test_case_insensitive(self): + assert classify_status_code("a3") == "accepted" + assert classify_status_code("A6") == "rejected" + + +# --------------------------------------------------------------------------- # +# Parser: minimal happy path with mixed statuses +# --------------------------------------------------------------------------- # + + +class TestParse277CAMixed: + @pytest.fixture + def parsed(self) -> ParseResult277CA: + text = (FIXTURE_DIR / "minimal_277ca.txt").read_text() + return parse_277ca_text(text, input_file="minimal_277ca.txt") + + def test_envelope_control_number(self, parsed): + assert parsed.envelope.control_number == "000000123" + assert parsed.envelope.implementation_guide == "005010X214" + + def test_bht_captures_reference_and_date(self, parsed): + assert parsed.bht.hierarchical_structure_code == "0085" + assert parsed.bht.transaction_set_purpose_code == "08" + assert parsed.bht.reference_identification == "REFNUM001" + assert parsed.bht.transaction_set_creation_date == date(2024, 6, 20) + assert parsed.bht.transaction_type_code == "TH" + + def test_three_patient_hls_three_statuses(self, parsed): + assert len(parsed.claim_statuses) == 3 + + def test_first_status_is_accepted(self, parsed): + s = parsed.claim_statuses[0] + assert s.payer_claim_control_number == "CLAIM001" + assert s.billing_provider_tax_id == "721587149" + assert s.status_code == "A3" + assert s.classification == "accepted" + assert s.total_claim_charge_amount == 100.00 + + def test_second_status_is_rejected(self, parsed): + s = parsed.claim_statuses[1] + assert s.payer_claim_control_number == "CLAIM002" + assert s.status_code == "A6" + assert s.classification == "rejected" + assert s.total_claim_charge_amount == 250.00 + + def test_third_status_is_pended(self, parsed): + s = parsed.claim_statuses[2] + assert s.payer_claim_control_number == "CLAIM003" + assert s.status_code == "A8" + assert s.classification == "pended" + + def test_service_date_parsed_from_dtp_472(self, parsed): + """DTP*472 with RD8 fmt should return the start date.""" + s = parsed.claim_statuses[0] + assert s.service_date == date(2024, 6, 15) + + def test_summary_counts(self, parsed): + # accepted=1 (A3), rejected=1 (A6), pended=1 (A8) + assert parsed.summary.total_claims == 3 + assert parsed.summary.passed == 1 # only "accepted" + assert parsed.summary.failed == 1 # only "rejected" (pended excluded) + + def test_round_trips_via_json(self, parsed): + blob = json.loads(parsed.model_dump_json()) + assert blob["envelope"]["control_number"] == "000000123" + assert len(blob["claim_statuses"]) == 3 + rebuilt = ParseResult277CA.model_validate(blob) + assert rebuilt.claim_statuses[1].status_code == "A6" + + +# --------------------------------------------------------------------------- # +# Parser: ST*277 (instead of ST*277CA) — X12 spec also allows it +# --------------------------------------------------------------------------- # + + +class TestParse277CAAltST: + def test_st_277_accepted(self): + text = (FIXTURE_DIR / "minimal_277ca_st277.txt").read_text() + result = parse_277ca_text(text, input_file="minimal_277ca_st277.txt") + assert len(result.claim_statuses) == 1 + assert result.claim_statuses[0].classification == "accepted" + + def test_st_wrong_value_rejected(self): + """A non-277 ST segment should raise CycloneParseError.""" + text = ( + "ISA*00* *00* *ZZ*AAAAAAAAAAAAAAA*ZZ*BBBBBBBBBBBBBBB*240620*1200*^*00501*000000001*0*P*:~" + "GS*HN*A*B*20240620*1200*1*X*005010X214~" + "ST*835*0001*005010X221A1~" + "BHT*0085*08*X*20240620*1200*TH~" + "SE*3*0001~" + "GE*1*1~" + "IEA*1*000000001~" + ) + with pytest.raises(CycloneParseError, match="ST\\*277"): + parse_277ca_text(text) + + +# --------------------------------------------------------------------------- # +# Parser: rejected-only fixture (single A7) +# --------------------------------------------------------------------------- # + + +class TestParse277CARejectedOnly: + def test_single_a7_rejected(self): + text = (FIXTURE_DIR / "minimal_277ca_rejected_only.txt").read_text() + result = parse_277ca_text(text, input_file="minimal_277ca_rejected_only.txt") + assert len(result.claim_statuses) == 1 + s = result.claim_statuses[0] + assert s.status_code == "A7" + assert s.classification == "rejected" + assert s.payer_claim_control_number == "CLAIM099" + + +# --------------------------------------------------------------------------- # +# Parser: error handling +# --------------------------------------------------------------------------- # + + +class TestParse277CAErrors: + def test_missing_isa_raises(self): + """No ISA envelope → CycloneParseError, never silent fail.""" + with pytest.raises(CycloneParseError, match="ISA"): + parse_277ca_text("not a valid 277ca\n") + + def test_empty_input_raises(self): + with pytest.raises(CycloneParseError): + parse_277ca_text("") + + +# --------------------------------------------------------------------------- # +# Parser: multiple STC per patient (last wins) +# --------------------------------------------------------------------------- # + + +class TestParse277CAMultipleStcPerPatient: + def test_last_stc_wins_per_patient(self): + """A patient HL with multiple STC segments: the last one wins. + + This is the canonical pattern for "first pended, then paid" + acknowledgments — the most recent action is authoritative. + """ + text = ( + "ISA*00* *00* *ZZ*AAAAAAAAAAAAAAA*ZZ*BBBBBBBBBBBBBBB*240620*1200*^*00501*000000001*0*P*:~" + "GS*HN*A*B*20240620*1200*1*X*005010X214~" + "ST*277CA*0001*005010X214~" + "BHT*0085*08*X*20240620*1200*TH~" + "HL*1**20*1~" + "HL*2*1*21*1~" + "HL*3*2*19*1~" + "HL*4*3*PT~" + "REF*1K*CLAIM555~" + "STC*A1:19:PR*20240620*WQ*100.00~" + "STC*A8:19:PR*20240620*WQ*100.00~" + "STC*A3:19:PR*20240620*WQ*100.00~" + "SE*9*0001~" + "GE*1*1~" + "IEA*1*000000001~" + ) + result = parse_277ca_text(text) + assert len(result.claim_statuses) == 1 + assert result.claim_statuses[0].status_code == "A3" + assert result.claim_statuses[0].classification == "accepted" + + +# --------------------------------------------------------------------------- # +# Parser: subscriber-level STCs surface in unscoped_statuses +# --------------------------------------------------------------------------- # + + +class TestParse277CASubscriberLevelStc: + def test_subscriber_stc_goes_to_unscoped(self): + """Subscriber-level (HL*19) STCs without a Patient child go to unscoped_statuses.""" + text = ( + "ISA*00* *00* *ZZ*AAAAAAAAAAAAAAA*ZZ*BBBBBBBBBBBBBBB*240620*1200*^*00501*000000001*0*P*:~" + "GS*HN*A*B*20240620*1200*1*X*005010X214~" + "ST*277CA*0001*005010X214~" + "BHT*0085*08*X*20240620*1200*TH~" + "HL*1**20*1~" + "HL*2*1*21*1~" + "HL*3*2*19*1~" + "STC*A6:19:PR*20240620*U~" + "SE*7*0001~" + "GE*1*1~" + "IEA*1*000000001~" + ) + result = parse_277ca_text(text) + assert len(result.claim_statuses) == 0 + assert len(result.unscoped_statuses) == 1 + assert result.unscoped_statuses[0].status_code == "A6" + assert result.unscoped_statuses[0].classification == "rejected" diff --git a/backend/tests/test_payer_config_277ca.py b/backend/tests/test_payer_config_277ca.py new file mode 100644 index 0000000..838740a --- /dev/null +++ b/backend/tests/test_payer_config_277ca.py @@ -0,0 +1,104 @@ +"""Tests for PayerConfig277CA loading from YAML. + +SP10 T4. Mirrors ``test_payer_config_loading.py`` but for the new +``PayerConfig277CA`` block that the 277CA parser and Inbox lane +consume for status-code classification. +""" +from __future__ import annotations + +from pathlib import Path + +import pytest +import yaml + +from cyclone.payers import ( + DEFAULT_CONFIG_PATH, + _tx_to_model, + all_configs, + get_config, + load_payer_configs, + reset, +) +from cyclone.providers import PayerConfig277CA + + +@pytest.fixture(autouse=True) +def _clear_registry(): + """Each test starts with an empty config registry.""" + reset() + yield + reset() + + +class TestPayerConfig277CALoading: + def test_loads_default_config_with_277ca_block(self): + """The default config/payers.yaml must have a CO_TXIX 277CA block.""" + load_payer_configs(DEFAULT_CONFIG_PATH) + cfg = get_config("CO_TXIX", "277CA") + assert cfg is not None + # Defaults from PayerConfig277CA. + assert "A6" in cfg["rejected_status_codes"] + assert "A8" in cfg["pended_status_codes"] + assert "A3" in cfg["accepted_status_codes"] + assert "P1" in cfg["paid_status_codes"] + assert "277CA" in cfg["transaction_set_ids_allowed"] + + def test_model_rejects_bad_status_code_type(self, tmp_path: Path): + """A non-list rejected_status_codes value must fail validation.""" + bad = { + "payers": [ + { + "payer_id": "BAD_PAYER", + "name": "Bad", + "receiver_name": "BAD", + "receiver_id": "BADID", + "configs": { + "277CA": { + "rejected_status_codes": "A6", # must be list + "pended_status_codes": ["A8"], + "accepted_status_codes": ["A1"], + "paid_status_codes": ["P1"], + "transaction_set_ids_allowed": ["277", "277CA"], + "implementation_guide": "005010X214", + }, + }, + }, + ], + } + p = tmp_path / "bad.yaml" + p.write_text(yaml.safe_dump(bad)) + with pytest.raises(ValueError, match="BAD_PAYER"): + load_payer_configs(p) + + +class TestPayerConfig277CAModel: + def test_default_factory_values(self): + """A minimal PayerConfig277CA defaults to the canonical HCPF sets.""" + cfg = PayerConfig277CA() + assert "A4" in cfg.rejected_status_codes + assert "A6" in cfg.rejected_status_codes + assert "A7" in cfg.rejected_status_codes + assert "A8" in cfg.pended_status_codes + assert "277CA" in cfg.transaction_set_ids_allowed + + def test_explicit_override(self): + cfg = PayerConfig277CA( + rejected_status_codes=["X1"], + pended_status_codes=["X2"], + accepted_status_codes=["X3"], + paid_status_codes=["X4"], + ) + assert cfg.rejected_status_codes == ["X1"] + assert cfg.pended_status_codes == ["X2"] + + +class TestTxToModelFor277CA: + def test_int_key_routes_to_277ca_model(self): + """PyYAML may parse numeric keys as int — _tx_to_model must handle both.""" + # 277CA as int isn't likely (not pure digits), but 277 as int is. + # Test the canonical string. + assert _tx_to_model("277CA") is PayerConfig277CA + + def test_string_key_routes_correctly(self): + assert _tx_to_model("277CA") is PayerConfig277CA + assert _tx_to_model("835") is not PayerConfig277CA # it's 835 model diff --git a/config/payers.yaml b/config/payers.yaml index 0111a83..c017384 100644 --- a/config/payers.yaml +++ b/config/payers.yaml @@ -42,3 +42,20 @@ payers: - "1811725341" expected_payer_health_plan_id: "7912900843" payer_name_pattern: "^CO_(TXIX|BHA)$" + "277CA": + # Per X12 005010X214. HCPF sends back 277CAs to acknowledge + # claims we submit. The parser matches each STC row against our + # 837 batch via REF*1K (cross-references CLM01 / patient_control_number). + # + # rejected_status_codes → claim ends up in the Inbox Payer-Rejected + # lane. Per HCPF's published STC code set, A4/A6/A7 are rejection, + # A8/A9 are pended, A1/A2/A3 are accepted, P1-P5 are paid. + rejected_status_codes: ["A4", "A6", "A7"] + pended_status_codes: ["A8", "A9"] + accepted_status_codes: ["A1", "A2", "A3"] + paid_status_codes: ["P1", "P2", "P3", "P4", "P5"] + # ST*01 transaction set identifier — HCPF uses "277CA" but the + # X12 005010X214 spec allows just "277". Both are accepted. + transaction_set_ids_allowed: ["277", "277CA"] + # Implementation guide version HCPF sends. + implementation_guide: "005010X214" diff --git a/docs/reference/co-medicaid.md b/docs/reference/co-medicaid.md index 04632f0..5972514 100644 --- a/docs/reference/co-medicaid.md +++ b/docs/reference/co-medicaid.md @@ -147,6 +147,38 @@ All CMS POS codes `01`–`99` are accepted. The canonical list lives in `cyclone/parsers/payer.py` as `CMS_PLACE_OF_SERVICE_CODES` and is the source of truth for validation and any UI dropdowns. +## 277CA Claim Acknowledgment (SP10) + +After Gainwell accepts our 837P file (999 AK5=A) and adjudicates the +claims, they send back a 277CA per X12 005010X214. The 277CA carries +one `STC` segment per claim with a category code: + +| STC code | Meaning | Cyclone lane | +|---|---|---| +| A1, A2, A3 | Acknowledged / accepted | (logged, no action) | +| A4, A6, A7 | Rejected by payer | **Inbox Payer-Rejected** | +| A8, A9 | Pended | (logged for follow-up) | +| P1–P5 | Paid | (835 follow-up expected) | + +The Payer-Rejected lane is distinct from the 999 envelope "rejected" +lane: a claim can be syntactically valid (999 A) but semantically +denied (277CA STC A6). + +To upload a 277CA: + +```bash +curl -X POST http://localhost:8000/api/parse-277ca \ + -F "file=@TP11525703-837P_M019048402-...-1of1_277.x12" +``` + +The response includes `matched_claim_ids` (which Cyclone claims were +stamped payer-rejected) and `orphan_status_codes` (status entries we +couldn't tie to a Cyclone claim — usually because the PCN in REF*1K +doesn't match anything we sent). + +The Inbox at `/api/inbox/lanes` returns the new `payer_rejected` lane +alongside the existing four. + ## Validation rules Cyclone enforces See [837p.md](./837p.md#validation-rules-cyclone-enforces) and the