diff --git a/backend/src/cyclone/reconcile.py b/backend/src/cyclone/reconcile.py new file mode 100644 index 0000000..e175eca --- /dev/null +++ b/backend/src/cyclone/reconcile.py @@ -0,0 +1,112 @@ +"""Pure reconciliation functions. + +These functions take lists of objects (ORM or stand-in dataclasses) and +return intent objects describing what should change. They do not own a +database session; the caller (cyclone.store) persists the intents. + +Match algorithm: +- Group claims by patient_control_number. +- For each remit, find a candidate claim with the same PCN. +- Prefer the claim whose service_date_from is closest to the remit's + service_date, within +/- window_days. +- If the remit has no service_date, fall back to the first claim for + that PCN (in input order). +- Skip claims that already have matched_remittance_id set. +- is_reversal flag is propagated to the Match. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date +from typing import Iterable, Optional, Protocol + + +WINDOW_DAYS_DEFAULT = 7 + + +class _ClaimLike(Protocol): + id: str + patient_control_number: str + service_date_from: Optional[date] + matched_remittance_id: Optional[str] + + +class _RemitLike(Protocol): + id: str + payer_claim_control_number: str + service_date: Optional[date] + is_reversal: bool + + +@dataclass +class Match: + claim: _ClaimLike + remittance: _RemitLike + strategy: str + is_reversal: bool + + +def match( + claims: Iterable[_ClaimLike], + remittances: Iterable[_RemitLike], + *, + window_days: int = WINDOW_DAYS_DEFAULT, +) -> list[Match]: + """Pair each remit with the closest eligible claim. Returns Match intents.""" + claims_list = list(claims) + remits_list = list(remittances) + + # Eligible = no current match, has a PCN. + eligible = [c for c in claims_list if c.patient_control_number and not c.matched_remittance_id] + by_pcn: dict[str, list[_ClaimLike]] = {} + for c in eligible: + by_pcn.setdefault(c.patient_control_number, []).append(c) + + matches: list[Match] = [] + used_claim_ids: set[str] = set() + + for r in remits_list: + candidates = by_pcn.get(r.payer_claim_control_number, []) + candidates = [c for c in candidates if c.id not in used_claim_ids] + if not candidates: + continue + + chosen = _pick_claim(candidates, r.service_date, window_days) + if chosen is None: + continue + + matches.append(Match( + claim=chosen, + remittance=r, + strategy="auto", + is_reversal=r.is_reversal, + )) + used_claim_ids.add(chosen.id) + + return matches + + +def _pick_claim( + candidates: list[_ClaimLike], + remit_service_date: Optional[date], + window_days: int, +) -> Optional[_ClaimLike]: + """Choose the claim with the closest service_date_from within the window.""" + if remit_service_date is None: + return candidates[0] # no date info → first claim for that PCN + + best: tuple[int, _ClaimLike] | None = None + for c in candidates: + if c.service_date_from is None: + continue + delta = abs((c.service_date_from - remit_service_date).days) + if delta > window_days: + continue + if best is None or delta < best[0]: + best = (delta, c) + + if best is not None: + return best[1] + # Remit has a date but no candidate was within the window → no match. + return None diff --git a/backend/tests/test_reconcile.py b/backend/tests/test_reconcile.py new file mode 100644 index 0000000..53fa857 --- /dev/null +++ b/backend/tests/test_reconcile.py @@ -0,0 +1,114 @@ +"""Tests for the pure reconciliation functions. + +These tests use lightweight stand-in objects (namedtuples) rather than +SQLAlchemy ORM instances, so the reconcile module has zero DB dependency. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date +from typing import Optional + +import pytest + +from cyclone import reconcile + + +# --- Stand-in types ----------------------------------------------------------- + + +@dataclass +class FakeClaim: + id: str + patient_control_number: str + service_date_from: Optional[date] + state: str = "submitted" + matched_remittance_id: Optional[str] = None + + +@dataclass +class FakeRemit: + id: str + payer_claim_control_number: str + service_date: Optional[date] + is_reversal: bool = False + + +# --- match() ------------------------------------------------------------------ + + +def test_match_same_pcn_within_window_matches(): + claims = [FakeClaim("CLM-1", "PCN-A", date(2026, 6, 1))] + remits = [FakeRemit("CLP-1", "PCN-A", date(2026, 6, 2))] + matches = reconcile.match(claims, remits, window_days=7) + assert len(matches) == 1 + assert matches[0].claim.id == "CLM-1" + assert matches[0].remittance.id == "CLP-1" + assert matches[0].strategy == "auto" + + +def test_match_dates_outside_window_unmatched(): + claims = [FakeClaim("CLM-1", "PCN-A", date(2026, 6, 1))] + remits = [FakeRemit("CLP-1", "PCN-A", date(2026, 7, 1))] # 30d apart + matches = reconcile.match(claims, remits, window_days=7) + assert matches == [] + + +def test_match_different_pcn_unmatched(): + claims = [FakeClaim("CLM-1", "PCN-A", date(2026, 6, 1))] + remits = [FakeRemit("CLP-1", "PCN-B", date(2026, 6, 1))] + matches = reconcile.match(claims, remits) + assert matches == [] + + +def test_match_multiple_claims_same_pcn_picks_closest_date(): + claims = [ + FakeClaim("CLM-1", "PCN-A", date(2026, 5, 1)), + FakeClaim("CLM-2", "PCN-A", date(2026, 6, 1)), # closer + ] + remits = [FakeRemit("CLP-1", "PCN-A", date(2026, 6, 2))] + matches = reconcile.match(claims, remits) + assert len(matches) == 1 + assert matches[0].claim.id == "CLM-2" + + +def test_match_multiple_remits_same_pcn_each_gets_own_claim(): + claims = [ + FakeClaim("CLM-1", "PCN-A", date(2026, 6, 1)), + FakeClaim("CLM-2", "PCN-A", date(2026, 6, 15)), + ] + remits = [ + FakeRemit("CLP-1", "PCN-A", date(2026, 6, 2)), + FakeRemit("CLP-2", "PCN-A", date(2026, 6, 16)), + ] + matches = reconcile.match(claims, remits) + assert len(matches) == 2 + claim_ids = {m.claim.id for m in matches} + assert claim_ids == {"CLM-1", "CLM-2"} + + +def test_match_skips_already_matched_claims(): + claims = [FakeClaim("CLM-1", "PCN-A", date(2026, 6, 1), matched_remittance_id="OLD")] + remits = [FakeRemit("CLP-1", "PCN-A", date(2026, 6, 1))] + matches = reconcile.match(claims, remits) + assert matches == [] + + +def test_match_falls_back_to_first_claim_when_no_service_date(): + """When the remit has no service_date, match the first claim for that PCN.""" + claims = [ + FakeClaim("CLM-1", "PCN-A", None), + FakeClaim("CLM-2", "PCN-A", None), + ] + remits = [FakeRemit("CLP-1", "PCN-A", None)] + matches = reconcile.match(claims, remits) + assert len(matches) == 1 + assert matches[0].claim.id == "CLM-1" + + +def test_match_reversal_flag_propagates(): + claims = [FakeClaim("CLM-1", "PCN-A", date(2026, 6, 1))] + remits = [FakeRemit("CLP-1", "PCN-A", date(2026, 6, 1), is_reversal=True)] + matches = reconcile.match(claims, remits) + assert matches[0].is_reversal is True