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:
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""EDI utilities: filename helpers, future segment-level transforms."""
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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():
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"}
|
||||
@@ -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 != ""
|
||||
@@ -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")
|
||||
@@ -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")]
|
||||
@@ -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)$"
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user