101 lines
3.7 KiB
JavaScript
101 lines
3.7 KiB
JavaScript
/**
|
|
* 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))
|
|
}
|