Finanzen: Zuordnungsvorschlaege und Erkennung wiederkehrender Ausgaben
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3cedb5feef
commit
f14a6b7b57
4 files changed
+658
No files matched your search
@@ -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, [])
|
||||
})
|
||||
@@ -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'])
|
||||
})
|
||||
Reference in new issue
Block a user