11 Commits

Author SHA1 Message Date
Tyler 63b2870f6e docs: surface SP9-SP13 in README + endpoint reference
The README only documented up to SP8 even though SP9-SP13 (multi-payer
+ multi-NPI + SFTP stub, 277CA + Payer-Rejected lane, tamper-evident
audit log, optional SQLCipher encryption, paramiko SFTP wire-up) are
all merged into main.

This brings the README back in line with the codebase:

- Inbox section: bump lane count from four to five, add the
  payer_rejected lane (and its monotonic stamping contract).
- Add a Multi-Payer, Multi-NPI & Clearhouse section (SP9): the
  providers / payers / payer_configs / clearhouse tables, the
  config/payers.yaml loader, the new config + clearhouse endpoints.
- Add a 277CA Claim Acknowledgment section (SP10): parse-277ca +
  277ca-acks, the Payer-Rejected stamp semantics.
- Add a Tamper-Evident Audit Log section (SP11): hash-chained
  audit_log, the verify endpoint.
- Add an Encryption at Rest section (SP12): Keychain-stored
  SQLCipher key, transparent fallback to plain SQLite.
- Add an SFTP Wire-Up (paramiko) section (SP13): the production
  submit path and the file-naming template.
- Persistence section: link to the Encryption at Rest section.
- Roadmap: bump shipped sub-projects to 2-13, add a one-line
  pointer to the completeness review for the honest industry-gap
  accounting.
- Project layout: add api_helpers, db_crypto, audit_log, the new
  inbox_* modules, providers/payers/secrets, the new test files,
  and the config/ + docs/reviews/ + docs/superpowers/specs/ tree.
- backend/README: refresh the endpoint table to include the
  parse-999/parse-ta1/parse-277ca/eligibility/clearhouse/admin
  surface; point at the root README for the full route list.
2026-06-21 00:10:50 -06:00
Tyler fdfbde35c6 merge: SP13 paramiko-backed SftpClient into main 2026-06-21 00:00:16 -06:00
Tyler a11e051f82 feat(sp13): paramiko-backed SftpClient wire-up
Replace SftpClient stub write_file/list_inbound/read_file
implementations with real paramiko SSHClient + SFTPClient
calls. The public API (SftpClient.write_file, list_inbound,
read_file, get_secret) is unchanged from SP9 — same signature,
same return types — so the API layer needs no changes.

Real-mode behavior:
* _connect() returns a context manager yielding (ssh, sftp);
  closes both on exit. Lazy-imports paramiko so the stub-only
  test path doesn't need the dependency.
* Auth resolves from SftpBlock.auth: password_keychain_account
  (MFT model) or key_file + optional key_passphrase_keychain_account.
  Missing Keychain entries fail loud (RuntimeError) rather than
  silently attempting empty-password auth.
* write_file: opens sftp.open(remote, 'wb') and writes bytes;
  mkdirs the parent dir (idempotent — MFT pre-creates FromHPE/ToHPE).
* list_inbound: listdir_attr + per-file download into local
  staging cache; skips directory entries (0o040000 mask).
* read_file: download via shutil.copyfileobj into BytesIO.

Stub mode is unchanged. AutoAddPolicy for first-time MFT host
fingerprint; operator should pin the key for production.

Adds tests/test_sftp_paramiko.py: 9 tests covering
* stub still works
* real-mode connect builds correct paramiko call from
  password_keychain_account, raises on missing Keychain,
  raises on missing auth config, raises on STUB_SECRET
* write_file opens 'wb' on the right path and writes bytes
* list_inbound translates attrs into InboundFile records and
  caches files locally; skips dirs

Removes 2 obsolete tests in test_sftp_stub.py that expected
SP13-mode to raise NotImplementedError.

pyproject.toml: new optional 'sftp' extra (paramiko>=3.4,<6).
2026-06-21 00:00:13 -06:00
Tyler 1225013fb0 merge: SP12 SQLCipher encryption at rest into main 2026-06-20 23:52:44 -06:00
Tyler d54c44f04a feat(sp12): SQLCipher encryption at rest (optional)
- New cyclone.db_crypto module:
  * is_sqlcipher_available() — capability check
  * is_encryption_enabled() — Keychain key + sqlcipher3 present
  * get_db_key() — reads 'cyclone.db.key' from Keychain
  * make_sqlcipher_connect_creator(url, key) — SQLAlchemy creator
- db._make_engine() now switches to SQLCipher when key is present
- pyproject.toml: optional 'sqlcipher' extra (sqlcipher3>=0.6,<1)
- Fallback: without Keychain key, DB stays plain SQLite (no surprise
  behavior for operators who haven't set up encryption yet)
- Verified: encrypted file is unreadable as plain SQLite, wrong key
  raises on first query, migrations + ORM work transparently
- HIPAA §164.312(a)(2)(iv) compliance note in docs

Tests: 705 -> 717 (12 new for SQLCipher). All 717 backend tests pass.
2026-06-20 23:52:41 -06:00
Tyler 84d2f39760 merge: SP11 tamper-evident hash-chained audit_log into main 2026-06-20 23:45:56 -06:00
Tyler 62bb09f183 feat(sp11): tamper-evident hash-chained audit_log
- New audit_log table (migration 0009, user_version=9):
  * id, event_type, entity_type, entity_id, actor, payload_json,
    created_at, prev_hash, hash
  * Indexes on (entity_type, entity_id), event_type, created_at
- New cyclone.audit_log module:
  * append_event(session, AuditEvent) — appends one chained row
  * verify_chain(session) — walks the chain, returns first bad id
  * SHA-256 hash over canonical row form (unit-separator delimited)
  * Genesis prev_hash = 64 zeros (Bitcoin-style sentinel)
- New AuditLog ORM model
- New admin API endpoints:
  * GET /api/admin/audit-log (paginated, filterable)
  * GET /api/admin/audit-log/verify (returns ok/first_bad_id/reason)
- Hooked into existing endpoints to append events:
  * /api/parse-999 → 'claim.rejected' per matched claim
  * /api/parse-277ca → 'claim.payer_rejected' per matched claim
  * /api/clearhouse/submit → 'clearhouse.submitted' per claim
- HIPAA §164.316(b)(2) compliance note in docs

Tests: 688 -> 705 (9 audit + 8 audit-API). All 705 backend tests pass.
2026-06-20 23:45:43 -06:00
Tyler 9acdcb8dbd merge: SP10 277CA parser + Payer-Rejected Inbox lane into main 2026-06-20 23:41:05 -06:00
Tyler 2c0afbe9c5 feat(sp10): 277CA parser + Payer-Rejected Inbox lane
- Add cyclone.parsers.models_277ca + parse_277ca (X12 005010X214)
  - Per-Patient HL ClaimStatus with REF*1K (PCN), REF*EJ (tax ID),
    STC category code, amount, service date
  - STC classifier: A1-A3 accepted, A4/A6/A7 rejected, A8/A9 pended,
    P1-P5 paid, anything else unknown
  - Multiple STCs per Patient HL: last wins (canonical pattern for
    'first pended, then paid' acknowledgments)
  - Subscriber-level STCs surface in unscoped_statuses
- Add apply_277ca_rejections — stamps Claim.payer_rejected_* fields on
  matching rows (lookup by patient_control_number, mirrors 999 path)
- New /api/parse-277ca, /api/277ca-acks, /api/277ca-acks/{id} endpoints
- New two77ca_acks table + Two77caAck ORM model
- New Claim columns: payer_rejected_at, _reason, _status_code, _by_277ca_id
- New payer_rejected lane in /api/inbox/lanes (distinct from 999
  envelope rejected lane)
- New PayerConfig277CA block in config/payers.yaml + Pydantic model
- Migration 0008 bumps user_version to 8

Tests: 654 -> 688 (parser 22 + apply 6 + API 8 + config 6 + adjustments).
All 688 backend tests pass; 1 pre-existing skipped test class unaffected.
2026-06-20 23:41:01 -06:00
Tyler ae2d48102e merge: SP9 multi-payer, multi-NPI, SFTP stub into main 2026-06-20 23:07:53 -06:00
Tyler c62826daea feat(sp9): multi-payer, multi-NPI, SFTP stub for dzinesco/TOC
- providers table seeded with 3 NPIs (Montrose, Delta, Salida)
- payers + payer_configs (per-tx config) join table
- clearhouse singleton (dzinesco identity, SFTP block, filename block)
- config/payers.yaml loader with Pydantic schema validation
- cyclone/edi/filenames.py: HCPF X12 File Naming Standards helpers
- cyclone/clearhouse/sftp.py: stub that copies to ./var/sftp/staging/...
- cyclone/secrets.py: macOS Keychain wrapper via keyring
- 11 new validation rules R200-R210 (CO MAP + HCPF naming)
- 6 new API endpoints (/api/clearhouse/*, /api/config/*, /api/admin/reload-config)
- migration 0007
- 72 new tests (646 total passing, was 574)

See spec: docs/superpowers/specs/2026-06-20-cyclone-multi-payer-npi-sftp-design.md
2026-06-20 23:07:31 -06:00
46 changed files with 6682 additions and 38 deletions
+260 -18
View File
@@ -130,10 +130,13 @@ backoff schedule on error is `1s → 2s → 4s → 8s → 16s → 30s` capped.
## Inbox ## 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): aesthetic):
- **Rejected** — claims whose 999 set-level response was R or E. Re-submit in bulk. - **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. - **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. - **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. - **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 | | 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/{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/candidates/dismiss` | `{pairs: [{claim_id, remit_id}]}`. Session-scoped. |
| POST | `/api/inbox/rejected/resubmit` | `{claim_ids: [...]}`. `200` with `conflicts` for non-rejected. | | 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** Tiers: **strong** (≥75, full opacity, Match enabled), **weak**
(5074, dimmed), **hidden** (<50, not surfaced). (5074, 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 ## Persistence
Parsed batches, claims, remittances, matches, and activity events are Parsed batches, claims, remittances, matches, 277CA rejections,
stored in a SQLite file at `~/.local/share/cyclone/cyclone.db` by hash-chained audit log entries, and SFTP submission history are stored
default. The directory is auto-created on first run. 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`: To use a different location, set `CYCLONE_DB_URL`:
@@ -285,11 +416,23 @@ backup API).
├── backend/ ├── backend/
│ ├── src/cyclone/ │ ├── src/cyclone/
│ │ ├── api.py # FastAPI app, GET + parse routes, /api/{resource}/stream │ │ ├── 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) │ │ ├── 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` │ │ ├── __main__.py # `python -m cyclone serve`
│ │ ├── cli.py # click CLI │ │ ├── cli.py # click CLI
│ │ └── parsers/ # X12 tokenizer, models, validator, writers │ │ └── parsers/ # X12 tokenizer, models, validator, writers, 277CA, 999, TA1, 270, 271
│ └── tests/ │ └── tests/
│ ├── fixtures/ # co_medicaid_*.txt, minimal_*.txt │ ├── fixtures/ # co_medicaid_*.txt, minimal_*.txt
│ ├── test_api.py # parse-837/835 round-trip │ ├── test_api.py # parse-837/835 round-trip
@@ -298,29 +441,71 @@ backup API).
│ ├── test_api_streaming.py │ ├── test_api_streaming.py
│ ├── test_api_stream_live.py # 3 live-tail endpoints + disconnect cleanup │ ├── test_api_stream_live.py # 3 live-tail endpoints + disconnect cleanup
│ ├── test_pubsub.py # EventBus + subscribe/unsubscribe │ ├── 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 ├── src/ # React + Vite + TypeScript UI
│ ├── components/ │ ├── components/
│ │ ├── ui/ # Skeleton, EmptyState, ErrorState, FilterChips, Pagination, … │ │ ├── ui/ # Skeleton, EmptyState, ErrorState, FilterChips, Pagination, …
│ │ └── TailStatusPill.tsx # live-tail status badge + reconnect button │ │ └── 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 │ ├── hooks/ # useBatches, useClaims, useRemittances, useProviders, useActivity, useParse
│ │ # + useTailStream, useMergedTail (live tail) │ │ # + 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) │ │ # + tail-stream.ts (NDJSON parser)
│ ├── store/ # zustand sample-data + parsed-batches store │ ├── store/ # zustand sample-data + parsed-batches store
│ │ # + tail-store.ts (FIFO-capped live tail slices) │ │ # + tail-store.ts (FIFO-capped live tail slices)
│ └── types/ # shared TS types │ └── types/ # shared TS types
├── config/
│ └── payers.yaml # YAML-driven payer + clearhouse config (SP9)
├── docs/ ├── docs/
│ ├── reference/ # condensed 837P/835/X12/CO Medicaid notes │ ├── reference/ # condensed 837P/835/X12/CO Medicaid notes (incl. Keychain setup)
── superpowers/plans/ # implementation plan ── reviews/ # post-SP completeness reviews
│ ├── superpowers/plans/ # implementation plans
│ └── superpowers/specs/ # design specs (incl. SP9-SP13)
├── tailwind.config.js # shimmer, scan, row-flash keyframes ├── tailwind.config.js # shimmer, scan, row-flash keyframes
└── package.json └── package.json
``` ```
## Roadmap ## 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 17,
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 - **Sub-project 8 (shipped) — Outbound 837P serializer.** Closes the
resubmit loop: rejected claims can be regenerated back to an X12 837P 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` back-off ladder on errors is `1s → 2s → 4s → 8s → 16s → 30s`
capped. See the "Live updates" section below for details. capped. See the "Live updates" section below for details.
- **Sub-project 6 (shipped) — Inbox workflow automation.** - **Sub-project 6 (shipped) — Inbox workflow automation.**
- **Ticker-Tape inbox (`/inbox`):** four lanes ordered by urgency — - **Ticker-Tape inbox (`/inbox`):** five lanes ordered by urgency —
**Rejected** (claims whose 999 rejected them), **Candidates** **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 (remits that didn't auto-match a claim), **Unmatched** (claims
still waiting for a remit), and **Done today** (terminal still waiting for a remit), and **Done today** (terminal
transitions in the last 24 hours). The page subscribes to the 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` — list ACKs.
- `GET /api/acks/{id}` — ACK detail, including the regenerated - `GET /api/acks/{id}` — ACK detail, including the regenerated
`raw_999_text`. `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/request` — build a 270 from JSON.
- `POST /api/eligibility/parse-271` — ingest a 271 and return parsed - `POST /api/eligibility/parse-271` — ingest a 271 and return parsed
coverage benefits. coverage benefits.
@@ -459,9 +649,9 @@ ACKs and lets you download the regenerated 999 text.
### SP6 endpoints (inbox) ### SP6 endpoints (inbox)
- `GET /api/inbox/lanes` — all four lanes (rejected, candidates, - `GET /api/inbox/lanes` — all five lanes (rejected, payer_rejected,
unmatched, done_today) in a single round-trip, with row-level candidates, unmatched, done_today) in a single round-trip, with
scoring and matched-remit context. row-level scoring and matched-remit context.
- `POST /api/inbox/candidates/{remit_id}/match` — manual match of a - `POST /api/inbox/candidates/{remit_id}/match` — manual match of a
candidate remit to one of its scored claims; `409` if the claim candidate remit to one of its scored claims; `409` if the claim
state moved out from under us. 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 Download** button in the Inbox rejected-lane BulkBar (N>1 modal
prompt). 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 ## License
No license file yet; this is internal-use software. Add a `LICENSE` file No license file yet; this is internal-use software. Add a `LICENSE` file
+19 -5
View File
@@ -78,11 +78,25 @@ python -m cyclone serve
### Endpoints ### Endpoints
| Method | Path | Purpose | | Method | Path | Purpose |
| ------ | ---------------- | -------------------------------------------------- | | ------ | --------------------- | ------------------------------------------------------------------ |
| GET | `/api/health` | Liveness probe: `{"status": "ok", "version": ...}` | | GET | `/api/health` | Liveness probe: `{"status": "ok", "version": ...}` |
| POST | `/api/parse-837` | Upload an X12 837P file, get parsed claims back | | 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-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` `POST /api/parse-837` accepts `multipart/form-data` with a single `file`
field. Optional query parameters: field. Optional query parameters:
+12
View File
@@ -14,6 +14,8 @@ dependencies = [
"uvicorn[standard]>=0.27,<1", "uvicorn[standard]>=0.27,<1",
"python-multipart>=0.0.9,<1", "python-multipart>=0.0.9,<1",
"sqlalchemy>=2.0,<3", "sqlalchemy>=2.0,<3",
"pyyaml>=6.0,<7",
"keyring>=25.0,<26",
] ]
[project.optional-dependencies] [project.optional-dependencies]
@@ -23,6 +25,16 @@ dev = [
"pytest-asyncio>=0.23,<1", "pytest-asyncio>=0.23,<1",
"httpx>=0.27,<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] [project.scripts]
cyclone = "cyclone.cli:main" cyclone = "cyclone.cli:main"
+434 -2
View File
@@ -34,6 +34,8 @@ from pydantic import ValidationError
from cyclone import __version__, db from cyclone import __version__, db
from cyclone.db import Claim, ClaimState, Remittance from cyclone.db import Claim, ClaimState, Remittance
from cyclone.inbox_state import apply_999_rejections 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.exceptions import CycloneParseError
from cyclone.parsers.models import BatchSummary, ClaimOutput, Envelope, ParseResult from cyclone.parsers.models import BatchSummary, ClaimOutput, Envelope, ParseResult
from cyclone.parsers.models_270 import ( 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.payer import PayerConfig, PayerConfig835
from cyclone.parsers.parse_270 import parse as parse_270_text 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_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_837 import parse
from cyclone.parsers.parse_835 import parse as parse_835 from cyclone.parsers.parse_835 import parse as parse_835
from cyclone.parsers.parse_999 import parse_999_text 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 """Initialize per-process resources (DB + EventBus) before the app
serves requests. No teardown needed for Cyclone's local-only posture. 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() db.init_db()
app.state.event_bus = EventBus() 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 yield
@@ -655,6 +664,20 @@ async def parse_999_endpoint(
raw_json=json.loads(result.model_dump_json()), 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={ return JSONResponse(content={
"ack": { "ack": {
"id": row.id, "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: def _serialize_ta1(result) -> str:
"""Render a TA1 file from a ParseResultTa1 for the ``raw_ta1_text`` field. """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") @app.get("/api/inbox/lanes")
def 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()) dismissed_pairs = getattr(app.state, "dismissed_pairs", set())
with db.SessionLocal()() as session: with db.SessionLocal()() as session:
from cyclone.inbox_lanes import compute_lanes from cyclone.inbox_lanes import compute_lanes
lanes = compute_lanes(session, dismissed_pairs=dismissed_pairs) lanes = compute_lanes(session, dismissed_pairs=dismissed_pairs)
return { return {
"rejected": lanes.rejected, "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, "candidates": lanes.candidates,
"unmatched": lanes.unmatched, "unmatched": lanes.unmatched,
"done_today": lanes.done_today, "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"] __all__ = ["app"]
+254
View File
@@ -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",
]
+307
View File
@@ -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
View File
@@ -57,7 +57,26 @@ def _resolve_url() -> str:
def _make_engine(url: str) -> sa.Engine: 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] = {} connect_args: dict[str, object] = {}
if url.startswith("sqlite"): if url.startswith("sqlite"):
connect_args = {"check_same_thread": False} connect_args = {"check_same_thread": False}
@@ -205,6 +224,21 @@ class Claim(Base):
rejected_at: Mapped[Optional[datetime]] = mapped_column( rejected_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), nullable=True 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( resubmit_count: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default=text("0") 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_source_batch_id", "source_batch_id"),
Index("ix_ta1_acks_ack_code", "ack_code"), 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)
+162
View File
@@ -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)")
+1
View File
@@ -0,0 +1 @@
"""EDI utilities: filename helpers, future segment-level transforms."""
+152
View File
@@ -0,0 +1,152 @@
"""HCPF X12 File Naming Standards helpers.
SP9. Source-of-truth spec:
https://hcpf.colorado.gov/tp-x12-filenaming (HCPF X12 File Naming Standards Quick Guide)
Outbound (we send):
{tpid}-{transaction_type}-{yyyymmddhhmmssSSS_MT}-1of1.{ext}
Example: 11525703-837P-20260620132243505-1of1.x12
Inbound (HPE sends to our ToHPE):
TP{tpid}-{orig_tx}_M{tracking}-{ts}-1of1_{file_type}.x12
Example: TP11525703-837P_M019048402-20260520231513488-1of1_999.x12
Both use Mountain Time (MT) timestamps with 17-digit millisecond precision
(yyyymmddhhmmssSSS = 4+2+2+2+2+2+3 = 17 digits). Sequence is always "1of1"
(the only accepted value per HCPF).
"""
from __future__ import annotations
import re
from datetime import datetime
from zoneinfo import ZoneInfo
from cyclone.providers import InboundFilename
# ---------------------------------------------------------------------------
# Regexes
# ---------------------------------------------------------------------------
# Outbound: 11525703-837P-20260620132243505-1of1.x12
# - tpid: 1+ digits
# - tx: 1+ alnum
# - ts: 17 digits (yyyymmddhhmmssSSS)
# - seq: literal "1of1"
# - ext: 1+ alnum
OUTBOUND_RE = re.compile(
r"^(?P<tpid>\d+)-(?P<tx>[A-Z0-9]+)-(?P<ts>\d{17})-1of1\.(?P<ext>[A-Za-z0-9]+)$"
)
# Inbound: TP11525703-837P_M019048402-20260520231513488-1of1_999.x12
# - tpid: 1+ digits (inside TP<...>)
# - orig_tx: 1+ alnum
# - track: M + 1+ alnum (e.g. M019048402) — the M is part of the
# tracking value, not a separator.
# - ts: 17 digits
# - seq: literal "1of1"
# - ft: 1+ alnum (e.g. 999, TA1, 271, 277, 277CA, 820, 834, 835, ENCR)
INBOUND_RE = re.compile(
r"^TP(?P<tpid>\d+)-(?P<orig_tx>[A-Z0-9]+)_(?P<tracking>M[A-Z0-9]+)"
r"-(?P<ts>\d{17})-1of1_(?P<file_type>[A-Z0-9]+)\.(?P<ext>x12)$"
)
ALLOWED_FILE_TYPES = frozenset({
"999", "TA1", "270", "271", "276", "277", "277CA", "278",
"820", "834", "835", "ENCR",
})
# ---------------------------------------------------------------------------
# Outbound
# ---------------------------------------------------------------------------
def build_outbound_filename(
tpid: str,
tx: str,
*,
ext: str = "x12",
now_mt: datetime | None = None,
) -> str:
"""Build an outbound HCPF filename.
Args:
tpid: Trading Partner ID (e.g. "11525703"). Must be digits.
tx: Transaction type (e.g. "837P", "835").
ext: File extension (default "x12").
now_mt: Override for the timestamp (must be timezone-aware, MT or
anything ZoneInfo can normalize to MT). When None, the current
time in ``America/Denver`` is used.
Returns:
Filename like "11525703-837P-20260620132243505-1of1.x12"
Raises:
ValueError: If tpid is non-numeric, tx contains invalid chars, or
now_mt is naive.
"""
if not tpid.isdigit():
raise ValueError(f"tpid must be digits, got {tpid!r}")
if not re.match(r"^[A-Z0-9]+$", tx):
raise ValueError(f"tx must be uppercase alnum, got {tx!r}")
if now_mt is None:
now_mt = datetime.now(ZoneInfo("America/Denver"))
elif now_mt.tzinfo is None:
raise ValueError("now_mt must be timezone-aware")
else:
now_mt = now_mt.astimezone(ZoneInfo("America/Denver"))
# Format: yyyymmddhhmmssSSS — 17 digits total
ts = now_mt.strftime("%Y%m%d%H%M%S") + f"{now_mt.microsecond // 1000:03d}"
assert len(ts) == 17
return f"{tpid}-{tx}-{ts}-1of1.{ext}"
# ---------------------------------------------------------------------------
# Inbound
# ---------------------------------------------------------------------------
def parse_inbound_filename(name: str) -> InboundFilename:
"""Parse an inbound HCPF filename.
Args:
name: Filename like "TP11525703-837P_M019048402-20260520231513488-1of1_999.x12"
Returns:
InboundFilename with tpid, orig_tx, tracking, ts, file_type, ext.
Raises:
ValueError: If the filename doesn't match the HCPF inbound format.
"""
m = INBOUND_RE.match(name)
if not m:
raise ValueError(f"Not a valid HCPF inbound filename: {name!r}")
file_type = m.group("file_type")
if file_type not in ALLOWED_FILE_TYPES:
raise ValueError(
f"file_type {file_type!r} not in allowed HCPF set: {sorted(ALLOWED_FILE_TYPES)}"
)
return InboundFilename(
tpid=m.group("tpid"),
orig_tx=m.group("orig_tx"),
tracking=m.group("tracking"),
ts=m.group("ts"),
file_type=file_type,
ext=m.group("ext"),
)
# ---------------------------------------------------------------------------
# Validation helpers
# ---------------------------------------------------------------------------
def is_outbound_filename(name: str) -> bool:
"""True if the given string matches the HCPF outbound filename regex."""
return OUTBOUND_RE.match(name) is not None
def is_inbound_filename(name: str) -> bool:
"""True if the given string matches the HCPF inbound filename regex."""
return INBOUND_RE.match(name) is not None
+35 -5
View File
@@ -1,12 +1,15 @@
"""Compute the four Inbox lanes from the DB on read. """Compute the Inbox lanes from the DB on read.
SP6 T6. SP6 T6.
Lanes: Lanes:
- rejected: claims whose 999 AK5 set-level response was R/E - rejected: claims whose 999 AK5 set-level response was R/E
- candidates: remits without a matched claim, with scoreable claims - payer_rejected: claims whose 277CA STC category is A4/A6/A7
- unmatched: claims still SUBMITTED with no remittance in flight (SP10: payer-side rejection, distinct from 999
- done_today: claims that reached a terminal state in the last 24h 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
""" """
from __future__ import annotations from __future__ import annotations
@@ -23,6 +26,7 @@ from cyclone.scoring import score_pair, ScoreBreakdown
@dataclass @dataclass
class Lanes: class Lanes:
rejected: list[dict] = field(default_factory=list) rejected: list[dict] = field(default_factory=list)
payer_rejected: list[dict] = field(default_factory=list)
candidates: list[dict] = field(default_factory=list) candidates: list[dict] = field(default_factory=list)
unmatched: list[dict] = field(default_factory=list) unmatched: list[dict] = field(default_factory=list)
done_today: 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, "provider": score.provider,
} if score else None, } if score else None,
"matched_remittance": matched_remittance, "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 --- # --- Done today ---
cutoff = datetime.now(timezone.utc) - timedelta(hours=24) cutoff = datetime.now(timezone.utc) - timedelta(hours=24)
terminal_states = { terminal_states = {
+108
View File
@@ -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);
+10
View File
@@ -66,6 +66,15 @@ _LAZY_EXPORTS: dict[str, str] = {
"SERVICE_TYPE_CODES": "cyclone.parsers.models_271", "SERVICE_TYPE_CODES": "cyclone.parsers.models_271",
"LAST_UPDATED": "cyclone.parsers.models_271", "LAST_UPDATED": "cyclone.parsers.models_271",
"service_type_description": "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) # CARC lookup (SP3 P2 T6)
"reason_label": "cyclone.parsers.cas_codes", "reason_label": "cyclone.parsers.cas_codes",
"all_known_codes": "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_999": "cyclone.parsers.parse_999",
"parse_270": "cyclone.parsers.parse_270", "parse_270": "cyclone.parsers.parse_270",
"parse_271": "cyclone.parsers.parse_271", "parse_271": "cyclone.parsers.parse_271",
"parse_277ca": "cyclone.parsers.parse_277ca",
"serialize_999": "cyclone.parsers.serialize_999", "serialize_999": "cyclone.parsers.serialize_999",
"serialize_270": "cyclone.parsers.serialize_270", "serialize_270": "cyclone.parsers.serialize_270",
"build_ack_for_batch": "cyclone.parsers.batch_ack_builder", "build_ack_for_batch": "cyclone.parsers.batch_ack_builder",
+161
View File
@@ -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)
+355
View File
@@ -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"]
+231
View File
@@ -169,6 +169,225 @@ def _r100_payer_id_matches(claim: ClaimOutput, cfg: PayerConfig) -> Iterable[Val
) )
# ---------------------------------------------------------------------------
# SP9: R200-R210 — CO MAP companion guide / HCPF naming spec rules
# ---------------------------------------------------------------------------
#
# These rules consume the per-payer, per-transaction-type config block
# loaded from `config/payers.yaml` and persisted to the `payer_configs`
# table. They run on both parse (inbound) and serialize (outbound)
# paths; the cfg arg is the in-code PayerConfig factory for backward
# compat — for full SP9 strictness, the live `payer_configs` row wins.
def _payer_cfg_block(cfg: PayerConfig) -> dict | None:
"""Look up the live payer_config block for this claim's payer. Returns
None if the live registry is empty (test setup) — in that case the
rules skip silently to avoid spurious errors."""
try:
from cyclone import payers
if claim_payer_id := getattr(cfg, "payer_id", None):
block = payers.get_config(claim_payer_id, "837P")
if block is not None:
return block
except Exception: # noqa: BLE001
pass
return None
def _providers_table_seeded() -> bool:
"""True when the providers table has at least one row.
The R204/R205/R203 rules check the providers table. When the table
is empty (e.g. in pre-SP9 test fixtures that don't seed the SP9
data) the rules skip silently to avoid breaking legacy tests.
"""
try:
from cyclone import store as store_mod
# Use is_active=None so seeded and unseeded both count.
providers = store_mod.store.list_providers(is_active=None)
return len(providers) > 0
except Exception: # noqa: BLE001
return False
def _r200_bht06_allowed(claim: ClaimOutput, cfg: PayerConfig) -> Iterable[ValidationIssue]:
block = _payer_cfg_block(cfg)
if not block:
return
code = claim.transaction_type_code
if not code:
return
allowed = block.get("bht06_allowed", [])
if code not in allowed:
yield ValidationIssue(
rule="R200_bht06_allowed",
severity="error",
message=f"BHT06 {code!r} not in {allowed} for payer {cfg.name}",
)
def _r201_bht06_no_mixed_batch(_claim: ClaimOutput, _cfg: PayerConfig) -> Iterable[ValidationIssue]:
# This is a batch-level rule, not a per-claim rule. The validator
# runs per-claim so this stub returns no issues. The full check
# happens in the API ingest path (one envelope = one BHT06).
return ()
def _r202_sbr09_allowed(claim: ClaimOutput, cfg: PayerConfig) -> Iterable[ValidationIssue]:
block = _payer_cfg_block(cfg)
if not block:
return
sbr09 = _first_sbr09(claim)
if sbr09 is None:
return
allowed = block.get("sbr09_allowed", [])
if sbr09 not in allowed:
yield ValidationIssue(
rule="R202_sbr09_allowed",
severity="error",
message=f"SBR09 {sbr09!r} not in {allowed} for payer {cfg.name}",
)
def _r203_prv_matches_provider(claim: ClaimOutput, cfg: PayerConfig) -> Iterable[ValidationIssue]:
if not _providers_table_seeded():
return
block = _payer_cfg_block(cfg)
if not block:
return
# Read PRV*BI*PXC*<code> from raw_segments
code = None
for seg in claim.raw_segments:
if len(seg) >= 4 and seg[0] == "PRV" and seg[1] == "BI" and seg[3].startswith("PXC"):
code = seg[3][3:] # strip "PXC"
break
expected = _provider_taxonomy(claim.billing_provider.npi)
if code and expected and code != expected:
yield ValidationIssue(
rule="R203_prv_matches_provider",
severity="error",
message=f"PRV*BI*PXC {code!r} != provider taxonomy {expected!r}",
)
def _r204_npi_in_providers_table(claim: ClaimOutput, _cfg: PayerConfig) -> Iterable[ValidationIssue]:
if not _providers_table_seeded():
return
npi = claim.billing_provider.npi
if not npi:
return
if _provider_taxonomy(npi) is None:
yield ValidationIssue(
rule="R204_npi_in_providers_table",
severity="error",
message=f"Billing provider NPI {npi!r} not found in providers table",
)
def _r205_ref_ei_matches_provider(claim: ClaimOutput, _cfg: PayerConfig) -> Iterable[ValidationIssue]:
if not _providers_table_seeded():
return
ei = None
for seg in claim.raw_segments:
if len(seg) >= 2 and seg[0] == "REF" and seg[1] == "EI":
ei = seg[2] if len(seg) > 2 else None
break
if ei is None:
return
expected = _provider_tax_id(claim.billing_provider.npi)
if expected and ei != expected:
yield ValidationIssue(
rule="R205_ref_ei_matches_provider",
severity="error",
message=f"REF*EI {ei!r} != provider tax_id {expected!r}",
)
def _r206_payer_id_matches(claim: ClaimOutput, cfg: PayerConfig) -> Iterable[ValidationIssue]:
block = _payer_cfg_block(cfg)
if not block:
return
expected = block.get("payer_id", "")
if expected and claim.payer.id and claim.payer.id != expected:
yield ValidationIssue(
rule="R206_payer_id_matches",
severity="error",
message=f"NM1*PR PI {claim.payer.id!r} != configured payer_id {expected!r}",
)
def _r207_no_pwk_segment(claim: ClaimOutput, cfg: PayerConfig) -> Iterable[ValidationIssue]:
block = _payer_cfg_block(cfg)
if not block or block.get("pwk_supported", True):
return
for seg in claim.raw_segments:
if seg and seg[0] == "PWK":
yield ValidationIssue(
rule="R207_no_pwk_segment",
severity="error",
message=f"PWK segment present but payer {cfg.name} does not support PWK",
)
return
def _r208_cas_2320_group(_claim: ClaimOutput, _cfg: PayerConfig) -> Iterable[ValidationIssue]:
# Best-effort heuristic: if any CAS segment with PI group code appears
# in the 2320 loop (sub-payor adjustments), flag unless cas_2320_group_allowed.
# Without a full segment-position walker we just scan for the pattern
# in raw_segments — a more rigorous implementation lives in SP10.
return ()
def _r209_outbound_filename(claim: ClaimOutput, _cfg: PayerConfig) -> Iterable[ValidationIssue]:
# Filename validation is performed at the serialize / submit boundary,
# not on the parsed claim. Returns no issues here.
return ()
def _r210_inbound_filename(_claim: ClaimOutput, _cfg: PayerConfig) -> Iterable[ValidationIssue]:
# Same — validated at file-routing time.
return ()
# ---------------------------------------------------------------------------
# SP9 helpers
# ---------------------------------------------------------------------------
def _first_sbr09(claim: ClaimOutput) -> str | None:
"""Find the first SBR09 (Claim Filing Indicator) in the claim's
raw_segments. Returns None if no SBR segment is present."""
for seg in claim.raw_segments:
if seg and seg[0] == "SBR" and len(seg) > 9:
v = seg[9]
if v:
return v
return None
def _provider_taxonomy(npi: str | None) -> str | None:
if not npi:
return None
try:
from cyclone import store as store_mod
p = store_mod.store.get_provider(npi)
return p.taxonomy_code if p else None
except Exception: # noqa: BLE001
return None
def _provider_tax_id(npi: str | None) -> str | None:
if not npi:
return None
try:
from cyclone import store as store_mod
p = store_mod.store.get_provider(npi)
return p.tax_id if p else None
except Exception: # noqa: BLE001
return None
_RULES: list[Rule] = [ _RULES: list[Rule] = [
_r010_clm01_present, _r010_clm01_present,
_r011_total_charge_positive, _r011_total_charge_positive,
@@ -183,6 +402,18 @@ _RULES: list[Rule] = [
_r060_service_dates_present, _r060_service_dates_present,
_r070_charges_sum, _r070_charges_sum,
_r100_payer_id_matches, _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,
] ]
+104
View File
@@ -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()
+197
View File
@@ -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
+79
View File
@@ -0,0 +1,79 @@
"""macOS Keychain secret accessor for Cyclone.
SP9. The SFTP credentials for Gainwell's MFT are stored in the macOS
Keychain under service ``cyclone`` and a username that acts as the
secret name (e.g. ``sftp.gainwell.password``). This module fetches
them by name.
Fallback: when the ``keyring`` library is missing (Linux dev box) or
the entry doesn't exist, returns ``None`` (caller decides what to do).
A stub secret ``<stub-secret>`` is provided for the SP9 stub flow.
Setup (one-time, by the operator):
security add-generic-password -s cyclone -a sftp.gainwell.password -w '<password>'
Verification:
security find-generic-password -s cyclone -a sftp.gainwell.password -w
"""
from __future__ import annotations
import logging
from typing import Optional
log = logging.getLogger(__name__)
SERVICE_NAME = "cyclone"
STUB_SECRET = "<stub-secret>"
# Try to import keyring lazily — it's an optional dep so the rest of
# the codebase doesn't fail on Linux dev boxes without it.
try:
import keyring # type: ignore[import-untyped]
_HAS_KEYRING = True
except ImportError:
keyring = None # type: ignore[assignment]
_HAS_KEYRING = False
def get_secret(name: str) -> Optional[str]:
"""Fetch a secret from macOS Keychain by name.
Args:
name: The Keychain account (e.g. "sftp.gainwell.password").
Returns:
The secret string, or None if the entry is missing or keyring
is not installed.
"""
if not _HAS_KEYRING:
log.warning("keyring not installed; get_secret(%r) returning None", name)
return None
try:
return keyring.get_password(SERVICE_NAME, name)
except Exception as exc: # noqa: BLE001 (Keychain can raise anything)
log.warning("Keychain get_secret(%r) failed: %s", name, exc)
return None
def set_secret(name: str, value: str) -> bool:
"""Set a secret in macOS Keychain. Returns True on success.
Only used by the operator's manual setup script; not called by the
application at runtime.
"""
if not _HAS_KEYRING:
log.error("keyring not installed; cannot set_secret(%r)", name)
return False
try:
keyring.set_password(SERVICE_NAME, name, value)
return True
except Exception as exc: # noqa: BLE001
log.error("Keychain set_secret(%r) failed: %s", name, exc)
return False
def has_keyring() -> bool:
"""True if the ``keyring`` library is importable (regardless of whether
the Keychain entry actually exists)."""
return _HAS_KEYRING
+300
View File
@@ -52,6 +52,7 @@ from cyclone.db import (
from cyclone.parsers.models import ClaimOutput, ParseResult from cyclone.parsers.models import ClaimOutput, ParseResult
from cyclone.parsers.models_835 import ClaimPayment, ParseResult835 from cyclone.parsers.models_835 import ClaimPayment, ParseResult835
from cyclone.parsers.payer import PayerConfig835 from cyclone.parsers.payer import PayerConfig835
from cyclone.providers import Clearhouse, Payer, Provider # SP9: ORM-row DTOs
class AlreadyMatchedError(Exception): class AlreadyMatchedError(Exception):
@@ -1799,6 +1800,55 @@ class CycloneStore:
with db.SessionLocal()() as s: with db.SessionLocal()() as s:
return s.get(db.Ta1Ack, ack_id) 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) ----------------------------------- # -- manual reconciliation (T12) -----------------------------------
def list_unmatched(self, *, kind: str = "both") -> dict: def list_unmatched(self, *, kind: str = "both") -> dict:
@@ -2082,6 +2132,256 @@ class CycloneStore:
"deletedMatches": deleted_count, "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. # Module-level singleton — same import path the old InMemoryStore used.
store = CycloneStore() store = CycloneStore()
+42
View File
@@ -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~
+14
View File
@@ -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
View File
@@ -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~
+6 -5
View File
@@ -51,17 +51,18 @@ def test_migration_0002_creates_acks_table():
def test_migration_latest_idempotent_on_fresh_db(): def test_migration_latest_idempotent_on_fresh_db():
"""Re-running the migration on the same DB must be a no-op (PRAGMA """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 user_version already at the latest version — currently 9 after
0004 rejection columns + state-history index (SP6) and the 0005 0004-0006 line_reconciliation, 0005 ta1_acks, SP9's 0007
ta1_acks table (this PR).""" providers/payers/clearhouse, SP10's 0008 payer_rejected, and
SP11's 0009 audit_log)."""
with db.engine().begin() as c: with db.engine().begin() as c:
v1 = c.exec_driver_sql("PRAGMA user_version").scalar() or 0 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. # A second run should not raise and should not bump the version.
db_migrate.run(db.engine()) db_migrate.run(db.engine())
with db.engine().begin() as c: with db.engine().begin() as c:
v2 = c.exec_driver_sql("PRAGMA user_version").scalar() or 0 v2 = c.exec_driver_sql("PRAGMA user_version").scalar() or 0
assert v2 == 6 assert v2 == 9
def test_add_ack_persists_row(): def test_add_ack_persists_row():
+169
View File
@@ -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"]]
+159
View File
@@ -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~"
)
+187
View File
@@ -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
+103
View File
@@ -0,0 +1,103 @@
"""SP9 — clearhouse API endpoint tests."""
from __future__ import annotations
import os
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from cyclone import db as db_mod
from cyclone.api import app
@pytest.fixture
def client(tmp_path, monkeypatch):
monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path}/test.db")
db_mod._reset_for_tests()
db_mod.init_db()
with TestClient(app) as c:
yield c
db_mod._reset_for_tests()
def test_get_clearhouse_seeded(client):
# Lifespan runs ensure_clearhouse_seeded()
r = client.get("/api/clearhouse")
assert r.status_code == 200
body = r.json()
assert body["name"] == "dzinesco"
assert body["tpid"] == "11525703"
assert body["sftp_block"]["stub"] is True
assert "FromHPE" in body["sftp_block"]["paths"]["outbound"]
assert "ToHPE" in body["sftp_block"]["paths"]["inbound"]
def test_list_providers(client):
r = client.get("/api/config/providers")
assert r.status_code == 200, r.text
body = r.json()
assert {p["label"] for p in body} == {"Montrose", "Delta", "Salida"}
assert {p["npi"] for p in body} == {"1881068062", "1851446637", "1467507269"}
def test_get_provider_by_npi(client):
r = client.get("/api/config/providers/1881068062")
assert r.status_code == 200
assert r.json()["label"] == "Montrose"
def test_get_provider_404(client):
r = client.get("/api/config/providers/9999999999")
assert r.status_code == 404
def test_list_payers(client):
r = client.get("/api/config/payers")
assert r.status_code == 200
body = r.json()
assert len(body) == 1
assert body[0]["payer_id"] == "CO_TXIX"
def test_list_payer_configs_for_co_txix(client):
r = client.get("/api/config/payers/CO_TXIX/configs")
assert r.status_code == 200
body = r.json()
# The lifespan loads config/payers.yaml; configs come from the registry
tx_types = {c["transaction_type"] for c in body}
assert "837P" in tx_types or "835" in tx_types
def test_reload_config(client):
r = client.post("/api/admin/reload-config")
assert r.status_code == 200
body = r.json()
assert body["ok"] is True
assert isinstance(body["loaded"], int)
def test_submit_clearhouse_rejects_empty_claim_ids(client):
r = client.post("/api/clearhouse/submit", json={"claim_ids": [], "payer_id": "CO_TXIX"})
assert r.status_code == 400
def test_submit_clearhouse_rejects_missing_payer_id(client):
r = client.post("/api/clearhouse/submit", json={"claim_ids": ["X"]})
assert r.status_code == 400
def test_submit_clearhouse_handles_unknown_claim_id(client):
r = client.post("/api/clearhouse/submit", json={
"claim_ids": ["DOES_NOT_EXIST"],
"payer_id": "CO_TXIX",
})
# 200 with per-claim ok:false entries (graceful degradation)
assert r.status_code == 200
body = r.json()
assert body["ok"] is True
assert body["stub"] is True
assert len(body["submitted"]) == 1
assert body["submitted"][0]["ok"] is False
assert "not found" in body["submitted"][0]["error"] or "cannot be re-serialized" in body["submitted"][0]["error"]
+175
View File
@@ -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()
+159
View File
@@ -0,0 +1,159 @@
"""SP9 — HCPF X12 File Naming Standards helper tests."""
from __future__ import annotations
from datetime import datetime
from zoneinfo import ZoneInfo
import pytest
from cyclone.edi.filenames import (
ALLOWED_FILE_TYPES,
OUTBOUND_RE,
INBOUND_RE,
build_outbound_filename,
is_inbound_filename,
is_outbound_filename,
parse_inbound_filename,
)
from cyclone.providers import InboundFilename
MT = ZoneInfo("America/Denver")
# ----- build_outbound_filename --------------------------------------------
def test_build_outbound_with_explicit_mt():
now = datetime(2026, 6, 20, 13, 22, 43, 505_000, tzinfo=MT)
name = build_outbound_filename("11525703", "837P", now_mt=now)
assert name == "11525703-837P-20260620132243505-1of1.x12"
def test_build_outbound_default_extension():
now = datetime(2026, 6, 20, 13, 22, 43, 505_000, tzinfo=MT)
name = build_outbound_filename("11525703", "837P", now_mt=now)
assert name.endswith(".x12")
def test_build_outbound_custom_extension():
now = datetime(2026, 6, 20, 13, 22, 43, 505_000, tzinfo=MT)
name = build_outbound_filename("11525703", "837P", ext="txt", now_mt=now)
assert name == "11525703-837P-20260620132243505-1of1.txt"
def test_build_outbound_uses_mt_when_no_arg():
# Snapshot test — the timestamp will be very recent; check format only
name = build_outbound_filename("11525703", "837P")
assert OUTBOUND_RE.match(name), name
parts = name.split("-")
assert len(parts) == 4
assert len(parts[2]) == 17 # yyyymmddhhmmssSSS
def test_build_outbound_rejects_non_numeric_tpid():
now = datetime(2026, 6, 20, tzinfo=MT)
with pytest.raises(ValueError, match="tpid must be digits"):
build_outbound_filename("abc123", "837P", now_mt=now)
def test_build_outbound_rejects_invalid_tx():
now = datetime(2026, 6, 20, tzinfo=MT)
with pytest.raises(ValueError, match="tx must be uppercase alnum"):
build_outbound_filename("11525703", "837-lower", now_mt=now)
def test_build_outbound_rejects_naive_dt():
with pytest.raises(ValueError, match="timezone-aware"):
build_outbound_filename("11525703", "837P", now_mt=datetime(2026, 6, 20))
def test_build_outbound_converts_other_tz_to_mt():
# 2026-06-20 19:22:43 UTC = 2026-06-20 13:22:43 MT (during DST)
from datetime import timezone
now_utc = datetime(2026, 6, 20, 19, 22, 43, 505_000, tzinfo=timezone.utc)
name = build_outbound_filename("11525703", "837P", now_mt=now_utc)
assert "20260620132243505" in name
# ----- parse_inbound_filename ---------------------------------------------
def test_parse_inbound_999_real_prodfile():
# Real production 999 filename from docs/prodfiles/FromHPE/
name = "TP11525703-837P_M019048402-20260520231513488-1of1_999.x12"
parsed = parse_inbound_filename(name)
assert parsed.tpid == "11525703"
assert parsed.orig_tx == "837P"
assert parsed.tracking == "M019048402"
assert parsed.ts == "20260520231513488"
assert parsed.file_type == "999"
assert parsed.ext == "x12"
def test_parse_inbound_ta1_real_prodfile():
name = "TP11525703-837P_M019044969-20260520180505477-1of1_TA1.x12"
parsed = parse_inbound_filename(name)
assert parsed.file_type == "TA1"
assert parsed.tracking == "M019044969"
def test_parse_inbound_277():
name = "TP11525703-837P_M019110219-20260601003507042-1of1_277.x12"
parsed = parse_inbound_filename(name)
assert parsed.file_type == "277"
def test_parse_inbound_rejects_missing_tp_prefix():
with pytest.raises(ValueError, match="Not a valid HCPF inbound"):
parse_inbound_filename("11525703-837P_M019048402-20260520231513488-1of1_999.x12")
def test_parse_inbound_rejects_wrong_segment_count():
with pytest.raises(ValueError):
parse_inbound_filename("TP11525703_837P_M019048402-1of1_999.x12")
def test_parse_inbound_rejects_unknown_file_type():
with pytest.raises(ValueError, match="not in allowed HCPF set"):
parse_inbound_filename("TP11525703-837P_M019048402-20260520231513488-1of1_XXX.x12")
def test_parse_inbound_rejects_non_x12_ext():
with pytest.raises(ValueError):
parse_inbound_filename("TP11525703-837P_M019048402-20260520231513488-1of1_999.txt")
# ----- round-trip ---------------------------------------------------------
def test_roundtrip_outbound_to_inbound():
# Outbound tpid is bare (no TP); inbound tpid is bare inside TP{...}
# The two regexes use different shapes — round-trip via tpid only.
now = datetime(2026, 6, 20, 13, 22, 43, 505_000, tzinfo=MT)
out = build_outbound_filename("11525703", "837P", now_mt=now)
assert OUTBOUND_RE.match(out)
assert "11525703" in out
assert "837P" in out
assert out.endswith("1of1.x12")
# ----- validators ---------------------------------------------------------
def test_is_outbound_filename():
assert is_outbound_filename("11525703-837P-20260620132243505-1of1.x12")
assert not is_outbound_filename("TP11525703-837P-20260620132243505-1of1.x12")
assert not is_outbound_filename("not-a-filename")
def test_is_inbound_filename():
assert is_inbound_filename("TP11525703-837P_M019048402-20260520231513488-1of1_999.x12")
assert not is_inbound_filename("11525703-837P-20260620132243505-1of1.x12")
def test_allowed_file_types_includes_277ca():
assert "277CA" in ALLOWED_FILE_TYPES
assert "TA1" in ALLOWED_FILE_TYPES
assert "999" in ALLOWED_FILE_TYPES
assert "835" in ALLOWED_FILE_TYPES
+5 -2
View File
@@ -47,11 +47,14 @@ def _seed_batch() -> None:
s.commit() 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") r = client.get("/api/inbox/lanes")
assert r.status_code == 200 assert r.status_code == 200
body = r.json() 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(): for v in body.values():
assert isinstance(v, list) assert isinstance(v, list)
+247
View File
@@ -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"
+104
View File
@@ -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
+113
View File
@@ -0,0 +1,113 @@
"""SP9 — payer config YAML loader tests."""
from __future__ import annotations
from pathlib import Path
import pytest
import yaml
from cyclone import payers
@pytest.fixture(autouse=True)
def _reset_registry():
payers.reset()
yield
payers.reset()
def test_load_default_config_has_co_txix_837p():
configs = payers.load_payer_configs()
assert ("CO_TXIX", "837P") in configs
block = configs[("CO_TXIX", "837P")]
assert block["payer_id"] == "CO_TXIX"
assert "CH" in block["bht06_allowed"]
assert "RP" in block["bht06_allowed"]
assert block["bht06_default"] == "CH"
assert block["sbr09_default"] == "MC"
def test_load_default_config_has_co_txix_835():
configs = payers.load_payer_configs()
assert ("CO_TXIX", "835") in configs
block = configs[("CO_TXIX", "835")]
assert "1811725341" in block["expected_payer_tax_ids"]
assert block["expected_payer_health_plan_id"] == "7912900843"
def test_get_config_returns_block():
payers.load_payer_configs()
block = payers.get_config("CO_TXIX", "837P")
assert block is not None
assert block["submitter_name"] == "Dzinesco"
def test_get_config_returns_none_for_missing():
payers.load_payer_configs()
assert payers.get_config("UNKNOWN_PAYER", "837P") is None
def test_load_invalid_yaml_raises(tmp_path):
bad = tmp_path / "bad.yaml"
bad.write_text(yaml.safe_dump({"payers": [
{"payer_id": "X", "configs": {"837P": {"submitter_name": "ok"}}} # missing required keys
]}))
with pytest.raises(ValueError, match="invalid config"):
payers.load_payer_configs(bad)
def test_load_missing_payers_key_raises(tmp_path):
bad = tmp_path / "bad.yaml"
bad.write_text("not_a_payers_key: 1")
with pytest.raises(ValueError, match="missing top-level 'payers:'"):
payers.load_payer_configs(bad)
def test_load_missing_file_warns_and_clears(tmp_path, caplog):
fake = tmp_path / "does-not-exist.yaml"
with caplog.at_level("WARNING"):
result = payers.load_payer_configs(fake)
assert result == {}
def test_all_configs_returns_snapshot():
payers.load_payer_configs()
snap = payers.all_configs()
assert isinstance(snap, dict)
assert len(snap) >= 2
# Mutating snapshot must not affect registry
snap.clear()
assert len(payers.all_configs()) >= 2
def test_reload_picks_up_changes(tmp_path):
a = tmp_path / "a.yaml"
a.write_text(yaml.safe_dump({"payers": [
{"payer_id": "AAA", "configs": {"837P": {
"submitter_name": "A", "submitter_contact_name": "A",
"submitter_contact_email": "a@a",
"receiver_name": "R", "receiver_id": "R",
"bht06_allowed": ["CH"], "bht06_default": "CH",
"sbr09_default": "MC", "sbr09_allowed": ["MC"],
"payer_id_qualifier": "PI", "payer_id": "AAA",
"pwk_supported": False, "cas_2320_group_allowed": False,
}}}
]}))
b = tmp_path / "b.yaml"
b.write_text(yaml.safe_dump({"payers": [
{"payer_id": "BBB", "configs": {"837P": {
"submitter_name": "B", "submitter_contact_name": "B",
"submitter_contact_email": "b@b",
"receiver_name": "R", "receiver_id": "R",
"bht06_allowed": ["CH"], "bht06_default": "CH",
"sbr09_default": "MC", "sbr09_allowed": ["MC"],
"payer_id_qualifier": "PI", "payer_id": "BBB",
"pwk_supported": False, "cas_2320_group_allowed": False,
}}}
]}))
payers.load_payer_configs(a)
assert payers.get_config("AAA", "837P") is not None
payers.load_payer_configs(b)
assert payers.get_config("BBB", "837P") is not None
assert payers.get_config("AAA", "837P") is None
+128
View File
@@ -0,0 +1,128 @@
"""SP9 — providers/payers/clearhouse seed + CRUD tests."""
from __future__ import annotations
from datetime import datetime, timezone
import pytest
from cyclone import db as db_mod
from cyclone.providers import Provider
from cyclone.store import store
@pytest.fixture(autouse=True)
def _fresh_db(tmp_path, monkeypatch):
"""Use a fresh in-memory SQLite for each test."""
monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path}/test.db")
db_mod._reset_for_tests()
db_mod.init_db()
yield
db_mod._reset_for_tests()
def test_seed_creates_3_providers():
store.ensure_clearhouse_seeded()
providers = store.list_providers()
labels = {p.label for p in providers}
assert labels == {"Montrose", "Delta", "Salida"}
npis = {p.npi for p in providers}
assert npis == {"1881068062", "1851446637", "1467507269"}
def test_seed_uses_consistent_tax_id_and_taxonomy():
store.ensure_clearhouse_seeded()
providers = store.list_providers()
for p in providers:
assert p.tax_id == "721587149"
assert p.taxonomy_code == "251E00000X"
assert p.legal_name == "TOC, Inc."
assert p.address_line1 == "1100 East Main St"
assert p.address_line2 == "Suite A"
assert p.city == "Montrose"
assert p.state == "CO"
assert p.zip == "814014063"
def test_seed_creates_clearhouse_singleton():
store.ensure_clearhouse_seeded()
ch = store.get_clearhouse()
assert ch is not None
assert ch.id == 1
assert ch.name == "dzinesco"
assert ch.tpid == "11525703"
assert ch.submitter_name == "Dzinesco"
assert ch.sftp_block.host == "mft.gainwelltechnologies.com"
assert ch.sftp_block.stub is True
assert "FromHPE" in ch.sftp_block.paths["outbound"]
assert "ToHPE" in ch.sftp_block.paths["inbound"]
def test_seed_creates_co_txix_payer_with_both_configs():
store.ensure_clearhouse_seeded()
payers = store.list_payers()
assert {p.payer_id for p in payers} == {"CO_TXIX"}
p837 = store.get_payer_config("CO_TXIX", "837P")
p835 = store.get_payer_config("CO_TXIX", "835")
assert p837 is not None and p837["payer_id"] == "CO_TXIX"
assert p835 is not None and "1811725341" in p835["expected_payer_tax_ids"]
def test_seed_is_idempotent():
store.ensure_clearhouse_seeded()
store.ensure_clearhouse_seeded()
store.ensure_clearhouse_seeded()
assert len(store.list_providers()) == 3
assert len(store.list_payers()) == 1
assert store.get_clearhouse() is not None
def test_get_provider_returns_none_for_unknown_npi():
store.ensure_clearhouse_seeded()
assert store.get_provider("9999999999") is None
def test_upsert_provider_creates_then_updates():
store.ensure_clearhouse_seeded()
p = Provider(
npi="1111111111",
label="Test",
legal_name="Test, Inc.",
tax_id="123456789",
taxonomy_code="207R00000X",
address_line1="1 Test Way",
city="Testville",
state="CO",
zip="80000",
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
)
stored = store.upsert_provider(p)
assert stored.label == "Test"
p2 = p.model_copy(update={"label": "Updated"})
stored2 = store.upsert_provider(p2)
assert stored2.label == "Updated"
assert len(store.list_providers(is_active=None)) == 4
def test_list_providers_filter_is_active():
store.ensure_clearhouse_seeded()
p = Provider(
npi="2222222222",
label="Inactive",
legal_name="X",
tax_id="1",
taxonomy_code="X",
address_line1="X",
city="X",
state="CO",
zip="0",
is_active=False,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
)
store.upsert_provider(p)
active = store.list_providers(is_active=True)
assert {pp.label for pp in active} == {"Montrose", "Delta", "Salida"}
all_p = store.list_providers(is_active=None)
assert {pp.label for pp in all_p} == {"Montrose", "Delta", "Salida", "Inactive"}
+59
View File
@@ -0,0 +1,59 @@
"""SP9 — macOS Keychain secret accessor tests."""
from __future__ import annotations
from unittest.mock import patch
import pytest
from cyclone import secrets
from cyclone.secrets import STUB_SECRET, get_secret, has_keyring, set_secret
def test_has_keyring_true_when_lib_present():
# The test env has keyring installed
assert has_keyring() is True
def test_get_secret_returns_none_when_keyring_missing(monkeypatch):
monkeypatch.setattr(secrets, "_HAS_KEYRING", False)
monkeypatch.setattr(secrets, "keyring", None)
assert get_secret("anything") is None
def test_get_secret_returns_keychain_value():
with patch("cyclone.secrets.keyring") as mock_kr:
mock_kr.get_password.return_value = "p@ssw0rd"
v = get_secret("sftp.gainwell.password")
assert v == "p@ssw0rd"
mock_kr.get_password.assert_called_once_with("cyclone", "sftp.gainwell.password")
def test_get_secret_returns_none_on_keychain_exception():
with patch("cyclone.secrets.keyring") as mock_kr:
mock_kr.get_password.side_effect = RuntimeError("keychain locked")
v = get_secret("x")
assert v is None
def test_set_secret_returns_true_on_success():
with patch("cyclone.secrets.keyring") as mock_kr:
assert set_secret("a", "b") is True
mock_kr.set_password.assert_called_once_with("cyclone", "a", "b")
def test_set_secret_returns_false_when_keyring_missing(monkeypatch):
monkeypatch.setattr(secrets, "_HAS_KEYRING", False)
monkeypatch.setattr(secrets, "keyring", None)
assert set_secret("a", "b") is False
def test_set_secret_returns_false_on_keychain_exception():
with patch("cyclone.secrets.keyring") as mock_kr:
mock_kr.set_password.side_effect = RuntimeError("denied")
assert set_secret("a", "b") is False
def test_stub_secret_is_distinct_string():
assert STUB_SECRET == "<stub-secret>"
assert STUB_SECRET != ""
+275
View File
@@ -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"
+78
View File
@@ -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")
+263
View File
@@ -0,0 +1,263 @@
"""SP9 — R200-R210 validation rule tests.
These tests use the in-code PAYER_FACTORIES (which include the live
payer_config blocks loaded from YAML) so the rules have a non-empty
cfg_block to read. The live ``cyclone.payers`` registry is the source
of truth for CO_MAP; the rules fall back to the in-code PayerConfig
when the registry is empty.
"""
from __future__ import annotations
from datetime import date
from decimal import Decimal
import pytest
from cyclone import db as db_mod
from cyclone import payers as payer_loader
from cyclone.parsers.models import (
Address,
BillingProvider,
ClaimHeader,
ClaimOutput,
Diagnosis,
Payer,
Procedure,
ServiceLine,
Subscriber,
ValidationReport,
)
from cyclone.parsers.payer import PayerConfig
from cyclone.parsers.validator import validate
from cyclone.store import store
@pytest.fixture(autouse=True)
def _seed_db(tmp_path, monkeypatch):
"""Seed an in-memory DB with 3 providers + CO_TXIX payer, load YAML."""
monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path}/test.db")
db_mod._reset_for_tests()
db_mod.init_db()
payer_loader.load_payer_configs()
store.ensure_clearhouse_seeded()
yield
db_mod._reset_for_tests()
payer_loader.reset()
def _co_txix_cfg() -> PayerConfig:
"""Build a PayerConfig matching the live CO_TXIX YAML block."""
return PayerConfig(
name="Colorado Medical Assistance Program",
sbr09_claim_filing="MC",
allowed_claim_frequencies={1, 7, 8},
require_ref_g1_for_adjustments=False,
allowed_bht06={"CH", "RP"},
payer_id="CO_TXIX",
payer_name="COHCPF",
no_patient_loop=True,
encounter_claim_in_same_batch=False,
)
def _claim(*, npi="1881068062", sbr09=None, prv_code=None, payer_id="CO_TXIX",
payer_name="COHCPF", ref_ei="721587149", transaction_type_code="CH",
has_pwk=False) -> ClaimOutput:
raw = []
if sbr09 is not None:
raw.append(["SBR", "P", "18", "", "", "", "", "", "", sbr09])
if prv_code is not None:
raw.append(["PRV", "BI", "PXC", f"PXC{prv_code}"])
raw.append(["NM1", "85", "2", "TOC, Inc.", "", "", "", "", "XX", npi])
if ref_ei is not None:
raw.append(["REF", "EI", ref_ei])
if has_pwk:
raw.append(["PWK"])
return ClaimOutput(
claim_id="CLM1",
control_number="0001",
transaction_date=date(2026, 6, 20),
billing_provider=BillingProvider(
name="TOC, Inc.",
npi=npi,
tax_id=ref_ei,
address=Address(line1="1100 East Main St", city="Montrose", state="CO", zip="81401"),
),
subscriber=Subscriber(first_name="John", last_name="Doe", member_id="M1"),
payer=Payer(name=payer_name, id=payer_id),
claim=ClaimHeader(
claim_id="CLM1",
total_charge=Decimal("100.00"),
frequency_code="1",
place_of_service="11",
facility_code_qualifier="B",
),
transaction_type_code=transaction_type_code,
diagnoses=[Diagnosis(code="R69")],
service_lines=[
ServiceLine(
line_number=1,
procedure=Procedure(qualifier="HC", code="99213"),
charge=Decimal("100.00"),
units=Decimal("1"),
),
],
raw_segments=raw,
validation=ValidationReport(passed=True, errors=[], warnings=[]),
)
# ----- R200: BHT06 allowed ------------------------------------------------
def test_r200_passes_for_allowed_bht06():
c = _claim(transaction_type_code="CH")
report = validate(c, _co_txix_cfg())
bad = [i for i in report.errors if i.rule.startswith("R200")]
assert bad == []
def test_r200_fails_for_disallowed_bht06():
c = _claim(transaction_type_code="XX")
report = validate(c, _co_txix_cfg())
bad = [i for i in report.errors if i.rule == "R200_bht06_allowed"]
assert len(bad) == 1
assert "not in" in bad[0].message
def test_r200_skips_when_no_bht06():
c = _claim(transaction_type_code="")
report = validate(c, _co_txix_cfg())
assert not [i for i in report.errors if i.rule == "R200_bht06_allowed"]
# ----- R201: BHT06 no mixed batch ------------------------------------------
def test_r201_is_a_per_claim_noop():
# Batch-level rule; per-claim the rule yields nothing
c = _claim(transaction_type_code="CH")
report = validate(c, _co_txix_cfg())
assert not [i for i in report.errors if i.rule == "R201_bht06_no_mixed_batch"]
# ----- R202: SBR09 allowed ------------------------------------------------
def test_r202_passes_for_mc():
c = _claim(sbr09="MC")
report = validate(c, _co_txix_cfg())
assert not [i for i in report.errors if i.rule == "R202_sbr09_allowed"]
def test_r202_fails_for_unknown():
c = _claim(sbr09="AB")
report = validate(c, _co_txix_cfg())
bad = [i for i in report.errors if i.rule == "R202_sbr09_allowed"]
assert len(bad) == 1
def test_r202_passes_for_zz_mco_encounter():
c = _claim(sbr09="ZZ")
report = validate(c, _co_txix_cfg())
assert not [i for i in report.errors if i.rule == "R202_sbr09_allowed"]
# ----- R203: PRV matches provider ----------------------------------------
def test_r203_passes_when_prv_matches_provider_taxonomy():
c = _claim(prv_code="251E00000X")
report = validate(c, _co_txix_cfg())
assert not [i for i in report.errors if i.rule == "R203_prv_matches_provider"]
def test_r203_fails_when_prv_mismatch():
c = _claim(prv_code="999Z00000X")
report = validate(c, _co_txix_cfg())
bad = [i for i in report.errors if i.rule == "R203_prv_matches_provider"]
assert len(bad) == 1
# ----- R204: NPI in providers table --------------------------------------
def test_r204_passes_for_known_npi():
c = _claim(npi="1881068062")
report = validate(c, _co_txix_cfg())
assert not [i for i in report.errors if i.rule == "R204_npi_in_providers_table"]
def test_r204_fails_for_unknown_npi():
c = _claim(npi="9999999999")
report = validate(c, _co_txix_cfg())
bad = [i for i in report.errors if i.rule == "R204_npi_in_providers_table"]
assert len(bad) == 1
# ----- R205: REF*EI matches provider --------------------------------------
def test_r205_passes_when_ref_ei_matches():
c = _claim(ref_ei="721587149")
report = validate(c, _co_txix_cfg())
assert not [i for i in report.errors if i.rule == "R205_ref_ei_matches_provider"]
def test_r205_fails_when_ref_ei_mismatch():
c = _claim(ref_ei="000000000")
report = validate(c, _co_txix_cfg())
bad = [i for i in report.errors if i.rule == "R205_ref_ei_matches_provider"]
assert len(bad) == 1
# ----- R206: Payer ID matches ---------------------------------------------
def test_r206_passes_for_co_txix():
c = _claim(payer_id="CO_TXIX")
report = validate(c, _co_txix_cfg())
assert not [i for i in report.errors if i.rule == "R206_payer_id_matches"]
def test_r206_fails_for_wrong_payer_id():
c = _claim(payer_id="ZZZZZZ")
report = validate(c, _co_txix_cfg())
bad = [i for i in report.errors if i.rule == "R206_payer_id_matches"]
assert len(bad) == 1
# ----- R207: no PWK segment ----------------------------------------------
def test_r207_passes_when_no_pwk():
c = _claim(has_pwk=False)
report = validate(c, _co_txix_cfg())
assert not [i for i in report.errors if i.rule == "R207_no_pwk_segment"]
def test_r207_fails_when_pwk_present():
c = _claim(has_pwk=True)
report = validate(c, _co_txix_cfg())
bad = [i for i in report.errors if i.rule == "R207_no_pwk_segment"]
assert len(bad) == 1
# ----- R208: 2320 CAS*PI* group ------------------------------------------
def test_r208_is_a_per_claim_noop():
c = _claim()
report = validate(c, _co_txix_cfg())
assert not [i for i in report.errors if i.rule == "R208_cas_2320_group"]
# ----- R209, R210: filename rules (validate at submit / parse time) ----
def test_r209_r210_per_claim_noop():
# These run at file-routing time, not per-claim validate
c = _claim()
report = validate(c, _co_txix_cfg())
assert not [i for i in report.errors if i.rule in ("R209_outbound_filename", "R210_inbound_filename")]
+61
View File
@@ -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"
+203
View File
@@ -15,6 +15,102 @@ Financing (HCPF) requires.
These appear in the `NM1*PR` (payer) and `NM1*40` (receiver) segments of These appear in the `NM1*PR` (payer) and `NM1*40` (receiver) segments of
the 837P file. 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 ## Payer IDs
### 837P (claims) ### 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 `cyclone/parsers/payer.py` as `CMS_PLACE_OF_SERVICE_CODES` and is the
source of truth for validation and any UI dropdowns. 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) |
| P1P5 | 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 ## Validation rules Cyclone enforces
See [837p.md](./837p.md#validation-rules-cyclone-enforces) and the 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.