merge: SP33 co-txix-payer-fix into main

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Nora
2026-07-04 20:29:27 -06:00
16 changed files with 599 additions and 47 deletions
+4
View File
@@ -38,3 +38,7 @@ claims_output/
# Brainstorm session artifacts (visual companion mockups, events, server state). # Brainstorm session artifacts (visual companion mockups, events, server state).
# Skills under .superpowers/skills/ are committed project-scoped guidance. # Skills under .superpowers/skills/ are committed project-scoped guidance.
.superpowers/brainstorm/ .superpowers/brainstorm/
# SP33+ scratch / production-data ingest. Generated artifacts live
# here only — the source EDI sits under docs/prodfiles/.
ingest/
+373 -4
View File
@@ -388,10 +388,6 @@ def backfill_rendering_npi(
) )
if __name__ == "__main__":
main()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# SP17: `cyclone backup` subcommands # SP17: `cyclone backup` subcommands
# #
@@ -403,6 +399,375 @@ if __name__ == "__main__":
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# SP33: `cyclone backfill-999-rejections`
#
# One-shot replay of the cascade fix in `apply_999_rejections` for any
# 999 acks already in the DB. Used on the night of 2026-07-02 after
# Gainwell rejected the four dzinesco batches at the SET level
# ("2010BB NM109 must equal CO_TXIX") — the 999s were ingested but the
# pre-SP33 cascade bug didn't flip claim states, so the dashboard's
# "0/145 accepted" widget was lying.
#
# Idempotent: claims already in REJECTED are skipped (counted in
# `already_rejected`). Safe to re-run.
# ---------------------------------------------------------------------------
@main.command("backfill-999-rejections")
@click.option("--dry-run", is_flag=True, default=False,
help="Print the would-be state transitions without writing.")
@click.option("--actor", default="sp33-backfill",
show_default=True,
help="Audit-log actor tag for the claim.rejected events.")
def backfill_999_rejections(dry_run: bool, actor: str) -> None:
"""Replay existing 999 rejections onto already-linked claims (SP33).
Walks ``claim_acks`` joined with ``claims`` where the link row's
``set_accept_reject_code='R'`` (a SET-level rejection from the 999
envelope) and flips the matching claim to ``REJECTED`` — mirroring
what the (now-fixed) ``apply_999_rejections`` would have done at
ingest time.
Each flipped claim gets:
- ``state`` set to ``REJECTED``
- ``state_changed_at`` and ``rejected_at`` set to now
- ``rejection_reason`` filled with the 999 AK5 code + SCN
- ``payer_rejected_at`` / ``payer_rejected_reason`` /
``payer_rejected_status_code`` filled (the 999 SET-level reject
is also a payer-side reject for Inbox-lanes purposes)
- one ``claim.rejected`` audit-log event
Claims already in REJECTED are skipped (counted in the summary).
"""
from datetime import datetime, timezone
from sqlalchemy import select, func
from cyclone import db as db_mod
from cyclone.audit_log import AuditEvent, append_event
db_mod.init_db()
now = datetime.now(timezone.utc)
with db_mod.SessionLocal()() as session:
# Pull one representative R-coded link per claim_id (a single
# 999 may stamp many AK2 rows against the same claim, so we
# collapse via MIN(set_control_number) / MIN(ak2_index) to avoid
# firing 1 audit event per duplicate ack row).
#
# 36777 R-coded rows resolve to 339 unique claims (338 still in
# SUBMITTED + 1 already in REJECTED). Grouping in SQL keeps the
# in-Python loop small AND emits exactly 1 audit event per claim.
rows = session.execute(
select(
db_mod.Claim.id,
db_mod.Claim.state,
func.min(db_mod.ClaimAck.set_control_number).label("scn"),
func.min(db_mod.ClaimAck.ak2_index).label("ak2"),
func.min(db_mod.Ack.ack_code).label("ack_code"),
)
.join(db_mod.ClaimAck, db_mod.ClaimAck.claim_id == db_mod.Claim.id)
.join(db_mod.Ack, db_mod.Ack.id == db_mod.ClaimAck.ack_id)
.where(db_mod.ClaimAck.set_accept_reject_code == "R")
.where(db_mod.ClaimAck.claim_id.is_not(None))
.group_by(db_mod.Claim.id, db_mod.Claim.state)
).all()
matched = 0
already = 0
errors = 0
for (claim_id, current_state, scn, ak2_idx, ack_code) in rows:
if current_state == db_mod.ClaimState.REJECTED:
already += 1
continue
if dry_run:
matched += 1
continue
claim = session.get(db_mod.Claim, claim_id)
if claim is None:
errors += 1
continue
claim.state = db_mod.ClaimState.REJECTED
claim.state_changed_at = now
claim.rejected_at = now
claim.rejection_reason = (
f"999 AK5={ack_code or 'R'} SCN={scn} ak2={ak2_idx}"
)
# Mirror the 999 SET-level reject into the payer-rejected
# lane so the Inbox sees it as a Payer-Rejected claim too.
claim.payer_rejected_at = now
claim.payer_rejected_reason = f"999 SET-level reject at SCN={scn}"
claim.payer_rejected_status_code = "R"
append_event(session, AuditEvent(
event_type="claim.rejected",
entity_type="claim",
entity_id=claim_id,
payload={"source": "backfill-999-rejections",
"scn": scn, "ak2_index": ak2_idx,
"ack_code": ack_code},
actor=actor,
))
matched += 1
if not dry_run:
session.commit()
click.echo(
f"matched={matched} already_rejected={already} errors={errors} "
f"dry_run={dry_run}"
)
# ---------------------------------------------------------------------------
# SP33: `cyclone resubmit-rejected-claims`
#
# Push the corrected single-claim 837 files to Gainwell's SFTP ToHPE
# dir so dzinesco can resubmit the batch. The byte-level
# SKCO0 -> CO_TXIX fix is assumed to have already been applied (see
# the SP33 plan §4.4 / `docs/ingest/corrected/`). This CLI just walks
# the corrected directory, validates each file via the parser, and
# uploads via the real SftpClient.
#
# Idempotent: a file already present on the remote with the same byte
# size is skipped (counted in `skipped`). Re-runnable after a partial
# failure without re-uploading files that landed.
# ---------------------------------------------------------------------------
@main.command("resubmit-rejected-claims")
@click.option("--ingest-dir", default="ingest/corrected",
show_default=True,
help="Root dir holding batch-*-claims/ subfolders of fixed .x12 files.")
@click.option("--actor", default="sp33-resubmit",
show_default=True,
help="Audit-log actor tag for the clearhouse.submitted events.")
@click.option("--validate/--no-validate", default=True,
help="Parse each file via parse_837 before upload (catches a bad fix).")
@click.option("--limit", type=int, default=None,
help="Stop after checking this many files (smoke-tests). Counts "
"all attempts, not just successful uploads.")
@click.option("--reconnect-every", type=int, default=50, show_default=True,
help="Reconnect SFTP every N uploads to avoid MOVEit's per-session cap.")
def resubmit_rejected_claims(
ingest_dir: str,
actor: str,
validate: bool,
limit: int | None,
reconnect_every: int,
) -> None:
"""Upload corrected 837 files to the Gainwell SFTP ToHPE dir (SP33).
Walks every ``batch-*-claims/*.x12`` under ``--ingest-dir`` (default
``./ingest/corrected``), validates each one through ``parse_837``,
and uploads via the seeded real-SFTP ``SftpClient``.
Idempotent: a file already present on the remote with the same
byte size is skipped (counted in ``skipped``). Reconnects every
``--reconnect-every`` uploads to avoid MOVEit's silent per-session
file cap (observed: ~200 puts/session before silent drops with no
exception — see SP33 root-cause notes).
Doesn't mutate claim state — claims stay in REJECTED until a 999
ACK confirms Gainwell accepted the resubmit. Emits one
``clearhouse.submitted`` audit event per successful upload.
"""
import time
from cyclone import db as db_mod
from cyclone.audit_log import AuditEvent, append_event
from cyclone.parsers.parse_837 import parse as parse_837_text
from cyclone.parsers.payer import PayerConfig
from cyclone.store import store as cycl_store
db_mod.init_db()
cycl_store.ensure_clearhouse_seeded()
clearhouse = cycl_store.get_clearhouse()
if clearhouse is None:
click.echo("No clearhouse seeded; cannot resolve SFTP block.", err=True)
sys.exit(2)
sftp_block = clearhouse.sftp_block
if sftp_block.stub:
click.echo("Clearhouse SFTP block is in stub mode; refusing to upload.", err=True)
sys.exit(2)
remote_root = sftp_block.paths["outbound"]
root = Path(ingest_dir).resolve()
if not root.exists():
click.echo(f"--ingest-dir does not exist: {root}", err=True)
sys.exit(2)
files: list[Path] = []
for batch_dir in sorted(root.glob("batch-*-claims")):
files.extend(sorted(p for p in batch_dir.glob("*.x12")
if not p.name.startswith("._")))
if not files:
click.echo(f"No .x12 files under {root}", err=True)
sys.exit(2)
click.echo(f"found {len(files)} files under {root}")
uploaded = 0
skipped = 0
failed = 0
validated = 0
payer_cfg = PayerConfig.co_medicaid()
start = time.monotonic()
last_progress = start
# One persistent paramiko session per batch, with periodic
# reconnect to dodge MOVEit's silent per-session file cap
# (~200 puts/session, no exception — see SP33 root-cause notes).
import paramiko
from cyclone.secrets import get_secret
def _open_session() -> tuple[paramiko.SSHClient, paramiko.SFTPClient]:
pw = get_secret(sftp_block.auth.get("password_keychain_account", ""))
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect(
sftp_block.host, port=sftp_block.port,
username=sftp_block.username, password=pw,
timeout=15, banner_timeout=15, auth_timeout=15,
)
return ssh, ssh.open_sftp()
def _close_session(ssh: paramiko.SSHClient | None) -> None:
if ssh is None:
return
try: ssh.close()
except Exception: pass
ssh: paramiko.SSHClient | None = None
sftp: paramiko.SFTPClient | None = None
for i, src in enumerate(files, 1):
if limit is not None and i > limit:
break
content = src.read_bytes()
# Validate: parse must succeed AND payer_id must match the
# companion-guide CO_TXIX (catches a bad byte-fix early).
if validate:
try:
parsed = parse_837_text(content.decode(), payer_cfg)
except Exception as exc: # noqa: BLE001
failed += 1
click.echo(f"PARSE FAIL {src.name}: {exc.__class__.__name__}: {exc}", err=True)
continue
mismatch = next(
(c for c in parsed.claims if c.payer.id != "CO_TXIX"), None
)
if mismatch is not None:
failed += 1
click.echo(
f"PAYER MISMATCH {src.name}: payer.id={mismatch.payer.id!r} "
f"(expected 'CO_TXIX')", err=True,
)
continue
validated += 1
local_size = len(content)
remote_path = f"{remote_root}/{src.name}"
attempts = 0
ok = False
was_skipped = False
while attempts < 3:
attempts += 1
if ssh is None:
try:
ssh, sftp = _open_session()
except Exception as exc: # noqa: BLE001
click.echo(
f"SFTP CONNECT FAIL attempt {attempts}: "
f"{exc.__class__.__name__}: {exc}", err=True,
)
continue
try:
# Idempotency check.
try:
rs = sftp.stat(remote_path) # type: ignore[union-attr]
if rs.st_size == local_size:
skipped += 1
was_skipped = True
break
except IOError:
pass # not on remote yet
sftp.put(str(src), remote_path) # type: ignore[union-attr]
ok = True
break
except Exception as exc: # noqa: BLE001
# Connection died — drop the session and let the next
# attempt reopen.
_close_session(ssh)
ssh, sftp = None, None
if attempts >= 3:
click.echo(
f"UPLOAD FAIL {src.name}: "
f"{exc.__class__.__name__}: {exc}", err=True,
)
continue
if not ok and not was_skipped:
failed += 1
continue
# Audit + reconnect cadence apply only to real uploads.
if not was_skipped:
# Audit (best-effort; if the DB is unavailable we still
# keep the file on the wire).
try:
with db_mod.SessionLocal()() as session:
append_event(session, AuditEvent(
event_type="clearhouse.submitted",
entity_type="claim_file",
entity_id=src.name,
payload={"remote_path": remote_path,
"source": "resubmit-rejected-claims",
"size": local_size},
actor=actor,
))
session.commit()
except Exception as exc: # noqa: BLE001
click.echo(
f"audit-log write failed for {src.name}: "
f"{exc.__class__.__name__}: {exc}", err=True,
)
uploaded += 1
# Reconnect periodically to dodge MOVEit's per-session cap.
if uploaded % reconnect_every == 0:
click.echo(f"reconnecting (after {uploaded} uploads)", err=True)
_close_session(ssh)
ssh, sftp = None, None
# Progress every 10s of wall-clock (or at end).
now = time.monotonic()
if now - last_progress >= 10 or i == len(files):
elapsed = now - start
rate = uploaded / elapsed if elapsed else 0
click.echo(
f"progress {i}/{len(files)} uploaded={uploaded} "
f"skipped={skipped} failed={failed} rate={rate:.2f}/s "
f"elapsed={elapsed:.1f}s", err=True,
)
last_progress = now
# Tear down the long-lived session if one is still open.
if ssh is not None:
_close_session(ssh)
elapsed = time.monotonic() - start
click.echo(
f"DONE uploaded={uploaded} skipped={skipped} failed={failed} "
f"validated={validated} files_total={len(files)} elapsed={elapsed:.1f}s"
)
@main.group() @main.group()
def backup() -> None: def backup() -> None:
"""Encrypted DB backup management (SP17).""" """Encrypted DB backup management (SP17)."""
@@ -824,3 +1189,7 @@ def pull_inbound(
click.echo("download errors:", err=True) click.echo("download errors:", err=True)
for e in summary["download_errors"]: for e in summary["download_errors"]:
click.echo(f" {e}", err=True) click.echo(f" {e}", err=True)
if __name__ == "__main__":
main()
+3 -1
View File
@@ -90,7 +90,9 @@ def handle(
.first() .first()
) )
rejection_result = apply_999_rejections( rejection_result = apply_999_rejections(
session, result, claim_lookup=_lookup, session, result,
claim_lookup=_lookup,
batch_envelope_index=batch_index,
) )
if rejection_result.matched: if rejection_result.matched:
for cid in rejection_result.matched: for cid in rejection_result.matched:
+37 -16
View File
@@ -33,42 +33,63 @@ def apply_999_rejections(
parsed_999, parsed_999,
*, *,
claim_lookup: Callable[[str], Claim | None], claim_lookup: Callable[[str], Claim | None],
batch_envelope_index: dict[str, list[str]] | None = None,
) -> Apply999Result: ) -> Apply999Result:
"""For each set response with code R or E, look up the matching claim and """For each set response with code R, E, or X, look up the matching claim and
move it to REJECTED. Idempotent on already-rejected claims. move it to REJECTED. Idempotent on already-rejected claims.
Args: Args:
session: SQLAlchemy session. session: SQLAlchemy session.
parsed_999: a ParseResult999 (or any object with .set_responses). parsed_999: a ParseResult999 (or any object with .set_responses).
claim_lookup: callable from patient_control_number → Claim or None. claim_lookup: callable from patient_control_number → Claim or None.
Legacy fallback; rarely hits when batch_envelope_index is present.
batch_envelope_index: SP33 — mapping from SET control_number (the 837
envelope's ST02) to list of Claim.id for the claims in that SET.
Mirrors the SP28 fix in apply_999_acceptances so SET-level
rejections correctly cascade across every claim under the SET.
Returns: Returns:
Apply999Result with lists of matched claim ids and orphan PCNs. Apply999Result with lists of matched claim ids and orphan PCNs.
""" """
result = Apply999Result() result = Apply999Result()
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
index = batch_envelope_index or {}
for sr in parsed_999.set_responses: for sr in parsed_999.set_responses:
code = sr.set_accept_reject.code code = sr.set_accept_reject.code
if code not in ("R", "E", "X"): if code not in ("R", "E", "X"):
continue continue
claim = claim_lookup(sr.set_control_number) # SP33: prefer batch_envelope_index (SCN -> [claim_id]) so a SET-level
if claim is None: # rejection correctly flips every claim in the SET. Fall back to
result.orphans.append(sr.set_control_number) # the legacy claim_lookup when the index is empty for this SCN.
continue candidate_ids = index.get(sr.set_control_number, []) or []
claims_to_reject: list[Claim] = []
if candidate_ids:
claims_to_reject = (
session.query(Claim)
.filter(Claim.id.in_(candidate_ids))
.all()
)
else:
legacy = claim_lookup(sr.set_control_number)
if legacy is not None:
claims_to_reject = [legacy]
else:
result.orphans.append(sr.set_control_number)
continue
if claim.state == ClaimState.REJECTED: for claim in claims_to_reject:
# Idempotent: don't double-mutate. if claim.state == ClaimState.REJECTED:
continue # Idempotent: don't double-mutate.
continue
claim.state = ClaimState.REJECTED claim.state = ClaimState.REJECTED
claim.state_changed_at = now claim.state_changed_at = now
claim.rejected_at = now claim.rejected_at = now
claim.rejection_reason = _build_reason( claim.rejection_reason = _build_reason(
code, len(sr.segment_errors or []) code, len(sr.segment_errors or [])
) )
result.matched.append(claim.id) result.matched.append(claim.id)
if result.matched or result.orphans: if result.matched or result.orphans:
session.commit() session.commit()
+2 -2
View File
@@ -66,8 +66,8 @@ class PayerConfig(BaseModel):
# Lenient in v1 — see spec §9 R031. # Lenient in v1 — see spec §9 R031.
require_ref_g1_for_adjustments=False, require_ref_g1_for_adjustments=False,
allowed_bht06={"CH"}, allowed_bht06={"CH"},
payer_id="SKCO0", payer_id="CO_TXIX",
payer_name="COHCPF", payer_name="CO_TXIX",
no_patient_loop=True, no_patient_loop=True,
encounter_claim_in_same_batch=False, encounter_claim_in_same_batch=False,
allowed_facility_qualifiers={"B"}, allowed_facility_qualifiers={"B"},
+2 -2
View File
@@ -17,7 +17,7 @@ NM1*IL*1*Balliache*Marianela****MI*P060946~
N3*1811 PAVILION DR APT 303~ N3*1811 PAVILION DR APT 303~
N4*Montrose*CO*814016072~ N4*Montrose*CO*814016072~
DMG*D8*19590223*F~ DMG*D8*19590223*F~
NM1*PR*2*COHCPF*****PI*SKCO0~ NM1*PR*2*CO_TXIX*****PI*CO_TXIX~
CLM*t991102984o1c1d*85.40***12:B:1*Y*A*Y*Y~ CLM*t991102984o1c1d*85.40***12:B:1*Y*A*Y*Y~
REF*G1*3173~ REF*G1*3173~
HI*ABK:R69~ HI*ABK:R69~
@@ -35,7 +35,7 @@ NM1*IL*1*Barella*Victoria****MI*H582447~
N3*1900 Kellie DR~ N3*1900 Kellie DR~
N4*Montrose*CO*814019524~ N4*Montrose*CO*814019524~
DMG*D8*19570727*F~ DMG*D8*19570727*F~
NM1*PR*2*COHCPF*****PI*SKCO0~ NM1*PR*2*CO_TXIX*****PI*CO_TXIX~
CLM*t991102984o1c2d*155.76***12:B:1*Y*A*Y*Y~ CLM*t991102984o1c2d*155.76***12:B:1*Y*A*Y*Y~
REF*G1*3173~ REF*G1*3173~
HI*ABK:R69~ HI*ABK:R69~
+2 -2
View File
@@ -17,7 +17,7 @@ NM1*IL*1*Balliache*Marianela****MI*P060946~
N3*1811 PAVILION DR APT 303~ N3*1811 PAVILION DR APT 303~
N4*Montrose*CO*814016072~ N4*Montrose*CO*814016072~
DMG*D8*19590223*F~ DMG*D8*19590223*F~
NM1*PR*2*COHCPF*****PI*SKCO0~ NM1*PR*2*CO_TXIX*****PI*CO_TXIX~
CLM*t991102984o1c1d*85.40***12:B:1*Y*A*Y*Y~ CLM*t991102984o1c1d*85.40***12:B:1*Y*A*Y*Y~
REF*G1*3173~ REF*G1*3173~
HI*ABK:R69~ HI*ABK:R69~
@@ -36,7 +36,7 @@ NM1*IL*1*Barella*Victoria****MI*H582447~
N3*1900 Kellie DR~ N3*1900 Kellie DR~
N4*Montrose*CO*814019524~ N4*Montrose*CO*814019524~
DMG*D8*19570727*F~ DMG*D8*19570727*F~
NM1*PR*2*COHCPF*****PI*SKCO0~ NM1*PR*2*CO_TXIX*****PI*CO_TXIX~
CLM*t991102984o1c2d*155.76***12:B:1*Y*A*Y*Y~ CLM*t991102984o1c2d*155.76***12:B:1*Y*A*Y*Y~
REF*G1*3173~ REF*G1*3173~
HI*ABK:R69~ HI*ABK:R69~
+1 -1
View File
@@ -17,7 +17,7 @@ NM1*IL*1*Doe*John****MI*ABC123~
N3*456 Member St~ N3*456 Member St~
N4*Denver*CO*80203~ N4*Denver*CO*80203~
DMG*D8*19800101*M~ DMG*D8*19800101*M~
NM1*PR*2*COHCPF*****PI*SKCO0~ NM1*PR*2*CO_TXIX*****PI*CO_TXIX~
CLM*CLM001*100.00***12:B:1*Y*A*Y*Y~ CLM*CLM001*100.00***12:B:1*Y*A*Y*Y~
REF*G1*PA123~ REF*G1*PA123~
HI*ABK:Z00~ HI*ABK:Z00~
+120
View File
@@ -0,0 +1,120 @@
"""SP33: apply_999_rejections must use batch_envelope_index (mirrors
SP28's apply_999_acceptances fix) so SET-level 999 rejections cascade
to claim state transitions.
The pre-SP33 bug: ``claim_lookup(sr.set_control_number)`` passed the
SET control_number to a PCN-based lookup, so no claim was ever matched
and rejections silently died. SP33 threads the same
``batch_envelope_index`` that ``apply_999_acceptances`` already has.
"""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import MagicMock
from cyclone.db import ClaimState
from cyclone.inbox_state import apply_999_rejections
def _mk_set_response(code: str = "R", scn: str = "991102994"):
"""Build a stand-in for ``ParseResult999.set_responses[0]``."""
return SimpleNamespace(
set_accept_reject=SimpleNamespace(code=code),
set_control_number=scn,
segment_errors=[],
)
def _mk_claim(cid: str = "claim-x", state=ClaimState.SUBMITTED):
c = MagicMock()
c.id = cid
c.state = state
return c
def test_set_level_rejection_cascades_via_envelope_index():
"""When the SET-level 999 has set_control_number=991102994 and the
batch_envelope_index maps it to a list of 3 claim_ids, all 3 should
transition to REJECTED.
"""
session = MagicMock()
claims = [_mk_claim(f"c-{i}") for i in range(3)]
session.query.return_value.filter.return_value.all.return_value = claims
parsed = SimpleNamespace(set_responses=[_mk_set_response(code="R", scn="991102994")])
index = {"991102994": [c.id for c in claims]}
result = apply_999_rejections(
session, parsed,
claim_lookup=lambda _pcn: None,
batch_envelope_index=index,
)
assert sorted(result.matched) == sorted(c.id for c in claims)
assert result.orphans == []
session.commit.assert_called_once()
for c in claims:
# ClaimState.REJECTED is the lowercase 'rejected' string (the enum
# value stored in the DB column via SQLAlchemy Enum type).
assert c.state == ClaimState.REJECTED
def test_set_level_rejection_without_index_still_uses_legacy_lookup():
"""Backwards compat: when batch_envelope_index is None, the function
must fall back to claim_lookup (existing behavior preserved).
"""
session = MagicMock()
parsed = SimpleNamespace(set_responses=[_mk_set_response(code="R", scn="991102994")])
legacy_claim = _mk_claim("legacy-claim")
result = apply_999_rejections(
session, parsed,
claim_lookup=lambda scn: legacy_claim if scn == "991102994" else None,
)
assert result.matched == ["legacy-claim"]
def test_no_envelope_index_no_match_becomes_orphan():
"""Empty index + empty legacy lookup => the SET becomes an orphan."""
session = MagicMock()
parsed = SimpleNamespace(set_responses=[_mk_set_response(code="R", scn="991102999")])
result = apply_999_rejections(
session, parsed,
claim_lookup=lambda _pcn: None,
batch_envelope_index={}, # empty index
)
assert result.matched == []
assert "991102999" in result.orphans
def test_accepted_999_does_not_reject():
"""An 'A' code must not transition any claim to REJECTED."""
session = MagicMock()
parsed = SimpleNamespace(set_responses=[_mk_set_response(code="A", scn="991102994")])
result = apply_999_rejections(
session, parsed,
claim_lookup=lambda _pcn: None,
batch_envelope_index={"991102994": ["claim-x"]},
)
assert result.matched == []
session.query.assert_not_called()
session.commit.assert_not_called()
def test_already_rejected_is_idempotent():
"""Pre-REJECTED claims in the index are left alone (no second commit)."""
session = MagicMock()
already = _mk_claim("already-rejected", state=ClaimState.REJECTED)
session.query.return_value.filter.return_value.all.return_value = [already]
parsed = SimpleNamespace(set_responses=[_mk_set_response(code="R", scn="991102994")])
result = apply_999_rejections(
session, parsed,
claim_lookup=lambda _pcn: None,
batch_envelope_index={"991102994": ["already-rejected"]},
)
assert result.matched == []
assert already.state == ClaimState.REJECTED # untouched
+2 -2
View File
@@ -36,5 +36,5 @@ def test_subscriber_payer_reused_across_claims():
result = parse(FIXTURE.read_text(), PayerConfig.co_medicaid()) result = parse(FIXTURE.read_text(), PayerConfig.co_medicaid())
assert result.claims[0].billing_provider.npi == "1881068062" assert result.claims[0].billing_provider.npi == "1881068062"
assert result.claims[1].billing_provider.npi == "1881068062" assert result.claims[1].billing_provider.npi == "1881068062"
assert result.claims[0].payer.id == "SKCO0" assert result.claims[0].payer.id == "CO_TXIX"
assert result.claims[1].payer.id == "SKCO0" assert result.claims[1].payer.id == "CO_TXIX"
@@ -0,0 +1,33 @@
"""SP33 regression test: ``PayerConfig.co_medicaid()`` emits CO_TXIX.
The HCPF 837P Companion Guide (June 2025 - Version 2.5) requires
``NM1*PR NM108=PI, NM109=CO_TXIX`` for the Payer Name loop (2010BB).
The in-code factory historically emitted ``SKCO0`` which Gainwell
rejects with ``IK3*NM1*17*2010*8``. This test pins the corrected value.
See ``docs/superpowers/specs/2026-07-02-cyclone-co-txix-payer-fix-design.md``.
"""
from __future__ import annotations
def test_co_medicaid_factory_emits_CO_TXIX():
"""Per HCPF 837P Companion Guide, NM1*PR NM109 must equal CO_TXIX."""
from cyclone.parsers.payer import PayerConfig
cfg = PayerConfig.co_medicaid()
assert cfg.payer_id == "CO_TXIX", (
f"co_medicaid().payer_id must be 'CO_TXIX' (HCPF guide), got {cfg.payer_id!r}"
)
assert cfg.payer_name == "CO_TXIX", (
f"co_medicaid().payer_name must be 'CO_TXIX' (matches docs/goodclaim.x12), got {cfg.payer_name!r}"
)
def test_skco0_not_present_in_factory():
"""Hard guard: SKCO0 must never be emitted by the factory again."""
from cyclone.parsers.payer import PayerConfig
cfg = PayerConfig.co_medicaid()
assert "SKCO0" not in cfg.payer_id, (
f"SKCO0 must not appear in payer_id (HCPF guide says CO_TXIX), got {cfg.payer_id!r}"
)
assert "SKCO0" not in cfg.payer_name, (
f"SKCO0 must not appear in payer_name, got {cfg.payer_name!r}"
)
+1 -1
View File
@@ -18,7 +18,7 @@ def test_parse_minimal_fixture_returns_one_claim():
assert claim.subscriber.last_name == "Doe" assert claim.subscriber.last_name == "Doe"
assert claim.subscriber.first_name == "John" assert claim.subscriber.first_name == "John"
assert claim.subscriber.member_id == "ABC123" assert claim.subscriber.member_id == "ABC123"
assert claim.payer.id == "SKCO0" assert claim.payer.id == "CO_TXIX"
assert claim.claim.frequency_code == "1" assert claim.claim.frequency_code == "1"
assert claim.claim.place_of_service == "12" assert claim.claim.place_of_service == "12"
assert claim.claim.prior_auth == "PA123" assert claim.claim.prior_auth == "PA123"
+2 -2
View File
@@ -8,8 +8,8 @@ def test_co_medicaid_defaults():
assert cfg.allowed_claim_frequencies == {1, 7, 8} assert cfg.allowed_claim_frequencies == {1, 7, 8}
assert cfg.require_ref_g1_for_adjustments is False # lenient in v1 assert cfg.require_ref_g1_for_adjustments is False # lenient in v1
assert cfg.allowed_bht06 == {"CH"} assert cfg.allowed_bht06 == {"CH"}
assert cfg.payer_id == "SKCO0" assert cfg.payer_id == "CO_TXIX"
assert cfg.payer_name == "COHCPF" assert cfg.payer_name == "CO_TXIX"
assert cfg.no_patient_loop is True assert cfg.no_patient_loop is True
assert cfg.encounter_claim_in_same_batch is False assert cfg.encounter_claim_in_same_batch is False
+9 -9
View File
@@ -5,13 +5,13 @@ The endpoint is the payer-level aggregate that the drill-down UI's
denial rate, and the top 5 NPIs by claim volume for one payer_id, denial rate, and the top 5 NPIs by claim volume for one payer_id,
cached in-process for 60s. cached in-process for 60s.
The minimal 837P fixture ships one CLM with ``payer_id="SKCO0"``, The minimal 837P fixture ships one CLM with ``payer_id="CO_TXIX"``,
charge_amount=100.00; the minimal 835 carries one CLP for the same charge_amount=100.00; the minimal 835 carries one CLP for the same
claim with total_paid=85.00. So ``/api/payers/SKCO0/summary`` returns claim with total_paid=85.00. So ``/api/payers/CO_TXIX/summary`` returns
``claim_count >= 1`` after both files are ingested. ``claim_count >= 1`` after both files are ingested.
Note: the spec calls this ``payer_id`` (the X12 NM1*PR*PI qualifier, Note: the spec calls this ``payer_id`` (the X12 NM1*PR*PI qualifier,
e.g. ``SKCO0``). It is NOT the configured payer name from e.g. ``CO_TXIX``). It is NOT the configured payer name from
``config/payers.yaml``. The filter key in the store layer is ``config/payers.yaml``. The filter key in the store layer is
``Claim.payer_id`` — not the ``payer=`` substring filter used by ``Claim.payer_id`` — not the ``payer=`` substring filter used by
``/api/claims?payer=...``. ``/api/claims?payer=...``.
@@ -54,7 +54,7 @@ def client() -> TestClient:
def seeded_db(client: TestClient): def seeded_db(client: TestClient):
"""Ingest one minimal 837P + one minimal 835. """Ingest one minimal 837P + one minimal 835.
Both fixtures carry ``payer_id="SKCO0"`` so the summary endpoint Both fixtures carry ``payer_id="CO_TXIX"`` so the summary endpoint
has something to aggregate. ``client`` is yielded back so the has something to aggregate. ``client`` is yielded back so the
test can hit the API on the same TestClient that ingested the test can hit the API on the same TestClient that ingested the
fixtures (parses share the per-test SQLite from conftest). fixtures (parses share the per-test SQLite from conftest).
@@ -77,11 +77,11 @@ def seeded_db(client: TestClient):
def test_payer_summary_happy_path(seeded_db: TestClient): def test_payer_summary_happy_path(seeded_db: TestClient):
"""Seeded db has at least one claim for SKCO0 → 200 with the spec shape.""" """Seeded db has at least one claim for CO_TXIX → 200 with the spec shape."""
resp = seeded_db.get("/api/payers/SKCO0/summary") resp = seeded_db.get("/api/payers/CO_TXIX/summary")
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
data = resp.json() data = resp.json()
assert data["payer_id"] == "SKCO0" assert data["payer_id"] == "CO_TXIX"
assert "claim_count" in data assert "claim_count" in data
assert "billed_total" in data assert "billed_total" in data
assert "received_total" in data assert "received_total" in data
@@ -99,8 +99,8 @@ def test_payer_summary_unknown_payer_returns_404(client: TestClient):
def test_payer_summary_caches_then_invalidates(seeded_db: TestClient): def test_payer_summary_caches_then_invalidates(seeded_db: TestClient):
"""Two back-to-back calls return identical payloads (in-process cache).""" """Two back-to-back calls return identical payloads (in-process cache)."""
resp1 = seeded_db.get("/api/payers/SKCO0/summary") resp1 = seeded_db.get("/api/payers/CO_TXIX/summary")
resp2 = seeded_db.get("/api/payers/SKCO0/summary") resp2 = seeded_db.get("/api/payers/CO_TXIX/summary")
assert resp1.status_code == 200 assert resp1.status_code == 200
assert resp2.status_code == 200 assert resp2.status_code == 200
assert resp1.json() == resp2.json() assert resp1.json() == resp2.json()
+1 -1
View File
@@ -45,7 +45,7 @@ full walker.
## CO Medicaid specifics ## CO Medicaid specifics
- Trading partner: `SKCO0` (sender) ↔ `COHCPF` (receiver) on `NM1*PR` / `NM1*40` - Trading partners: `COMEDASSISTPROG` (NM1*40 NM109, 1000B receiver) and `CO_TXIX` (NM1*PR NM109, 2010BB payer). See `docs/goodclaim.x12` for a canonical example and the HCPF 837P Companion Guide for the full segment table.
- `CLM05` is a composite of three components: place of service, facility code - `CLM05` is a composite of three components: place of service, facility code
qualifier, and frequency code (in that order) qualifier, and frequency code (in that order)
- `CLM05-1` = place of service (any valid CMS POS code) - `CLM05-1` = place of service (any valid CMS POS code)
+7 -4
View File
@@ -9,11 +9,14 @@ Financing (HCPF) requires.
| Role | 837P | 835 | | Role | 837P | 835 |
|---|---|---| |---|---|---|
| Sender | `SKCO0` | (varies; usually the clearinghouse) | | Sender (submitter, `NM1*41`) | `Dzinesco` (TPID `11525703`) | (varies; usually the clearinghouse) |
| Receiver | `COHCPF` | (varies) | | Receiver (`NM1*40`) | `COMEDASSISTPROG` | (varies) |
| Payer (`NM1*PR`) | `CO_TXIX` (per HCPF 837P Companion Guide) | (varies) |
These appear in the `NM1*PR` (payer) and `NM1*40` (receiver) segments of These appear in the `NM1*PR` (payer) and `NM1*40` (receiver) segments of
the 837P file. the 837P file. The legacy codes `SKCO0` / `COHCPF` are no longer
accepted by Gainwell (HCPF error: "2010BB NM109 must equal CO_TXIX or
CO_BHA"); see [SP33](../superpowers/specs/2026-07-02-cyclone-co-txix-payer-fix-design.md).
## dzinesco's TPID (clearinghouse identity) ## dzinesco's TPID (clearinghouse identity)
@@ -115,7 +118,7 @@ ls -la "./var/sftp/staging/CO XIX/PROD/coxix_prod_11525703/ToHPE/"
### 837P (claims) ### 837P (claims)
- `NM1*PR N104 = "SKCO0"` (COHCPF) - `NM1*PR NM108 = "PI"`, `NM1*PR NM109 = "CO_TXIX"` (per HCPF 837P Companion Guide, June 2025 — Version 2.5). dzinesco submits against this code under dzinesco TPID `11525703`. The legacy trading-partner ID `SKCO0` is no longer accepted as the payer identifier.
### 835 (remittance) ### 835 (remittance)