Commit Graph

604 Commits

Author SHA1 Message Date
Nora d1cd6e1a51 feat(sp35): /api/parse-835 envelope + empty-claims guards
Mirror of the parse-837 SP35 guards. Same defense-in-depth shape:
tokenize first, reject anything whose ST01 doesn't start with '835'
(400 'Mismatched file kind'), and after parse refuse to persist a
batch with zero CLP segments (400 'No claims parsed').

Reuses the _transaction_set_id_from_segments helper added by the
parse-837 commit.

New tests in tests/test_api_835.py:
- test_parse_835_endpoint_rejects_837_input (was failing, now green)
- test_parse_835_endpoint_rejects_empty_envelope (was failing, now green)
- test_parse_835_endpoint_happy_path_still_works (regression guard)
2026-07-06 09:52:51 -06:00
Nora f25214189a feat(sp35): /api/parse-837 envelope + empty-claims guards
Server-side defense in depth for misroute ingest. Before SP35, posting
an 835 file (or any other X12 with a parseable ISA envelope) to
/api/parse-837 silently produced a BatchRecord with claims=[] and a
bogus row on the History tab. The 837 parser only required an ISA
envelope; it didn't check the ST transaction-set id.

Two new guards run before persistence:

1. Envelope check: tokenize first, read ST01, reject anything that
   doesn't start with '837'. 400 with error='Mismatched file kind',
   expected='837p', detected_st=<actual>. Catches an 835/999/270/etc
   routed to the wrong endpoint.
2. Empty-claims check: even with the right envelope, if the parser
   produces zero CLM segments, return 400 'No claims parsed' and do
   NOT persist.

New tests in tests/test_api.py:
- test_parse_837_endpoint_rejects_835_input (was failing, now green)
- test_parse_837_endpoint_rejects_empty_envelope (was failing, now green)
- test_parse_837_endpoint_happy_path_still_works (regression guard)

Helper _transaction_set_id_from_segments reused by the 835 mirror.
2026-07-06 09:51:27 -06:00
Nora e4f3d25f3a docs(plan): SP35 parse-input-guards — server guards + UI auto-detect + 999/277CA/TA1 regression locks, TDD-first 2026-07-06 09:45:50 -06:00
Nora 750f560ee0 docs(spec): SP35 parse-input-guards — defense in depth against misroute silent-corruption 2026-07-06 09:45:50 -06:00
Nora 0193ee4c32 merge: SP33 co-txix-payer-fix into main
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 20:29:27 -06:00
Nora 6bda5005c1 feat(sp33): resubmit CLI: skipped files don't audit or count as uploads
Finishes the in-tree was_skipped draft: size-matched remote files no
longer emit clearhouse.submitted events or increment uploaded, and the
periodic reconnect now triggers only after real uploads.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 20:29:13 -06:00
Nora 27bca33b09 feat(sp33): fix resubmit CLI session reuse + mismatch skip
- payer-mismatch files are now skipped instead of uploaded
- SFTP session persists across files (was reopened and leaked per file)
- --reconnect-every now counts successful uploads, after increment
- drop unused SftpClient instantiation; read each file once
- apply_999_rejections docstring: R/E/X, not R/E

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 12:32:30 -06:00
Nora f10ab83628 feat(sp33): cli resubmit-rejected-claims + fixture/test payer_id refresh
- Adds `cyclone resubmit-rejected-claims` to push corrected single-claim
  837 files to the Gainwell ToHPE SFTP dir. Idempotent (stat-then-skip by
  byte size). One persistent paramiko session per batch with
  reconnect-every=50 to dodge MOVEit's silent per-session file cap
  (~200 puts/session, no exception).
- Validates each file via `parse_837` before upload and rejects any
  whose payer_id is not `CO_TXIX` (catches a bad byte-fix early).
- Refreshes test fixtures (`minimal_837p.txt`, `co_medicaid_837p.txt`,
  `co_medicaid_837p_with_renderer.txt`) and the corresponding test
  assertions (`test_payer.py`, `test_payer_summary.py`,
  `test_co_medicaid_fixture.py`, `test_parse_837.py`) from the old
  `SKCO0`/`COHCPF` payer IDs to `CO_TXIX`, matching
  PayerConfig.co_medicaid() and the HCPF 837P Companion Guide.
- Adds `ingest/` to .gitignore — local scratch / production-data
  staging only.
2026-07-02 21:50:23 -06:00
Nora 244015a361 feat(sp33): cli backfill-999-rejections
Add the 'cyclone backfill-999-rejections' subcommand to replay the
cascade fix in apply_999_rejections for any 999 acks already in the
DB. Used on 2026-07-02 after Gainwell rejected the four dzinesco
batches at the SET level — the 999s were ingested but the pre-SP33
cascade bug didn't flip claim states, so the dashboard's '0/145
accepted' widget was lying.

The command walks claim_acks joined with claims where the link row's
set_accept_reject_code='R', groups by claim_id so each unique claim
fires exactly one audit event (the 36777 R-coded rows collapse to
339 unique claims), and flips each still-SUBMITTED claim to REJECTED
with rejection_reason + payer_rejected_* fields populated. Claims
already in REJECTED are skipped.

Also moves 'if __name__ == "__main__": main()' to the bottom of the
file. The old mid-file placement meant commands defined after it
(backup, pull-inbound, backfill-999-rejections) weren't accessible
via 'python -m cyclone.cli <sub>' — only the 'cyclone' console script
worked. Latent bug since SP17; surfaced when SP33 added another
post-block command.
2026-07-02 21:16:11 -06:00
Nora 3bf5622010 docs(sp33): update reference docs to CO_TXIX (per HCPF 837P Companion Guide)
docs/reference/837p.md line 48: the CO Medicaid trading-partner line
was still saying 'SKCO0 (sender) <-> COHCPF (receiver)'. The HCPF
837P Companion Guide (June 2025 - Version 2.5) requires
NM1*PR NM109 = CO_TXIX; SKCO0 causes Gainwell to reject the SET
('2010BB NM109 must equal CO_TXIX or CO_BHA').

docs/reference/co-medicaid.md line 118 + the Trading Partner table at
the top: same fix, with a pointer to the SP33 spec for the
root-cause story.

No code changes. Pure doc fix to match what the source now emits
(PayerConfig.co_medicaid().payer_id = 'CO_TXIX').
2026-07-02 21:07:12 -06:00
Nora 0625c83a45 feat(sp33): apply_999_rejections uses batch_envelope_index
The 999 handler's rejection pass was looking up claims by patient
control number, but Gainwell rejects at the SET level (ST envelope)
when the whole batch fails the NM109=CO_TXIX rule. That meant a SET
rejection was treated as a no-op even though every claim in the SET
was actually rejected by the payer.

Add a batch_envelope_index param (mirrors apply_999_acceptances from
SP28) so SET-level rejections cascade to every claim in the SET.
Falls back to the legacy PCN lookup when the index has no entry.

Also tightens test_payer.py: PayerConfig.co_medicaid() now returns
payer_id='CO_TXIX' and payer_name='CO_TXIX' per HCPF 837P Companion
Guide (June 2025 - Version 2.5).
2026-07-02 21:07:07 -06:00
Nora cf7c343ff0 feat(sp33): PayerConfig.co_medicaid() emits CO_TXIX 2026-07-02 20:23:56 -06:00
Nora dae7749464 docs(plan): SP33 Co TXIX payer fix implementation 2026-07-02 20:22:17 -06:00
Nora 2e7ad471e0 docs(spec): SP33 CO TXIX payer fix design 2026-07-02 20:20:01 -06:00
Nora dfd654202e merge: SP32 rendering & service-provider NPI extraction into main 2026-07-02 17:21:49 -06:00
Nora 15c85300f6 docs(spec): SP32 align D7 with --file/--input-dir backfill design 2026-07-02 17:18:51 -06:00
Nora 1b2f6c6b21 test(sp32): bump migration version assertions to 19 2026-07-02 17:18:16 -06:00
Nora 0f3b264e41 feat(sp32): add backfill-rendering-npi CLI subcommand 2026-07-02 17:16:59 -06:00
Nora de77f19d9d feat(sp32): _content_keys_match prefers typed NPI columns over raw_json fallback 2026-07-02 17:08:09 -06:00
Nora c4bc118557 feat(sp32): wire rendering_provider_npi and service_provider_npi through ORM builders 2026-07-02 17:06:25 -06:00
Nora 055b4ae30e docs(spec): SP32 align test list with single-NPI-per-Remit design 2026-07-02 17:04:47 -06:00
Nora 4d3eef22ef feat(sp32): extract 837p NM1*82 (rendering provider NPI) per ClaimOutput 2026-07-02 17:04:07 -06:00
Nora d8707ba874 feat(sp32): extract 835 NM1*1P (service provider NPI) per ClaimPayment 2026-07-02 16:56:01 -06:00
Nora c491003287 feat(sp32): add rendering_provider_npi and service_provider_npi columns (migration 0019) 2026-07-02 16:53:55 -06:00
Nora 4363b4fe41 docs(plan): SP32 rendering & service-provider NPI extraction 2026-07-02 16:52:32 -06:00
Nora d44948cb90 docs(spec): SP32 update D10/testing for single-NPI per Remit 2026-07-02 16:51:18 -06:00
Nora 9e2dde6ddb docs(spec): SP32 simplify D4/D6 — single-NPI per Remit, no comma-aggregation 2026-07-02 16:51:12 -06:00
Nora 2d40fbbcbb docs(spec): SP32 rendering & service-provider NPI extraction 2026-07-02 16:43:14 -06:00
Nora a2ec65e5c6 merge: SP31 835 strict content-match auto-link into main 2026-07-02 16:25:48 -06:00
Nora 18fa119ff7 feat(sp31): expose keys_matched/candidate_count in Match dataclass and ActivityEvent payload, fix broken pre-existing test 2026-07-02 16:23:42 -06:00
Nora ab91449e62 feat(sp31): integrate content-keys fallback into reconcile.run() with auto_matched_835 event 2026-07-02 15:58:01 -06:00
Nora 9bade2429c fix(sp31): read NPI and charge from raw_json when ORM attribute missing 2026-07-02 15:27:32 -06:00
Nora 5a54961930 feat(sp31): add _score_fallback_candidates DB helper with ±30-day candidate pool 2026-07-02 15:17:26 -06:00
Nora b484ca36dc feat(sp31): add _content_keys_match pure helper (2-of-3 PCN/charge/NPI rule) 2026-07-02 15:04:48 -06:00
Nora 75a2800a9d refactor(sp31): rename Match strategy 'auto' to 'pcn-exact' for audit clarity 2026-07-02 14:59:49 -06:00
Nora 6624a0bafd docs(plan): SP31 835 strict content-match auto-link 2026-07-02 14:56:43 -06:00
Nora a12104fb0f docs(spec): SP31 835 strict content-match auto-link 2026-07-02 14:53:48 -06:00
Nora edee0a6259 merge: SP30 Dashboard Recent batches widget into main 2026-07-02 14:18:59 -06:00
Nora 97512ec4a7 feat(sp30): Dashboard Recent batches widget with billing outcome
- backend: GET /api/batches now returns acceptedCount/rejectedCount/
  pendingCount/billedTotal/topRejectionReason/hasProblem per item
  (one GROUP BY query + one ordered scan, no N+1)
- backend: 2 tests pin the 837p full-bucket case + the 835 zero case
- frontend: BatchSummary extended with 6 optional fields
  (backwards compat preserved)
- frontend: new RecentBatchesWidget renders one row per batch with
  status icon + billed total + accepted count + top rejection reason
- frontend: 837p rows show $ total + 'N/M accepted'; 835 rows show
  payment count (Remittance has no batch-level total_charge)
- frontend: full-width row between KPI tiles and Activity on the
  Dashboard; click navigates to /batches?batch=ID
- frontend: 3 component tests cover empty/clean/problem/835 branches
2026-07-02 14:18:55 -06:00
Nora bde3060e9e docs(spec,plan): SP30 Dashboard Recent batches widget 2026-07-02 14:08:30 -06:00
Nora 1d2572e624 merge: SP29 Inbox 999-rejected drill into main 2026-07-02 13:16:40 -06:00
Nora ffacfd8665 feat(sp29): inbox rejected-row 999 ack chips + per-row Resubmit
- backend: _ack_summary_for_claims helper attaches claim_acks payload
  to inbox rejected-lane rows (3 tests)
- frontend: InboxRow renders newest 3 AK2 chips + '+N more' overflow
  under the rejection reason, with '999 not linked' marker when an
  ack couldn't be linked to a claim
- frontend: per-row Resubmit button downloads a single corrected 837
  via api.serializeClaim837 (no bulk modal, no zip — one click, one
  .x12 file)
- frontend: 2 new tests for the chip rendering and the per-row
  download flow
2026-07-02 13:16:38 -06:00
Nora 9cc13e7940 docs(spec,plan): SP29 Inbox 999-rejected drill 2026-07-02 13:06:21 -06:00
Nora 37caf6af77 docs(plan): SP28 follow-up notes — Batch.kind fix + one-time backfill 2026-07-02 12:31:37 -06:00
Nora 02b879a204 merge: SP28 follow-up — fix Batch.kind '837' vs '837p' mismatch
Hotfix for the SP28 merge. The SP28 helpers (batch_envelope_index +
the two _batch_lookup closures in handle_ta1 / api.py) all filtered
Batch.kind == '837', but prod Batch rows are persisted with kind='837p'
(see api.py:443). Result on prod: claim_acks row count = 0, D10 Pass 1
(ST02 via batch.envelope.control_number) never fired, and the user's
specific claim t991102989o1c120d would not surface its 143 999 acks.

Fix: 4 production sites swapped to '837p'; 3 test files updated to
match the prod value. 25 SP28 tests + 20 handler tests pass post-fix.
Expected prod effect after re-deploy: 727 of 1,398 acks auto-link via
batch.envelope.control_number, plus the user's claim surfaces in
ClaimDrawer for /api/claims/t991102989o1c120d/acks.

Follow-up plan: backfill the existing 1,398 acks via a one-time script
that runs the SP28 helpers against the current DB (D10 join now
resolves the 991102989 ST02 from existing batches).
2026-07-02 12:22:36 -06:00
Nora fba594baf4 fix(sp28): align Batch.kind filter with prod ('837p' not '837')
The SP28 helpers (batch_envelope_index + lookup_claims_for_ack_set_response
+ two _batch_lookup closures in handle_ta1 + api.py) all filter
Batch.kind == "837", but prod Batch rows are persisted with
kind="837p" (lowercase p — see api.py:443 / store/records.py:58).
Result: the D10 batch-envelope index was empty on prod and zero 999 /
277CA / TA1 acks auto-linked to their claims via Pass 1 (ST02 via
batch.envelope.control_number). Pass 2 (PCN) was the only path firing,
which on this codebase matches 0 acks (Gainwell's 999 echoes the 837's
ST02, not its CLM01 — see spec §D10).

Fix: 4 production sites swapped to Batch.kind == "837p". 3 test
files updated to use the prod value (test_apply_claim_ack_links.py +
test_api_claim_acks.py + test_e2e_999_to_claim_drawer.py). All 25 SP28
tests + 20 handler tests pass. Expected prod effect: claim
t991102989o1c120d's 143 incoming 999 AK2 acks now auto-link via the
batch with envelope.control_number=991102989 (Pass 1), plus 727/1,398
total acks across all batches.
2026-07-02 12:22:29 -06:00
Nora 190f4b8c8d merge: SP28 ack-claim auto-link into main
Closes the operator gap where 999 / 277CA / TA1 acks were persisted but
never linked back to the claims they acknowledged. The original PCN-based
join matched 0 / 1,398 acks on prod (Gainwell's 999 echoes the source
837's ST02, not its CLM01); the D10 two-pass join via Batch.envelope.
control_number recovers 727 / 1,398 automatically.

Increment summary:
- New claim_acks join table (migration 0018) with partial unique dedup index
- Pure helpers (apply_claim_ack_links orchestrator + 999/277CA/TA1 paths)
  with the D10 two-pass lookup (Batch.envelope.control_number primary,
  Claim.patient_control_number fallback)
- CycloneStore facade: add_claim_ack, list_acks_for_claim,
  list_claims_for_ack, find_ack_orphans, remove_claim_ack, batch_envelope_index
- Handler integration: handle_999 / handle_277ca / handle_ta1
- 7 new API endpoints (read + stream + manual-match + unlink + orphan lane)
- Frontend: useClaimAcks / useAckClaims hooks, claimAcks live-tail slice,
  AcknowledgmentsPanel in ClaimDrawer, MatchedClaimPanel + manual-match
  dropdown in AckDrawer, Claims badge column on Acks page, Ack-orphans
  lane on Inbox page

Coverage: 727 / 1,398 acks auto-link; 671 remain real orphans (ST02=0001)
surfaced in the Inbox ack-orphans lane.

Auth posture: any logged-in user can run the manual-match endpoint
(D5/D9 deviation from admin-only remit-orphans — ack matches are
metadata-only, no Claim.state mutation).

Test baseline (no new failures):
- Backend: 25 new SP28 tests pass; full suite 1210 passed / 10 skipped /
  1 failed + 6 errors (pre-existing test_payer_summary +
  test_provider_extended_response pollution; all 7 pass in isolation)
- Frontend: 36 new SP28 tests pass; full suite 580 passed / 5 failed
  (pre-existing api.test.ts / tail-stream.test.ts / Inbox.test.tsx /
  InboxHeader.test.tsx baseline)
- Typecheck: 17 pre-existing errors remain; 0 in SP28-introduced files
  (refactor e10d388 fixed 3 pre-existing joinUrl import errors)

Files: 47 changed, +6,509/-23. Refactor e10d388 (joinUrl export) folded in
to keep SP28 merge coherent. 14 SP28 commits on branch preserved as audit
trail.
2026-07-02 12:12:31 -06:00
Nora 9d11ffcd8a docs(plan): SP28 deviations log — 4 backend + 5 frontend post-impl notes
Backend: helpers return dataclasses instead of inserting directly (preserves
publish-from-store contract); handlers commit work session before calling
cycl_store.add_claim_ack (SQLite single-writer); batch_envelope_index
accepts dict OR callable; link_manual returns ClaimAckLinkRow.

Frontend: TA1 row shape in Inbox lane; cross-page nav via useNavigate;
TA1 column header 'Batches' (not 'Claims'); ack-orphans lane position
between payer_rejected and candidates; fmt.usd instead of fmt.money
in MatchedClaimPanel.

Confirmed test baselines: backend 1210/10/6-err/1-fail = pre-SP28;
frontend 580 pass / 5 fail = pre-SP28. No new failures introduced.
46 files changed, +6,467/-22.
2026-07-02 12:05:45 -06:00
Nora 21a5485faa feat(sp28): api_routers/claim_acks (7 endpoints) + claim_acks surface on claims/acks
Phase 5 of SP28 (Ack↔Claim Auto-Link). Adds the public HTTP
surface for the claim_acks join table and wires it into the
existing claims + acks list endpoints.

New router (registered with matrix_gate auth, any-logged-in-user):

* GET    /api/claims/{id}/acks           — per-claim links
* GET    /api/claims/{id}/acks/stream    — NDJSON live-tail
* GET    /api/acks/{kind}/{id}/claims    — per-ack links
* GET    /api/acks/{kind}/{id}/claims/stream — NDJSON live-tail
* POST   /api/acks/{kind}/{id}/match-claim   — manual link (D5)
* DELETE /api/acks/{kind}/{id}/match-claim/{claim_id} — unlink
* GET    /api/inbox/ack-orphans          — orphan reconciliation

Manual match is any-logged-in-user (D5/D9), idempotent on the
(claim_id, ack_kind, ack_id) dedup key, rejects with 409 when the
claim is in a terminal state (REVERSED), and publishes
claim_ack_written / claim_ack_dropped on the bus so live-tail
subscribers refresh.

Existing list endpoints extended:
* /api/acks           — each item gains linked_claim_ids
* /api/ta1-acks       — each item gains linked_claim_ids
* /api/277ca-acks     — each item gains linked_claim_ids
* /api/claims/{id}    — body gains ack_links (compact form)

All three list extensions use a single batched SELECT against
claim_acks to avoid N+1.

Tests:
* tests/test_api_claim_acks.py — 8 tests covering all 7
  endpoints + the spec §6 named tests for the extended
  surfaces (claim_detail_includes_ack_links,
  acks_list_includes_linked_claim_ids,
  claim_acks_stream_emits_claim_ack_written). The stream test
  uses the established direct-endpoint-invocation pattern
  from test_api_stream_live.py so it gets true byte-streaming
  + clean async cancellation.
2026-07-02 11:55:15 -06:00
Nora 3fd2c445e7 fix(sp28): use fmt.usd instead of fmt.money in MatchedClaimPanel 2026-07-02 11:46:45 -06:00