docs(spec): design for SP27 remittances architecture refactor
Tier 1: split scheduler.py into handlers/ subpackage, dedup helpers,
loosen INBOUND_RE, add SFTP operation timeouts, surface SFTP errors
in Scheduler.status().
Tier 2: unify 835 ingest + reconciliation into one critical section,
add GET /api/claims/{id}/chain, guard matched_remittance_id ↔
Remittance.claim_id invariant, emit claim.rejected_after_remit audit
when a 277CA rejection hits a matched claim.
Status: Draft, awaiting user sign-off.
This commit is contained in:
+391
@@ -0,0 +1,391 @@
|
||||
# Sub-project 27 — Remittances Architecture Refactor: Design Spec
|
||||
|
||||
**Date:** 2026-06-29
|
||||
**Status:** Draft, awaiting user sign-off
|
||||
**Branch:** `sp27-remittances-architecture-refactor`
|
||||
**Aesthetic direction:** No new UI (chain view rendered into existing ClaimDrawer; no new page)
|
||||
|
||||
## 1. Scope
|
||||
|
||||
Cyclone today ships the full claim/ack/remit cycle end-to-end: outbound
|
||||
837 submitted via SFTP, inbound 999 / 277CA / 835 / TA1 polled and parsed,
|
||||
remits matched against claims. Live data is now flowing on the production
|
||||
system (~60K remits, ~542K 999 acks since 06/19). The architecture serving
|
||||
that cycle has accumulated rough edges that an afternoon of operational
|
||||
pressure surfaced:
|
||||
|
||||
- **Scheduler is fragile.** A 06/25 silent hang (no error logged) took
|
||||
the MFT poll down for ~3 days; recovered only because the operator
|
||||
restarted the backend. Root cause: `sftp.listdir_attr()` runs without
|
||||
a socket timeout, so when the MFT TCP-acks but stops responding the
|
||||
thread blocks indefinitely. The scheduler's status surface is too
|
||||
shallow to detect this in real time.
|
||||
- **Filename classification is brittle.** `edi/filenames.py:INBOUND_RE`
|
||||
requires a `_file_type.x12` suffix on inbound files. Gainwell's
|
||||
actual 835s ship without that suffix (see the 5×835 batch from
|
||||
6/15–6/19), so the only thing the scheduler could do was mark them
|
||||
`skipped`. Operators had to ingest those by hand.
|
||||
- **Helpers duplicate API logic.** `cyclone.scheduler._ack_count_summary`
|
||||
and `_ack_synthetic_source_batch_id` copy logic from
|
||||
`cyclone.api` with a comment saying so. Two places to keep in sync.
|
||||
- **Reconciliation is decoupled from ingest.** 835 ingest writes a
|
||||
`Remittance` with `adjustment_amount=0`, and a separate
|
||||
`reconcile.run()` pass later sums `CasAdjustment.amount` rows and
|
||||
overwrites `adjustment_amount`. The UI sees stale values until the
|
||||
pass runs, and the two phases can drift if reconcile is skipped or
|
||||
crashes mid-way.
|
||||
- **No unified claim-chain view.** A claim's 837 submission, 999 ack,
|
||||
277CA status, and 835 remit are reconstructed by joining across four
|
||||
pages and three queries. The "did this remit's claim ever get
|
||||
matched?" question requires manual SQL.
|
||||
|
||||
### In scope
|
||||
|
||||
Tier 1 (scheduler/ingest split, SFTP hardening, filename classifier):
|
||||
|
||||
1. Move the four per-file-type handlers (`999`, `835`, `277CA`, `TA1`)
|
||||
out of `backend/src/cyclone/scheduler.py` into
|
||||
`backend/src/cyclone/handlers/<type>.py`. Each handler exposes one
|
||||
`handle(text, source_file) -> (parser_used, claim_count)` function.
|
||||
2. Pull `_ack_count_summary`, `_ack_synthetic_source_batch_id`, and
|
||||
`_277ca_synthetic_source_batch_id` out of `scheduler.py` and out of
|
||||
`api.py` into a new `backend/src/cyclone/handlers/_ack_id.py`. Both
|
||||
callers import from one module.
|
||||
3. Loosen `edi/filenames.py:parse_inbound_filename` to accept filenames
|
||||
lacking `_file_type.x12`; fall back to `orig_tx` as the
|
||||
provisional `file_type`, and accept the file as either `835` (if
|
||||
`orig_tx` ends in `835`) or `999`/`277CA` (if `orig_tx` does).
|
||||
4. Wrap `clearhouse.SftpClient._list_inbound_paramiko` /
|
||||
`_list_inbound_names_paramiko` / `_download_inbound_paramiko` /
|
||||
`_read_file_paramiko` calls in `asyncio.wait_for(... timeout=N)`
|
||||
via `asyncio.to_thread`, with `N` configurable via
|
||||
`CYCLONE_SFTP_OP_TIMEOUT_SECONDS` (default 30s).
|
||||
5. Surface SFTP errors in `Scheduler.status()` — `last_error_at`,
|
||||
`last_error`, `consecutive_failures`, `last_sftp_attempt_at`.
|
||||
When `consecutive_failures >= 3`, the operator pill flips to a
|
||||
destructive state until a successful tick clears it.
|
||||
6. Stop swallowing `IOError` from `sftp.listdir_attr()` — return the
|
||||
error in `result.errors` and let the scheduler record `last_error`.
|
||||
|
||||
Tier 2 (reconciliation atomicity, chain view, invariant guards):
|
||||
|
||||
7. Unify 835 ingest + `reconcile.run()` into a single critical
|
||||
section inside `cyclone.handlers.handle_835`. The whole flow
|
||||
(parse → validate → persist batch + remits + CasAdjustments → match
|
||||
claims → write back `adjustment_amount` and `matched_remittance_id`)
|
||||
happens in one `db.SessionLocal()()`. The `Remittance.adjustment_amount`
|
||||
is computed in the same transaction as the `CasAdjustment` rows.
|
||||
8. Add `GET /api/claims/{id}/chain` returning the claim's full chain
|
||||
in one response: `submission` (837 → `Claim` row),
|
||||
`ack_999` (matching `Ack` rows), `ack_277ca` (matching
|
||||
`Two77caAck` rows), `remittance` (`Remittance` row including
|
||||
adjustments and matched claim FK). Empty slots when a piece is
|
||||
missing — never 404 the whole endpoint just because one piece is
|
||||
absent.
|
||||
9. The 999 ack handler and 277CA ack handler — when they reject a
|
||||
claim, and the claim is already matched to a remit — emit an
|
||||
`ActivityEvent` `claim.rejected_after_remit` so the operator
|
||||
surfaces the conflict in the Activity page rather than silently
|
||||
breaking the manual-match workflow.
|
||||
10. Verify the denormalized pair `Claim.matched_remittance_id` ↔
|
||||
`Remittance.claim_id` is written in the same transaction on every
|
||||
write-path (`store.manual_match`, `store.manual_unmatch`, the new
|
||||
atomic 835 handler). Add a startup invariant check: if any Claim
|
||||
has `matched_remittance_id` but the matching Remittance's
|
||||
`claim_id` is unset (or vice versa), log a structured error so an
|
||||
operator sees the drift in the logs.
|
||||
|
||||
### Out of scope (deferred to future increments)
|
||||
|
||||
- **Frontend scheduler operator UI** (start/stop/tick/status page,
|
||||
reconnect button, live log tail). The `/api/admin/scheduler/*`
|
||||
JSON endpoints remain sufficient; the operator uses curl.
|
||||
- **Multi-tenant SFTP blocks** (per-payer or per-tenant). Single
|
||||
dzinesco block remains.
|
||||
- **Docker-secret `_FILE` conventions** beyond what SP26 already added.
|
||||
- **Outbound file pickup verification** (polling HPE's MFT to
|
||||
confirm the operator-dropped 837 was received).
|
||||
- **Frontend chain drawer component.** The `GET /api/claims/{id}/chain`
|
||||
endpoint is exposed; rendering it inside the existing
|
||||
`ClaimDrawer` is a separate UI increment.
|
||||
|
||||
## 2. Decisions (locked during brainstorming)
|
||||
|
||||
1. **Branch base is `Version-1.0.0`, not `main`.** `main` is at
|
||||
`74aa64f` (SP26 only) and is missing the SP25+26 SFTP work, the
|
||||
`[sftp]` docker extra, and the permission-matrix fixes. The
|
||||
production system runs `Version-1.0.0`, so the refactor lands
|
||||
there. A subsequent forward-merge from `Version-1.0.0` → `main`
|
||||
can pull SP27 in if the operator wants it on main.
|
||||
|
||||
2. **One module per handler, not a class.** Each handler exposes one
|
||||
pure function `handle(text: str, source_file: str) -> HandleResult`,
|
||||
where `HandleResult` is a small dataclass with `parser_used`,
|
||||
`claim_count`, `batch_id?`, and per-handler extras (e.g. 835
|
||||
carries `matched_count`). No class hierarchy, no plugin registry —
|
||||
the scheduler keeps its `HANDLERS` dict as the registry.
|
||||
|
||||
3. **The `_FILE` env-var convention from SP26 stays.** This spec
|
||||
doesn't add a new secret tier; the existing `secrets.get_secret()`
|
||||
three-tier lookup (env var → file → Keychain) is reused.
|
||||
|
||||
4. **Time-bounded SFTP operations use `asyncio.to_thread` +
|
||||
`asyncio.wait_for`.** `paramiko` is synchronous; the scheduler
|
||||
already wraps calls in `asyncio.to_thread` for the stub path.
|
||||
Real-mode calls get the same treatment so a hanging `listdir_attr`
|
||||
on the worker thread can be cancelled by the event loop and
|
||||
surfaced as a `Scheduler._last_error`.
|
||||
|
||||
5. **Default SFTP operation timeout is 30s.** `CYCLONE_SFTP_OP_TIMEOUT_SECONDS`
|
||||
lets operators tune it down (e.g. 10s for a flaky VPN uplink) or
|
||||
up for slow connections. The poll-interval itself remains
|
||||
`CYCLONE_SCHEDULER_POLL_SECONDS` (default 60s).
|
||||
|
||||
6. **Reconciliation runs inside the 835 ingest session.** No
|
||||
background reconciler process. If a future need emerges (catch-up
|
||||
reconcile on old batches) it lands in a future SP with a
|
||||
separate `cyclone reconcile` CLI subcommand.
|
||||
|
||||
7. **Chain endpoint renders even when slots are empty.** The contract
|
||||
is "give me this claim's chain; if any piece is missing, return
|
||||
`null` for that field with `missing: ["277ca_ack"]` so the UI can
|
||||
show a placeholder." This matches the operator mental model:
|
||||
"I submitted 837 week-1, the 999 came back accepted, the 277CA
|
||||
came back accepted, but I haven't seen the 835 yet — show me that
|
||||
intermediate state."
|
||||
|
||||
8. **`Scheduler.status()` gains `consecutive_failures`,
|
||||
`last_error_at`, `last_error`, `last_sftp_attempt_at`.**
|
||||
The existing fields (`running`, `poll_interval_seconds`,
|
||||
`sftp_block_name`, `last_poll_at`, `poll_count`, totals, last_tick)
|
||||
are unchanged for backward-compat.
|
||||
|
||||
9. **The `claim.rejected_after_remit` audit event is informational,
|
||||
not blocking.** The 999/277CA handlers still write the rejection
|
||||
even when a matched remit exists; the audit event is the operator's
|
||||
signal to manually unmatch the pair. We do NOT auto-unmatch (would
|
||||
violate the manual-match semantics from T15).
|
||||
|
||||
10. **The startup invariant check is log-only.** It does not block
|
||||
boot. A drift between `Claim.matched_remittance_id` and
|
||||
`Remittance.claim_id` indicates a historical bug; we surface it
|
||||
in the logs and continue. Operators who want to reconcile the
|
||||
drift have a one-shot CLI subcommand (`cyclone reconcile
|
||||
reindex-matches`) deferred to a follow-up SP.
|
||||
|
||||
## 3. Architecture (after SP27)
|
||||
|
||||
```
|
||||
backend/src/cyclone/
|
||||
scheduler.py — slim: Scheduler class + HANDLERS registry + lifecycle
|
||||
handlers/ NEW subpackage
|
||||
__init__.py
|
||||
_ack_id.py — ack_count_summary, ack_synthetic_source_batch_id, *_277ca_*
|
||||
ack.py — top-level ack dispatch (re-exported helpers)
|
||||
handle_999.py — text → parsed 999 → ack row → claim rejections
|
||||
handle_835.py — text → parsed 835 → batch + remits + CasAdjustments
|
||||
+ match claims (atomic one-section)
|
||||
handle_277ca.py — text → parsed 277CA → ack row + claim rejections
|
||||
+ emit claim.rejected_after_remit
|
||||
handle_ta1.py — text → parsed TA1 → ack row
|
||||
clearhouse/__init__.py — _paramiko ops wrapped in asyncio.wait_for via to_thread
|
||||
edi/filenames.py — parse_inbound_filename accepts suffix-less names
|
||||
store.py — manual_match / manual_unmatch keep matched_remittance_id
|
||||
and Remittance.claim_id in sync (same transaction);
|
||||
startup invariant check
|
||||
api.py — /api/parse-835 deduped vs scheduler handlers via
|
||||
handlers.handle_835; new /api/claims/{id}/chain endpoint
|
||||
reconcile.py — run() folded into handle_835; top-level `match()`
|
||||
kept for the CLI subcommand (deferred)
|
||||
api_routers/chain.py NEW — GET /api/claims/{id}/chain (auth, matrix_gate)
|
||||
|
||||
src/
|
||||
hooks/useClaimChain.ts NEW — TanStack Query wrapper around /api/claims/{id}/chain
|
||||
pages/ClaimDrawer.tsx — one new section: "Chain" with submission / 999 / 277CA / remit
|
||||
placeholders; renders adjustments inline
|
||||
```
|
||||
|
||||
The scheduler shrinks from 860 LOC to ~250 LOC (the Scheduler class +
|
||||
singleton plumbing). Each `handle_*.py` is ~80–150 LOC and can be
|
||||
tested in isolation against a real prodfiles fixture.
|
||||
|
||||
## 4. Files
|
||||
|
||||
**New:**
|
||||
|
||||
- `backend/src/cyclone/handlers/__init__.py`
|
||||
- `backend/src/cyclone/handlers/_ack_id.py`
|
||||
- `backend/src/cyclone/handlers/handle_999.py`
|
||||
- `backend/src/cyclone/handlers/handle_835.py`
|
||||
- `backend/src/cyclone/handlers/handle_277ca.py`
|
||||
- `backend/src/cyclone/handlers/handle_ta1.py`
|
||||
- `backend/src/cyclone/api_routers/chain.py`
|
||||
- `src/hooks/useClaimChain.ts`
|
||||
- `backend/tests/test_handlers_999.py`
|
||||
- `backend/tests/test_handlers_835.py`
|
||||
- `backend/tests/test_handlers_277ca.py`
|
||||
- `backend/tests/test_handlers_ta1.py`
|
||||
- `backend/tests/test_inbound_filename_loose.py`
|
||||
- `backend/tests/test_sftp_op_timeout.py`
|
||||
- `backend/tests/test_scheduler_status_errors.py`
|
||||
- `backend/tests/test_handler_835_atomic_reconcile.py`
|
||||
- `backend/tests/test_api_claim_chain.py`
|
||||
- `backend/tests/test_handler_277ca_rejected_after_remit.py`
|
||||
- `backend/tests/test_store_match_invariant.py`
|
||||
- `src/hooks/useClaimChain.test.ts`
|
||||
- `src/pages/ClaimDrawer.test.tsx` (extension — the chain drawer section)
|
||||
|
||||
**Modified:**
|
||||
|
||||
- `backend/src/cyclone/scheduler.py` — slim to Scheduler class + lifecycle
|
||||
- `backend/src/cyclone/edi/filenames.py` — `parse_inbound_filename`
|
||||
accepts suffix-less inbound filenames; `is_inbound_filename` likewise
|
||||
- `backend/src/cyclone/clearhouse/__init__.py` — wrap paramiko
|
||||
operations in `asyncio.wait_for(asyncio.to_thread(...), timeout=N)`
|
||||
- `backend/src/cyclone/store.py` — `manual_match` /
|
||||
`manual_unmatch` write the pair in one transaction; startup
|
||||
invariant log; `add` (835 branch) calls `handlers.handle_835`
|
||||
instead of inlining
|
||||
- `backend/src/cyclone/reconcile.py` — `run` exposed for the
|
||||
follow-up CLI subcommand; `match` kept; the scheduler / 835
|
||||
handler stop calling `run` directly
|
||||
- `backend/src/cyclone/api.py` — `/api/parse-835` reuses
|
||||
`handlers.handle_835`; `/api/parse-999`, `/api/parse-277ca`,
|
||||
`/api/parse-ta1` likewise; add `/api/claims/{id}/chain` route
|
||||
- `backend/src/cyclone/api_helpers.py` (or new file) — wire
|
||||
`/api/claims/{id}/chain` if the route doesn't fit the existing
|
||||
pattern
|
||||
- `src/pages/ClaimDrawer.tsx` — one new "Chain" section
|
||||
- `src/lib/api.ts` — expose `api.fetchClaimChain(id)`
|
||||
- `src/types/index.ts` — add `ClaimChain` type
|
||||
|
||||
**New migrations:** none. The DB schema is unchanged.
|
||||
|
||||
## 5. API surface
|
||||
|
||||
| Method | Path | Returns | Auth |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/claims/{id}/chain` | `ClaimChain` JSON | `matrix_gate` (any logged-in user) |
|
||||
|
||||
No new endpoints beyond this one. No new env-var-driven behavior
|
||||
beyond `CYCLONE_SFTP_OP_TIMEOUT_SECONDS` (default 30s).
|
||||
|
||||
```jsonc
|
||||
// GET /api/claims/{id}/chain
|
||||
{
|
||||
"claim_id": "...",
|
||||
"submission": {
|
||||
"batch_id": "...",
|
||||
"patient_control_number": "...",
|
||||
"service_date_from": "2026-06-15",
|
||||
"service_date_to": "2026-06-15",
|
||||
"charge_amount": "125.00",
|
||||
"state": "submitted",
|
||||
"submitted_at": "2026-06-16T..."
|
||||
},
|
||||
"ack_999": {
|
||||
"source_batch_id": "999-PCN-12345678",
|
||||
"ack_code": "A",
|
||||
"received_count": 1,
|
||||
"accepted_count": 1,
|
||||
"rejected_count": 0,
|
||||
"received_at": "2026-06-17T..."
|
||||
} | null,
|
||||
"ack_277ca": {
|
||||
"source_batch_id": "277CA-000000001",
|
||||
"classification": "accepted",
|
||||
"received_at": "2026-06-18T..."
|
||||
} | null,
|
||||
"remittance": {
|
||||
"id": "...",
|
||||
"payer_claim_control_number": "...",
|
||||
"total_paid": "85.00",
|
||||
"patient_responsibility": "15.00",
|
||||
"adjustment_amount": "25.00",
|
||||
"status_label": "Paid",
|
||||
"received_at": "2026-06-19T...",
|
||||
"matched": true,
|
||||
"adjustments": [
|
||||
{ "group_code": "CO", "reason_code": "97", "amount": "25.00", "label": "..." }
|
||||
]
|
||||
} | null,
|
||||
"missing": ["277ca_ack"] // list of any of the above that came back null
|
||||
}
|
||||
```
|
||||
|
||||
## 6. Env vars (operator-facing)
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `CYCLONE_SFTP_OP_TIMEOUT_SECONDS` | `30` | Per-operation SFTP timeout in `clearhouse`. Lower for flaky links, higher for slow ones. The existing `CYCLONE_SCHEDULER_POLL_SECONDS` and `CYCLONE_SFTP_PASSWORD[_FILE]` from SP25+26 are unchanged. |
|
||||
|
||||
No new env vars beyond this one.
|
||||
|
||||
## 7. Validation rules
|
||||
|
||||
No new R-code rules. The 835 validator (`parser_835.validator_835`)
|
||||
is called unchanged by `handlers.handle_835` inside the same
|
||||
transaction; the new chain endpoint doesn't introduce new parser
|
||||
or validation logic.
|
||||
|
||||
## 8. Testing plan
|
||||
|
||||
Target backend test count after SP27: current + 17 new tests.
|
||||
|
||||
1. **Per-handler unit tests** (4 files × ~3 tests each):
|
||||
`test_handlers_999.py`, `test_handlers_835.py`,
|
||||
`test_handlers_277ca.py`, `test_handlers_ta1.py` — call each
|
||||
handler against a prodfiles fixture and assert the persisted
|
||||
rows.
|
||||
2. **`test_inbound_filename_loose.py`** (4 cases):
|
||||
- filename with `_835.x12` suffix → `file_type="835"` (existing behavior).
|
||||
- filename without `_file_type.x12` and `orig_tx=835` → `file_type="835"` (new).
|
||||
- filename without suffix and `orig_tx=999` → `file_type="999"` (new).
|
||||
- filename without suffix and `orig_tx=837P` → `file_type="999"` rejected by `ALLOWED_FILE_TYPES` — no false positive for 837s.
|
||||
3. **`test_sftp_op_timeout.py`** (3 cases): stub SFTP returns a slow
|
||||
`sftp.listdir_attr` (raises after 60s); assert the scheduler
|
||||
times out within `CYCLONE_SFTP_OP_TIMEOUT_SECONDS` + slack;
|
||||
stub returns fast → assert happy path still works.
|
||||
4. **`test_scheduler_status_errors.py`** (4 cases): `consecutive_failures`
|
||||
increments on tick error; `last_error` populated;
|
||||
`status_sftp_health` flips to `failing` after 3 fails; flips back
|
||||
on next success.
|
||||
5. **`test_handler_835_atomic_reconcile.py`** (3 cases): 835 ingest
|
||||
persists `Remittance` with `adjustment_amount` correct from the
|
||||
start; reconcile crash mid-transaction leaves no orphans; an
|
||||
existing claim gets `matched_remittance_id` set in the same
|
||||
transaction.
|
||||
6. **`test_api_claim_chain.py`** (5 cases): happy path with all four
|
||||
slots populated; missing ack_999 → `null + missing=["ack_999"]`;
|
||||
missing remittance → `null + missing=["remittance"]`; missing
|
||||
claim → 404; auth → 401.
|
||||
7. **`test_handler_277ca_rejected_after_remit.py`** (2 cases):
|
||||
277CA rejects a claim that is already matched → emits
|
||||
`claim.rejected_after_remit` audit event with both IDs;
|
||||
277CA rejects an unmatched claim → no audit event.
|
||||
8. **`test_store_match_invariant.py`** (3 cases):
|
||||
`manual_match` writes `Claim.matched_remittance_id` and
|
||||
`Remittance.claim_id` in one transaction;
|
||||
`manual_unmatch` clears both; pre-existing drift is logged at
|
||||
startup without blocking boot.
|
||||
9. **Frontend useClaimChain.test.ts** (3 cases): hook fetches the
|
||||
chain and returns typed data; loading/error states; caches
|
||||
for 30s.
|
||||
10. **Frontend ClaimDrawer.test.tsx extension** (2 cases): renders
|
||||
the chain section; renders placeholders for missing pieces.
|
||||
|
||||
The full backend suite (`cd backend && .venv/bin/pytest`) and
|
||||
frontend suite (`npm test`) remain the merge gate.
|
||||
|
||||
## 9. Out of scope (future SPs)
|
||||
|
||||
- Frontend scheduler operator UI (start/stop/tick/status page).
|
||||
- Multi-tenant / per-payer SFTP blocks.
|
||||
- `<NAME>_FILE` Docker-secret convention beyond what SP26 added.
|
||||
- Outbound file pickup verification.
|
||||
- Auto-unmatch on rejection (would violate T15 manual-match contract).
|
||||
- `cyclone reconcile reindex-matches` one-shot CLI for historical drift.
|
||||
- Per-payer chain-fanout (different payer-specific parsing of the same
|
||||
277CA segment).
|
||||
- Persisting the chain response server-side (it is a join, not a stored
|
||||
view; keeping it computed avoids drift).
|
||||
Reference in New Issue
Block a user