diff --git a/backend/src/cyclone/reconcile.py b/backend/src/cyclone/reconcile.py index 1989a29..60162ca 100644 --- a/backend/src/cyclone/reconcile.py +++ b/backend/src/cyclone/reconcile.py @@ -19,8 +19,11 @@ from __future__ import annotations from dataclasses import dataclass from datetime import date +from decimal import Decimal from typing import Iterable, Optional, Protocol +from cyclone.db import ClaimState + WINDOW_DAYS_DEFAULT = 7 @@ -110,3 +113,96 @@ def _pick_claim( return best[1] # Remit has a date but no candidate was within the window → no match. return None + + +@dataclass +class ApplyIntent: + """Result of applying a match. The caller persists this. + + A noop is produced when the claim is in a terminal state or the operation + is not applicable. The caller should record an Activity row and skip any + Claim mutation when `skipped is True`. + """ + new_state: Optional[ClaimState] = None + activity_kind: str = "reconcile" + skipped: bool = False + prior_claim_state: Optional[ClaimState] = None + reason: str = "" + + @classmethod + def noop(cls, *, activity_kind: str = "invalid_state", reason: str = "") -> "ApplyIntent": + return cls( + new_state=None, + activity_kind=activity_kind, + skipped=True, + prior_claim_state=None, + reason=reason, + ) + + +def apply_payment( + claim: _ClaimLike, + remit: _RemitLike, + *, + charge: Decimal, + paid: Decimal, + status_code: str = "", +) -> ApplyIntent: + """Decide the Claim's new state given a non-reversal match. + + Rules: + - If the claim is already in a terminal state (paid/partial/denied/reconciled), + return a noop (the caller should record an invalid_state activity). + - status_code == "4" → DENIED. + - paid <= 0 → RECEIVED (claim acknowledged but no money yet). + - paid >= charge → PAID in full. + - 0 < paid < charge → PARTIAL. + """ + if claim.state in (ClaimState.PAID.value, ClaimState.PARTIAL.value, + ClaimState.DENIED.value, ClaimState.RECONCILED.value): + return ApplyIntent.noop( + reason=f"claim already in terminal state {claim.state}", + ) + + if status_code == "4": + new_state = ClaimState.DENIED + elif paid is None or paid <= 0: + new_state = ClaimState.RECEIVED + elif paid >= charge: + new_state = ClaimState.PAID + else: + new_state = ClaimState.PARTIAL + + return ApplyIntent(new_state=new_state, activity_kind="reconcile") + + +def apply_reversal(claim: _ClaimLike, remit: _RemitLike) -> ApplyIntent: + """Apply a reversal: flip paid/partial → REVERSED, preserve prior state. + + Reversals on claims not in a paid/partial state are noops with + activity_kind "reversal_skipped" so the caller can still log them. + """ + if claim.state in (ClaimState.PAID.value, ClaimState.PARTIAL.value): + return ApplyIntent( + new_state=ClaimState.REVERSED, + activity_kind="reversal", + prior_claim_state=ClaimState(claim.state), + ) + return ApplyIntent.noop( + activity_kind="reversal_skipped", + reason=f"reversal on state {claim.state}", + ) + + +def split_unmatched( + claims: list[_ClaimLike], + remits: list[_RemitLike], + matches: list[Match], +) -> tuple[list[_ClaimLike], list[_RemitLike]]: + """Return (unmatched_claims, unmatched_remits) after applying matches.""" + matched_claim_ids = {m.claim.id for m in matches} + matched_remit_ids = {m.remittance.id for m in matches} + return ( + [c for c in claims if c.id not in matched_claim_ids], + [r for r in remits if r.id not in matched_remit_ids], + ) diff --git a/backend/tests/test_reconcile.py b/backend/tests/test_reconcile.py index 53fa857..c22cb5a 100644 --- a/backend/tests/test_reconcile.py +++ b/backend/tests/test_reconcile.py @@ -8,11 +8,14 @@ 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 ----------------------------------------------------------- @@ -112,3 +115,88 @@ def test_match_reversal_flag_propagates(): 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"]