From 69b760e89080ed8191176a5e2593fae88302d320 Mon Sep 17 00:00:00 2001 From: Nora Date: Tue, 7 Jul 2026 19:59:18 -0600 Subject: [PATCH] feat(sp41): visit-to-835 reconcile on (member_id, procedure, DOS) with best-of-N --- backend/src/cyclone/rebill/reconcile.py | 69 ++++++++++++++++++++++ backend/tests/test_rebill_reconcile.py | 76 +++++++++++++++++++++++++ 2 files changed, 145 insertions(+) create mode 100644 backend/src/cyclone/rebill/reconcile.py create mode 100644 backend/tests/test_rebill_reconcile.py diff --git a/backend/src/cyclone/rebill/reconcile.py b/backend/src/cyclone/rebill/reconcile.py new file mode 100644 index 0000000..f5a803b --- /dev/null +++ b/backend/src/cyclone/rebill/reconcile.py @@ -0,0 +1,69 @@ +"""Visit-to-835 reconciliation on (member_id, procedure, DOS). + +Authoritative match key. The 5% tolerance on PAID handles the legitimate +charge-amount differences between AxisCare's per-unit CSV and the 835's +per-SVC breakdown. +""" +from collections import defaultdict +from dataclasses import dataclass +from datetime import date +from decimal import Decimal +from enum import Enum +from typing import Iterable + +# SvcRow is owned by parse_835_svc (Task 1) — re-exported here so callers +# of `cyclone.rebill.reconcile` only need one import. +from cyclone.rebill.parse_835_svc import SvcRow # noqa: F401 (re-exported) + +PAID_TOLERANCE = Decimal("0.05") # charge match tolerance +PARTIAL_RATIO = Decimal("0.95") # PAID if total_paid >= 95% of billed + + +class OutcomeCategory(str, Enum): + PAID = "PAID" + PARTIAL = "PARTIAL" + DENIED = "DENIED" + NOT_IN_835 = "NOT_IN_835" + + +@dataclass(frozen=True) +class VisitRow: + date: date + member_id: str + procedure: str + billed: Decimal + + +@dataclass(frozen=True) +class ReconcileOutcome: + visit: VisitRow + category: OutcomeCategory + unpaid: Decimal + matched_svc_count: int + + +def reconcile_visits_to_835( + visits: Iterable[VisitRow], + svcs: Iterable[SvcRow], +) -> list[ReconcileOutcome]: + """Index SVCs by (member_id, procedure, DOS), best-of-N outcome per visit.""" + by_key: dict[tuple[str, str, date], list[SvcRow]] = defaultdict(list) + for s in svcs: + by_key[(s.member_id, s.procedure, s.svc_date)].append(s) + + out: list[ReconcileOutcome] = [] + for v in visits: + cands = by_key.get((v.member_id, v.procedure, v.date), []) + if not cands: + out.append(ReconcileOutcome(v, OutcomeCategory.NOT_IN_835, v.billed, 0)) + continue + total_paid = sum((c.paid for c in cands), Decimal("0")) + any_paid = any(c.paid > 0 for c in cands) + if any_paid and total_paid >= v.billed * PARTIAL_RATIO: + cat, unpaid = OutcomeCategory.PAID, Decimal("0") + elif any_paid: + cat, unpaid = OutcomeCategory.PARTIAL, max(Decimal("0"), v.billed - total_paid) + else: + cat, unpaid = OutcomeCategory.DENIED, v.billed + out.append(ReconcileOutcome(v, cat, unpaid, len(cands))) + return out diff --git a/backend/tests/test_rebill_reconcile.py b/backend/tests/test_rebill_reconcile.py new file mode 100644 index 0000000..30db4be --- /dev/null +++ b/backend/tests/test_rebill_reconcile.py @@ -0,0 +1,76 @@ +"""Visit-to-835 reconciliation on (member_id, procedure, DOS).""" +from datetime import date +from decimal import Decimal +from cyclone.rebill.reconcile import ( + VisitRow, + SvcRow, + ReconcileOutcome, + reconcile_visits_to_835, + OutcomeCategory, +) + + +def _v(date, member, proc, amt): + return VisitRow(date=date, member_id=member, procedure=proc, billed=Decimal(amt)) + + +def _s(date, member, proc, chg, paid, status="1"): + return SvcRow( + src_file="x.835", claim_id="c1", member_id=member, status=status, + procedure=proc, modifiers="", charge=Decimal(chg), paid=Decimal(paid), + units=Decimal("0"), svc_date=date, cas_reasons=(), pay_date=None, + ) + + +def test_visit_paid_when_any_svc_paid_within_tolerance(): + visits = [_v(date(2026, 1, 18), "J813715", "T1019", "2.32")] + svcs = [_s(date(2026, 1, 18), "J813715", "T1019", "2.32", "2.30")] + out = reconcile_visits_to_835(visits, svcs) + assert out[0].category == OutcomeCategory.PAID + assert out[0].unpaid == Decimal("0") + + +def test_visit_paid_when_one_svc_paid_others_denied_duplicate(): + """OA-18 on duplicates is noise; paid SVC wins.""" + visits = [_v(date(2026, 1, 18), "J813715", "T1019", "2.32")] + svcs = [ + _s(date(2026, 1, 18), "J813715", "T1019", "2.32", "2.30", "1"), + _s(date(2026, 1, 18), "J813715", "T1019", "2.32", "0.00", "4"), + _s(date(2026, 1, 18), "J813715", "T1019", "2.32", "0.00", "4"), + ] + out = reconcile_visits_to_835(visits, svcs) + assert out[0].category == OutcomeCategory.PAID + + +def test_visit_denied_when_all_svc_denied(): + visits = [_v(date(2026, 1, 18), "J813715", "T1019", "2.32")] + svcs = [ + _s(date(2026, 1, 18), "J813715", "T1019", "2.32", "0.00", "4"), + _s(date(2026, 1, 18), "J813715", "T1019", "2.32", "0.00", "4"), + ] + out = reconcile_visits_to_835(visits, svcs) + assert out[0].category == OutcomeCategory.DENIED + assert out[0].unpaid == Decimal("2.32") + + +def test_visit_not_in_835_when_no_svc_match(): + visits = [_v(date(2026, 1, 18), "J813715", "T1019", "2.32")] + svcs = [_s(date(2026, 1, 19), "J813715", "T1019", "2.32", "2.30")] + out = reconcile_visits_to_835(visits, svcs) + assert out[0].category == OutcomeCategory.NOT_IN_835 + assert out[0].unpaid == Decimal("2.32") + + +def test_partial_when_total_paid_below_95pct_of_billed(): + visits = [_v(date(2026, 1, 18), "J813715", "T1019", "10.00")] + svcs = [_s(date(2026, 1, 18), "J813715", "T1019", "10.00", "4.00")] + out = reconcile_visits_to_835(visits, svcs) + assert out[0].category == OutcomeCategory.PARTIAL + assert out[0].unpaid == Decimal("6.00") + + +def test_different_member_does_not_match(): + visits = [_v(date(2026, 1, 18), "J813715", "T1019", "2.32")] + svcs = [_s(date(2026, 1, 18), "OTHERMEMBER", "T1019", "2.32", "2.30")] + out = reconcile_visits_to_835(visits, svcs) + assert out[0].category == OutcomeCategory.NOT_IN_835