156 lines
7.9 KiB
JavaScript
156 lines
7.9 KiB
JavaScript
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'])
|
|
})
|