merge: SP10 277CA parser + Payer-Rejected Inbox lane into main

This commit is contained in:
Tyler
2026-06-20 23:41:05 -06:00
22 changed files with 1899 additions and 16 deletions
+171 -1
View File
@@ -34,6 +34,7 @@ from pydantic import ValidationError
from cyclone import __version__, db from cyclone import __version__, db
from cyclone.db import Claim, ClaimState, Remittance from cyclone.db import Claim, ClaimState, Remittance
from cyclone.inbox_state import apply_999_rejections 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.exceptions import CycloneParseError
from cyclone.parsers.models import BatchSummary, ClaimOutput, Envelope, ParseResult from cyclone.parsers.models import BatchSummary, ClaimOutput, Envelope, ParseResult
from cyclone.parsers.models_270 import ( 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.payer import PayerConfig, PayerConfig835
from cyclone.parsers.parse_270 import parse as parse_270_text 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_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_837 import parse
from cyclone.parsers.parse_835 import parse as parse_835 from cyclone.parsers.parse_835 import parse as parse_835
from cyclone.parsers.parse_999 import parse_999_text 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-<ISA13>``. 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": <ParseResult277CA>}``.
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: def _serialize_ta1(result) -> str:
"""Render a TA1 file from a ParseResultTa1 for the ``raw_ta1_text`` field. """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") @app.get("/api/inbox/lanes")
def 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()) dismissed_pairs = getattr(app.state, "dismissed_pairs", set())
with db.SessionLocal()() as session: with db.SessionLocal()() as session:
from cyclone.inbox_lanes import compute_lanes from cyclone.inbox_lanes import compute_lanes
lanes = compute_lanes(session, dismissed_pairs=dismissed_pairs) lanes = compute_lanes(session, dismissed_pairs=dismissed_pairs)
return { return {
"rejected": lanes.rejected, "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, "candidates": lanes.candidates,
"unmatched": lanes.unmatched, "unmatched": lanes.unmatched,
"done_today": lanes.done_today, "done_today": lanes.done_today,
+51
View File
@@ -205,6 +205,21 @@ class Claim(Base):
rejected_at: Mapped[Optional[datetime]] = mapped_column( rejected_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), nullable=True 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( resubmit_count: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default=text("0") 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-<ISA13>`` 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 # SP9: providers, payers, payer_configs, clearhouse
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+35 -5
View File
@@ -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. SP6 T6.
Lanes: Lanes:
- rejected: claims whose 999 AK5 set-level response was R/E - rejected: claims whose 999 AK5 set-level response was R/E
- candidates: remits without a matched claim, with scoreable claims - payer_rejected: claims whose 277CA STC category is A4/A6/A7
- unmatched: claims still SUBMITTED with no remittance in flight (SP10: payer-side rejection, distinct from 999
- done_today: claims that reached a terminal state in the last 24h 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 from __future__ import annotations
@@ -23,6 +26,7 @@ from cyclone.scoring import score_pair, ScoreBreakdown
@dataclass @dataclass
class Lanes: class Lanes:
rejected: list[dict] = field(default_factory=list) rejected: list[dict] = field(default_factory=list)
payer_rejected: list[dict] = field(default_factory=list)
candidates: list[dict] = field(default_factory=list) candidates: list[dict] = field(default_factory=list)
unmatched: list[dict] = field(default_factory=list) unmatched: list[dict] = field(default_factory=list)
done_today: 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, "provider": score.provider,
} if score else None, } if score else None,
"matched_remittance": matched_remittance, "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 --- # --- Done today ---
cutoff = datetime.now(timezone.utc) - timedelta(hours=24) cutoff = datetime.now(timezone.utc) - timedelta(hours=24)
terminal_states = { terminal_states = {
+108
View File
@@ -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"]
@@ -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);
+10
View File
@@ -66,6 +66,15 @@ _LAZY_EXPORTS: dict[str, str] = {
"SERVICE_TYPE_CODES": "cyclone.parsers.models_271", "SERVICE_TYPE_CODES": "cyclone.parsers.models_271",
"LAST_UPDATED": "cyclone.parsers.models_271", "LAST_UPDATED": "cyclone.parsers.models_271",
"service_type_description": "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) # CARC lookup (SP3 P2 T6)
"reason_label": "cyclone.parsers.cas_codes", "reason_label": "cyclone.parsers.cas_codes",
"all_known_codes": "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_999": "cyclone.parsers.parse_999",
"parse_270": "cyclone.parsers.parse_270", "parse_270": "cyclone.parsers.parse_270",
"parse_271": "cyclone.parsers.parse_271", "parse_271": "cyclone.parsers.parse_271",
"parse_277ca": "cyclone.parsers.parse_277ca",
"serialize_999": "cyclone.parsers.serialize_999", "serialize_999": "cyclone.parsers.serialize_999",
"serialize_270": "cyclone.parsers.serialize_270", "serialize_270": "cyclone.parsers.serialize_270",
"build_ack_for_batch": "cyclone.parsers.batch_ack_builder", "build_ack_for_batch": "cyclone.parsers.batch_ack_builder",
+161
View File
@@ -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)
+355
View File
@@ -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"]
+5 -3
View File
@@ -20,7 +20,7 @@ from typing import Any
import yaml import yaml
from pydantic import ValidationError from pydantic import ValidationError
from cyclone.providers import PayerConfig837, PayerConfig835 from cyclone.providers import PayerConfig277CA, PayerConfig837, PayerConfig835
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -42,8 +42,10 @@ def _tx_to_model(transaction_type: str) -> type:
return PayerConfig837 return PayerConfig837
if tx_str == "835": if tx_str == "835":
return PayerConfig835 return PayerConfig835
# Other tx types (999, TA1, 270, 271, 277CA) reuse the 837 schema if tx_str == "277CA":
# for now — they have similar per-payer config shapes. return PayerConfig277CA
# Other tx types (999, TA1, 270, 271) reuse the 837 schema for now
# — they have similar per-payer config shapes.
return PayerConfig837 return PayerConfig837
+21
View File
@@ -98,6 +98,27 @@ class PayerConfig835(BaseModel):
payer_name_pattern: str = "" 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): class PayerConfigRow(BaseModel):
"""One row in the payer_configs table. """One row in the payer_configs table.
+49
View File
@@ -1800,6 +1800,55 @@ class CycloneStore:
with db.SessionLocal()() as s: with db.SessionLocal()() as s:
return s.get(db.Ta1Ack, ack_id) 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) ----------------------------------- # -- manual reconciliation (T12) -----------------------------------
def list_unmatched(self, *, kind: str = "both") -> dict: def list_unmatched(self, *, kind: str = "both") -> dict:
+42
View File
@@ -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~
+14
View File
@@ -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~
+15
View File
@@ -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~
+5 -5
View File
@@ -51,17 +51,17 @@ def test_migration_0002_creates_acks_table():
def test_migration_latest_idempotent_on_fresh_db(): def test_migration_latest_idempotent_on_fresh_db():
"""Re-running the migration on the same DB must be a no-op (PRAGMA """Re-running the migration on the same DB must be a no-op (PRAGMA
user_version already at the latest version — currently 7 after user_version already at the latest version — currently 8 after
0004-0006 line_reconciliation, 0005 ta1_acks, and SP9's 0007 0004-0006 line_reconciliation, 0005 ta1_acks, SP9's 0007
providers/payers/clearhouse).""" providers/payers/clearhouse, and SP10's 0008 payer_rejected)."""
with db.engine().begin() as c: with db.engine().begin() as c:
v1 = c.exec_driver_sql("PRAGMA user_version").scalar() or 0 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. # A second run should not raise and should not bump the version.
db_migrate.run(db.engine()) db_migrate.run(db.engine())
with db.engine().begin() as c: with db.engine().begin() as c:
v2 = c.exec_driver_sql("PRAGMA user_version").scalar() or 0 v2 = c.exec_driver_sql("PRAGMA user_version").scalar() or 0
assert v2 == 7 assert v2 == 8
def test_add_ack_persists_row(): def test_add_ack_persists_row():
+169
View File
@@ -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"]]
@@ -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~"
)
+5 -2
View File
@@ -47,11 +47,14 @@ def _seed_batch() -> None:
s.commit() 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") r = client.get("/api/inbox/lanes")
assert r.status_code == 200 assert r.status_code == 200
body = r.json() 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(): for v in body.values():
assert isinstance(v, list) assert isinstance(v, list)
+247
View File
@@ -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"
+104
View File
@@ -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
+17
View File
@@ -42,3 +42,20 @@ payers:
- "1811725341" - "1811725341"
expected_payer_health_plan_id: "7912900843" expected_payer_health_plan_id: "7912900843"
payer_name_pattern: "^CO_(TXIX|BHA)$" 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"
+32
View File
@@ -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 `cyclone/parsers/payer.py` as `CMS_PLACE_OF_SERVICE_CODES` and is the
source of truth for validation and any UI dropdowns. 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) |
| P1P5 | 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 ## Validation rules Cyclone enforces
See [837p.md](./837p.md#validation-rules-cyclone-enforces) and the See [837p.md](./837p.md#validation-rules-cyclone-enforces) and the