"""Label/amount normalisation and dedup hashing (datamodel-finance.md §4.3, §7.1).""" import hashlib import re import unicodedata from datetime import date, timedelta from decimal import ROUND_HALF_UP, Decimal, InvalidOperation from uuid import UUID TWO_PLACES = Decimal("0.01") # Purely technical bank prefixes stripped from label_clean (§4.3). _TECH_PREFIX_RE = re.compile( r"^(CARTE \d{2}/\d{2}(/\d{2,4})? |PAIEMENT (PSC |CB )?\d{4} |PRLV SEPA " r"|VIR SEPA |VIR INST |ACHAT CB )", re.IGNORECASE, ) # Spaces found in French bank exports: NBSP, narrow NBSP, thin space. _SPACES = "   " def normalize_label_for_hash(label: str) -> str: """STABLE and CONSERVATIVE normalisation used by the dedup hash. Uppercase + accents stripped + whitespace collapsed, nothing else — any more aggressive cleaning would merge distinct transactions. """ s = unicodedata.normalize("NFKD", label) s = "".join(c for c in s if not unicodedata.combining(c)) s = s.upper() return re.sub(r"\s+", " ", s).strip() def light_clean(label_raw: str) -> str: """Initial label_clean: trim, collapse whitespace, drop technical prefixes.""" s = re.sub(r"\s+", " ", label_raw).strip() s = _TECH_PREFIX_RE.sub("", s).strip() return s or label_raw.strip() def compute_dedup_hash( account_id: UUID, booked_date: date, amount: Decimal, label_raw: str, occurrence: int, ) -> str: canonical = "|".join( [ str(account_id), booked_date.isoformat(), f"{amount:.2f}", # sign included normalize_label_for_hash(label_raw), str(occurrence), # rank among identical rows of the SAME file ] ) return hashlib.sha256(canonical.encode("utf-8")).hexdigest() def occurrence_key(booked_date: date, amount: Decimal, label_raw: str) -> tuple: return (booked_date, f"{amount:.2f}", normalize_label_for_hash(label_raw)) def quantize2(value: Decimal) -> Decimal: return value.quantize(TWO_PLACES, rounding=ROUND_HALF_UP) def parse_amount(raw: str, decimal_separator: str = ",") -> Decimal: """Parse a French/English bank amount string into a signed Decimal. Covers: "-6,4", "+3500,00", "-123 456,78", "1.234,56", "12,50 €", "−12,50" (U+2212), "226.68". """ s = raw.strip() if not s: raise ValueError("montant vide") s = s.replace("−", "-") # unicode minus for sp in _SPACES: s = s.replace(sp, "") s = s.replace(" ", "") s = re.sub(r"(?i)(€|EUR)", "", s).strip() if decimal_separator == ",": # Remove dot/space thousands separators, then turn the comma into a dot. if "," in s: s = s.replace(".", "").replace(",", ".") # else: already dot-decimal (BoursoBank mixes both in one file) else: s = s.replace(",", "") try: return Decimal(s) except InvalidOperation as exc: raise ValueError(f"montant illisible : {raw!r}") from exc _MERCHANT_NOISE_WORDS = re.compile( r"\b(CB|CARTE|PRLV|SEPA|VIR|ECH|PAIEMENT|ACHAT|WEB|FACT(URE)?)\b" ) def merchant_key(label_raw: str, counterparty: str | None = None) -> str: """Aggressive grouping key for recurring/merchant detection (§7.1).""" if counterparty: return normalize_label_for_hash(counterparty)[:60] s = normalize_label_for_hash(label_raw) s = re.sub(r"\b\d{2}/\d{2}(/\d{2,4})?\b", "", s) # dates inside the label s = re.sub(r"\b\d{4,}\b", "", s) # card/reference numbers s = _MERCHANT_NOISE_WORDS.sub("", s) s = re.sub(r"[^A-Z0-9 ]", " ", s) s = re.sub(r"\s+", " ", s).strip() return s[:60] def month_str(d: date) -> str: return f"{d.year:04d}-{d.month:02d}" def month_first_day(year: int, month: int) -> date: return date(year, month, 1) def add_months(d: date, months: int) -> date: """First day of the month `months` after (or before) d's month.""" total = d.year * 12 + (d.month - 1) + months return date(total // 12, total % 12 + 1, 1) def month_last_day(d: date) -> date: return add_months(d, 1) - timedelta(days=1) def parse_month(value: str) -> date: m = re.fullmatch(r"(\d{4})-(\d{2})", value.strip()) if not m: raise ValueError(f"mois invalide : {value!r}") year, month = int(m.group(1)), int(m.group(2)) if not 1 <= month <= 12: raise ValueError(f"mois invalide : {value!r}") return date(year, month, 1)