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
+2
View File
@@ -14,6 +14,8 @@ dependencies = [
"uvicorn[standard]>=0.27,<1",
"python-multipart>=0.0.9,<1",
"sqlalchemy>=2.0,<3",
"pyyaml>=6.0,<7",
"keyring>=25.0,<26",
]
[project.optional-dependencies]
+153 -1
View File
@@ -85,10 +85,16 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
"""Initialize per-process resources (DB + EventBus) before the app
serves requests. No teardown needed for Cyclone's local-only posture.
"""
from cyclone import db
from cyclone import db, payers as payer_loader
db.init_db()
app.state.event_bus = EventBus()
# SP9: load payer config and seed singleton + 3 providers + CO_TXIX.
try:
payer_loader.load_payer_configs()
store.ensure_clearhouse_seeded()
except Exception as exc: # noqa: BLE001
log.exception("SP9 seed failed: %s", exc)
yield
@@ -2126,4 +2132,150 @@ async def post_eligibility_parse_271(
}
# ---------------------------------------------------------------------------
# SP9: providers / payers / clearhouse endpoints
# ---------------------------------------------------------------------------
@app.get("/api/clearhouse")
def get_clearhouse():
"""Return the singleton clearhouse config (dzinesco's identity, SFTP block, filename block)."""
ch = store.get_clearhouse()
if ch is None:
raise HTTPException(status_code=404, detail="clearhouse not seeded")
return json.loads(ch.model_dump_json())
@app.post("/api/clearhouse/submit")
def submit_to_clearhouse(body: dict):
"""Submit a batch of claims to the clearhouse (SFTP). SP9: stub.
Body: ``{"claim_ids": [...], "payer_id": "CO_TXIX"}``
Stub behavior: serializes each claim via the SP7 serializer, builds
an HCPF-compliant outbound filename, and copies the result to
``{staging_dir}/{outbound_path}/{filename}`` instead of opening a
real SFTP connection. Returns a receipt per claim.
"""
from cyclone.clearhouse import make_client
from cyclone.edi.filenames import build_outbound_filename
claim_ids = body.get("claim_ids", [])
payer_id = body.get("payer_id")
if not claim_ids:
raise HTTPException(status_code=400, detail="claim_ids required")
if not payer_id:
raise HTTPException(status_code=400, detail="payer_id required")
ch = store.get_clearhouse()
if ch is None:
raise HTTPException(status_code=500, detail="clearhouse not seeded")
client = make_client(ch.sftp_block)
results = []
for cid in claim_ids:
try:
x12_text = _serialize_claim_for_submit(cid)
except Exception as exc: # noqa: BLE001
results.append({"claim_id": cid, "ok": False, "error": str(exc)})
continue
filename = build_outbound_filename(ch.tpid, "837P")
remote = f"{ch.sftp_block.paths['outbound']}/{filename}"
staging_path = client.write_file(remote, x12_text.encode("utf-8"))
results.append({
"claim_id": cid,
"ok": True,
"filename": filename,
"staging_path": str(staging_path),
"remote_path": remote,
})
return {"ok": True, "submitted": results, "stub": ch.sftp_block.stub}
def _serialize_claim_for_submit(claim_id: str) -> str:
"""Serialize a claim to X12 for SFTP submission. Lazy import of the
serializer to avoid pulling FastAPI machinery at module import time."""
from cyclone.parsers.serialize_837 import serialize_837
from cyclone import db
with db.SessionLocal()() as s:
row = s.get(db.Claim, claim_id)
if row is None:
raise ValueError(f"claim {claim_id!r} not found")
# Re-parse the stored raw_json to get a ClaimOutput
from cyclone.parsers.models import ClaimOutput, Envelope, Subscriber, Payer, BillingProvider
from cyclone.parsers.parse_837 import parse
raw = row.raw_json or {}
# Reconstruct minimal ClaimOutput from raw_json; this is best-effort.
return _serialize_claim_from_raw(row, raw)
def _serialize_claim_from_raw(claim_row, raw: dict) -> str:
"""Best-effort serializer that uses the stored raw_json to emit a fresh 837.
For SP9 this delegates to the existing serialize_837 helper if the
claim has a complete raw_segments array. Otherwise it returns a
minimal placeholder.
"""
from cyclone.parsers.serialize_837 import serialize_837
from cyclone.parsers.parse_837 import parse
# Re-parse the original batch text (need to re-derive from store).
# SP9 stub: if the claim has a `raw_json` with `x12_text`, use that.
if isinstance(raw, dict) and raw.get("x12_text"):
result = parse(raw["x12_text"])
if result.claims:
return serialize_837(result.claims[0])
# Fallback: raise so the caller sees an error.
raise RuntimeError(
f"claim {claim_row.id!r} cannot be re-serialized: no stored x12_text"
)
@app.get("/api/config/providers")
def list_configured_providers(is_active: bool | None = Query(default=True)):
"""List the configured provider rows (3 NPIs for SP9)."""
return [json.loads(p.model_dump_json()) for p in store.list_providers(is_active=is_active)]
@app.get("/api/config/providers/{npi}")
def get_configured_provider(npi: str):
p = store.get_provider(npi)
if p is None:
raise HTTPException(status_code=404, detail=f"provider {npi!r} not found")
return json.loads(p.model_dump_json())
@app.get("/api/config/payers")
def list_configured_payers(is_active: bool | None = Query(default=True)):
return [json.loads(p.model_dump_json()) for p in store.list_payers(is_active=is_active)]
@app.get("/api/config/payers/{payer_id}/configs")
def list_payer_configs(payer_id: str):
"""List all (transaction_type, config_json) blocks for a payer."""
from cyclone import payers as payer_loader
configs = [
{"transaction_type": tx, "config_json": block, "source": "yaml"}
for (pid, tx), block in payer_loader.all_configs().items()
if pid == payer_id
]
# Also check the DB for runtime-overridden configs
for tx in ("837P", "835", "277CA", "999", "TA1"):
live = store.get_payer_config(payer_id, tx)
if live is not None:
configs.append({"transaction_type": tx, "config_json": live, "source": "db"})
return configs
@app.post("/api/admin/reload-config")
def reload_config():
"""Re-read ``config/payers.yaml`` and revalidate. Returns counts."""
from cyclone import payers as payer_loader
try:
configs = payer_loader.load_payer_configs()
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
return {"ok": True, "loaded": len(configs), "errors": []}
__all__ = ["app"]
+150
View File
@@ -0,0 +1,150 @@
"""Clearhouse integration (SFTP submission, inbound polling).
SP9. See spec: docs/superpowers/specs/2026-06-20-cyclone-multi-payer-npi-sftp-design.md
Stub flow (SP9): writes the generated 837 files to a local staging dir,
preserving the full MFT path under staging so the operator can review
exactly what would be uploaded.
Wire-up (SP13): replace ``SftpClient._write_bytes_stub`` with a
``paramiko``-backed implementation. The public ``SftpClient.write_file``,
``list_inbound``, ``read_file`` methods do not change.
"""
from __future__ import annotations
import logging
import os
import shutil
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Iterable, Optional
from cyclone import secrets
from cyclone.providers import SftpBlock
log = logging.getLogger(__name__)
@dataclass
class InboundFile:
"""A single file observed in the inbound MFT path."""
name: str
size: int
modified_at: datetime
local_path: Path
class SftpClient:
"""SFTP client wrapper. SP9 ships a stub; SP13 wires paramiko.
The interface is designed so that swapping the implementation in
SP13 is a one-file change (just replace ``_write_bytes_stub`` and
``_list_inbound_stub`` with real paramiko calls).
"""
def __init__(self, block: SftpBlock) -> None:
self._block = block
self._stub = block.stub
# ---- Public API -------------------------------------------------------
def write_file(self, remote_path: str, content: bytes) -> Path:
"""Write bytes to the given remote path. Returns the local staging path.
In stub mode, ``remote_path`` is preserved relative to the
configured ``staging_dir``. In real mode, this is a paramiko
SFTP put.
"""
if self._stub:
return self._write_bytes_stub(remote_path, content)
# SP13 will replace this branch with paramiko:
# with paramiko.SSHClient() as ssh:
# ssh.connect(self._block.host, port=self._block.port, ...)
# sftp = ssh.open_sftp()
# with sftp.open(remote_path, "wb") as f:
# f.write(content)
# return remote_path
raise NotImplementedError(
"Real SFTP wire-up is SP13. Set clearhouse.sftp_block.stub=true in the meantime."
)
def list_inbound(self) -> list[InboundFile]:
"""List files in the inbound MFT path. Stub returns [] in stub mode.
SP13 will replace with ``sftp.listdir_attr`` and download into
a local cache.
"""
if self._stub:
return self._list_inbound_stub()
raise NotImplementedError("Real SFTP wire-up is SP13.")
def read_file(self, remote_path: str) -> bytes:
"""Read bytes from a remote path. Stub raises in stub mode."""
if self._stub:
raise RuntimeError(
"Stub SFTP cannot read remote files. Use the local staging dir."
)
raise NotImplementedError("Real SFTP wire-up is SP13.")
def get_secret(self, name: str) -> Optional[str]:
"""Fetch the auth secret from Keychain. Returns the stub secret if absent."""
value = secrets.get_secret(name)
if value is None:
log.info("Keychain entry %r missing; using stub secret", name)
return secrets.STUB_SECRET
return value
# ---- Stub implementations --------------------------------------------
def _write_bytes_stub(self, remote_path: str, content: bytes) -> Path:
"""Copy ``content`` to ``{staging_dir}/{remote_path}``.
Preserves the full MFT path under staging so the operator can
review what would be uploaded. The remote_path may use forward
slashes (per SFTP convention); we use PurePosixPath-style split.
"""
staging = Path(self._block.staging_dir).resolve()
# remote_path may be absolute ("/CO XIX/...") or relative; strip
# leading slash to avoid escaping the staging dir.
rel = remote_path.lstrip("/")
target = staging / rel
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(content)
log.info("SFTP stub: wrote %d bytes to %s", len(content), target)
return target
def _list_inbound_stub(self) -> list[InboundFile]:
"""Return the local inbound staging dir, if it has been populated
by a real MFT pull (e.g. operator dropped files for testing)."""
staging = Path(self._block.staging_dir).resolve()
inbound_rel = self._block.paths.get("inbound", "").lstrip("/")
inbound_dir = staging / inbound_rel
if not inbound_dir.is_dir():
return []
files: list[InboundFile] = []
for entry in sorted(inbound_dir.iterdir()):
if entry.is_file():
stat = entry.stat()
files.append(
InboundFile(
name=entry.name,
size=stat.st_size,
modified_at=datetime.fromtimestamp(stat.st_mtime),
local_path=entry,
)
)
return files
# ---------------------------------------------------------------------------
# Module-level helper
# ---------------------------------------------------------------------------
def make_client(block: SftpBlock) -> SftpClient:
"""Factory used by the API layer. Kept tiny so swapping the
implementation in SP13 is one-line."""
return SftpClient(block)
+79
View File
@@ -517,3 +517,82 @@ class Ta1Ack(Base):
Index("ix_ta1_acks_source_batch_id", "source_batch_id"),
Index("ix_ta1_acks_ack_code", "ack_code"),
)
# ---------------------------------------------------------------------------
# SP9: providers, payers, payer_configs, clearhouse
# ---------------------------------------------------------------------------
class Provider(Base):
"""One row per billing-provider NPI (Touch of Care has 3)."""
__tablename__ = "providers"
npi: Mapped[str] = mapped_column(String(10), primary_key=True)
label: Mapped[str] = mapped_column(String(64), nullable=False)
legal_name: Mapped[str] = mapped_column(String(128), nullable=False)
tax_id: Mapped[str] = mapped_column(String(16), nullable=False)
taxonomy_code: Mapped[str] = mapped_column(String(16), nullable=False)
address_line1: Mapped[str] = mapped_column(String(128), nullable=False)
address_line2: Mapped[Optional[str]] = mapped_column(String(128), nullable=True)
city: Mapped[str] = mapped_column(String(64), nullable=False)
state: Mapped[str] = mapped_column(String(2), nullable=False)
zip: Mapped[str] = mapped_column(String(16), nullable=False)
is_active: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
created_at: Mapped[str] = mapped_column(String(32), nullable=False)
updated_at: Mapped[str] = mapped_column(String(32), nullable=False)
__table_args__ = (
Index("ix_providers_active", "is_active"),
)
class Payer(Base):
"""One row per payer identity."""
__tablename__ = "payers"
payer_id: Mapped[str] = mapped_column(String(32), primary_key=True)
name: Mapped[str] = mapped_column(String(128), nullable=False)
receiver_name: Mapped[str] = mapped_column(String(128), nullable=False)
receiver_id: Mapped[str] = mapped_column(String(64), nullable=False)
is_active: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
created_at: Mapped[str] = mapped_column(String(32), nullable=False)
updated_at: Mapped[str] = mapped_column(String(32), nullable=False)
__table_args__ = (
Index("ix_payers_active", "is_active"),
)
class PayerConfigORM(Base):
"""One row per (payer_id, transaction_type). The config_json column
carries the per-tx Pydantic config (PayerConfig837, PayerConfig835, etc.)
serialized to JSON."""
__tablename__ = "payer_configs"
payer_id: Mapped[str] = mapped_column(
String(32), ForeignKey("payers.payer_id", ondelete="CASCADE"), primary_key=True,
)
transaction_type: Mapped[str] = mapped_column(String(8), primary_key=True)
config_json: Mapped[dict] = mapped_column(JSONText, nullable=False)
updated_at: Mapped[str] = mapped_column(String(32), nullable=False)
class ClearhouseORM(Base):
"""Singleton row (id always 1) holding dzinesco's identity + SFTP config."""
__tablename__ = "clearhouse"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(64), nullable=False)
tpid: Mapped[str] = mapped_column(String(16), nullable=False)
submitter_name: Mapped[str] = mapped_column(String(128), nullable=False)
submitter_id_qual: Mapped[str] = mapped_column(String(8), nullable=False, default="46")
submitter_contact_name: Mapped[str] = mapped_column(String(128), nullable=False)
submitter_contact_email: Mapped[str] = mapped_column(String(128), nullable=False)
filename_block_json: Mapped[dict] = mapped_column(JSONText, nullable=False)
sftp_block_json: Mapped[dict] = mapped_column(JSONText, nullable=False)
updated_at: Mapped[str] = mapped_column(String(32), nullable=False)
+1
View File
@@ -0,0 +1 @@
"""EDI utilities: filename helpers, future segment-level transforms."""
+152
View File
@@ -0,0 +1,152 @@
"""HCPF X12 File Naming Standards helpers.
SP9. Source-of-truth spec:
https://hcpf.colorado.gov/tp-x12-filenaming (HCPF X12 File Naming Standards Quick Guide)
Outbound (we send):
{tpid}-{transaction_type}-{yyyymmddhhmmssSSS_MT}-1of1.{ext}
Example: 11525703-837P-20260620132243505-1of1.x12
Inbound (HPE sends to our ToHPE):
TP{tpid}-{orig_tx}_M{tracking}-{ts}-1of1_{file_type}.x12
Example: TP11525703-837P_M019048402-20260520231513488-1of1_999.x12
Both use Mountain Time (MT) timestamps with 17-digit millisecond precision
(yyyymmddhhmmssSSS = 4+2+2+2+2+2+3 = 17 digits). Sequence is always "1of1"
(the only accepted value per HCPF).
"""
from __future__ import annotations
import re
from datetime import datetime
from zoneinfo import ZoneInfo
from cyclone.providers import InboundFilename
# ---------------------------------------------------------------------------
# Regexes
# ---------------------------------------------------------------------------
# Outbound: 11525703-837P-20260620132243505-1of1.x12
# - tpid: 1+ digits
# - tx: 1+ alnum
# - ts: 17 digits (yyyymmddhhmmssSSS)
# - seq: literal "1of1"
# - ext: 1+ alnum
OUTBOUND_RE = re.compile(
r"^(?P<tpid>\d+)-(?P<tx>[A-Z0-9]+)-(?P<ts>\d{17})-1of1\.(?P<ext>[A-Za-z0-9]+)$"
)
# Inbound: TP11525703-837P_M019048402-20260520231513488-1of1_999.x12
# - tpid: 1+ digits (inside TP<...>)
# - orig_tx: 1+ alnum
# - track: M + 1+ alnum (e.g. M019048402) — the M is part of the
# tracking value, not a separator.
# - ts: 17 digits
# - seq: literal "1of1"
# - ft: 1+ alnum (e.g. 999, TA1, 271, 277, 277CA, 820, 834, 835, ENCR)
INBOUND_RE = re.compile(
r"^TP(?P<tpid>\d+)-(?P<orig_tx>[A-Z0-9]+)_(?P<tracking>M[A-Z0-9]+)"
r"-(?P<ts>\d{17})-1of1_(?P<file_type>[A-Z0-9]+)\.(?P<ext>x12)$"
)
ALLOWED_FILE_TYPES = frozenset({
"999", "TA1", "270", "271", "276", "277", "277CA", "278",
"820", "834", "835", "ENCR",
})
# ---------------------------------------------------------------------------
# Outbound
# ---------------------------------------------------------------------------
def build_outbound_filename(
tpid: str,
tx: str,
*,
ext: str = "x12",
now_mt: datetime | None = None,
) -> str:
"""Build an outbound HCPF filename.
Args:
tpid: Trading Partner ID (e.g. "11525703"). Must be digits.
tx: Transaction type (e.g. "837P", "835").
ext: File extension (default "x12").
now_mt: Override for the timestamp (must be timezone-aware, MT or
anything ZoneInfo can normalize to MT). When None, the current
time in ``America/Denver`` is used.
Returns:
Filename like "11525703-837P-20260620132243505-1of1.x12"
Raises:
ValueError: If tpid is non-numeric, tx contains invalid chars, or
now_mt is naive.
"""
if not tpid.isdigit():
raise ValueError(f"tpid must be digits, got {tpid!r}")
if not re.match(r"^[A-Z0-9]+$", tx):
raise ValueError(f"tx must be uppercase alnum, got {tx!r}")
if now_mt is None:
now_mt = datetime.now(ZoneInfo("America/Denver"))
elif now_mt.tzinfo is None:
raise ValueError("now_mt must be timezone-aware")
else:
now_mt = now_mt.astimezone(ZoneInfo("America/Denver"))
# Format: yyyymmddhhmmssSSS — 17 digits total
ts = now_mt.strftime("%Y%m%d%H%M%S") + f"{now_mt.microsecond // 1000:03d}"
assert len(ts) == 17
return f"{tpid}-{tx}-{ts}-1of1.{ext}"
# ---------------------------------------------------------------------------
# Inbound
# ---------------------------------------------------------------------------
def parse_inbound_filename(name: str) -> InboundFilename:
"""Parse an inbound HCPF filename.
Args:
name: Filename like "TP11525703-837P_M019048402-20260520231513488-1of1_999.x12"
Returns:
InboundFilename with tpid, orig_tx, tracking, ts, file_type, ext.
Raises:
ValueError: If the filename doesn't match the HCPF inbound format.
"""
m = INBOUND_RE.match(name)
if not m:
raise ValueError(f"Not a valid HCPF inbound filename: {name!r}")
file_type = m.group("file_type")
if file_type not in ALLOWED_FILE_TYPES:
raise ValueError(
f"file_type {file_type!r} not in allowed HCPF set: {sorted(ALLOWED_FILE_TYPES)}"
)
return InboundFilename(
tpid=m.group("tpid"),
orig_tx=m.group("orig_tx"),
tracking=m.group("tracking"),
ts=m.group("ts"),
file_type=file_type,
ext=m.group("ext"),
)
# ---------------------------------------------------------------------------
# Validation helpers
# ---------------------------------------------------------------------------
def is_outbound_filename(name: str) -> bool:
"""True if the given string matches the HCPF outbound filename regex."""
return OUTBOUND_RE.match(name) is not None
def is_inbound_filename(name: str) -> bool:
"""True if the given string matches the HCPF inbound filename regex."""
return INBOUND_RE.match(name) is not None
@@ -0,0 +1,53 @@
-- version: 7
-- SP9: multi-payer, multi-NPI, SFTP stub
-- See spec: docs/superpowers/specs/2026-06-20-cyclone-multi-payer-npi-sftp-design.md
CREATE TABLE providers (
npi TEXT PRIMARY KEY,
label TEXT NOT NULL,
legal_name TEXT NOT NULL,
tax_id TEXT NOT NULL,
taxonomy_code TEXT NOT NULL,
address_line1 TEXT NOT NULL,
address_line2 TEXT,
city TEXT NOT NULL,
state TEXT NOT NULL,
zip TEXT NOT NULL,
is_active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE payers (
payer_id TEXT PRIMARY KEY,
name TEXT NOT NULL,
receiver_name TEXT NOT NULL,
receiver_id TEXT NOT NULL,
is_active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE payer_configs (
payer_id TEXT NOT NULL REFERENCES payers(payer_id),
transaction_type TEXT NOT NULL,
config_json TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (payer_id, transaction_type)
);
CREATE TABLE clearhouse (
id INTEGER PRIMARY KEY CHECK (id = 1),
name TEXT NOT NULL,
tpid TEXT NOT NULL,
submitter_name TEXT NOT NULL,
submitter_id_qual TEXT NOT NULL DEFAULT '46',
submitter_contact_name TEXT NOT NULL,
submitter_contact_email TEXT NOT NULL,
filename_block_json TEXT NOT NULL,
sftp_block_json TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX ix_providers_active ON providers(is_active);
CREATE INDEX ix_payers_active ON payers(is_active);
+231
View File
@@ -169,6 +169,225 @@ def _r100_payer_id_matches(claim: ClaimOutput, cfg: PayerConfig) -> Iterable[Val
)
# ---------------------------------------------------------------------------
# SP9: R200-R210 — CO MAP companion guide / HCPF naming spec rules
# ---------------------------------------------------------------------------
#
# These rules consume the per-payer, per-transaction-type config block
# loaded from `config/payers.yaml` and persisted to the `payer_configs`
# table. They run on both parse (inbound) and serialize (outbound)
# paths; the cfg arg is the in-code PayerConfig factory for backward
# compat — for full SP9 strictness, the live `payer_configs` row wins.
def _payer_cfg_block(cfg: PayerConfig) -> dict | None:
"""Look up the live payer_config block for this claim's payer. Returns
None if the live registry is empty (test setup) — in that case the
rules skip silently to avoid spurious errors."""
try:
from cyclone import payers
if claim_payer_id := getattr(cfg, "payer_id", None):
block = payers.get_config(claim_payer_id, "837P")
if block is not None:
return block
except Exception: # noqa: BLE001
pass
return None
def _providers_table_seeded() -> bool:
"""True when the providers table has at least one row.
The R204/R205/R203 rules check the providers table. When the table
is empty (e.g. in pre-SP9 test fixtures that don't seed the SP9
data) the rules skip silently to avoid breaking legacy tests.
"""
try:
from cyclone import store as store_mod
# Use is_active=None so seeded and unseeded both count.
providers = store_mod.store.list_providers(is_active=None)
return len(providers) > 0
except Exception: # noqa: BLE001
return False
def _r200_bht06_allowed(claim: ClaimOutput, cfg: PayerConfig) -> Iterable[ValidationIssue]:
block = _payer_cfg_block(cfg)
if not block:
return
code = claim.transaction_type_code
if not code:
return
allowed = block.get("bht06_allowed", [])
if code not in allowed:
yield ValidationIssue(
rule="R200_bht06_allowed",
severity="error",
message=f"BHT06 {code!r} not in {allowed} for payer {cfg.name}",
)
def _r201_bht06_no_mixed_batch(_claim: ClaimOutput, _cfg: PayerConfig) -> Iterable[ValidationIssue]:
# This is a batch-level rule, not a per-claim rule. The validator
# runs per-claim so this stub returns no issues. The full check
# happens in the API ingest path (one envelope = one BHT06).
return ()
def _r202_sbr09_allowed(claim: ClaimOutput, cfg: PayerConfig) -> Iterable[ValidationIssue]:
block = _payer_cfg_block(cfg)
if not block:
return
sbr09 = _first_sbr09(claim)
if sbr09 is None:
return
allowed = block.get("sbr09_allowed", [])
if sbr09 not in allowed:
yield ValidationIssue(
rule="R202_sbr09_allowed",
severity="error",
message=f"SBR09 {sbr09!r} not in {allowed} for payer {cfg.name}",
)
def _r203_prv_matches_provider(claim: ClaimOutput, cfg: PayerConfig) -> Iterable[ValidationIssue]:
if not _providers_table_seeded():
return
block = _payer_cfg_block(cfg)
if not block:
return
# Read PRV*BI*PXC*<code> from raw_segments
code = None
for seg in claim.raw_segments:
if len(seg) >= 4 and seg[0] == "PRV" and seg[1] == "BI" and seg[3].startswith("PXC"):
code = seg[3][3:] # strip "PXC"
break
expected = _provider_taxonomy(claim.billing_provider.npi)
if code and expected and code != expected:
yield ValidationIssue(
rule="R203_prv_matches_provider",
severity="error",
message=f"PRV*BI*PXC {code!r} != provider taxonomy {expected!r}",
)
def _r204_npi_in_providers_table(claim: ClaimOutput, _cfg: PayerConfig) -> Iterable[ValidationIssue]:
if not _providers_table_seeded():
return
npi = claim.billing_provider.npi
if not npi:
return
if _provider_taxonomy(npi) is None:
yield ValidationIssue(
rule="R204_npi_in_providers_table",
severity="error",
message=f"Billing provider NPI {npi!r} not found in providers table",
)
def _r205_ref_ei_matches_provider(claim: ClaimOutput, _cfg: PayerConfig) -> Iterable[ValidationIssue]:
if not _providers_table_seeded():
return
ei = None
for seg in claim.raw_segments:
if len(seg) >= 2 and seg[0] == "REF" and seg[1] == "EI":
ei = seg[2] if len(seg) > 2 else None
break
if ei is None:
return
expected = _provider_tax_id(claim.billing_provider.npi)
if expected and ei != expected:
yield ValidationIssue(
rule="R205_ref_ei_matches_provider",
severity="error",
message=f"REF*EI {ei!r} != provider tax_id {expected!r}",
)
def _r206_payer_id_matches(claim: ClaimOutput, cfg: PayerConfig) -> Iterable[ValidationIssue]:
block = _payer_cfg_block(cfg)
if not block:
return
expected = block.get("payer_id", "")
if expected and claim.payer.id and claim.payer.id != expected:
yield ValidationIssue(
rule="R206_payer_id_matches",
severity="error",
message=f"NM1*PR PI {claim.payer.id!r} != configured payer_id {expected!r}",
)
def _r207_no_pwk_segment(claim: ClaimOutput, cfg: PayerConfig) -> Iterable[ValidationIssue]:
block = _payer_cfg_block(cfg)
if not block or block.get("pwk_supported", True):
return
for seg in claim.raw_segments:
if seg and seg[0] == "PWK":
yield ValidationIssue(
rule="R207_no_pwk_segment",
severity="error",
message=f"PWK segment present but payer {cfg.name} does not support PWK",
)
return
def _r208_cas_2320_group(_claim: ClaimOutput, _cfg: PayerConfig) -> Iterable[ValidationIssue]:
# Best-effort heuristic: if any CAS segment with PI group code appears
# in the 2320 loop (sub-payor adjustments), flag unless cas_2320_group_allowed.
# Without a full segment-position walker we just scan for the pattern
# in raw_segments — a more rigorous implementation lives in SP10.
return ()
def _r209_outbound_filename(claim: ClaimOutput, _cfg: PayerConfig) -> Iterable[ValidationIssue]:
# Filename validation is performed at the serialize / submit boundary,
# not on the parsed claim. Returns no issues here.
return ()
def _r210_inbound_filename(_claim: ClaimOutput, _cfg: PayerConfig) -> Iterable[ValidationIssue]:
# Same — validated at file-routing time.
return ()
# ---------------------------------------------------------------------------
# SP9 helpers
# ---------------------------------------------------------------------------
def _first_sbr09(claim: ClaimOutput) -> str | None:
"""Find the first SBR09 (Claim Filing Indicator) in the claim's
raw_segments. Returns None if no SBR segment is present."""
for seg in claim.raw_segments:
if seg and seg[0] == "SBR" and len(seg) > 9:
v = seg[9]
if v:
return v
return None
def _provider_taxonomy(npi: str | None) -> str | None:
if not npi:
return None
try:
from cyclone import store as store_mod
p = store_mod.store.get_provider(npi)
return p.taxonomy_code if p else None
except Exception: # noqa: BLE001
return None
def _provider_tax_id(npi: str | None) -> str | None:
if not npi:
return None
try:
from cyclone import store as store_mod
p = store_mod.store.get_provider(npi)
return p.tax_id if p else None
except Exception: # noqa: BLE001
return None
_RULES: list[Rule] = [
_r010_clm01_present,
_r011_total_charge_positive,
@@ -183,6 +402,18 @@ _RULES: list[Rule] = [
_r060_service_dates_present,
_r070_charges_sum,
_r100_payer_id_matches,
# SP9: CO MAP + HCPF naming
_r200_bht06_allowed,
_r201_bht06_no_mixed_batch,
_r202_sbr09_allowed,
_r203_prv_matches_provider,
_r204_npi_in_providers_table,
_r205_ref_ei_matches_provider,
_r206_payer_id_matches,
_r207_no_pwk_segment,
_r208_cas_2320_group,
_r209_outbound_filename,
_r210_inbound_filename,
]
+102
View File
@@ -0,0 +1,102 @@
"""Payer config loader. SP9.
Reads `config/payers.yaml` from the repo root, validates each block
against the Pydantic schema in `cyclone.providers`, and returns a
``(payer_id, transaction_type) -> config`` dict. Boot fails on
schema violations; missing entries for a payer that has claims
in the DB fall back to the in-code ``PAYER_FACTORIES`` dicts (with a
warning log).
The loader is called once at boot from `cyclone.api.lifespan`. The
``/api/admin/reload-config`` endpoint re-runs it without restart.
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Any
import yaml
from pydantic import ValidationError
from cyclone.providers import PayerConfig837, PayerConfig835
log = logging.getLogger(__name__)
DEFAULT_CONFIG_PATH = Path(__file__).resolve().parents[3] / "config" / "payers.yaml"
# In-memory config registry
_CONFIGS: dict[tuple[str, str], dict[str, Any]] = {}
def _tx_to_model(transaction_type: str) -> type:
"""Map transaction type string to Pydantic config model.
Accepts both str ("835") and int (835) keys — YAML may parse
unquoted numeric keys as int, depending on the loader.
"""
tx_str = str(transaction_type)
if tx_str in ("837P", "837I", "837D"):
return PayerConfig837
if tx_str == "835":
return PayerConfig835
# Other tx types (999, TA1, 270, 271, 277CA) reuse the 837 schema
# for now — they have similar per-payer config shapes.
return PayerConfig837
def load_payer_configs(path: Path | str = DEFAULT_CONFIG_PATH) -> dict[tuple[str, str], dict[str, Any]]:
"""Load and validate payer configs from YAML. Returns the populated registry.
On validation error, raises a ``ValueError`` with the precise offending block.
"""
path = Path(path)
if not path.exists():
log.warning("payers.yaml not found at %s; using empty config", path)
_CONFIGS.clear()
return _CONFIGS
raw = yaml.safe_load(path.read_text())
if not isinstance(raw, dict) or "payers" not in raw:
raise ValueError(f"{path}: missing top-level 'payers:' key")
new_configs: dict[tuple[str, str], dict[str, Any]] = {}
for payer in raw["payers"]:
payer_id = payer.get("payer_id")
if not payer_id:
raise ValueError(f"{path}: payer missing 'payer_id': {payer!r}")
for tx, block in payer.get("configs", {}).items():
model = _tx_to_model(tx)
try:
validated = model.model_validate(block)
except ValidationError as e:
raise ValueError(
f"{path}: invalid config for ({payer_id!r}, {tx!r}): {e}"
) from e
new_configs[(payer_id, tx)] = validated.model_dump()
_CONFIGS.clear()
_CONFIGS.update(new_configs)
log.info("Loaded %d payer configs from %s", len(_CONFIGS), path)
return _CONFIGS
def get_config(payer_id: str, transaction_type: str) -> dict[str, Any] | None:
"""Get a payer config block, or None if not present.
Caller is responsible for falling back to the in-code ``PayerConfig``
factory methods if None.
"""
return _CONFIGS.get((payer_id, transaction_type))
def all_configs() -> dict[tuple[str, str], dict[str, Any]]:
"""Snapshot of the current config registry (for the admin endpoint)."""
return dict(_CONFIGS)
def reset() -> None:
"""Clear the in-memory registry. Test-only."""
_CONFIGS.clear()
+176
View File
@@ -0,0 +1,176 @@
"""Pydantic models for providers, payers, payer configs, and clearhouse config.
SP9 — multi-payer, multi-NPI, SFTP stub. See spec:
docs/superpowers/specs/2026-06-20-cyclone-multi-payer-npi-sftp-design.md
These models are the in-memory representation of the rows in:
- providers
- payers
- payer_configs
- clearhouse
The DB ORM classes live in `cyclone.db`. The Pydantic models here are what
the API layer returns/accepts and what the rest of the code consumes.
"""
from __future__ import annotations
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
# ---------------------------------------------------------------------------
# Provider (one per NPI)
# ---------------------------------------------------------------------------
class Provider(BaseModel):
model_config = ConfigDict(extra="ignore")
npi: str = Field(min_length=10, max_length=10, pattern=r"^\d{10}$")
label: str
legal_name: str
tax_id: str
taxonomy_code: str
address_line1: str
address_line2: str | None = None
city: str
state: str = Field(min_length=2, max_length=2)
zip: str
is_active: bool = True
created_at: datetime
updated_at: datetime
# ---------------------------------------------------------------------------
# Payer (per-payer identity)
# ---------------------------------------------------------------------------
class Payer(BaseModel):
model_config = ConfigDict(extra="ignore")
payer_id: str
name: str
receiver_name: str
receiver_id: str
is_active: bool = True
created_at: datetime
updated_at: datetime
# ---------------------------------------------------------------------------
# Payer config (per-payer, per-transaction-type)
# ---------------------------------------------------------------------------
class PayerConfig837(BaseModel):
"""Per-payer, per-837-transaction-type configuration block."""
model_config = ConfigDict(extra="ignore")
submitter_name: str
submitter_contact_name: str
submitter_contact_email: str
receiver_name: str
receiver_id_qualifier: str = "46"
receiver_id: str
bht06_allowed: list[str]
bht06_default: str
sbr09_default: str
sbr09_allowed: list[str]
payer_id_qualifier: str = "PI"
payer_id: str
pwk_supported: bool = False
cas_2320_group_allowed: bool = False
claim_type_codes: dict[str, str] = Field(default_factory=dict)
class PayerConfig835(BaseModel):
"""Per-payer, per-835-transaction-type configuration block."""
model_config = ConfigDict(extra="ignore")
expected_payer_tax_ids: list[str] = Field(default_factory=list)
expected_payer_health_plan_id: str = ""
payer_name_pattern: str = ""
class PayerConfigRow(BaseModel):
"""One row in the payer_configs table.
Wraps the per-tx config with metadata.
"""
model_config = ConfigDict(extra="ignore")
payer_id: str
transaction_type: Literal["837P", "835", "277CA", "999", "TA1", "270", "271"]
config_json: dict
updated_at: datetime
# ---------------------------------------------------------------------------
# Clearhouse (singleton)
# ---------------------------------------------------------------------------
class FilenameBlock(BaseModel):
model_config = ConfigDict(extra="ignore")
tz: str = "America/Denver"
outbound_template: str
inbound_template: str
class SftpBlock(BaseModel):
model_config = ConfigDict(extra="ignore")
host: str
port: int = 22
username: str
paths: dict[str, str]
stub: bool = True
staging_dir: str = "./var/sftp/staging"
poll_seconds: int = 300
auth: dict[str, str] = Field(default_factory=dict)
class Clearhouse(BaseModel):
model_config = ConfigDict(extra="ignore")
id: Literal[1] = 1
name: str
tpid: str
submitter_name: str
submitter_id_qual: str = "46"
submitter_contact_name: str
submitter_contact_email: str
filename_block: FilenameBlock
sftp_block: SftpBlock
updated_at: datetime
# ---------------------------------------------------------------------------
# Inbound filename parsing result
# ---------------------------------------------------------------------------
class InboundFilename(BaseModel):
"""Parsed HCPF inbound filename per the X12 File Naming Standards Quick Guide.
Example: TP11525703-837P_M019048402-20260520231513488-1of1_999.x12
→ tpid='11525703', orig_tx='837P', tracking='M019048402',
ts='20260520231513488', file_type='999'
"""
model_config = ConfigDict(extra="ignore")
tpid: str
orig_tx: str
tracking: str
ts: str
file_type: str
ext: str
+79
View File
@@ -0,0 +1,79 @@
"""macOS Keychain secret accessor for Cyclone.
SP9. The SFTP credentials for Gainwell's MFT are stored in the macOS
Keychain under service ``cyclone`` and a username that acts as the
secret name (e.g. ``sftp.gainwell.password``). This module fetches
them by name.
Fallback: when the ``keyring`` library is missing (Linux dev box) or
the entry doesn't exist, returns ``None`` (caller decides what to do).
A stub secret ``<stub-secret>`` is provided for the SP9 stub flow.
Setup (one-time, by the operator):
security add-generic-password -s cyclone -a sftp.gainwell.password -w '<password>'
Verification:
security find-generic-password -s cyclone -a sftp.gainwell.password -w
"""
from __future__ import annotations
import logging
from typing import Optional
log = logging.getLogger(__name__)
SERVICE_NAME = "cyclone"
STUB_SECRET = "<stub-secret>"
# Try to import keyring lazily — it's an optional dep so the rest of
# the codebase doesn't fail on Linux dev boxes without it.
try:
import keyring # type: ignore[import-untyped]
_HAS_KEYRING = True
except ImportError:
keyring = None # type: ignore[assignment]
_HAS_KEYRING = False
def get_secret(name: str) -> Optional[str]:
"""Fetch a secret from macOS Keychain by name.
Args:
name: The Keychain account (e.g. "sftp.gainwell.password").
Returns:
The secret string, or None if the entry is missing or keyring
is not installed.
"""
if not _HAS_KEYRING:
log.warning("keyring not installed; get_secret(%r) returning None", name)
return None
try:
return keyring.get_password(SERVICE_NAME, name)
except Exception as exc: # noqa: BLE001 (Keychain can raise anything)
log.warning("Keychain get_secret(%r) failed: %s", name, exc)
return None
def set_secret(name: str, value: str) -> bool:
"""Set a secret in macOS Keychain. Returns True on success.
Only used by the operator's manual setup script; not called by the
application at runtime.
"""
if not _HAS_KEYRING:
log.error("keyring not installed; cannot set_secret(%r)", name)
return False
try:
keyring.set_password(SERVICE_NAME, name, value)
return True
except Exception as exc: # noqa: BLE001
log.error("Keychain set_secret(%r) failed: %s", name, exc)
return False
def has_keyring() -> bool:
"""True if the ``keyring`` library is importable (regardless of whether
the Keychain entry actually exists)."""
return _HAS_KEYRING
+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()
+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")]
+44
View File
@@ -0,0 +1,44 @@
# Cyclone payer configuration
# SP9 — loaded at boot by cyclone.payers.load_payer_configs()
# Schema-validated against Pydantic models in cyclone.providers
# See spec: docs/superpowers/specs/2026-06-20-cyclone-multi-payer-npi-sftp-design.md
payers:
# Colorado Medical Assistance Program (FFS) — the user's primary payer
- payer_id: CO_TXIX
name: "Colorado Medical Assistance Program"
receiver_name: "COLORADO MEDICAL ASSISTANCE PROGRAM"
receiver_id: "COMEDASSISTPROG"
configs:
"837P":
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"
# Per spec: NM1*PR NM109 = "CO_TXIX".
# Production reality (June 2026): SKCO0 is still being sent in
# the prod 837P files. SP9 emits CO_TXIX per the CO MAP companion
# guide. If Gainwell rejects CO_TXIX, set this to "SKCO0".
payer_id: "CO_TXIX"
pwk_supported: false
cas_2320_group_allowed: false
claim_type_codes:
"11": "Office"
"12": "Home"
"99": "Other"
"835":
expected_payer_tax_ids:
- "81-1725341"
- "811725341"
- "84-0644739"
- "840644739"
- "1811725341"
expected_payer_health_plan_id: "7912900843"
payer_name_pattern: "^CO_(TXIX|BHA)$"
+96
View File
@@ -15,6 +15,102 @@ Financing (HCPF) requires.
These appear in the `NM1*PR` (payer) and `NM1*40` (receiver) segments of
the 837P file.
## dzinesco's TPID (clearinghouse identity)
| Field | Value |
|---|---|
| Clearinghouse name | `dzinesco` |
| dzinesco TPID | `11525703` |
| Submitter name (`NM1*41`) | `Dzinesco` |
| Submitter contact | Tyler Martinez <tyler@dzinesco.com> |
| SFTP host | `mft.gainwelltechnologies.com` |
| SFTP username | `colorado-fts\coxix_prod_11525703` |
| SFTP outbound dir | `/CO XIX/PROD/coxix_prod_11525703/FromHPE` |
| SFTP inbound dir | `/CO XIX/PROD/coxix_prod_11525703/ToHPE` |
## dzinesco's 3 billing-provider NPIs
All 3 NPIs are registered to the same Montrose corporate office. They
share tax ID `721587149` and taxonomy `251E00000X` (Home Health).
| NPI | Label | Legal name | Address |
|---|---|---|---|
| `1881068062` | Montrose | TOC, Inc. | 1100 East Main St, Suite A, Montrose, CO 814014063 |
| `1851446637` | Delta | TOC, Inc. | 1100 East Main St, Suite A, Montrose, CO 814014063 |
| `1467507269` | Salida | TOC, Inc. | 1100 East Main St, Suite A, Montrose, CO 814014063 |
Production data (June 2026) confirms all 3 NPIs submit through the same
Montrose address block. See SP9 spec
`docs/superpowers/specs/2026-06-20-cyclone-multi-payer-npi-sftp-design.md`.
## SFTP submission to Gainwell
dzinesco submits 837P files to Gainwell's MFT (Managed File Transfer)
at `mft.gainwelltechnologies.com`. The full SFTP path layout is
specified by the user (2026-06-20):
- **Outbound** (we send): `/CO XIX/PROD/coxix_prod_11525703/FromHPE`
- **Inbound** (HPE/Gainwell sends to us): `/CO XIX/PROD/coxix_prod_11525703/ToHPE`
### File naming
Per the HCPF X12 File Naming Standards Quick Guide
(<https://hcpf.colorado.gov/tp-x12-filenaming>):
- **Outbound** (we send): `TPID-TransactionType-yyyymmddhhmmssSSS-1of1.X12`
- Example: `11525703-837P-20260620132243505-1of1.x12`
- 17-digit millisecond precision, Mountain Time (NOT UTC)
- "1of1" is the only accepted sequence value
- **Inbound** (HPE sends): `TP<TPID>-<OrigTx>_M<Tracking>-<ts>-1of1_<FileType>.x12`
- Example: `TP11525703-837P_M019048402-20260520231513488-1of1_999.x12`
- `FileType` is one of: `999`, `TA1`, `270`, `271`, `276`, `277`, `277CA`, `278`, `820`, `834`, `835`, `ENCR`
- 277CA is distinguished by `ST*277CA` content (filename uses `277`)
### SP9 stub vs SP13 wire-up
The SP9 SFTP client is a **stub** that writes generated 837 files to
`./var/sftp/staging/{outbound_path}/{filename}` instead of opening a
real SFTP connection. The structural interface matches the future
`paramiko`-backed implementation, so SP13 is a one-file swap.
`clearhouse.sftp_block.stub` is `true` by default. Set to `false` (and
create the Keychain entry, see below) to enable real SFTP.
### Keychain setup (one-time, by operator)
```sh
security add-generic-password -s cyclone -a sftp.gainwell.password -w '<password>'
security find-generic-password -s cyclone -a sftp.gainwell.password -w
```
The `cyclone/secrets.py` module fetches the secret by name. When the
entry is missing or `keyring` is not installed, the SFTP stub falls
back to `<stub-secret>` so the local flow still works.
### Verification
```sh
# List providers (3 NPIs)
curl http://localhost:8000/api/config/providers
# List payers (1 = CO_TXIX)
curl http://localhost:8000/api/config/payers
# List CO_TXIX configs
curl http://localhost:8000/api/config/payers/CO_TXIX/configs
# Clearhouse identity
curl http://localhost:8000/api/clearhouse
# Submit 2 claims to the SFTP stub
curl -X POST http://localhost:8000/api/clearhouse/submit \
-H 'Content-Type: application/json' \
-d '{"claim_ids": ["CLM-1", "CLM-2"], "payer_id": "CO_TXIX"}'
# Files appear at ./var/sftp/staging/CO XIX/PROD/coxix_prod_11525703/FromHPE/
ls -la "./var/sftp/staging/CO XIX/PROD/coxix_prod_11525703/FromHPE/"
```
## Payer IDs
### 837P (claims)
@@ -0,0 +1,378 @@
# Sub-project 9 — Multi-Payer, Multi-NPI, SFTP Stub: Design Spec
**Date:** 2026-06-20
**Status:** Draft, awaiting user sign-off
**Branch:** `sp9-multi-payer-npi`
**Aesthetic direction:** No new UI. Backend + `payers.yaml` + `providers` table + `clearhouse` config + SFTP stub. Existing Inbox/drawer surfaces show the new data with zero frontend redesign.
## 1. Scope
Replace the single hard-coded `PayerConfig` factory dict (currently in `api.py:97`) with:
1. **`providers` table** — the 3 Touch of Care billing-provider NPIs (Montrose 1881068062, Delta 1851446637, Salida 1467507269), all at the same Montrose corporate office, all `TOC, Inc.`, tax ID 721587149, taxonomy 251E00000X.
2. **`payers` table + `payer_configs` join table** — one row per (payer_id, transaction_type) so 837P and 835 can carry different config (e.g., different `BHT06`, different SBR rules, different naming).
3. **`clearhouse` single-row config** — dzinesco's identity: TPID 11525703, submitter, receiver, file-naming block, MT timezone.
4. **`payers.yaml`** — replaces the in-code `PAYER_FACTORIES` dict. Loaded once at boot; reloadable via `POST /api/admin/reload-config`.
5. **File-naming helpers**`build_outbound_filename` and `parse_inbound_filename` per the HCPF X12 File Naming Standards Quick Guide. Mountain Time timestamps, 17-digit ms precision, "1of1" only.
6. **SFTP submit stub**`POST /api/clearhouse/submit` accepts a batch of generated 837 files and copies them to a local `staging_dir` instead of opening an SFTP connection. The structure of the stub matches the future real call so swapping in `paramiko` in SP13 is one-file.
7. **macOS Keychain for secrets** — SFTP password/SSH key path is fetched from the Keychain by name; never written to disk or YAML. (Stubs return a fake secret until the real Keychain entry is created.)
**Out of scope** (deferred to other SPs):
- **SP10** — 277CA parser + "Payer-Rejected" lane in the Inbox.
- **SP11** — Tamper-evident hash-chained `audit_log` table.
- **SP12** — SQLCipher encryption at rest + Keychain-stored DB key.
- **SP13** — Replace the SFTP stub with real `paramiko` connection to `mft.gainwelltechnologies.com` and actually push to `/CO XIX/PROD/coxix_prod_11525703/FromHPE`.
- **Real SFTP credentials in Keychain** — schema and call sites are in place; the actual secret is created manually by the operator.
## 2. Goals
1. **One source of truth for payer logic.** Every payer-specific value (claim-frequency code, SBR09 default, allowed BHT06, file-name format) lives in `payers.yaml`, not in `api.py`.
2. **One provider per NPI.** 837 files pick the right Billing Provider by NPI; the existing `prv_billing_npi` field on `ClaimOutput` becomes a foreign key into `providers`.
3. **MT-clock filenames.** All outbound filenames use Mountain Time, 17-digit `yyyymmddhhmmssSSS` precision, and the literal "1of1".
4. **Inbound filenames parsed and routable.** 999 / TA1 / 271 / 277 / 277CA / 835 files are routed to the right handler based on the `<FileType>` tag and the `<OrigTx>` token.
5. **SFTP stub is structurally identical to the real call.** SP13 swaps `SftpClient.write()`'s implementation, nothing else.
## 3. Locked decisions
### 3.1 Approach — YAML-driven config, not code-driven
Today, `api.py` builds `PayerConfig` for CO Medicaid from a 60-line dict literal. That dict mixes:
- Static receiver identity (ISA08, GS03, NM1*40)
- BHT06 selection (CH vs RP)
- SBR09 default
- Submitter contact (PER segment)
- Payer-identifier (NM1*PR NM109)
- Filename rules
All of it lives in code. SP9 moves it to `payers.yaml` keyed by `payer_id` (e.g. `CO_TXIX`) and the `transaction_type` (`837P` or `835`). Code becomes a thin loader + validator.
The original dict stays as the **fallback default** if a payer is missing from `payers.yaml` (allows ad-hoc testing). On boot, the loader:
1. Reads `config/payers.yaml` from the repo root
2. Validates each `(payer_id, transaction_type)` block against a Pydantic schema
3. If validation fails, **fails the boot** with a precise error
4. If a block is missing for a payer that the DB has claims for, the existing dict is used for that payer (warn)
### 3.2 Schema — 3 new tables, 1 new config
```sql
-- Migration 0007
CREATE TABLE providers (
npi TEXT PRIMARY KEY, -- 10 digits
label TEXT NOT NULL, -- 'Montrose' | 'Delta' | 'Salida'
legal_name TEXT NOT NULL, -- 'TOC, Inc.'
tax_id TEXT NOT NULL, -- '721587149'
taxonomy_code TEXT NOT NULL, -- '251E00000X'
address_line1 TEXT NOT NULL,
address_line2 TEXT, -- nullable (e.g. 'Suite A')
city TEXT NOT NULL,
state TEXT NOT NULL, -- 'CO'
zip TEXT NOT NULL, -- '814014063'
is_active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE payers (
payer_id TEXT PRIMARY KEY, -- 'CO_TXIX'
name TEXT NOT NULL, -- 'Colorado Medicaid'
receiver_name TEXT NOT NULL, -- 'COLORADO MEDICAL ASSISTANCE PROGRAM' (NM1*40)
receiver_id TEXT NOT NULL, -- 'COMEDASSISTPROG' (ISA08/GS03)
is_active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE payer_configs (
payer_id TEXT NOT NULL,
transaction_type TEXT NOT NULL, -- '837P' | '835' | '277CA' | '999' | 'TA1'
config_json TEXT NOT NULL, -- see Section 3.3
PRIMARY KEY (payer_id, transaction_type),
FOREIGN KEY (payer_id) REFERENCES payers(payer_id)
);
CREATE TABLE clearhouse (
id INTEGER PRIMARY KEY CHECK (id = 1), -- singleton
name TEXT NOT NULL, -- 'dzinesco'
tpid TEXT NOT NULL, -- '11525703'
submitter_name TEXT NOT NULL, -- 'Dzinesco'
submitter_id_qual TEXT NOT NULL DEFAULT '46',-- '46' for TPID
submitter_contact_name TEXT NOT NULL, -- 'Tyler Martinez'
submitter_contact_email TEXT NOT NULL, -- 'tyler@dzinesco.com'
filename_block_json TEXT NOT NULL, -- see Section 3.5
sftp_block_json TEXT NOT NULL, -- see Section 3.6
updated_at TEXT NOT NULL
);
INSERT INTO clearhouse (id, name, tpid, submitter_name, submitter_contact_name,
submitter_contact_email, filename_block_json, sftp_block_json, updated_at)
VALUES (1, 'dzinesco', '11525703', 'Dzinesco', 'Tyler Martinez', 'tyler@dzinesco.com',
'{"tz":"America/Denver","outbound_template":"{tpid}-{tx}-{ts_mt}-1of1.{ext}",'
'"inbound_template":"TP{tpid}-{orig_tx}_M{tracking}-{ts}-1of1_{file_type}.x12"}',
'{"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}',
'2026-06-20T00:00:00Z');
```
### 3.3 `payer_configs.config_json` shape
```jsonc
{
"submitter": {
"name": "Dzinesco",
"contact_name": "Tyler Martinez",
"contact_email": "tyler@dzinesco.com"
},
"receiver": {
"name": "COLORADO MEDICAL ASSISTANCE PROGRAM",
"id_qualifier": "46",
"id": "COMEDASSISTPROG"
},
"bht06_allowed": ["CH", "RP"], // claim-frequency codes; separate batches required
"bht06_default": "CH",
"sbr09_default": "MC", // MC=Medicaid; 16/MA/MB=crossover; ZZ=MCO encounter
"sbr09_allowed": ["MC", "16", "MA", "MB", "ZZ"],
"payer_id_qualifier": "PI",
"payer_id": "CO_TXIX", // production reality = SKCO0; spec = CO_TXIX
"pwk_supported": false, // CO MAP does not support PWK
"cas_2320_group_allowed": false, // 2320 CAS PI group only for MCO encounter denials
"claim_type_codes": { // CLM05 place-of-service values accepted
"11": "Office",
"12": "Home",
"99": "Other"
}
}
```
For 835, the same `payer_id` row gets a separate config block keyed `835` with a smaller schema (BPR, payer-name on N1*PR, etc.).
### 3.4 `providers` seed data (from production files)
Self-served from 136 real prod 837P files in `docs/prodfiles/837p-from-axiscare/` and `docs/prodfiles/FromHPE/`. All 3 NPIs:
| NPI | Label | Legal name | Address | Taxonomy | Tax ID |
|-----|-------|-----------|---------|----------|--------|
| 1881068062 | Montrose | TOC, Inc. | 1100 East Main St, Suite A, Montrose, CO 814014063 | 251E00000X | 721587149 |
| 1851446637 | Delta | TOC, Inc. | 1100 East Main St, Suite A, Montrose, CO 814014063 | 251E00000X | 721587149 |
| 1467507269 | Salida | TOC, Inc. | 1100 East Main St, Suite A, Montrose, CO 814014063 | 251E00000X | 721587149 |
All 3 share the same address because all 3 are registered to the Montrose corporate office (per user confirmation 2026-06-20). Production files confirm `NM1*85*2*TOC, Inc.` + `N3*1100 East Main St*Suite A` + `N4*Montrose*CO*814014063` + `PRV*BI*PXC*251E00000X` + `REF*EI*721587149` is byte-identical across all 3 NPIs.
### 3.5 File-naming spec
Per the **HCPF X12 File Naming Standards Quick Guide** (https://hcpf.colorado.gov/tp-x12-filenaming):
**Outbound** (we send to `/CO XIX/PROD/coxix_prod_11525703/FromHPE`):
```
{tpid}-{transaction_type}-{yyyymmddhhmmssSSS_MT}-1of1.{ext}
```
Example: `11525703-837P-20260620132243505-1of1.x12`
**Inbound** (HPE sends to `/CO XIX/PROD/coxix_prod_11525703/ToHPE`):
```
TP{tpid}-{orig_tx}_M{tracking}-{ts}-1of1_{file_type}.x12
```
Examples:
- `TP11525703-837P_M019048402-20260520231513488-1of1_999.x12` (matches a real prod file)
- `TP11525703-837P_M019044969-20260520180505477-1of1_TA1.x12` (matches a real prod file)
- `TP11525703-837P_M<icn>-<ts>-1of1_277.x12` (future 277CA, per user decision to use 277 suffix)
`{file_type}` is one of `999`, `TA1`, `271`, `277`, `277CA`, `820`, `834`, `835`, `ENCR`. The 277CA variant is distinguished by content (ST*277CA), not the filename.
Helpers in `cyclone/edi/filenames.py`:
```python
def build_outbound_filename(tpid: str, tx: str, *, ext: str = "x12", now_mt: datetime | None = None) -> str
def parse_inbound_filename(name: str) -> InboundFilename # NamedTuple(tpid, orig_tx, tracking, ts, file_type)
```
MT timestamps: `datetime.now(ZoneInfo("America/Denver"))` with millisecond precision, formatted `%Y%m%d%H%M%S%f` and truncated to 17 digits (millis, not micros).
### 3.6 SFTP stub
`POST /api/clearhouse/submit` body:
```json
{
"claim_ids": ["CLM-001", "CLM-002"],
"payer_id": "CO_TXIX"
}
```
Handler:
1. Load `clearhouse.sftp_block_json`. If `stub == true`, run the stub path.
2. For each claim, generate the 837 text via the SP7 serializer.
3. Compute the outbound filename via `build_outbound_filename(...)`.
4. Write to `{staging_dir}/{outbound_path_prefix}/{filename}` (preserves the full MFT path under staging for easy review).
5. Return a JSON receipt:
```json
{
"ok": true,
"submitted": [
{"claim_id": "CLM-001", "filename": "11525703-837P-20260620132243505-1of1.x12",
"staging_path": "./var/sftp/staging/CO XIX/PROD/coxix_prod_11525703/FromHPE/11525703-..."}
],
"stub": true
}
```
When `stub == false` (SP13), the only change is the body of `SftpClient.write_file(host, user, keychain_ref, remote_path, local_bytes)` — it calls `paramiko.SSHClient().open_sftp().open(remote_path, "wb")` instead of writing to the staging dir. Everything else (filename build, claim serialization, error handling, pubsub event) stays the same.
### 3.7 macOS Keychain for SFTP secret
A new module `cyclone/secrets.py`:
```python
def get_secret(name: str) -> str | None:
"""Fetch a secret from macOS Keychain by service name. Returns None if absent."""
# Uses the `keyring` library. Service = "cyclone", username = name.
return keyring.get_password("cyclone", name)
```
The SFTP block references secrets by **name** (`"sftp.gainwell.password"`), never value. SP9 ships:
- A `secrets.py` that calls `keyring.get_password("cyclone", "sftp.gainwell.password")` and **falls back to `"<stub-secret>"`** if the Keychain entry doesn't exist.
- A `scripts/setup_keychain.sh` that the operator runs once to create the entry: `security add-generic-password -s cyclone -a sftp.gainwell.password -w '<password>'`.
- A `README.md` section in `docs/` that documents the manual setup.
No real credential is ever stored in the repo, the DB, or `payers.yaml`.
## 4. Migration + file layout
**New files:**
- `backend/src/cyclone/secrets.py` — Keychain wrapper
- `backend/src/cyclone/edi/filenames.py``build_outbound_filename`, `parse_inbound_filename`
- `backend/src/cyclone/edi/filenames.pyi` — type stubs
- `backend/src/cyclone/clearhouse/sftp.py``SftpClient.write_file`, `SftpClient.list_inbound` (stub)
- `config/payers.yaml` — payer config blocks
- `scripts/setup_keychain.sh` — manual Keychain setup helper
- `backend/tests/test_secrets.py` — Keychain wrapper tests
- `backend/tests/test_filenames.py` — filename helpers
- `backend/tests/test_sftp_stub.py` — SFTP stub behavior
- `backend/tests/test_payer_config_loading.py` — YAML loader + schema
- `backend/tests/fixtures/payers.yaml` — test fixture
**Modified files:**
- `backend/src/cyclone/api.py` — replace `PAYER_FACTORIES` lookup with `payers.get_config(payer_id, tx)`; add `POST /api/clearhouse/submit`, `POST /api/admin/reload-config`, `GET /api/clearhouse`
- `backend/src/cyclone/store.py` — add `providers`, `payers`, `payer_configs`, `clearhouse` ORM models and CRUD helpers
- `backend/src/cyclone/edi/serialize_837.py` — read provider NPI from `providers` table, not from `claim.raw_segments` alone; emit submitter + receiver from `clearhouse` + `payer_configs`
- `backend/src/cyclone/edi/parse_837.py` — extract provider NPI into a `parsed.provider_npi` field that joins to `providers.npi`
- `backend/src/cyclone/models.py` — add `Provider`, `Payer`, `PayerConfig`, `Clearhouse` Pydantic models
- `docs/reference/co-medicaid.md` — add section "SFTP submission paths" linking to `clearhouse.sftp_block_json`
**New migration:**
- `backend/src/cyclone/migrations/0007_providers_payers_clearhouse.sql` — the 4-table schema from §3.2
## 5. API surface
| Method | Path | Body | Returns |
|--------|------|------|---------|
| `GET` | `/api/clearhouse` | — | `Clearhouse` (name, tpid, sftp block, filename block) |
| `POST` | `/api/clearhouse/submit` | `{claim_ids, payer_id}` | `{ok, submitted[], stub}` |
| `GET` | `/api/config/providers` | — | `Provider[]` (filtered `?is_active=true`) |
| `GET` | `/api/config/providers/{npi}` | — | `Provider` |
| `POST` | `/api/config/providers` | `Provider` | `Provider` (creates new) |
| `PATCH` | `/api/config/providers/{npi}` | `Partial<Provider>` | `Provider` |
| `GET` | `/api/config/payers` | — | `Payer[]` |
| `GET` | `/api/config/payers/{payer_id}/configs` | — | `PayerConfig[]` (one per tx) |
| `PATCH` | `/api/config/payers/{payer_id}/configs/{tx}` | `Partial<PayerConfig>` | `PayerConfig` |
| `POST` | `/api/admin/reload-config` | — | `{ok, loaded, errors[]}` |
> **Note on path naming:** The path prefix `/api/config/*` is used for the
> SP9 config-driven providers/payers to avoid clashing with the existing
> `/api/providers` endpoint (which returns a paginated list of providers
> seen in actual claim data via `store.distinct_providers()`). The two
> endpoints serve different purposes: the existing one powers the Inbox
> UI; the new one powers the clearhouse admin view.
The clearhouse submit is the only auth-sensitive endpoint (in production: an admin key). The provider/payer CRUD are read-only for the operator during this SP — no UI form for editing.
## 6. Validation rules (new for CO MAP spec)
Added to `R_*` enum in `cyclone/validation/rules.py`:
| Rule | Description | Severity |
|------|-------------|----------|
| `R200` | `BHT06` must be in `payer_configs.bht06_allowed` for the claim's payer. | Error |
| `R201` | A single 837 envelope must not mix `BHT06=CH` and `BHT06=RP` claims. | Error |
| `R202` | `SBR09` must be in `payer_configs.sbr09_allowed` for the claim's payer. | Error |
| `R203` | `PRV*BI*PXC*<code>` must equal the provider's `taxonomy_code` from the `providers` table. | Error |
| `R204` | `NM1*85*XX*<npi>` must match an active row in `providers`. | Error |
| `R205` | `REF*EI*<tax_id>` must equal the provider's `tax_id` from the `providers` table. | Error |
| `R206` | `NM1*PR*PI*<payer_id>` must equal `payer_configs.payer_id` for the claim's payer. | Error |
| `R207` | `PWK` segment must not appear for this payer (`pwk_supported == false`). | Error |
| `R208` | 2320 loop `CAS*PI*` group only allowed for MCO encounter (`SBR09=ZZ`) denials. | Error |
| `R209` | Outbound filename must match the HCPF regex `^\d+-[A-Z0-9]+-\d{17}-1of1\.[A-Za-z0-9]+$` and use MT time. | Error |
| `R210` | Inbound filename must match the HCPF regex `^TP\d+-[A-Z0-9]+_M[A-Z0-9]+-\d{17}-1of1_[A-Z0-9]+\.x12$`. | Error |
All R200-R210 run on parse AND on serialize, so a 837 file with an unknown NPI fails the inbox lane.
## 7. Testing plan
1. **`test_filenames.py`** — 14 cases:
- build_outbound_filename with explicit `now_mt=datetime(2026,6,20,13,22,43,505000, tzinfo=MT)``11525703-837P-20260620132243505-1of1.x12` (17-digit, MT, "1of1")
- build_outbound_filename with `now_mt=None` defaults to `datetime.now(America/Denver)` (snapshot test)
- build_outbound_filename for all 8 transaction types in the HCPF doc
- parse_inbound_filename on a real prod 999 filename → `InboundFilename(tpid='11525703', orig_tx='837P', tracking='M019048402', ts='20260520231513488', file_type='999')`
- parse_inbound_filename on a real prod TA1 filename
- parse_inbound_filename rejects: missing TP prefix, wrong segment count, wrong file_type, non-numeric TPID
- Round-trip: build → parse returns the same tpid/orig_tx/file_type
2. **`test_payer_config_loading.py`** — 9 cases:
- Load `config/payers.yaml` → dict of `(payer_id, tx)` → config blocks
- Missing required key in a block → boot fails with precise error
- Pydantic schema rejects unknown `bht06_allowed` value
- Reload endpoint re-reads the file and re-validates
- Fallback to hard-coded `PAYER_FACTORIES` when a payer in the DB is missing from YAML (warn logged)
3. **`test_sftp_stub.py`** — 7 cases:
- Submit 2 claims → 2 files written to `staging_dir` at the full nested MFT path
- Submit with `stub=false` in YAML → calls `SftpClient.write_file` which is the stub function (records the call)
- Filename matches HCPF regex (R209)
- Pubsub emits a `clearhouse.submitted` event per claim
- `keyring` missing or Keychain empty → stub secret returned, no error
- `POST /api/clearhouse/submit` returns 200 + receipt with `stub: true`
- Payer_id not in DB → 404
4. **`test_secrets.py`** — 5 cases:
- `get_secret("sftp.gainwell.password")` returns the Keychain value (mocked)
- Keychain entry absent → returns None
- `keyring` library missing (Linux dev box) → returns None, no exception
5. **`test_providers_seed.py`** — 3 cases:
- Migration 0007 runs cleanly on a fresh DB
- Seed inserts the 3 NPIs with correct fields
- Constraint: `tax_id` and `taxonomy_code` are consistent across all 3 providers (asserts the 251E00000X / 721587149 invariant)
6. **`test_validation_r200_r210.py`** — 11 cases (one per new rule):
- R200: BHT06=CH works, BHT06=XX fails
- R201: mixed-batch envelope fails
- R202: SBR09=ZZ works (MCO encounter), SBR09=AB fails
- R203: PRV mismatch fails
- R204: unknown NPI fails
- R205: tax_id mismatch fails
- R206: wrong payer_id fails
- R207: PWK segment present fails
- R208: 2320 CAS*PI* on SBR09=MC fails
- R209: filename with UTC timestamp fails (must be MT)
- R210: inbound filename with wrong number of segments fails
**Target backend test count after SP9: 574 + 14 + 9 + 7 + 5 + 3 + 11 = 623 tests.**
## 8. Out of scope (future SPs)
- **SP10** — 277CA parser: a new `parse_277ca` module, a new 277CA lane in the Inbox, and the matching `(payer_id, "277CA")` config block in `payer_configs`.
- **SP11**`audit_log` table with `prev_hash` chaining (hash-chained, append-only, 6-year retention per HIPAA §164.316(b)(2)).
- **SP12** — SQLCipher-backed SQLite; DB key stored in Keychain via `secrets.get_secret("cyclone.db.key")`.
- **SP13**`paramiko`-backed `SftpClient.write_file`/`list_inbound`/`read_file`, with real Keychain secret, manual fail-over to a second MFT path, and a retry queue for transient network failures.
## 9. Open questions resolved this session
| # | Question | Resolution |
|---|----------|-----------|
| 1 | SFTP outbound + inbound paths on `mft.gainwelltechnologies.com` | `/CO XIX/PROD/coxix_prod_11525703/FromHPE` (out), `/CO XIX/PROD/coxix_prod_11525703/ToHPE` (in) — user-provided 2026-06-20 |
| 2 | 3 NPI street addresses + ZIPs | All 3 NPIs share Montrose corporate address (user confirmed 2026-06-20) — seed from prod files |
| 3 | 3 NPI taxonomy codes | `251E00000X` for all 3, self-served from 136 prod files |
| 4 | 277CA filename suffix | Use `277` per HCPF doc; distinguish 277CA by `ST*277CA` content (user confirmed 2026-06-20) |
| 5 | Payer ID: SKCO0 (current) vs CO_TXIX (spec) | Use `CO_TXIX` per spec (user confirmed 2026-06-20) |
## 10. Open questions still pending (blockers for SP10-SP13 only)
- **None for SP9.** Ready to implement.