"""Rules engine, internal-transfer detection and recurring-series detection. Reference: datamodel-finance.md §5 (rules), §6 (transfers), §7 (recurring). Nothing here ever overwrites a manual categorisation (`category_source = 'user'`) unless the caller explicitly forces it (§10.6). """ import re import uuid from collections import Counter, defaultdict from dataclasses import dataclass, field from datetime import date, timedelta from decimal import Decimal from itertools import pairwise from statistics import median from typing import Any from sqlalchemy import select from sqlalchemy.orm import Session from app.core.errors import DomainValidationError, NotFoundError from app.core.timeutils import utcnow from app.modules.finance.enums import CategoryKind, CategorySource from app.modules.finance.models import FinCategory, FinRule, FinTransaction from app.modules.finance.normalize import merchant_key, normalize_label_for_hash TRANSFER_MAX_DAYS = 3 TRANSFER_LABEL_RE = re.compile(r"\bVIR(EMENT)?\b|\bVIRT\b|TRANSFERT|PAYPAL") RULE_SCOPES = ("uncategorized", "all_non_manual", "all") # (name, min_interval_days, max_interval_days, nominal_interval_days) PERIODICITIES: tuple[tuple[str, int, int, int], ...] = ( ("weekly", 6, 8, 7), ("monthly", 25, 35, 30), ("quarterly", 85, 97, 91), ("yearly", 350, 380, 365), ) # Monthly normalisation factor used by `monthly_total_estimate` (§9.8). MONTHLY_FACTOR: dict[str, Decimal] = { "weekly": Decimal("4.33"), "monthly": Decimal(1), "quarterly": Decimal(1) / Decimal(3), "yearly": Decimal(1) / Decimal(12), } MIN_OCCURRENCES = 3 REGULARITY_RATIO = 0.8 # --------------------------------------------------------------------------- # Rules engine (§5) # --------------------------------------------------------------------------- def compile_matchers(matchers: dict[str, Any]) -> re.Pattern[str] | None: """Compile `label_regex` once; raises DomainValidationError if invalid.""" pattern = matchers.get("label_regex") if not pattern: return None try: return re.compile(pattern, re.IGNORECASE) except re.error as exc: raise DomainValidationError(f"Expression régulière invalide : {exc}") from exc def validate_matchers(matchers: dict[str, Any]) -> None: if not isinstance(matchers, dict): raise DomainValidationError("Conditions de règle invalides.") direction = matchers.get("direction") if direction is not None and direction not in ("debit", "credit", "any"): raise DomainValidationError( "Sens invalide : utilisez « debit », « credit » ou « any »." ) conditions = ( "label_contains", "label_regex", "amount_min", "amount_max", "account_id", ) has_condition = any( matchers.get(key) not in (None, [], "") for key in conditions ) or direction in ("debit", "credit") if not has_condition: raise DomainValidationError("La règle doit comporter au moins une condition.") compile_matchers(matchers) def validate_actions(actions: dict[str, Any]) -> None: if not isinstance(actions, dict) or not any( actions.get(key) not in (None, "", False) for key in ( "set_category_id", "set_label_clean", "set_counterparty", "mark_transfer", ) ): raise DomainValidationError("La règle doit comporter au moins une action.") @dataclass class CompiledRule: rule: FinRule regex: re.Pattern[str] | None contains: tuple[str, ...] @property def matchers(self) -> dict[str, Any]: return self.rule.matchers or {} @property def actions(self) -> dict[str, Any]: return self.rule.actions or {} def compile_rules(rules: list[FinRule]) -> list[CompiledRule]: compiled: list[CompiledRule] = [] for rule in rules: matchers = rule.matchers or {} try: regex = compile_matchers(matchers) except DomainValidationError: regex = None # stored rule broke: ignore its regex, keep the rest contains = tuple( normalize_label_for_hash(str(needle)) for needle in (matchers.get("label_contains") or []) if str(needle).strip() ) compiled.append(CompiledRule(rule=rule, regex=regex, contains=contains)) return compiled def load_enabled_rules(db: Session, user_id: int) -> list[CompiledRule]: stmt = ( select(FinRule) .where(FinRule.user_id == user_id, FinRule.enabled.is_(True)) .order_by(FinRule.priority.asc(), FinRule.created_at.asc(), FinRule.id.asc()) ) return compile_rules(list(db.scalars(stmt).all())) def rule_matches(compiled: CompiledRule, tx: FinTransaction) -> bool: m = compiled.matchers account_id = m.get("account_id") if account_id and str(tx.account_id) != str(account_id): return False direction = m.get("direction") if direction == "debit" and tx.amount >= 0: return False if direction == "credit" and tx.amount <= 0: return False if m.get("amount_min") is not None and tx.amount < Decimal(str(m["amount_min"])): return False if m.get("amount_max") is not None and tx.amount > Decimal(str(m["amount_max"])): return False if compiled.contains: hay = ( normalize_label_for_hash(tx.label_raw) + " | " + normalize_label_for_hash(tx.label_clean or "") ) if not any(needle in hay for needle in compiled.contains): return False return compiled.regex is None or bool( compiled.regex.search(tx.label_raw or "") or compiled.regex.search(tx.label_clean or "") ) def apply_rules_to_tx( tx: FinTransaction, rules: list[CompiledRule], transfer_category_id: uuid.UUID | None, *, force: bool = False, ) -> list[uuid.UUID]: """Mutate `tx` in place; returns the ids of the rules that fired (§5.3).""" fired: list[uuid.UUID] = [] for compiled in rules: if not rule_matches(compiled, tx): continue actions = compiled.actions protected = tx.category_source == CategorySource.USER and not force if actions.get("set_category_id") and not protected: tx.category_id = uuid.UUID(str(actions["set_category_id"])) tx.category_source = CategorySource.RULE tx.applied_rule_id = compiled.rule.id if actions.get("set_label_clean"): tx.label_clean = str(actions["set_label_clean"]) if actions.get("set_counterparty"): tx.counterparty = str(actions["set_counterparty"])[:150] if actions.get("mark_transfer") and not protected and transfer_category_id: tx.category_id = transfer_category_id tx.category_source = CategorySource.RULE tx.applied_rule_id = compiled.rule.id fired.append(compiled.rule.id) compiled.rule.hit_count += 1 compiled.rule.last_applied_at = utcnow() if compiled.rule.stop: break return fired def get_transfer_category_id(db: Session, user_id: int) -> uuid.UUID | None: stmt = select(FinCategory.id).where( FinCategory.user_id == user_id, FinCategory.kind == CategoryKind.TRANSFER, ) return db.scalars(stmt.limit(1)).first() @dataclass class RuleApplyResult: scanned: int = 0 matched: int = 0 updated: int = 0 dry_run: bool = False by_rule: list[dict[str, Any]] = field(default_factory=list) def apply_rules( db: Session, user_id: int, *, scope: str = "uncategorized", date_from: date | None = None, date_to: date | None = None, account_id: uuid.UUID | None = None, rule_id: uuid.UUID | None = None, dry_run: bool = False, force: bool = False, ) -> RuleApplyResult: """Re-run the rules over stored transactions (§5.4).""" if scope not in RULE_SCOPES: raise DomainValidationError( "Portée inconnue : utilisez « uncategorized », « all_non_manual » " "ou « all »." ) if scope == "all" and not force: raise DomainValidationError( "La portée « all » écrase les catégories manuelles : " "confirmez avec force=true." ) rules = load_enabled_rules(db, user_id) if rule_id is not None: rules = [c for c in rules if c.rule.id == rule_id] if not rules: raise NotFoundError("Règle introuvable.") stmt = select(FinTransaction).where(FinTransaction.user_id == user_id) if scope == "uncategorized": stmt = stmt.where(FinTransaction.category_id.is_(None)) elif scope == "all_non_manual": stmt = stmt.where( (FinTransaction.category_source.is_(None)) | (FinTransaction.category_source != CategorySource.USER) ) if date_from is not None: stmt = stmt.where(FinTransaction.booked_date >= date_from) if date_to is not None: stmt = stmt.where(FinTransaction.booked_date <= date_to) if account_id is not None: stmt = stmt.where(FinTransaction.account_id == account_id) stmt = stmt.order_by(FinTransaction.booked_date.asc(), FinTransaction.id.asc()) transfer_cat = get_transfer_category_id(db, user_id) result = RuleApplyResult(dry_run=dry_run) per_rule: Counter[uuid.UUID] = Counter() for tx in db.scalars(stmt.execution_options(yield_per=1000)): result.scanned += 1 before = (tx.category_id, tx.category_source, tx.label_clean, tx.counterparty) fired = apply_rules_to_tx(tx, rules, transfer_cat, force=force) if fired: result.matched += 1 for fired_id in fired: per_rule[fired_id] += 1 after = (tx.category_id, tx.category_source, tx.label_clean, tx.counterparty) if before != after: result.updated += 1 result.by_rule = [ { "rule_id": compiled.rule.id, "name": compiled.rule.name, "matched": per_rule.get(compiled.rule.id, 0), } for compiled in rules if per_rule.get(compiled.rule.id, 0) ] if dry_run: db.rollback() else: db.commit() return result def preview_rule( db: Session, user_id: int, matchers: dict[str, Any], limit: int = 50, ) -> tuple[list[FinTransaction], int]: """Transactions that an unsaved rule would match (§9.4 `/rules/preview`).""" validate_matchers(matchers) probe = FinRule( id=uuid.uuid4(), user_id=user_id, name="preview", matchers=matchers, actions={}, ) compiled = compile_rules([probe])[0] stmt = ( select(FinTransaction) .where(FinTransaction.user_id == user_id) .order_by(FinTransaction.booked_date.desc(), FinTransaction.id.desc()) ) matches: list[FinTransaction] = [] total = 0 for tx in db.scalars(stmt.execution_options(yield_per=1000)): if rule_matches(compiled, tx): total += 1 if len(matches) < limit: matches.append(tx) return matches, total # --------------------------------------------------------------------------- # Internal transfers (§6) # --------------------------------------------------------------------------- def detect_transfers( db: Session, user_id: int, date_min: date | None = None, date_max: date | None = None, ) -> int: """Pair opposite transactions across accounts; returns the pairs created.""" stmt = select(FinTransaction).where( FinTransaction.user_id == user_id, FinTransaction.transfer_group_id.is_(None), ) if date_min is not None: stmt = stmt.where(FinTransaction.booked_date >= date_min) if date_max is not None: stmt = stmt.where(FinTransaction.booked_date <= date_max) txs = list(db.scalars(stmt).all()) debits = [t for t in txs if t.amount < 0] credits_by_amount: dict[Decimal, list[FinTransaction]] = defaultdict(list) for t in txs: if t.amount > 0: credits_by_amount[t.amount].append(t) pairs: list[tuple[int, FinTransaction, FinTransaction]] = [] for debit in debits: for credit in credits_by_amount.get(-debit.amount, []): if credit.account_id == debit.account_id: continue if credit.currency != debit.currency: continue delta = abs((credit.booked_date - debit.booked_date).days) if delta > TRANSFER_MAX_DAYS: continue score = (TRANSFER_MAX_DAYS - delta) * 10 joined = normalize_label_for_hash(f"{debit.label_raw} {credit.label_raw}") if TRANSFER_LABEL_RE.search(joined): score += 5 pairs.append((score, debit, credit)) pairs.sort(key=lambda p: (-p[0], p[1].booked_date, str(p[1].id))) transfer_cat = get_transfer_category_id(db, user_id) used: set[uuid.UUID] = set() created = 0 for _score, debit, credit in pairs: if debit.id in used or credit.id in used: continue group_id = uuid.uuid4() for leg in (debit, credit): leg.transfer_group_id = group_id if leg.category_source != CategorySource.USER: leg.category_id = transfer_cat leg.category_source = CategorySource.RULE used |= {debit.id, credit.id} created += 1 return created def link_transfer( db: Session, user_id: int, id_a: uuid.UUID, id_b: uuid.UUID ) -> uuid.UUID: """Manual pairing (§6): opposite amounts, different accounts.""" if id_a == id_b: raise DomainValidationError("Sélectionnez deux transactions différentes.") legs = list( db.scalars( select(FinTransaction).where( FinTransaction.user_id == user_id, FinTransaction.id.in_([id_a, id_b]), ) ).all() ) if len(legs) != 2: raise NotFoundError("Transaction introuvable.") first, second = legs if first.amount + second.amount != 0: raise DomainValidationError( "Les montants doivent être strictement opposés pour former " "un virement interne." ) if first.account_id == second.account_id: raise DomainValidationError( "Les deux jambes d'un virement doivent appartenir à des comptes différents." ) if first.currency != second.currency: raise DomainValidationError("Les deux jambes doivent être dans la même devise.") group_id = uuid.uuid4() transfer_cat = get_transfer_category_id(db, user_id) for leg in legs: leg.transfer_group_id = group_id if leg.category_source != CategorySource.USER: leg.category_id = transfer_cat leg.category_source = CategorySource.RULE db.commit() return group_id def unlink_transfer(db: Session, user_id: int, group_id: uuid.UUID) -> None: legs = list( db.scalars( select(FinTransaction).where( FinTransaction.user_id == user_id, FinTransaction.transfer_group_id == group_id, ) ).all() ) if not legs: raise NotFoundError("Virement introuvable.") transfer_cat = get_transfer_category_id(db, user_id) for leg in legs: leg.transfer_group_id = None if ( leg.category_id == transfer_cat and leg.category_source == CategorySource.RULE ): leg.category_id = None leg.category_source = None db.commit() # --------------------------------------------------------------------------- # Recurring series (§7) # --------------------------------------------------------------------------- @dataclass class RecurringSeries: merchant_key: str label_display: str category_id: uuid.UUID | None category_name: str | None periodicity: str occurrences: int average_amount: Decimal expected_amount: Decimal last_date: date next_date_predicted: date is_active: bool def _most_common(values: list[str]) -> str: counter = Counter(v for v in values if v) return counter.most_common(1)[0][0] if counter else "" def detect_recurring( db: Session, user_id: int, *, direction: str = "debit", lookback_months: int = 18, include_inactive: bool = False, today: date | None = None, ) -> list[RecurringSeries]: """On-the-fly recurring detection (§7.2) — no dedicated table in v1.""" today = today or utcnow().date() since = today - timedelta(days=int(lookback_months * 30.44)) stmt = select(FinTransaction).where( FinTransaction.user_id == user_id, FinTransaction.booked_date >= since, FinTransaction.transfer_group_id.is_(None), ) stmt = stmt.where( FinTransaction.amount < 0 if direction == "debit" else FinTransaction.amount > 0 ) txs = list(db.scalars(stmt).all()) if not txs: return [] category_names = dict( db.execute( select(FinCategory.id, FinCategory.name).where( FinCategory.user_id == user_id ) ).all() ) transfer_cat = get_transfer_category_id(db, user_id) groups: dict[str, list[FinTransaction]] = defaultdict(list) for tx in txs: if transfer_cat is not None and tx.category_id == transfer_cat: continue groups[merchant_key(tx.label_raw, tx.counterparty)].append(tx) series: list[RecurringSeries] = [] for key, items in groups.items(): if not key or len(items) < MIN_OCCURRENCES: continue dates = sorted({t.booked_date for t in items}) if len(dates) < MIN_OCCURRENCES: continue intervals = [(b - a).days for a, b in pairwise(dates)] med_int = median(intervals) period = next( (p for p in PERIODICITIES if p[1] <= med_int <= p[2]), None, ) if period is None: continue regular = sum(1 for i in intervals if period[1] <= i <= period[2]) if regular / len(intervals) < REGULARITY_RATIO: continue amounts = [abs(t.amount) for t in items] med_amt = Decimal(str(median(amounts))) mad = Decimal(str(median([abs(a - med_amt) for a in amounts]))) if mad > max(Decimal("1.00"), med_amt * Decimal("0.10")): continue cat_counter = Counter(t.category_id for t in items if t.category_id) category_id = cat_counter.most_common(1)[0][0] if cat_counter else None average = (sum(amounts) / Decimal(len(amounts))).quantize(Decimal("0.01")) is_active = (today - dates[-1]).days <= period[2] * 2 if not is_active and not include_inactive: continue series.append( RecurringSeries( merchant_key=key, label_display=_most_common([t.label_clean for t in items]) or key, category_id=category_id, category_name=category_names.get(category_id), periodicity=period[0], occurrences=len(dates), average_amount=average, expected_amount=med_amt.quantize(Decimal("0.01")), last_date=dates[-1], next_date_predicted=dates[-1] + timedelta(days=round(median(intervals))), is_active=is_active, ) ) series.sort(key=lambda s: (not s.is_active, s.next_date_predicted)) return series def monthly_total_estimate(series: list[RecurringSeries]) -> Decimal: total = sum( ( s.expected_amount * MONTHLY_FACTOR.get(s.periodicity, Decimal(1)) for s in series if s.is_active ), Decimal(0), ) return Decimal(total).quantize(Decimal("0.01"))