Commit Graph

649 Commits

Author SHA1 Message Date
Nora 30aee61956 feat(sp41): cyclone rebill-from-835 CLI (--window, --override-filing, --status) 2026-07-07 22:43:52 -06:00
Nora b19f43f448 fix(sp41): log dedup raise + TestClient coverage for api_routers special-case 2026-07-07 22:40:17 -06:00
Nora 2dfe213af0 feat(sp41): wire claim-id dedup into submit_file pre-flight
Inserts a per-claim-id check_duplicate() walk into submit_file between
the payer-mismatch guard and the BatchRecord837 DB write (Task 9).

If any CLM01 in the parsed 837P file was submitted within the 30-day
window recorded in the submission_dedup table, submit_file raises
DuplicateClaimError before any DB write or SFTP upload. The DB-first,
upload-second invariant is preserved: a duplicate blocks both the
BatchRecord837 write AND the paramiko upload.

The api_routers/submission.submit_batch per-file loop special-cases
DuplicateClaimError BEFORE the generic Exception handler so the
200-with-results status-code contract documented at the top of the
module is preserved (per-file failures stay in the body). The
exception remains 409-class by design — a future caller that
propagates it past the per-file loop will surface 409 at the FastAPI
layer.

Approach A per the Task 9 spec (raise, don't return a SubmitResult).

Tests:
- test_submission_dedup.test_submit_file_raises_on_duplicate_claim_id
  pre-populates the dedup table via record_submission('CLM001', ...),
  calls submit_file against minimal_837p.txt, expects DuplicateClaimError
  with structured claim_id / original_submission_at attributes, and
  pins the DB-write invariant (no Batch row landed) plus an
  AssertionError sentinel on the SFTP factory.
2026-07-07 22:33:42 -06:00
Nora bd5530539d fix(sp41): trailing newlines + correct docstring ref in DuplicateClaimError 2026-07-07 22:28:05 -06:00
Nora 53df3f0684 refactor(sp41): align submission_dedup with cyclone.store conventions (main Base, db.SessionLocal, now(timezone.utc), exceptions.py) 2026-07-07 22:16:07 -06:00
Nora 0e130579b4 feat(sp41): claim-id dedup at SFTP pre-flight (30-day window, configurable) 2026-07-07 22:02:46 -06:00
Nora 13dd240328 fix(sp41): test all 6 disposition constants + document pipe separator choice 2026-07-07 21:53:39 -06:00
Nora 543422c343 feat(sp41): summary CSV with REBILLED_A/B + EXCLUDED_* categories 2026-07-07 21:48:12 -06:00
Nora 39f578aa5e fix(sp41): test has_overridden_visits + clarify pipeline_b forward-compat helper 2026-07-07 21:37:48 -06:00
Nora 4d396d40b6 fix(sp41): drop unused Decimal import from pipeline_b 2026-07-07 21:25:20 -06:00
Nora fcac090885 feat(sp41): pipeline B — NOT_IN_835 → fresh 837Ps by (member, ISO-week) 2026-07-07 21:20:11 -06:00
Nora 45b6c6291a fix(sp41): drop unused write_pipeline_a_files test import + clarify RebillClaim docstring 2026-07-07 21:13:37 -06:00
Nora a3831213cc fix(sp41): tighten pipeline_a write_files contract + cleanup imports 2026-07-07 20:57:18 -06:00
Nora e946a4f7aa feat(sp41): pipeline A — denied/partial rebill with frequency-7 + CARC gate 2026-07-07 20:34:04 -06:00
Nora 30d13876cd feat(sp41): 120-day timely-filing gate with per-batch override 2026-07-07 20:24:40 -06:00
Nora ea7acd2e66 fix(sp41): trailing newline on carc_filter module + test (PEP 8) 2026-07-07 20:21:30 -06:00
Nora 3b4f18eef6 feat(sp41): CARC-aware filter — CO-45/26/129 excluded, PI-16/OA-18 reviewed 2026-07-07 20:12:28 -06:00
Nora 69b760e890 feat(sp41): visit-to-835 reconcile on (member_id, procedure, DOS) with best-of-N 2026-07-07 19:59:18 -06:00
sp41-bot f8e2f0933e fix(sp41): reset member_id on new CLP + tighten status-preservation test 2026-07-07 19:53:40 -06:00
Nora 52795adfb1 feat(sp41): SVC-level 835 reparser with member_id at SVC scope 2026-07-07 19:41:00 -06:00
Nora acf3b506ec fix(sp40): use canonical build_outbound_filename for HCPF-spec filenames
The regen script was emitting filenames like 'tp-cyc-837P-20260707183154271-0271-1of1.x12'
which violate the HCPF X12 File Naming Standards Quick Guide. The spec requires:

    tp{tpid}-{transaction_type}-yyyymmddhhmmssSSS-1of1.x12

— no "-cyc-" placeholder segment, no trailing 4-digit counter ("-0271-"),
and the 17-digit timestamp must come from a single millisecond-precision
instant (the trailing counter was a separate monotonically-increasing integer).

Route filename generation through the canonical
cyclone.edi.filenames.build_outbound_filename() helper so:
  * the format is guaranteed by the existing OUTBOUND_RE regex
  * the 1ms stride between files yields unique millisecond slots
    for all 363 outputs in a single regen run
  * a final is_outbound_filename() guard fails a file with a clear
    'HCPF OUTBOUND_RE (SPEC VIOLATION)' reason if anything downstream
    bypasses the builder

Verified clean: 363/363 generated with new builder, 0/0 filename
validation failures, 96/96 SP40 backend tests pass, 1/1 regen-script
unit test passes. Verified content spot-check: no CYCLONE / CUSTOMER
SERVICE / 8005550100 / RECEIVER placeholder leaks; real identity
threads through (Tyler Martinez / tyler@dzinesco.com / COLORADO MEDICAL
ASSISTANCE PROGRAM / COMEDASSISTPROG).
2026-07-07 18:40:34 -06:00
Nora e7098b99b2 fix(sp40): thread real clearhouse + payer config through serializer call sites
The original SP40 serializer fix added a placeholder fallback (CYCLONE /
CUSTOMER SERVICE / 8005550100 / RECEIVER) for callers that pass no
contact info. That fallback fires today on three production paths:

1. /api/claims/{id}/serialize-837 single-claim download
2. dev/unbilled-july2026/scripts/regen_corrected_files.py regen script
3. (bulk export at /api/batches/{id}/export-837 already threads real config)

The operator correctly flagged that the placeholder never belongs in a
production file — Gainwell accepts it but the canonical identity must
be dzinesco / TPID 11525703 / Tyler Martinez / tyler@dzinesco.com on
the wire.

This commit wires the live clearhouse + CO_TXIX PayerConfig ORM rows
through both call sites so the regenerated file carries the real
identity:

- serialize_claim_as_837 builds _serialize_kwargs_for_claim(...) and
  passes it to serialize_837, mirroring the bulk exporter.
- regen_corrected_files.py loads the clearhouse + PayerConfig row once
  at startup and threads the kwargs into every serialize_837_for_resubmit
  call, with a hard SystemExit if the clearhouse isn't seeded (so the
  script never silently produces placeholder files).
- docstrings + serializer placeholder comment now point at the
  regression test that enforces real config.
- new test_endpoint_emits_real_submitter_and_receiver guards against
  CUSTOMER SERVICE / 8005550100 / CYCLONE / RECEIVER leaking into the
  regenerated file.

Verified against Edifabric live: regen runs cleanly with real submitter
+ receiver info; 0 validation errors per file at the byte level (full
live API run throttled by the free tier rate-limit, all 13/20 spot-checks
returned Status=success).
2026-07-07 18:35:51 -06:00
Nora 7838ed2a4c merge: SP40 Edifabric validation gate + 837P byte-defect sweep into main 2026-07-07 18:29:37 -06:00
Nora c3ef4f3847 docs(sp40): RUNBOOK entry for Edifabric validation gate 2026-07-07 18:19:58 -06:00
Nora cfca1d8975 feat(sp40): edifabric.api_key secret wiring + dev fixture 2026-07-07 18:19:17 -06:00
Nora b652b18458 feat(sp40): resubmit-rejected-claims Edifabric pre-upload gate + bypass flag 2026-07-07 18:18:41 -06:00
Nora 0815a4ad35 feat(sp40): POST /api/admin/validate-837 admin endpoint + 5 TestClient tests 2026-07-07 18:15:19 -06:00
Nora 7218ec76a8 feat(sp40): cyclone validate-837 CLI + 4 CliRunner tests 2026-07-07 18:11:04 -06:00
Nora b237b9cfc4 feat(sp40): Edifabric HTTP client (read + validate + composed)
Two-step wrapper around https://api.edination.com/v2/x12/read and
/x12/validate. Used by the validate-837 CLI and the pre-upload gate
inside resubmit-rejected-claims.

cyclone.edifabric public surface:
- read_interchange(edi_bytes) -> X12Interchange
- validate_interchange(x12_json) -> OperationResult
- validate_edi(edi_bytes) -> OperationResult
- EdifabricError(status, body) for non-2xx responses

API key resolved lazily via cyclone.secrets.get_secret('edifabric.api_key')
which maps to CYCLONE_EDIFABRIC_API_KEY env var or keychain. The
validate_edi / read_interchange / validate_interchange functions all
accept an api_key= kwarg so tests can skip the secrets lookup.

Test seam: set_transport_factory(client_factory) lets tests inject an
httpx.Client backed by httpx.MockTransport — no live HTTP. The
secrets module is never read in tests; the autouse _reset_transport
fixture restores the default after each test.

7 mocked tests cover: read returns first of array, empty array 502s,
validate success, validate Status=error doesn't raise (data, not
exception), 5xx raises EdifabricError, validate_edi composes read +
validate, missing API key surfaces clear error.
2026-07-07 18:07:53 -06:00
Nora c5604fdfa0 feat(sp40): 837P serializer always emits PER-02/03/04 + SBR-09
Edifabric /v2/x12/validate rejects the SP39-regenerated 837P files
with 'Element PER-03 is required / Element PER-04 is required /
Element SBR-09 is required'. Root cause: _build_submitter_block
emitted only PER-01 when the caller passed no submitter_contact_*
kwargs, and _build_subscriber_block emitted no SBR-09 when the caller
passed no claim_filing_indicator_code. SP33-era callers and the SP39
regen script both bypass the kwargs.

Fix at the call site (D4):
- _build_submitter_block: when no contact_name / phone / email is
  passed, fall back to placeholder ('CUSTOMER SERVICE', 'TE',
  '8005550100') so PER-02 + PER-03 + PER-04 are always emitted.
  Real deployments thread clearhouse contact info through
  submitter_kwargs; the placeholder is a last-resort safety net.
- _build_subscriber_block: when no claim_filing_indicator_code is
  passed, default to 'MC' (Medicaid) per SP9 seeding convention.
  Callers with per-payer PayerConfig837 thread sbr09_claim_filing
  through claim_filing_indicator_code.

3 regression tests in test_serialize_837.py:
- PER always emits Name + Comm-Qualifier + Comm-Number
- SBR always emits SBR-09 default 'MC'
- Explicit claim_filing_indicator_code kwarg wins over default
2026-07-07 18:04:45 -06:00
Nora f21201a22e docs(spec): SP40 Edifabric validation gate + 837P byte-defect sweep
Covers three things:
1. _build_per / _build_sbr call-site fixes so the serializer always
   emits PER-02/03/04 + SBR-09 (Edifabric rejects the current shape).
2. New cyclone.edifabric HTTP client wrapping the two-step
   /x12/read -> /x12/validate flow against api.edination.com.
3. Pre-upload gate inside resubmit-rejected-claims so an
   Edifabric-invalid file can't reach Gainwell.

Public eval API key 3ecf6b1c5cf34bd797a5f4c57951a1cf for dev.
Production is operator-supplied via cyclone secrets set
edifabric.api_key <paid-key>.
2026-07-07 18:02:58 -06:00
Nora 0436820023 merge: SP39 2010BB NM109 byte defect fix + resubmission tracking into main 2026-07-07 17:39:34 -06:00
Nora 055d95224a test(sp39): bump PRAGMA user_version assertion from 20 to 21
Migration 0021_resubmissions.sql (SP39 Task 2) advances the head from
20 to 21. Both test_migration_0020.migrated_engine and
test_acks.test_migration_latest_idempotent_on_fresh_db pinned the old
head; update both. No production behavior change.
2026-07-07 17:39:31 -06:00
Nora 77f73cf73e docs(sp39): RUNBOOK + submission/core.py TODO for upstream trace follow-up 2026-07-07 17:35:07 -06:00
Nora 08c0430ee7 feat(sp39): sibling-project regen_corrected_files.py + test
Re-serialize the 363 corrected 837P files through the SP39-fixed
serializer, emitting NM1*PR*2*CO_TXIX*****PI*CO_TXIX in 2010BB and
writing to ingest/corrected-v2/v2-batch-<batch-id>-<N>-claims/.

Lives under dev/unbilled-july2026/scripts/ because the unbilled-july2026
project at /home/tyler/dev/unbilled-july2026/ is NOT a git repo of its
own (the operator's working tree only). Mirroring the physical path
keeps the script's import path (sys.path.insert ../../cyclone/backend/src)
working without per-run shimming.

Verified end-to-end: ok=363 failed=0, 5 random spot-checks all show
NM1*PR*2*CO_TXIX*****PI*CO_TXIX, zero SKCO0/COHCPF fallbacks in any
of the 363 output files.
2026-07-07 17:34:30 -06:00
Nora 3400ed5ec2 feat(sp39): resubmit-rejected-claims instruments Resubmission rows 2026-07-07 17:34:20 -06:00
Nora 0493814489 feat(sp39): cli resubmissions status 2026-07-07 17:23:24 -06:00
Nora 538fad211b feat(sp39): CycloneStore.record_resubmission + find_resubmission_status 2026-07-07 17:22:35 -06:00
Nora 353adfc08b feat(sp39): Resubmission model + migration 0021 + idempotency tests 2026-07-07 17:21:24 -06:00
Nora d328c0b224 feat(sp39): _normalize_payer_id normalizes SKCO0/CO_BHA/empty -> CO_TXIX 2026-07-07 17:19:18 -06:00
Nora 8516b90601 docs(spec): SP39 2010BB NM109 byte defect fix + resubmission tracking design 2026-07-07 17:18:00 -06:00
Nora 8a251d20a9 merge: SP25 orphan data recovery into main 2026-07-07 14:35:17 -06:00
Nora 01e5ee781e docs: 2026-07-07 dashboard mess postmortem (SP25 follow-up) 2026-07-07 14:35:10 -06:00
Nora 28835e2f1d feat(sp25): cyclone recover-ingest CLI for stranded source files 2026-07-07 14:33:38 -06:00
Nora 0ccc396e5e feat(sp25): read-side defensive stub for synthetic-batch rows 2026-07-07 14:29:56 -06:00
Nora c8f5af3ba5 docs(plan): implementation plan for SP25 orphan data recovery (Step 1) 2026-07-07 14:18:22 -06:00
Nora fa034b1cd7 docs(spec): design for SP25 orphan data recovery (Step 1) 2026-07-07 14:18:22 -06:00
Nora 9c0aa577fb fix: RateLimitMiddleware._buckets is class-level so reload-safe
20 pre-existing pytest failures were caused by tests/test_api.py
calling importlib.reload(cyclone.api) mid-suite. Reload creates a
NEW FastAPI app with a NEW RateLimitMiddleware whose private
_buckets dict is independent of the OLD instance. Tests that
imported 'from cyclone.api import app' at module load kept
referencing the OLD app, so their requests accumulated in the
orphaned bucket which the conftest reset never cleared. After
~300 requests, the orphaned bucket tripped the limiter and these
tests got spurious 429s:
  - test_existing_endpoints_require_auth (6)
  - test_inbox_endpoints (8)
  - test_inbox_endpoints_sp7 (1)
  - test_list_endpoint_counts (4)

Hoisting _buckets to a class-level dict makes one
RateLimitMiddleware._buckets.clear() (in conftest reset) reach
every instance — current, stale, post-reload — eliminating the
orphaned-bucket leak. Per-instance _lock stays per-instance
since it guards mutation of the shared dict.

Verified stable: 3 consecutive full-suite runs all pass with
1435 passed, 10 skipped, 0 failed in ~82s each.
2026-07-07 13:56:49 -06:00
Nora 83a31b6fea merge: SP38 orphan-ack housekeeping into main
Captures the historical 804-orphan 999 situation as a known
artifact (RUNBOOK entry + status command) and provides a
one-shot idempotent synthetic-batch seeder so future acks for
the orphan ST02s can resolve against batch_envelope_index.

6 commits merged atomically (no squash — the SP-N merge commit
is the audit record):
  d776a4a docs(spec): design for SP38 orphan-ack housekeeping
  8890627 docs(plan): SP38 orphan-ack housekeeping plan
  ad14b56 feat(sp38): CycloneStore.find_ack_orphan_st02_summary + reconcile_orphan_st02s
  9ef749c feat(sp38): 'cyclone ack-orphans {status,reconcile}' CLI subcommands
  76923a7 docs(sp38): RUNBOOK entry for orphan-ack housekeeping
  07ea7ca fix(sp38): restore per-kind control_number in find_ack_orphans + review cleanups

Test delta: +36 passed (12 store helper + 7 CLI + 17 claim_acks
regression including the pr-reviewer 277ca/ta1 control_number
fix). No new failures.

Live verified: cyclone ack-orphans status exits 0 with 4 distinct
orphan ST02s (419+226+106+53=804) matching the SQLite investigation.
2026-07-07 13:12:09 -06:00
Nora 07ea7ca1d6 fix(sp38): restore per-kind control_number in find_ack_orphans + review cleanups
Three pr-reviewer followups from the 2026-07-07 review of commit ad14b56:

1. BUG: find_ack_orphans refactor routed 277ca/ta1 through
   _ack_control_number which only knew 999 — restored per-kind source
   (999 reads raw_json.envelope.control_number, 277ca/ta1 read the ORM
   control_number column). Added regression test pinning all three
   kinds.

2. DOCSTRING DRIFT: reconcile_orphan_st02s said 'remaining columns
   take their defaults' but explicitly passes parsed_at and
   transaction_set_control_number — added both to the explicit list
   and clarified the rest take schema defaults.

3. WASTED SORT: _iter_orphan_999_st02s yielded sorted() but the
   caller re-sorts by (-ack_count, st02) — yielding unsorted now.

Plus three cleanups the reviewer flagged:

* Hoisted 'json' / 'uuid' / 'datetime' imports to module top of
  store/__init__.py (replaced in-method imports).
* Added two missing tests: sentinel grep-discoverability via
  LIKE '<synthetic:%>' + 999-walk tolerance for non-dict raw_json
  (None / list shapes — bytes is unreachable through the ORM).
* Aligned spec/plan exit codes to the cyclone-cli convention
  (exit 1 on DB error, not 2 — matches the existing CLI
  sys.exit(1) and the cyclone-cli skill documentation).

36/36 SP38 tests pass.
2026-07-07 13:11:29 -06:00