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
+251
View File
@@ -52,6 +52,7 @@ from cyclone.db import (
from cyclone.parsers.models import ClaimOutput, ParseResult
from cyclone.parsers.models_835 import ClaimPayment, ParseResult835
from cyclone.parsers.payer import PayerConfig835
from cyclone.providers import Clearhouse, Payer, Provider # SP9: ORM-row DTOs
class AlreadyMatchedError(Exception):
@@ -2082,6 +2083,256 @@ class CycloneStore:
"deletedMatches": deleted_count,
}
# ------------------------------------------------------------------
# SP9: providers / payers / payer_configs / clearhouse
# ------------------------------------------------------------------
def list_providers(self, *, is_active: bool | None = True) -> list[Provider]:
"""List providers. ``is_active=None`` returns all."""
from cyclone.db import Provider as ProviderORM
from cyclone.providers import Provider
with db.SessionLocal()() as s:
q = s.query(ProviderORM)
if is_active is not None:
q = q.filter(ProviderORM.is_active == (1 if is_active else 0))
rows = q.order_by(ProviderORM.label).all()
return [Provider.model_validate(_provider_orm_to_dict(r)) for r in rows]
def get_provider(self, npi: str) -> Provider | None:
from cyclone.db import Provider as ProviderORM
from cyclone.providers import Provider
with db.SessionLocal()() as s:
row = s.get(ProviderORM, npi)
return Provider.model_validate(_provider_orm_to_dict(row)) if row else None
def upsert_provider(self, provider: Provider) -> Provider:
from cyclone.db import Provider as ProviderORM
with db.SessionLocal()() as s:
row = s.get(ProviderORM, provider.npi)
now = utcnow().isoformat()
if row is None:
row = ProviderORM(
npi=provider.npi, label=provider.label,
legal_name=provider.legal_name, tax_id=provider.tax_id,
taxonomy_code=provider.taxonomy_code,
address_line1=provider.address_line1,
address_line2=provider.address_line2,
city=provider.city, state=provider.state, zip=provider.zip,
is_active=1 if provider.is_active else 0,
created_at=provider.created_at.isoformat(),
updated_at=now,
)
s.add(row)
else:
row.label = provider.label
row.legal_name = provider.legal_name
row.tax_id = provider.tax_id
row.taxonomy_code = provider.taxonomy_code
row.address_line1 = provider.address_line1
row.address_line2 = provider.address_line2
row.city = provider.city
row.state = provider.state
row.zip = provider.zip
row.is_active = 1 if provider.is_active else 0
row.updated_at = now
s.commit()
return self.get_provider(provider.npi) # type: ignore[return-value]
def list_payers(self, *, is_active: bool | None = True) -> list[Payer]:
from cyclone.db import Payer as PayerORM
from cyclone.providers import Payer
with db.SessionLocal()() as s:
q = s.query(PayerORM)
if is_active is not None:
q = q.filter(PayerORM.is_active == (1 if is_active else 0))
rows = q.order_by(PayerORM.payer_id).all()
return [Payer.model_validate(_payer_orm_to_dict(r)) for r in rows]
def get_payer_config(self, payer_id: str, transaction_type: str) -> dict | None:
from cyclone.db import PayerConfigORM
with db.SessionLocal()() as s:
row = s.get(PayerConfigORM, (payer_id, transaction_type))
return dict(row.config_json) if row else None
def get_clearhouse(self) -> Clearhouse | None:
from cyclone.db import ClearhouseORM
from cyclone.providers import Clearhouse
with db.SessionLocal()() as s:
row = s.get(ClearhouseORM, 1)
if row is None:
return None
return Clearhouse.model_validate({
"id": 1,
"name": row.name,
"tpid": row.tpid,
"submitter_name": row.submitter_name,
"submitter_id_qual": row.submitter_id_qual,
"submitter_contact_name": row.submitter_contact_name,
"submitter_contact_email": row.submitter_contact_email,
"filename_block": dict(row.filename_block_json),
"sftp_block": dict(row.sftp_block_json),
"updated_at": row.updated_at,
})
def ensure_clearhouse_seeded(self) -> None:
"""Insert the default clearhouse singleton + 3 providers + CO_TXIX payer
if they don't exist. Idempotent. Called from the API lifespan."""
from cyclone.db import ClearhouseORM, Payer as PayerORM, PayerConfigORM, Provider as ProviderORM
from cyclone.providers import Clearhouse
with db.SessionLocal()() as s:
if s.get(ClearhouseORM, 1) is None:
ch = Clearhouse(
id=1,
name="dzinesco",
tpid="11525703",
submitter_name="Dzinesco",
submitter_id_qual="46",
submitter_contact_name="Tyler Martinez",
submitter_contact_email="tyler@dzinesco.com",
filename_block={
"tz": "America/Denver",
"outbound_template": "{tpid}-{tx}-{ts_mt}-1of1.{ext}",
"inbound_template": "TP{tpid}-{orig_tx}_M{tracking}-{ts}-1of1_{file_type}.x12",
},
sftp_block={
"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": "./var/sftp/staging",
"poll_seconds": 300,
"auth": {"method": "keychain", "secret_ref": "sftp.gainwell.password"},
},
updated_at=utcnow(),
)
s.add(ClearhouseORM(
id=1,
name=ch.name,
tpid=ch.tpid,
submitter_name=ch.submitter_name,
submitter_id_qual=ch.submitter_id_qual,
submitter_contact_name=ch.submitter_contact_name,
submitter_contact_email=ch.submitter_contact_email,
filename_block_json=ch.filename_block.model_dump(),
sftp_block_json=ch.sftp_block.model_dump(),
updated_at=ch.updated_at.isoformat(),
))
# Seed 3 providers (idempotent)
from cyclone.providers import Provider
now = utcnow().isoformat()
for npi, label in [
("1881068062", "Montrose"),
("1851446637", "Delta"),
("1467507269", "Salida"),
]:
if s.get(ProviderORM, npi) is None:
s.add(ProviderORM(
npi=npi,
label=label,
legal_name="TOC, Inc.",
tax_id="721587149",
taxonomy_code="251E00000X",
address_line1="1100 East Main St",
address_line2="Suite A",
city="Montrose",
state="CO",
zip="814014063",
is_active=1,
created_at=now,
updated_at=now,
))
# Seed CO_TXIX payer (idempotent)
if s.get(PayerORM, "CO_TXIX") is None:
s.add(PayerORM(
payer_id="CO_TXIX",
name="Colorado Medical Assistance Program",
receiver_name="COLORADO MEDICAL ASSISTANCE PROGRAM",
receiver_id="COMEDASSISTPROG",
is_active=1,
created_at=now,
updated_at=now,
))
# 837P config block
s.add(PayerConfigORM(
payer_id="CO_TXIX",
transaction_type="837P",
config_json={
"submitter_name": "Dzinesco",
"submitter_contact_name": "Tyler Martinez",
"submitter_contact_email": "tyler@dzinesco.com",
"receiver_name": "COLORADO MEDICAL ASSISTANCE PROGRAM",
"receiver_id_qualifier": "46",
"receiver_id": "COMEDASSISTPROG",
"bht06_allowed": ["CH", "RP"],
"bht06_default": "CH",
"sbr09_default": "MC",
"sbr09_allowed": ["MC", "16", "MA", "MB", "ZZ"],
"payer_id_qualifier": "PI",
"payer_id": "CO_TXIX",
"pwk_supported": False,
"cas_2320_group_allowed": False,
"claim_type_codes": {"11": "Office", "12": "Home", "99": "Other"},
},
updated_at=now,
))
# 835 config block
s.add(PayerConfigORM(
payer_id="CO_TXIX",
transaction_type="835",
config_json={
"expected_payer_tax_ids": [
"81-1725341", "811725341", "84-0644739",
"840644739", "1811725341",
],
"expected_payer_health_plan_id": "7912900843",
"payer_name_pattern": "^CO_(TXIX|BHA)$",
},
updated_at=now,
))
s.commit()
# ---------------------------------------------------------------------------
# SP9: ORM-to-Pydantic conversion helpers
# ---------------------------------------------------------------------------
def _provider_orm_to_dict(row) -> dict:
return {
"npi": row.npi,
"label": row.label,
"legal_name": row.legal_name,
"tax_id": row.tax_id,
"taxonomy_code": row.taxonomy_code,
"address_line1": row.address_line1,
"address_line2": row.address_line2,
"city": row.city,
"state": row.state,
"zip": row.zip,
"is_active": bool(row.is_active),
"created_at": row.created_at,
"updated_at": row.updated_at,
}
def _payer_orm_to_dict(row) -> dict:
return {
"payer_id": row.payer_id,
"name": row.name,
"receiver_name": row.receiver_name,
"receiver_id": row.receiver_id,
"is_active": bool(row.is_active),
"created_at": row.created_at,
"updated_at": row.updated_at,
}
# Module-level singleton — same import path the old InMemoryStore used.
store = CycloneStore()