feat(sp9): multi-payer, multi-NPI, SFTP stub for dzinesco/TOC

- providers table seeded with 3 NPIs (Montrose, Delta, Salida)
- payers + payer_configs (per-tx config) join table
- clearhouse singleton (dzinesco identity, SFTP block, filename block)
- config/payers.yaml loader with Pydantic schema validation
- cyclone/edi/filenames.py: HCPF X12 File Naming Standards helpers
- cyclone/clearhouse/sftp.py: stub that copies to ./var/sftp/staging/...
- cyclone/secrets.py: macOS Keychain wrapper via keyring
- 11 new validation rules R200-R210 (CO MAP + HCPF naming)
- 6 new API endpoints (/api/clearhouse/*, /api/config/*, /api/admin/reload-config)
- migration 0007
- 72 new tests (646 total passing, was 574)

See spec: docs/superpowers/specs/2026-06-20-cyclone-multi-payer-npi-sftp-design.md
This commit is contained in:
Tyler
2026-06-20 23:07:31 -06:00
parent fbe9940a3f
commit c62826daea
23 changed files with 2869 additions and 6 deletions
+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 5 after the
0004 rejection columns + state-history index (SP6) and the 0005
ta1_acks table (this PR)."""
user_version already at the latest version — currently 7 after
0004-0006 line_reconciliation, 0005 ta1_acks, and SP9's 0007
providers/payers/clearhouse)."""
with db.engine().begin() as c:
v1 = c.exec_driver_sql("PRAGMA user_version").scalar() or 0
assert v1 == 6
assert v1 == 7
# 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 == 6
assert v2 == 7
def test_add_ack_persists_row():
+103
View File
@@ -0,0 +1,103 @@
"""SP9 — clearhouse API endpoint tests."""
from __future__ import annotations
import os
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from cyclone import db as db_mod
from cyclone.api import app
@pytest.fixture
def client(tmp_path, monkeypatch):
monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path}/test.db")
db_mod._reset_for_tests()
db_mod.init_db()
with TestClient(app) as c:
yield c
db_mod._reset_for_tests()
def test_get_clearhouse_seeded(client):
# Lifespan runs ensure_clearhouse_seeded()
r = client.get("/api/clearhouse")
assert r.status_code == 200
body = r.json()
assert body["name"] == "dzinesco"
assert body["tpid"] == "11525703"
assert body["sftp_block"]["stub"] is True
assert "FromHPE" in body["sftp_block"]["paths"]["outbound"]
assert "ToHPE" in body["sftp_block"]["paths"]["inbound"]
def test_list_providers(client):
r = client.get("/api/config/providers")
assert r.status_code == 200, r.text
body = r.json()
assert {p["label"] for p in body} == {"Montrose", "Delta", "Salida"}
assert {p["npi"] for p in body} == {"1881068062", "1851446637", "1467507269"}
def test_get_provider_by_npi(client):
r = client.get("/api/config/providers/1881068062")
assert r.status_code == 200
assert r.json()["label"] == "Montrose"
def test_get_provider_404(client):
r = client.get("/api/config/providers/9999999999")
assert r.status_code == 404
def test_list_payers(client):
r = client.get("/api/config/payers")
assert r.status_code == 200
body = r.json()
assert len(body) == 1
assert body[0]["payer_id"] == "CO_TXIX"
def test_list_payer_configs_for_co_txix(client):
r = client.get("/api/config/payers/CO_TXIX/configs")
assert r.status_code == 200
body = r.json()
# The lifespan loads config/payers.yaml; configs come from the registry
tx_types = {c["transaction_type"] for c in body}
assert "837P" in tx_types or "835" in tx_types
def test_reload_config(client):
r = client.post("/api/admin/reload-config")
assert r.status_code == 200
body = r.json()
assert body["ok"] is True
assert isinstance(body["loaded"], int)
def test_submit_clearhouse_rejects_empty_claim_ids(client):
r = client.post("/api/clearhouse/submit", json={"claim_ids": [], "payer_id": "CO_TXIX"})
assert r.status_code == 400
def test_submit_clearhouse_rejects_missing_payer_id(client):
r = client.post("/api/clearhouse/submit", json={"claim_ids": ["X"]})
assert r.status_code == 400
def test_submit_clearhouse_handles_unknown_claim_id(client):
r = client.post("/api/clearhouse/submit", json={
"claim_ids": ["DOES_NOT_EXIST"],
"payer_id": "CO_TXIX",
})
# 200 with per-claim ok:false entries (graceful degradation)
assert r.status_code == 200
body = r.json()
assert body["ok"] is True
assert body["stub"] is True
assert len(body["submitted"]) == 1
assert body["submitted"][0]["ok"] is False
assert "not found" in body["submitted"][0]["error"] or "cannot be re-serialized" in body["submitted"][0]["error"]
+159
View File
@@ -0,0 +1,159 @@
"""SP9 — HCPF X12 File Naming Standards helper tests."""
from __future__ import annotations
from datetime import datetime
from zoneinfo import ZoneInfo
import pytest
from cyclone.edi.filenames import (
ALLOWED_FILE_TYPES,
OUTBOUND_RE,
INBOUND_RE,
build_outbound_filename,
is_inbound_filename,
is_outbound_filename,
parse_inbound_filename,
)
from cyclone.providers import InboundFilename
MT = ZoneInfo("America/Denver")
# ----- build_outbound_filename --------------------------------------------
def test_build_outbound_with_explicit_mt():
now = datetime(2026, 6, 20, 13, 22, 43, 505_000, tzinfo=MT)
name = build_outbound_filename("11525703", "837P", now_mt=now)
assert name == "11525703-837P-20260620132243505-1of1.x12"
def test_build_outbound_default_extension():
now = datetime(2026, 6, 20, 13, 22, 43, 505_000, tzinfo=MT)
name = build_outbound_filename("11525703", "837P", now_mt=now)
assert name.endswith(".x12")
def test_build_outbound_custom_extension():
now = datetime(2026, 6, 20, 13, 22, 43, 505_000, tzinfo=MT)
name = build_outbound_filename("11525703", "837P", ext="txt", now_mt=now)
assert name == "11525703-837P-20260620132243505-1of1.txt"
def test_build_outbound_uses_mt_when_no_arg():
# Snapshot test — the timestamp will be very recent; check format only
name = build_outbound_filename("11525703", "837P")
assert OUTBOUND_RE.match(name), name
parts = name.split("-")
assert len(parts) == 4
assert len(parts[2]) == 17 # yyyymmddhhmmssSSS
def test_build_outbound_rejects_non_numeric_tpid():
now = datetime(2026, 6, 20, tzinfo=MT)
with pytest.raises(ValueError, match="tpid must be digits"):
build_outbound_filename("abc123", "837P", now_mt=now)
def test_build_outbound_rejects_invalid_tx():
now = datetime(2026, 6, 20, tzinfo=MT)
with pytest.raises(ValueError, match="tx must be uppercase alnum"):
build_outbound_filename("11525703", "837-lower", now_mt=now)
def test_build_outbound_rejects_naive_dt():
with pytest.raises(ValueError, match="timezone-aware"):
build_outbound_filename("11525703", "837P", now_mt=datetime(2026, 6, 20))
def test_build_outbound_converts_other_tz_to_mt():
# 2026-06-20 19:22:43 UTC = 2026-06-20 13:22:43 MT (during DST)
from datetime import timezone
now_utc = datetime(2026, 6, 20, 19, 22, 43, 505_000, tzinfo=timezone.utc)
name = build_outbound_filename("11525703", "837P", now_mt=now_utc)
assert "20260620132243505" in name
# ----- parse_inbound_filename ---------------------------------------------
def test_parse_inbound_999_real_prodfile():
# Real production 999 filename from docs/prodfiles/FromHPE/
name = "TP11525703-837P_M019048402-20260520231513488-1of1_999.x12"
parsed = parse_inbound_filename(name)
assert parsed.tpid == "11525703"
assert parsed.orig_tx == "837P"
assert parsed.tracking == "M019048402"
assert parsed.ts == "20260520231513488"
assert parsed.file_type == "999"
assert parsed.ext == "x12"
def test_parse_inbound_ta1_real_prodfile():
name = "TP11525703-837P_M019044969-20260520180505477-1of1_TA1.x12"
parsed = parse_inbound_filename(name)
assert parsed.file_type == "TA1"
assert parsed.tracking == "M019044969"
def test_parse_inbound_277():
name = "TP11525703-837P_M019110219-20260601003507042-1of1_277.x12"
parsed = parse_inbound_filename(name)
assert parsed.file_type == "277"
def test_parse_inbound_rejects_missing_tp_prefix():
with pytest.raises(ValueError, match="Not a valid HCPF inbound"):
parse_inbound_filename("11525703-837P_M019048402-20260520231513488-1of1_999.x12")
def test_parse_inbound_rejects_wrong_segment_count():
with pytest.raises(ValueError):
parse_inbound_filename("TP11525703_837P_M019048402-1of1_999.x12")
def test_parse_inbound_rejects_unknown_file_type():
with pytest.raises(ValueError, match="not in allowed HCPF set"):
parse_inbound_filename("TP11525703-837P_M019048402-20260520231513488-1of1_XXX.x12")
def test_parse_inbound_rejects_non_x12_ext():
with pytest.raises(ValueError):
parse_inbound_filename("TP11525703-837P_M019048402-20260520231513488-1of1_999.txt")
# ----- round-trip ---------------------------------------------------------
def test_roundtrip_outbound_to_inbound():
# Outbound tpid is bare (no TP); inbound tpid is bare inside TP{...}
# The two regexes use different shapes — round-trip via tpid only.
now = datetime(2026, 6, 20, 13, 22, 43, 505_000, tzinfo=MT)
out = build_outbound_filename("11525703", "837P", now_mt=now)
assert OUTBOUND_RE.match(out)
assert "11525703" in out
assert "837P" in out
assert out.endswith("1of1.x12")
# ----- validators ---------------------------------------------------------
def test_is_outbound_filename():
assert is_outbound_filename("11525703-837P-20260620132243505-1of1.x12")
assert not is_outbound_filename("TP11525703-837P-20260620132243505-1of1.x12")
assert not is_outbound_filename("not-a-filename")
def test_is_inbound_filename():
assert is_inbound_filename("TP11525703-837P_M019048402-20260520231513488-1of1_999.x12")
assert not is_inbound_filename("11525703-837P-20260620132243505-1of1.x12")
def test_allowed_file_types_includes_277ca():
assert "277CA" in ALLOWED_FILE_TYPES
assert "TA1" in ALLOWED_FILE_TYPES
assert "999" in ALLOWED_FILE_TYPES
assert "835" in ALLOWED_FILE_TYPES
+113
View File
@@ -0,0 +1,113 @@
"""SP9 — payer config YAML loader tests."""
from __future__ import annotations
from pathlib import Path
import pytest
import yaml
from cyclone import payers
@pytest.fixture(autouse=True)
def _reset_registry():
payers.reset()
yield
payers.reset()
def test_load_default_config_has_co_txix_837p():
configs = payers.load_payer_configs()
assert ("CO_TXIX", "837P") in configs
block = configs[("CO_TXIX", "837P")]
assert block["payer_id"] == "CO_TXIX"
assert "CH" in block["bht06_allowed"]
assert "RP" in block["bht06_allowed"]
assert block["bht06_default"] == "CH"
assert block["sbr09_default"] == "MC"
def test_load_default_config_has_co_txix_835():
configs = payers.load_payer_configs()
assert ("CO_TXIX", "835") in configs
block = configs[("CO_TXIX", "835")]
assert "1811725341" in block["expected_payer_tax_ids"]
assert block["expected_payer_health_plan_id"] == "7912900843"
def test_get_config_returns_block():
payers.load_payer_configs()
block = payers.get_config("CO_TXIX", "837P")
assert block is not None
assert block["submitter_name"] == "Dzinesco"
def test_get_config_returns_none_for_missing():
payers.load_payer_configs()
assert payers.get_config("UNKNOWN_PAYER", "837P") is None
def test_load_invalid_yaml_raises(tmp_path):
bad = tmp_path / "bad.yaml"
bad.write_text(yaml.safe_dump({"payers": [
{"payer_id": "X", "configs": {"837P": {"submitter_name": "ok"}}} # missing required keys
]}))
with pytest.raises(ValueError, match="invalid config"):
payers.load_payer_configs(bad)
def test_load_missing_payers_key_raises(tmp_path):
bad = tmp_path / "bad.yaml"
bad.write_text("not_a_payers_key: 1")
with pytest.raises(ValueError, match="missing top-level 'payers:'"):
payers.load_payer_configs(bad)
def test_load_missing_file_warns_and_clears(tmp_path, caplog):
fake = tmp_path / "does-not-exist.yaml"
with caplog.at_level("WARNING"):
result = payers.load_payer_configs(fake)
assert result == {}
def test_all_configs_returns_snapshot():
payers.load_payer_configs()
snap = payers.all_configs()
assert isinstance(snap, dict)
assert len(snap) >= 2
# Mutating snapshot must not affect registry
snap.clear()
assert len(payers.all_configs()) >= 2
def test_reload_picks_up_changes(tmp_path):
a = tmp_path / "a.yaml"
a.write_text(yaml.safe_dump({"payers": [
{"payer_id": "AAA", "configs": {"837P": {
"submitter_name": "A", "submitter_contact_name": "A",
"submitter_contact_email": "a@a",
"receiver_name": "R", "receiver_id": "R",
"bht06_allowed": ["CH"], "bht06_default": "CH",
"sbr09_default": "MC", "sbr09_allowed": ["MC"],
"payer_id_qualifier": "PI", "payer_id": "AAA",
"pwk_supported": False, "cas_2320_group_allowed": False,
}}}
]}))
b = tmp_path / "b.yaml"
b.write_text(yaml.safe_dump({"payers": [
{"payer_id": "BBB", "configs": {"837P": {
"submitter_name": "B", "submitter_contact_name": "B",
"submitter_contact_email": "b@b",
"receiver_name": "R", "receiver_id": "R",
"bht06_allowed": ["CH"], "bht06_default": "CH",
"sbr09_default": "MC", "sbr09_allowed": ["MC"],
"payer_id_qualifier": "PI", "payer_id": "BBB",
"pwk_supported": False, "cas_2320_group_allowed": False,
}}}
]}))
payers.load_payer_configs(a)
assert payers.get_config("AAA", "837P") is not None
payers.load_payer_configs(b)
assert payers.get_config("BBB", "837P") is not None
assert payers.get_config("AAA", "837P") is None
+128
View File
@@ -0,0 +1,128 @@
"""SP9 — providers/payers/clearhouse seed + CRUD tests."""
from __future__ import annotations
from datetime import datetime, timezone
import pytest
from cyclone import db as db_mod
from cyclone.providers import Provider
from cyclone.store import store
@pytest.fixture(autouse=True)
def _fresh_db(tmp_path, monkeypatch):
"""Use a fresh in-memory SQLite for each test."""
monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path}/test.db")
db_mod._reset_for_tests()
db_mod.init_db()
yield
db_mod._reset_for_tests()
def test_seed_creates_3_providers():
store.ensure_clearhouse_seeded()
providers = store.list_providers()
labels = {p.label for p in providers}
assert labels == {"Montrose", "Delta", "Salida"}
npis = {p.npi for p in providers}
assert npis == {"1881068062", "1851446637", "1467507269"}
def test_seed_uses_consistent_tax_id_and_taxonomy():
store.ensure_clearhouse_seeded()
providers = store.list_providers()
for p in providers:
assert p.tax_id == "721587149"
assert p.taxonomy_code == "251E00000X"
assert p.legal_name == "TOC, Inc."
assert p.address_line1 == "1100 East Main St"
assert p.address_line2 == "Suite A"
assert p.city == "Montrose"
assert p.state == "CO"
assert p.zip == "814014063"
def test_seed_creates_clearhouse_singleton():
store.ensure_clearhouse_seeded()
ch = store.get_clearhouse()
assert ch is not None
assert ch.id == 1
assert ch.name == "dzinesco"
assert ch.tpid == "11525703"
assert ch.submitter_name == "Dzinesco"
assert ch.sftp_block.host == "mft.gainwelltechnologies.com"
assert ch.sftp_block.stub is True
assert "FromHPE" in ch.sftp_block.paths["outbound"]
assert "ToHPE" in ch.sftp_block.paths["inbound"]
def test_seed_creates_co_txix_payer_with_both_configs():
store.ensure_clearhouse_seeded()
payers = store.list_payers()
assert {p.payer_id for p in payers} == {"CO_TXIX"}
p837 = store.get_payer_config("CO_TXIX", "837P")
p835 = store.get_payer_config("CO_TXIX", "835")
assert p837 is not None and p837["payer_id"] == "CO_TXIX"
assert p835 is not None and "1811725341" in p835["expected_payer_tax_ids"]
def test_seed_is_idempotent():
store.ensure_clearhouse_seeded()
store.ensure_clearhouse_seeded()
store.ensure_clearhouse_seeded()
assert len(store.list_providers()) == 3
assert len(store.list_payers()) == 1
assert store.get_clearhouse() is not None
def test_get_provider_returns_none_for_unknown_npi():
store.ensure_clearhouse_seeded()
assert store.get_provider("9999999999") is None
def test_upsert_provider_creates_then_updates():
store.ensure_clearhouse_seeded()
p = Provider(
npi="1111111111",
label="Test",
legal_name="Test, Inc.",
tax_id="123456789",
taxonomy_code="207R00000X",
address_line1="1 Test Way",
city="Testville",
state="CO",
zip="80000",
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
)
stored = store.upsert_provider(p)
assert stored.label == "Test"
p2 = p.model_copy(update={"label": "Updated"})
stored2 = store.upsert_provider(p2)
assert stored2.label == "Updated"
assert len(store.list_providers(is_active=None)) == 4
def test_list_providers_filter_is_active():
store.ensure_clearhouse_seeded()
p = Provider(
npi="2222222222",
label="Inactive",
legal_name="X",
tax_id="1",
taxonomy_code="X",
address_line1="X",
city="X",
state="CO",
zip="0",
is_active=False,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
)
store.upsert_provider(p)
active = store.list_providers(is_active=True)
assert {pp.label for pp in active} == {"Montrose", "Delta", "Salida"}
all_p = store.list_providers(is_active=None)
assert {pp.label for pp in all_p} == {"Montrose", "Delta", "Salida", "Inactive"}
+59
View File
@@ -0,0 +1,59 @@
"""SP9 — macOS Keychain secret accessor tests."""
from __future__ import annotations
from unittest.mock import patch
import pytest
from cyclone import secrets
from cyclone.secrets import STUB_SECRET, get_secret, has_keyring, set_secret
def test_has_keyring_true_when_lib_present():
# The test env has keyring installed
assert has_keyring() is True
def test_get_secret_returns_none_when_keyring_missing(monkeypatch):
monkeypatch.setattr(secrets, "_HAS_KEYRING", False)
monkeypatch.setattr(secrets, "keyring", None)
assert get_secret("anything") is None
def test_get_secret_returns_keychain_value():
with patch("cyclone.secrets.keyring") as mock_kr:
mock_kr.get_password.return_value = "p@ssw0rd"
v = get_secret("sftp.gainwell.password")
assert v == "p@ssw0rd"
mock_kr.get_password.assert_called_once_with("cyclone", "sftp.gainwell.password")
def test_get_secret_returns_none_on_keychain_exception():
with patch("cyclone.secrets.keyring") as mock_kr:
mock_kr.get_password.side_effect = RuntimeError("keychain locked")
v = get_secret("x")
assert v is None
def test_set_secret_returns_true_on_success():
with patch("cyclone.secrets.keyring") as mock_kr:
assert set_secret("a", "b") is True
mock_kr.set_password.assert_called_once_with("cyclone", "a", "b")
def test_set_secret_returns_false_when_keyring_missing(monkeypatch):
monkeypatch.setattr(secrets, "_HAS_KEYRING", False)
monkeypatch.setattr(secrets, "keyring", None)
assert set_secret("a", "b") is False
def test_set_secret_returns_false_on_keychain_exception():
with patch("cyclone.secrets.keyring") as mock_kr:
mock_kr.set_password.side_effect = RuntimeError("denied")
assert set_secret("a", "b") is False
def test_stub_secret_is_distinct_string():
assert STUB_SECRET == "<stub-secret>"
assert STUB_SECRET != ""
+92
View File
@@ -0,0 +1,92 @@
"""SP9 — SFTP stub tests."""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from cyclone.clearhouse import SftpClient
from cyclone.providers import SftpBlock
@pytest.fixture
def sftp_block(tmp_path):
staging = tmp_path / "staging"
return SftpBlock(
host="mft.gainwelltechnologies.com",
port=22,
username="colorado-fts\\coxix_prod_11525703",
paths={
"outbound": "/CO XIX/PROD/coxix_prod_11525703/FromHPE",
"inbound": "/CO XIX/PROD/coxix_prod_11525703/ToHPE",
},
stub=True,
staging_dir=str(staging),
poll_seconds=300,
auth={"method": "keychain", "secret_ref": "sftp.gainwell.password"},
)
def test_stub_writes_preserving_remote_path(sftp_block, tmp_path):
client = SftpClient(sftp_block)
remote = "/CO XIX/PROD/coxix_prod_11525703/FromHPE/11525703-837P-20260620132243505-1of1.x12"
target = client.write_file(remote, b"ISA*00*...~IEA*1*1~")
assert target.exists()
assert target.read_bytes() == b"ISA*00*...~IEA*1*1~"
# Confirm the full nested MFT path is preserved under staging
rel = target.relative_to(sftp_block.staging_dir)
assert str(rel) == "CO XIX/PROD/coxix_prod_11525703/FromHPE/11525703-837P-20260620132243505-1of1.x12"
def test_stub_creates_parent_dirs(sftp_block):
client = SftpClient(sftp_block)
remote = "/deep/nested/path/file.x12"
target = client.write_file(remote, b"data")
assert target.exists()
assert target.parent.is_dir()
def test_stub_list_inbound_empty_when_no_local_files(sftp_block):
client = SftpClient(sftp_block)
assert client.list_inbound() == []
def test_stub_list_inbound_returns_local_files(sftp_block):
# Simulate operator dropping a file in the inbound staging dir
inbound_dir = Path(sftp_block.staging_dir) / "CO XIX/PROD/coxix_prod_11525703/ToHPE"
inbound_dir.mkdir(parents=True, exist_ok=True)
(inbound_dir / "TP11525703-837P_M019048402-20260520231513488-1of1_999.x12").write_bytes(b"X")
client = SftpClient(sftp_block)
files = client.list_inbound()
assert len(files) == 1
assert files[0].name.startswith("TP11525703-837P_M019048402")
assert files[0].size == 1
def test_stub_get_secret_returns_stub_when_keychain_empty(sftp_block):
client = SftpClient(sftp_block)
# Keychain is empty on the test box, so we get the stub sentinel
secret = client.get_secret("sftp.gainwell.password")
assert secret == "<stub-secret>"
def test_real_mode_write_raises_not_implemented(sftp_block):
block = sftp_block.model_copy(update={"stub": False})
client = SftpClient(block)
with pytest.raises(NotImplementedError, match="SP13"):
client.write_file("/x.x12", b"y")
def test_real_mode_list_inbound_raises_not_implemented(sftp_block):
block = sftp_block.model_copy(update={"stub": False})
client = SftpClient(block)
with pytest.raises(NotImplementedError, match="SP13"):
client.list_inbound()
def test_stub_read_file_raises(sftp_block):
client = SftpClient(sftp_block)
with pytest.raises(RuntimeError, match="Stub SFTP cannot read"):
client.read_file("/x.x12")
+263
View File
@@ -0,0 +1,263 @@
"""SP9 — R200-R210 validation rule tests.
These tests use the in-code PAYER_FACTORIES (which include the live
payer_config blocks loaded from YAML) so the rules have a non-empty
cfg_block to read. The live ``cyclone.payers`` registry is the source
of truth for CO_MAP; the rules fall back to the in-code PayerConfig
when the registry is empty.
"""
from __future__ import annotations
from datetime import date
from decimal import Decimal
import pytest
from cyclone import db as db_mod
from cyclone import payers as payer_loader
from cyclone.parsers.models import (
Address,
BillingProvider,
ClaimHeader,
ClaimOutput,
Diagnosis,
Payer,
Procedure,
ServiceLine,
Subscriber,
ValidationReport,
)
from cyclone.parsers.payer import PayerConfig
from cyclone.parsers.validator import validate
from cyclone.store import store
@pytest.fixture(autouse=True)
def _seed_db(tmp_path, monkeypatch):
"""Seed an in-memory DB with 3 providers + CO_TXIX payer, load YAML."""
monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path}/test.db")
db_mod._reset_for_tests()
db_mod.init_db()
payer_loader.load_payer_configs()
store.ensure_clearhouse_seeded()
yield
db_mod._reset_for_tests()
payer_loader.reset()
def _co_txix_cfg() -> PayerConfig:
"""Build a PayerConfig matching the live CO_TXIX YAML block."""
return PayerConfig(
name="Colorado Medical Assistance Program",
sbr09_claim_filing="MC",
allowed_claim_frequencies={1, 7, 8},
require_ref_g1_for_adjustments=False,
allowed_bht06={"CH", "RP"},
payer_id="CO_TXIX",
payer_name="COHCPF",
no_patient_loop=True,
encounter_claim_in_same_batch=False,
)
def _claim(*, npi="1881068062", sbr09=None, prv_code=None, payer_id="CO_TXIX",
payer_name="COHCPF", ref_ei="721587149", transaction_type_code="CH",
has_pwk=False) -> ClaimOutput:
raw = []
if sbr09 is not None:
raw.append(["SBR", "P", "18", "", "", "", "", "", "", sbr09])
if prv_code is not None:
raw.append(["PRV", "BI", "PXC", f"PXC{prv_code}"])
raw.append(["NM1", "85", "2", "TOC, Inc.", "", "", "", "", "XX", npi])
if ref_ei is not None:
raw.append(["REF", "EI", ref_ei])
if has_pwk:
raw.append(["PWK"])
return ClaimOutput(
claim_id="CLM1",
control_number="0001",
transaction_date=date(2026, 6, 20),
billing_provider=BillingProvider(
name="TOC, Inc.",
npi=npi,
tax_id=ref_ei,
address=Address(line1="1100 East Main St", city="Montrose", state="CO", zip="81401"),
),
subscriber=Subscriber(first_name="John", last_name="Doe", member_id="M1"),
payer=Payer(name=payer_name, id=payer_id),
claim=ClaimHeader(
claim_id="CLM1",
total_charge=Decimal("100.00"),
frequency_code="1",
place_of_service="11",
facility_code_qualifier="B",
),
transaction_type_code=transaction_type_code,
diagnoses=[Diagnosis(code="R69")],
service_lines=[
ServiceLine(
line_number=1,
procedure=Procedure(qualifier="HC", code="99213"),
charge=Decimal("100.00"),
units=Decimal("1"),
),
],
raw_segments=raw,
validation=ValidationReport(passed=True, errors=[], warnings=[]),
)
# ----- R200: BHT06 allowed ------------------------------------------------
def test_r200_passes_for_allowed_bht06():
c = _claim(transaction_type_code="CH")
report = validate(c, _co_txix_cfg())
bad = [i for i in report.errors if i.rule.startswith("R200")]
assert bad == []
def test_r200_fails_for_disallowed_bht06():
c = _claim(transaction_type_code="XX")
report = validate(c, _co_txix_cfg())
bad = [i for i in report.errors if i.rule == "R200_bht06_allowed"]
assert len(bad) == 1
assert "not in" in bad[0].message
def test_r200_skips_when_no_bht06():
c = _claim(transaction_type_code="")
report = validate(c, _co_txix_cfg())
assert not [i for i in report.errors if i.rule == "R200_bht06_allowed"]
# ----- R201: BHT06 no mixed batch ------------------------------------------
def test_r201_is_a_per_claim_noop():
# Batch-level rule; per-claim the rule yields nothing
c = _claim(transaction_type_code="CH")
report = validate(c, _co_txix_cfg())
assert not [i for i in report.errors if i.rule == "R201_bht06_no_mixed_batch"]
# ----- R202: SBR09 allowed ------------------------------------------------
def test_r202_passes_for_mc():
c = _claim(sbr09="MC")
report = validate(c, _co_txix_cfg())
assert not [i for i in report.errors if i.rule == "R202_sbr09_allowed"]
def test_r202_fails_for_unknown():
c = _claim(sbr09="AB")
report = validate(c, _co_txix_cfg())
bad = [i for i in report.errors if i.rule == "R202_sbr09_allowed"]
assert len(bad) == 1
def test_r202_passes_for_zz_mco_encounter():
c = _claim(sbr09="ZZ")
report = validate(c, _co_txix_cfg())
assert not [i for i in report.errors if i.rule == "R202_sbr09_allowed"]
# ----- R203: PRV matches provider ----------------------------------------
def test_r203_passes_when_prv_matches_provider_taxonomy():
c = _claim(prv_code="251E00000X")
report = validate(c, _co_txix_cfg())
assert not [i for i in report.errors if i.rule == "R203_prv_matches_provider"]
def test_r203_fails_when_prv_mismatch():
c = _claim(prv_code="999Z00000X")
report = validate(c, _co_txix_cfg())
bad = [i for i in report.errors if i.rule == "R203_prv_matches_provider"]
assert len(bad) == 1
# ----- R204: NPI in providers table --------------------------------------
def test_r204_passes_for_known_npi():
c = _claim(npi="1881068062")
report = validate(c, _co_txix_cfg())
assert not [i for i in report.errors if i.rule == "R204_npi_in_providers_table"]
def test_r204_fails_for_unknown_npi():
c = _claim(npi="9999999999")
report = validate(c, _co_txix_cfg())
bad = [i for i in report.errors if i.rule == "R204_npi_in_providers_table"]
assert len(bad) == 1
# ----- R205: REF*EI matches provider --------------------------------------
def test_r205_passes_when_ref_ei_matches():
c = _claim(ref_ei="721587149")
report = validate(c, _co_txix_cfg())
assert not [i for i in report.errors if i.rule == "R205_ref_ei_matches_provider"]
def test_r205_fails_when_ref_ei_mismatch():
c = _claim(ref_ei="000000000")
report = validate(c, _co_txix_cfg())
bad = [i for i in report.errors if i.rule == "R205_ref_ei_matches_provider"]
assert len(bad) == 1
# ----- R206: Payer ID matches ---------------------------------------------
def test_r206_passes_for_co_txix():
c = _claim(payer_id="CO_TXIX")
report = validate(c, _co_txix_cfg())
assert not [i for i in report.errors if i.rule == "R206_payer_id_matches"]
def test_r206_fails_for_wrong_payer_id():
c = _claim(payer_id="ZZZZZZ")
report = validate(c, _co_txix_cfg())
bad = [i for i in report.errors if i.rule == "R206_payer_id_matches"]
assert len(bad) == 1
# ----- R207: no PWK segment ----------------------------------------------
def test_r207_passes_when_no_pwk():
c = _claim(has_pwk=False)
report = validate(c, _co_txix_cfg())
assert not [i for i in report.errors if i.rule == "R207_no_pwk_segment"]
def test_r207_fails_when_pwk_present():
c = _claim(has_pwk=True)
report = validate(c, _co_txix_cfg())
bad = [i for i in report.errors if i.rule == "R207_no_pwk_segment"]
assert len(bad) == 1
# ----- R208: 2320 CAS*PI* group ------------------------------------------
def test_r208_is_a_per_claim_noop():
c = _claim()
report = validate(c, _co_txix_cfg())
assert not [i for i in report.errors if i.rule == "R208_cas_2320_group"]
# ----- R209, R210: filename rules (validate at submit / parse time) ----
def test_r209_r210_per_claim_noop():
# These run at file-routing time, not per-claim validate
c = _claim()
report = validate(c, _co_txix_cfg())
assert not [i for i in report.errors if i.rule in ("R209_outbound_filename", "R210_inbound_filename")]