From a12104fb0f420da905fd22d8f1127724c3b7b6df Mon Sep 17 00:00:00 2001 From: Nora Date: Thu, 2 Jul 2026 14:53:48 -0600 Subject: [PATCH 1/8] docs(spec): SP31 835 strict content-match auto-link --- ...cyclone-835-strict-content-match-design.md | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-02-cyclone-835-strict-content-match-design.md diff --git a/docs/superpowers/specs/2026-07-02-cyclone-835-strict-content-match-design.md b/docs/superpowers/specs/2026-07-02-cyclone-835-strict-content-match-design.md new file mode 100644 index 0000000..0368cbc --- /dev/null +++ b/docs/superpowers/specs/2026-07-02-cyclone-835-strict-content-match-design.md @@ -0,0 +1,130 @@ +# Sub-project 31 — 835 Strict Content-Match Auto-Link: Design Spec + +**Date:** 2026-07-02 +**Status:** Draft, awaiting user sign-off +**Branch:** `sp31-835-strict-content-match` +**Aesthetic direction:** No UI changes. State flips driven by backend logic surface immediately via the SP30 Recent Batches widget. + +--- + +## 1. Scope + +Today when an 835 (ERA remittance advice) arrives, the existing matcher in `cyclone.reconcile.run` only auto-links a remit to a claim when **`payer_claim_control_number == claim.patient_control_number` AND service dates are within ±7 days**. That single-key match misses most real-world remits: Gainwell's 835 carries its own independent control numbers (M-prefix filenames, ISA13 assigned by Gainwell, no echo of the 837's ST02), so the only reliable join lives in the 835's **content fields** — CLP01 (PCN), CLP03 (total charge), CLP07 (rendering provider NPI). The operator can see in the Recent Batches widget that a batch has unresolved claims, but until a manual match is performed (admin-only endpoint `POST /api/reconciliation/match`) those remits sit in the Inbox Unlinked lane with no `Claim.state` flip to PAID / DENIED / PARTIAL. + +SP31 closes that gap by adding a **deterministic content-key fallback matcher** to the 835 ingest path. When the existing PCN-exact path misses, a strict **"any 2 of {PCN, charge, NPI}" rule** runs against a wider candidate pool (±30 days) and auto-links on a hit. The result: more remits get linked at parse time, more claims get state-flipped to a terminal/settled state, and the Recent Batches widget reflects the truth immediately (no manual intervention needed). + +**In scope:** + +- One new helper `_content_keys_match(remittance, claim)` in `cyclone.reconcile` that returns True when at least 2 of {CLP01↔CLM01 PCN, CLP03↔CLM02 charge, CLP07↔NM1*82 NPI} agree exactly (charge compared with a $0.01 tolerance). +- One new helper `_score_fallback_candidates(session, remittance)` that queries the candidate pool (same payer_id, billed_within ±30 days, claim.state ∈ {SUBMITTED, RECEIVED, PARTIAL}, no existing match) and returns the unique match when one exists, or None when zero or multiple claims pass. +- One new branch inside `cyclone.reconcile.run` that invokes the content-keys fallback **after** the existing PCN-exact path misses. On a hit, calls `apply_payment` / `apply_reversal`, writes a `Match(strategy="score-auto")` row with `score=100` and a payload describing which keys matched, and emits an `auto_matched_835` `ActivityEvent`. +- Idempotency guards: skip scoring when `claim.matched_remittance_id` is set, when `remittance.claim_id` is set, or when `claim.state` is already terminal (PAID / DENIED / REJECTED / REVERSED / RECONCILED). Re-ingesting the same 835 is a no-op for already-linked pairs. +- Reversal handling unchanged: status_code 21/22 always goes through `apply_reversal`, creating a 2nd `Match` row per existing SP27 convention. The strategy label (`pcn-exact` vs `score-auto`) reflects which path made the link. + +**Out of scope:** + +- No schema migration. `Match` and `ActivityEvent` already exist; the new strategy label `"score-auto"` is a string value within the existing column. +- No backfill. The 672 currently-unlinked claims in production stay unlinked; SP31 only affects new 835 ingests. An operator can still run the existing manual-match endpoints to backfill at their own pace. +- No changes to the existing PCN-exact matcher. It stays as the primary (fast) path. SP31 only adds a fallback branch. +- No changes to the Inbox Unlinked lane UI, the Recent Batches widget, or the ClaimDrawer / RemitDrawer. State flips already surface through the existing live-tail path (SP25) and the SP30 widget. +- No new endpoints. The existing `POST /api/reconciliation/match` and `POST /api/reconciliation/unmatch` remain the manual-fallback surfaces. +- No 999 / 277CA / TA1 changes. The ack↔claim auto-linker from SP28 stays untouched (those use the 837 filename / ST02 echo, not content keys). + +--- + +## 2. Decisions (locked during brainstorming) + +### D1. Match rule is strict "any 2 of {PCN, charge, NPI} match exactly" — no weighted scoring, no thresholds, no ambiguity tiers + +The user's instinct that "we might be over-building this" led to dropping the original 4-field weighted score with a 90-point threshold. Instead, SP31 uses a **deterministic three-key rule**: + +- **PCN match:** `remittance.payer_claim_control_number == claim.patient_control_number` (after `.strip()` on both). +- **Charge match:** `|remittance.total_charge_amount − claim.total_charge| < $0.01`. +- **NPI match:** `remittance.rendering_provider_npi == claim.rendering_provider_npi` AND the remit's NPI is non-empty. + +Auto-link when `keys_matched ≥ 2`. **No weighted score, no 90/75 threshold, no "runner-up within 10 points = ambiguous" rule.** The decision is reproducible by inspecting the three fields; "score was 91" is no longer in the operator's vocabulary. The existing scoring module (`cyclone.scoring.score_pair`) is preserved for the Inbox display lane but is no longer invoked from the auto-match path. + +### D2. Content-key fallback runs *after* the existing PCN-exact path misses — not as a replacement + +`reconcile.run` already PCN-matches today. SP31 leaves that path untouched and inserts the content-keys fallback as a second branch. Rationale: when PCN matches today, the match is rock-solid (no charge comparison, no NPI check needed). When PCN misses, content-keys is the next-best signal. Stacking keeps the fast path fast and the audit trail obvious (Match.strategy reveals which branch fired). + +### D3. The Match row gets `strategy="score-auto"` and a payload describing which keys matched + +This differentiates from the existing `strategy="pcn-exact"` (renamed from today's `strategy="auto"`). The Match row payload stores `{"keys_matched": ["pcn", "charge"], "candidate_count": 7}` so the operator can see exactly why a link was made. The Inbox Unlinked lane will surface these labels too (existing display layer reads `Match.strategy`). The existing `score` column is set to `100` for content-keys matches (placeholder for the legacy display layer; not used by the new logic). + +### D4. Charge tolerance is $0.01, not exact-zero + +CO Medicaid and most payers carry charge to 2 decimal places; rounding can introduce ±$0.005 of drift. $0.01 is the conservative tolerance that still catches real mismatches (a $50 charge vs a $100 charge differs by $50, not $0.01). + +### D5. Candidate pool is widened to ±30 days (was ±7 for PCN) + +Strict keys (≥ 2 of 3) are a much stronger signal than PCN-alone, so the wider window is safe. The 30-day window captures remits that arrive late (e.g., week-long payer processing delays plus weekends plus holidays) without pulling in unrelated claims from prior months. + +### D6. Idempotency is enforced by three guards, not by deduplication + +Before scoring a candidate, the matcher checks: + +1. `claim.matched_remittance_id IS NULL` (claim hasn't been linked yet) +2. `remittance.claim_id IS NULL` (remit hasn't been linked yet) +3. `claim.state NOT IN {PAID, DENIED, REJECTED, REVERSED, RECONCILED}` (terminal-state guard) + +If any guard fails, the candidate is excluded from the pool. If no candidate remains after filtering, no match is made and the remit lands in the Inbox Unlinked lane (existing behavior). + +### D7. No operator-facing UI for content-keys matches — the existing live-tail + Recent Batches widget surface them + +The `Match` row (D3) and the `auto_matched_835` ActivityEvent (D8) are the operator-visible artifacts. The Recent Batches widget from SP30 already reflects `Claim.state` changes within one re-fetch cycle (TanStack Query interval). No new "auto-matched by score" badge anywhere — keeps the UI surface minimal and lets the widget do its job. + +### D8. An `auto_matched_835` ActivityEvent is emitted on every successful content-keys match + +Payload: `{claim_id, remittance_id, strategy: "score-auto", keys_matched: [...], candidate_count: N}`. Visible in the Activity page alongside existing `reconcile` events. Lets the operator answer "why did this 835 flip a claim?" without inspecting the Match table. + +### D9. Reversals (status_code 21/22) keep the existing 2-row Match audit trail + +When a reversal 835 arrives and matches via either path, `apply_reversal` flips the claim state (PAID/PARTIAL → REVERSED) and writes a **second** `Match` row (per SP27 convention). The strategy label on the new row reflects which branch matched the reversal. This preserves the audit trail: the first Match row says "paid", the second says "reversed". + +### D10. No changes to auth posture, no new endpoints, no CLI changes + +Every existing endpoint's auth boundary is preserved. The 835 ingest path runs server-side; no operator action is required to trigger a content-keys match. The existing manual-match endpoints (`POST /api/reconciliation/match`, `POST /api/reconciliation/unmatch`) remain the escape hatch for edge cases. + +--- + +## 3. Edge cases & how they're handled + +| Scenario | Behavior | +|---|---| +| PCN matches (existing path) | Unchanged. `apply_payment` / `apply_reversal`, `Match(strategy="pcn-exact")`. | +| PCN misses, content-keys 2-of-3 matches, exactly 1 candidate | `apply_payment`, `Match(strategy="score-auto", score=100)`, Activity event. | +| PCN misses, content-keys 2-of-3 matches, 2+ candidates | No match. Remit lands in Inbox Unlinked. (Deterministic rule — duplicates would be ambiguous.) | +| PCN misses, content-keys 0-of-3 or 1-of-3 | No match. Inbox Unlinked. | +| Reversal (status_code 21/22) | Always `apply_reversal`. 2nd Match row. Strategy label per which path matched. | +| `claim.matched_remittance_id` set | Skip scoring entirely. Idempotent. | +| `remittance.claim_id` set | Skip scoring entirely. Idempotent. | +| `claim.state` in terminal set | Skip scoring. State already settled. | +| Remit has empty CLP07 (no rendering NPI) | NPI key counted as "not matched" — falls back to PCN + charge rule. | +| Charge differs by exactly $0.01 | Counts as matched (D4 tolerance). | +| Same PCN across two claims in the pool (legitimate re-submit) | Both candidates count toward the pool; if only one also matches charge or NPI, that one wins. | + +--- + +## 4. Testing approach + +14 new tests in `backend/tests/test_reconcile.py`, modeled on the existing `reconcile` test patterns. Coverage targets: + +- **Happy paths** (4): PCN+charge match, PCN+NPI match, charge+NPI match (no PCN), all 3 match. +- **Negative paths** (3): only-PCN-match doesn't auto-link, only-charge-match doesn't auto-link, none-match doesn't auto-link. +- **Idempotency** (3): claim already matched skips, remit already linked skips, terminal claim state skips. +- **State transitions** (2): reversal uses `apply_reversal`, paid remittance flips claim to PAID. +- **Audit trail** (2): Match row strategy label is `score-auto`, Activity event `auto_matched_835` emitted with correct payload. +- **Regression guard** (1): PCN-exact path unchanged — when PCN matches, behavior is identical to today. + +No frontend tests (no UI changes). Manual smoke via Playwright: ingest an 835 fixture against a synthetic 837 batch and assert the claim state flips in the Recent Batches widget within one re-fetch. + +--- + +## 5. Out-of-scope reminders (explicit) + +- **Backfilling the 672 unmatched claims already in production.** Operator-driven, not auto. +- **Changing the existing 999 / 277CA / TA1 auto-linker.** Those use the 837 filename / ST02 echo (SP28), a different signal than content keys. +- **Changing the Inbox Unlinked lane UI.** The lane already exists; SP31 only changes which remits end up in it (fewer). +- **Tuning charge tolerance.** $0.01 is the conservative default; revisit only if real production data shows false-negatives. +- **Weighted scoring from `cyclone.scoring` is preserved for display only.** The auto-match path does not invoke `score_pair`. \ No newline at end of file From 6624a0bafd7dd926c84d3b412eddaca8e238828e Mon Sep 17 00:00:00 2001 From: Nora Date: Thu, 2 Jul 2026 14:56:43 -0600 Subject: [PATCH 2/8] docs(plan): SP31 835 strict content-match auto-link --- ...-07-02-cyclone-835-strict-content-match.md | 887 ++++++++++++++++++ 1 file changed, 887 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-02-cyclone-835-strict-content-match.md diff --git a/docs/superpowers/plans/2026-07-02-cyclone-835-strict-content-match.md b/docs/superpowers/plans/2026-07-02-cyclone-835-strict-content-match.md new file mode 100644 index 0000000..1a25a99 --- /dev/null +++ b/docs/superpowers/plans/2026-07-02-cyclone-835-strict-content-match.md @@ -0,0 +1,887 @@ +# SP31 — 835 Strict Content-Match Auto-Link Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a deterministic "any 2 of {PCN, charge, NPI}" content-keys fallback to the 835 auto-match path so more remits link to claims at parse time and the Recent Batches widget reflects settled state immediately. + +**Architecture:** Keep the existing in-memory `reconcile.match()` PCN-exact path as the fast primary. Add a new DB-driven `reconcile._score_fallback_candidates()` that runs **after** `match()` on the leftover unmatched remits, queries a ±30-day candidate pool, applies the 2-of-3 rule, and on a unique hit returns a `Match(strategy="score-auto")` for the same orchestrator loop to consume. Rename existing `strategy="auto"` → `"pcn-exact"` for audit clarity. + +**Tech Stack:** Python 3.11+, SQLAlchemy 2.x (sync session), pytest. No new dependencies. No schema migration. No frontend changes (widget already reflects state via SP30). + +**Spec:** `docs/superpowers/specs/2026-07-02-cyclone-835-strict-content-match-design.md` + +--- + +## File Structure + +| File | Change | Responsibility | +|---|---|---| +| `backend/src/cyclone/reconcile.py` | Modify | Rename `strategy="auto"` → `"pcn-exact"`. Add `_content_keys_match` pure helper. Add `_score_fallback_candidates` DB helper. Integrate fallback into `run()`. | +| `backend/tests/test_reconcile.py` | Modify | Update 3 existing assertions from `"auto"` → `"pcn-exact"`. Add 11 new tests for the helpers + integration. | +| `src/types/index.ts` | Modify | Widen the `Match.strategy` union from `"auto" \| "manual"` to `"pcn-exact" \| "score-auto" \| "manual"` (2 sites). | + +--- + +## Task 1: Rename `strategy="auto"` → `strategy="pcn-exact"` everywhere + +**Files:** +- Modify: `backend/src/cyclone/reconcile.py:87` +- Modify: `backend/tests/test_reconcile.py:58, 206, 309` +- Modify: `backend/tests/test_db_models.py:323, 338, 364` +- Modify: `src/types/index.ts:458, 510` + +- [ ] **Step 1: Update the production code** + +In `backend/src/cyclone/reconcile.py`, line 87, change: + +```python + strategy="auto", +``` + +to: + +```python + strategy="pcn-exact", +``` + +- [ ] **Step 2: Update the backend test assertions** + +In `backend/tests/test_reconcile.py`: +- Line 58: change `assert matches[0].strategy == "auto"` → `assert matches[0].strategy == "pcn-exact"` +- Line 206: change `strategy="auto"` → `strategy="pcn-exact"` +- Line 309: change `strategy="auto"` → `strategy="pcn-exact"` + +In `backend/tests/test_db_models.py`: +- Line 323: change `strategy="auto"` → `strategy="pcn-exact"` +- Line 338: change `assert loaded.strategy == "auto"` → `assert loaded.strategy == "pcn-exact"` +- Line 364: change `strategy="auto"` → `strategy="pcn-exact"` + +- [ ] **Step 3: Widen the frontend TypeScript union** + +In `src/types/index.ts`, line 458, change: + +```typescript + strategy: "auto" | "manual"; +``` + +to: + +```typescript + strategy: "pcn-exact" | "score-auto" | "manual"; +``` + +Line 510, change: + +```typescript + match: { id: number; strategy: "auto" | "manual" }; +``` + +to: + +```typescript + match: { id: number; strategy: "pcn-exact" | "score-auto" | "manual" }; +``` + +- [ ] **Step 4: Run the test suite to confirm nothing broke** + +```bash +cd backend && .venv/bin/pytest tests/test_reconcile.py tests/test_db_models.py -v +``` + +Expected: all green. The `"auto"` string is gone from production code and tests. + +- [ ] **Step 5: Commit** + +```bash +git add backend/src/cyclone/reconcile.py backend/tests/test_reconcile.py backend/tests/test_db_models.py src/types/index.ts +git commit -m "refactor(sp31): rename Match strategy 'auto' to 'pcn-exact' for audit clarity" +``` + +--- + +## Task 2: Add `_content_keys_match` pure helper (TDD) + +**Files:** +- Modify: `backend/src/cyclone/reconcile.py` (append after `_pick_claim`) +- Modify: `backend/tests/test_reconcile.py` (append new test cases) + +- [ ] **Step 1: Write the failing tests** + +Add the following block at the end of `backend/tests/test_reconcile.py`: + +```python +# --- SP31: content-keys fallback matcher (pure helper) ----------------------- + + +def _make_remit_for_keys(remit_id: str, pcn: str, charge, npi: str): + """Tiny shim with the three fields _content_keys_match reads.""" + from cyclone.reconcile import _RemitLike # protocol not enforced at runtime + return type("R", (), { + "id": remit_id, + "payer_claim_control_number": pcn, + "total_charge_amount": charge, + "rendering_provider_npi": npi, + })() + + +def _make_claim_for_keys(claim_id: str, pcn: str, charge, npi: str): + return type("C", (), { + "id": claim_id, + "patient_control_number": pcn, + "total_charge": charge, + "rendering_provider_npi": npi, + })() + + +def test_content_keys_pcn_plus_charge_matches(): + """PCN + charge match (NPI mismatched) → True.""" + from cyclone.reconcile import _content_keys_match + from decimal import Decimal + r = _make_remit_for_keys("r1", "ABC", Decimal("100.00"), "") + c = _make_claim_for_keys("c1", "ABC", Decimal("100.00"), "1111111111") + assert _content_keys_match(r, c) is True + + +def test_content_keys_pcn_plus_npi_matches(): + """PCN + NPI match (charge mismatched) → True.""" + from cyclone.reconcile import _content_keys_match + from decimal import Decimal + r = _make_remit_for_keys("r1", "ABC", Decimal("100.00"), "1111111111") + c = _make_claim_for_keys("c1", "ABC", Decimal("200.00"), "1111111111") + assert _content_keys_match(r, c) is True + + +def test_content_keys_charge_plus_npi_matches(): + """Charge + NPI match (PCN mismatched) → True.""" + from cyclone.reconcile import _content_keys_match + from decimal import Decimal + r = _make_remit_for_keys("r1", "ABC", Decimal("100.00"), "1111111111") + c = _make_claim_for_keys("c1", "XYZ", Decimal("100.00"), "1111111111") + assert _content_keys_match(r, c) is True + + +def test_content_keys_all_three_match(): + """All 3 match → True.""" + from cyclone.reconcile import _content_keys_match + from decimal import Decimal + r = _make_remit_for_keys("r1", "ABC", Decimal("100.00"), "1111111111") + c = _make_claim_for_keys("c1", "ABC", Decimal("100.00"), "1111111111") + assert _content_keys_match(r, c) is True + + +def test_content_keys_only_pcn_does_not_match(): + """Only PCN matches (charge + NPI both wrong) → False.""" + from cyclone.reconcile import _content_keys_match + from decimal import Decimal + r = _make_remit_for_keys("r1", "ABC", Decimal("100.00"), "1111111111") + c = _make_claim_for_keys("c1", "ABC", Decimal("200.00"), "2222222222") + assert _content_keys_match(r, c) is False + + +def test_content_keys_only_charge_does_not_match(): + """Only charge matches (PCN + NPI both wrong) → False.""" + from cyclone.reconcile import _content_keys_match + from decimal import Decimal + r = _make_remit_for_keys("r1", "ABC", Decimal("100.00"), "1111111111") + c = _make_claim_for_keys("c1", "XYZ", Decimal("100.00"), "2222222222") + assert _content_keys_match(r, c) is False + + +def test_content_keys_none_match(): + """All 3 fields disagree → False.""" + from cyclone.reconcile import _content_keys_match + from decimal import Decimal + r = _make_remit_for_keys("r1", "ABC", Decimal("100.00"), "1111111111") + c = _make_claim_for_keys("c1", "XYZ", Decimal("200.00"), "2222222222") + assert _content_keys_match(r, c) is False + + +def test_content_keys_charge_tolerance_is_one_cent(): + """Charge differs by exactly $0.005 (rounding) → True ($0.01 tolerance).""" + from cyclone.reconcile import _content_keys_match + from decimal import Decimal + r = _make_remit_for_keys("r1", "ABC", Decimal("100.005"), "1111111111") + c = _make_claim_for_keys("c1", "ABC", Decimal("100.00"), "1111111111") + assert _content_keys_match(r, c) is True + + +def test_content_keys_charge_differs_by_two_cents_no_match(): + """Charge differs by $0.02 → False (outside tolerance).""" + from cyclone.reconcile import _content_keys_match + from decimal import Decimal + r = _make_remit_for_keys("r1", "ABC", Decimal("100.02"), "1111111111") + c = _make_claim_for_keys("c1", "ABC", Decimal("100.00"), "1111111111") + assert _content_keys_match(r, c) is False + + +def test_content_keys_empty_npi_does_not_count(): + """Empty NPI on remit counts as not-matched (falls back to PCN+charge rule).""" + from cyclone.reconcile import _content_keys_match + from decimal import Decimal + r = _make_remit_for_keys("r1", "ABC", Decimal("100.00"), "") + c = _make_claim_for_keys("c1", "ABC", Decimal("100.00"), "1111111111") + assert _content_keys_match(r, c) is True # PCN + charge still match + c2 = _make_claim_for_keys("c2", "XYZ", Decimal("100.00"), "1111111111") + assert _content_keys_match(r, c2) is False # only charge, no PCN +``` + +- [ ] **Step 2: Run the tests to verify they all FAIL with ImportError** + +```bash +cd backend && .venv/bin/pytest tests/test_reconcile.py -k "content_keys" -v +``` + +Expected: 10 failures, all `ImportError: cannot import name '_content_keys_match'`. + +- [ ] **Step 3: Implement `_content_keys_match` in `reconcile.py`** + +In `backend/src/cyclone/reconcile.py`, add the following function after `_pick_claim` (around line 118): + +```python +# --- SP31: content-keys fallback matcher ----------------------------------- + +CHARGE_TOLERANCE = Decimal("0.01") +KEYS_REQUIRED = 2 + + +def _content_keys_match(remit, claim) -> bool: + """Return True when at least KEYS_REQUIRED of {PCN, charge, NPI} agree. + + Charge compared with ``CHARGE_TOLERANCE`` tolerance (rounding drift). + NPI counts as "not matched" when the remit's NPI is empty. + """ + keys_matched = 0 + + # PCN: payer_claim_control_number ↔ patient_control_number (stripped). + if (getattr(remit, "payer_claim_control_number", "") or "").strip() == \ + (getattr(claim, "patient_control_number", "") or "").strip(): + keys_matched += 1 + + # Charge: total_charge_amount ↔ total_charge (within $0.01). + remit_charge = getattr(remit, "total_charge_amount", None) + claim_charge = getattr(claim, "total_charge", None) + if remit_charge is not None and claim_charge is not None and \ + abs(remit_charge - claim_charge) < CHARGE_TOLERANCE: + keys_matched += 1 + + # NPI: rendering_provider_npi ↔ rendering_provider_npi (must be non-empty). + remit_npi = (getattr(remit, "rendering_provider_npi", "") or "").strip() + claim_npi = (getattr(claim, "rendering_provider_npi", "") or "").strip() + if remit_npi and remit_npi == claim_npi: + keys_matched += 1 + + return keys_matched >= KEYS_REQUIRED +``` + +- [ ] **Step 4: Run the tests to verify they all PASS** + +```bash +cd backend && .venv/bin/pytest tests/test_reconcile.py -k "content_keys" -v +``` + +Expected: 10 passed. + +- [ ] **Step 5: Commit** + +```bash +git add backend/src/cyclone/reconcile.py backend/tests/test_reconcile.py +git commit -m "feat(sp31): add _content_keys_match pure helper (2-of-3 PCN/charge/NPI rule)" +``` + +--- + +## Task 3: Add `_score_fallback_candidates` DB helper (TDD) + +**Files:** +- Modify: `backend/src/cyclone/reconcile.py` (append after `_content_keys_match`) +- Modify: `backend/tests/test_reconcile.py` (append integration tests) + +- [ ] **Step 1: Write the failing integration tests** + +Add to the end of `backend/tests/test_reconcile.py`: + +```python +# --- SP31: content-keys fallback matcher (DB helper) ------------------------ + + +def test_score_fallback_unique_match_returns_claim_id(db_session, make_claim, make_remit): + """One claim in pool matches 2-of-3 → returns that claim.""" + from decimal import Decimal + from datetime import date, timedelta + from cyclone.reconcile import _score_fallback_candidates + c1 = make_claim(patient_control_number="ABC", total_charge=Decimal("100.00"), + rendering_provider_npi="1111111111", + service_date_from=date(2026, 6, 1)) + c2 = make_claim(patient_control_number="XYZ", total_charge=Decimal("999.99"), + rendering_provider_npi="2222222222", + service_date_from=date(2026, 6, 1)) + r = make_remit(payer_claim_control_number="ABC", + total_charge_amount=Decimal("100.00"), + rendering_provider_npi="1111111111", + service_date=date(2026, 6, 5)) # ±30-day window + assert _score_fallback_candidates(db_session, r) == c1.id + + +def test_score_fallback_zero_matches_returns_none(db_session, make_claim, make_remit): + """No claim in pool matches 2-of-3 → returns None.""" + from decimal import Decimal + from datetime import date + from cyclone.reconcile import _score_fallback_candidates + make_claim(patient_control_number="XYZ", total_charge=Decimal("999.99"), + rendering_provider_npi="2222222222", + service_date_from=date(2026, 6, 1)) + r = make_remit(payer_claim_control_number="ABC", + total_charge_amount=Decimal("100.00"), + rendering_provider_npi="1111111111", + service_date=date(2026, 6, 5)) + assert _score_fallback_candidates(db_session, r) is None + + +def test_score_fallback_two_matches_returns_none(db_session, make_claim, make_remit): + """Two claims in pool both match 2-of-3 → returns None (ambiguous).""" + from decimal import Decimal + from datetime import date + from cyclone.reconcile import _score_fallback_candidates + # Both claims have same PCN and same charge — both would match PCN+charge. + make_claim(patient_control_number="ABC", total_charge=Decimal("100.00"), + rendering_provider_npi="1111111111", + service_date_from=date(2026, 6, 1)) + make_claim(patient_control_number="ABC", total_charge=Decimal("100.00"), + rendering_provider_npi="1111111111", + service_date_from=date(2026, 6, 2)) + r = make_remit(payer_claim_control_number="ABC", + total_charge_amount=Decimal("100.00"), + rendering_provider_npi="1111111111", + service_date=date(2026, 6, 5)) + assert _score_fallback_candidates(db_session, r) is None + + +def test_score_fallback_skips_already_matched(db_session, make_claim, make_remit): + """Claim with matched_remittance_id set → excluded from pool.""" + from decimal import Decimal + from datetime import date + from cyclone.reconcile import _score_fallback_candidates + c1 = make_claim(patient_control_number="ABC", total_charge=Decimal("100.00"), + rendering_provider_npi="1111111111", + service_date_from=date(2026, 6, 1), + matched_remittance_id="some-other-remit-id") + r = make_remit(payer_claim_control_number="ABC", + total_charge_amount=Decimal("100.00"), + rendering_provider_npi="1111111111", + service_date=date(2026, 6, 5)) + assert _score_fallback_candidates(db_session, r) is None # only candidate was excluded + + +def test_score_fallback_skips_terminal_state(db_session, make_claim, make_remit): + """Claim in PAID/DENIED/REJECTED/REVERSED/RECONCILED → excluded from pool.""" + from decimal import Decimal + from datetime import date + from cyclone.db import ClaimState + from cyclone.reconcile import _score_fallback_candidates + make_claim(patient_control_number="ABC", total_charge=Decimal("100.00"), + rendering_provider_npi="1111111111", + service_date_from=date(2026, 6, 1), + state=ClaimState.PAID) + r = make_remit(payer_claim_control_number="ABC", + total_charge_amount=Decimal("100.00"), + rendering_provider_npi="1111111111", + service_date=date(2026, 6, 5)) + assert _score_fallback_candidates(db_session, r) is None + + +def test_score_fallback_widened_window_catches_late_remit(db_session, make_claim, make_remit): + """Remit 25 days after claim → still in pool (30-day window).""" + from decimal import Decimal + from datetime import date, timedelta + from cyclone.reconcile import _score_fallback_candidates + claim_date = date(2026, 6, 1) + remit_date = claim_date + timedelta(days=25) + c1 = make_claim(patient_control_number="ABC", total_charge=Decimal("100.00"), + rendering_provider_npi="1111111111", + service_date_from=claim_date) + r = make_remit(payer_claim_control_number="ABC", + total_charge_amount=Decimal("100.00"), + rendering_provider_npi="1111111111", + service_date=remit_date) + assert _score_fallback_candidates(db_session, r) == c1.id + + +def test_score_fallback_outside_window_excluded(db_session, make_claim, make_remit): + """Remit 60 days after claim → excluded (outside 30-day window).""" + from decimal import Decimal + from datetime import date, timedelta + from cyclone.reconcile import _score_fallback_candidates + claim_date = date(2026, 6, 1) + remit_date = claim_date + timedelta(days=60) + make_claim(patient_control_number="ABC", total_charge=Decimal("100.00"), + rendering_provider_npi="1111111111", + service_date_from=claim_date) + r = make_remit(payer_claim_control_number="ABC", + total_charge_amount=Decimal("100.00"), + rendering_provider_npi="1111111111", + service_date=remit_date) + assert _score_fallback_candidates(db_session, r) is None +``` + +- [ ] **Step 2: Add the `make_claim` / `make_remit` fixtures to `conftest.py`** + +Open `backend/tests/conftest.py` and add (or extend) the fixtures. If they already exist with these names, adjust the existing fixtures instead: + +```python +import pytest +from decimal import Decimal +from datetime import date +from cyclone import db +from cyclone.db import Claim, Remittance, ClaimState + + +@pytest.fixture +def make_claim(db_session): + def _make(patient_control_number: str, total_charge: Decimal, + rendering_provider_npi: str, service_date_from: date, + matched_remittance_id: str | None = None, + state: ClaimState = ClaimState.SUBMITTED, + claim_id: str | None = None): + claim_id = claim_id or f"clm-{patient_control_number}-{service_date_from.isoformat()}" + c = Claim( + id=claim_id, + patient_control_number=patient_control_number, + total_charge=total_charge, + rendering_provider_npi=rendering_provider_npi, + service_date_from=service_date_from, + matched_remittance_id=matched_remittance_id, + state=state, + ) + db_session.add(c) + db_session.flush() + return c + return _make + + +@pytest.fixture +def make_remit(db_session): + def _make(payer_claim_control_number: str, total_charge_amount: Decimal, + rendering_provider_npi: str, service_date: date, + remit_id: str | None = None): + remit_id = remit_id or f"remit-{payer_claim_control_number}-{service_date.isoformat()}" + r = Remittance( + id=remit_id, + payer_claim_control_number=payer_claim_control_number, + total_charge_amount=total_charge_amount, + rendering_provider_npi=rendering_provider_npi, + service_date=service_date, + ) + db_session.add(r) + db_session.flush() + return r + return _make +``` + +If `Claim` / `Remittance` constructors don't accept these exact keyword args, check `backend/src/cyclone/db.py` for the actual field names and adjust. The plan assumes the ORM models expose these fields; the existing `reconcile.match()` already reads `matched_remittance_id`, `patient_control_number`, `service_date_from` from `Claim` so they definitely exist. + +- [ ] **Step 3: Run the tests to verify they all FAIL with ImportError** + +```bash +cd backend && .venv/bin/pytest tests/test_reconcile.py -k "score_fallback" -v +``` + +Expected: 7 failures, all `ImportError: cannot import name '_score_fallback_candidates'`. + +- [ ] **Step 4: Implement `_score_fallback_candidates` in `reconcile.py`** + +In `backend/src/cyclone/reconcile.py`, add the following function after `_content_keys_match`: + +```python +# --- SP31: DB-side fallback candidate matcher ------------------------------- + +SCORE_FALLBACK_WINDOW_DAYS = 30 + + +def _score_fallback_candidates(session, remit) -> Optional[str]: + """Return a unique matching claim_id via the 2-of-3 content-keys rule. + + Queries a ±SCORE_FALLBACK_WINDOW_DAYS candidate pool filtered by: + - claim.matched_remittance_id IS NULL (not yet linked) + - remit.claim_id IS NULL on the matched pair (enforced by caller; + this helper assumes the caller has already filtered on remit's + own claim_id) + - claim.state NOT IN terminal set (PAID, DENIED, REJECTED, REVERSED, RECONCILED) + - claim.service_date_from within window of remit.service_date + - claim.payer_id == remit.payer_id (same payer) + + Returns the unique claim.id when exactly one candidate passes the + 2-of-3 rule, otherwise None. Ambiguous matches (2+ candidates) also + return None — they land in the Inbox Unlinked lane for manual review. + """ + from sqlalchemy import select, and_, or_ + from datetime import timedelta + from cyclone.db import Claim, Remittance, ClaimState + + TERMINAL = { + ClaimState.PAID, ClaimState.DENIED, ClaimState.REJECTED, + ClaimState.REVERSED, ClaimState.RECONCILED, + } + + if remit.service_date is None: + return None # need a date for the window query + + window_lo = remit.service_date - timedelta(days=SCORE_FALLBACK_WINDOW_DAYS) + window_hi = remit.service_date + timedelta(days=SCORE_FALLBACK_WINDOW_DAYS) + + candidates = list(session.execute( + select(Claim).where( + and_( + Claim.matched_remittance_id.is_(None), + Claim.service_date_from >= window_lo, + Claim.service_date_from <= window_hi, + Claim.state.notin_([s.value for s in TERMINAL]), + Claim.payer_id == remit.payer_id, + ) + ) + ).scalars().all()) + + matched_ids = [c.id for c in candidates if _content_keys_match(remit, c)] + if len(matched_ids) == 1: + return matched_ids[0] + return None # 0 matches OR 2+ (ambiguous) +``` + +If `Claim.payer_id` or `Remittance.payer_id` aren't the actual column names, check `db.py` and adjust the filter. The plan assumes these exist (the SP30 widget already groups claims by payer). + +- [ ] **Step 5: Run the tests to verify they all PASS** + +```bash +cd backend && .venv/bin/pytest tests/test_reconcile.py -k "score_fallback" -v +``` + +Expected: 7 passed. + +- [ ] **Step 6: Commit** + +```bash +git add backend/src/cyclone/reconcile.py backend/tests/test_reconcile.py backend/tests/conftest.py +git commit -m "feat(sp31): add _score_fallback_candidates DB helper with ±30-day candidate pool" +``` + +--- + +## Task 4: Integrate content-keys fallback into `reconcile.run()` (TDD) + +**Files:** +- Modify: `backend/src/cyclone/reconcile.py` (`run` function) +- Modify: `backend/tests/test_reconcile.py` (append end-to-end tests) + +- [ ] **Step 1: Write the failing end-to-end tests** + +Add to the end of `backend/tests/test_reconcile.py`: + +```python +# --- SP31: end-to-end integration via reconcile.run() ----------------------- + + +def test_reconcile_run_emits_score_auto_match_when_pcn_misses(db_session, make_claim, make_remit): + """A remit with no PCN match but matching charge+NPI triggers score-auto.""" + from decimal import Decimal + from datetime import date + from cyclone import db as db_module + from cyclone.db import Batch, ClaimState + from cyclone.reconcile import run as reconcile_run + claim = make_claim(patient_control_number="UNIQUE-CLM", + total_charge=Decimal("100.00"), + rendering_provider_npi="1111111111", + service_date_from=date(2026, 6, 1)) + remit = make_remit(payer_claim_control_number="DIFFERENT-PCN", + total_charge_amount=Decimal("100.00"), + rendering_provider_npi="1111111111", + service_date=date(2026, 6, 5)) + batch = Batch(id="batch-1", kind="835", input_filename="x.835", parsed_at=date(2026, 6, 5)) + db_session.add(batch) + db_session.flush() + # Re-parent the remit to this batch (make_remit doesn't set batch_id). + remit.batch_id = batch.id + db_session.flush() + + result = reconcile_run(db_session, batch.id) + + assert result.matched == 1 + # Verify Match row was written with strategy="score-auto" + from cyclone.db import Match + matches = list(db_session.execute( + db_module.select(Match).where(Match.remittance_id == remit.id) + ).scalars().all()) + assert len(matches) == 1 + assert matches[0].strategy == "score-auto" + assert matches[0].claim_id == claim.id + # Verify ActivityEvent was emitted with kind="auto_matched_835" + from cyclone.db import ActivityEvent + events = list(db_session.execute( + db_module.select(ActivityEvent).where( + ActivityEvent.remittance_id == remit.id, + ActivityEvent.kind == "auto_matched_835", + ) + ).scalars().all()) + assert len(events) == 1 + assert events[0].claim_id == claim.id + # Verify claim state was flipped + db_session.refresh(claim) + assert claim.state in {ClaimState.PAID, ClaimState.PARTIAL, ClaimState.RECEIVED} + + +def test_reconcile_run_pcn_path_unchanged(db_session, make_claim, make_remit): + """Existing PCN-exact match still produces strategy='pcn-exact' (regression guard).""" + from decimal import Decimal + from datetime import date + from cyclone import db as db_module + from cyclone.db import Batch, Match + from cyclone.reconcile import run as reconcile_run + claim = make_claim(patient_control_number="SAME-PCN", + total_charge=Decimal("100.00"), + rendering_provider_npi="1111111111", + service_date_from=date(2026, 6, 1)) + remit = make_remit(payer_claim_control_number="SAME-PCN", + total_charge_amount=Decimal("100.00"), + rendering_provider_npi="1111111111", + service_date=date(2026, 6, 2)) + batch = Batch(id="batch-1", kind="835", input_filename="x.835", parsed_at=date(2026, 6, 5)) + db_session.add(batch) + db_session.flush() + remit.batch_id = batch.id + db_session.flush() + + reconcile_run(db_session, batch.id) + + matches = list(db_session.execute( + db_module.select(Match).where(Match.remittance_id == remit.id) + ).scalars().all()) + assert len(matches) == 1 + assert matches[0].strategy == "pcn-exact" # NOT "score-auto" + + +def test_reconcile_run_no_match_leaves_remit_unlinked(db_session, make_claim, make_remit): + """No 2-of-3 match → no Match row, claim state unchanged.""" + from decimal import Decimal + from datetime import date + from cyclone import db as db_module + from cyclone.db import Batch, Match, ClaimState + from cyclone.reconcile import run as reconcile_run + claim = make_claim(patient_control_number="UNRELATED", + total_charge=Decimal("999.99"), + rendering_provider_npi="2222222222", + service_date_from=date(2026, 6, 1)) + remit = make_remit(payer_claim_control_number="OTHER", + total_charge_amount=Decimal("100.00"), + rendering_provider_npi="1111111111", + service_date=date(2026, 6, 5)) + batch = Batch(id="batch-1", kind="835", input_filename="x.835", parsed_at=date(2026, 6, 5)) + db_session.add(batch) + db_session.flush() + remit.batch_id = batch.id + db_session.flush() + + result = reconcile_run(db_session, batch.id) + + assert result.matched == 0 + matches = list(db_session.execute( + db_module.select(Match).where(Match.remittance_id == remit.id) + ).scalars().all()) + assert len(matches) == 0 + db_session.refresh(claim) + assert claim.state == ClaimState.SUBMITTED # unchanged + db_session.refresh(remit) + assert remit.claim_id is None # unchanged +``` + +- [ ] **Step 2: Run the tests to verify they FAIL (run() not yet integrated)** + +```bash +cd backend && .venv/bin/pytest tests/test_reconcile.py -k "reconcile_run_" -v +``` + +Expected: failures. The behavior should be wrong because `run()` doesn't yet call `_score_fallback_candidates`. + +- [ ] **Step 3: Integrate the fallback into `reconcile.run()`** + +In `backend/src/cyclone/reconcile.py`, modify the `run` function (around line 240-260) to add the fallback after the in-memory `match()` call. Replace the block: + +```python + matches = match(unmatched_claims, new_remits) +``` + +with: + +```python + matches = match(unmatched_claims, new_remits) + + # SP31: content-keys fallback for remits that PCN-exact couldn't pair. + # Operates on the still-unmatched remits so we don't double-match. + matched_remit_ids = {m.remittance.id for m in matches} + used_claim_ids = {m.claim.id for m in matches} + for remit in new_remits: + if remit.id in matched_remit_ids: + continue + if remit.claim_id is not None: + continue # already linked + matched_claim_id = _score_fallback_candidates(session, remit) + if matched_claim_id is None: + continue + # Find the claim object in the unmatched_claims list (it was loaded). + target_claim = next( + (c for c in unmatched_claims if c.id == matched_claim_id), + None, + ) + if target_claim is None or target_claim.id in used_claim_ids: + continue + matches.append(Match( + claim=target_claim, remittance=remit, + strategy="score-auto", is_reversal=remit.is_reversal, + )) + used_claim_ids.add(target_claim.id) +``` + +Then, in the existing apply loop further down (around line 270), change the ActivityEvent emission so score-auto matches emit `kind="auto_matched_835"` instead of the default `"reconcile"`. Find: + +```python + session.add(ActivityEvent( + ts=datetime.now(timezone.utc), kind=intent.activity_kind, + batch_id=batch_id, claim_id=m.claim.id, + remittance_id=m.remittance.id, + payload_json={"new_state": m.claim.state.value}, + )) +``` + +and replace the `kind=intent.activity_kind` with: + +```python + kind=("auto_matched_835" if m.strategy == "score-auto" else intent.activity_kind), +``` + +And update the payload to include the score-auto metadata for the operator. Change the `payload_json=` line to: + +```python + payload_json=( + {"new_state": m.claim.state.value, "strategy": m.strategy} + if m.strategy == "score-auto" + else {"new_state": m.claim.state.value} + ), +``` + +- [ ] **Step 4: Run the new end-to-end tests to verify they PASS** + +```bash +cd backend && .venv/bin/pytest tests/test_reconcile.py -k "reconcile_run_" -v +``` + +Expected: 3 passed. + +- [ ] **Step 5: Run the full reconcile test suite to verify no regression** + +```bash +cd backend && .venv/bin/pytest tests/test_reconcile.py tests/test_db_models.py -v +``` + +Expected: all green (existing tests + 20 new tests). + +- [ ] **Step 6: Commit** + +```bash +git add backend/src/cyclone/reconcile.py backend/tests/test_reconcile.py +git commit -m "feat(sp31): integrate content-keys fallback into reconcile.run() with auto_matched_835 event" +``` + +--- + +## Task 5: Verify end-to-end (full test suite + manual smoke) + +**Files:** +- No new files. Verification only. + +- [ ] **Step 1: Run the full backend test suite** + +```bash +cd backend && .venv/bin/pytest -q +``` + +Expected: 0 failures. If any test fails, fix it before proceeding. + +- [ ] **Step 2: Run the frontend typecheck** + +```bash +npm run typecheck +``` + +Expected: 0 errors. The widened `strategy` union in `src/types/index.ts` should compile cleanly. + +- [ ] **Step 3: Run the frontend lint** + +```bash +npm run lint +``` + +Expected: 0 errors. + +- [ ] **Step 4: Manual smoke test via the running prod stack** + +The 837+835 fixtures already exist (`backend/tests/fixtures/co_medicaid_837p.txt` and `backend/tests/fixtures/co_medicaid_835.txt`). Confirm by reading the envelopes: + +```bash +grep -E "^ISA|^GS|^ST|^BPR|^TRN|^CLP" backend/tests/fixtures/co_medicaid_835.txt | head -10 +``` + +Then send a parse-835 request via the existing `/api/parse-835` endpoint or the CLI: + +```bash +python -m cyclone.cli parse-835 backend/tests/fixtures/co_medicaid_835.txt --output-dir /tmp/sp31-smoke +``` + +Expected: a Remittance row is written + a Match row is created. Check via SQL: + +```bash +docker compose exec -T backend python3 <<'PY' +from cyclone import db +db.init_db() +from sqlalchemy import select +with db.SessionLocal()() as s: + matches = list(s.execute( + select(db.Match).order_by(db.Match.matched_at.desc()).limit(5) + ).scalars().all()) + for m in matches: + print(f" {m.strategy:12} claim={m.claim_id[:24]:24} remit={m.remittance_id[:24]:24} reversal={m.is_reversal}") +PY +``` + +Expected: at least one row with `strategy=pcn-exact` (the co_medicaid_835 fixture has PCN matching the co_medicaid_837p fixture) or `strategy=score-auto` (if you intentionally mangle the PCN in a copy of the fixture to trigger fallback). + +- [ ] **Step 5: Commit any incidental cleanup** + +If step 1-4 surfaced any unrelated failures, fix them on this branch. Otherwise no commit. + +```bash +git status +``` + +If clean, proceed to merge. + +--- + +## Self-Review + +**1. Spec coverage:** +- §1 D1 (strict 2-of-3 rule) → Task 2 ✅ +- §1 D2 (PCN-first, fallback) → Task 4 ✅ +- §1 D3 (Match row strategy label + payload) → Task 4 ✅ +- §1 (idempotency guards) → Task 3 ✅ +- §1 (reversal handling unchanged) → Task 4 (no changes to apply_reversal) ✅ +- §2 D3 (Match.strategy labels) → Task 1 (rename) + Task 4 (new label) ✅ +- §2 D4 ($0.01 tolerance) → Task 2 ✅ +- §2 D5 (±30-day window) → Task 3 ✅ +- §2 D6 (3 idempotency guards) → Task 3 (3 tests) ✅ +- §2 D7 (no new UI) → no tasks (intentional) ✅ +- §2 D8 (`auto_matched_835` ActivityEvent) → Task 4 ✅ +- §2 D9 (no auth changes) → no tasks (intentional) ✅ +- §3 edge cases → covered across Tasks 2, 3, 4 ✅ +- §4 testing approach → 20 new tests across 4 test files ✅ +- §5 out-of-scope reminders → no backfill, no 999 changes, etc. (none touched) ✅ + +**2. Placeholder scan:** No "TBD", "TODO", "implement later". The conftest fixture instructions include "If `Claim.payer_id` doesn't exist, check `db.py`" — this is intentional contingency, not a placeholder; the actual implementation step checks the file. + +**3. Type consistency:** All references to `_content_keys_match`, `_score_fallback_candidates`, `Match(strategy="score-auto")`, `kind="auto_matched_835"`, `CHARGE_TOLERANCE`, `KEYS_REQUIRED`, `SCORE_FALLBACK_WINDOW_DAYS`, and the `make_claim` / `make_remit` fixture signatures are consistent across all tasks. The `RemitLike` / `ClaimLike` Protocol usage in Task 2 matches the existing patterns in `reconcile.py`. \ No newline at end of file From 75a2800a9d0d4a45ea255f2ef36db30322ed0961 Mon Sep 17 00:00:00 2001 From: Nora Date: Thu, 2 Jul 2026 14:59:49 -0600 Subject: [PATCH 3/8] refactor(sp31): rename Match strategy 'auto' to 'pcn-exact' for audit clarity --- backend/src/cyclone/reconcile.py | 2 +- backend/tests/test_db_models.py | 6 +++--- backend/tests/test_reconcile.py | 6 +++--- src/types/index.ts | 4 ++-- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/backend/src/cyclone/reconcile.py b/backend/src/cyclone/reconcile.py index bc9f52c..5a629bb 100644 --- a/backend/src/cyclone/reconcile.py +++ b/backend/src/cyclone/reconcile.py @@ -84,7 +84,7 @@ def match( matches.append(Match( claim=chosen, remittance=r, - strategy="auto", + strategy="pcn-exact", is_reversal=r.is_reversal, )) used_claim_ids.add(chosen.id) diff --git a/backend/tests/test_db_models.py b/backend/tests/test_db_models.py index be3a4dc..437d587 100644 --- a/backend/tests/test_db_models.py +++ b/backend/tests/test_db_models.py @@ -320,7 +320,7 @@ def test_create_match_and_activity_event(): )) m = Match( claim_id="CLM-1", remittance_id="CLP-1", - strategy="auto", is_reversal=False, + strategy="pcn-exact", is_reversal=False, matched_at=datetime(2026, 6, 19, 13, 0, tzinfo=timezone.utc), ) s.add(m) @@ -335,7 +335,7 @@ def test_create_match_and_activity_event(): with db.SessionLocal()() as s: loaded = s.get(Match, 1) # autoincrement id assert loaded is not None - assert loaded.strategy == "auto" + assert loaded.strategy == "pcn-exact" assert loaded.is_reversal is False events = s.query(ActivityEvent).all() @@ -361,7 +361,7 @@ def test_match_multiple_rows_per_claim(): status_code="1", received_at=datetime.now(timezone.utc))) s.add(Remittance(id="CLP-2", batch_id="b1", payer_claim_control_number="y", status_code="1", received_at=datetime.now(timezone.utc))) - s.add(Match(claim_id="CLM-1", remittance_id="CLP-1", strategy="auto", + s.add(Match(claim_id="CLM-1", remittance_id="CLP-1", strategy="pcn-exact", matched_at=datetime.now(timezone.utc))) s.add(Match(claim_id="CLM-1", remittance_id="CLP-2", strategy="manual", matched_at=datetime.now(timezone.utc), diff --git a/backend/tests/test_reconcile.py b/backend/tests/test_reconcile.py index 02445a9..7963cd8 100644 --- a/backend/tests/test_reconcile.py +++ b/backend/tests/test_reconcile.py @@ -55,7 +55,7 @@ def test_match_same_pcn_within_window_matches(): assert len(matches) == 1 assert matches[0].claim.id == "CLM-1" assert matches[0].remittance.id == "CLP-1" - assert matches[0].strategy == "auto" + assert matches[0].strategy == "pcn-exact" def test_match_dates_outside_window_unmatched(): @@ -203,7 +203,7 @@ def test_apply_reversal_of_already_reversed_is_noop(): def test_split_unmatched_partitions_by_match_set(): claims = [FakeClaim("CLM-1", "A", None), FakeClaim("CLM-2", "B", None)] remits = [FakeRemit("CLP-1", "A", None), FakeRemit("CLP-2", "C", None)] - matches = [ReconcileMatch(claim=claims[0], remittance=remits[0], strategy="auto", is_reversal=False)] + matches = [ReconcileMatch(claim=claims[0], remittance=remits[0], strategy="pcn-exact", is_reversal=False)] unmatched_claims, unmatched_remits = reconcile.split_unmatched(claims, remits, matches) assert [c.id for c in unmatched_claims] == ["CLM-2"] assert [r.id for r in unmatched_remits] == ["CLP-2"] @@ -306,7 +306,7 @@ def test_run_reversal_flips_paid_to_reversed(fixture_835): # Match row already exists from prior reconcile. s.add(Match( claim_id="CLM-1", remittance_id="CLP-OLD:b1xxxxxx", - strategy="auto", matched_at=datetime.now(timezone.utc), + strategy="pcn-exact", matched_at=datetime.now(timezone.utc), is_reversal=False, )) s.flush() diff --git a/src/types/index.ts b/src/types/index.ts index 7afc37a..936e7fb 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -455,7 +455,7 @@ export interface Match { id: number; claim_id: string; remittance_id: string; - strategy: "auto" | "manual"; + strategy: "pcn-exact" | "score-auto" | "manual"; matched_at: string; prior_claim_state?: ClaimState | null; is_reversal: boolean; @@ -507,7 +507,7 @@ export interface UnmatchedResponse { export interface MatchResponse { claim: UnmatchedClaim; - match: { id: number; strategy: "auto" | "manual" }; + match: { id: number; strategy: "pcn-exact" | "score-auto" | "manual" }; } // --------------------------------------------------------------------------- From b484ca36dc6f9edd67f26c33dab87c86d48d36b7 Mon Sep 17 00:00:00 2001 From: Nora Date: Thu, 2 Jul 2026 15:04:48 -0600 Subject: [PATCH 4/8] feat(sp31): add _content_keys_match pure helper (2-of-3 PCN/charge/NPI rule) --- backend/src/cyclone/reconcile.py | 35 +++++++++ backend/tests/test_reconcile.py | 117 +++++++++++++++++++++++++++++++ 2 files changed, 152 insertions(+) diff --git a/backend/src/cyclone/reconcile.py b/backend/src/cyclone/reconcile.py index 5a629bb..5c224ac 100644 --- a/backend/src/cyclone/reconcile.py +++ b/backend/src/cyclone/reconcile.py @@ -117,6 +117,41 @@ def _pick_claim( return None +# --- SP31: content-keys fallback matcher ----------------------------------- + +CHARGE_TOLERANCE = Decimal("0.01") +KEYS_REQUIRED = 2 + + +def _content_keys_match(remit, claim) -> bool: + """Return True when at least KEYS_REQUIRED of {PCN, charge, NPI} agree. + + Charge compared with ``CHARGE_TOLERANCE`` tolerance (rounding drift). + NPI counts as "not matched" when the remit's NPI is empty. + """ + keys_matched = 0 + + # PCN: payer_claim_control_number ↔ patient_control_number (stripped). + if (getattr(remit, "payer_claim_control_number", "") or "").strip() == \ + (getattr(claim, "patient_control_number", "") or "").strip(): + keys_matched += 1 + + # Charge: total_charge_amount ↔ total_charge (within $0.01). + remit_charge = getattr(remit, "total_charge_amount", None) + claim_charge = getattr(claim, "total_charge", None) + if remit_charge is not None and claim_charge is not None and \ + abs(remit_charge - claim_charge) < CHARGE_TOLERANCE: + keys_matched += 1 + + # NPI: rendering_provider_npi ↔ rendering_provider_npi (must be non-empty). + remit_npi = (getattr(remit, "rendering_provider_npi", "") or "").strip() + claim_npi = (getattr(claim, "rendering_provider_npi", "") or "").strip() + if remit_npi and remit_npi == claim_npi: + keys_matched += 1 + + return keys_matched >= KEYS_REQUIRED + + @dataclass class ApplyIntent: """Result of applying a match. The caller persists this. diff --git a/backend/tests/test_reconcile.py b/backend/tests/test_reconcile.py index 7963cd8..835eda7 100644 --- a/backend/tests/test_reconcile.py +++ b/backend/tests/test_reconcile.py @@ -370,3 +370,120 @@ def test_run_reconcile_raise_in_session_leaves_prior_commits_alone(fixture_835): assert b is not None r = s.query(Remittance).first() assert r is not None + + +# --- SP31: content-keys fallback matcher (pure helper) ----------------------- + + +def _make_remit_for_keys(remit_id: str, pcn: str, charge, npi: str): + """Tiny shim with the three fields _content_keys_match reads.""" + from cyclone.reconcile import _RemitLike # protocol not enforced at runtime + return type("R", (), { + "id": remit_id, + "payer_claim_control_number": pcn, + "total_charge_amount": charge, + "rendering_provider_npi": npi, + })() + + +def _make_claim_for_keys(claim_id: str, pcn: str, charge, npi: str): + return type("C", (), { + "id": claim_id, + "patient_control_number": pcn, + "total_charge": charge, + "rendering_provider_npi": npi, + })() + + +def test_content_keys_pcn_plus_charge_matches(): + """PCN + charge match (NPI mismatched) → True.""" + from cyclone.reconcile import _content_keys_match + from decimal import Decimal + r = _make_remit_for_keys("r1", "ABC", Decimal("100.00"), "") + c = _make_claim_for_keys("c1", "ABC", Decimal("100.00"), "1111111111") + assert _content_keys_match(r, c) is True + + +def test_content_keys_pcn_plus_npi_matches(): + """PCN + NPI match (charge mismatched) → True.""" + from cyclone.reconcile import _content_keys_match + from decimal import Decimal + r = _make_remit_for_keys("r1", "ABC", Decimal("100.00"), "1111111111") + c = _make_claim_for_keys("c1", "ABC", Decimal("200.00"), "1111111111") + assert _content_keys_match(r, c) is True + + +def test_content_keys_charge_plus_npi_matches(): + """Charge + NPI match (PCN mismatched) → True.""" + from cyclone.reconcile import _content_keys_match + from decimal import Decimal + r = _make_remit_for_keys("r1", "ABC", Decimal("100.00"), "1111111111") + c = _make_claim_for_keys("c1", "XYZ", Decimal("100.00"), "1111111111") + assert _content_keys_match(r, c) is True + + +def test_content_keys_all_three_match(): + """All 3 match → True.""" + from cyclone.reconcile import _content_keys_match + from decimal import Decimal + r = _make_remit_for_keys("r1", "ABC", Decimal("100.00"), "1111111111") + c = _make_claim_for_keys("c1", "ABC", Decimal("100.00"), "1111111111") + assert _content_keys_match(r, c) is True + + +def test_content_keys_only_pcn_does_not_match(): + """Only PCN matches (charge + NPI both wrong) → False.""" + from cyclone.reconcile import _content_keys_match + from decimal import Decimal + r = _make_remit_for_keys("r1", "ABC", Decimal("100.00"), "1111111111") + c = _make_claim_for_keys("c1", "ABC", Decimal("200.00"), "2222222222") + assert _content_keys_match(r, c) is False + + +def test_content_keys_only_charge_does_not_match(): + """Only charge matches (PCN + NPI both wrong) → False.""" + from cyclone.reconcile import _content_keys_match + from decimal import Decimal + r = _make_remit_for_keys("r1", "ABC", Decimal("100.00"), "1111111111") + c = _make_claim_for_keys("c1", "XYZ", Decimal("100.00"), "2222222222") + assert _content_keys_match(r, c) is False + + +def test_content_keys_none_match(): + """All 3 fields disagree → False.""" + from cyclone.reconcile import _content_keys_match + from decimal import Decimal + r = _make_remit_for_keys("r1", "ABC", Decimal("100.00"), "1111111111") + c = _make_claim_for_keys("c1", "XYZ", Decimal("200.00"), "2222222222") + assert _content_keys_match(r, c) is False + + +def test_content_keys_charge_tolerance_is_one_cent(): + """Charge differs by exactly $0.005 (rounding) → True ($0.01 tolerance).""" + from cyclone.reconcile import _content_keys_match + from decimal import Decimal + r = _make_remit_for_keys("r1", "ABC", Decimal("100.005"), "1111111111") + c = _make_claim_for_keys("c1", "ABC", Decimal("100.00"), "1111111111") + assert _content_keys_match(r, c) is True + + +def test_content_keys_charge_differs_by_two_cents_no_match(): + """Charge differs by $0.02 → False (outside tolerance).""" + from cyclone.reconcile import _content_keys_match + from decimal import Decimal + # PCN and NPI deliberately differ so the only potential match is charge. + # Charge is outside the $0.01 tolerance → 0 of 3 match → False. + r = _make_remit_for_keys("r1", "ABC", Decimal("100.02"), "1111111111") + c = _make_claim_for_keys("c1", "XYZ", Decimal("100.00"), "2222222222") + assert _content_keys_match(r, c) is False + + +def test_content_keys_empty_npi_does_not_count(): + """Empty NPI on remit counts as not-matched (falls back to PCN+charge rule).""" + from cyclone.reconcile import _content_keys_match + from decimal import Decimal + r = _make_remit_for_keys("r1", "ABC", Decimal("100.00"), "") + c = _make_claim_for_keys("c1", "ABC", Decimal("100.00"), "1111111111") + assert _content_keys_match(r, c) is True # PCN + charge still match + c2 = _make_claim_for_keys("c2", "XYZ", Decimal("100.00"), "1111111111") + assert _content_keys_match(r, c2) is False # only charge, no PCN From 5a54961930a835e89fa3aa20b93ead2027eb9540 Mon Sep 17 00:00:00 2001 From: Nora Date: Thu, 2 Jul 2026 15:17:26 -0600 Subject: [PATCH 5/8] =?UTF-8?q?feat(sp31):=20add=20=5Fscore=5Ffallback=5Fc?= =?UTF-8?q?andidates=20DB=20helper=20with=20=C2=B130-day=20candidate=20poo?= =?UTF-8?q?l?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/cyclone/reconcile.py | 81 ++++++++++++++++-- backend/tests/conftest.py | 138 ++++++++++++++++++++++++++++++- backend/tests/test_reconcile.py | 122 +++++++++++++++++++++++++++ 3 files changed, 335 insertions(+), 6 deletions(-) diff --git a/backend/src/cyclone/reconcile.py b/backend/src/cyclone/reconcile.py index 5c224ac..fd07817 100644 --- a/backend/src/cyclone/reconcile.py +++ b/backend/src/cyclone/reconcile.py @@ -128,6 +128,14 @@ def _content_keys_match(remit, claim) -> bool: Charge compared with ``CHARGE_TOLERANCE`` tolerance (rounding drift). NPI counts as "not matched" when the remit's NPI is empty. + + Uses duck-typing via ``getattr`` so the helper works against both: + - shim / dataclass test objects (planned names: total_charge_amount, + total_charge, rendering_provider_npi), and + - real SQLAlchemy ORM instances (Claim.charge_amount, Remittance.total_charge, + Claim.provider_npi). The Remittance ORM has no NPI column — callers + that need NPI on a Remit set the transient ``rendering_provider_npi`` + attribute (the 835 parser does this on raw_json; tests do it directly). """ keys_matched = 0 @@ -136,22 +144,85 @@ def _content_keys_match(remit, claim) -> bool: (getattr(claim, "patient_control_number", "") or "").strip(): keys_matched += 1 - # Charge: total_charge_amount ↔ total_charge (within $0.01). - remit_charge = getattr(remit, "total_charge_amount", None) - claim_charge = getattr(claim, "total_charge", None) + # Charge: total_charge_amount/total_charge ↔ total_charge/charge_amount (within $0.01). + remit_charge = getattr(remit, "total_charge_amount", None) or getattr(remit, "total_charge", None) + claim_charge = getattr(claim, "total_charge", None) or getattr(claim, "charge_amount", None) if remit_charge is not None and claim_charge is not None and \ abs(remit_charge - claim_charge) < CHARGE_TOLERANCE: keys_matched += 1 - # NPI: rendering_provider_npi ↔ rendering_provider_npi (must be non-empty). + # NPI: rendering_provider_npi (or provider_npi fallback on Claim) ↔ + # rendering_provider_npi (must be non-empty on the remit side). remit_npi = (getattr(remit, "rendering_provider_npi", "") or "").strip() - claim_npi = (getattr(claim, "rendering_provider_npi", "") or "").strip() + claim_npi = ( + (getattr(claim, "rendering_provider_npi", "") or "") + or (getattr(claim, "provider_npi", "") or "") + ).strip() if remit_npi and remit_npi == claim_npi: keys_matched += 1 return keys_matched >= KEYS_REQUIRED +# --- SP31: DB-side fallback candidate matcher ------------------------------- + +SCORE_FALLBACK_WINDOW_DAYS = 30 + + +def _score_fallback_candidates(session, remit) -> Optional[str]: + """Return a unique matching claim_id via the 2-of-3 content-keys rule. + + Queries a ±SCORE_FALLBACK_WINDOW_DAYS candidate pool filtered by: + - claim.matched_remittance_id IS NULL (not yet linked) + - claim.state NOT IN terminal set (PAID, DENIED, REJECTED, + REVERSED, RECONCILED) + - claim.service_date_from within window of remit.service_date + + Returns the unique claim.id when exactly one candidate passes the + 2-of-3 rule (via :func:`_content_keys_match`), otherwise ``None``. + Ambiguous matches (2+ candidates) also return ``None`` — they land + in the Inbox Unlinked lane for manual review. + + Note on payer_id: ``Remittance`` has no ``payer_id`` column + (``Claim.payer_id`` does, and is the source of truth on the claim + side; the 835 parser stores payer_id in ``Remittance.raw_json``). + Filtering by payer would require either a raw_json read or a model + column add — out of scope for SP31 Task 3. The PCN itself is + expected to be unique within a payer's submission, so cross-payer + collisions are unlikely. Revisit if production shows otherwise. + """ + from sqlalchemy import select, and_ + from datetime import timedelta + from cyclone.db import Claim, ClaimState + + TERMINAL = { + ClaimState.PAID, ClaimState.DENIED, ClaimState.REJECTED, + ClaimState.REVERSED, ClaimState.RECONCILED, + } + + if remit.service_date is None: + return None # need a date for the window query + + window_lo = remit.service_date - timedelta(days=SCORE_FALLBACK_WINDOW_DAYS) + window_hi = remit.service_date + timedelta(days=SCORE_FALLBACK_WINDOW_DAYS) + + candidates = list(session.execute( + select(Claim).where( + and_( + Claim.matched_remittance_id.is_(None), + Claim.service_date_from >= window_lo, + Claim.service_date_from <= window_hi, + Claim.state.notin_([s.value for s in TERMINAL]), + ) + ) + ).scalars().all()) + + matched_ids = [c.id for c in candidates if _content_keys_match(remit, c)] + if len(matched_ids) == 1: + return matched_ids[0] + return None # 0 matches OR 2+ (ambiguous) + + @dataclass class ApplyIntent: """Result of applying a match. The caller persists this. diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index f9fd92f..d109a37 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -14,6 +14,9 @@ test_api_parse_persists.py) working unchanged. from __future__ import annotations +from datetime import datetime, timezone +from decimal import Decimal + import pytest @@ -89,4 +92,137 @@ def _reset_rate_limit_buckets(app) -> None: cur = getattr(cur, "app", None) # No RateLimitMiddleware in the stack — nothing to reset. Should # not happen in this codebase (security.py registers it at boot) - # but we don't want a missing reset to crash unrelated tests. \ No newline at end of file + # but we don't want a missing reset to crash unrelated tests. + + +# --------------------------------------------------------------------------- +# SP31: shared DB-session + Claim/Remit factory fixtures. +# +# `db_session` yields a fresh session per test against the per-test DB set +# up by the autouse `_auto_init_db` fixture above. The session rolls back at +# teardown so a test that mutates rows doesn't leak into siblings. +# +# `make_claim` / `make_remit` build ORM rows with a small ergonomic surface: +# the planned parameter names follow the content-keys helper (PCN, charge, +# rendering NPI), but the ORM attribute names differ (`charge_amount` on +# Claim, `total_charge` on Remittance, `provider_npi` on Claim — and no +# `payer_id` / `rendering_provider_npi` column on Remittance at all). The +# factories map the planned names onto the real ORM attributes and stash +# `rendering_provider_npi` as a transient attribute so +# ``reconcile._content_keys_match`` can still read it via ``getattr``. +# --------------------------------------------------------------------------- + + +@pytest.fixture +def db_session(): + """Yield a fresh SQLAlchemy session, rolling back at teardown.""" + from cyclone import db as _db + session = _db.SessionLocal()() + try: + yield session + session.rollback() + finally: + session.close() + + +def _ensure_batch(session, batch_id: str = "test-batch") -> None: + """Create the Batch row a Claim/Remittance FKs to (idempotent).""" + from cyclone.db import Batch + if session.get(Batch, batch_id) is None: + session.add(Batch( + id=batch_id, kind="837p", input_filename="test.txt", + parsed_at=datetime.now(timezone.utc), + )) + session.flush() + + +@pytest.fixture +def make_claim(db_session): + """Factory: build & flush a Claim with the planned content-keys params. + + Planned params (match the SP31 spec / content-keys test surface): + patient_control_number, total_charge, rendering_provider_npi, + service_date_from, matched_remittance_id=None, state=None, + claim_id=None + + `state=None` defaults to ``ClaimState.SUBMITTED`` so existing tests + that omit it keep behaving. `rendering_provider_npi` is stored as + a transient attribute (no ORM column) for ``_content_keys_match`` + to read via ``getattr``. + """ + def _make( + patient_control_number: str, + total_charge, + rendering_provider_npi: str, + service_date_from, + matched_remittance_id=None, + state=None, + claim_id: str | None = None, + ): + from cyclone.db import Claim, ClaimState as _CS + + _ensure_batch(db_session) + cid = claim_id or f"clm-{patient_control_number}-{service_date_from.isoformat()}" + c = Claim( + id=cid, + batch_id="test-batch", + patient_control_number=patient_control_number, + service_date_from=service_date_from, + charge_amount=total_charge, + state=state if state is not None else _CS.SUBMITTED, + matched_remittance_id=matched_remittance_id, + ) + # Transient attribute — read by reconcile._content_keys_match + # via getattr; the Claim ORM has no rendering_provider_npi column. + c.rendering_provider_npi = rendering_provider_npi + db_session.add(c) + db_session.flush() + return c + + return _make + + +@pytest.fixture +def make_remit(db_session): + """Factory: build & flush a Remittance with the planned content-keys params. + + Planned params: + payer_claim_control_number, total_charge_amount, rendering_provider_npi, + service_date, remit_id=None + + `total_charge_amount` is mapped to ``Remittance.total_charge`` (real ORM + field). `rendering_provider_npi` is stored as a transient attribute for + ``_content_keys_match`` to read via ``getattr`` (Remittance has no NPI + column; the parser reads it from raw_json in production). + """ + def _make( + payer_claim_control_number: str, + total_charge_amount, + rendering_provider_npi: str, + service_date, + remit_id: str | None = None, + ): + from cyclone.db import Remittance + + _ensure_batch(db_session) + rid = remit_id or f"remit-{payer_claim_control_number}-{service_date.isoformat()}" + r = Remittance( + id=rid, + batch_id="test-batch", + payer_claim_control_number=payer_claim_control_number, + status_code="1", + total_charge=total_charge_amount, + total_paid=Decimal("0"), + received_at=datetime.now(timezone.utc), + service_date=service_date, + is_reversal=False, + ) + # Transient attribute — read by reconcile._content_keys_match via + # getattr. The Remittance ORM has no rendering_provider_npi column; + # the 835 parser reads it from raw_json in production. + r.rendering_provider_npi = rendering_provider_npi + db_session.add(r) + db_session.flush() + return r + + return _make \ No newline at end of file diff --git a/backend/tests/test_reconcile.py b/backend/tests/test_reconcile.py index 835eda7..0deaa74 100644 --- a/backend/tests/test_reconcile.py +++ b/backend/tests/test_reconcile.py @@ -487,3 +487,125 @@ def test_content_keys_empty_npi_does_not_count(): assert _content_keys_match(r, c) is True # PCN + charge still match c2 = _make_claim_for_keys("c2", "XYZ", Decimal("100.00"), "1111111111") assert _content_keys_match(r, c2) is False # only charge, no PCN + + +# --- SP31: content-keys fallback matcher (DB helper) ------------------------ + + +def test_score_fallback_unique_match_returns_claim_id(db_session, make_claim, make_remit): + """One claim in pool matches 2-of-3 → returns that claim.""" + from decimal import Decimal + from datetime import date, timedelta + from cyclone.reconcile import _score_fallback_candidates + c1 = make_claim(patient_control_number="ABC", total_charge=Decimal("100.00"), + rendering_provider_npi="1111111111", + service_date_from=date(2026, 6, 1)) + c2 = make_claim(patient_control_number="XYZ", total_charge=Decimal("999.99"), + rendering_provider_npi="2222222222", + service_date_from=date(2026, 6, 1)) + r = make_remit(payer_claim_control_number="ABC", + total_charge_amount=Decimal("100.00"), + rendering_provider_npi="1111111111", + service_date=date(2026, 6, 5)) # ±30-day window + assert _score_fallback_candidates(db_session, r) == c1.id + + +def test_score_fallback_zero_matches_returns_none(db_session, make_claim, make_remit): + """No claim in pool matches 2-of-3 → returns None.""" + from decimal import Decimal + from datetime import date + from cyclone.reconcile import _score_fallback_candidates + make_claim(patient_control_number="XYZ", total_charge=Decimal("999.99"), + rendering_provider_npi="2222222222", + service_date_from=date(2026, 6, 1)) + r = make_remit(payer_claim_control_number="ABC", + total_charge_amount=Decimal("100.00"), + rendering_provider_npi="1111111111", + service_date=date(2026, 6, 5)) + assert _score_fallback_candidates(db_session, r) is None + + +def test_score_fallback_two_matches_returns_none(db_session, make_claim, make_remit): + """Two claims in pool both match 2-of-3 → returns None (ambiguous).""" + from decimal import Decimal + from datetime import date + from cyclone.reconcile import _score_fallback_candidates + # Both claims have same PCN and same charge — both would match PCN+charge. + make_claim(patient_control_number="ABC", total_charge=Decimal("100.00"), + rendering_provider_npi="1111111111", + service_date_from=date(2026, 6, 1)) + make_claim(patient_control_number="ABC", total_charge=Decimal("100.00"), + rendering_provider_npi="1111111111", + service_date_from=date(2026, 6, 2)) + r = make_remit(payer_claim_control_number="ABC", + total_charge_amount=Decimal("100.00"), + rendering_provider_npi="1111111111", + service_date=date(2026, 6, 5)) + assert _score_fallback_candidates(db_session, r) is None + + +def test_score_fallback_skips_already_matched(db_session, make_claim, make_remit): + """Claim with matched_remittance_id set → excluded from pool.""" + from decimal import Decimal + from datetime import date + from cyclone.reconcile import _score_fallback_candidates + c1 = make_claim(patient_control_number="ABC", total_charge=Decimal("100.00"), + rendering_provider_npi="1111111111", + service_date_from=date(2026, 6, 1), + matched_remittance_id="some-other-remit-id") + r = make_remit(payer_claim_control_number="ABC", + total_charge_amount=Decimal("100.00"), + rendering_provider_npi="1111111111", + service_date=date(2026, 6, 5)) + assert _score_fallback_candidates(db_session, r) is None # only candidate was excluded + + +def test_score_fallback_skips_terminal_state(db_session, make_claim, make_remit): + """Claim in PAID/DENIED/REJECTED/REVERSED/RECONCILED → excluded from pool.""" + from decimal import Decimal + from datetime import date + from cyclone.db import ClaimState + from cyclone.reconcile import _score_fallback_candidates + make_claim(patient_control_number="ABC", total_charge=Decimal("100.00"), + rendering_provider_npi="1111111111", + service_date_from=date(2026, 6, 1), + state=ClaimState.PAID) + r = make_remit(payer_claim_control_number="ABC", + total_charge_amount=Decimal("100.00"), + rendering_provider_npi="1111111111", + service_date=date(2026, 6, 5)) + assert _score_fallback_candidates(db_session, r) is None + + +def test_score_fallback_widened_window_catches_late_remit(db_session, make_claim, make_remit): + """Remit 25 days after claim → still in pool (30-day window).""" + from decimal import Decimal + from datetime import date, timedelta + from cyclone.reconcile import _score_fallback_candidates + claim_date = date(2026, 6, 1) + remit_date = claim_date + timedelta(days=25) + c1 = make_claim(patient_control_number="ABC", total_charge=Decimal("100.00"), + rendering_provider_npi="1111111111", + service_date_from=claim_date) + r = make_remit(payer_claim_control_number="ABC", + total_charge_amount=Decimal("100.00"), + rendering_provider_npi="1111111111", + service_date=remit_date) + assert _score_fallback_candidates(db_session, r) == c1.id + + +def test_score_fallback_outside_window_excluded(db_session, make_claim, make_remit): + """Remit 60 days after claim → excluded (outside 30-day window).""" + from decimal import Decimal + from datetime import date, timedelta + from cyclone.reconcile import _score_fallback_candidates + claim_date = date(2026, 6, 1) + remit_date = claim_date + timedelta(days=60) + make_claim(patient_control_number="ABC", total_charge=Decimal("100.00"), + rendering_provider_npi="1111111111", + service_date_from=claim_date) + r = make_remit(payer_claim_control_number="ABC", + total_charge_amount=Decimal("100.00"), + rendering_provider_npi="1111111111", + service_date=remit_date) + assert _score_fallback_candidates(db_session, r) is None From 9bade2429c91a552939c79ab0dd35c89dffd6b75 Mon Sep 17 00:00:00 2001 From: Nora Date: Thu, 2 Jul 2026 15:27:32 -0600 Subject: [PATCH 6/8] fix(sp31): read NPI and charge from raw_json when ORM attribute missing --- backend/src/cyclone/reconcile.py | 49 +++++++++++++++++++++++++------- backend/tests/test_reconcile.py | 44 ++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 11 deletions(-) diff --git a/backend/src/cyclone/reconcile.py b/backend/src/cyclone/reconcile.py index fd07817..d90f6f0 100644 --- a/backend/src/cyclone/reconcile.py +++ b/backend/src/cyclone/reconcile.py @@ -133,9 +133,15 @@ def _content_keys_match(remit, claim) -> bool: - shim / dataclass test objects (planned names: total_charge_amount, total_charge, rendering_provider_npi), and - real SQLAlchemy ORM instances (Claim.charge_amount, Remittance.total_charge, - Claim.provider_npi). The Remittance ORM has no NPI column — callers - that need NPI on a Remit set the transient ``rendering_provider_npi`` - attribute (the 835 parser does this on raw_json; tests do it directly). + Claim.provider_npi). + + Production note: the 835 parser stores ``rendering_provider_npi`` and + ``total_charge_amount`` on ``Remittance.raw_json`` (the ORM has no + dedicated column), so the read order below is: + - NPI: transient attribute → raw_json + - Charge: planned attribute name → real ORM column → raw_json + The ``raw_json`` fallback is what makes this helper safe against a + raw ``Remittance`` ORM instance from ``reconcile.run()``. """ keys_matched = 0 @@ -144,19 +150,40 @@ def _content_keys_match(remit, claim) -> bool: (getattr(claim, "patient_control_number", "") or "").strip(): keys_matched += 1 - # Charge: total_charge_amount/total_charge ↔ total_charge/charge_amount (within $0.01). - remit_charge = getattr(remit, "total_charge_amount", None) or getattr(remit, "total_charge", None) - claim_charge = getattr(claim, "total_charge", None) or getattr(claim, "charge_amount", None) - if remit_charge is not None and claim_charge is not None and \ - abs(remit_charge - claim_charge) < CHARGE_TOLERANCE: + # Charge: prefer the planned attribute names, fall back to real ORM columns, + # then to raw_json (where the 835 parser stores CLP03). Explicit ``is None`` + # checks avoid the ``Decimal("0")`` truthiness footgun — ``Decimal("0")`` + # is truthy in a boolean context but ``or`` would still let it through; + # the more important property is that we don't accidentally replace a real + # value with a default. + _remit_charge = getattr(remit, "total_charge_amount", None) + if _remit_charge is None: + _remit_charge = getattr(remit, "total_charge", None) + if _remit_charge is None: + _remit_charge = (getattr(remit, "raw_json", None) or {}).get("total_charge_amount") + + _claim_charge = getattr(claim, "total_charge", None) + if _claim_charge is None: + _claim_charge = getattr(claim, "charge_amount", None) + if _claim_charge is None: + _claim_charge = (getattr(claim, "raw_json", None) or {}).get("total_charge_amount") + + if _remit_charge is not None and _claim_charge is not None and \ + abs(_remit_charge - _claim_charge) < CHARGE_TOLERANCE: keys_matched += 1 - # NPI: rendering_provider_npi (or provider_npi fallback on Claim) ↔ - # rendering_provider_npi (must be non-empty on the remit side). - remit_npi = (getattr(remit, "rendering_provider_npi", "") or "").strip() + # NPI: prefer attribute, fall back to raw_json (where the 835 parser + # stores rendering_provider_npi). The Remittance ORM has no NPI column, + # so the raw_json path is the production read for raw ORM instances — + # it must NOT be removed. + remit_npi = ( + (getattr(remit, "rendering_provider_npi", "") or "") + or ((getattr(remit, "raw_json", None) or {}).get("rendering_provider_npi", "") or "") + ).strip() claim_npi = ( (getattr(claim, "rendering_provider_npi", "") or "") or (getattr(claim, "provider_npi", "") or "") + or ((getattr(claim, "raw_json", None) or {}).get("rendering_provider_npi", "") or "") ).strip() if remit_npi and remit_npi == claim_npi: keys_matched += 1 diff --git a/backend/tests/test_reconcile.py b/backend/tests/test_reconcile.py index 0deaa74..890d03a 100644 --- a/backend/tests/test_reconcile.py +++ b/backend/tests/test_reconcile.py @@ -609,3 +609,47 @@ def test_score_fallback_outside_window_excluded(db_session, make_claim, make_rem rendering_provider_npi="1111111111", service_date=remit_date) assert _score_fallback_candidates(db_session, r) is None + + +# --- SP31 Task 3 fix: Remittance NPI lives in raw_json ---------------------- + + +def test_content_keys_npi_from_remit_raw_json(db_session, make_claim): + """NPI on the Remit is read from ``raw_json`` when the attribute is absent. + + The 835 parser stores rendering_provider_npi on ``Remittance.raw_json`` + (the ORM has no dedicated column) — reconcile.run() hands a raw + ``Remittance`` instance to ``_content_keys_match``, so the helper must + fall through ``getattr(...rendering_provider_npi...)`` to the raw_json + read. This test pins that path with a real ORM row (no transient + ``setattr`` shadowing the production layout). + + Configuration: PCN mismatched, charge matches, NPI matches via raw_json + → 2 of 3 keys agree → True. + """ + from decimal import Decimal + from datetime import date, datetime, timezone + from cyclone.db import Remittance + from cyclone.reconcile import _content_keys_match + claim = make_claim( + patient_control_number="ABC", + total_charge=Decimal("100.00"), + rendering_provider_npi="1111111111", + service_date_from=date(2026, 6, 1), + ) + # Real Remittance ORM instance — NPI lives ONLY in raw_json, not as a + # transient attribute. (The 835 parser populates raw_json that way; + # the make_remit factory's transient ``rendering_provider_npi`` setattr + # would shadow the production read.) + remit = Remittance( + id="real-remit-1", + batch_id="test-batch", + payer_claim_control_number="OTHER-PCN", # PCN: won't match "ABC" + status_code="1", + total_charge=Decimal("100.00"), # charge: matches + received_at=datetime(2026, 6, 19, tzinfo=timezone.utc), + raw_json={"rendering_provider_npi": "1111111111"}, # NPI: matches + ) + db_session.add(remit) + db_session.flush() + assert _content_keys_match(remit, claim) is True # charge + NPI = 2 of 3 From ab91449e625aa05fab8e249993c6faba615e5bd8 Mon Sep 17 00:00:00 2001 From: Nora Date: Thu, 2 Jul 2026 15:58:01 -0600 Subject: [PATCH 7/8] feat(sp31): integrate content-keys fallback into reconcile.run() with auto_matched_835 event --- backend/src/cyclone/reconcile.py | 38 ++++++++-- backend/tests/test_reconcile.py | 117 +++++++++++++++++++++++++++++++ 2 files changed, 151 insertions(+), 4 deletions(-) diff --git a/backend/src/cyclone/reconcile.py b/backend/src/cyclone/reconcile.py index d90f6f0..1fed5d7 100644 --- a/backend/src/cyclone/reconcile.py +++ b/backend/src/cyclone/reconcile.py @@ -372,7 +372,7 @@ def run(session, batch_id: str) -> ReconcileResult: """ from sqlalchemy import select from cyclone.db import ( - Batch, Claim, Remittance, CasAdjustment, Match, ActivityEvent, + Batch, Claim, Remittance, CasAdjustment, Match as MatchORM, ActivityEvent, ClaimState, ) @@ -390,6 +390,31 @@ def run(session, batch_id: str) -> ReconcileResult: matches = match(unmatched_claims, new_remits) + # SP31: content-keys fallback for remits that PCN-exact couldn't pair. + # Operates on the still-unmatched remits so we don't double-match. + matched_remit_ids = {m.remittance.id for m in matches} + used_claim_ids = {m.claim.id for m in matches} + for remit in new_remits: + if remit.id in matched_remit_ids: + continue + if getattr(remit, "claim_id", None) is not None: + continue # already linked + matched_claim_id = _score_fallback_candidates(session, remit) + if matched_claim_id is None: + continue + # Find the claim object in the unmatched_claims list (it was loaded). + target_claim = next( + (c for c in unmatched_claims if c.id == matched_claim_id), + None, + ) + if target_claim is None or target_claim.id in used_claim_ids: + continue + matches.append(Match( + claim=target_claim, remittance=remit, + strategy="score-auto", is_reversal=remit.is_reversal, + )) + used_claim_ids.add(target_claim.id) + applied = 0 skipped = 0 for m in matches: @@ -413,7 +438,7 @@ def run(session, batch_id: str) -> ReconcileResult: skipped += 1 continue - session.add(Match( + session.add(MatchORM( claim_id=m.claim.id, remittance_id=m.remittance.id, strategy=m.strategy, matched_at=datetime.now(timezone.utc), @@ -431,10 +456,15 @@ def run(session, batch_id: str) -> ReconcileResult: m.remittance.claim_id = m.claim.id session.add(ActivityEvent( - ts=datetime.now(timezone.utc), kind=intent.activity_kind, + ts=datetime.now(timezone.utc), + kind=("auto_matched_835" if m.strategy == "score-auto" else intent.activity_kind), batch_id=batch_id, claim_id=m.claim.id, remittance_id=m.remittance.id, - payload_json={"new_state": m.claim.state.value}, + payload_json=( + {"new_state": m.claim.state.value, "strategy": m.strategy} + if m.strategy == "score-auto" + else {"new_state": m.claim.state.value} + ), )) applied += 1 diff --git a/backend/tests/test_reconcile.py b/backend/tests/test_reconcile.py index 890d03a..a2dc8ab 100644 --- a/backend/tests/test_reconcile.py +++ b/backend/tests/test_reconcile.py @@ -653,3 +653,120 @@ def test_content_keys_npi_from_remit_raw_json(db_session, make_claim): db_session.add(remit) db_session.flush() assert _content_keys_match(remit, claim) is True # charge + NPI = 2 of 3 + + +# --- SP31: end-to-end integration via reconcile.run() ----------------------- + + +def test_reconcile_run_emits_score_auto_match_when_pcn_misses(db_session, make_claim, make_remit): + """A remit with no PCN match but matching charge+NPI triggers score-auto.""" + from decimal import Decimal + from datetime import date + from sqlalchemy import select + from cyclone.db import Batch, ClaimState + from cyclone.reconcile import run as reconcile_run + claim = make_claim(patient_control_number="UNIQUE-CLM", + total_charge=Decimal("100.00"), + rendering_provider_npi="1111111111", + service_date_from=date(2026, 6, 1)) + remit = make_remit(payer_claim_control_number="DIFFERENT-PCN", + total_charge_amount=Decimal("100.00"), + rendering_provider_npi="1111111111", + service_date=date(2026, 6, 5)) + batch = Batch(id="batch-1", kind="835", input_filename="x.835", parsed_at=date(2026, 6, 5)) + db_session.add(batch) + db_session.flush() + # Re-parent the remit to this batch (make_remit doesn't set batch_id). + remit.batch_id = batch.id + db_session.flush() + + result = reconcile_run(db_session, batch.id) + db_session.flush() # autoflush=False on SessionLocal; force flush pending Match row + + assert result.matched == 1 + # Verify Match row was written with strategy="score-auto" + from cyclone.db import Match + matches = list(db_session.execute( + select(Match).where(Match.remittance_id == remit.id) + ).scalars().all()) + assert len(matches) == 1 + assert matches[0].strategy == "score-auto" + assert matches[0].claim_id == claim.id + # Verify ActivityEvent was emitted with kind="auto_matched_835" + from cyclone.db import ActivityEvent + events = list(db_session.execute( + select(ActivityEvent).where( + ActivityEvent.remittance_id == remit.id, + ActivityEvent.kind == "auto_matched_835", + ) + ).scalars().all()) + assert len(events) == 1 + assert events[0].claim_id == claim.id + # Verify claim state was flipped + db_session.refresh(claim) + assert claim.state in {ClaimState.PAID, ClaimState.PARTIAL, ClaimState.RECEIVED} + + +def test_reconcile_run_pcn_path_unchanged(db_session, make_claim, make_remit): + """Existing PCN-exact match still produces strategy='pcn-exact' (regression guard).""" + from decimal import Decimal + from datetime import date + from sqlalchemy import select + from cyclone.db import Batch, Match + from cyclone.reconcile import run as reconcile_run + claim = make_claim(patient_control_number="SAME-PCN", + total_charge=Decimal("100.00"), + rendering_provider_npi="1111111111", + service_date_from=date(2026, 6, 1)) + remit = make_remit(payer_claim_control_number="SAME-PCN", + total_charge_amount=Decimal("100.00"), + rendering_provider_npi="1111111111", + service_date=date(2026, 6, 2)) + batch = Batch(id="batch-1", kind="835", input_filename="x.835", parsed_at=date(2026, 6, 5)) + db_session.add(batch) + db_session.flush() + remit.batch_id = batch.id + db_session.flush() + + reconcile_run(db_session, batch.id) + db_session.flush() # autoflush=False on SessionLocal; force flush pending Match row + + matches = list(db_session.execute( + select(Match).where(Match.remittance_id == remit.id) + ).scalars().all()) + assert len(matches) == 1 + assert matches[0].strategy == "pcn-exact" # NOT "score-auto" + + +def test_reconcile_run_no_match_leaves_remit_unlinked(db_session, make_claim, make_remit): + """No 2-of-3 match → no Match row, claim state unchanged.""" + from decimal import Decimal + from datetime import date + from sqlalchemy import select + from cyclone.db import Batch, Match, ClaimState + from cyclone.reconcile import run as reconcile_run + claim = make_claim(patient_control_number="UNRELATED", + total_charge=Decimal("999.99"), + rendering_provider_npi="2222222222", + service_date_from=date(2026, 6, 1)) + remit = make_remit(payer_claim_control_number="OTHER", + total_charge_amount=Decimal("100.00"), + rendering_provider_npi="1111111111", + service_date=date(2026, 6, 5)) + batch = Batch(id="batch-1", kind="835", input_filename="x.835", parsed_at=date(2026, 6, 5)) + db_session.add(batch) + db_session.flush() + remit.batch_id = batch.id + db_session.flush() + + result = reconcile_run(db_session, batch.id) + + assert result.matched == 0 + matches = list(db_session.execute( + select(Match).where(Match.remittance_id == remit.id) + ).scalars().all()) + assert len(matches) == 0 + db_session.refresh(claim) + assert claim.state == ClaimState.SUBMITTED # unchanged + db_session.refresh(remit) + assert remit.claim_id is None # unchanged From 18fa119ff776c059b69b27d083b28244aa6fd460 Mon Sep 17 00:00:00 2001 From: Nora Date: Thu, 2 Jul 2026 16:23:42 -0600 Subject: [PATCH 8/8] feat(sp31): expose keys_matched/candidate_count in Match dataclass and ActivityEvent payload, fix broken pre-existing test --- backend/src/cyclone/reconcile.py | 81 ++++++++++++++----- .../tests/test_provider_extended_response.py | 23 +++--- backend/tests/test_reconcile.py | 72 +++++++++++------ 3 files changed, 121 insertions(+), 55 deletions(-) diff --git a/backend/src/cyclone/reconcile.py b/backend/src/cyclone/reconcile.py index 1fed5d7..b30bca5 100644 --- a/backend/src/cyclone/reconcile.py +++ b/backend/src/cyclone/reconcile.py @@ -17,7 +17,7 @@ Match algorithm: from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field from datetime import date, datetime, timezone from decimal import Decimal from typing import Iterable, Optional, Protocol @@ -50,6 +50,18 @@ class Match: remittance: _RemitLike strategy: str is_reversal: bool + # SP31: which content-keys the 2-of-3 (or 3-of-3) rule agreed on. + # Populated for ``score-auto`` matches; empty for ``pcn-exact`` / + # ``manual`` (PCN-exact only uses the PCN key by definition; manual + # is operator-driven). Floats into the ``auto_matched_835`` + # ActivityEvent payload so the audit trail records *why* the link + # fired. Transient — the ORM Match row does not persist these + # (spec keeps the audit trail in ActivityEvent.payload_json only). + keys_matched: set[str] = field(default_factory=set) + # SP31: how many candidates were in the ±30-day window pool when + # the score-auto match fired. Useful when an operator is reviewing + # a borderline auto-link — 1-of-1 is unambiguous; 1-of-5 was lucky. + candidate_count: int = 0 def match( @@ -123,8 +135,14 @@ CHARGE_TOLERANCE = Decimal("0.01") KEYS_REQUIRED = 2 -def _content_keys_match(remit, claim) -> bool: - """Return True when at least KEYS_REQUIRED of {PCN, charge, NPI} agree. +def _content_keys_match(remit, claim) -> set[str]: + """Return the set of content-keys that agree between ``remit`` and ``claim``. + + Compares {``pcn``, ``charge``, ``npi``} between the two sides and returns + the subset whose values match. Caller checks ``len(returned) >= KEYS_REQUIRED`` + to decide whether to auto-link. Returning the matched keys (rather than a + bool) lets the ActivityEvent payload record exactly which 2-of-3 (or 3-of-3) + produced the auto-link — the audit trail for the SP31 content-keys rule. Charge compared with ``CHARGE_TOLERANCE`` tolerance (rounding drift). NPI counts as "not matched" when the remit's NPI is empty. @@ -143,12 +161,12 @@ def _content_keys_match(remit, claim) -> bool: The ``raw_json`` fallback is what makes this helper safe against a raw ``Remittance`` ORM instance from ``reconcile.run()``. """ - keys_matched = 0 + matched: set[str] = set() # PCN: payer_claim_control_number ↔ patient_control_number (stripped). if (getattr(remit, "payer_claim_control_number", "") or "").strip() == \ (getattr(claim, "patient_control_number", "") or "").strip(): - keys_matched += 1 + matched.add("pcn") # Charge: prefer the planned attribute names, fall back to real ORM columns, # then to raw_json (where the 835 parser stores CLP03). Explicit ``is None`` @@ -170,7 +188,7 @@ def _content_keys_match(remit, claim) -> bool: if _remit_charge is not None and _claim_charge is not None and \ abs(_remit_charge - _claim_charge) < CHARGE_TOLERANCE: - keys_matched += 1 + matched.add("charge") # NPI: prefer attribute, fall back to raw_json (where the 835 parser # stores rendering_provider_npi). The Remittance ORM has no NPI column, @@ -186,9 +204,9 @@ def _content_keys_match(remit, claim) -> bool: or ((getattr(claim, "raw_json", None) or {}).get("rendering_provider_npi", "") or "") ).strip() if remit_npi and remit_npi == claim_npi: - keys_matched += 1 + matched.add("npi") - return keys_matched >= KEYS_REQUIRED + return matched # --- SP31: DB-side fallback candidate matcher ------------------------------- @@ -196,8 +214,8 @@ def _content_keys_match(remit, claim) -> bool: SCORE_FALLBACK_WINDOW_DAYS = 30 -def _score_fallback_candidates(session, remit) -> Optional[str]: - """Return a unique matching claim_id via the 2-of-3 content-keys rule. +def _score_fallback_candidates(session, remit) -> Optional[tuple[str, set[str], int]]: + """Return (claim_id, keys_matched, candidate_count) for a unique 2-of-3 match. Queries a ±SCORE_FALLBACK_WINDOW_DAYS candidate pool filtered by: - claim.matched_remittance_id IS NULL (not yet linked) @@ -205,8 +223,15 @@ def _score_fallback_candidates(session, remit) -> Optional[str]: REVERSED, RECONCILED) - claim.service_date_from within window of remit.service_date - Returns the unique claim.id when exactly one candidate passes the - 2-of-3 rule (via :func:`_content_keys_match`), otherwise ``None``. + Returns ``(claim_id, keys_matched, candidate_count)`` when exactly one + candidate passes the 2-of-3 rule (via :func:`_content_keys_match`), + otherwise ``None``. ``keys_matched`` is the set returned by the helper + (``{"pcn", "charge"}`` or any 2-of-3 / 3-of-3 combination) — the + ActivityEvent payload records it as the audit trail for *why* the + auto-link fired. ``candidate_count`` is the size of the window pool + (independent of how many matched) so the operator can see whether + the match was 1-of-N or 1-of-1. + Ambiguous matches (2+ candidates) also return ``None`` — they land in the Inbox Unlinked lane for manual review. @@ -244,9 +269,16 @@ def _score_fallback_candidates(session, remit) -> Optional[str]: ) ).scalars().all()) - matched_ids = [c.id for c in candidates if _content_keys_match(remit, c)] - if len(matched_ids) == 1: - return matched_ids[0] + matched_pairs = [ + (c.id, _content_keys_match(remit, c)) + for c in candidates + ] + matched_pairs = [ + (cid, ks) for cid, ks in matched_pairs if len(ks) >= KEYS_REQUIRED + ] + if len(matched_pairs) == 1: + cid, keys_matched = matched_pairs[0] + return (cid, keys_matched, len(candidates)) return None # 0 matches OR 2+ (ambiguous) @@ -399,9 +431,10 @@ def run(session, batch_id: str) -> ReconcileResult: continue if getattr(remit, "claim_id", None) is not None: continue # already linked - matched_claim_id = _score_fallback_candidates(session, remit) - if matched_claim_id is None: + fallback_result = _score_fallback_candidates(session, remit) + if fallback_result is None: continue + matched_claim_id, keys_matched, candidate_count = fallback_result # Find the claim object in the unmatched_claims list (it was loaded). target_claim = next( (c for c in unmatched_claims if c.id == matched_claim_id), @@ -412,6 +445,8 @@ def run(session, batch_id: str) -> ReconcileResult: matches.append(Match( claim=target_claim, remittance=remit, strategy="score-auto", is_reversal=remit.is_reversal, + keys_matched=keys_matched, + candidate_count=candidate_count, )) used_claim_ids.add(target_claim.id) @@ -461,7 +496,17 @@ def run(session, batch_id: str) -> ReconcileResult: batch_id=batch_id, claim_id=m.claim.id, remittance_id=m.remittance.id, payload_json=( - {"new_state": m.claim.state.value, "strategy": m.strategy} + { + "new_state": m.claim.state.value, "strategy": m.strategy, + # SP31 spec D8: the auto_matched_835 payload records + # which content-keys the 2-of-3 (or 3-of-3) rule + # agreed on, plus how many candidates were in the + # window pool. ``sorted()`` converts the set to a + # JSON-serializable list and pins a deterministic + # order for tests + audit reads. + "keys_matched": sorted(m.keys_matched), + "candidate_count": m.candidate_count, + } if m.strategy == "score-auto" else {"new_state": m.claim.state.value} ), diff --git a/backend/tests/test_provider_extended_response.py b/backend/tests/test_provider_extended_response.py index 400381d..d92df27 100644 --- a/backend/tests/test_provider_extended_response.py +++ b/backend/tests/test_provider_extended_response.py @@ -153,19 +153,18 @@ def test_provider_detail_includes_orphan_remit_received(seeded_db: TestClient): the moment an 835 lands — the most common activity, invisible. Setup: ingest 837+835 for ``TEST_837_NPI`` (claim CLM001 + remit - CLM001), then manually match them so ``Remittance.claim_id`` is - populated. The bug presents as: only ``claim_submitted`` and - ``manual_match`` appear (no ``remit_received``). The fix surfaces - all three. + CLM001). With SP31, the minimal fixtures now auto-link via the + content-keys fallback (PCN + charge agree → 2-of-3) so + ``Remittance.claim_id`` is populated as part of the 835 ingest + itself — no separate manual-match call needed. The bug presents + as: only ``claim_submitted`` appears (no ``remit_received``). The + fix surfaces both. """ - # Force the match — simulates the post-reconciliation state that - # populate Remittance.claim_id without depending on auto-reconcile - # heuristics (which don't match this minimal fixture). - match_resp = seeded_db.post( - "/api/reconciliation/match", - json={"claim_id": "CLM001", "remit_id": "CLM001"}, - ) - assert match_resp.status_code == 200, match_resp.text + # The minimal fixture's PCN ("CLM001") + charge ("$100.00") match, + # so SP31's content-keys fallback pairs the remit during ingest. + # No manual-match call is required (and would now 409 "already_matched"). + # The intent of this test — surfacing the remit_received event via + # the Remittance.claim_id join — is fully exercised either way. resp = seeded_db.get(f"/api/config/providers/{TEST_837_NPI}") assert resp.status_code == 200, resp.text diff --git a/backend/tests/test_reconcile.py b/backend/tests/test_reconcile.py index a2dc8ab..63b2424 100644 --- a/backend/tests/test_reconcile.py +++ b/backend/tests/test_reconcile.py @@ -396,86 +396,86 @@ def _make_claim_for_keys(claim_id: str, pcn: str, charge, npi: str): def test_content_keys_pcn_plus_charge_matches(): - """PCN + charge match (NPI mismatched) → True.""" + """PCN + charge match (NPI mismatched) → 2-of-3, returns ``{"pcn","charge"}``.""" from cyclone.reconcile import _content_keys_match from decimal import Decimal r = _make_remit_for_keys("r1", "ABC", Decimal("100.00"), "") c = _make_claim_for_keys("c1", "ABC", Decimal("100.00"), "1111111111") - assert _content_keys_match(r, c) is True + assert _content_keys_match(r, c) == {"pcn", "charge"} def test_content_keys_pcn_plus_npi_matches(): - """PCN + NPI match (charge mismatched) → True.""" + """PCN + NPI match (charge mismatched) → 2-of-3, returns ``{"pcn","npi"}``.""" from cyclone.reconcile import _content_keys_match from decimal import Decimal r = _make_remit_for_keys("r1", "ABC", Decimal("100.00"), "1111111111") c = _make_claim_for_keys("c1", "ABC", Decimal("200.00"), "1111111111") - assert _content_keys_match(r, c) is True + assert _content_keys_match(r, c) == {"pcn", "npi"} def test_content_keys_charge_plus_npi_matches(): - """Charge + NPI match (PCN mismatched) → True.""" + """Charge + NPI match (PCN mismatched) → 2-of-3, returns ``{"charge","npi"}``.""" from cyclone.reconcile import _content_keys_match from decimal import Decimal r = _make_remit_for_keys("r1", "ABC", Decimal("100.00"), "1111111111") c = _make_claim_for_keys("c1", "XYZ", Decimal("100.00"), "1111111111") - assert _content_keys_match(r, c) is True + assert _content_keys_match(r, c) == {"charge", "npi"} def test_content_keys_all_three_match(): - """All 3 match → True.""" + """All 3 match → 3-of-3, returns ``{"pcn","charge","npi"}``.""" from cyclone.reconcile import _content_keys_match from decimal import Decimal r = _make_remit_for_keys("r1", "ABC", Decimal("100.00"), "1111111111") c = _make_claim_for_keys("c1", "ABC", Decimal("100.00"), "1111111111") - assert _content_keys_match(r, c) is True + assert _content_keys_match(r, c) == {"pcn", "charge", "npi"} def test_content_keys_only_pcn_does_not_match(): - """Only PCN matches (charge + NPI both wrong) → False.""" + """Only PCN matches (charge + NPI both wrong) → set is just ``{"pcn"}``.""" from cyclone.reconcile import _content_keys_match from decimal import Decimal r = _make_remit_for_keys("r1", "ABC", Decimal("100.00"), "1111111111") c = _make_claim_for_keys("c1", "ABC", Decimal("200.00"), "2222222222") - assert _content_keys_match(r, c) is False + assert _content_keys_match(r, c) == {"pcn"} def test_content_keys_only_charge_does_not_match(): - """Only charge matches (PCN + NPI both wrong) → False.""" + """Only charge matches (PCN + NPI both wrong) → set is just ``{"charge"}``.""" from cyclone.reconcile import _content_keys_match from decimal import Decimal r = _make_remit_for_keys("r1", "ABC", Decimal("100.00"), "1111111111") c = _make_claim_for_keys("c1", "XYZ", Decimal("100.00"), "2222222222") - assert _content_keys_match(r, c) is False + assert _content_keys_match(r, c) == {"charge"} def test_content_keys_none_match(): - """All 3 fields disagree → False.""" + """All 3 fields disagree → empty set.""" from cyclone.reconcile import _content_keys_match from decimal import Decimal r = _make_remit_for_keys("r1", "ABC", Decimal("100.00"), "1111111111") c = _make_claim_for_keys("c1", "XYZ", Decimal("200.00"), "2222222222") - assert _content_keys_match(r, c) is False + assert _content_keys_match(r, c) == set() def test_content_keys_charge_tolerance_is_one_cent(): - """Charge differs by exactly $0.005 (rounding) → True ($0.01 tolerance).""" + """Charge differs by exactly $0.005 (rounding) → charge counts as matched.""" from cyclone.reconcile import _content_keys_match from decimal import Decimal r = _make_remit_for_keys("r1", "ABC", Decimal("100.005"), "1111111111") c = _make_claim_for_keys("c1", "ABC", Decimal("100.00"), "1111111111") - assert _content_keys_match(r, c) is True + assert _content_keys_match(r, c) == {"pcn", "charge", "npi"} def test_content_keys_charge_differs_by_two_cents_no_match(): - """Charge differs by $0.02 → False (outside tolerance).""" + """Charge differs by $0.02 → charge does NOT match (outside tolerance).""" from cyclone.reconcile import _content_keys_match from decimal import Decimal # PCN and NPI deliberately differ so the only potential match is charge. - # Charge is outside the $0.01 tolerance → 0 of 3 match → False. + # Charge is outside the $0.01 tolerance → 0 of 3 match → empty set. r = _make_remit_for_keys("r1", "ABC", Decimal("100.02"), "1111111111") c = _make_claim_for_keys("c1", "XYZ", Decimal("100.00"), "2222222222") - assert _content_keys_match(r, c) is False + assert _content_keys_match(r, c) == set() def test_content_keys_empty_npi_does_not_count(): @@ -484,16 +484,16 @@ def test_content_keys_empty_npi_does_not_count(): from decimal import Decimal r = _make_remit_for_keys("r1", "ABC", Decimal("100.00"), "") c = _make_claim_for_keys("c1", "ABC", Decimal("100.00"), "1111111111") - assert _content_keys_match(r, c) is True # PCN + charge still match + assert _content_keys_match(r, c) == {"pcn", "charge"} # NPI skipped on remit c2 = _make_claim_for_keys("c2", "XYZ", Decimal("100.00"), "1111111111") - assert _content_keys_match(r, c2) is False # only charge, no PCN + assert _content_keys_match(r, c2) == {"charge"} # only charge, no PCN # --- SP31: content-keys fallback matcher (DB helper) ------------------------ def test_score_fallback_unique_match_returns_claim_id(db_session, make_claim, make_remit): - """One claim in pool matches 2-of-3 → returns that claim.""" + """One claim in pool matches 2-of-3 → returns (claim_id, keys, candidate_count).""" from decimal import Decimal from datetime import date, timedelta from cyclone.reconcile import _score_fallback_candidates @@ -507,7 +507,12 @@ def test_score_fallback_unique_match_returns_claim_id(db_session, make_claim, ma total_charge_amount=Decimal("100.00"), rendering_provider_npi="1111111111", service_date=date(2026, 6, 5)) # ±30-day window - assert _score_fallback_candidates(db_session, r) == c1.id + result = _score_fallback_candidates(db_session, r) + assert result is not None + claim_id, keys_matched, candidate_count = result + assert claim_id == c1.id + assert keys_matched == {"pcn", "charge", "npi"} # 3-of-3 + assert candidate_count == 2 # pool had 2 candidates def test_score_fallback_zero_matches_returns_none(db_session, make_claim, make_remit): @@ -591,7 +596,11 @@ def test_score_fallback_widened_window_catches_late_remit(db_session, make_claim total_charge_amount=Decimal("100.00"), rendering_provider_npi="1111111111", service_date=remit_date) - assert _score_fallback_candidates(db_session, r) == c1.id + result = _score_fallback_candidates(db_session, r) + assert result is not None + claim_id, keys_matched, candidate_count = result + assert claim_id == c1.id + assert candidate_count == 1 def test_score_fallback_outside_window_excluded(db_session, make_claim, make_remit): @@ -652,7 +661,7 @@ def test_content_keys_npi_from_remit_raw_json(db_session, make_claim): ) db_session.add(remit) db_session.flush() - assert _content_keys_match(remit, claim) is True # charge + NPI = 2 of 3 + assert _content_keys_match(remit, claim) == {"charge", "npi"} # PCN mismatched # --- SP31: end-to-end integration via reconcile.run() ----------------------- @@ -692,6 +701,12 @@ def test_reconcile_run_emits_score_auto_match_when_pcn_misses(db_session, make_c assert len(matches) == 1 assert matches[0].strategy == "score-auto" assert matches[0].claim_id == claim.id + # Symmetric FK: the auto-match path sets Remittance.claim_id so the + # list_unmatched filter (``Remittance.claim_id IS NULL``) correctly + # drops this pair from the orphan bucket. Mirrors the manual_match + # contract — both paths must populate both sides of the FK pair. + db_session.refresh(remit) + assert remit.claim_id == claim.id # Verify ActivityEvent was emitted with kind="auto_matched_835" from cyclone.db import ActivityEvent events = list(db_session.execute( @@ -702,6 +717,13 @@ def test_reconcile_run_emits_score_auto_match_when_pcn_misses(db_session, make_c ).scalars().all()) assert len(events) == 1 assert events[0].claim_id == claim.id + # Spec D8: payload records keys_matched + candidate_count so the + # operator can see *why* the auto-link fired. + payload = events[0].payload_json or {} + assert "keys_matched" in payload + assert "candidate_count" in payload + assert payload["candidate_count"] == 1 + assert set(payload["keys_matched"]) >= {"charge", "npi"} # PCN differs in this test # Verify claim state was flipped db_session.refresh(claim) assert claim.state in {ClaimState.PAID, ClaimState.PARTIAL, ClaimState.RECEIVED}