feat(backend): add apply_payment, apply_reversal, split_unmatched pure fns
This commit is contained in:
@@ -19,8 +19,11 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
from typing import Iterable, Optional, Protocol
|
from typing import Iterable, Optional, Protocol
|
||||||
|
|
||||||
|
from cyclone.db import ClaimState
|
||||||
|
|
||||||
|
|
||||||
WINDOW_DAYS_DEFAULT = 7
|
WINDOW_DAYS_DEFAULT = 7
|
||||||
|
|
||||||
@@ -110,3 +113,96 @@ def _pick_claim(
|
|||||||
return best[1]
|
return best[1]
|
||||||
# Remit has a date but no candidate was within the window → no match.
|
# Remit has a date but no candidate was within the window → no match.
|
||||||
return None
|
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],
|
||||||
|
)
|
||||||
|
|||||||
@@ -8,11 +8,14 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from cyclone import reconcile
|
from cyclone import reconcile
|
||||||
|
from cyclone.db import ClaimState
|
||||||
|
from cyclone.reconcile import Match
|
||||||
|
|
||||||
|
|
||||||
# --- Stand-in types -----------------------------------------------------------
|
# --- 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)]
|
remits = [FakeRemit("CLP-1", "PCN-A", date(2026, 6, 1), is_reversal=True)]
|
||||||
matches = reconcile.match(claims, remits)
|
matches = reconcile.match(claims, remits)
|
||||||
assert matches[0].is_reversal is True
|
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"]
|
||||||
|
|||||||
Reference in New Issue
Block a user