From b484ca36dc6f9edd67f26c33dab87c86d48d36b7 Mon Sep 17 00:00:00 2001 From: Nora Date: Thu, 2 Jul 2026 15:04:48 -0600 Subject: [PATCH] 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