203 lines
7.4 KiB
Python
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"]
|