"""Matching strategy classes for the reconcile engine. Each strategy takes a bank amount + list of candidate journal items and returns a MatchResult with the picked ids + confidence + residual. Strategies are pure Python; no ORM dependency. """ from dataclasses import dataclass, field from itertools import combinations @dataclass class Candidate: id: int amount: float partner_id: int age_days: int @dataclass class MatchResult: picked_ids: list[int] = field(default_factory=list) confidence: float = 0.0 residual: float = 0.0 # bank_amount - sum(picked); positive = under-allocated strategy_name: str = "" AMOUNT_TOLERANCE = 0.005 # currency rounding tolerance class AmountExactStrategy: """Pick a single candidate whose amount equals the bank amount exactly. If multiple candidates match exactly, pick the oldest (FIFO tiebreaker).""" def match(self, *, bank_amount: float, candidates: list[Candidate]) -> MatchResult: exact = [c for c in candidates if abs(c.amount - bank_amount) < AMOUNT_TOLERANCE] if not exact: return MatchResult(strategy_name='amount_exact') oldest = max(exact, key=lambda c: c.age_days) return MatchResult( picked_ids=[oldest.id], confidence=1.0, residual=0.0, strategy_name='amount_exact', ) class FIFOStrategy: """Pick oldest candidates first until the bank amount is exhausted. May produce partial reconcile residual if last candidate doesn't fit exactly.""" def match(self, *, bank_amount: float, candidates: list[Candidate]) -> MatchResult: if not candidates: return MatchResult(strategy_name='fifo') oldest_first = sorted(candidates, key=lambda c: -c.age_days) picked = [] remaining = bank_amount for c in oldest_first: if remaining <= AMOUNT_TOLERANCE: break picked.append(c.id) remaining -= c.amount confidence = 0.7 if remaining < AMOUNT_TOLERANCE else 0.5 return MatchResult( picked_ids=picked, confidence=confidence, residual=remaining, strategy_name='fifo', ) class MultiInvoiceStrategy: """Find the smallest combination of candidates summing to the bank amount. Bounded by max_combinations to keep complexity manageable.""" def __init__(self, max_combinations=3): self.max_combinations = max_combinations def match(self, *, bank_amount: float, candidates: list[Candidate]) -> MatchResult: for k in range(2, self.max_combinations + 1): for combo in combinations(candidates, k): total = sum(c.amount for c in combo) if abs(total - bank_amount) < AMOUNT_TOLERANCE: return MatchResult( picked_ids=[c.id for c in combo], confidence=0.85, residual=0.0, strategy_name=f'multi_invoice_{k}', ) return MatchResult(strategy_name='multi_invoice')