diff --git a/server/services/finance/matching.js b/server/services/finance/matching.js new file mode 100644 index 0000000..627a854 --- /dev/null +++ b/server/services/finance/matching.js @@ -0,0 +1,106 @@ +/** + * Zuordnungsvorschlaege fuer Bankeingaenge (rein, ohne I/O): Zu einer Gutschrift auf dem Konto werden + * offene Stripe-Rechnungen oder Stripe-Auszahlungen vorgeschlagen. Die Buchung wird nie automatisch + * zugeordnet; die Vorschlaege sind nur Hilfe fuer die Auswahl in der Oberflaeche. + * + * Regeln in dieser Reihenfolge, je Rechnung/Auszahlung gilt der staerkste Treffer: + * 1. Stripe-Auszahlung (hoch): "stripe" in Gegenpartei oder Verwendungszweck, gleicher Betrag, Buchung + * hoechstens 3 Tage von der Ankunft entfernt. + * 2. Rechnungsnummer (hoch / mittel): Nummer im Verwendungszweck; Betrag = offener Betrag -> hoch, sonst mittel. + * 3. Betrag und Name (mittel): Betrag = offener Betrag und ein Namenswort der Rechnung steht im Zahlernamen. + * 4. Nur Betrag (niedrig): Betrag = offener Betrag genau einer offenen Rechnung. + */ +import { daysBetween } from './dates.js' + +const MAX_SUGGESTIONS = 3 +const MAX_PAYOUT_DAYS = 3 +// Kuerzere Rechnungsnummern ("R1") stecken zufaellig in fast jedem Verwendungszweck +const MIN_NUMBER_LENGTH = 4 +const MIN_TOKEN_LENGTH = 4 +const OPEN_STATUS = new Set(['offen', 'ueberfaellig']) +const NAME_STOPWORDS = new Set(['gmbh', 'gbr', 'ohg', 'mbh', 'und', 'der', 'die', 'das', 'kg', 'ag', 'ug', 'haftungsbeschränkt']) +const RANK = { hoch: 0, mittel: 1, niedrig: 2 } + +/** Grossbuchstaben, ohne Leerraum, Bindestrich, Unterstrich, Punkt und Schraegstrich: "wk-0007" -> "WK0007". */ +export function normalizeRef(text) { + return String(text ?? '') + .toUpperCase() + .replace(/[\s\-_./]+/g, '') +} + +/** Namenswoerter zum Vergleich: klein, an Nicht-Buchstaben getrennt (Umlaute bleiben), mindestens 4 Zeichen, ohne Rechtsformen. */ +export function nameTokens(name) { + return String(name ?? '') + .toLowerCase() + .split(/[^\p{L}]+/u) + .filter((token) => token.length >= MIN_TOKEN_LENGTH && !NAME_STOPWORDS.has(token)) +} + +/** Abstand in Tagen; bei einem kaputten Datum Infinity, damit nichts zugeordnet wird (statt einen Fehler zu werfen). */ +function dayDistance(fromYmd, toYmd) { + try { + return Math.abs(daysBetween(fromYmd, toYmd)) + } catch { + return Infinity + } +} + +/** + * Bis zu 3 Vorschlaege fuer eine noch nicht zugeordnete Gutschrift, hoch -> niedrig: + * [{ kind: 'invoice' | 'payout', id, number, confidence, reason, amountDiffers }] + */ +export function suggestMatches(tx, { invoices = [], payouts = [] } = {}) { + if (!(tx.amountCents > 0) || tx.category) return [] + const suggestions = [] + const amount = tx.amountCents + const payerName = String(tx.counterpartyName ?? '').toLowerCase() + const remittance = normalizeRef(tx.remittance) + + // Regel 1: Stripe-Auszahlung + if (`${tx.counterpartyName ?? ''} ${tx.remittance ?? ''}`.toLowerCase().includes('stripe')) { + for (const payout of payouts || []) { + if (payout.amountCents === amount && dayDistance(payout.arrivalDate, tx.bookingDate) <= MAX_PAYOUT_DAYS) { + suggestions.push({ + kind: 'payout', + id: payout.id, + number: '', + confidence: 'hoch', + reason: 'Stripe-Auszahlung gleicher Betrag', + amountDiffers: false, + }) + } + } + } + + const open = (invoices || []).filter((invoice) => OPEN_STATUS.has(invoice.status)) + const taken = new Set() + const suggestInvoice = (invoice, confidence, reason, amountDiffers) => { + taken.add(invoice.id) + suggestions.push({ kind: 'invoice', id: invoice.id, number: invoice.number || '', confidence, reason, amountDiffers }) + } + + // Regel 2: Rechnungsnummer im Verwendungszweck + for (const invoice of open) { + const number = normalizeRef(invoice.number) + if (number.length < MIN_NUMBER_LENGTH || !remittance.includes(number)) continue + if (invoice.remainingCents === amount) suggestInvoice(invoice, 'hoch', 'Rechnungsnummer im Verwendungszweck', false) + else suggestInvoice(invoice, 'mittel', 'Rechnungsnummer im Verwendungszweck, Betrag weicht ab', true) + } + + // Regel 3: Betrag und Name + for (const invoice of open) { + if (taken.has(invoice.id) || invoice.remainingCents !== amount) continue + if (nameTokens(invoice.customerName).some((token) => payerName.includes(token))) { + suggestInvoice(invoice, 'mittel', 'Betrag und Name passen', false) + } + } + + // Regel 4: nur der Betrag, aber nur wenn er zu genau einer offenen Rechnung passt + const sameAmount = open.filter((invoice) => invoice.remainingCents === amount) + if (sameAmount.length === 1 && !taken.has(sameAmount[0].id)) { + suggestInvoice(sameAmount[0], 'niedrig', 'Nur der Betrag passt', false) + } + + // Stabil sortiert: innerhalb einer Stufe bleibt die Reihenfolge der Regeln und der Eingabe erhalten + return suggestions.sort((a, b) => RANK[a.confidence] - RANK[b.confidence]).slice(0, MAX_SUGGESTIONS) +} diff --git a/server/services/finance/recurring.js b/server/services/finance/recurring.js new file mode 100644 index 0000000..44e5e75 --- /dev/null +++ b/server/services/finance/recurring.js @@ -0,0 +1,100 @@ +/** + * 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)) +} diff --git a/test/financeMatching.test.js b/test/financeMatching.test.js new file mode 100644 index 0000000..10d1835 --- /dev/null +++ b/test/financeMatching.test.js @@ -0,0 +1,297 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { normalizeRef, nameTokens, suggestMatches } from '../server/services/finance/matching.js' + +const tx = (overrides = {}) => ({ + $id: 'tx1', + status: 'BOOK', + bookingDate: '2026-09-30', + amountCents: 16879, + counterpartyName: 'EWV SOFTWARE GMBH', + counterpartyIban: 'DE02120300000000202051', + remittance: 'Rechnung WK-0007 vom 15.09.2026', + mcc: '', + category: '', + ...overrides, +}) + +const invoice = (overrides = {}) => ({ + id: 'in_1', + number: 'WK-0007', + status: 'offen', + customerName: 'EWV Software GmbH', + remainingCents: 16879, + ...overrides, +}) + +const payout = (overrides = {}) => ({ id: 'po_1', amountCents: 120000, arrivalDate: '2026-09-28', status: 'paid', ...overrides }) + +test('normalizeRef: Grossbuchstaben ohne Leerraum, Bindestrich, Unterstrich, Punkt und Schraegstrich', () => { + assert.equal(normalizeRef('wk-0007'), 'WK0007') + assert.equal(normalizeRef(' Re 2026/0042_a.b '), 'RE20260042AB') + assert.equal(normalizeRef('WK 00\n07'), 'WK0007') + assert.equal(normalizeRef(''), '') + assert.equal(normalizeRef(null), '') + assert.equal(normalizeRef(undefined), '') +}) + +test('nameTokens: klein, an Nicht-Buchstaben getrennt, Umlaute bleiben, mindestens 4 Zeichen, ohne Rechtsformen', () => { + assert.deepEqual(nameTokens('EWV Software GmbH'), ['software']) + assert.deepEqual(nameTokens('Döner Nation'), ['döner', 'nation']) + assert.deepEqual(nameTokens('Müller & Söhne e.K.'), ['müller', 'söhne']) + assert.deepEqual(nameTokens('Meier-Bau GbR'), ['meier']) + assert.deepEqual(nameTokens('Haftungsbeschränkt Fenster und Türen'), ['fenster', 'türen']) + assert.deepEqual(nameTokens('Webklar24 Studio UG'), ['webklar', 'studio']) + assert.deepEqual(nameTokens('Die Der Das'), []) + assert.deepEqual(nameTokens(''), []) + assert.deepEqual(nameTokens(null), []) +}) + +test('Regel 2: Rechnungsnummer im Verwendungszweck und Betrag passt -> hoch', () => { + const result = suggestMatches(tx(), { invoices: [invoice()], payouts: [] }) + assert.equal(result.length, 1) + assert.deepEqual(result[0], { + kind: 'invoice', + id: 'in_1', + number: 'WK-0007', + confidence: 'hoch', + reason: 'Rechnungsnummer im Verwendungszweck', + amountDiffers: false, + }) +}) + +test('Regel 2: Rechnungsnummer passt, Betrag weicht ab -> mittel mit Hinweis', () => { + const result = suggestMatches(tx(), { invoices: [invoice({ remainingCents: 20000 })], payouts: [] }) + assert.equal(result.length, 1) + assert.equal(result[0].confidence, 'mittel') + assert.equal(result[0].amountDiffers, true) + assert.match(result[0].reason, /Rechnungsnummer im Verwendungszweck/) + assert.match(result[0].reason, /Betrag weicht ab/) +}) + +test('Regel 2: Nummer wird normalisiert verglichen (Bindestrich, Leerraum, Gross-/Kleinschreibung)', () => { + for (const remittance of ['wk0007', 'WK 0007', 'Zahlung wk-0007 danke', 'WK_0007', 'WK/0007', 'WK.0007']) { + const result = suggestMatches(tx({ remittance }), { invoices: [invoice()], payouts: [] }) + assert.equal(result.length, 1, remittance) + assert.equal(result[0].confidence, 'hoch', remittance) + } +}) + +test('Regel 2: sehr kurze Rechnungsnummern (normalisiert < 4 Zeichen) und Entwuerfe ohne Nummer werden ignoriert', () => { + // "R-1" -> "R1" steckt in fast jedem Verwendungszweck; ohne Namensbezug zaehlt dann nur der Betrag (niedrig) + const kurz = suggestMatches(tx({ remittance: 'R1 R-1 Zahlung', counterpartyName: 'Fremd AG' }), { + invoices: [invoice({ number: 'R-1' })], + payouts: [], + }) + assert.equal(kurz.length, 1) + assert.equal(kurz[0].confidence, 'niedrig') + // Entwurf mit leerer Nummer: '' steckt in jedem Text, darf aber nie als Nummer treffen + const leer = suggestMatches(tx({ counterpartyName: 'Fremd AG', remittance: 'irgendwas' }), { + invoices: [invoice({ number: '', remainingCents: 5 })], + payouts: [], + }) + assert.deepEqual(leer, []) +}) + +test('nur offene und ueberfaellige Rechnungen kommen in Frage', () => { + for (const status of ['entwurf', 'bezahlt', 'storniert', 'uneinbringlich']) { + assert.deepEqual(suggestMatches(tx(), { invoices: [invoice({ status })], payouts: [] }), [], status) + } + const ueberfaellig = suggestMatches(tx(), { invoices: [invoice({ status: 'ueberfaellig' })], payouts: [] }) + assert.equal(ueberfaellig.length, 1) + assert.equal(ueberfaellig[0].confidence, 'hoch') +}) + +test('Regel 3: Betrag passt, Name passt, keine Nummer -> mittel', () => { + const result = suggestMatches(tx({ remittance: 'Zahlung Projekt Website' }), { + invoices: [invoice()], + payouts: [], + }) + assert.equal(result.length, 1) + assert.equal(result[0].confidence, 'mittel') + assert.equal(result[0].reason, 'Betrag und Name passen') + assert.equal(result[0].amountDiffers, false) +}) + +test('Regel 3: Namenswort muss im Zahlernamen vorkommen, Rechtsformen zaehlen nicht', () => { + // Kunde "Beispiel GmbH" und Zahler "Andere GmbH": nur "gmbh" gemeinsam -> kein Namenstreffer, nur Betrag + const result = suggestMatches(tx({ remittance: 'Zahlung', counterpartyName: 'Andere GmbH' }), { + invoices: [invoice({ customerName: 'Beispiel GmbH' })], + payouts: [], + }) + assert.equal(result.length, 1) + assert.equal(result[0].confidence, 'niedrig') +}) + +test('Regel 3: Name passt, aber der Betrag nicht -> kein Vorschlag', () => { + const result = suggestMatches(tx({ remittance: 'Zahlung' }), { + invoices: [invoice({ remainingCents: 20000 })], + payouts: [], + }) + assert.deepEqual(result, []) +}) + +test('Regel 4: nur Betrag, genau eine offene Rechnung -> niedrig', () => { + const result = suggestMatches(tx({ remittance: 'Zahlung', counterpartyName: 'Max Mustermann' }), { + invoices: [invoice({ customerName: 'Döner Nation' })], + payouts: [], + }) + assert.equal(result.length, 1) + assert.equal(result[0].confidence, 'niedrig') + assert.equal(result[0].reason, 'Nur der Betrag passt') + assert.equal(result[0].amountDiffers, false) + assert.equal(result[0].number, 'WK-0007') +}) + +test('Regel 4: zwei offene Rechnungen gleichen Betrags ohne Namensbezug -> kein niedrig-Vorschlag', () => { + const result = suggestMatches(tx({ remittance: 'Zahlung', counterpartyName: 'Max Mustermann' }), { + invoices: [ + invoice({ id: 'in_1', number: 'WK-0007', customerName: 'Döner Nation' }), + invoice({ id: 'in_2', number: 'WK-0008', customerName: 'Pizza Palast' }), + ], + payouts: [], + }) + assert.deepEqual(result, []) +}) + +test('Regel 3 trifft auch bei gleichem Betrag mehrerer Rechnungen den passenden Namen, die andere bleibt ohne Vorschlag', () => { + const result = suggestMatches(tx({ remittance: 'Zahlung' }), { + invoices: [ + invoice({ id: 'in_1', number: 'WK-0007', customerName: 'Döner Nation' }), + invoice({ id: 'in_2', number: 'WK-0008', customerName: 'EWV Software GmbH' }), + ], + payouts: [], + }) + assert.equal(result.length, 1) + assert.equal(result[0].id, 'in_2') + assert.equal(result[0].confidence, 'mittel') +}) + +test('je Rechnung hoechstens ein Vorschlag, der staerkste (Nummer vor Name vor Betrag)', () => { + // Nummer UND Name UND Betrag passen: nur der hoch-Vorschlag + const result = suggestMatches(tx(), { invoices: [invoice()], payouts: [] }) + assert.equal(result.length, 1) + assert.equal(result[0].confidence, 'hoch') + // Nummer passt mit abweichendem Betrag UND Name passt: mittel wegen Nummer (kein zweiter Eintrag) + const abw = suggestMatches(tx(), { invoices: [invoice({ remainingCents: 1 })], payouts: [] }) + assert.equal(abw.length, 1) + assert.equal(abw[0].reason.includes('Betrag weicht ab'), true) +}) + +test('Sortierung hoch -> mittel -> niedrig und hoechstens 3 Vorschlaege', () => { + const invoices = [ + invoice({ id: 'in_n1', number: 'XX-0001', customerName: 'Fremd', remainingCents: 16879 }), + invoice({ id: 'in_m1', number: 'XX-0002', customerName: 'EWV Software GmbH', remainingCents: 16879 }), + invoice({ id: 'in_h', number: 'WK-0007', customerName: 'Fremd', remainingCents: 16879 }), + invoice({ id: 'in_m2', number: 'XX-0003', customerName: 'Software Haus', remainingCents: 16879 }), + invoice({ id: 'in_m3', number: 'XX-0004', customerName: 'Software Kontor', remainingCents: 16879 }), + ] + const result = suggestMatches(tx(), { invoices, payouts: [] }) + assert.equal(result.length, 3) + assert.deepEqual( + result.map((s) => s.confidence), + ['hoch', 'mittel', 'mittel'] + ) + assert.equal(result[0].id, 'in_h') + // Rangfolge innerhalb einer Stufe: Reihenfolge der Eingabe + assert.deepEqual( + result.slice(1).map((s) => s.id), + ['in_m1', 'in_m2'] + ) +}) + +test('Sortierung: Rechnungsnummer mit abweichendem Betrag (mittel) steht vor dem reinen Betragstreffer (niedrig), auch bei umgekehrter Eingabe', () => { + const result = suggestMatches(tx({ counterpartyName: 'Max Mustermann' }), { + invoices: [ + invoice({ id: 'in_low', number: 'XX-0001', customerName: 'Fremd', remainingCents: 16879 }), + invoice({ id: 'in_mid', number: 'WK-0007', customerName: 'Fremd', remainingCents: 20000 }), + ], + payouts: [], + }) + assert.deepEqual( + result.map((s) => [s.id, s.confidence]), + [ + ['in_mid', 'mittel'], + ['in_low', 'niedrig'], + ] + ) +}) + +test('Auszahlung (hoch) steht vor Rechnungsvorschlaegen', () => { + const result = suggestMatches( + tx({ amountCents: 120000, counterpartyName: 'Stripe Technologies Europe Ltd', remittance: '', bookingDate: '2026-09-29' }), + { invoices: [invoice({ remainingCents: 120000, customerName: 'Fremd' })], payouts: [payout()] } + ) + assert.deepEqual( + result.map((s) => [s.kind, s.confidence]), + [ + ['payout', 'hoch'], + ['invoice', 'niedrig'], + ] + ) +}) + +test('Regel 1: Stripe-Auszahlung gleicher Betrag, 2 Tage nach Ankunft -> payout, hoch', () => { + const result = suggestMatches( + tx({ amountCents: 120000, counterpartyName: 'Stripe Technologies Europe Ltd', remittance: 'STRIPE PAYOUT', bookingDate: '2026-09-30' }), + { invoices: [], payouts: [payout()] } + ) + assert.equal(result.length, 1) + assert.deepEqual(result[0], { + kind: 'payout', + id: 'po_1', + number: '', + confidence: 'hoch', + reason: 'Stripe-Auszahlung gleicher Betrag', + amountDiffers: false, + }) +}) + +test('Regel 1: 5 Tage Abstand -> kein Vorschlag, 3 Tage (vor und nach Ankunft) -> Vorschlag', () => { + const base = { amountCents: 120000, counterpartyName: 'Stripe Technologies Europe Ltd', remittance: '' } + assert.deepEqual(suggestMatches(tx({ ...base, bookingDate: '2026-10-03' }), { invoices: [], payouts: [payout()] }), []) + assert.equal(suggestMatches(tx({ ...base, bookingDate: '2026-10-01' }), { invoices: [], payouts: [payout()] }).length, 1) + assert.equal(suggestMatches(tx({ ...base, bookingDate: '2026-09-25' }), { invoices: [], payouts: [payout()] }).length, 1) + assert.deepEqual(suggestMatches(tx({ ...base, bookingDate: '2026-09-24' }), { invoices: [], payouts: [payout()] }), []) +}) + +test('Regel 1: "stripe" im Verwendungszweck genuegt, Gross-/Kleinschreibung egal; ohne "stripe" kein Auszahlungsvorschlag', () => { + const base = { amountCents: 120000, bookingDate: '2026-09-28' } + const imZweck = suggestMatches(tx({ ...base, counterpartyName: 'Irgendeine Bank', remittance: 'payout via StRiPe' }), { + invoices: [], + payouts: [payout()], + }) + assert.equal(imZweck.length, 1) + assert.equal(imZweck[0].kind, 'payout') + const ohne = suggestMatches(tx({ ...base, counterpartyName: 'Irgendeine Bank', remittance: 'Auszahlung' }), { + invoices: [], + payouts: [payout()], + }) + assert.deepEqual(ohne, []) +}) + +test('Regel 1: Betrag muss exakt der Auszahlung entsprechen', () => { + const result = suggestMatches( + tx({ amountCents: 119999, counterpartyName: 'Stripe Technologies Europe Ltd', remittance: '', bookingDate: '2026-09-28' }), + { invoices: [], payouts: [payout()] } + ) + assert.deepEqual(result, []) +}) + +test('Ausgang (amountCents < 0), Betrag 0 oder schon zugeordnet -> []', () => { + const ctx = { invoices: [invoice()], payouts: [payout({ amountCents: 16879 })] } + assert.deepEqual(suggestMatches(tx({ amountCents: -16879 }), ctx), []) + assert.deepEqual(suggestMatches(tx({ amountCents: 0 }), ctx), []) + assert.deepEqual(suggestMatches(tx({ category: 'einnahme_rechnung' }), ctx), []) +}) + +test('fehlende Felder und fehlende Listen werfen nicht', () => { + assert.deepEqual(suggestMatches(tx({ counterpartyName: undefined, remittance: undefined }), {}), []) + assert.deepEqual(suggestMatches(tx({ counterpartyName: null, remittance: null }), { invoices: undefined, payouts: undefined }), []) + // kaputtes Datum einer Auszahlung -> kein Treffer, kein Fehler + const result = suggestMatches( + tx({ amountCents: 120000, counterpartyName: 'Stripe', remittance: '' }), + { invoices: [], payouts: [payout({ arrivalDate: '' })] } + ) + assert.deepEqual(result, []) +}) diff --git a/test/financeRecurring.test.js b/test/financeRecurring.test.js new file mode 100644 index 0000000..1a57411 --- /dev/null +++ b/test/financeRecurring.test.js @@ -0,0 +1,155 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { detectRecurring } from '../server/services/finance/recurring.js' + +let counter = 0 +const out = (counterpartyName, bookingDate, cents, overrides = {}) => ({ + $id: `tx${++counter}`, + status: 'BOOK', + bookingDate, + amountCents: -cents, + counterpartyName, + counterpartyIban: '', + remittance: '', + mcc: '', + category: '', + ...overrides, +}) + +test('drei PORKBUN-Buchungen binnen weniger Tage sind keine wiederkehrende Ausgabe', () => { + const txs = [out('PORKBUN LLC', '2026-09-27', 449), out('PORKBUN LLC', '2026-09-29', 449), out('PORKBUN LLC', '2026-09-29', 449)] + assert.deepEqual(detectRecurring(txs), []) +}) + +test('OPENROUTER monatlich 43,58 EUR -> monat', () => { + const txs = [out('OPENROUTER INC', '2026-06-16', 4358), out('OPENROUTER INC', '2026-07-16', 4358), out('OPENROUTER INC', '2026-08-16', 4358)] + assert.deepEqual(detectRecurring(txs), [ + { key: 'openrouter inc', name: 'OPENROUTER INC', amountCents: 4358, interval: 'monat', lastDate: '2026-08-16', count: 3 }, + ]) +}) + +test('stark schwankende Betraege (1000/2000/3000) sind keine wiederkehrende Ausgabe', () => { + const txs = [out('Strom AG', '2026-06-16', 1000), out('Strom AG', '2026-07-16', 2000), out('Strom AG', '2026-08-16', 3000)] + assert.deepEqual(detectRecurring(txs), []) +}) + +test('aktive Fixkostenposition mit passendem counterpartyMatch blendet die Gruppe aus', () => { + const txs = [out('OPENROUTER INC', '2026-06-16', 4358), out('OPENROUTER INC', '2026-07-16', 4358), out('OPENROUTER INC', '2026-08-16', 4358)] + const fixedCosts = [{ $id: 'fc1', name: 'OpenRouter', amountCents: 4358, interval: 'monat', nextDate: '2026-09-16', counterpartyMatch: 'openrouter', active: true }] + assert.deepEqual(detectRecurring(txs, { fixedCosts }), []) +}) + +test('inaktive Fixkosten, fremde Treffer und leerer counterpartyMatch blenden nichts aus', () => { + const txs = [out('OPENROUTER INC', '2026-06-16', 4358), out('OPENROUTER INC', '2026-07-16', 4358), out('OPENROUTER INC', '2026-08-16', 4358)] + const base = { $id: 'fc1', name: 'X', amountCents: 1, interval: 'monat', nextDate: '2026-09-16' } + assert.equal(detectRecurring(txs, { fixedCosts: [{ ...base, counterpartyMatch: 'openrouter', active: false }] }).length, 1) + assert.equal(detectRecurring(txs, { fixedCosts: [{ ...base, counterpartyMatch: 'hetzner', active: true }] }).length, 1) + assert.equal(detectRecurring(txs, { fixedCosts: [{ ...base, counterpartyMatch: '', active: true }] }).length, 1) + assert.equal(detectRecurring(txs, { fixedCosts: [{ ...base, active: true }] }).length, 1) +}) + +test('Gruppierung ueber normalisierte Gegenpartei (Gross-/Kleinschreibung, Ziffern, Satzzeichen, Leerraum)', () => { + const txs = [ + out('Hetzner Online GmbH', '2026-06-03', 2599), + out('HETZNER ONLINE GMBH 123', '2026-07-03', 2599), + out('hetzner online gmbh.', '2026-08-03', 2599), + ] + const result = detectRecurring(txs) + assert.equal(result.length, 1) + assert.equal(result[0].key, 'hetzner online gmbh') + assert.equal(result[0].count, 3) + assert.equal(result[0].interval, 'monat') + // Name der juengsten Buchung + assert.equal(result[0].name, 'hetzner online gmbh.') +}) + +test('Quartal (80-100 Tage) und Jahr (350-380 Tage)', () => { + const quartal = [out('Versicherung AG', '2026-01-01', 15000), out('Versicherung AG', '2026-04-01', 15000), out('Versicherung AG', '2026-07-01', 15000)] + const jahr = [out('Domain Shop', '2024-03-10', 1999), out('Domain Shop', '2025-03-10', 1999), out('Domain Shop', '2026-03-10', 1999)] + const result = detectRecurring([...quartal, ...jahr]) + assert.equal(result.length, 2) + const byKey = Object.fromEntries(result.map((r) => [r.key, r])) + assert.equal(byKey['versicherung ag'].interval, 'quartal') + assert.equal(byKey['domain shop'].interval, 'jahr') + assert.equal(byKey['domain shop'].lastDate, '2026-03-10') +}) + +test('Intervallgrenzen: 25 und 35 Tage zaehlen als monat, 24 und 36 nicht', () => { + const reihe = (name, gap) => { + const start = Date.UTC(2026, 0, 1) + return [0, 1, 2].map((i) => out(name, new Date(start + i * gap * 86_400_000).toISOString().slice(0, 10), 1000)) + } + assert.equal(detectRecurring(reihe('Gap25', 25)).length, 1) + assert.equal(detectRecurring(reihe('Gap35', 35)).length, 1) + assert.deepEqual(detectRecurring(reihe('Gap24', 24)), []) + assert.deepEqual(detectRecurring(reihe('Gap36', 36)), []) + assert.equal(detectRecurring(reihe('Gap80', 80))[0].interval, 'quartal') + assert.equal(detectRecurring(reihe('Gap100', 100))[0].interval, 'quartal') + assert.deepEqual(detectRecurring(reihe('Gap101', 101)), []) + assert.deepEqual(detectRecurring(reihe('Gap60', 60)), []) + assert.equal(detectRecurring(reihe('Gap350', 350))[0].interval, 'jahr') + assert.equal(detectRecurring(reihe('Gap380', 380))[0].interval, 'jahr') + assert.deepEqual(detectRecurring(reihe('Gap381', 381)), []) +}) + +test('Median der Abstaende: ein Ausreisser stoert nicht', () => { + // Abstaende 30, 30, 30, 90 (eine Buchung uebersprungen) -> Median 30 + const txs = [ + out('Cloud Dienst', '2026-01-10', 999), + out('Cloud Dienst', '2026-02-09', 999), + out('Cloud Dienst', '2026-03-11', 999), + out('Cloud Dienst', '2026-04-10', 999), + out('Cloud Dienst', '2026-07-09', 999), + ] + const result = detectRecurring(txs) + assert.equal(result.length, 1) + assert.equal(result[0].interval, 'monat') + assert.equal(result[0].count, 5) +}) + +test('Betrag ist der Median als positiver Wert; Variationskoeffizient bis 0,15 erlaubt', () => { + const txs = [out('Telefon', '2026-06-05', 4000), out('Telefon', '2026-07-05', 4500), out('Telefon', '2026-08-05', 4100)] + const result = detectRecurring(txs) + assert.equal(result.length, 1) + assert.equal(result[0].amountCents, 4100) + assert.ok(result[0].amountCents > 0) + // 4 Betraege, gerade Anzahl: Mittel der beiden mittleren, ganzzahlig gerundet + const gerade = [out('Gerade', '2026-05-05', 4000), out('Gerade', '2026-06-05', 4001), out('Gerade', '2026-07-05', 4100), out('Gerade', '2026-08-05', 4101)] + const g = detectRecurring(gerade) + assert.equal(g.length, 1) + assert.equal(Number.isInteger(g[0].amountCents), true) + assert.equal(g[0].amountCents, 4051) +}) + +test('weniger als 3 Buchungen -> keine Erkennung', () => { + assert.deepEqual(detectRecurring([out('Zwei', '2026-06-16', 1000), out('Zwei', '2026-07-16', 1000)]), []) + assert.deepEqual(detectRecurring([]), []) +}) + +test('Eingaenge, vorgemerkte (PDNG) und Buchungen ohne Gegenpartei zaehlen nicht', () => { + const eingang = [ + out('Kunde', '2026-06-16', 1000, { amountCents: 1000 }), + out('Kunde', '2026-07-16', 1000, { amountCents: 1000 }), + out('Kunde', '2026-08-16', 1000, { amountCents: 1000 }), + ] + assert.deepEqual(detectRecurring(eingang), []) + const vorgemerkt = [out('Pending', '2026-06-16', 1000), out('Pending', '2026-07-16', 1000), out('Pending', '2026-08-16', 1000, { status: 'PDNG' })] + assert.deepEqual(detectRecurring(vorgemerkt), []) + const ohneName = [out('', '2026-06-16', 1000), out('', '2026-07-16', 1000), out('123', '2026-08-16', 1000, { counterpartyName: '123' })] + assert.deepEqual(detectRecurring(ohneName), []) + assert.deepEqual(detectRecurring([out(undefined, '2026-06-16', 1000, { counterpartyName: undefined })]), []) +}) + +test('unsortierte Eingabe und kaputte Datumswerte', () => { + const txs = [out('Reihenfolge', '2026-08-16', 4358), out('Reihenfolge', '2026-06-16', 4358), out('Reihenfolge', '2026-07-16', 4358), out('Reihenfolge', 'kaputt', 4358)] + const result = detectRecurring(txs) + assert.equal(result.length, 1) + assert.equal(result[0].count, 3) + assert.equal(result[0].lastDate, '2026-08-16') +}) + +test('Ergebnis: groesste Betraege zuerst', () => { + const klein = [out('Klein', '2026-06-01', 500), out('Klein', '2026-07-01', 500), out('Klein', '2026-08-01', 500)] + const gross = [out('Gross', '2026-06-01', 9000), out('Gross', '2026-07-01', 9000), out('Gross', '2026-08-01', 9000)] + assert.deepEqual(detectRecurring([...klein, ...gross]).map((r) => r.key), ['gross', 'klein']) +})