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, []) })