/** * Erkennung wiederkehrender Ausgaben aus den Buchungen (rein, ohne I/O): Abbuchungen derselben Gegenpartei * in regelmaessigem Abstand und mit nahezu gleichem Betrag, die noch nicht als feste Kosten erfasst sind. * Die Oberflaeche schlaegt sie dann als neue Fixkostenposition vor. */ import { daysBetween } from './dates.js' const MIN_COUNT = 3 // Abstand (Tage, Median) -> Rhythmus const INTERVALS = [ { interval: 'monat', min: 25, max: 35 }, { interval: 'quartal', min: 80, max: 100 }, { interval: 'jahr', min: 350, max: 380 }, ] // Variationskoeffizient (Standardabweichung / Mittelwert) der Betraege const MAX_AMOUNT_VARIATION = 0.15 /** Gegenpartei als Gruppenschluessel: klein, ohne Ziffern und Satzzeichen, Leerraum zusammengefasst. */ function counterpartyKey(name) { return String(name ?? '') .toLowerCase() .replace(/[^\p{L}\s]/gu, '') .replace(/\s+/g, ' ') .trim() } function median(numbers) { const sorted = [...numbers].sort((a, b) => a - b) const mid = sorted.length >> 1 return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2 } /** Variationskoeffizient (Grundgesamtheit); Infinity, wenn der Mittelwert 0 ist. */ function variation(numbers) { const mean = numbers.reduce((sum, n) => sum + n, 0) / numbers.length if (!mean) return Infinity const variance = numbers.reduce((sum, n) => sum + (n - mean) ** 2, 0) / numbers.length return Math.sqrt(variance) / Math.abs(mean) } /** true, wenn das Datum ein echter Kalendertag YYYY-MM-DD ist (daysBetween wirft sonst). */ function validDay(ymd) { try { daysBetween(ymd, ymd) return true } catch { return false } } /** * Wiederkehrende Abbuchungen: [{ key, name, amountCents, interval: 'monat'|'quartal'|'jahr', lastDate, count }], * groesster Betrag zuerst. name ist die Schreibweise der juengsten Buchung, amountCents der Median als positiver Wert. */ export function detectRecurring(transactions, { fixedCosts = [] } = {}) { const matches = (fixedCosts || []) .filter((fixedCost) => fixedCost.active) .map((fixedCost) => String(fixedCost.counterpartyMatch ?? '').trim().toLowerCase()) .filter(Boolean) const groups = new Map() for (const tx of transactions || []) { if (!(tx.amountCents < 0) || tx.status !== 'BOOK' || !validDay(tx.bookingDate)) continue const key = counterpartyKey(tx.counterpartyName) if (!key) continue if (!groups.has(key)) groups.set(key, []) groups.get(key).push(tx) } const result = [] for (const [key, group] of groups) { if (group.length < MIN_COUNT) continue group.sort((a, b) => (a.bookingDate < b.bookingDate ? -1 : a.bookingDate > b.bookingDate ? 1 : 0)) const last = group[group.length - 1] const name = String(last.counterpartyName).trim() // Schon als feste Kosten erfasst (Treffer gegen den bereinigten Schluessel und den Originalnamen, denn // ein counterpartyMatch darf Ziffern enthalten) const lowerName = name.toLowerCase() if (matches.some((match) => key.includes(match) || lowerName.includes(match))) continue const gaps = [] for (let i = 1; i < group.length; i++) gaps.push(daysBetween(group[i - 1].bookingDate, group[i].bookingDate)) const gap = median(gaps) const rhythm = INTERVALS.find(({ min, max }) => gap >= min && gap <= max) if (!rhythm) continue const amounts = group.map((tx) => -tx.amountCents) if (variation(amounts) > MAX_AMOUNT_VARIATION) continue result.push({ key, name, amountCents: Math.round(median(amounts)), interval: rhythm.interval, lastDate: last.bookingDate, count: group.length, }) } return result.sort((a, b) => b.amountCents - a.amountCents || (a.key < b.key ? -1 : 1)) }