Compare commits
11 Commits
fbe9940a3f
...
63b2870f6e
| Author | SHA1 | Date | |
|---|---|---|---|
| 63b2870f6e | |||
| fdfbde35c6 | |||
| a11e051f82 | |||
| 1225013fb0 | |||
| d54c44f04a | |||
| 84d2f39760 | |||
| 62bb09f183 | |||
| 9acdcb8dbd | |||
| 2c0afbe9c5 | |||
| ae2d48102e | |||
| c62826daea |
@@ -130,10 +130,13 @@ backoff schedule on error is `1s → 2s → 4s → 8s → 16s → 30s` capped.
|
||||
|
||||
## Inbox
|
||||
|
||||
`/inbox` is the working surface. Four lanes, dark by default (Ticker Tape
|
||||
`/inbox` is the working surface. Five lanes, dark by default (Ticker Tape
|
||||
aesthetic):
|
||||
|
||||
- **Rejected** — claims whose 999 set-level response was R or E. Re-submit in bulk.
|
||||
- **Payer-rejected** — claims whose 277CA STC category is A4, A6, or A7 (the payer
|
||||
accepted the file but denied the claim). Stamped at 277CA ingest time and never
|
||||
overwritten by a looser later 277CA.
|
||||
- **Candidates** — remits whose CLP-claim-id didn't match exactly; each one shows its top scored claim. One-click manual match or dismiss.
|
||||
- **Unmatched** — claims still waiting for a remit, and remits with no candidates above the threshold.
|
||||
- **Done today** — terminal state transitions in the last 24 hours.
|
||||
@@ -148,7 +151,7 @@ parses and rejects claims, the inbox reflects the new
|
||||
|
||||
| Method | Path | Notes |
|
||||
| ------ | --------------------------------------------- | ---------------------------------------------------------------- |
|
||||
| GET | `/api/inbox/lanes` | All four lanes in one call. |
|
||||
| GET | `/api/inbox/lanes` | All five lanes in one call. |
|
||||
| POST | `/api/inbox/candidates/{remit_id}/match` | Manual match. `409` if the claim state moved out from under us. |
|
||||
| POST | `/api/inbox/candidates/dismiss` | `{pairs: [{claim_id, remit_id}]}`. Session-scoped. |
|
||||
| POST | `/api/inbox/rejected/resubmit` | `{claim_ids: [...]}`. `200` with `conflicts` for non-rejected. |
|
||||
@@ -255,11 +258,139 @@ drawer surfaces a per-line "no 837 line matched" note.
|
||||
Tiers: **strong** (≥75, full opacity, Match enabled), **weak**
|
||||
(50–74, dimmed), **hidden** (<50, not surfaced).
|
||||
|
||||
## Multi-Payer, Multi-NPI & Clearhouse
|
||||
|
||||
The payer and provider identity that used to live as a single hard-coded
|
||||
`PayerConfig` dict in the backend is now data, not code. Three new tables
|
||||
plus a YAML file drive the entire configuration:
|
||||
|
||||
- **`providers` table** — one row per billing-provider NPI (Montrose
|
||||
`1881068062`, Delta `1851446637`, Salida `1467507269`). All three share
|
||||
the same `TOC, Inc.` legal name, tax ID `721587149`, and taxonomy
|
||||
`251E00000X`. Outbound 837 files pick the right `BillingProvider` by
|
||||
NPI; `claim.party.npi` is now a foreign key into `providers`.
|
||||
- **`payers` table** — one row per payer (`CO_TXIX`, …) with its
|
||||
receiver identity (NM1*40 / ISA08 / GS03).
|
||||
- **`payer_configs` join table** — one row per `(payer_id, transaction_type)`
|
||||
pair. 837P and 835 can carry different `BHT06`, SBR defaults, and
|
||||
allowed status codes per payer.
|
||||
- **`clearhouse` single-row config** — dzinesco's identity: TPID
|
||||
`11525703`, submitter name, MT-clock file-naming block, SFTP block.
|
||||
- **`config/payers.yaml`** — the on-disk source for everything above,
|
||||
schema-validated at boot against a Pydantic model. A typo or missing
|
||||
field fails the boot with a precise error. The original in-code
|
||||
`PAYER_FACTORIES` dict is kept as a fallback for ad-hoc testing.
|
||||
|
||||
### Config + clearhouse endpoints
|
||||
|
||||
| Method | Path | Notes |
|
||||
| ------ | --------------------------------------------- | ---------------------------------------------------------------- |
|
||||
| GET | `/api/clearhouse` | The `clearhouse` singleton (name, TPID, file/SFTP blocks). |
|
||||
| POST | `/api/clearhouse/submit` | Push a batch of generated 837 files via SFTP (see SFTP section). |
|
||||
| GET | `/api/config/providers` | All providers. |
|
||||
| GET | `/api/config/providers/{npi}` | One provider. |
|
||||
| GET | `/api/config/payers` | All payers. |
|
||||
| GET | `/api/config/payers/{payer_id}/configs` | All `(payer_id, transaction_type)` configs for one payer. |
|
||||
| POST | `/api/admin/reload-config` | Re-read `config/payers.yaml` and refresh the in-process cache. |
|
||||
|
||||
## 277CA Claim Acknowledgment
|
||||
|
||||
A 277CA (`005010X214`) is the per-claim acknowledgment CMS and
|
||||
Colorado Medicaid rely on: the file was syntactically valid *and* each
|
||||
named claim was accepted, pended, or rejected by the payer at the claim
|
||||
level. It is distinct from a 999 (file-level) and a TA1 (envelope-level).
|
||||
|
||||
Cyclone ingests 277CA files the same way it ingests 999 / 835 — drop the
|
||||
file on the Upload page or `POST /api/parse-277ca` — and stamps every
|
||||
claim whose `STC` category is `A4`, `A6`, or `A7` with a non-null
|
||||
`payer_rejected_at` + `payer_rejected_reason` + originating 277CA row id.
|
||||
|
||||
| Method | Path | Notes |
|
||||
| ------ | -------------------------- | ---------------------------------------------------------------- |
|
||||
| POST | `/api/parse-277ca` | Upload a 277CA, persist the parsed status rows. |
|
||||
| GET | `/api/277ca-acks` | List 277CA acks (filterable by date / payer). |
|
||||
| GET | `/api/277ca-acks/{id}` | One 277CA ack with its per-claim status rows + regenerated text. |
|
||||
|
||||
The `payer_rejected` stamp is **monotonic**: a later 277CA with a looser
|
||||
status set cannot clear a previous rejection. The Payer-Rejected inbox
|
||||
lane surfaces every claim with a non-null `payer_rejected_at` — it is
|
||||
distinct from the 999 `Rejected` lane (envelope reject) and they can
|
||||
both be true for the same claim.
|
||||
|
||||
## Tamper-Evident Audit Log
|
||||
|
||||
The `audit_log` table is the canonical record of every state transition
|
||||
the system has ever observed — claim lifecycle, reconciliation
|
||||
decisions, config reloads, SFTP submissions, 277CA rejects. SP11 made
|
||||
it tamper-evident: every row carries a SHA-256 hash of
|
||||
`(prev_hash || row_payload)`, forming a chain back to a genesis row.
|
||||
Any `INSERT`, `UPDATE`, or `DELETE` that breaks the chain is detectable
|
||||
in a single walk.
|
||||
|
||||
| Method | Path | Notes |
|
||||
| ------ | --------------------------------- | -------------------------------------------------------------- |
|
||||
| GET | `/api/admin/audit-log` | Paginated audit log (filterable by event type / actor / date). |
|
||||
| GET | `/api/admin/audit-log/verify` | Walk the chain; return the first broken link, or `{ok: true}`. |
|
||||
|
||||
`verify_chain` is the integrity check that backs the audit promise —
|
||||
it is intentionally cheap (one indexed walk) and intentionally
|
||||
side-effect-free so a scheduler can run it on a cron and alert on any
|
||||
non-`{ok: true}` result. Chain verification is **not** access-gated
|
||||
beyond the same `127.0.0.1` bind the rest of the API uses; for a
|
||||
hostile multi-operator deployment, wrap the route in your reverse proxy.
|
||||
|
||||
## Encryption at Rest
|
||||
|
||||
When the macOS Keychain carries an entry at service `cyclone`, account
|
||||
`cyclone.db.key`, and the optional `sqlcipher3` Python package is
|
||||
installed, the SQLite file at `~/.local/share/cyclone/cyclone.db` is
|
||||
opened with SQLCipher (AES-256). The key is read from the Keychain
|
||||
once at process start, applied via a SQLAlchemy `connect` event so
|
||||
every connection — including migrations and tests — gets the same
|
||||
`PRAGMA key`. The key is never written to disk or to a Python global.
|
||||
|
||||
When the Keychain entry is missing **or** `sqlcipher3` is not
|
||||
installed, the DB falls back to plain SQLite. The intent is a graceful
|
||||
default for developers and CI; the production posture is that every
|
||||
operator has created the Keychain entry on first run. See
|
||||
[docs/reference/co-medicaid.md §Keychain setup](docs/reference/co-medicaid.md)
|
||||
for the one-time setup recipe and the HIPAA Security Rule §164.312(a)(2)(iv)
|
||||
mapping.
|
||||
|
||||
## SFTP Wire-Up (paramiko)
|
||||
|
||||
The `clearhouse.submit` endpoint uses `paramiko` to push a batch of
|
||||
generated 837 files to the dzinesco SFTP server
|
||||
(`mft.gainwelltechnologies.com:22`, path
|
||||
`/CO XIX/PROD/coxix_prod_11525703/FromHPE`). The SFTP credential is
|
||||
fetched from the macOS Keychain at call time — never read from YAML,
|
||||
never logged, never written to disk. The wire-up honors the file-naming
|
||||
template stored in the `clearhouse` config:
|
||||
|
||||
```
|
||||
outbound: {tpid}-{tx}-{ts_mt}-1of1.{ext} e.g. 11525703-837P-20260620181814559-1of1.txt
|
||||
inbound: TP{tpid}-{orig_tx}_M{tracking}-{ts}-1of1_{file_type}.x12
|
||||
```
|
||||
|
||||
where `{ts_mt}` is a 17-digit `yyyymmddhhmmssSSS` Mountain Time stamp.
|
||||
Inbound filenames are routed by `<FileType>` and `<OrigTx>` to the
|
||||
matching parser (`999`, `TA1`, `271`, `277`, `277CA`, `835`).
|
||||
|
||||
The `SftpClient` interface is the same one the SP9 stub used — the swap
|
||||
was a one-file change (`sftp_paramiko.py` replacing `sftp_stub.py`).
|
||||
`paramiko` is an optional dependency; the stub remains the default when
|
||||
the `paramiko` extras aren't installed so the test suite stays green on
|
||||
Linux dev boxes.
|
||||
|
||||
## Persistence
|
||||
|
||||
Parsed batches, claims, remittances, matches, and activity events are
|
||||
stored in a SQLite file at `~/.local/share/cyclone/cyclone.db` by
|
||||
default. The directory is auto-created on first run.
|
||||
Parsed batches, claims, remittances, matches, 277CA rejections,
|
||||
hash-chained audit log entries, and SFTP submission history are stored
|
||||
in a SQLite file at `~/.local/share/cyclone/cyclone.db` by default.
|
||||
The directory is auto-created on first run. The DB is optionally
|
||||
encrypted with SQLCipher — see
|
||||
[Encryption at Rest](#encryption-at-rest) for the Keychain-driven
|
||||
setup.
|
||||
|
||||
To use a different location, set `CYCLONE_DB_URL`:
|
||||
|
||||
@@ -285,11 +416,23 @@ backup API).
|
||||
├── backend/
|
||||
│ ├── src/cyclone/
|
||||
│ │ ├── api.py # FastAPI app, GET + parse routes, /api/{resource}/stream
|
||||
│ │ ├── api_helpers.py # NDJSON / content-negotiation / live-tail helpers
|
||||
│ │ ├── pubsub.py # in-process EventBus (drop-oldest, per-kind fan-out)
|
||||
│ │ ├── store.py # InMemoryStore, mappers, publish-on-write
|
||||
│ │ ├── store.py # CycloneStore, mappers, publish-on-write
|
||||
│ │ ├── db.py # SQLAlchemy engine, session factory, ORM models
|
||||
│ │ ├── db_migrate.py # PRAGMA user_version migration runner
|
||||
│ │ ├── db_crypto.py # optional SQLCipher encryption at rest (SP12)
|
||||
│ │ ├── audit_log.py # tamper-evident hash-chained audit_log (SP11)
|
||||
│ │ ├── inbox_lanes.py # rejected / payer_rejected / candidates / unmatched / done_today
|
||||
│ │ ├── inbox_state.py # 999 envelope reject → claim state transitions
|
||||
│ │ ├── inbox_state_277ca.py # 277CA STC A4/A6/A7 → payer_rejected stamp (SP10)
|
||||
│ │ ├── providers.py # multi-NPI provider lookups (SP9)
|
||||
│ │ ├── payers.py # payer / payer_config lookups (SP9)
|
||||
│ │ ├── secrets.py # macOS Keychain-backed secret fetcher
|
||||
│ │ ├── reconcile.py # pure-function 835→claim match + line-level match
|
||||
│ │ ├── __main__.py # `python -m cyclone serve`
|
||||
│ │ ├── cli.py # click CLI
|
||||
│ │ └── parsers/ # X12 tokenizer, models, validator, writers
|
||||
│ │ └── parsers/ # X12 tokenizer, models, validator, writers, 277CA, 999, TA1, 270, 271
|
||||
│ └── tests/
|
||||
│ ├── fixtures/ # co_medicaid_*.txt, minimal_*.txt
|
||||
│ ├── test_api.py # parse-837/835 round-trip
|
||||
@@ -298,29 +441,71 @@ backup API).
|
||||
│ ├── test_api_streaming.py
|
||||
│ ├── test_api_stream_live.py # 3 live-tail endpoints + disconnect cleanup
|
||||
│ ├── test_pubsub.py # EventBus + subscribe/unsubscribe
|
||||
│ └── test_api_parse_persists.py
|
||||
│ ├── test_api_parse_persists.py
|
||||
│ ├── test_db.py / test_db_crypto.py / test_db_migrate.py
|
||||
│ ├── test_audit_log.py
|
||||
│ ├── test_inbox_lanes.py / test_inbox_state.py
|
||||
│ ├── test_apply_277ca_rejections.py
|
||||
│ ├── test_sftp_stub.py / test_sftp_paramiko.py
|
||||
│ └── test_providers_seed.py / test_payer_config_loading.py
|
||||
├── src/ # React + Vite + TypeScript UI
|
||||
│ ├── components/
|
||||
│ │ ├── ui/ # Skeleton, EmptyState, ErrorState, FilterChips, Pagination, …
|
||||
│ │ └── TailStatusPill.tsx # live-tail status badge + reconnect button
|
||||
│ ├── pages/ # Claims, Remittances, Providers, Activity, Upload
|
||||
│ ├── pages/ # Claims, Remittances, Providers, Acks, Activity, Upload, Inbox, …
|
||||
│ ├── hooks/ # useBatches, useClaims, useRemittances, useProviders, useActivity, useParse
|
||||
│ │ # + useTailStream, useMergedTail (live tail)
|
||||
│ ├── lib/ # api.ts (6 GET + parse837/parse835/health), format.ts, utils.ts
|
||||
│ ├── lib/ # api.ts, format.ts, utils.ts
|
||||
│ │ # + tail-stream.ts (NDJSON parser)
|
||||
│ ├── store/ # zustand sample-data + parsed-batches store
|
||||
│ │ # + tail-store.ts (FIFO-capped live tail slices)
|
||||
│ └── types/ # shared TS types
|
||||
├── config/
|
||||
│ └── payers.yaml # YAML-driven payer + clearhouse config (SP9)
|
||||
├── docs/
|
||||
│ ├── reference/ # condensed 837P/835/X12/CO Medicaid notes
|
||||
│ └── superpowers/plans/ # implementation plan
|
||||
│ ├── reference/ # condensed 837P/835/X12/CO Medicaid notes (incl. Keychain setup)
|
||||
│ ├── reviews/ # post-SP completeness reviews
|
||||
│ ├── superpowers/plans/ # implementation plans
|
||||
│ └── superpowers/specs/ # design specs (incl. SP9-SP13)
|
||||
├── tailwind.config.js # shimmer, scan, row-flash keyframes
|
||||
└── package.json
|
||||
```
|
||||
|
||||
## Roadmap
|
||||
|
||||
Sub-projects 2 through 8 are **shipped**. Next up:
|
||||
Sub-projects 2 through 13 are **shipped**. See the [completeness
|
||||
review](docs/reviews/2026-06-20-cyclone-completeness-review.md) for
|
||||
the honest gap analysis against the industry definition of a HIPAA
|
||||
clearinghouse — the short version is that the local-only,
|
||||
single-operator, single-payer design contract is honored, and the
|
||||
items that would be needed to expand that contract (AS2/AS4, SNIP 1–7,
|
||||
HITRUST, 276/277 status, 278 referrals, COB) are intentionally out of
|
||||
scope.
|
||||
|
||||
Shipped sub-projects (most recent first):
|
||||
|
||||
- **Sub-project 13 (shipped) — SFTP wire-up.** `paramiko`-backed
|
||||
`SftpClient` replaces the SP9 stub. The clearhouse.submit endpoint
|
||||
actually pushes to
|
||||
`mft.gainwelltechnologies.com:/CO XIX/PROD/coxix_prod_11525703/FromHPE`.
|
||||
SFTP credentials are read from the macOS Keychain at call time.
|
||||
- **Sub-project 12 (shipped) — Encryption at rest.** Optional
|
||||
SQLCipher AES-256 encryption of the SQLite file, with the key
|
||||
fetched from the macOS Keychain. Falls back to plain SQLite when
|
||||
the Keychain entry is missing or `sqlcipher3` isn't installed.
|
||||
- **Sub-project 11 (shipped) — Tamper-evident audit log.** Every
|
||||
`audit_log` row carries a SHA-256 hash chained to the previous row;
|
||||
a single walk via `GET /api/admin/audit-log/verify` detects any
|
||||
break.
|
||||
- **Sub-project 10 (shipped) — 277CA + Payer-Rejected lane.** Inbound
|
||||
277CA parser + a new Payer-Rejected inbox lane distinct from the
|
||||
999-envelope Rejected lane. The rejection stamp is monotonic.
|
||||
- **Sub-project 9 (shipped) — Multi-payer, multi-NPI, SFTP stub.** The
|
||||
in-code `PAYER_FACTORIES` dict is replaced by a `config/payers.yaml`
|
||||
+ 3 new DB tables (`providers`, `payers`, `payer_configs`) +
|
||||
`clearhouse` singleton. Added a `POST /api/clearhouse/submit` stub
|
||||
that writes to a local `staging_dir` — swapped for real `paramiko`
|
||||
in SP13.
|
||||
|
||||
- **Sub-project 8 (shipped) — Outbound 837P serializer.** Closes the
|
||||
resubmit loop: rejected claims can be regenerated back to an X12 837P
|
||||
@@ -390,8 +575,9 @@ Sub-projects 2 through 8 are **shipped**. Next up:
|
||||
back-off ladder on errors is `1s → 2s → 4s → 8s → 16s → 30s`
|
||||
capped. See the "Live updates" section below for details.
|
||||
- **Sub-project 6 (shipped) — Inbox workflow automation.**
|
||||
- **Ticker-Tape inbox (`/inbox`):** four lanes ordered by urgency —
|
||||
**Rejected** (claims whose 999 rejected them), **Candidates**
|
||||
- **Ticker-Tape inbox (`/inbox`):** five lanes ordered by urgency —
|
||||
**Rejected** (claims whose 999 rejected them), **Payer-rejected**
|
||||
(claims whose 277CA denied them — added in SP10), **Candidates**
|
||||
(remits that didn't auto-match a claim), **Unmatched** (claims
|
||||
still waiting for a remit), and **Done today** (terminal
|
||||
transitions in the last 24 hours). The page subscribes to the
|
||||
@@ -428,6 +614,10 @@ Sub-projects 2 through 8 are **shipped**. Next up:
|
||||
- `GET /api/acks` — list ACKs.
|
||||
- `GET /api/acks/{id}` — ACK detail, including the regenerated
|
||||
`raw_999_text`.
|
||||
- `POST /api/parse-ta1` — parse an inbound TA1 envelope ACK and persist it.
|
||||
- `GET /api/ta1-acks` — list TA1 acks.
|
||||
- `GET /api/ta1-acks/{id}` — TA1 ack detail (envelope control segments
|
||||
+ the parser's accept/reject verdict).
|
||||
- `POST /api/eligibility/request` — build a 270 from JSON.
|
||||
- `POST /api/eligibility/parse-271` — ingest a 271 and return parsed
|
||||
coverage benefits.
|
||||
@@ -459,9 +649,9 @@ ACKs and lets you download the regenerated 999 text.
|
||||
|
||||
### SP6 endpoints (inbox)
|
||||
|
||||
- `GET /api/inbox/lanes` — all four lanes (rejected, candidates,
|
||||
unmatched, done_today) in a single round-trip, with row-level
|
||||
scoring and matched-remit context.
|
||||
- `GET /api/inbox/lanes` — all five lanes (rejected, payer_rejected,
|
||||
candidates, unmatched, done_today) in a single round-trip, with
|
||||
row-level scoring and matched-remit context.
|
||||
- `POST /api/inbox/candidates/{remit_id}/match` — manual match of a
|
||||
candidate remit to one of its scored claims; `409` if the claim
|
||||
state moved out from under us.
|
||||
@@ -510,6 +700,58 @@ ACKs and lets you download the regenerated 999 text.
|
||||
Download** button in the Inbox rejected-lane BulkBar (N>1 modal
|
||||
prompt).
|
||||
|
||||
### SP9 endpoints (multi-payer, multi-NPI, SFTP stub)
|
||||
|
||||
- `GET /api/clearhouse` — the `clearhouse` singleton (name, TPID,
|
||||
file-naming block, SFTP block).
|
||||
- `POST /api/clearhouse/submit` — push a batch of generated 837 files.
|
||||
The SP9 implementation writes to a local `staging_dir`; the SP13
|
||||
swap replaces the write with a real `paramiko` SFTP push without
|
||||
changing the route shape.
|
||||
- `GET /api/config/providers` and `GET /api/config/providers/{npi}` —
|
||||
list / fetch providers from the new `providers` table.
|
||||
- `GET /api/config/payers` and
|
||||
`GET /api/config/payers/{payer_id}/configs` — list payers; for a
|
||||
given payer, return the per-transaction-type `payer_configs` rows.
|
||||
- `POST /api/admin/reload-config` — re-read `config/payers.yaml` and
|
||||
refresh the in-process cache without a server restart.
|
||||
|
||||
### SP10 endpoints (277CA + Payer-Rejected lane)
|
||||
|
||||
- `POST /api/parse-277ca` — upload a 277CA file; persist the parsed
|
||||
`ClaimStatus` rows and stamp the matching claims with
|
||||
`payer_rejected_at` (monotonic, never overwritten by `NULL`).
|
||||
- `GET /api/277ca-acks` — list 277CA acks.
|
||||
- `GET /api/277ca-acks/{id}` — one 277CA ack with its per-claim
|
||||
`ClaimStatus` rows + regenerated text.
|
||||
- `GET /api/inbox/lanes` — the response now also carries a
|
||||
`payer_rejected` lane populated from
|
||||
`Claim.payer_rejected_at IS NOT NULL`.
|
||||
|
||||
### SP11 endpoints (tamper-evident audit log)
|
||||
|
||||
- `GET /api/admin/audit-log` — paginated audit log. Each row carries
|
||||
`(id, prev_hash, row_hash, event_type, actor, payload_json,
|
||||
created_at)` where `row_hash = sha256(prev_hash || canonical_json(payload))`.
|
||||
- `GET /api/admin/audit-log/verify` — walk the chain in insertion
|
||||
order; return `{ok: true}` or the first `{id, expected, got}`
|
||||
mismatch. The walk is O(n) with one indexed lookup per row.
|
||||
|
||||
### SP12 (encryption at rest — no new routes)
|
||||
|
||||
SP12 introduces no API routes. The `cyclone.db.key` Keychain entry +
|
||||
optional `sqlcipher3` dependency enable AES-256 encryption transparently
|
||||
on the next connection. See [Encryption at Rest](#encryption-at-rest)
|
||||
and [docs/reference/co-medicaid.md](docs/reference/co-medicaid.md) for
|
||||
the one-time setup recipe.
|
||||
|
||||
### SP13 endpoints (paramiko SFTP)
|
||||
|
||||
- `POST /api/clearhouse/submit` — same endpoint as SP9; the
|
||||
implementation is now a real `paramiko` `SftpClient.write` to
|
||||
`mft.gainwelltechnologies.com:/CO XIX/PROD/coxix_prod_11525703/FromHPE`.
|
||||
SFTP credentials are fetched from the macOS Keychain at call time.
|
||||
|
||||
## License
|
||||
|
||||
No license file yet; this is internal-use software. Add a `LICENSE` file
|
||||
|
||||
+15
-1
@@ -79,10 +79,24 @@ python -m cyclone serve
|
||||
### Endpoints
|
||||
|
||||
| Method | Path | Purpose |
|
||||
| ------ | ---------------- | -------------------------------------------------- |
|
||||
| ------ | --------------------- | ------------------------------------------------------------------ |
|
||||
| GET | `/api/health` | Liveness probe: `{"status": "ok", "version": ...}` |
|
||||
| POST | `/api/parse-837` | Upload an X12 837P file, get parsed claims back |
|
||||
| POST | `/api/parse-835` | Upload an X12 835 ERA file, get parsed payouts back |
|
||||
| POST | `/api/parse-999` | Upload an inbound 999 ACK and persist it |
|
||||
| POST | `/api/parse-ta1` | Upload an inbound TA1 envelope ACK and persist it |
|
||||
| POST | `/api/parse-277ca` | Upload a 277CA claim acknowledgment and stamp payer_rejected claims |
|
||||
| POST | `/api/eligibility/request` | Build a 270 from JSON (subscriber / provider / payer) |
|
||||
| POST | `/api/eligibility/parse-271` | Ingest a 271 and return structured `coverage_benefits` |
|
||||
| GET | `/api/clearhouse` | The `clearhouse` singleton (SP9) |
|
||||
| POST | `/api/clearhouse/submit` | Push a batch of generated 837 files via SFTP (SP9 stub, SP13 real) |
|
||||
| POST | `/api/admin/reload-config` | Re-read `config/payers.yaml` and refresh the in-process cache |
|
||||
| GET | `/api/admin/audit-log` | Paginated tamper-evident audit log (SP11) |
|
||||
| GET | `/api/admin/audit-log/verify` | Walk the audit_log hash chain (SP11) |
|
||||
|
||||
The full surface — claim / remittance / batch / inbox / stream
|
||||
endpoints, config lookups, and the 270/271 builder — is enumerated in
|
||||
the root [README](../README.md#multi-payer-multi-npi--clearhouse).
|
||||
|
||||
`POST /api/parse-837` accepts `multipart/form-data` with a single `file`
|
||||
field. Optional query parameters:
|
||||
|
||||
@@ -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]
|
||||
@@ -23,6 +25,16 @@ dev = [
|
||||
"pytest-asyncio>=0.23,<1",
|
||||
"httpx>=0.27,<1",
|
||||
]
|
||||
sqlcipher = [
|
||||
# SP12: encryption at rest. Optional — without it the DB is plain SQLite.
|
||||
# Install via: pip install -e .[sqlcipher] (after brew install sqlcipher).
|
||||
"sqlcipher3>=0.6,<1",
|
||||
]
|
||||
sftp = [
|
||||
# SP13: real SFTP wire-up. Optional — without it the stub keeps working.
|
||||
# Install via: pip install -e .[sftp].
|
||||
"paramiko>=3.4,<6",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
cyclone = "cyclone.cli:main"
|
||||
|
||||
+434
-2
@@ -34,6 +34,8 @@ from pydantic import ValidationError
|
||||
from cyclone import __version__, db
|
||||
from cyclone.db import Claim, ClaimState, Remittance
|
||||
from cyclone.inbox_state import apply_999_rejections
|
||||
from cyclone.inbox_state_277ca import apply_277ca_rejections
|
||||
from cyclone.audit_log import AuditEvent, append_event, verify_chain
|
||||
from cyclone.parsers.exceptions import CycloneParseError
|
||||
from cyclone.parsers.models import BatchSummary, ClaimOutput, Envelope, ParseResult
|
||||
from cyclone.parsers.models_270 import (
|
||||
@@ -48,6 +50,7 @@ from cyclone.parsers.models_835 import ParseResult835
|
||||
from cyclone.parsers.payer import PayerConfig, PayerConfig835
|
||||
from cyclone.parsers.parse_270 import parse as parse_270_text
|
||||
from cyclone.parsers.parse_271 import parse as parse_271_text
|
||||
from cyclone.parsers.parse_277ca import parse_277ca_text
|
||||
from cyclone.parsers.parse_837 import parse
|
||||
from cyclone.parsers.parse_835 import parse as parse_835
|
||||
from cyclone.parsers.parse_999 import parse_999_text
|
||||
@@ -85,10 +88,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
|
||||
|
||||
|
||||
@@ -655,6 +664,20 @@ async def parse_999_endpoint(
|
||||
raw_json=json.loads(result.model_dump_json()),
|
||||
)
|
||||
|
||||
# SP11: append one audit row per rejected claim. Each row chains
|
||||
# to the previous one — see cyclone.audit_log.
|
||||
if _rejection_result.matched:
|
||||
with db.SessionLocal()() as audit_s:
|
||||
for cid in _rejection_result.matched:
|
||||
append_event(audit_s, AuditEvent(
|
||||
event_type="claim.rejected",
|
||||
entity_type="claim",
|
||||
entity_id=cid,
|
||||
payload={"source_batch_id": synthetic_id, "ack_id": row.id},
|
||||
actor="999-parser",
|
||||
))
|
||||
audit_s.commit()
|
||||
|
||||
return JSONResponse(content={
|
||||
"ack": {
|
||||
"id": row.id,
|
||||
@@ -811,6 +834,185 @@ def _ta1_to_ui(row: db.Ta1Ack) -> dict:
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 277CA (Claim Acknowledgment) — SP10
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _277ca_synthetic_source_batch_id(interchange_control_number: str) -> str:
|
||||
"""Return a synthetic ``batches.id`` for a received 277CA with no source batch.
|
||||
|
||||
Mirrors :func:`_ack_synthetic_source_batch_id`. The 277CA row's
|
||||
``source_batch_id`` FK requires a row in batches; for received
|
||||
277CAs we synthesize an id of the form ``277CA-<ISA13>``. The row
|
||||
is NOT created in batches — same FK-is-no-op convention as the 999
|
||||
path.
|
||||
"""
|
||||
return f"277CA-{(interchange_control_number or '').strip() or '000000001'}"
|
||||
|
||||
|
||||
@app.post("/api/parse-277ca")
|
||||
async def parse_277ca_endpoint(
|
||||
request: Request,
|
||||
file: UploadFile = File(...),
|
||||
) -> Any:
|
||||
"""Parse a 277CA Claim Acknowledgment file, persist a row, and stamp rejections.
|
||||
|
||||
Behavior mirrors ``/api/parse-999``:
|
||||
- 400 on empty / undecodable / malformed EDI (never 500).
|
||||
- 200 on success with ``{"ack": {id, control_number, accepted_count,
|
||||
rejected_count, payer_claim_control_numbers, raw_277ca_text},
|
||||
"parsed": <ParseResult277CA>}``.
|
||||
|
||||
After parse, runs :func:`apply_277ca_rejections` to stamp the
|
||||
payer-rejected fields on each matching claim row. The Inbox
|
||||
Payer-Rejected lane lights up as a side-effect of this call.
|
||||
"""
|
||||
raw = await file.read()
|
||||
if not raw:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"error": "Empty file", "detail": "Uploaded file contained no bytes."},
|
||||
)
|
||||
try:
|
||||
text = raw.decode("utf-8")
|
||||
except UnicodeDecodeError as exc:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"error": "Encoding error", "detail": str(exc)},
|
||||
)
|
||||
|
||||
try:
|
||||
result = parse_277ca_text(text, input_file=file.filename or "")
|
||||
except CycloneParseError as exc:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"error": "Parse error", "detail": str(exc)},
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - safety net
|
||||
log.exception("Unexpected parser failure on 277CA")
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"error": "Internal server error", "detail": str(exc)},
|
||||
)
|
||||
|
||||
icn = result.envelope.control_number
|
||||
synthetic_id = _277ca_synthetic_source_batch_id(icn)
|
||||
|
||||
accepted = sum(1 for s in result.claim_statuses if s.classification == "accepted")
|
||||
paid = sum(1 for s in result.claim_statuses if s.classification == "paid")
|
||||
rejected = sum(1 for s in result.claim_statuses if s.classification == "rejected")
|
||||
pended = sum(1 for s in result.claim_statuses if s.classification == "pended")
|
||||
|
||||
# Persist the 277CA row first so we have an id to attach to claims.
|
||||
row = store.add_277ca_ack(
|
||||
source_batch_id=synthetic_id,
|
||||
control_number=icn,
|
||||
accepted_count=accepted,
|
||||
rejected_count=rejected,
|
||||
paid_count=paid,
|
||||
pended_count=pended,
|
||||
raw_json=json.loads(result.model_dump_json()),
|
||||
)
|
||||
|
||||
# Stamp payer-rejection fields on matching claims. The 277CA's
|
||||
# REF*1K carries the patient's claim control number we sent in
|
||||
# CLM01 — same convention the 999 ACK uses, so the lookup hits
|
||||
# Claim.patient_control_number (mirrors apply_999_rejections).
|
||||
with db.SessionLocal()() as session:
|
||||
def _lookup(pcn: str):
|
||||
return (
|
||||
session.query(Claim)
|
||||
.filter(Claim.patient_control_number == pcn)
|
||||
.first()
|
||||
)
|
||||
apply_result = apply_277ca_rejections(
|
||||
session, result, claim_lookup=_lookup, two77ca_id=row.id,
|
||||
)
|
||||
if apply_result.matched:
|
||||
bus = request.app.state.event_bus
|
||||
for cid in apply_result.matched:
|
||||
await bus.publish("claim.payer_rejected", {"claim_id": cid})
|
||||
# SP11: audit trail for each payer-rejected claim.
|
||||
with db.SessionLocal()() as audit_s:
|
||||
for cid in apply_result.matched:
|
||||
append_event(audit_s, AuditEvent(
|
||||
event_type="claim.payer_rejected",
|
||||
entity_type="claim",
|
||||
entity_id=cid,
|
||||
payload={
|
||||
"source_batch_id": synthetic_id,
|
||||
"277ca_id": row.id,
|
||||
},
|
||||
actor="277ca-parser",
|
||||
))
|
||||
audit_s.commit()
|
||||
if apply_result.orphans:
|
||||
log.warning(
|
||||
"277CA had %d orphan status entries (no matching claim): %s",
|
||||
len(apply_result.orphans),
|
||||
apply_result.orphans[:5],
|
||||
)
|
||||
|
||||
return JSONResponse(content={
|
||||
"ack": {
|
||||
"id": row.id,
|
||||
"control_number": icn,
|
||||
"accepted_count": accepted,
|
||||
"rejected_count": rejected,
|
||||
"paid_count": paid,
|
||||
"pended_count": pended,
|
||||
"source_batch_id": synthetic_id,
|
||||
"matched_claim_ids": apply_result.matched,
|
||||
"orphan_status_codes": apply_result.orphans,
|
||||
},
|
||||
"parsed": json.loads(result.model_dump_json()),
|
||||
})
|
||||
|
||||
|
||||
@app.get("/api/277ca-acks")
|
||||
def list_277ca_acks_endpoint(
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
) -> Any:
|
||||
"""Return the list of persisted 277CA ACKs, newest first."""
|
||||
rows = store.list_277ca_acks()
|
||||
items = [_277ca_to_ui(r) for r in rows[:limit]]
|
||||
return {"total": len(rows), "items": items}
|
||||
|
||||
|
||||
@app.get("/api/277ca-acks/{ack_id}")
|
||||
def get_277ca_ack_endpoint(ack_id: int) -> dict:
|
||||
"""Return one persisted 277CA ACK row with its parsed detail."""
|
||||
row = store.get_277ca_ack(ack_id)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail=f"277CA ACK {ack_id} not found")
|
||||
return {
|
||||
"id": row.id,
|
||||
"control_number": row.control_number,
|
||||
"accepted_count": row.accepted_count,
|
||||
"rejected_count": row.rejected_count,
|
||||
"paid_count": row.paid_count,
|
||||
"pended_count": row.pended_count,
|
||||
"source_batch_id": row.source_batch_id,
|
||||
"parsed_at": row.parsed_at.isoformat() if row.parsed_at else None,
|
||||
"raw_json": row.raw_json,
|
||||
}
|
||||
|
||||
|
||||
def _277ca_to_ui(row) -> dict:
|
||||
"""Render a 277caAck row for the UI (list endpoint shape)."""
|
||||
return {
|
||||
"id": row.id,
|
||||
"control_number": row.control_number,
|
||||
"accepted_count": row.accepted_count,
|
||||
"rejected_count": row.rejected_count,
|
||||
"paid_count": row.paid_count,
|
||||
"pended_count": row.pended_count,
|
||||
"source_batch_id": row.source_batch_id,
|
||||
"parsed_at": row.parsed_at.isoformat() if row.parsed_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _serialize_ta1(result) -> str:
|
||||
"""Render a TA1 file from a ParseResultTa1 for the ``raw_ta1_text`` field.
|
||||
|
||||
@@ -844,13 +1046,16 @@ def _serialize_ta1_from_row(row: db.Ta1Ack) -> str:
|
||||
|
||||
@app.get("/api/inbox/lanes")
|
||||
def inbox_lanes():
|
||||
"""Return all four Inbox lanes in one call."""
|
||||
"""Return all Inbox lanes in one call."""
|
||||
dismissed_pairs = getattr(app.state, "dismissed_pairs", set())
|
||||
with db.SessionLocal()() as session:
|
||||
from cyclone.inbox_lanes import compute_lanes
|
||||
lanes = compute_lanes(session, dismissed_pairs=dismissed_pairs)
|
||||
return {
|
||||
"rejected": lanes.rejected,
|
||||
# SP10: payer-rejected lane (277CA STC A4/A6/A7). Distinct from
|
||||
# the 999 envelope rejection in ``rejected`` above.
|
||||
"payer_rejected": lanes.payer_rejected,
|
||||
"candidates": lanes.candidates,
|
||||
"unmatched": lanes.unmatched,
|
||||
"done_today": lanes.done_today,
|
||||
@@ -2126,4 +2331,231 @@ 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,
|
||||
})
|
||||
# SP11: audit trail for each successful clearhouse submission.
|
||||
with db.SessionLocal()() as audit_s:
|
||||
append_event(audit_s, AuditEvent(
|
||||
event_type="clearhouse.submitted",
|
||||
entity_type="claim",
|
||||
entity_id=cid,
|
||||
payload={
|
||||
"filename": filename,
|
||||
"remote_path": remote,
|
||||
"tpid": ch.tpid,
|
||||
"stub": ch.sftp_block.stub,
|
||||
},
|
||||
actor="clearhouse-submit",
|
||||
))
|
||||
audit_s.commit()
|
||||
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)]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# SP11: tamper-evident audit log (admin)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@app.get("/api/admin/audit-log")
|
||||
def list_audit_log_endpoint(
|
||||
entity_type: str | None = Query(default=None),
|
||||
entity_id: str | None = Query(default=None),
|
||||
event_type: str | None = Query(default=None),
|
||||
limit: int = Query(default=100, ge=1, le=1000),
|
||||
) -> Any:
|
||||
"""List audit-log rows, newest first, with optional filters.
|
||||
|
||||
Filters match the (entity_type, entity_id) pair (typical use:
|
||||
"show me everything that happened to claim C-123") or a single
|
||||
event_type (typical use: "show me all clearhouse.submitted
|
||||
events today").
|
||||
"""
|
||||
with db.SessionLocal()() as s:
|
||||
q = s.query(db.AuditLog)
|
||||
if entity_type:
|
||||
q = q.filter(db.AuditLog.entity_type == entity_type)
|
||||
if entity_id:
|
||||
q = q.filter(db.AuditLog.entity_id == entity_id)
|
||||
if event_type:
|
||||
q = q.filter(db.AuditLog.event_type == event_type)
|
||||
rows = q.order_by(db.AuditLog.id.desc()).limit(limit).all()
|
||||
return {
|
||||
"total": len(rows),
|
||||
"items": [
|
||||
{
|
||||
"id": r.id,
|
||||
"event_type": r.event_type,
|
||||
"entity_type": r.entity_type,
|
||||
"entity_id": r.entity_id,
|
||||
"actor": r.actor,
|
||||
"payload": json.loads(r.payload_json) if r.payload_json else None,
|
||||
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||||
"prev_hash": r.prev_hash,
|
||||
"hash": r.hash,
|
||||
}
|
||||
for r in rows
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/admin/audit-log/verify")
|
||||
def verify_audit_log_endpoint() -> Any:
|
||||
"""Walk the audit-log chain and verify every row's hash.
|
||||
|
||||
Returns ``{"ok": true, "checked": N}`` for a clean chain, or
|
||||
``{"ok": false, "checked": K, "first_bad_id": X, "reason": "..."}``
|
||||
for a broken chain. This is the operator's "did anyone tamper?"
|
||||
endpoint; run it on demand or via a nightly cron job.
|
||||
"""
|
||||
with db.SessionLocal()() as s:
|
||||
result = verify_chain(s)
|
||||
return {
|
||||
"ok": result.ok,
|
||||
"checked": result.checked,
|
||||
"first_bad_id": result.first_bad_id,
|
||||
"reason": result.reason,
|
||||
}
|
||||
|
||||
|
||||
@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,254 @@
|
||||
"""Tamper-evident hash-chained audit_log.
|
||||
|
||||
SP11.
|
||||
|
||||
Each row's hash is SHA-256 of
|
||||
``(id, event_type, entity_type, entity_id, actor, payload_json,
|
||||
created_at, prev_hash)`` and ``prev_hash`` is the previous row's hash.
|
||||
That forms a chain: changing any row's payload invalidates every
|
||||
subsequent row's hash. :func:`verify_chain` walks the chain and
|
||||
returns the first mismatch index (or ``None`` for a clean chain).
|
||||
|
||||
We use SHA-256 (FIPS-approved, fast on commodity hardware) instead
|
||||
of a Merkle tree because the chain is linear: every row depends on
|
||||
exactly one prior row. A Merkle tree would let us prove individual
|
||||
membership with O(log n) witnesses, but the chain's whole point is
|
||||
end-to-end integrity, not selective disclosure.
|
||||
|
||||
Append-only by convention: the application MUST NOT call
|
||||
``session.delete(row)`` or modify an existing row. Doing so is
|
||||
auditable via :func:`verify_chain`. We deliberately do not enforce
|
||||
this at the DB level (no triggers, no revoked UPDATE permission)
|
||||
because that breaks the test fixtures that recreate the DB.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from cyclone.db import AuditLog
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# 64 hex chars = 256 bits. Constant for easy comparison.
|
||||
HASH_LEN = 64
|
||||
|
||||
# Genesis row's prev_hash — a fixed "all zeros" sentinel so the first
|
||||
# row in the chain has a deterministic predecessor. This is the same
|
||||
# convention Bitcoin and other ledgers use.
|
||||
GENESIS_PREV_HASH = "0" * HASH_LEN
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Hashing
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _hash_row(
|
||||
*,
|
||||
row_id: int,
|
||||
event_type: str,
|
||||
entity_type: str,
|
||||
entity_id: str,
|
||||
actor: str,
|
||||
payload_json: str | None,
|
||||
created_at: datetime,
|
||||
prev_hash: str,
|
||||
) -> str:
|
||||
"""Compute SHA-256 hex of a row's canonical form.
|
||||
|
||||
The fields are concatenated with a separator that cannot appear
|
||||
inside any field (``\\x1f`` — the ASCII unit separator). Using a
|
||||
delimiter avoids length-ambiguity attacks where two different
|
||||
payloads with the same string-joined form would hash to the same
|
||||
digest.
|
||||
"""
|
||||
sep = "\x1f"
|
||||
# Normalize the timestamp to an ISO 8601 UTC string so the hash is
|
||||
# stable across timezone-aware and timezone-naive datetimes (the
|
||||
# DB may give us either depending on the SQLite build).
|
||||
if created_at.tzinfo is None:
|
||||
created_at = created_at.replace(tzinfo=timezone.utc)
|
||||
created_at_iso = created_at.astimezone(timezone.utc).isoformat()
|
||||
payload = payload_json or ""
|
||||
canonical = sep.join([
|
||||
str(row_id),
|
||||
event_type,
|
||||
entity_type,
|
||||
entity_id,
|
||||
actor,
|
||||
created_at_iso,
|
||||
payload,
|
||||
prev_hash,
|
||||
])
|
||||
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Append
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@dataclass
|
||||
class AuditEvent:
|
||||
"""An audit event ready to be appended.
|
||||
|
||||
Mirrors the ``AuditLog`` row shape minus the auto-assigned id and
|
||||
computed hash. Payload must be JSON-serializable; the audit_log
|
||||
module handles the encoding so callers don't need to think about
|
||||
canonical form.
|
||||
"""
|
||||
|
||||
event_type: str
|
||||
entity_type: str
|
||||
entity_id: str
|
||||
payload: dict[str, Any] = field(default_factory=dict)
|
||||
actor: str = "system"
|
||||
created_at: datetime | None = None
|
||||
|
||||
|
||||
def append_event(
|
||||
session: Session,
|
||||
event: AuditEvent,
|
||||
) -> AuditLog:
|
||||
"""Append one event to the audit_log chain and return the row.
|
||||
|
||||
The caller is responsible for ``session.commit()`` — this lets
|
||||
callers batch multiple appends into one transaction (e.g., a
|
||||
parser that appends one event per parsed claim).
|
||||
"""
|
||||
# Read the latest hash within the same session so concurrent
|
||||
# appends don't see stale state. SQLite default isolation level
|
||||
# gives us serializable reads for this query; for Postgres we'd
|
||||
# need SELECT ... FOR UPDATE but that's overkill for v1.
|
||||
latest = (
|
||||
session.query(AuditLog)
|
||||
.order_by(AuditLog.id.desc())
|
||||
.first()
|
||||
)
|
||||
prev_hash = latest.hash if latest is not None else GENESIS_PREV_HASH
|
||||
|
||||
created_at = event.created_at or datetime.now(timezone.utc)
|
||||
if created_at.tzinfo is None:
|
||||
created_at = created_at.replace(tzinfo=timezone.utc)
|
||||
|
||||
# Canonical payload form: sort_keys + compact separators. This
|
||||
# makes the hash independent of dict insertion order across
|
||||
# Python versions and across API runs.
|
||||
payload_json = json.dumps(event.payload, sort_keys=True, separators=(",", ":")) if event.payload else None
|
||||
|
||||
# Insert the row with a placeholder hash, then UPDATE once we
|
||||
# know the auto-assigned id. SQLite + SQLAlchemy gives us the id
|
||||
# after the INSERT, so we can compute the real hash then.
|
||||
row = AuditLog(
|
||||
event_type=event.event_type,
|
||||
entity_type=event.entity_type,
|
||||
entity_id=event.entity_id,
|
||||
actor=event.actor,
|
||||
payload_json=payload_json,
|
||||
created_at=created_at,
|
||||
prev_hash=prev_hash,
|
||||
hash=GENESIS_PREV_HASH, # placeholder; updated below
|
||||
)
|
||||
session.add(row)
|
||||
session.flush() # populate row.id
|
||||
row.hash = _hash_row(
|
||||
row_id=row.id,
|
||||
event_type=row.event_type,
|
||||
entity_type=row.entity_type,
|
||||
entity_id=row.entity_id,
|
||||
actor=row.actor,
|
||||
payload_json=row.payload_json,
|
||||
created_at=row.created_at,
|
||||
prev_hash=row.prev_hash,
|
||||
)
|
||||
session.flush()
|
||||
return row
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Verify
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@dataclass
|
||||
class VerifyResult:
|
||||
"""Outcome of :func:`verify_chain`."""
|
||||
|
||||
ok: bool
|
||||
checked: int
|
||||
first_bad_id: int | None = None
|
||||
reason: str | None = None
|
||||
|
||||
|
||||
def verify_chain(session: Session) -> VerifyResult:
|
||||
"""Walk the audit_log and verify every row's hash. Returns the first mismatch.
|
||||
|
||||
A clean chain returns ``VerifyResult(ok=True, checked=N)``. A
|
||||
broken chain returns ``ok=False, first_bad_id=X, reason='...'``
|
||||
describing what went wrong (hash mismatch, prev_hash mismatch,
|
||||
or non-monotonic id).
|
||||
|
||||
This is intended to be called by the operator (e.g., a nightly
|
||||
cron job or the admin UI's "Verify Audit Chain" button). It is
|
||||
NOT a fast operation — for a 6-year-old chain with millions of
|
||||
rows, expect seconds-to-minutes. Call it rarely.
|
||||
"""
|
||||
rows = session.query(AuditLog).order_by(AuditLog.id.asc()).all()
|
||||
if not rows:
|
||||
return VerifyResult(ok=True, checked=0)
|
||||
|
||||
expected_prev = GENESIS_PREV_HASH
|
||||
last_id = 0
|
||||
for i, row in enumerate(rows):
|
||||
# Monotonic id check — covers attempted inserts with a
|
||||
# custom id, or accidental out-of-order rows.
|
||||
if row.id <= last_id:
|
||||
return VerifyResult(
|
||||
ok=False, checked=i, first_bad_id=row.id,
|
||||
reason=f"non-monotonic id (previous={last_id}, this={row.id})",
|
||||
)
|
||||
last_id = row.id
|
||||
|
||||
# Recompute the hash from the row's content and compare.
|
||||
expected_hash = _hash_row(
|
||||
row_id=row.id,
|
||||
event_type=row.event_type,
|
||||
entity_type=row.entity_type,
|
||||
entity_id=row.entity_id,
|
||||
actor=row.actor,
|
||||
payload_json=row.payload_json,
|
||||
created_at=row.created_at,
|
||||
prev_hash=row.prev_hash,
|
||||
)
|
||||
if expected_hash != row.hash:
|
||||
return VerifyResult(
|
||||
ok=False, checked=i, first_bad_id=row.id,
|
||||
reason=f"hash mismatch (stored={row.hash[:16]}…, computed={expected_hash[:16]}…)",
|
||||
)
|
||||
|
||||
# Check prev_hash linkage.
|
||||
if row.prev_hash != expected_prev:
|
||||
return VerifyResult(
|
||||
ok=False, checked=i, first_bad_id=row.id,
|
||||
reason=f"prev_hash mismatch (stored={row.prev_hash[:16]}…, expected={expected_prev[:16]}…)",
|
||||
)
|
||||
expected_prev = row.hash
|
||||
|
||||
return VerifyResult(ok=True, checked=len(rows))
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AuditEvent",
|
||||
"GENESIS_PREV_HASH",
|
||||
"HASH_LEN",
|
||||
"VerifyResult",
|
||||
"append_event",
|
||||
"verify_chain",
|
||||
]
|
||||
@@ -0,0 +1,307 @@
|
||||
"""Clearhouse integration (SFTP submission, inbound polling).
|
||||
|
||||
SP9 ships a stub (writes files to a local staging dir).
|
||||
SP13 wires the real ``paramiko``-backed SFTP.
|
||||
|
||||
Public API is unchanged across SP9 and SP13:
|
||||
* ``SftpClient.write_file(remote_path, content)`` — uploads bytes
|
||||
* ``SftpClient.list_inbound()`` — lists files in the inbound MFT path
|
||||
* ``SftpClient.read_file(remote_path)`` — downloads bytes
|
||||
* ``SftpClient.get_secret(name)`` — fetches the auth secret
|
||||
|
||||
Authentication is configured via ``SftpBlock.auth``:
|
||||
* ``{"password_keychain_account": "sftp.gainwell.password"}`` — fetch
|
||||
the password from Keychain (Gainwell's MFT model).
|
||||
* ``{"key_file": "/path/to/id_rsa", "key_passphrase_keychain_account": "..."}``
|
||||
— SSH private key (rare for MFT, but supported).
|
||||
|
||||
The block's ``stub`` flag still controls behavior: ``stub=true`` keeps
|
||||
the SP9 staging-dir behavior (useful for tests); ``stub=false`` uses
|
||||
real paramiko. There is no flag for "fail if no Keychain entry" — if
|
||||
the auth dict references a missing account, ``get_secret`` returns the
|
||||
stub secret and the paramiko auth will fail loudly at connect time.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Iterable, Iterator, Optional, Tuple
|
||||
|
||||
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 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).
|
||||
"""
|
||||
|
||||
# How long an SFTP connection may sit idle before we tear it down.
|
||||
# paramiko's default is None (no timeout); Gainwell's MFT drops
|
||||
# idle sessions after ~10 minutes so we recycle every 5.
|
||||
_IDLE_TIMEOUT_SECONDS = 5 * 60
|
||||
|
||||
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)
|
||||
return self._write_bytes_paramiko(remote_path, content)
|
||||
|
||||
def list_inbound(self) -> list[InboundFile]:
|
||||
"""List files in the inbound MFT path. Stub returns [] in stub mode.
|
||||
|
||||
Real mode downloads each file into the local inbound staging
|
||||
dir and returns :class:`InboundFile` records pointing at the
|
||||
cache copy. The remote file is *not* deleted — the operator
|
||||
archives inbound files in the MFT UI.
|
||||
"""
|
||||
if self._stub:
|
||||
return self._list_inbound_stub()
|
||||
return self._list_inbound_paramiko()
|
||||
|
||||
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."
|
||||
)
|
||||
return self._read_file_paramiko(remote_path)
|
||||
|
||||
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 (SP9) -------------------------------------
|
||||
|
||||
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
|
||||
|
||||
# ---- Real implementations (SP13) -------------------------------------
|
||||
|
||||
@contextmanager
|
||||
def _connect(self) -> Iterator[Tuple["object", "object"]]:
|
||||
"""Open a paramiko SSHClient and yield (ssh, sftp).
|
||||
|
||||
Closes the connection on context exit (caller wraps in
|
||||
``with self._connect() as (ssh, sftp):``). Auth resolves the
|
||||
password or private key from Keychain via the ``auth`` block.
|
||||
|
||||
Why we wrap the SSH client lifecycle here: paramiko caches
|
||||
host keys in ``~/.ssh/known_hosts`` by default; for MFT sites
|
||||
the operator may have a different key fingerprint than their
|
||||
workstation's. We accept the server's key on first connect
|
||||
(``AutoAddPolicy``) and warn — the operator should pin it for
|
||||
production.
|
||||
"""
|
||||
import paramiko
|
||||
|
||||
ssh = paramiko.SSHClient()
|
||||
ssh.load_system_host_keys()
|
||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
|
||||
auth = self._block.auth or {}
|
||||
password_account = auth.get("password_keychain_account")
|
||||
key_file = auth.get("key_file")
|
||||
key_passphrase_account = auth.get("key_passphrase_keychain_account")
|
||||
|
||||
connect_kwargs: dict = {
|
||||
"hostname": self._block.host,
|
||||
"port": self._block.port,
|
||||
"username": self._block.username,
|
||||
"timeout": 30,
|
||||
"allow_agent": False,
|
||||
"look_for_keys": False,
|
||||
}
|
||||
if password_account:
|
||||
password = self.get_secret(password_account)
|
||||
if password and password != secrets.STUB_SECRET:
|
||||
connect_kwargs["password"] = password
|
||||
else:
|
||||
# Don't attempt empty-password auth — fail loud.
|
||||
raise RuntimeError(
|
||||
f"SFTP: Keychain entry {password_account!r} missing or stub. "
|
||||
"Real SFTP wire-up requires the actual password."
|
||||
)
|
||||
elif key_file:
|
||||
pkey_kwargs: dict = {}
|
||||
if key_passphrase_account:
|
||||
passphrase = self.get_secret(key_passphrase_account)
|
||||
if passphrase and passphrase != secrets.STUB_SECRET:
|
||||
pkey_kwargs["password"] = passphrase
|
||||
connect_kwargs["key_filename"] = key_file
|
||||
if pkey_kwargs:
|
||||
connect_kwargs["pkey"] = paramiko.RSAKey.from_private_key_file(
|
||||
key_file, password=pkey_kwargs.get("password"),
|
||||
)
|
||||
else:
|
||||
raise RuntimeError(
|
||||
"SftpBlock.auth must contain either 'password_keychain_account' or 'key_file'"
|
||||
)
|
||||
|
||||
log.info("SFTP: connecting to %s:%d as %s", self._block.host, self._block.port, self._block.username)
|
||||
ssh.connect(**connect_kwargs)
|
||||
sftp = ssh.open_sftp()
|
||||
try:
|
||||
yield ssh, sftp
|
||||
finally:
|
||||
try:
|
||||
sftp.close()
|
||||
except Exception: # noqa: BLE001 — close errors are non-fatal
|
||||
pass
|
||||
try:
|
||||
ssh.close()
|
||||
except Exception: # noqa: BLE001 — close errors are non-fatal
|
||||
pass
|
||||
|
||||
def _write_bytes_paramiko(self, remote_path: str, content: bytes) -> Path:
|
||||
"""Upload ``content`` to ``remote_path`` via paramiko SFTP.
|
||||
|
||||
Returns ``remote_path`` as a :class:`Path` (Posix-style) for
|
||||
API symmetry with the stub. The actual write uses paramiko's
|
||||
``SFTPFile.open(..., "wb")`` and an in-memory BytesIO buffer
|
||||
so we don't have to materialize a local temp file.
|
||||
"""
|
||||
# Lazy import so the stub-only test path doesn't need paramiko.
|
||||
with self._connect() as (ssh, sftp):
|
||||
# Ensure the parent dir exists on the remote. Gainwell's
|
||||
# MFT has pre-created the FromHPE/ToHPE dirs, but creating
|
||||
# them again is harmless and idempotent.
|
||||
parent = "/".join(remote_path.rstrip("/").split("/")[:-1])
|
||||
if parent:
|
||||
try:
|
||||
sftp.mkdir(parent)
|
||||
except IOError:
|
||||
# Already exists — fine.
|
||||
pass
|
||||
with sftp.open(remote_path, "wb") as f:
|
||||
f.write(content)
|
||||
log.info("SFTP: wrote %d bytes to %s", len(content), remote_path)
|
||||
# Return a PosixPath so the API response shape matches the
|
||||
# stub (which returns a real local Path).
|
||||
return Path(remote_path)
|
||||
|
||||
def _list_inbound_paramiko(self) -> list[InboundFile]:
|
||||
"""List inbound MFT files via paramiko; cache each into local staging."""
|
||||
with self._connect() as (ssh, sftp):
|
||||
inbound_dir = self._block.paths.get("inbound", "/")
|
||||
staging = Path(self._block.staging_dir).resolve()
|
||||
inbound_rel = inbound_dir.lstrip("/")
|
||||
cache_dir = staging / inbound_rel
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
files: list[InboundFile] = []
|
||||
try:
|
||||
attrs = sftp.listdir_attr(inbound_dir)
|
||||
except IOError as exc:
|
||||
log.warning("SFTP: cannot list %s: %s", inbound_dir, exc)
|
||||
return []
|
||||
|
||||
for attr in sorted(attrs, key=lambda a: a.filename):
|
||||
if attr.st_mode and (attr.st_mode & 0o170000) == 0o040000:
|
||||
# Directory entry — skip.
|
||||
continue
|
||||
remote = f"{inbound_dir.rstrip('/')}/{attr.filename}"
|
||||
cache_path = cache_dir / attr.filename
|
||||
# Download into cache. We use ``prefetch`` to keep memory
|
||||
# bounded for large 999s/TA1s (rarely >100KB in practice
|
||||
# but the API supports it).
|
||||
with sftp.open(remote, "rb") as src, open(cache_path, "wb") as dst:
|
||||
shutil.copyfileobj(src, dst, length=64 * 1024)
|
||||
files.append(InboundFile(
|
||||
name=attr.filename,
|
||||
size=attr.st_size or cache_path.stat().st_size,
|
||||
modified_at=datetime.fromtimestamp(attr.st_mtime or 0),
|
||||
local_path=cache_path,
|
||||
))
|
||||
return files
|
||||
|
||||
def _read_file_paramiko(self, remote_path: str) -> bytes:
|
||||
with self._connect() as (ssh, sftp):
|
||||
buf = io.BytesIO()
|
||||
with sftp.open(remote_path, "rb") as f:
|
||||
shutil.copyfileobj(f, buf, length=64 * 1024)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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)
|
||||
+189
-1
@@ -57,7 +57,26 @@ def _resolve_url() -> str:
|
||||
|
||||
|
||||
def _make_engine(url: str) -> sa.Engine:
|
||||
"""Build an Engine with sensible defaults for SQLite + FastAPI."""
|
||||
"""Build an Engine with sensible defaults for SQLite + FastAPI.
|
||||
|
||||
SP12: when ``cyclone.db_crypto.is_encryption_enabled()`` returns
|
||||
True, swap the underlying driver to ``sqlcipher3`` and apply the
|
||||
Keychain-stored key via a connect-time PRAGMA. Otherwise the
|
||||
plain sqlite3 driver is used (current behavior, no surprises for
|
||||
operators who haven't set up Keychain yet).
|
||||
"""
|
||||
from cyclone import db_crypto # late import to avoid cycles
|
||||
|
||||
if url.startswith("sqlite") and db_crypto.is_encryption_enabled():
|
||||
key = db_crypto.get_db_key()
|
||||
if key:
|
||||
creator = db_crypto.make_sqlcipher_connect_creator(url, key)
|
||||
return sa.create_engine(
|
||||
url,
|
||||
creator=creator,
|
||||
future=True,
|
||||
)
|
||||
|
||||
connect_args: dict[str, object] = {}
|
||||
if url.startswith("sqlite"):
|
||||
connect_args = {"check_same_thread": False}
|
||||
@@ -205,6 +224,21 @@ class Claim(Base):
|
||||
rejected_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
# SP10: payer-side rejection (277CA STC A4/A6/A7) — distinct from
|
||||
# the 999 envelope rejection above. A claim can be rejected at the
|
||||
# envelope level (bad file) or at the payer-adjudication level
|
||||
# (good file, bad claim). We track them separately so the Inbox
|
||||
# Payer-Rejected lane can distinguish.
|
||||
payer_rejected_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
payer_rejected_reason: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
payer_rejected_status_code: Mapped[Optional[str]] = mapped_column(
|
||||
String(8), nullable=True
|
||||
)
|
||||
payer_rejected_by_277ca_id: Mapped[Optional[str]] = mapped_column(
|
||||
String(64), nullable=True
|
||||
)
|
||||
resubmit_count: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, default=0, server_default=text("0")
|
||||
)
|
||||
@@ -517,3 +551,157 @@ class Ta1Ack(Base):
|
||||
Index("ix_ta1_acks_source_batch_id", "source_batch_id"),
|
||||
Index("ix_ta1_acks_ack_code", "ack_code"),
|
||||
)
|
||||
|
||||
|
||||
class Two77caAck(Base):
|
||||
"""277CA (Claim Acknowledgment) row — one per parsed 277CA file.
|
||||
|
||||
Mirrors :class:`Ta1Ack` but for the *semantic* claim-level ack.
|
||||
A 277CA acknowledges individual claims by REF*1K
|
||||
(payer_claim_control_number) rather than the whole envelope.
|
||||
|
||||
Per X12 005010X214 a single 277CA can carry many claim statuses;
|
||||
we keep the per-claim detail in ``raw_json`` and promote only the
|
||||
counts + ICN here so the list endpoint stays fast.
|
||||
|
||||
``source_batch_id`` uses the synthetic ``277CA-<ISA13>`` id — same
|
||||
FK-is-no-op convention as the 999 / TA1 paths. The 277CA itself
|
||||
never has an inbound Cyclone batch to point at.
|
||||
"""
|
||||
|
||||
__tablename__ = "two77ca_acks"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
source_batch_id: Mapped[str] = mapped_column(
|
||||
String(32), ForeignKey("batches.id", ondelete="CASCADE"), nullable=False,
|
||||
)
|
||||
control_number: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
accepted_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
rejected_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
paid_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
pended_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
parsed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
raw_json: Mapped[Optional[dict]] = mapped_column(JSONText, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_two77ca_acks_source_batch_id", "source_batch_id"),
|
||||
Index("ix_two77ca_acks_control_number", "control_number"),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SP11: tamper-evident hash-chained audit_log
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class AuditLog(Base):
|
||||
"""One row per audit event. Append-only by convention.
|
||||
|
||||
Each row's :attr:`hash` is SHA-256 of
|
||||
``(id, event_type, entity_type, entity_id, actor, payload_json,
|
||||
created_at, prev_hash)`` — and :attr:`prev_hash` is the previous
|
||||
row's ``hash``. That forms a tamper-evident chain: changing any
|
||||
row's payload invalidates every subsequent row's hash.
|
||||
|
||||
See ``cyclone.audit_log.append_event`` and ``verify_chain`` for
|
||||
the append + verify operations. The application code MUST NOT
|
||||
UPDATE or DELETE rows; doing so breaks the chain and is
|
||||
auditable via :func:`verify_chain`.
|
||||
"""
|
||||
|
||||
__tablename__ = "audit_log"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
event_type: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
entity_type: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
entity_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
actor: Mapped[str] = mapped_column(String(64), nullable=False, default="system")
|
||||
payload_json: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
prev_hash: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
hash: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_audit_log_entity", "entity_type", "entity_id"),
|
||||
Index("idx_audit_log_event_type", "event_type"),
|
||||
Index("idx_audit_log_created_at", "created_at"),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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,162 @@
|
||||
"""SQLCipher integration — encryption at rest for the SQLite DB.
|
||||
|
||||
SP12.
|
||||
|
||||
When ``cyclone.db.key`` is present in the macOS Keychain and the
|
||||
``sqlcipher3`` Python package is installed, the database file is
|
||||
encrypted with SQLCipher (AES-256). Without the key, the DB falls back
|
||||
to plain SQLite — operators who haven't set up Keychain yet see no
|
||||
behavior change.
|
||||
|
||||
Why this design:
|
||||
- The DB key never lives on disk in plaintext. It's stored in macOS
|
||||
Keychain under service ``cyclone``, account ``cyclone.db.key``.
|
||||
Operators create the entry one-time via ``security add-generic-password``
|
||||
(see docs/reference/co-medicaid.md §"Keychain setup").
|
||||
- We don't *require* SQLCipher at import time. ``sqlcipher3`` is an
|
||||
optional dependency — when it's not installed we log a warning and
|
||||
fall back to plain SQLite. This keeps the test suite green on
|
||||
Linux dev boxes where SQLCipher's C build is non-trivial.
|
||||
- The encryption key is applied via a SQLAlchemy connect event so
|
||||
every connection (including the migration runner and test fixtures)
|
||||
gets the same PRAGMA. We never store the key in a Python global.
|
||||
|
||||
Compliance: HIPAA §164.312(a)(2)(iv) — encryption at rest. §164.312(d)
|
||||
— person/entity authentication (Keychain is the operator's macOS login).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
import sqlalchemy as sa
|
||||
import sqlalchemy.event
|
||||
|
||||
from cyclone.secrets import STUB_SECRET, get_secret
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Keychain account name for the DB encryption key.
|
||||
KEYCHAIN_ACCOUNT = "cyclone.db.key"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Capability checks
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def is_sqlcipher_available() -> bool:
|
||||
"""Return True if the ``sqlcipher3`` package is importable.
|
||||
|
||||
We import lazily so the check doesn't fail at module import time
|
||||
on systems that don't have SQLCipher built.
|
||||
"""
|
||||
try:
|
||||
import sqlcipher3 # noqa: F401
|
||||
return True
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
|
||||
def is_encryption_enabled() -> bool:
|
||||
"""Return True when SQLCipher is available AND a DB key exists in Keychain.
|
||||
|
||||
Both conditions must hold. SQLCipher without a key is useless (we'd
|
||||
just be running encrypted with a stub secret), and a key without
|
||||
SQLCipher means we silently degrade to plain SQLite (we'd warn).
|
||||
"""
|
||||
if not is_sqlcipher_available():
|
||||
return False
|
||||
key = get_secret(KEYCHAIN_ACCOUNT)
|
||||
return bool(key) and key != STUB_SECRET
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Key retrieval
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def get_db_key() -> str | None:
|
||||
"""Return the SQLCipher DB key from Keychain, or ``None`` if not set.
|
||||
|
||||
``None`` means "fall back to plain SQLite". This is the only
|
||||
function that reads the key — the engine builder passes the
|
||||
result directly to the connect creator without storing it.
|
||||
"""
|
||||
key = get_secret(KEYCHAIN_ACCOUNT)
|
||||
if not key or key == STUB_SECRET:
|
||||
return None
|
||||
return key
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Engine wiring
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def make_sqlcipher_connect_creator(url: str, key: str):
|
||||
"""Return a SQLAlchemy connect creator that opens via ``sqlcipher3``.
|
||||
|
||||
SQLAlchemy's ``creator`` hook expects a zero-arg callable. We
|
||||
capture the SQLite URL (extracted from the SQLAlchemy URL) in the
|
||||
closure and pass it to ``sqlcipher3.connect()`` at every new
|
||||
pool connection.
|
||||
|
||||
Why a creator and not a pool event: SQLAlchemy's creator is the
|
||||
canonical hook for swapping out the DB-API module. The connect
|
||||
event would require us to first open a plain connection and then
|
||||
upgrade it, which doesn't work for SQLCipher because the
|
||||
encryption happens at the driver level.
|
||||
"""
|
||||
import sqlcipher3 # late import — only needed when encryption is on
|
||||
|
||||
# Strip the ``sqlite:///`` prefix; SQLCipher takes a plain path.
|
||||
if url.startswith("sqlite:///"):
|
||||
db_path = url[len("sqlite:///"):]
|
||||
elif url.startswith("sqlite://"):
|
||||
db_path = url[len("sqlite://"):]
|
||||
else:
|
||||
# In-memory or other — leave the URL alone.
|
||||
db_path = url
|
||||
|
||||
def _creator() -> sqlite3.Connection:
|
||||
# SQLCipher's PRAGMA key must be the FIRST statement issued
|
||||
# on a connection — before any other read or write.
|
||||
conn = sqlcipher3.connect(db_path)
|
||||
# SQLCipher accepts hex-encoded keys with ``PRAGMA key = "x'..'"``
|
||||
# but the simpler ``PRAGMA key = "..."`` form uses PBKDF2 with
|
||||
# an empty salt — adequate for a key generated by the operator
|
||||
# (random 32 bytes from /dev/urandom is what we recommend).
|
||||
conn.execute(f'PRAGMA key = "{key}"')
|
||||
return conn
|
||||
|
||||
return _creator
|
||||
|
||||
|
||||
def configure_engine_for_encryption(engine: sa.Engine, key: str) -> None:
|
||||
"""Attach the SQLCipher PRAGMA hook to a SQLAlchemy engine.
|
||||
|
||||
After this call, every new connection opens via the
|
||||
``sqlcipher3`` driver with the given key applied. Idempotent —
|
||||
safe to call once per engine. We use ``connect`` (not ``pool_connect``)
|
||||
so the key is applied at connection open time, before any other
|
||||
statement.
|
||||
"""
|
||||
creator = make_sqlcipher_connect_creator(key)
|
||||
|
||||
# Swap the underlying driver. SQLAlchemy calls ``creator(dbapi_connection_url)``
|
||||
# for each new pool connection. The ``url`` argument is the path
|
||||
# string after ``sqlite:///`` (e.g. ``/path/to/cyclone.db``).
|
||||
@sa.event.listens_for(engine, "connect")
|
||||
def _on_connect(dbapi_connection, connection_record): # noqa: ANN001
|
||||
# The engine already routed this connection through the
|
||||
# creator, which applied PRAGMA key. We could re-issue here
|
||||
# for paranoia, but it's not needed.
|
||||
pass
|
||||
|
||||
# Replace the pool's creator. SQLAlchemy 2.0 exposes this on the
|
||||
# pool; setting ``creator`` directly is supported but deprecated.
|
||||
# Instead we use the dialect-level hook.
|
||||
engine.pool._creator = creator # type: ignore[attr-defined]
|
||||
log.info("SQLCipher encryption enabled (db key in Keychain)")
|
||||
@@ -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
|
||||
@@ -1,9 +1,12 @@
|
||||
"""Compute the four Inbox lanes from the DB on read.
|
||||
"""Compute the Inbox lanes from the DB on read.
|
||||
|
||||
SP6 T6.
|
||||
|
||||
Lanes:
|
||||
- rejected: claims whose 999 AK5 set-level response was R/E
|
||||
- payer_rejected: claims whose 277CA STC category is A4/A6/A7
|
||||
(SP10: payer-side rejection, distinct from 999
|
||||
envelope rejection)
|
||||
- candidates: remits without a matched claim, with scoreable claims
|
||||
- unmatched: claims still SUBMITTED with no remittance in flight
|
||||
- done_today: claims that reached a terminal state in the last 24h
|
||||
@@ -23,6 +26,7 @@ from cyclone.scoring import score_pair, ScoreBreakdown
|
||||
@dataclass
|
||||
class Lanes:
|
||||
rejected: list[dict] = field(default_factory=list)
|
||||
payer_rejected: list[dict] = field(default_factory=list)
|
||||
candidates: list[dict] = field(default_factory=list)
|
||||
unmatched: list[dict] = field(default_factory=list)
|
||||
done_today: list[dict] = field(default_factory=list)
|
||||
@@ -63,6 +67,12 @@ def _claim_to_row(
|
||||
"provider": score.provider,
|
||||
} if score else None,
|
||||
"matched_remittance": matched_remittance,
|
||||
# SP10: payer-side rejection fields. Populated when a 277CA
|
||||
# acknowledged the claim with STC A4/A6/A7.
|
||||
"payer_rejected_at": _isoformat(c.payer_rejected_at),
|
||||
"payer_rejected_reason": c.payer_rejected_reason,
|
||||
"payer_rejected_status_code": c.payer_rejected_status_code,
|
||||
"payer_rejected_by_277ca_id": c.payer_rejected_by_277ca_id,
|
||||
}
|
||||
|
||||
|
||||
@@ -176,6 +186,26 @@ def compute_lanes(session: Session, *, dismissed_pairs: Iterable[frozenset]) ->
|
||||
),
|
||||
))
|
||||
|
||||
# --- Payer-Rejected (SP10) ---
|
||||
# Distinct from the 999 envelope "rejected" lane above. A claim
|
||||
# lands here when a 277CA STC category code is A4/A6/A7 (rejected
|
||||
# by the payer after we submitted a syntactically-valid file).
|
||||
# We don't filter by Claim.state here because the claim may still
|
||||
# be in SUBMITTED state — the payer just hasn't paid it yet.
|
||||
payer_rejected_claims = (
|
||||
session.query(Claim).filter(Claim.payer_rejected_at.is_not(None)).all()
|
||||
)
|
||||
pr_matched, pr_total = _line_count_lookup(session, payer_rejected_claims)
|
||||
matched_counts.update(pr_matched)
|
||||
total_lines_by_claim.update(pr_total)
|
||||
for c in payer_rejected_claims:
|
||||
lanes.payer_rejected.append(_claim_to_row(
|
||||
c, kind="claim",
|
||||
matched_remittance=_matched_remittance_block(
|
||||
session, c, matched_counts, total_lines_by_claim,
|
||||
),
|
||||
))
|
||||
|
||||
# --- Done today ---
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(hours=24)
|
||||
terminal_states = {
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"""277CA Claim Acknowledgment → claim payer-rejection state transitions.
|
||||
|
||||
SP10 T2.
|
||||
|
||||
For each ``ClaimStatus`` in a parsed 277CA whose ``classification``
|
||||
is ``"rejected"`` (STC category codes A4 / A6 / A7), look up the
|
||||
matching Cyclone claim row by ``payer_claim_control_number`` and stamp
|
||||
``payer_rejected_at`` + ``payer_rejected_reason`` + the STC category
|
||||
code + the originating 277CA row id.
|
||||
|
||||
Distinct from :func:`cyclone.inbox_state.apply_999_rejections` (which
|
||||
handles envelope-level 999 AK5 R/E rejections). A claim can be:
|
||||
* rejected at the envelope level only (999 R, no 277CA yet)
|
||||
* payer-rejected only (999 A, then 277CA STC A6 — file was fine,
|
||||
claim was denied)
|
||||
* both (rare — envelope retry after payer rejection)
|
||||
|
||||
We never overwrite a previous ``payer_rejected_at`` with ``NULL``
|
||||
(empty STC list) — that prevents a later, looser 277CA from
|
||||
accidentally clearing the rejection flag.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Callable
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from cyclone.db import Claim
|
||||
|
||||
|
||||
@dataclass
|
||||
class Apply277CAResult:
|
||||
matched: list[str] = field(default_factory=list)
|
||||
orphans: list[str] = field(default_factory=list)
|
||||
already_rejected: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def _build_reason(status_code: str, status_description: str | None) -> str:
|
||||
parts = [f"277CA STC {status_code}"]
|
||||
if status_description:
|
||||
parts.append(f"({status_description})")
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def apply_277ca_rejections(
|
||||
session: Session,
|
||||
parsed_277ca,
|
||||
*,
|
||||
claim_lookup: Callable[[str], Claim | None],
|
||||
two77ca_id: str,
|
||||
) -> Apply277CAResult:
|
||||
"""For each rejected ``ClaimStatus``, look up the matching claim and stamp it.
|
||||
|
||||
Args:
|
||||
session: SQLAlchemy session.
|
||||
parsed_277ca: a :class:`cyclone.parsers.models_277ca.ParseResult277CA`.
|
||||
claim_lookup: callable from ``payer_claim_control_number`` →
|
||||
``Claim`` or ``None``. Use REF*1K (the payer claim control
|
||||
number) — that's the canonical cross-reference.
|
||||
two77ca_id: the id of the persisted 277CA row that produced
|
||||
this parsed result. Stored on each claim so an operator
|
||||
can trace the rejection back to the source file.
|
||||
|
||||
Returns:
|
||||
Apply277CAResult with matched/orphan/already-rejected lists.
|
||||
"""
|
||||
result = Apply277CAResult()
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
for status in parsed_277ca.claim_statuses:
|
||||
if status.classification != "rejected":
|
||||
continue
|
||||
pcn = status.payer_claim_control_number
|
||||
if not pcn:
|
||||
# No REF*1K — we can't tie this rejection to a Cyclone claim.
|
||||
# Surface it as an orphan so the operator knows we saw it.
|
||||
result.orphans.append(status.status_code or "")
|
||||
continue
|
||||
|
||||
claim = claim_lookup(pcn)
|
||||
if claim is None:
|
||||
result.orphans.append(pcn)
|
||||
continue
|
||||
|
||||
if claim.payer_rejected_at is not None:
|
||||
# Idempotent — already stamped. Update only if this is a
|
||||
# newer status code (e.g. A6 → A7 escalation).
|
||||
if (claim.payer_rejected_status_code or "") == status.status_code:
|
||||
result.already_rejected.append(claim.id)
|
||||
continue
|
||||
|
||||
claim.payer_rejected_at = now
|
||||
claim.payer_rejected_reason = _build_reason(
|
||||
status.status_code, status.status_description,
|
||||
)
|
||||
claim.payer_rejected_status_code = status.status_code
|
||||
claim.payer_rejected_by_277ca_id = two77ca_id
|
||||
result.matched.append(claim.id)
|
||||
|
||||
if result.matched or result.already_rejected:
|
||||
session.commit()
|
||||
|
||||
return result
|
||||
|
||||
|
||||
__all__ = ["Apply277CAResult", "apply_277ca_rejections"]
|
||||
@@ -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);
|
||||
@@ -0,0 +1,36 @@
|
||||
-- version: 8
|
||||
-- SP10: 277CA Payer-Rejected lane
|
||||
-- Adds columns to capture the payer-side rejection state separate from
|
||||
-- the existing 999-envelope rejection (file-level). A claim can be:
|
||||
-- * REJECTED via 999 ACK AK5 R/E (envelope rejection, already covered
|
||||
-- by rejected_at + rejection_reason from migration 0004)
|
||||
-- * PAYER_REJECTED via 277CA STC A4/A6/A7 (claim-level rejection by
|
||||
-- the payer after envelope acceptance)
|
||||
-- We keep these distinct so operators can tell *why* a claim isn't
|
||||
-- paid: was it our file (999), or was it the payer's adjudication?
|
||||
--
|
||||
-- Also creates two77ca_acks to persist parsed 277CA files (one row per
|
||||
-- inbound 277CA file, with the per-claim status detail in raw_json).
|
||||
|
||||
ALTER TABLE claims ADD COLUMN payer_rejected_at TEXT;
|
||||
ALTER TABLE claims ADD COLUMN payer_rejected_reason TEXT;
|
||||
ALTER TABLE claims ADD COLUMN payer_rejected_status_code TEXT;
|
||||
ALTER TABLE claims ADD COLUMN payer_rejected_by_277ca_id TEXT;
|
||||
|
||||
CREATE INDEX idx_claims_payer_rejected_at ON claims(payer_rejected_at);
|
||||
|
||||
CREATE TABLE two77ca_acks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
source_batch_id TEXT NOT NULL REFERENCES batches(id) ON DELETE CASCADE,
|
||||
control_number TEXT NOT NULL,
|
||||
accepted_count INTEGER NOT NULL DEFAULT 0,
|
||||
rejected_count INTEGER NOT NULL DEFAULT 0,
|
||||
paid_count INTEGER NOT NULL DEFAULT 0,
|
||||
pended_count INTEGER NOT NULL DEFAULT 0,
|
||||
parsed_at TEXT NOT NULL,
|
||||
raw_json TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX ix_two77ca_acks_source_batch_id ON two77ca_acks(source_batch_id);
|
||||
CREATE INDEX ix_two77ca_acks_control_number ON two77ca_acks(control_number);
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
-- version: 9
|
||||
-- SP11: tamper-evident hash-chained audit_log
|
||||
--
|
||||
-- Each row carries a SHA-256 hash of (id, event_type, entity_type,
|
||||
-- entity_id, actor, payload_json, created_at, prev_hash). The prev_hash
|
||||
-- field chains the row to the previous row's hash — a tamper-evident
|
||||
-- Merkle-like chain (no tree, just a list).
|
||||
--
|
||||
-- Append-only by convention: no UPDATE/DELETE in the application code.
|
||||
-- Compliance: HIPAA §164.316(b)(2) requires 6-year retention. We don't
|
||||
-- enforce retention in the schema (no TTL), but a separate vacuum
|
||||
-- job (out of scope here) can prune rows older than 6 years after
|
||||
-- exporting them to cold storage.
|
||||
--
|
||||
-- Indexes: (entity_type, entity_id) for "show me the audit trail for
|
||||
-- this claim"; (event_type) for "show me all clearhouse.submitted
|
||||
-- events"; (created_at) for time-range scans.
|
||||
|
||||
CREATE TABLE audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
event_type TEXT NOT NULL,
|
||||
entity_type TEXT NOT NULL,
|
||||
entity_id TEXT NOT NULL,
|
||||
actor TEXT NOT NULL DEFAULT 'system',
|
||||
payload_json TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
prev_hash TEXT NOT NULL,
|
||||
hash TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_audit_log_entity ON audit_log(entity_type, entity_id);
|
||||
CREATE INDEX idx_audit_log_event_type ON audit_log(event_type);
|
||||
CREATE INDEX idx_audit_log_created_at ON audit_log(created_at);
|
||||
@@ -66,6 +66,15 @@ _LAZY_EXPORTS: dict[str, str] = {
|
||||
"SERVICE_TYPE_CODES": "cyclone.parsers.models_271",
|
||||
"LAST_UPDATED": "cyclone.parsers.models_271",
|
||||
"service_type_description": "cyclone.parsers.models_271",
|
||||
# models (277CA — SP10)
|
||||
"ACCEPTED_CODES": "cyclone.parsers.models_277ca",
|
||||
"AcknowledgmentHeader277": "cyclone.parsers.models_277ca",
|
||||
"ClaimStatus": "cyclone.parsers.models_277ca",
|
||||
"PAID_CODES": "cyclone.parsers.models_277ca",
|
||||
"PENDED_CODES": "cyclone.parsers.models_277ca",
|
||||
"ParseResult277CA": "cyclone.parsers.models_277ca",
|
||||
"REJECTED_CODES": "cyclone.parsers.models_277ca",
|
||||
"classify_status_code": "cyclone.parsers.models_277ca",
|
||||
# CARC lookup (SP3 P2 T6)
|
||||
"reason_label": "cyclone.parsers.cas_codes",
|
||||
"all_known_codes": "cyclone.parsers.cas_codes",
|
||||
@@ -81,6 +90,7 @@ _LAZY_EXPORTS: dict[str, str] = {
|
||||
"parse_999": "cyclone.parsers.parse_999",
|
||||
"parse_270": "cyclone.parsers.parse_270",
|
||||
"parse_271": "cyclone.parsers.parse_271",
|
||||
"parse_277ca": "cyclone.parsers.parse_277ca",
|
||||
"serialize_999": "cyclone.parsers.serialize_999",
|
||||
"serialize_270": "cyclone.parsers.serialize_270",
|
||||
"build_ack_for_batch": "cyclone.parsers.batch_ack_builder",
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
"""Pydantic v2 models for parsed 277CA (Claim Acknowledgment) files.
|
||||
|
||||
Mirrors the X12 005010X214 segment shape (HCPF sends this transaction
|
||||
back after we submit an 837P batch):
|
||||
|
||||
- ``AcknowledgmentHeader`` (BHT) — beginning of hierarchical transaction
|
||||
- ``ClaimStatus`` (STC + REF*1K + REF*EJ + AMT*YU + DTP*472) — one per
|
||||
payer-acknowledged claim. Carries the category/status code and the
|
||||
payer claim control number used to match back to a Cyclone claim row.
|
||||
- ``ParseResult277CA`` — top-level envelope + per-claim status list.
|
||||
|
||||
Why this lean shape: the 277CA is a status-reporting transaction, not a
|
||||
claim. We surface just enough information to (a) match each STC row back
|
||||
to a Cyclone claim by ``payer_claim_control_number`` (REF*1K) and
|
||||
(b) classify it as accepted / pended / rejected / paid so the Inbox
|
||||
Payer-Rejected lane can light up.
|
||||
|
||||
Per HCPF X12 File Naming Standards, the inbound filename uses ``277``
|
||||
in the file_type slot (the transaction-set id ``277CA`` is conveyed in
|
||||
the ST segment itself, not in the filename).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_serializer
|
||||
|
||||
from cyclone.parsers.models import BatchSummary, Envelope, ValidationIssue, ValidationReport
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Shared base
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class _Base(BaseModel):
|
||||
"""Shared Pydantic base; matches the 999 / 835 / 270 / 271 models for JSON consistency."""
|
||||
|
||||
model_config = ConfigDict(extra="ignore", str_strip_whitespace=True)
|
||||
|
||||
@model_serializer(mode="wrap")
|
||||
def _serialize(self, handler): # type: ignore[no-untyped-def]
|
||||
data = handler(self)
|
||||
for key, value in data.items():
|
||||
if isinstance(value, date):
|
||||
data[key] = value.isoformat()
|
||||
return data
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 277CA segment shapes
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
# Status category codes per X12 005010X214 §1.4.2 STC01-1.
|
||||
# We categorize each STC into a small set of lanes the Inbox cares about.
|
||||
ACCEPTED_CODES = {"A1", "A2", "A3"}
|
||||
REJECTED_CODES = {"A4", "A6", "A7"}
|
||||
PENDED_CODES = {"A8", "A9"}
|
||||
PAID_CODES = {"P1", "P2", "P3", "P4", "P5"}
|
||||
|
||||
|
||||
def classify_status_code(code: str) -> Literal["accepted", "rejected", "pended", "paid", "unknown"]:
|
||||
"""Map an STC01-1 category code to an Inbox lane label.
|
||||
|
||||
Unknown codes (rare; new payer-specific codes) become ``"unknown"``
|
||||
so the parser doesn't raise — operators can still see them in the
|
||||
277CA detail view.
|
||||
"""
|
||||
code = (code or "").strip().upper()
|
||||
if code in ACCEPTED_CODES:
|
||||
return "accepted"
|
||||
if code in REJECTED_CODES:
|
||||
return "rejected"
|
||||
if code in PENDED_CODES:
|
||||
return "pended"
|
||||
if code in PAID_CODES:
|
||||
return "paid"
|
||||
return "unknown"
|
||||
|
||||
|
||||
class AcknowledgmentHeader(_Base):
|
||||
"""BHT — Beginning of Hierarchical Transaction.
|
||||
|
||||
BHT01 = hierarchical_structure_code (e.g. "0085" for Claim Ack).
|
||||
BHT02 = transaction_set_purpose_code (e.g. "08" for Status).
|
||||
BHT03 = reference_identification (often the submitter batch id).
|
||||
BHT04 = transaction_set_creation_date (CCYYMMDD).
|
||||
BHT05 = transaction_set_creation_time (HHMM).
|
||||
BHT06 = transaction_type_code (e.g. "TH").
|
||||
"""
|
||||
|
||||
hierarchical_structure_code: str
|
||||
transaction_set_purpose_code: str
|
||||
reference_identification: str | None = None
|
||||
transaction_set_creation_date: date | None = None
|
||||
transaction_set_creation_time: str | None = None
|
||||
transaction_type_code: str | None = None
|
||||
|
||||
|
||||
class ClaimStatus(_Base):
|
||||
"""STC + REF*1K + REF*EJ + AMT*YU + DTP*472 — one payer-acknowledged claim.
|
||||
|
||||
STC01 is a composite of three elements: ``category_code:status_code:entity_identifier``.
|
||||
HCPF only populates the category code (e.g. ``A3:19:PR``); the parser
|
||||
surfaces ``category_code`` and ``status_code`` separately.
|
||||
|
||||
``payer_claim_control_number`` is the REF*1K value the payer assigned
|
||||
(or echoed back) for the claim. This is what matches back to a
|
||||
Cyclone claim row (we store it on Claim.payer_claim_control_number
|
||||
during 837 serialize).
|
||||
|
||||
``amount`` is the AMT*YU value (total claim charge amount
|
||||
acknowledged by the payer). Optional — HCPF omits it for non-claim
|
||||
statuses (e.g. subscriber-level pends).
|
||||
"""
|
||||
|
||||
status_code: str
|
||||
status_description: str | None = None
|
||||
entity_identifier: str | None = None
|
||||
status_effective_date: date | None = None
|
||||
status_action_code: str | None = None
|
||||
total_claim_charge_amount: float | None = None
|
||||
payer_claim_control_number: str | None = None
|
||||
billing_provider_tax_id: str | None = None
|
||||
service_date: date | None = None
|
||||
classification: Literal["accepted", "rejected", "pended", "paid", "unknown"] = "unknown"
|
||||
|
||||
|
||||
class ParseResult277CA(_Base):
|
||||
"""Top-level parsed 277CA document.
|
||||
|
||||
Multiple claims can be acknowledged in a single 277CA; the parser
|
||||
flattens the HL hierarchy (20→21→19→PT) into one ClaimStatus per
|
||||
Patient-level HL. Subscriber- and provider-level statuses (when no
|
||||
Patient HL exists) are also captured under ``unscoped_statuses``
|
||||
for operator visibility.
|
||||
"""
|
||||
|
||||
envelope: Envelope
|
||||
bht: AcknowledgmentHeader
|
||||
claim_statuses: list[ClaimStatus] = Field(default_factory=list)
|
||||
unscoped_statuses: list[ClaimStatus] = Field(default_factory=list)
|
||||
summary: BatchSummary
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ACCEPTED_CODES",
|
||||
"AcknowledgmentHeader",
|
||||
"ClaimStatus",
|
||||
"PAID_CODES",
|
||||
"PENDED_CODES",
|
||||
"ParseResult277CA",
|
||||
"REJECTED_CODES",
|
||||
"classify_status_code",
|
||||
]
|
||||
|
||||
|
||||
# Silence unused-import lints.
|
||||
_ = (BaseModel, ValidationIssue, ValidationReport)
|
||||
@@ -0,0 +1,355 @@
|
||||
"""Parse an X12 277CA (Claim Acknowledgment) file.
|
||||
|
||||
The 277CA is the *semantic* ack a payer (or its clearinghouse) sends
|
||||
back after they accept our 837P batch. It contrasts with the 999 ACK
|
||||
which only reports the *syntactic* envelope status. A 999 "Accepted"
|
||||
plus a 277CA with STC*A6 means: the file was syntactically valid, but
|
||||
one or more specific claims were rejected by the payer.
|
||||
|
||||
Layout (simplified, single claim)::
|
||||
|
||||
ISA*…~
|
||||
GS*HN*…*…*20240620*1200*1*X*005010X214~
|
||||
ST*277*0001*005010X214~ (HCPF sometimes sends ST*277CA*0001*005010X214)
|
||||
BHT*0085*08*REFNUM*20240620*1200*TH~
|
||||
HL*1**20*1~ (Information Source — the payer)
|
||||
NM1*PR*2*COLORADO MEDICAL ASSISTANCE PROGRAM*****PI*COMEDASSISTPROG~
|
||||
HL*2*1*21*1~ (Information Receiver — us)
|
||||
NM1*41*2*DZINESCO*****46*DZINESCO~
|
||||
HL*3*2*19*1~ (Subscriber)
|
||||
NM1*IL*1*DOE*JOHN****MI*MEMBERID~
|
||||
HL*4*3*PT~ (Patient — a claim follows)
|
||||
NM1*QC*1*DOE*JANE~
|
||||
REF*1K*PAYERCLAIMID123~
|
||||
REF*EJ*721587149~
|
||||
STC*A6:19:PR*20240620*U*150.00~ (rejected)
|
||||
QTY*90*1~
|
||||
AMT*YU*150.00~
|
||||
DTP*472*RD8*20240601-20240601~
|
||||
SE*XX*0001~
|
||||
GE*1*1~
|
||||
IEA*1*000000001~
|
||||
|
||||
The parser walks the HL hierarchy and, for each ``HL*…*PT`` segment,
|
||||
collects the trailing ``STC*``, ``REF*1K``, ``REF*EJ``, ``AMT*YU`` and
|
||||
``DTP*472`` siblings into a :class:`ClaimStatus`. STC segments under
|
||||
non-Patient HLs (provider- or subscriber-level) go into
|
||||
``unscoped_statuses`` — HCPF sometimes sends a blanket reject at the
|
||||
subscriber level which we want to surface even though we can't tie it
|
||||
to a specific Cyclone claim row.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import date
|
||||
|
||||
from cyclone.parsers.exceptions import CycloneParseError
|
||||
from cyclone.parsers.models import BatchSummary, Envelope
|
||||
from cyclone.parsers.models_277ca import (
|
||||
AcknowledgmentHeader,
|
||||
ClaimStatus,
|
||||
ParseResult277CA,
|
||||
classify_status_code,
|
||||
)
|
||||
from cyclone.parsers.segments import tokenize
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Date / amount parsing
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _parse_yyyymmdd(s: str) -> date | None:
|
||||
if not s or len(s) != 8 or not s.isdigit():
|
||||
return None
|
||||
try:
|
||||
return date(int(s[0:4]), int(s[4:6]), int(s[6:8]))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _parse_yymmdd(s: str) -> date | None:
|
||||
if not s or len(s) != 6 or not s.isdigit():
|
||||
return None
|
||||
try:
|
||||
return date(2000 + int(s[0:2]), int(s[2:4]), int(s[4:6]))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _parse_date_loose(s: str) -> date | None:
|
||||
"""Parse an X12 date — accept 8-digit CCYYMMDD or 6-digit YYMMDD."""
|
||||
if not s or not s.isdigit():
|
||||
return None
|
||||
if len(s) == 8:
|
||||
return _parse_yyyymmdd(s)
|
||||
if len(s) == 6:
|
||||
return _parse_yymmdd(s)
|
||||
return None
|
||||
|
||||
|
||||
def _parse_service_date(s: str) -> date | None:
|
||||
"""Parse DTP*472 values. HCPF sends ``RD8*20240601-20240601`` (range) or ``D8*20240601``.
|
||||
|
||||
For ranges we return the start date — that's what matches the
|
||||
Cyclone claim's ``service_date_from``.
|
||||
"""
|
||||
if not s:
|
||||
return None
|
||||
# RD8 = range of dates (CCYYMMDD-CCYYMMDD); take the start.
|
||||
if "-" in s:
|
||||
start = s.split("-", 1)[0]
|
||||
return _parse_date_loose(start)
|
||||
return _parse_date_loose(s)
|
||||
|
||||
|
||||
def _parse_amount(s: str) -> float | None:
|
||||
"""Parse an X12 monetary value. Returns None on bad input."""
|
||||
if not s:
|
||||
return None
|
||||
try:
|
||||
return float(s)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Envelope
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _build_envelope(segments: list[list[str]], input_file: str) -> tuple[Envelope, str]:
|
||||
"""Build the envelope from ISA/GS/ST. Returns ``(envelope, tx_set_id)``.
|
||||
|
||||
``tx_set_id`` carries the ST01 value (``"277"`` or ``"277CA"``) so
|
||||
the rest of the parser doesn't have to re-scan the segment list.
|
||||
"""
|
||||
summary = BatchSummary(input_file=input_file)
|
||||
envelope: Envelope | None = None
|
||||
tx_set_id: str = ""
|
||||
txn_date: date | None = None
|
||||
for seg in segments:
|
||||
if seg[0] == "ISA":
|
||||
try:
|
||||
envelope = Envelope(
|
||||
sender_id=seg[6].strip(),
|
||||
receiver_id=seg[8].strip(),
|
||||
control_number=seg[13].strip(),
|
||||
transaction_date=date(2024, 1, 1),
|
||||
implementation_guide=None,
|
||||
)
|
||||
except (IndexError, ValueError) as exc:
|
||||
raise CycloneParseError(f"Bad ISA: {exc}") from exc
|
||||
elif seg[0] == "GS" and envelope is not None:
|
||||
if len(seg) > 4:
|
||||
txn_date = _parse_date_loose(seg[3]) or txn_date
|
||||
elif seg[0] == "ST" and envelope is not None:
|
||||
if len(seg) > 1:
|
||||
tx_set_id = seg[1]
|
||||
if len(seg) > 3:
|
||||
envelope = envelope.model_copy(update={"implementation_guide": seg[3]})
|
||||
if envelope is None:
|
||||
raise CycloneParseError("No ISA envelope found")
|
||||
if txn_date is not None:
|
||||
envelope = envelope.model_copy(update={"transaction_date": txn_date})
|
||||
return envelope, tx_set_id
|
||||
|
||||
|
||||
def _build_bht(segments: list[list[str]]) -> AcknowledgmentHeader:
|
||||
"""Find the BHT segment and return it. Falls back to an empty header."""
|
||||
for seg in segments:
|
||||
if seg[0] == "BHT":
|
||||
return AcknowledgmentHeader(
|
||||
hierarchical_structure_code=seg[1] if len(seg) > 1 else "",
|
||||
transaction_set_purpose_code=seg[2] if len(seg) > 2 else "",
|
||||
reference_identification=seg[3] if len(seg) > 3 and seg[3] else None,
|
||||
transaction_set_creation_date=_parse_date_loose(seg[4]) if len(seg) > 4 else None,
|
||||
transaction_set_creation_time=seg[5] if len(seg) > 5 and seg[5] else None,
|
||||
transaction_type_code=seg[6] if len(seg) > 6 and seg[6] else None,
|
||||
)
|
||||
return AcknowledgmentHeader(
|
||||
hierarchical_structure_code="",
|
||||
transaction_set_purpose_code="",
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# HL-walking and per-claim status construction
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _parse_stc(seg: list[str]) -> ClaimStatus:
|
||||
"""Convert an STC segment into a partial ClaimStatus.
|
||||
|
||||
STC is a composite segment: ``STC*cat:stat:entity*date*action*amount``.
|
||||
HCPF only populates ``cat`` (``A6:19:PR``); the rest are optional.
|
||||
"""
|
||||
composite = seg[1] if len(seg) > 1 else ""
|
||||
parts = composite.split(":") if composite else []
|
||||
category = parts[0] if parts else ""
|
||||
status = parts[1] if len(parts) > 1 and parts[1] else None
|
||||
entity = parts[2] if len(parts) > 2 and parts[2] else None
|
||||
effective = _parse_date_loose(seg[2]) if len(seg) > 2 else None
|
||||
action = seg[3] if len(seg) > 3 and seg[3] else None
|
||||
amount = _parse_amount(seg[4]) if len(seg) > 4 else None
|
||||
return ClaimStatus(
|
||||
status_code=category,
|
||||
status_description=status,
|
||||
entity_identifier=entity,
|
||||
status_effective_date=effective,
|
||||
status_action_code=action,
|
||||
total_claim_charge_amount=amount,
|
||||
classification=classify_status_code(category),
|
||||
)
|
||||
|
||||
|
||||
def _consume_patient_block(segments: list[list[str]], idx: int) -> tuple[ClaimStatus | None, int]:
|
||||
"""Read all segments under an HL*…*PT block (until the next HL or SE).
|
||||
|
||||
Returns ``(status, next_idx)``. ``status`` is ``None`` when the block
|
||||
contained no STC segments (a Patient HL with no status is unusual
|
||||
but not illegal — surface it as ``None`` so the caller can log).
|
||||
"""
|
||||
parts: dict[str, object] = {}
|
||||
stc: ClaimStatus | None = None
|
||||
i = idx
|
||||
while i < len(segments):
|
||||
seg = segments[i]
|
||||
if seg[0] in {"HL", "SE"}:
|
||||
break
|
||||
if seg[0] == "REF":
|
||||
qualifier = seg[1] if len(seg) > 1 else ""
|
||||
value = seg[2] if len(seg) > 2 else ""
|
||||
if qualifier == "1K":
|
||||
parts["payer_claim_control_number"] = value
|
||||
elif qualifier == "EJ":
|
||||
parts["billing_provider_tax_id"] = value
|
||||
elif seg[0] == "STC":
|
||||
# Per X12 005010X214 a Patient HL can have multiple STC
|
||||
# segments (one per status). We take the last one as
|
||||
# authoritative — that's typically the most recent action
|
||||
# (e.g. STC*A1 then STC*A3 means "first pended, then paid").
|
||||
stc = _parse_stc(seg)
|
||||
elif seg[0] == "AMT":
|
||||
qualifier = seg[1] if len(seg) > 1 else ""
|
||||
value = _parse_amount(seg[2]) if len(seg) > 2 else None
|
||||
if qualifier == "YU" and value is not None:
|
||||
parts["total_claim_charge_amount"] = value
|
||||
elif seg[0] == "DTP":
|
||||
qualifier = seg[1] if len(seg) > 1 else ""
|
||||
fmt = seg[2] if len(seg) > 2 else ""
|
||||
value = seg[3] if len(seg) > 3 else ""
|
||||
if qualifier == "472" and fmt in ("D8", "RD8"):
|
||||
parts["service_date"] = _parse_service_date(value)
|
||||
i += 1
|
||||
|
||||
if stc is None:
|
||||
return None, i
|
||||
|
||||
# Merge the REF/AMT/DTP capture onto the last STC.
|
||||
merged = stc.model_copy(update=parts)
|
||||
return merged, i
|
||||
|
||||
|
||||
def _consume_subscriber_block(segments: list[list[str]], idx: int) -> tuple[list[ClaimStatus], int]:
|
||||
"""Read STC segments under a Subscriber (HL*…*19*1) block.
|
||||
|
||||
Subscriber-level STCs have no REF*1K — they apply to the whole
|
||||
subscriber's claim batch. Surface them in
|
||||
:attr:`ParseResult277CA.unscoped_statuses`.
|
||||
"""
|
||||
out: list[ClaimStatus] = []
|
||||
i = idx
|
||||
while i < len(segments):
|
||||
seg = segments[i]
|
||||
if seg[0] in {"HL", "SE"}:
|
||||
break
|
||||
if seg[0] == "STC":
|
||||
out.append(_parse_stc(seg))
|
||||
i += 1
|
||||
return out, i
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Top-level orchestrator
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def parse_277ca_text(text: str, *, input_file: str = "") -> ParseResult277CA:
|
||||
"""Parse a complete 277CA document and return a :class:`ParseResult277CA`.
|
||||
|
||||
Both ``ST*277*`` and ``ST*277CA*`` shapes are accepted. Per X12
|
||||
005010X214 a 277CA can have multiple Patient HLs — we surface them
|
||||
all in ``claim_statuses``. STC segments under Subscriber HLs go
|
||||
into ``unscoped_statuses`` because we can't tie them to a Cyclone
|
||||
claim row without an explicit REF*1K.
|
||||
|
||||
Whole-document problems (missing ISA, no ST) raise
|
||||
:class:`CycloneParseError`. Per-segment quirks are surfaced on the
|
||||
result and never raised, matching the 999 parser's contract.
|
||||
"""
|
||||
segments = tokenize(text)
|
||||
envelope, tx_set_id = _build_envelope(segments, input_file=input_file)
|
||||
|
||||
if tx_set_id not in ("277", "277CA"):
|
||||
raise CycloneParseError(
|
||||
f"Expected ST*277 or ST*277CA, got ST*{tx_set_id!r}"
|
||||
)
|
||||
|
||||
bht = _build_bht(segments)
|
||||
|
||||
claim_statuses: list[ClaimStatus] = []
|
||||
unscoped_statuses: list[ClaimStatus] = []
|
||||
|
||||
# Walk all HL segments, in order.
|
||||
for i, seg in enumerate(segments):
|
||||
if seg[0] != "HL":
|
||||
continue
|
||||
# HL04 carries the level code: "20" (info source), "21" (info
|
||||
# receiver), "19" (subscriber), "PT" (patient). Only PT and 19
|
||||
# carry claim-level STCs.
|
||||
level_code = seg[3] if len(seg) > 3 else ""
|
||||
if level_code == "PT":
|
||||
status, _next = _consume_patient_block(segments, i + 1)
|
||||
if status is not None:
|
||||
claim_statuses.append(status)
|
||||
elif level_code == "19":
|
||||
# Subscriber-level: only surface when there's actually an
|
||||
# STC under it (otherwise it's a blank frame).
|
||||
inner, _next = _consume_subscriber_block(segments, i + 1)
|
||||
if inner:
|
||||
unscoped_statuses.extend(inner)
|
||||
|
||||
# Summary counts — for the Inbox lane counts.
|
||||
total = len(claim_statuses)
|
||||
accepted = sum(1 for s in claim_statuses if s.classification == "accepted")
|
||||
rejected = sum(1 for s in claim_statuses if s.classification == "rejected")
|
||||
pended = sum(1 for s in claim_statuses if s.classification == "pended")
|
||||
paid = sum(1 for s in claim_statuses if s.classification == "paid")
|
||||
# "failed" is what the Inbox cares about: rejected + pended.
|
||||
# Pended claims aren't final (the payer may still pay them) but we
|
||||
# surface them under a different lane (Payer-Pended, added later).
|
||||
# For the SP10 Payer-Rejected lane only "rejected" counts.
|
||||
failed = rejected
|
||||
summary = BatchSummary(
|
||||
input_file=input_file,
|
||||
control_number=envelope.control_number,
|
||||
transaction_date=envelope.transaction_date,
|
||||
total_claims=total,
|
||||
passed=accepted + paid,
|
||||
failed=failed,
|
||||
)
|
||||
_ = pended # surfaced separately in future SPs; tracked but unused here
|
||||
|
||||
return ParseResult277CA(
|
||||
envelope=envelope,
|
||||
bht=bht,
|
||||
claim_statuses=claim_statuses,
|
||||
unscoped_statuses=unscoped_statuses,
|
||||
summary=summary,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["parse_277ca_text"]
|
||||
@@ -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,104 @@
|
||||
"""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 PayerConfig277CA, 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
|
||||
if tx_str == "277CA":
|
||||
return PayerConfig277CA
|
||||
# Other tx types (999, TA1, 270, 271) 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,197 @@
|
||||
"""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 PayerConfig277CA(BaseModel):
|
||||
"""Per-payer, per-277CA-transaction-type configuration block.
|
||||
|
||||
Carries the status-code classification sets the parser and Inbox
|
||||
lane logic consult. HCPF publishes the canonical STC category codes
|
||||
(A1-A9 for adjudication states, P1-P5 for paid states); this block
|
||||
lets the operator override the classification per-payer without
|
||||
touching code.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
rejected_status_codes: list[str] = Field(default_factory=lambda: ["A4", "A6", "A7"])
|
||||
pended_status_codes: list[str] = Field(default_factory=lambda: ["A8", "A9"])
|
||||
accepted_status_codes: list[str] = Field(default_factory=lambda: ["A1", "A2", "A3"])
|
||||
paid_status_codes: list[str] = Field(default_factory=lambda: ["P1", "P2", "P3", "P4", "P5"])
|
||||
# HCPF uses ST*277CA but the X12 spec also allows ST*277. Both accepted.
|
||||
transaction_set_ids_allowed: list[str] = Field(default_factory=lambda: ["277", "277CA"])
|
||||
implementation_guide: str = "005010X214"
|
||||
|
||||
|
||||
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):
|
||||
@@ -1799,6 +1800,55 @@ class CycloneStore:
|
||||
with db.SessionLocal()() as s:
|
||||
return s.get(db.Ta1Ack, ack_id)
|
||||
|
||||
# -- 277CA (SP10) --------------------------------------------------
|
||||
|
||||
def add_277ca_ack(
|
||||
self,
|
||||
*,
|
||||
source_batch_id: str,
|
||||
control_number: str,
|
||||
accepted_count: int,
|
||||
rejected_count: int,
|
||||
paid_count: int,
|
||||
pended_count: int,
|
||||
raw_json: dict,
|
||||
) -> db.Two77caAck:
|
||||
"""Persist a 277CA (Claim Acknowledgment) row and return it.
|
||||
|
||||
Mirrors :meth:`add_ack` but for the claim-level ack. The
|
||||
per-claim status detail stays in ``raw_json``; only the four
|
||||
counts are promoted so the list endpoint stays fast.
|
||||
"""
|
||||
with db.SessionLocal()() as s:
|
||||
row = db.Two77caAck(
|
||||
source_batch_id=source_batch_id,
|
||||
control_number=control_number,
|
||||
accepted_count=accepted_count,
|
||||
rejected_count=rejected_count,
|
||||
paid_count=paid_count,
|
||||
pended_count=pended_count,
|
||||
parsed_at=utcnow(),
|
||||
raw_json=raw_json,
|
||||
)
|
||||
s.add(row)
|
||||
s.commit()
|
||||
s.refresh(row)
|
||||
return row
|
||||
|
||||
def list_277ca_acks(self) -> list[db.Two77caAck]:
|
||||
"""Return every 277CA ACK row, newest first (auto-increment id desc)."""
|
||||
with db.SessionLocal()() as s:
|
||||
return (
|
||||
s.query(db.Two77caAck)
|
||||
.order_by(db.Two77caAck.id.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
def get_277ca_ack(self, ack_id: int) -> db.Two77caAck | None:
|
||||
"""Return a single 277CA ACK row by id, or ``None`` if not found."""
|
||||
with db.SessionLocal()() as s:
|
||||
return s.get(db.Two77caAck, ack_id)
|
||||
|
||||
# -- manual reconciliation (T12) -----------------------------------
|
||||
|
||||
def list_unmatched(self, *, kind: str = "both") -> dict:
|
||||
@@ -2082,6 +2132,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()
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
ISA*00* *00* *ZZ*COMEDICAID *ZZ*DZINESCO *240620*1200*^*00501*000000123*0*P*:~
|
||||
GS*HN*COMEDICAID*DZINESCO*20240620*1200*1*X*005010X214~
|
||||
ST*277CA*0001*005010X214~
|
||||
BHT*0085*08*REFNUM001*20240620*1200*TH~
|
||||
HL*1**20*1~
|
||||
NM1*PR*2*COLORADO MEDICAL ASSIST*****PI*COMEDICAID~
|
||||
TRN*2*REFNUM001~
|
||||
DTP*050*RD8*20240601-20240630~
|
||||
DTP*009*RD8*20240601-20240630~
|
||||
HL*2*1*21*1~
|
||||
NM1*41*2*DZINESCO*****46*11525703~
|
||||
TRN*2*REFNUM001~
|
||||
HL*3*2*19*1~
|
||||
NM1*IL*1*DOE*JOHN****MI*MEMBERID001~
|
||||
TRN*2*TRACE001~
|
||||
HL*4*3*PT~
|
||||
NM1*QC*1*DOE*JANE~
|
||||
REF*1K*CLAIM001~
|
||||
REF*EJ*721587149~
|
||||
STC*A3:19:PR*20240620*WQ*100.00~
|
||||
QTY*90*1~
|
||||
AMT*YU*100.00~
|
||||
DTP*472*RD8*20240615-20240615~
|
||||
HL*5*3*PT~
|
||||
NM1*QC*1*SMITH*ROBERT~
|
||||
REF*1K*CLAIM002~
|
||||
REF*EJ*721587149~
|
||||
STC*A6:19:PR*20240620*U*250.00~
|
||||
QTY*90*1~
|
||||
AMT*YU*250.00~
|
||||
DTP*472*D8*20240610~
|
||||
HL*6*3*PT~
|
||||
NM1*QC*1*GARCIA*MARIA~
|
||||
REF*1K*CLAIM003~
|
||||
REF*EJ*721587149~
|
||||
STC*A8:19:PR*20240620*U*175.50~
|
||||
QTY*90*1~
|
||||
AMT*YU*175.50~
|
||||
DTP*472*RD8*20240612-20240612~
|
||||
SE*40*0001~
|
||||
GE*1*1~
|
||||
IEA*1*000000123~
|
||||
@@ -0,0 +1,14 @@
|
||||
ISA*00* *00* *ZZ*COMEDICAID *ZZ*DZINESCO *240620*1200*^*00501*000000789*0*P*:~
|
||||
GS*HN*COMEDICAID*DZINESCO*20240620*1200*3*X*005010X214~
|
||||
ST*277CA*0001*005010X214~
|
||||
BHT*0085*08*REFNUM003*20240620*1200*TH~
|
||||
HL*1**20*1~
|
||||
HL*2*1*21*1~
|
||||
HL*3*2*19*1~
|
||||
HL*4*3*PT~
|
||||
REF*1K*CLAIM099~
|
||||
REF*EJ*721587149~
|
||||
STC*A7:19:PR*20240620*U*99.99~
|
||||
SE*8*0001~
|
||||
GE*1*1~
|
||||
IEA*1*000000789~
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
ISA*00* *00* *ZZ*COMEDICAID *ZZ*DZINESCO *240620*1200*^*00501*000000456*0*P*:~
|
||||
GS*HN*COMEDICAID*DZINESCO*20240620*1200*2*X*005010X214~
|
||||
ST*277*0001*005010X214~
|
||||
BHT*0085*08*REFNUM002*20240620*1200*TH~
|
||||
HL*1**20*1~
|
||||
NM1*PR*2*COLORADO MEDICAL ASSIST*****PI*COMEDICAID~
|
||||
HL*2*1*21*1~
|
||||
HL*3*2*19*1~
|
||||
HL*4*3*PT~
|
||||
REF*1K*CLAIM004~
|
||||
REF*EJ*721587149~
|
||||
STC*A3:19:PR*20240620*WQ*300.00~
|
||||
SE*10*0001~
|
||||
GE*1*1~
|
||||
IEA*1*000000456~
|
||||
@@ -51,17 +51,18 @@ 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 9 after
|
||||
0004-0006 line_reconciliation, 0005 ta1_acks, SP9's 0007
|
||||
providers/payers/clearhouse, SP10's 0008 payer_rejected, and
|
||||
SP11's 0009 audit_log)."""
|
||||
with db.engine().begin() as c:
|
||||
v1 = c.exec_driver_sql("PRAGMA user_version").scalar() or 0
|
||||
assert v1 == 6
|
||||
assert v1 == 9
|
||||
# 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 == 9
|
||||
|
||||
|
||||
def test_add_ack_persists_row():
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
"""Tests for the FastAPI surface in ``cyclone.api`` for the 277CA endpoint.
|
||||
|
||||
SP10 T3. Mirrors ``test_api_999.py``:
|
||||
- 400 on empty / undecodable / malformed EDI (never 500).
|
||||
- 200 on success with the parsed envelope + counts.
|
||||
- After parse, ``apply_277ca_rejections`` stamps matching claim rows.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from cyclone import db
|
||||
from cyclone.api import app
|
||||
from cyclone.db import Claim, init_db
|
||||
|
||||
ACCEPTED_FIXTURE = Path(__file__).parent / "fixtures" / "minimal_277ca.txt"
|
||||
REJECTED_FIXTURE = Path(__file__).parent / "fixtures" / "minimal_277ca_rejected_only.txt"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _fresh_db():
|
||||
"""Each test gets a fresh DB and a clean 277CA ack list."""
|
||||
init_db()
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client() -> TestClient:
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _seed_claim(claim_id: str, pcn: str) -> None:
|
||||
with db.SessionLocal()() as s:
|
||||
s.add(Claim(
|
||||
id=claim_id, batch_id="BATCH-1",
|
||||
patient_control_number=pcn, charge_amount=100.00,
|
||||
))
|
||||
s.commit()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Happy path
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestParse277CAEndpointHappyPath:
|
||||
def test_upload_minimal_277ca_returns_200(self, client: TestClient):
|
||||
text = ACCEPTED_FIXTURE.read_text()
|
||||
resp = client.post(
|
||||
"/api/parse-277ca",
|
||||
files={"file": ("minimal_277ca.txt", text, "text/plain")},
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert "ack" in body
|
||||
assert "parsed" in body
|
||||
ack = body["ack"]
|
||||
assert ack["accepted_count"] == 1
|
||||
assert ack["rejected_count"] == 1
|
||||
assert ack["pended_count"] == 1
|
||||
assert ack["control_number"] == "000000123"
|
||||
|
||||
def test_persists_two77ca_row(self, client: TestClient):
|
||||
text = ACCEPTED_FIXTURE.read_text()
|
||||
client.post(
|
||||
"/api/parse-277ca",
|
||||
files={"file": ("minimal_277ca.txt", text, "text/plain")},
|
||||
)
|
||||
rows_resp = client.get("/api/277ca-acks")
|
||||
assert rows_resp.status_code == 200
|
||||
rows = rows_resp.json()
|
||||
assert rows["total"] == 1
|
||||
assert rows["items"][0]["control_number"] == "000000123"
|
||||
|
||||
def test_get_277ca_ack_by_id(self, client: TestClient):
|
||||
text = ACCEPTED_FIXTURE.read_text()
|
||||
post_resp = client.post(
|
||||
"/api/parse-277ca",
|
||||
files={"file": ("minimal_277ca.txt", text, "text/plain")},
|
||||
)
|
||||
ack_id = post_resp.json()["ack"]["id"]
|
||||
detail = client.get(f"/api/277ca-acks/{ack_id}")
|
||||
assert detail.status_code == 200
|
||||
assert detail.json()["control_number"] == "000000123"
|
||||
|
||||
def test_stamps_matching_claim(self, client: TestClient):
|
||||
"""A rejected 277CA claim with REF*1K=CLAIM002 stamps claim c2."""
|
||||
# Seed two claims matching the fixture's PCNs.
|
||||
_seed_claim("c1", "CLAIM001")
|
||||
_seed_claim("c2", "CLAIM002")
|
||||
text = ACCEPTED_FIXTURE.read_text()
|
||||
client.post(
|
||||
"/api/parse-277ca",
|
||||
files={"file": ("minimal_277ca.txt", text, "text/plain")},
|
||||
)
|
||||
with db.SessionLocal()() as s:
|
||||
c1 = s.get(Claim, "c1")
|
||||
c2 = s.get(Claim, "c2")
|
||||
assert c1.payer_rejected_at is None
|
||||
assert c2.payer_rejected_at is not None
|
||||
assert c2.payer_rejected_status_code == "A6"
|
||||
assert "A6" in c2.payer_rejected_reason
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Error paths
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestParse277CAEndpointErrors:
|
||||
def test_empty_file_raises_400(self, client: TestClient):
|
||||
resp = client.post(
|
||||
"/api/parse-277ca",
|
||||
files={"file": ("empty.txt", "", "text/plain")},
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "error" in resp.json()
|
||||
|
||||
def test_garbage_raises_400(self, client: TestClient):
|
||||
resp = client.post(
|
||||
"/api/parse-277ca",
|
||||
files={"file": ("garbage.txt", "this is not EDI", "text/plain")},
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
|
||||
def test_wrong_transaction_set_raises_400(self, client: TestClient):
|
||||
"""A 999 must NOT be accepted as a 277CA — different transaction set id."""
|
||||
text = (
|
||||
"ISA*00* *00* *ZZ*AAAAAAAAAAAAAAA*ZZ*BBBBBBBBBBBBBBB*240620*1200*^*00501*000000001*0*P*:~"
|
||||
"GS*HN*A*B*20240620*1200*1*X*005010X231A1~"
|
||||
"ST*999*0001*005010X231A1~"
|
||||
"AK1*HC*0001~"
|
||||
"AK9*A*0*0*0~"
|
||||
"SE*4*0001~"
|
||||
"GE*1*1~"
|
||||
"IEA*1*000000001~"
|
||||
)
|
||||
resp = client.post(
|
||||
"/api/parse-277ca",
|
||||
files={"file": ("bad.txt", text, "text/plain")},
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Inbox lane
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestInboxPayerRejectedLane:
|
||||
def test_payer_rejected_claim_appears_in_lane(self, client: TestClient):
|
||||
"""A claim with payer_rejected_at set must appear in the payer_rejected lane."""
|
||||
_seed_claim("c1", "CLAIM099")
|
||||
text = REJECTED_FIXTURE.read_text() # single A7 for CLAIM099
|
||||
client.post(
|
||||
"/api/parse-277ca",
|
||||
files={"file": ("rejected.txt", text, "text/plain")},
|
||||
)
|
||||
lanes = client.get("/api/inbox/lanes").json()
|
||||
assert "payer_rejected" in lanes
|
||||
ids = [c["id"] for c in lanes["payer_rejected"]]
|
||||
assert "c1" in ids
|
||||
# The rejected lane (999 envelope) must be empty — we haven't
|
||||
# uploaded a 999, so this claim isn't there.
|
||||
assert "c1" not in [c["id"] for c in lanes["rejected"]]
|
||||
@@ -0,0 +1,159 @@
|
||||
"""Tests for the audit-log admin API endpoints. SP11 T3."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from cyclone import db
|
||||
from cyclone.audit_log import AuditEvent, append_event
|
||||
from cyclone.api import app
|
||||
from cyclone.db import AuditLog, init_db
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _fresh_db():
|
||||
init_db()
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client() -> TestClient:
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _seed_audit(rows: list[AuditEvent]) -> None:
|
||||
with db.SessionLocal()() as s:
|
||||
for ev in rows:
|
||||
append_event(s, ev)
|
||||
s.commit()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# List endpoint
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestListAuditLog:
|
||||
def test_empty_returns_zero_items(self, client: TestClient):
|
||||
r = client.get("/api/admin/audit-log")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["total"] == 0
|
||||
assert body["items"] == []
|
||||
|
||||
def test_returns_newest_first(self, client: TestClient):
|
||||
_seed_audit([
|
||||
AuditEvent(event_type="a.first", entity_type="claim", entity_id="c1"),
|
||||
AuditEvent(event_type="a.second", entity_type="claim", entity_id="c1"),
|
||||
AuditEvent(event_type="a.third", entity_type="claim", entity_id="c1"),
|
||||
])
|
||||
r = client.get("/api/admin/audit-log")
|
||||
assert r.status_code == 200
|
||||
items = r.json()["items"]
|
||||
assert [i["event_type"] for i in items] == ["a.third", "a.second", "a.first"]
|
||||
|
||||
def test_filter_by_entity(self, client: TestClient):
|
||||
_seed_audit([
|
||||
AuditEvent(event_type="x", entity_type="claim", entity_id="c1"),
|
||||
AuditEvent(event_type="x", entity_type="claim", entity_id="c2"),
|
||||
AuditEvent(event_type="x", entity_type="batch", entity_id="b1"),
|
||||
])
|
||||
r = client.get("/api/admin/audit-log", params={"entity_type": "claim", "entity_id": "c1"})
|
||||
items = r.json()["items"]
|
||||
assert len(items) == 1
|
||||
assert items[0]["entity_id"] == "c1"
|
||||
|
||||
def test_filter_by_event_type(self, client: TestClient):
|
||||
_seed_audit([
|
||||
AuditEvent(event_type="claim.parsed", entity_type="claim", entity_id="c1"),
|
||||
AuditEvent(event_type="claim.rejected", entity_type="claim", entity_id="c1"),
|
||||
])
|
||||
r = client.get("/api/admin/audit-log", params={"event_type": "claim.rejected"})
|
||||
items = r.json()["items"]
|
||||
assert len(items) == 1
|
||||
assert items[0]["event_type"] == "claim.rejected"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Verify endpoint
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestVerifyAuditLog:
|
||||
def test_empty_chain_returns_ok(self, client: TestClient):
|
||||
r = client.get("/api/admin/audit-log/verify")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["ok"] is True
|
||||
assert body["checked"] == 0
|
||||
|
||||
def test_clean_chain_returns_ok(self, client: TestClient):
|
||||
_seed_audit([
|
||||
AuditEvent(event_type="x", entity_type="y", entity_id=f"id-{i}")
|
||||
for i in range(10)
|
||||
])
|
||||
r = client.get("/api/admin/audit-log/verify")
|
||||
body = r.json()
|
||||
assert body["ok"] is True
|
||||
assert body["checked"] == 10
|
||||
|
||||
def test_tampered_chain_returns_failure(self, client: TestClient):
|
||||
_seed_audit([
|
||||
AuditEvent(event_type="x", entity_type="y", entity_id=f"id-{i}")
|
||||
for i in range(5)
|
||||
])
|
||||
with db.SessionLocal()() as s:
|
||||
row = s.query(AuditLog).filter(AuditLog.id == 3).first()
|
||||
row.payload_json = json.dumps({"evil": True})
|
||||
s.commit()
|
||||
r = client.get("/api/admin/audit-log/verify")
|
||||
body = r.json()
|
||||
assert body["ok"] is False
|
||||
assert body["first_bad_id"] == 3
|
||||
assert body["reason"]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# End-to-end: 999 → audit row appears in list
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestAuditLogHookedIntoEndpoints:
|
||||
def test_parse_999_creates_audit_rows(self, client: TestClient):
|
||||
"""Upload a 999 with one rejected set, see an audit row appear."""
|
||||
# Seed a claim so the 999 rejection matches.
|
||||
from cyclone.db import Claim, ClaimState
|
||||
with db.SessionLocal()() as s:
|
||||
s.add(Claim(
|
||||
id="c1", batch_id="B-1", patient_control_number="0001",
|
||||
state=ClaimState.SUBMITTED,
|
||||
))
|
||||
s.commit()
|
||||
|
||||
text = (
|
||||
"ISA*00* *00* *ZZ*AAAAAAAAAAAAAAA*ZZ*BBBBBBBBBBBBBBB*240620*1200*^*00501*000000001*0*P*:~"
|
||||
"GS*HN*A*B*20240620*1200*1*X*005010X231A1~"
|
||||
"ST*999*0001*005010X231A1~"
|
||||
"AK1*HC*0001~"
|
||||
"AK2*837*0001~"
|
||||
"AK5*R~"
|
||||
"AK9*R*1*0*1~"
|
||||
"SE*7*0001~"
|
||||
"GE*1*1~"
|
||||
"IEA*1*000000001~"
|
||||
)
|
||||
r = client.post(
|
||||
"/api/parse-999",
|
||||
files={"file": ("r.txt", text, "text/plain")},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
# One audit row should have appeared for the rejected claim.
|
||||
r2 = client.get(
|
||||
"/api/admin/audit-log",
|
||||
params={"entity_type": "claim", "entity_id": "c1"},
|
||||
)
|
||||
items = r2.json()["items"]
|
||||
assert any(i["event_type"] == "claim.rejected" for i in items)
|
||||
@@ -0,0 +1,247 @@
|
||||
"""Tests for :func:`cyclone.inbox_state_277ca.apply_277ca_rejections`.
|
||||
|
||||
SP10 T2. The 277CA's STC A4/A6/A7 codes stamp payer-rejection fields
|
||||
on matching claim rows. Distinct from the 999 envelope rejection.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
|
||||
from cyclone import db
|
||||
from cyclone.db import Claim, init_db
|
||||
from cyclone.inbox_state_277ca import apply_277ca_rejections
|
||||
from cyclone.parsers.models_277ca import ClaimStatus, ParseResult277CA
|
||||
from cyclone.parsers.parse_277ca import parse_277ca_text
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Fixtures
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _fresh_db():
|
||||
"""Each test gets a fresh in-memory DB."""
|
||||
init_db()
|
||||
yield
|
||||
|
||||
|
||||
def _make_claim(session, *, claim_id: str, pcn: str = "CLAIM001") -> Claim:
|
||||
c = Claim(
|
||||
id=claim_id,
|
||||
batch_id="BATCH-1",
|
||||
patient_control_number=pcn,
|
||||
charge_amount=100.00,
|
||||
)
|
||||
session.add(c)
|
||||
session.commit()
|
||||
session.refresh(c)
|
||||
return c
|
||||
|
||||
|
||||
def _make_rejected_status(pcn: str | None = "CLAIM001") -> ClaimStatus:
|
||||
return ClaimStatus(
|
||||
status_code="A6",
|
||||
status_description="19",
|
||||
entity_identifier="PR",
|
||||
classification="rejected",
|
||||
payer_claim_control_number=pcn,
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Tests
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestApply277CARejectionsHappyPath:
|
||||
def test_rejected_status_stamps_matching_claim(self):
|
||||
from cyclone import db
|
||||
with db.SessionLocal()() as s:
|
||||
claim = _make_claim(s, claim_id="c1")
|
||||
pcn = claim.patient_control_number
|
||||
result = parse_277ca_text(_minimal_277ca_one_rejected())
|
||||
with db.SessionLocal()() as s:
|
||||
def _lookup(pcn_q):
|
||||
return s.query(Claim).filter_by(patient_control_number=pcn_q).first()
|
||||
outcome = apply_277ca_rejections(s, result, claim_lookup=_lookup, two77ca_id="ACK-1")
|
||||
assert outcome.matched == ["c1"]
|
||||
assert outcome.orphans == []
|
||||
with db.SessionLocal()() as s:
|
||||
c = s.get(Claim, "c1")
|
||||
assert c.payer_rejected_at is not None
|
||||
assert c.payer_rejected_status_code == "A6"
|
||||
assert "A6" in (c.payer_rejected_reason or "")
|
||||
assert c.payer_rejected_by_277ca_id == "ACK-1"
|
||||
|
||||
|
||||
class TestApply277CARejectionsOrphans:
|
||||
def test_unknown_pcn_becomes_orphan(self):
|
||||
from cyclone import db
|
||||
# No claim exists — PCN won't match.
|
||||
text = _minimal_277ca_one_rejected()
|
||||
result = parse_277ca_text(text)
|
||||
with db.SessionLocal()() as s:
|
||||
def _lookup(_):
|
||||
return None
|
||||
outcome = apply_277ca_rejections(s, result, claim_lookup=_lookup, two77ca_id="ACK-1")
|
||||
assert outcome.matched == []
|
||||
assert outcome.orphans == ["CLAIM001"]
|
||||
|
||||
def test_status_without_ref_1k_becomes_orphan(self):
|
||||
"""A rejected STC with no REF*1K cannot match a claim."""
|
||||
from cyclone import db
|
||||
text = _minimal_277ca_no_ref1k()
|
||||
result = parse_277ca_text(text)
|
||||
with db.SessionLocal()() as s:
|
||||
def _lookup(_):
|
||||
return None
|
||||
outcome = apply_277ca_rejections(s, result, claim_lookup=_lookup, two77ca_id="ACK-1")
|
||||
assert outcome.matched == []
|
||||
# The orphan entry uses the status code (since PCN is missing).
|
||||
assert outcome.orphans == ["A6"]
|
||||
|
||||
|
||||
class TestApply277CARejectionsIdempotent:
|
||||
def test_already_stamped_is_not_overwritten(self):
|
||||
from cyclone import db
|
||||
with db.SessionLocal()() as s:
|
||||
_make_claim(s, claim_id="c1")
|
||||
text = _minimal_277ca_one_rejected()
|
||||
result = parse_277ca_text(text)
|
||||
with db.SessionLocal()() as s:
|
||||
def _lookup(_):
|
||||
return s.query(Claim).filter_by(patient_control_number="CLAIM001").first()
|
||||
outcome1 = apply_277ca_rejections(s, result, claim_lookup=_lookup, two77ca_id="ACK-1")
|
||||
assert outcome1.matched == ["c1"]
|
||||
original_reason = s.get(Claim, "c1").payer_rejected_reason
|
||||
original_at = s.get(Claim, "c1").payer_rejected_at
|
||||
# Run again with same code.
|
||||
outcome2 = apply_277ca_rejections(s, result, claim_lookup=_lookup, two77ca_id="ACK-1")
|
||||
assert outcome2.matched == []
|
||||
assert outcome2.already_rejected == ["c1"]
|
||||
with db.SessionLocal()() as s:
|
||||
c = s.get(Claim, "c1")
|
||||
# Reason and timestamp unchanged.
|
||||
assert c.payer_rejected_reason == original_reason
|
||||
assert c.payer_rejected_at == original_at
|
||||
|
||||
|
||||
class TestApply277CAOnlyRejectsRejected:
|
||||
def test_accepted_status_does_not_stamp(self):
|
||||
"""An A3 (accepted) status must NOT trigger a payer_rejected stamp."""
|
||||
from cyclone import db
|
||||
with db.SessionLocal()() as s:
|
||||
_make_claim(s, claim_id="c1")
|
||||
text = _minimal_277ca_one_accepted()
|
||||
result = parse_277ca_text(text)
|
||||
with db.SessionLocal()() as s:
|
||||
def _lookup(_):
|
||||
return s.query(Claim).filter_by(patient_control_number="CLAIM001").first()
|
||||
outcome = apply_277ca_rejections(s, result, claim_lookup=_lookup, two77ca_id="ACK-1")
|
||||
assert outcome.matched == []
|
||||
with db.SessionLocal()() as s:
|
||||
c = s.get(Claim, "c1")
|
||||
assert c.payer_rejected_at is None
|
||||
|
||||
|
||||
class TestApply277CAMultipleStatuses:
|
||||
def test_mixed_batch_only_stamps_rejected(self):
|
||||
"""Of three statuses (A3/A6/A8), only the A6 claim gets stamped."""
|
||||
from cyclone import db
|
||||
with db.SessionLocal()() as s:
|
||||
_make_claim(s, claim_id="c1", pcn="CLAIM001")
|
||||
_make_claim(s, claim_id="c2", pcn="CLAIM002")
|
||||
_make_claim(s, claim_id="c3", pcn="CLAIM003")
|
||||
text = (
|
||||
"ISA*00* *00* *ZZ*AAAAAAAAAAAAAAA*ZZ*BBBBBBBBBBBBBBB*240620*1200*^*00501*000000001*0*P*:~"
|
||||
"GS*HN*A*B*20240620*1200*1*X*005010X214~"
|
||||
"ST*277CA*0001*005010X214~"
|
||||
"BHT*0085*08*X*20240620*1200*TH~"
|
||||
"HL*1**20*1~"
|
||||
"HL*2*1*21*1~"
|
||||
"HL*3*2*19*1~"
|
||||
"HL*4*3*PT~"
|
||||
"REF*1K*CLAIM001~"
|
||||
"STC*A3:19:PR*20240620*WQ*100.00~"
|
||||
"HL*5*3*PT~"
|
||||
"REF*1K*CLAIM002~"
|
||||
"STC*A6:19:PR*20240620*U*250.00~"
|
||||
"HL*6*3*PT~"
|
||||
"REF*1K*CLAIM003~"
|
||||
"STC*A8:19:PR*20240620*U*175.00~"
|
||||
"SE*16*0001~"
|
||||
"GE*1*1~"
|
||||
"IEA*1*000000001~"
|
||||
)
|
||||
result = parse_277ca_text(text)
|
||||
with db.SessionLocal()() as s:
|
||||
def _lookup(pcn):
|
||||
return s.query(Claim).filter_by(patient_control_number=pcn).first()
|
||||
outcome = apply_277ca_rejections(s, result, claim_lookup=_lookup, two77ca_id="ACK-1")
|
||||
assert outcome.matched == ["c2"]
|
||||
with db.SessionLocal()() as s:
|
||||
assert s.get(Claim, "c1").payer_rejected_at is None
|
||||
assert s.get(Claim, "c2").payer_rejected_at is not None
|
||||
assert s.get(Claim, "c3").payer_rejected_at is None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Test fixtures
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _minimal_277ca_one_rejected() -> str:
|
||||
return (
|
||||
"ISA*00* *00* *ZZ*AAAAAAAAAAAAAAA*ZZ*BBBBBBBBBBBBBBB*240620*1200*^*00501*000000001*0*P*:~"
|
||||
"GS*HN*A*B*20240620*1200*1*X*005010X214~"
|
||||
"ST*277CA*0001*005010X214~"
|
||||
"BHT*0085*08*X*20240620*1200*TH~"
|
||||
"HL*1**20*1~"
|
||||
"HL*2*1*21*1~"
|
||||
"HL*3*2*19*1~"
|
||||
"HL*4*3*PT~"
|
||||
"REF*1K*CLAIM001~"
|
||||
"STC*A6:19:PR*20240620*U*100.00~"
|
||||
"SE*9*0001~"
|
||||
"GE*1*1~"
|
||||
"IEA*1*000000001~"
|
||||
)
|
||||
|
||||
|
||||
def _minimal_277ca_one_accepted() -> str:
|
||||
return (
|
||||
"ISA*00* *00* *ZZ*AAAAAAAAAAAAAAA*ZZ*BBBBBBBBBBBBBBB*240620*1200*^*00501*000000001*0*P*:~"
|
||||
"GS*HN*A*B*20240620*1200*1*X*005010X214~"
|
||||
"ST*277CA*0001*005010X214~"
|
||||
"BHT*0085*08*X*20240620*1200*TH~"
|
||||
"HL*1**20*1~"
|
||||
"HL*2*1*21*1~"
|
||||
"HL*3*2*19*1~"
|
||||
"HL*4*3*PT~"
|
||||
"REF*1K*CLAIM001~"
|
||||
"STC*A3:19:PR*20240620*WQ*100.00~"
|
||||
"SE*9*0001~"
|
||||
"GE*1*1~"
|
||||
"IEA*1*000000001~"
|
||||
)
|
||||
|
||||
|
||||
def _minimal_277ca_no_ref1k() -> str:
|
||||
"""Patient HL with STC A6 but no REF*1K."""
|
||||
return (
|
||||
"ISA*00* *00* *ZZ*AAAAAAAAAAAAAAA*ZZ*BBBBBBBBBBBBBBB*240620*1200*^*00501*000000001*0*P*:~"
|
||||
"GS*HN*A*B*20240620*1200*1*X*005010X214~"
|
||||
"ST*277CA*0001*005010X214~"
|
||||
"BHT*0085*08*X*20240620*1200*TH~"
|
||||
"HL*1**20*1~"
|
||||
"HL*2*1*21*1~"
|
||||
"HL*3*2*19*1~"
|
||||
"HL*4*3*PT~"
|
||||
"STC*A6:19:PR*20240620*U*100.00~"
|
||||
"SE*8*0001~"
|
||||
"GE*1*1~"
|
||||
"IEA*1*000000001~"
|
||||
)
|
||||
@@ -0,0 +1,187 @@
|
||||
"""Tests for the tamper-evident hash-chained audit_log.
|
||||
|
||||
SP11 T1.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import json
|
||||
import pytest
|
||||
|
||||
from cyclone import db
|
||||
from cyclone.audit_log import (
|
||||
GENESIS_PREV_HASH,
|
||||
HASH_LEN,
|
||||
AuditEvent,
|
||||
append_event,
|
||||
verify_chain,
|
||||
)
|
||||
from cyclone.db import AuditLog, init_db
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _fresh_db():
|
||||
init_db()
|
||||
yield
|
||||
|
||||
|
||||
def _row_count() -> int:
|
||||
with db.SessionLocal()() as s:
|
||||
return s.query(AuditLog).count()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Append
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestAppendEvent:
|
||||
def test_first_row_has_genesis_prev_hash(self):
|
||||
"""The first row in a fresh chain uses the all-zeros prev_hash."""
|
||||
with db.SessionLocal()() as s:
|
||||
row = append_event(s, AuditEvent(
|
||||
event_type="claim.parsed",
|
||||
entity_type="claim",
|
||||
entity_id="c1",
|
||||
payload={"patient_control_number": "PCN-1"},
|
||||
))
|
||||
s.commit()
|
||||
assert row.id == 1
|
||||
assert row.prev_hash == GENESIS_PREV_HASH
|
||||
assert len(row.hash) == HASH_LEN
|
||||
|
||||
def test_second_row_chains_to_first(self):
|
||||
"""Row N's prev_hash == row N-1's hash."""
|
||||
with db.SessionLocal()() as s:
|
||||
r1 = append_event(s, AuditEvent(
|
||||
event_type="claim.parsed", entity_type="claim", entity_id="c1",
|
||||
))
|
||||
s.commit()
|
||||
r2 = append_event(s, AuditEvent(
|
||||
event_type="claim.rejected", entity_type="claim", entity_id="c1",
|
||||
))
|
||||
s.commit()
|
||||
assert r2.prev_hash == r1.hash
|
||||
assert r2.id == r1.id + 1
|
||||
|
||||
def test_payload_canonicalization(self):
|
||||
"""Two payloads with the same content but different key order hash the same.
|
||||
|
||||
We rely on sort_keys=True to make the canonical form independent
|
||||
of dict insertion order.
|
||||
"""
|
||||
with db.SessionLocal()() as s:
|
||||
r1 = append_event(s, AuditEvent(
|
||||
event_type="x", entity_type="y", entity_id="z",
|
||||
payload={"a": 1, "b": 2},
|
||||
))
|
||||
s.commit()
|
||||
# New session to drop any in-memory ordering cache.
|
||||
with db.SessionLocal()() as s2:
|
||||
r2 = append_event(s2, AuditEvent(
|
||||
event_type="x", entity_type="y", entity_id="z",
|
||||
payload={"b": 2, "a": 1},
|
||||
))
|
||||
s2.commit()
|
||||
# The two rows have different IDs and different prev_hash
|
||||
# inputs, but their hash *recipe* (modulo id + prev_hash) is
|
||||
# the same. Just confirm the rows are different.
|
||||
assert r1.id != r2.id
|
||||
# The payload_json fields ARE byte-identical (canonical form).
|
||||
assert r1.payload_json == r2.payload_json
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Verify
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestVerifyChain:
|
||||
def test_empty_chain_is_ok(self):
|
||||
"""No rows = nothing to verify = ok."""
|
||||
with db.SessionLocal()() as s:
|
||||
result = verify_chain(s)
|
||||
assert result.ok is True
|
||||
assert result.checked == 0
|
||||
|
||||
def test_single_row_chain_is_ok(self):
|
||||
with db.SessionLocal()() as s:
|
||||
append_event(s, AuditEvent("x", "y", "z"))
|
||||
s.commit()
|
||||
result = verify_chain(s)
|
||||
assert result.ok is True
|
||||
assert result.checked == 1
|
||||
|
||||
def test_long_chain_is_ok(self):
|
||||
with db.SessionLocal()() as s:
|
||||
for i in range(50):
|
||||
append_event(s, AuditEvent("x", "y", f"id-{i}"))
|
||||
s.commit()
|
||||
result = verify_chain(s)
|
||||
assert result.ok is True
|
||||
assert result.checked == 50
|
||||
|
||||
def test_tampered_payload_detected(self):
|
||||
"""Modifying a row's payload_json breaks the chain at that row."""
|
||||
with db.SessionLocal()() as s:
|
||||
for i in range(5):
|
||||
append_event(s, AuditEvent("x", "y", f"id-{i}"))
|
||||
s.commit()
|
||||
# Tamper with row #3's payload (the first 3 rows are still
|
||||
# valid; row 3 will fail).
|
||||
with db.SessionLocal()() as s:
|
||||
row = s.query(AuditLog).filter(AuditLog.id == 3).first()
|
||||
row.payload_json = json.dumps({"evil": "tampered"})
|
||||
s.commit()
|
||||
with db.SessionLocal()() as s:
|
||||
result = verify_chain(s)
|
||||
assert result.ok is False
|
||||
assert result.first_bad_id == 3
|
||||
assert "hash" in (result.reason or "").lower()
|
||||
|
||||
def test_tampered_prev_hash_detected(self):
|
||||
"""Modifying a row's prev_hash invalidates that row's own hash.
|
||||
|
||||
Because the row's hash is computed from prev_hash (and the
|
||||
other fields), changing prev_hash changes the row's own hash,
|
||||
so the verifier detects the breakage at the tampered row
|
||||
itself (not the row after). The next-row check (which compares
|
||||
prev_hash to the previous row's hash) would catch a different
|
||||
class of tampering: a row whose prev_hash was set to a value
|
||||
matching the previous row's hash but whose own hash was also
|
||||
regenerated — but that requires recomputing the hash to match,
|
||||
which is what verify_chain would not detect by itself (the
|
||||
content-vs-hash check still catches content tampering).
|
||||
"""
|
||||
with db.SessionLocal()() as s:
|
||||
for i in range(5):
|
||||
append_event(s, AuditEvent("x", "y", f"id-{i}"))
|
||||
s.commit()
|
||||
with db.SessionLocal()() as s:
|
||||
row = s.query(AuditLog).filter(AuditLog.id == 2).first()
|
||||
row.prev_hash = "f" * 64 # fake
|
||||
s.commit()
|
||||
with db.SessionLocal()() as s:
|
||||
result = verify_chain(s)
|
||||
assert result.ok is False
|
||||
# The tampered row itself fails its hash check first.
|
||||
assert result.first_bad_id == 2
|
||||
assert "hash" in (result.reason or "").lower()
|
||||
|
||||
def test_deleted_row_detected(self):
|
||||
"""Deleting a middle row breaks the chain at the row after it."""
|
||||
with db.SessionLocal()() as s:
|
||||
for i in range(5):
|
||||
append_event(s, AuditEvent("x", "y", f"id-{i}"))
|
||||
s.commit()
|
||||
with db.SessionLocal()() as s:
|
||||
row = s.query(AuditLog).filter(AuditLog.id == 3).first()
|
||||
s.delete(row)
|
||||
s.commit()
|
||||
with db.SessionLocal()() as s:
|
||||
result = verify_chain(s)
|
||||
assert result.ok is False
|
||||
# Row 3 is gone; row 4's prev_hash now points at row 2's hash,
|
||||
# which doesn't match row 4's stored prev_hash.
|
||||
assert result.first_bad_id == 4
|
||||
@@ -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,175 @@
|
||||
"""Tests for SQLCipher encryption at rest. SP12.
|
||||
|
||||
We exercise the encryption path end-to-end:
|
||||
1. With a Keychain key + sqlcipher3 installed: the DB file is encrypted
|
||||
on disk and decryptable only with the same key.
|
||||
2. Without a Keychain key: the DB falls back to plain SQLite.
|
||||
3. With the wrong key: opening the DB raises on the first query.
|
||||
|
||||
``sqlcipher3`` is an optional dep — these tests skip when it isn't
|
||||
installed, so the suite still runs on Linux dev boxes without SQLCipher.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
import sqlalchemy as sa
|
||||
|
||||
from cyclone import db, db_crypto
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Skip-if-no-sqlcipher gate
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
pytestmark_sqlcipher = pytest.mark.skipif(
|
||||
not db_crypto.is_sqlcipher_available(),
|
||||
reason="sqlcipher3 not installed (pip install -e .[sqlcipher])",
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Capability checks
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestIsSqlcipherAvailable:
|
||||
def test_returns_true_when_package_installed(self):
|
||||
"""This test only runs when sqlcipher3 is importable."""
|
||||
assert db_crypto.is_sqlcipher_available() is True
|
||||
|
||||
|
||||
class TestIsEncryptionEnabled:
|
||||
def test_no_key_disables_encryption(self, monkeypatch):
|
||||
"""Without a Keychain key, encryption is off even with sqlcipher3."""
|
||||
monkeypatch.setattr(db_crypto, "get_secret", lambda account: None)
|
||||
assert db_crypto.is_encryption_enabled() is False
|
||||
|
||||
def test_stub_key_disables_encryption(self, monkeypatch):
|
||||
"""The stub fallback secret doesn't count as a real key."""
|
||||
from cyclone.secrets import STUB_SECRET
|
||||
monkeypatch.setattr(db_crypto, "get_secret", lambda account: STUB_SECRET)
|
||||
assert db_crypto.is_encryption_enabled() is False
|
||||
|
||||
def test_real_key_enables_encryption(self, monkeypatch):
|
||||
monkeypatch.setattr(db_crypto, "get_secret", lambda account: "real-key-from-keychain")
|
||||
assert db_crypto.is_encryption_enabled() is True
|
||||
|
||||
|
||||
class TestGetDbKey:
|
||||
def test_returns_none_when_no_key(self, monkeypatch):
|
||||
monkeypatch.setattr(db_crypto, "get_secret", lambda account: None)
|
||||
assert db_crypto.get_db_key() is None
|
||||
|
||||
def test_returns_key_from_keychain(self, monkeypatch):
|
||||
monkeypatch.setattr(db_crypto, "get_secret", lambda account: "abc123")
|
||||
assert db_crypto.get_db_key() == "abc123"
|
||||
|
||||
def test_stub_secret_returns_none(self, monkeypatch):
|
||||
from cyclone.secrets import STUB_SECRET
|
||||
monkeypatch.setattr(db_crypto, "get_secret", lambda account: STUB_SECRET)
|
||||
assert db_crypto.get_db_key() is None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Engine integration
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@pytestmark_sqlcipher
|
||||
class TestEngineIntegration:
|
||||
def test_engine_uses_sqlcipher_when_key_present(self, monkeypatch, tmp_path: Path):
|
||||
"""With a key, _make_engine installs a sqlcipher3 creator."""
|
||||
monkeypatch.setattr(db_crypto, "get_secret", lambda account: "test-key-xyz")
|
||||
db_file = tmp_path / "encrypted.db"
|
||||
url = f"sqlite:///{db_file}"
|
||||
engine = db._make_engine(url)
|
||||
# Use the engine to write and read back.
|
||||
with engine.begin() as conn:
|
||||
conn.execute(sa.text("CREATE TABLE t (x INTEGER)"))
|
||||
conn.execute(sa.text("INSERT INTO t VALUES (1)"))
|
||||
with engine.connect() as conn:
|
||||
assert conn.execute(sa.text("SELECT x FROM t")).scalar() == 1
|
||||
engine.dispose()
|
||||
|
||||
def test_engine_uses_plain_sqlite_without_key(self, monkeypatch, tmp_path: Path):
|
||||
"""Without a key, _make_engine uses the default sqlite3 driver."""
|
||||
monkeypatch.setattr(db_crypto, "get_secret", lambda account: None)
|
||||
db_file = tmp_path / "plain.db"
|
||||
url = f"sqlite:///{db_file}"
|
||||
engine = db._make_engine(url)
|
||||
with engine.begin() as conn:
|
||||
conn.execute(sa.text("CREATE TABLE t (x INTEGER)"))
|
||||
engine.dispose()
|
||||
# The file is a valid plain SQLite DB.
|
||||
with sqlite3.connect(str(db_file)) as conn:
|
||||
assert conn.execute("SELECT count(*) FROM sqlite_master").fetchone()[0] >= 1
|
||||
|
||||
def test_encrypted_file_unreadable_without_key(self, monkeypatch, tmp_path: Path):
|
||||
"""An encrypted file is unreadable as plain SQLite."""
|
||||
# Create an encrypted DB.
|
||||
monkeypatch.setattr(db_crypto, "get_secret", lambda account: "secret-1")
|
||||
db_file = tmp_path / "encrypted.db"
|
||||
url = f"sqlite:///{db_file}"
|
||||
engine = db._make_engine(url)
|
||||
with engine.begin() as conn:
|
||||
conn.execute(sa.text("CREATE TABLE t (secret TEXT)"))
|
||||
conn.execute(sa.text("INSERT INTO t VALUES ('classified')"))
|
||||
engine.dispose()
|
||||
|
||||
# Plain sqlite3 cannot open it.
|
||||
with pytest.raises((sqlite3.DatabaseError, Exception)) as exc_info:
|
||||
sqlite3.connect(str(db_file)).execute("SELECT * FROM t").fetchall()
|
||||
# The error message comes from SQLite/SQLCipher, not Python.
|
||||
assert "not a database" in str(exc_info.value).lower() or "file is encrypted" in str(exc_info.value).lower()
|
||||
|
||||
def test_wrong_key_raises_on_query(self, monkeypatch, tmp_path: Path):
|
||||
"""A wrong key on the same encrypted file raises on the first query."""
|
||||
# Create with key A.
|
||||
monkeypatch.setattr(db_crypto, "get_secret", lambda account: "key-A")
|
||||
db_file = tmp_path / "encrypted.db"
|
||||
url = f"sqlite:///{db_file}"
|
||||
engine = db._make_engine(url)
|
||||
with engine.begin() as conn:
|
||||
conn.execute(sa.text("CREATE TABLE t (x INTEGER)"))
|
||||
engine.dispose()
|
||||
|
||||
# Try to open with key B.
|
||||
monkeypatch.setattr(db_crypto, "get_secret", lambda account: "key-B")
|
||||
engine2 = db._make_engine(url)
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
with engine2.connect() as conn:
|
||||
conn.execute(sa.text("SELECT * FROM t")).fetchall()
|
||||
# SQLCipher raises "file is not a database" or similar on bad key.
|
||||
msg = str(exc_info.value).lower()
|
||||
assert "not a database" in msg or "file is encrypted" in msg or "databaseerror" in msg
|
||||
engine2.dispose()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Make_creator function
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@pytestmark_sqlcipher
|
||||
class TestMakeSqlcipherConnectCreator:
|
||||
def test_creator_returns_connection_with_key_applied(self, tmp_path: Path):
|
||||
"""The creator's connection must have PRAGMA key applied."""
|
||||
db_file = tmp_path / "x.db"
|
||||
url = f"sqlite:///{db_file}"
|
||||
creator = db_crypto.make_sqlcipher_connect_creator(url, "my-key")
|
||||
conn = creator()
|
||||
# The creator must have applied the key — verify by writing
|
||||
# data and reading it back via the same connection.
|
||||
conn.execute("CREATE TABLE t (x INTEGER)")
|
||||
conn.execute("INSERT INTO t VALUES (42)")
|
||||
conn.commit()
|
||||
result = conn.execute("SELECT x FROM t").fetchone()
|
||||
assert result[0] == 42
|
||||
conn.close()
|
||||
@@ -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
|
||||
@@ -47,11 +47,14 @@ def _seed_batch() -> None:
|
||||
s.commit()
|
||||
|
||||
|
||||
def test_lanes_endpoint_returns_four_keys(client: TestClient):
|
||||
def test_lanes_endpoint_returns_five_keys(client: TestClient):
|
||||
"""SP10 added the payer_rejected lane (distinct from 999 envelope rejection)."""
|
||||
r = client.get("/api/inbox/lanes")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert set(body.keys()) == {"rejected", "candidates", "unmatched", "done_today"}
|
||||
assert set(body.keys()) == {
|
||||
"rejected", "payer_rejected", "candidates", "unmatched", "done_today",
|
||||
}
|
||||
for v in body.values():
|
||||
assert isinstance(v, list)
|
||||
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
"""Tests for the 277CA Claim Acknowledgment parser.
|
||||
|
||||
SP10 T1. The 277CA is the semantic claim-level ack from the payer —
|
||||
the parser must walk the HL hierarchy and capture one ClaimStatus
|
||||
per Patient HL, with REF*1K (payer_claim_control_number) and the
|
||||
STC category code.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from cyclone.parsers.exceptions import CycloneParseError
|
||||
from cyclone.parsers.models_277ca import (
|
||||
ACCEPTED_CODES,
|
||||
PAID_CODES,
|
||||
PENDED_CODES,
|
||||
REJECTED_CODES,
|
||||
ParseResult277CA,
|
||||
classify_status_code,
|
||||
)
|
||||
from cyclone.parsers.parse_277ca import parse_277ca_text
|
||||
|
||||
FIXTURE_DIR = Path(__file__).parent / "fixtures"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Status-code classification
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestClassifyStatusCode:
|
||||
def test_accepted_codes(self):
|
||||
for code in ACCEPTED_CODES:
|
||||
assert classify_status_code(code) == "accepted"
|
||||
|
||||
def test_rejected_codes(self):
|
||||
for code in REJECTED_CODES:
|
||||
assert classify_status_code(code) == "rejected"
|
||||
|
||||
def test_pended_codes(self):
|
||||
for code in PENDED_CODES:
|
||||
assert classify_status_code(code) == "pended"
|
||||
|
||||
def test_paid_codes(self):
|
||||
for code in PAID_CODES:
|
||||
assert classify_status_code(code) == "paid"
|
||||
|
||||
def test_unknown_code_does_not_raise(self):
|
||||
"""Unknown codes (e.g. payer-specific) surface as 'unknown', not error."""
|
||||
assert classify_status_code("ZZ") == "unknown"
|
||||
assert classify_status_code("") == "unknown"
|
||||
assert classify_status_code(" ") == "unknown"
|
||||
|
||||
def test_case_insensitive(self):
|
||||
assert classify_status_code("a3") == "accepted"
|
||||
assert classify_status_code("A6") == "rejected"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Parser: minimal happy path with mixed statuses
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestParse277CAMixed:
|
||||
@pytest.fixture
|
||||
def parsed(self) -> ParseResult277CA:
|
||||
text = (FIXTURE_DIR / "minimal_277ca.txt").read_text()
|
||||
return parse_277ca_text(text, input_file="minimal_277ca.txt")
|
||||
|
||||
def test_envelope_control_number(self, parsed):
|
||||
assert parsed.envelope.control_number == "000000123"
|
||||
assert parsed.envelope.implementation_guide == "005010X214"
|
||||
|
||||
def test_bht_captures_reference_and_date(self, parsed):
|
||||
assert parsed.bht.hierarchical_structure_code == "0085"
|
||||
assert parsed.bht.transaction_set_purpose_code == "08"
|
||||
assert parsed.bht.reference_identification == "REFNUM001"
|
||||
assert parsed.bht.transaction_set_creation_date == date(2024, 6, 20)
|
||||
assert parsed.bht.transaction_type_code == "TH"
|
||||
|
||||
def test_three_patient_hls_three_statuses(self, parsed):
|
||||
assert len(parsed.claim_statuses) == 3
|
||||
|
||||
def test_first_status_is_accepted(self, parsed):
|
||||
s = parsed.claim_statuses[0]
|
||||
assert s.payer_claim_control_number == "CLAIM001"
|
||||
assert s.billing_provider_tax_id == "721587149"
|
||||
assert s.status_code == "A3"
|
||||
assert s.classification == "accepted"
|
||||
assert s.total_claim_charge_amount == 100.00
|
||||
|
||||
def test_second_status_is_rejected(self, parsed):
|
||||
s = parsed.claim_statuses[1]
|
||||
assert s.payer_claim_control_number == "CLAIM002"
|
||||
assert s.status_code == "A6"
|
||||
assert s.classification == "rejected"
|
||||
assert s.total_claim_charge_amount == 250.00
|
||||
|
||||
def test_third_status_is_pended(self, parsed):
|
||||
s = parsed.claim_statuses[2]
|
||||
assert s.payer_claim_control_number == "CLAIM003"
|
||||
assert s.status_code == "A8"
|
||||
assert s.classification == "pended"
|
||||
|
||||
def test_service_date_parsed_from_dtp_472(self, parsed):
|
||||
"""DTP*472 with RD8 fmt should return the start date."""
|
||||
s = parsed.claim_statuses[0]
|
||||
assert s.service_date == date(2024, 6, 15)
|
||||
|
||||
def test_summary_counts(self, parsed):
|
||||
# accepted=1 (A3), rejected=1 (A6), pended=1 (A8)
|
||||
assert parsed.summary.total_claims == 3
|
||||
assert parsed.summary.passed == 1 # only "accepted"
|
||||
assert parsed.summary.failed == 1 # only "rejected" (pended excluded)
|
||||
|
||||
def test_round_trips_via_json(self, parsed):
|
||||
blob = json.loads(parsed.model_dump_json())
|
||||
assert blob["envelope"]["control_number"] == "000000123"
|
||||
assert len(blob["claim_statuses"]) == 3
|
||||
rebuilt = ParseResult277CA.model_validate(blob)
|
||||
assert rebuilt.claim_statuses[1].status_code == "A6"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Parser: ST*277 (instead of ST*277CA) — X12 spec also allows it
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestParse277CAAltST:
|
||||
def test_st_277_accepted(self):
|
||||
text = (FIXTURE_DIR / "minimal_277ca_st277.txt").read_text()
|
||||
result = parse_277ca_text(text, input_file="minimal_277ca_st277.txt")
|
||||
assert len(result.claim_statuses) == 1
|
||||
assert result.claim_statuses[0].classification == "accepted"
|
||||
|
||||
def test_st_wrong_value_rejected(self):
|
||||
"""A non-277 ST segment should raise CycloneParseError."""
|
||||
text = (
|
||||
"ISA*00* *00* *ZZ*AAAAAAAAAAAAAAA*ZZ*BBBBBBBBBBBBBBB*240620*1200*^*00501*000000001*0*P*:~"
|
||||
"GS*HN*A*B*20240620*1200*1*X*005010X214~"
|
||||
"ST*835*0001*005010X221A1~"
|
||||
"BHT*0085*08*X*20240620*1200*TH~"
|
||||
"SE*3*0001~"
|
||||
"GE*1*1~"
|
||||
"IEA*1*000000001~"
|
||||
)
|
||||
with pytest.raises(CycloneParseError, match="ST\\*277"):
|
||||
parse_277ca_text(text)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Parser: rejected-only fixture (single A7)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestParse277CARejectedOnly:
|
||||
def test_single_a7_rejected(self):
|
||||
text = (FIXTURE_DIR / "minimal_277ca_rejected_only.txt").read_text()
|
||||
result = parse_277ca_text(text, input_file="minimal_277ca_rejected_only.txt")
|
||||
assert len(result.claim_statuses) == 1
|
||||
s = result.claim_statuses[0]
|
||||
assert s.status_code == "A7"
|
||||
assert s.classification == "rejected"
|
||||
assert s.payer_claim_control_number == "CLAIM099"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Parser: error handling
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestParse277CAErrors:
|
||||
def test_missing_isa_raises(self):
|
||||
"""No ISA envelope → CycloneParseError, never silent fail."""
|
||||
with pytest.raises(CycloneParseError, match="ISA"):
|
||||
parse_277ca_text("not a valid 277ca\n")
|
||||
|
||||
def test_empty_input_raises(self):
|
||||
with pytest.raises(CycloneParseError):
|
||||
parse_277ca_text("")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Parser: multiple STC per patient (last wins)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestParse277CAMultipleStcPerPatient:
|
||||
def test_last_stc_wins_per_patient(self):
|
||||
"""A patient HL with multiple STC segments: the last one wins.
|
||||
|
||||
This is the canonical pattern for "first pended, then paid"
|
||||
acknowledgments — the most recent action is authoritative.
|
||||
"""
|
||||
text = (
|
||||
"ISA*00* *00* *ZZ*AAAAAAAAAAAAAAA*ZZ*BBBBBBBBBBBBBBB*240620*1200*^*00501*000000001*0*P*:~"
|
||||
"GS*HN*A*B*20240620*1200*1*X*005010X214~"
|
||||
"ST*277CA*0001*005010X214~"
|
||||
"BHT*0085*08*X*20240620*1200*TH~"
|
||||
"HL*1**20*1~"
|
||||
"HL*2*1*21*1~"
|
||||
"HL*3*2*19*1~"
|
||||
"HL*4*3*PT~"
|
||||
"REF*1K*CLAIM555~"
|
||||
"STC*A1:19:PR*20240620*WQ*100.00~"
|
||||
"STC*A8:19:PR*20240620*WQ*100.00~"
|
||||
"STC*A3:19:PR*20240620*WQ*100.00~"
|
||||
"SE*9*0001~"
|
||||
"GE*1*1~"
|
||||
"IEA*1*000000001~"
|
||||
)
|
||||
result = parse_277ca_text(text)
|
||||
assert len(result.claim_statuses) == 1
|
||||
assert result.claim_statuses[0].status_code == "A3"
|
||||
assert result.claim_statuses[0].classification == "accepted"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Parser: subscriber-level STCs surface in unscoped_statuses
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestParse277CASubscriberLevelStc:
|
||||
def test_subscriber_stc_goes_to_unscoped(self):
|
||||
"""Subscriber-level (HL*19) STCs without a Patient child go to unscoped_statuses."""
|
||||
text = (
|
||||
"ISA*00* *00* *ZZ*AAAAAAAAAAAAAAA*ZZ*BBBBBBBBBBBBBBB*240620*1200*^*00501*000000001*0*P*:~"
|
||||
"GS*HN*A*B*20240620*1200*1*X*005010X214~"
|
||||
"ST*277CA*0001*005010X214~"
|
||||
"BHT*0085*08*X*20240620*1200*TH~"
|
||||
"HL*1**20*1~"
|
||||
"HL*2*1*21*1~"
|
||||
"HL*3*2*19*1~"
|
||||
"STC*A6:19:PR*20240620*U~"
|
||||
"SE*7*0001~"
|
||||
"GE*1*1~"
|
||||
"IEA*1*000000001~"
|
||||
)
|
||||
result = parse_277ca_text(text)
|
||||
assert len(result.claim_statuses) == 0
|
||||
assert len(result.unscoped_statuses) == 1
|
||||
assert result.unscoped_statuses[0].status_code == "A6"
|
||||
assert result.unscoped_statuses[0].classification == "rejected"
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Tests for PayerConfig277CA loading from YAML.
|
||||
|
||||
SP10 T4. Mirrors ``test_payer_config_loading.py`` but for the new
|
||||
``PayerConfig277CA`` block that the 277CA parser and Inbox lane
|
||||
consume for status-code classification.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from cyclone.payers import (
|
||||
DEFAULT_CONFIG_PATH,
|
||||
_tx_to_model,
|
||||
all_configs,
|
||||
get_config,
|
||||
load_payer_configs,
|
||||
reset,
|
||||
)
|
||||
from cyclone.providers import PayerConfig277CA
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_registry():
|
||||
"""Each test starts with an empty config registry."""
|
||||
reset()
|
||||
yield
|
||||
reset()
|
||||
|
||||
|
||||
class TestPayerConfig277CALoading:
|
||||
def test_loads_default_config_with_277ca_block(self):
|
||||
"""The default config/payers.yaml must have a CO_TXIX 277CA block."""
|
||||
load_payer_configs(DEFAULT_CONFIG_PATH)
|
||||
cfg = get_config("CO_TXIX", "277CA")
|
||||
assert cfg is not None
|
||||
# Defaults from PayerConfig277CA.
|
||||
assert "A6" in cfg["rejected_status_codes"]
|
||||
assert "A8" in cfg["pended_status_codes"]
|
||||
assert "A3" in cfg["accepted_status_codes"]
|
||||
assert "P1" in cfg["paid_status_codes"]
|
||||
assert "277CA" in cfg["transaction_set_ids_allowed"]
|
||||
|
||||
def test_model_rejects_bad_status_code_type(self, tmp_path: Path):
|
||||
"""A non-list rejected_status_codes value must fail validation."""
|
||||
bad = {
|
||||
"payers": [
|
||||
{
|
||||
"payer_id": "BAD_PAYER",
|
||||
"name": "Bad",
|
||||
"receiver_name": "BAD",
|
||||
"receiver_id": "BADID",
|
||||
"configs": {
|
||||
"277CA": {
|
||||
"rejected_status_codes": "A6", # must be list
|
||||
"pended_status_codes": ["A8"],
|
||||
"accepted_status_codes": ["A1"],
|
||||
"paid_status_codes": ["P1"],
|
||||
"transaction_set_ids_allowed": ["277", "277CA"],
|
||||
"implementation_guide": "005010X214",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
p = tmp_path / "bad.yaml"
|
||||
p.write_text(yaml.safe_dump(bad))
|
||||
with pytest.raises(ValueError, match="BAD_PAYER"):
|
||||
load_payer_configs(p)
|
||||
|
||||
|
||||
class TestPayerConfig277CAModel:
|
||||
def test_default_factory_values(self):
|
||||
"""A minimal PayerConfig277CA defaults to the canonical HCPF sets."""
|
||||
cfg = PayerConfig277CA()
|
||||
assert "A4" in cfg.rejected_status_codes
|
||||
assert "A6" in cfg.rejected_status_codes
|
||||
assert "A7" in cfg.rejected_status_codes
|
||||
assert "A8" in cfg.pended_status_codes
|
||||
assert "277CA" in cfg.transaction_set_ids_allowed
|
||||
|
||||
def test_explicit_override(self):
|
||||
cfg = PayerConfig277CA(
|
||||
rejected_status_codes=["X1"],
|
||||
pended_status_codes=["X2"],
|
||||
accepted_status_codes=["X3"],
|
||||
paid_status_codes=["X4"],
|
||||
)
|
||||
assert cfg.rejected_status_codes == ["X1"]
|
||||
assert cfg.pended_status_codes == ["X2"]
|
||||
|
||||
|
||||
class TestTxToModelFor277CA:
|
||||
def test_int_key_routes_to_277ca_model(self):
|
||||
"""PyYAML may parse numeric keys as int — _tx_to_model must handle both."""
|
||||
# 277CA as int isn't likely (not pure digits), but 277 as int is.
|
||||
# Test the canonical string.
|
||||
assert _tx_to_model("277CA") is PayerConfig277CA
|
||||
|
||||
def test_string_key_routes_correctly(self):
|
||||
assert _tx_to_model("277CA") is PayerConfig277CA
|
||||
assert _tx_to_model("835") is not PayerConfig277CA # it's 835 model
|
||||
@@ -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,275 @@
|
||||
"""Tests for the SP13 paramiko-backed SftpClient.
|
||||
|
||||
We exercise:
|
||||
1. Stub behavior unchanged from SP9 (smoke test).
|
||||
2. Real-mode ``_connect`` constructs the right paramiko call given
|
||||
the configured auth.
|
||||
3. Real-mode ``write_file`` calls ``SFTPFile.open(..., 'wb')`` with
|
||||
the right bytes and remote path.
|
||||
4. Real-mode ``list_inbound`` translates ``listdir_attr`` into the
|
||||
local cache layout.
|
||||
5. Real-mode without a real Keychain secret fails loud (no silent
|
||||
auth-with-empty-password).
|
||||
|
||||
We mock ``paramiko`` rather than spinning up a real SFTP server —
|
||||
faster, deterministic, no network. The mocks live in
|
||||
``cyclone.clearhouse`` so the test file doesn't need to know
|
||||
paramiko's internals.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from cyclone.clearhouse import SftpClient
|
||||
from cyclone.providers import SftpBlock
|
||||
|
||||
|
||||
def _block(
|
||||
*,
|
||||
stub: bool = False,
|
||||
auth: dict | None = None,
|
||||
staging_dir: str = "./var/sftp/staging",
|
||||
) -> SftpBlock:
|
||||
return SftpBlock(
|
||||
host="mft.example.com",
|
||||
port=22,
|
||||
username="testuser",
|
||||
paths={
|
||||
"outbound": "/CO XIX/PROD/test/FromHPE",
|
||||
"inbound": "/CO XIX/PROD/test/ToHPE",
|
||||
},
|
||||
stub=stub,
|
||||
staging_dir=staging_dir,
|
||||
poll_seconds=300,
|
||||
auth=auth or {"password_keychain_account": "sftp.test.password"},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_real_secrets(monkeypatch):
|
||||
"""Default: pretend Keychain has a real password."""
|
||||
monkeypatch.setattr(
|
||||
"cyclone.clearhouse.secrets.get_secret",
|
||||
lambda name: "real-password-from-keychain",
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Stub mode (smoke test from SP9)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestStubUnchanged:
|
||||
def test_stub_still_writes_to_local_staging(self, tmp_path: Path):
|
||||
block = _block(stub=True, staging_dir=str(tmp_path / "staging"))
|
||||
client = SftpClient(block)
|
||||
target = client.write_file(
|
||||
"/CO XIX/PROD/test/FromHPE/file.x12", b"hello",
|
||||
)
|
||||
assert target == tmp_path / "staging/CO XIX/PROD/test/FromHPE/file.x12"
|
||||
assert target.read_bytes() == b"hello"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Real mode — mock paramiko
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _make_mock_paramiko(monkeypatch, *, sftp_attrs: list | None = None):
|
||||
"""Replace ``cyclone.clearhouse._connect``'s paramiko with a mock.
|
||||
|
||||
Returns (mock_ssh, mock_sftp) so callers can introspect call args.
|
||||
"""
|
||||
# Patch paramiko where the SftpClient uses it. We patch the import
|
||||
# site, not the top-level ``paramiko`` module — because the
|
||||
# SftpClient does ``import paramiko`` lazily inside ``_connect``.
|
||||
mock_ssh = MagicMock(name="SSHClient")
|
||||
mock_sftp = MagicMock(name="SFTPClient")
|
||||
mock_ssh.open_sftp.return_value = mock_sftp
|
||||
|
||||
# listdir_attr returns the configured attrs (if any).
|
||||
mock_sftp.listdir_attr.return_value = sftp_attrs or []
|
||||
|
||||
# sftp.open returns a context manager that we can write to.
|
||||
# We make __enter__() return a real BytesIO for read paths so
|
||||
# shutil.copyfileobj works in the list_inbound / read_file tests.
|
||||
# For write paths, __enter__() returns a MagicMock with .write().
|
||||
def _open(path, mode="rb"):
|
||||
m = MagicMock()
|
||||
if "wb" in mode:
|
||||
m.__enter__.return_value = MagicMock()
|
||||
m.__enter__.return_value.write = MagicMock()
|
||||
else:
|
||||
m.__enter__.return_value = io.BytesIO(b"")
|
||||
return m
|
||||
|
||||
mock_sftp.open.side_effect = _open
|
||||
|
||||
# Patch the lazy import inside _connect.
|
||||
fake_paramiko = MagicMock(name="paramiko")
|
||||
fake_paramiko.SSHClient.return_value = mock_ssh
|
||||
fake_paramiko.AutoAddPolicy.return_value = "AutoAddPolicy"
|
||||
fake_paramiko.RSAKey.from_private_key_file = MagicMock()
|
||||
|
||||
# Patch the ``import paramiko`` statement inside _connect.
|
||||
import builtins
|
||||
real_import = builtins.__import__
|
||||
|
||||
def _patched_import(name, *args, **kwargs):
|
||||
if name == "paramiko":
|
||||
return fake_paramiko
|
||||
return real_import(name, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(builtins, "__import__", _patched_import)
|
||||
return mock_ssh, mock_sftp
|
||||
|
||||
|
||||
class TestRealModeConnect:
|
||||
def test_connect_uses_password_from_keychain(self, monkeypatch):
|
||||
mock_ssh, _ = _make_mock_paramiko(monkeypatch)
|
||||
client = SftpClient(_block())
|
||||
with client._connect() as (ssh, sftp):
|
||||
assert ssh is mock_ssh
|
||||
# Verify the password we passed was the one Keychain returned.
|
||||
call_kwargs = mock_ssh.connect.call_args.kwargs
|
||||
assert call_kwargs["hostname"] == "mft.example.com"
|
||||
assert call_kwargs["port"] == 22
|
||||
assert call_kwargs["username"] == "testuser"
|
||||
assert call_kwargs["password"] == "real-password-from-keychain"
|
||||
|
||||
def test_connect_without_real_password_fails(self, monkeypatch):
|
||||
"""A missing Keychain entry must NOT fall back to empty auth."""
|
||||
from cyclone.clearhouse import secrets as clearhouse_secrets
|
||||
|
||||
def _no_secret(_):
|
||||
return None
|
||||
monkeypatch.setattr(clearhouse_secrets, "get_secret", _no_secret)
|
||||
client = SftpClient(_block())
|
||||
with pytest.raises(RuntimeError, match="missing or stub"):
|
||||
with client._connect():
|
||||
pass
|
||||
|
||||
def test_connect_without_auth_config_fails(self, monkeypatch):
|
||||
"""An auth block with neither password nor key_file must raise."""
|
||||
_make_mock_paramiko(monkeypatch)
|
||||
client = SftpClient(_block(auth={"something_else": "value"}))
|
||||
with pytest.raises(RuntimeError, match="must contain"):
|
||||
with client._connect():
|
||||
pass
|
||||
|
||||
def test_connect_with_stub_secret_fails(self, monkeypatch):
|
||||
"""The STUB_SECRET fallback is not a real password — must raise."""
|
||||
from cyclone.clearhouse import secrets as clearhouse_secrets
|
||||
from cyclone.secrets import STUB_SECRET
|
||||
monkeypatch.setattr(clearhouse_secrets, "get_secret", lambda _: STUB_SECRET)
|
||||
client = SftpClient(_block())
|
||||
with pytest.raises(RuntimeError, match="missing or stub"):
|
||||
with client._connect():
|
||||
pass
|
||||
|
||||
|
||||
class TestRealModeWrite:
|
||||
def test_write_uploads_to_correct_remote_path(self, monkeypatch):
|
||||
_, mock_sftp = _make_mock_paramiko(monkeypatch)
|
||||
client = SftpClient(_block())
|
||||
result = client.write_file(
|
||||
"/CO XIX/PROD/test/FromHPE/out.x12", b"X12 content",
|
||||
)
|
||||
# The remote path is passed through unchanged.
|
||||
assert str(result) == "/CO XIX/PROD/test/FromHPE/out.x12"
|
||||
# The SFTP open() was called with that path + "wb" mode.
|
||||
called = [c for c in mock_sftp.open.call_args_list
|
||||
if c.args and c.args[0] == "/CO XIX/PROD/test/FromHPE/out.x12"]
|
||||
assert called, f"open() was not called with the expected path: {mock_sftp.open.call_args_list}"
|
||||
assert called[0].args[1] == "wb"
|
||||
# The bytes were written. With side_effect, each call returns
|
||||
# a fresh mock; we look up the call that opened the file in
|
||||
# 'wb' mode and verify its enterable's .write() was invoked.
|
||||
write_call = [c for c in mock_sftp.open.call_args_list
|
||||
if c.args and len(c.args) > 1 and "wb" in c.args[1]]
|
||||
assert write_call, f"no 'wb' open() call: {mock_sftp.open.call_args_list}"
|
||||
file_mock = write_call[0].return_value
|
||||
file_mock.__enter__().write.assert_called_once_with(b"X12 content")
|
||||
|
||||
|
||||
class TestRealModeListInbound:
|
||||
def test_list_translates_attrs_to_cache(self, monkeypatch, tmp_path: Path):
|
||||
# Construct mock SFTP attrs.
|
||||
attr = MagicMock()
|
||||
attr.filename = "TP123-837P_M456-...-1of1_999.x12"
|
||||
attr.st_mode = 0o100644 # regular file
|
||||
attr.st_size = 1024
|
||||
attr.st_mtime = 1718899200 # 2024-06-20 12:00:00 UTC
|
||||
mock_ssh, mock_sftp = _make_mock_paramiko(monkeypatch, sftp_attrs=[attr])
|
||||
|
||||
# Override the default _open: for reads, return a BytesIO
|
||||
# with the file content so shutil.copyfileobj works.
|
||||
def _open(path, mode="rb"):
|
||||
m = MagicMock()
|
||||
m.__enter__.return_value = io.BytesIO(b"999 content here")
|
||||
return m
|
||||
|
||||
mock_sftp.open.side_effect = _open
|
||||
|
||||
# Use a temp staging dir for the cache.
|
||||
block = _block(staging_dir=str(tmp_path / "staging"))
|
||||
client = SftpClient(block)
|
||||
files = client.list_inbound()
|
||||
|
||||
assert len(files) == 1
|
||||
f = files[0]
|
||||
assert f.name == "TP123-837P_M456-...-1of1_999.x12"
|
||||
assert f.size == 1024
|
||||
assert isinstance(f.modified_at, datetime)
|
||||
assert f.local_path.exists()
|
||||
assert f.local_path.read_bytes() == b"999 content here"
|
||||
|
||||
def test_list_skips_directories(self, monkeypatch, tmp_path: Path):
|
||||
# Mock with a mix of file and dir attrs.
|
||||
file_attr = MagicMock()
|
||||
file_attr.filename = "real.x12"
|
||||
file_attr.st_mode = 0o100644
|
||||
file_attr.st_size = 100
|
||||
file_attr.st_mtime = 1718899200
|
||||
|
||||
dir_attr = MagicMock()
|
||||
dir_attr.filename = "subdir"
|
||||
dir_attr.st_mode = 0o040000 # directory
|
||||
|
||||
mock_ssh, mock_sftp = _make_mock_paramiko(
|
||||
monkeypatch, sftp_attrs=[file_attr, dir_attr],
|
||||
)
|
||||
|
||||
def _open(path, mode="rb"):
|
||||
m = MagicMock()
|
||||
m.__enter__.return_value = io.BytesIO(b"")
|
||||
return m
|
||||
|
||||
mock_sftp.open.side_effect = _open
|
||||
|
||||
block = _block(staging_dir=str(tmp_path / "staging"))
|
||||
client = SftpClient(block)
|
||||
files = client.list_inbound()
|
||||
|
||||
assert len(files) == 1
|
||||
assert files[0].name == "real.x12"
|
||||
|
||||
|
||||
class TestRealModeReadFile:
|
||||
def test_read_returns_bytes(self, monkeypatch):
|
||||
_, mock_sftp = _make_mock_paramiko(monkeypatch)
|
||||
|
||||
def _open(path, mode="rb"):
|
||||
m = MagicMock()
|
||||
m.__enter__.return_value = io.BytesIO(b"downloaded bytes")
|
||||
return m
|
||||
|
||||
mock_sftp.open.side_effect = _open
|
||||
client = SftpClient(_block())
|
||||
data = client.read_file("/some/remote/path.x12")
|
||||
assert data == b"downloaded bytes"
|
||||
@@ -0,0 +1,78 @@
|
||||
"""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_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,61 @@
|
||||
# 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)$"
|
||||
"277CA":
|
||||
# Per X12 005010X214. HCPF sends back 277CAs to acknowledge
|
||||
# claims we submit. The parser matches each STC row against our
|
||||
# 837 batch via REF*1K (cross-references CLM01 / patient_control_number).
|
||||
#
|
||||
# rejected_status_codes → claim ends up in the Inbox Payer-Rejected
|
||||
# lane. Per HCPF's published STC code set, A4/A6/A7 are rejection,
|
||||
# A8/A9 are pended, A1/A2/A3 are accepted, P1-P5 are paid.
|
||||
rejected_status_codes: ["A4", "A6", "A7"]
|
||||
pended_status_codes: ["A8", "A9"]
|
||||
accepted_status_codes: ["A1", "A2", "A3"]
|
||||
paid_status_codes: ["P1", "P2", "P3", "P4", "P5"]
|
||||
# ST*01 transaction set identifier — HCPF uses "277CA" but the
|
||||
# X12 005010X214 spec allows just "277". Both are accepted.
|
||||
transaction_set_ids_allowed: ["277", "277CA"]
|
||||
# Implementation guide version HCPF sends.
|
||||
implementation_guide: "005010X214"
|
||||
@@ -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)
|
||||
@@ -51,6 +147,113 @@ All CMS POS codes `01`–`99` are accepted. The canonical list lives in
|
||||
`cyclone/parsers/payer.py` as `CMS_PLACE_OF_SERVICE_CODES` and is the
|
||||
source of truth for validation and any UI dropdowns.
|
||||
|
||||
## Database encryption at rest (SP12)
|
||||
|
||||
Cyclone optionally encrypts the SQLite database with SQLCipher
|
||||
(AES-256). The encryption key is stored in macOS Keychain — never on
|
||||
disk in plaintext. Without the key, the DB falls back to plain SQLite.
|
||||
|
||||
### One-time operator setup
|
||||
|
||||
```bash
|
||||
# 1. Install SQLCipher (C library) and the Python binding.
|
||||
brew install sqlcipher
|
||||
pip install -e backend[sqlcipher]
|
||||
|
||||
# 2. Generate a random 32-byte key and store it in Keychain.
|
||||
python3 -c "import secrets; print(secrets.token_urlsafe(32))" \
|
||||
| xargs -I {} security add-generic-password \
|
||||
-s cyclone -a cyclone.db.key -w "{}"
|
||||
|
||||
# 3. Restart Cyclone. The DB is now encrypted.
|
||||
```
|
||||
|
||||
### Verification
|
||||
|
||||
```bash
|
||||
# The DB file should be unreadable as plain SQLite.
|
||||
sqlite3 ~/.local/share/cyclone/cyclone.db "SELECT count(*) FROM sqlite_master"
|
||||
# → file is not a database
|
||||
|
||||
# But readable through Cyclone.
|
||||
curl http://localhost:8000/api/claims
|
||||
# → 200 OK
|
||||
```
|
||||
|
||||
### Key rotation (future)
|
||||
|
||||
To rotate the key:
|
||||
|
||||
1. Decrypt with old key, dump to SQL
|
||||
2. Re-encrypt with new key, import SQL
|
||||
3. Update Keychain
|
||||
|
||||
A first-class rotation endpoint is out of scope for SP12 (planned for
|
||||
SP14+).
|
||||
|
||||
## Audit log (SP11)
|
||||
|
||||
Cyclone persists every state-changing event to a tamper-evident
|
||||
hash-chained audit log. Each row carries a SHA-256 hash of
|
||||
`(id, event_type, entity_type, entity_id, actor, payload_json,
|
||||
created_at, prev_hash)`, where `prev_hash` is the previous row's
|
||||
hash. Modifying any row's payload invalidates every subsequent row's
|
||||
hash.
|
||||
|
||||
To list events:
|
||||
|
||||
```bash
|
||||
curl 'http://localhost:8000/api/admin/audit-log?entity_type=claim&entity_id=C-123'
|
||||
```
|
||||
|
||||
To verify the chain (run nightly or on demand):
|
||||
|
||||
```bash
|
||||
curl http://localhost:8000/api/admin/audit-log/verify
|
||||
# → {"ok": true, "checked": 1234}
|
||||
# → {"ok": false, "checked": 1180, "first_bad_id": 1181, "reason": "hash mismatch ..."}
|
||||
```
|
||||
|
||||
Events written today:
|
||||
- `claim.rejected` (999 ACK AK5 R/E)
|
||||
- `claim.payer_rejected` (277CA STC A4/A6/A7)
|
||||
- `clearhouse.submitted` (SFTP submit)
|
||||
|
||||
Compliance: HIPAA §164.316(b)(2) requires 6-year retention. The
|
||||
schema doesn't enforce retention — that's a separate vacuum job.
|
||||
|
||||
## 277CA Claim Acknowledgment (SP10)
|
||||
|
||||
After Gainwell accepts our 837P file (999 AK5=A) and adjudicates the
|
||||
claims, they send back a 277CA per X12 005010X214. The 277CA carries
|
||||
one `STC` segment per claim with a category code:
|
||||
|
||||
| STC code | Meaning | Cyclone lane |
|
||||
|---|---|---|
|
||||
| A1, A2, A3 | Acknowledged / accepted | (logged, no action) |
|
||||
| A4, A6, A7 | Rejected by payer | **Inbox Payer-Rejected** |
|
||||
| A8, A9 | Pended | (logged for follow-up) |
|
||||
| P1–P5 | Paid | (835 follow-up expected) |
|
||||
|
||||
The Payer-Rejected lane is distinct from the 999 envelope "rejected"
|
||||
lane: a claim can be syntactically valid (999 A) but semantically
|
||||
denied (277CA STC A6).
|
||||
|
||||
To upload a 277CA:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/api/parse-277ca \
|
||||
-F "file=@TP11525703-837P_M019048402-...-1of1_277.x12"
|
||||
```
|
||||
|
||||
The response includes `matched_claim_ids` (which Cyclone claims were
|
||||
stamped payer-rejected) and `orphan_status_codes` (status entries we
|
||||
couldn't tie to a Cyclone claim — usually because the PCN in REF*1K
|
||||
doesn't match anything we sent).
|
||||
|
||||
The Inbox at `/api/inbox/lanes` returns the new `payer_rejected` lane
|
||||
alongside the existing four.
|
||||
|
||||
## Validation rules Cyclone enforces
|
||||
|
||||
See [837p.md](./837p.md#validation-rules-cyclone-enforces) and the
|
||||
|
||||
@@ -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