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 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],
)