feat(backend): add apply_payment, apply_reversal, split_unmatched pure fns

This commit is contained in:
Tyler
2026-06-19 22:10:44 -06:00
parent c13bbf9190
commit 00371248b3
2 changed files with 184 additions and 0 deletions
+96
View File
@@ -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],
)
+88
View File
@@ -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"]