feat(sp10): 277CA parser + Payer-Rejected Inbox lane

- 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.
This commit is contained in:
Tyler
2026-06-20 23:41:01 -06:00
parent ae2d48102e
commit 2c0afbe9c5
22 changed files with 1899 additions and 16 deletions
+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():
"""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():
+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()
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)
+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