# ============================================================================= # Projet : AIR — Accounting Intermediate Representation # Auteur : Simon-Pierre Boucher # Contact : contact@spboucher.ai # Fichier : reconcile.py # Description : Bank reconciliation — match statement lines against ledger cash movements. # ============================================================================= """Bank reconciliation (Phase 6). A bank statement (camt.053, MT940, or CSV — parsers in kernel/bank_formats.py) is reduced to a list of BankTransaction records, then matched against the ledger's cash-account movements: - signed amounts: positive = money into our account (bank credit = our debit); - a match is exact on (signed amount, currency) within a configurable date-tolerance window (default 3 days); - greedy one-to-one matching, earliest ledger candidate first; - everything unmatched — on either side — is reported, never dropped. Amounts are Decimal end to end; floats are rejected at the parser boundary. """ from __future__ import annotations from dataclasses import dataclass, field from datetime import date from decimal import Decimal from core.journal import Side from kernel.ledger import Ledger @dataclass(frozen=True, slots=True) class BankTransaction: """One statement line, format-agnostic. Positive amount = money in.""" date: date amount: Decimal currency: str description: str = "" reference: str = "" @dataclass(frozen=True, slots=True) class LedgerMovement: """One cash-account line in the books. Positive amount = money in (debit).""" date: date amount: Decimal currency: str entry_id: str memo: str = "" @dataclass class ReconciliationResult: matched: list[tuple[BankTransaction, LedgerMovement]] = field(default_factory=list) unmatched_bank: list[BankTransaction] = field(default_factory=list) unmatched_ledger: list[LedgerMovement] = field(default_factory=list) @property def is_clean(self) -> bool: return not self.unmatched_bank and not self.unmatched_ledger def summary(self) -> dict[str, int]: return { "matched": len(self.matched), "unmatched_bank": len(self.unmatched_bank), "unmatched_ledger": len(self.unmatched_ledger), } def render(self) -> str: lines = ["Bank Reconciliation", "-" * 72] for txn, movement in self.matched: lines.append( f"MATCH {txn.date} {txn.amount:>12} {txn.currency} " f"bank:{txn.reference or txn.description[:24]:<24} " f"ledger:{movement.entry_id}" ) for txn in self.unmatched_bank: lines.append( f"BANK? {txn.date} {txn.amount:>12} {txn.currency} " f"{txn.description[:40]} <- in the bank, not in the books" ) for movement in self.unmatched_ledger: lines.append( f"BOOK? {movement.date} {movement.amount:>12} " f"{movement.currency} {movement.entry_id} " f"<- in the books, not at the bank" ) lines.append("-" * 72) s = self.summary() lines.append( f"{s['matched']} matched, {s['unmatched_bank']} unexplained bank, " f"{s['unmatched_ledger']} outstanding ledger — " + ("CLEAN" if self.is_clean else "DIFFERENCES FOUND") ) return "\n".join(lines) def cash_movements(ledger: Ledger, cash_account: str = "1000") -> list[LedgerMovement]: """Every line touching the cash account, debit-positive (money in).""" movements: list[LedgerMovement] = [] for entry in ledger.entries: for line in entry.lines: if line.account.code != cash_account: continue signed = (line.amount.amount if line.side is Side.DEBIT else -line.amount.amount) movements.append(LedgerMovement( date=entry.date, amount=signed, currency=line.amount.currency, entry_id=entry.id, memo=line.memo, )) return movements def reconcile( transactions: list[BankTransaction], movements: list[LedgerMovement], tolerance_days: int = 3, ) -> ReconciliationResult: """Greedy one-to-one matching on (signed amount, currency) within the date window; among candidates, the closest date wins.""" result = ReconciliationResult() remaining = list(movements) for txn in sorted(transactions, key=lambda t: t.date): candidates = [ m for m in remaining if m.amount == txn.amount and m.currency == txn.currency and abs((m.date - txn.date).days) <= tolerance_days ] if candidates: best = min(candidates, key=lambda m: abs((m.date - txn.date).days)) remaining.remove(best) result.matched.append((txn, best)) else: result.unmatched_bank.append(txn) result.unmatched_ledger = remaining return result