Files
cyclone/backend/tests/test_reconcile.py
T

203 lines
7.4 KiB
Python

"""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 decimal import Decimal
from typing import Optional
import pytest
from cyclone import reconcile
from cyclone.db import ClaimState
from cyclone.reconcile import Match
# --- 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
# ApplyIntent + apply_payment / apply_reversal / split_unmatched ----------------
def test_apply_payment_full_amount_returns_paid():
claim = FakeClaim("CLM-1", "PCN-A", None, state=ClaimState.SUBMITTED.value)
remit = FakeRemit("CLP-1", "PCN-A", None)
intent = reconcile.apply_payment(claim, remit, charge=Decimal("100"), paid=Decimal("100"))
assert intent.new_state == ClaimState.PAID
assert intent.activity_kind == "reconcile"
assert intent.skipped is False
def test_apply_payment_partial_amount_returns_partial():
claim = FakeClaim("CLM-1", "PCN-A", None, state=ClaimState.SUBMITTED.value)
remit = FakeRemit("CLP-1", "PCN-A", None)
intent = reconcile.apply_payment(claim, remit, charge=Decimal("100"), paid=Decimal("60"))
assert intent.new_state == ClaimState.PARTIAL
def test_apply_payment_zero_paid_returns_received():
claim = FakeClaim("CLM-1", "PCN-A", None, state=ClaimState.SUBMITTED.value)
remit = FakeRemit("CLP-1", "PCN-A", None)
intent = reconcile.apply_payment(claim, remit, charge=Decimal("100"), paid=Decimal("0"))
assert intent.new_state == ClaimState.RECEIVED
def test_apply_payment_status_4_returns_denied():
claim = FakeClaim("CLM-1", "PCN-A", None, state=ClaimState.SUBMITTED.value)
remit = FakeRemit("CLP-1", "PCN-A", None)
intent = reconcile.apply_payment(claim, remit, charge=Decimal("100"), paid=Decimal("0"),
status_code="4")
assert intent.new_state == ClaimState.DENIED
def test_apply_payment_already_paid_is_noop():
claim = FakeClaim("CLM-1", "PCN-A", None, state=ClaimState.PAID.value)
remit = FakeRemit("CLP-1", "PCN-A", None)
intent = reconcile.apply_payment(claim, remit, charge=Decimal("100"), paid=Decimal("100"))
assert intent.skipped is True
assert intent.activity_kind == "invalid_state"
assert intent.new_state is None
def test_apply_reversal_of_paid_returns_reversed_with_prior_state():
claim = FakeClaim("CLM-1", "PCN-A", None, state=ClaimState.PAID.value)
remit = FakeRemit("CLP-1", "PCN-A", None, is_reversal=True)
intent = reconcile.apply_reversal(claim, remit)
assert intent.new_state == ClaimState.REVERSED
assert intent.prior_claim_state == ClaimState.PAID
assert intent.activity_kind == "reversal"
assert intent.skipped is False
def test_apply_reversal_of_partial_works_same_way():
claim = FakeClaim("CLM-1", "PCN-A", None, state=ClaimState.PARTIAL.value)
remit = FakeRemit("CLP-1", "PCN-A", None, is_reversal=True)
intent = reconcile.apply_reversal(claim, remit)
assert intent.new_state == ClaimState.REVERSED
assert intent.prior_claim_state == ClaimState.PARTIAL
def test_apply_reversal_of_denied_is_noop():
claim = FakeClaim("CLM-1", "PCN-A", None, state=ClaimState.DENIED.value)
remit = FakeRemit("CLP-1", "PCN-A", None, is_reversal=True)
intent = reconcile.apply_reversal(claim, remit)
assert intent.skipped is True
assert intent.activity_kind == "reversal_skipped"
def test_apply_reversal_of_already_reversed_is_noop():
claim = FakeClaim("CLM-1", "PCN-A", None, state=ClaimState.REVERSED.value)
remit = FakeRemit("CLP-1", "PCN-A", None, is_reversal=True)
intent = reconcile.apply_reversal(claim, remit)
assert intent.skipped is True
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 = [Match(claim=claims[0], remittance=remits[0], strategy="auto", 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"]