c62826daea
- 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
264 lines
8.1 KiB
Python
264 lines
8.1 KiB
Python
"""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")]
|