import { describe, expect, it } from 'vitest' import { EINZELZAHLUNG_AB_CENT, MINDESTANZAHL, abstandInTagen, besteSchreibweise, betraegeSchwanken, einzelneZahlungen, gruppieren, normalisieren, eindeutigerVertrag, offeneKandidaten, rhythmusAusAbstand, rhythmusErklaerung, type ZahlungEingabe, } from '@/lib/finanzen/wiederkehrend' /** * Wiederkehrende Zahlungen aufspüren. * * Der teuerste Fehler dieser Funktion ist nicht eine übersehene Zahlung — * die sieht man beim Durchgehen der Liste. Es ist eine **falsch * zusammengefasste Gruppe**: Dann verschwindet ein Vertrag hinter einem * fremden Namen, und im Vermögensverzeichnis fehlt er. * * Die Prüfungen sind deshalb in beide Richtungen gebaut: Was * zusammengehört, muss zusammenfallen — und was nur ähnlich aussieht, muss * getrennt bleiben. */ function zahlung( gegenstelle: string | null, datum: string, euro: number, id = `${gegenstelle}-${datum}`, ): ZahlungEingabe { return { id, gegenstelle, datum: new Date(`${datum}T00:00:00.000Z`), betragCent: BigInt(Math.round(euro * 100)), } } /** Eine Reihe monatlicher Abbuchungen, jeweils am Monatsersten. */ function monatlich(gegenstelle: string, euro: number, monate: number, jahr = 2026) { return Array.from({ length: monate }, (_, i) => zahlung( gegenstelle, `${jahr}-${String(i + 1).padStart(2, '0')}-01`, euro, `${gegenstelle}-${i}`, ), ) } describe('Schreibweisen vergleichbar machen', () => { it('fasst Groß- und Kleinschreibung, Satzzeichen und Rechtsform zusammen', () => { const erwartet = normalisieren('Allianz Lebensversicherung AG') expect(normalisieren('ALLIANZ LEBENSVERSICHERUNG AG')).toBe(erwartet) expect(normalisieren('allianz lebensversicherung ag')).toBe(erwartet) expect(normalisieren('Allianz Lebensversicherung-AG')).toBe(erwartet) }) it('löst Umlaute auf', () => { expect(normalisieren('Münchener Verein')).toBe(normalisieren('Muenchener Verein')) expect(normalisieren('Süddeutsche Krankenversicherung')).toBe( normalisieren('Sueddeutsche Krankenversicherung'), ) }) it('entfernt die Rechtsform nur am Ende, nicht mitten im Namen', () => { // "ag" steckt in "Agentur" - wer stumpf ersetzt, macht daraus "entur". expect(normalisieren('Agentur für Arbeit')).toContain('agentur') expect(normalisieren('Debeka AG')).toBe(normalisieren('Debeka')) }) it('entfernt auch mehrteilige Rechtsformen', () => { expect(normalisieren('Stadtwerke Bamberg GmbH & Co KG')).toBe( normalisieren('Stadtwerke Bamberg'), ) }) it('hält verschiedene Empfänger auseinander, auch bei ähnlichem Namen', () => { // Der wichtigste Test der Datei. Drei Zeichen Unterschied, zwei völlig // verschiedene Rechnungssteller. expect(normalisieren('Stadtwerke Bamberg')).not.toBe(normalisieren('Stadtwerke Bayreuth')) // Und: "Versicherung" ist kein wegwerfbarer Zusatz. expect(normalisieren('Allianz Versicherung')).not.toBe( normalisieren('Allianz Lebensversicherung'), ) }) it('verträgt leere Angaben', () => { expect(normalisieren(null)).toBe('') expect(normalisieren(undefined)).toBe('') expect(normalisieren(' ')).toBe('') }) }) describe('Welche Schreibweise angezeigt wird', () => { it('zieht die gemischte Schreibweise der geschrienen vor', () => { // Nicht Kosmetik: Dieser Name landet beim „Vertrag anlegen" im Feld // Gesellschaft. Steht dort alles in Versalien, tippt man es neu — und // dann war die Vorbelegung wertlos. expect( besteSchreibweise(['ALLIANZ LEBENSVERSICHERUNG AG', 'Allianz Lebensvers. AG']), ).toBe('Allianz Lebensvers. AG') }) it('nimmt unter mehreren gemischten die längste', () => { expect(besteSchreibweise(['Allianz', 'Allianz Lebensversicherung AG'])).toBe( 'Allianz Lebensversicherung AG', ) }) it('nimmt die längste geschriene, wenn es nichts anderes gibt', () => { expect(besteSchreibweise(['ALLIANZ AG', 'ALLIANZ LEBENSVERSICHERUNG AG'])).toBe( 'ALLIANZ LEBENSVERSICHERUNG AG', ) }) it('verträgt eine leere Liste', () => { expect(besteSchreibweise([])).toBe('') }) }) describe('Rhythmus', () => { it('erkennt monatlich trotz unterschiedlich langer Monate', () => { // Februar auf März sind 28 Tage, Juli auf August 31 - beides monatlich. expect(rhythmusAusAbstand(28)).toBe('MONATLICH') expect(rhythmusAusAbstand(31)).toBe('MONATLICH') expect(rhythmusAusAbstand(30)).toBe('MONATLICH') }) it('erkennt die übrigen üblichen Rhythmen', () => { expect(rhythmusAusAbstand(61)).toBe('ZWEIMONATLICH') expect(rhythmusAusAbstand(91)).toBe('VIERTELJAEHRLICH') expect(rhythmusAusAbstand(182)).toBe('HALBJAEHRLICH') expect(rhythmusAusAbstand(365)).toBe('JAEHRLICH') }) it('nennt Unpassendes unregelmäßig, statt es zu erzwingen', () => { expect(rhythmusAusAbstand(3)).toBe('UNREGELMAESSIG') expect(rhythmusAusAbstand(45)).toBe('UNREGELMAESSIG') expect(rhythmusAusAbstand(250)).toBe('UNREGELMAESSIG') }) it('nimmt den Median, nicht den Durchschnitt', () => { // Vier monatliche Zahlungen, dann eine Lücke von einem Jahr. Der // Durchschnitt läge bei über 100 Tagen und ergäbe Unsinn. const daten = [ new Date('2026-01-01T00:00:00Z'), new Date('2026-02-01T00:00:00Z'), new Date('2026-03-01T00:00:00Z'), new Date('2026-04-01T00:00:00Z'), new Date('2027-04-01T00:00:00Z'), ] const abstand = abstandInTagen(daten) expect(abstand).not.toBeNull() expect(rhythmusAusAbstand(abstand!)).toBe('MONATLICH') }) it('liefert ohne zweite Zahlung keinen Abstand', () => { expect(abstandInTagen([new Date('2026-01-01T00:00:00Z')])).toBeNull() expect(abstandInTagen([])).toBeNull() }) }) describe('Schwankende Beträge', () => { it('lässt eine Beitragsanpassung durchgehen', () => { expect(betraegeSchwanken([4250n, 4250n, 4390n])).toBe(false) }) it('meldet einen Stromabschlag, der nach Verbrauch schwankt', () => { expect(betraegeSchwanken([8000n, 12000n, 4000n])).toBe(true) }) it('meldet nichts bei einer einzelnen Zahlung', () => { expect(betraegeSchwanken([4250n])).toBe(false) }) }) describe('Gruppieren', () => { it('fasst monatliche Abbuchungen zu einer Gruppe zusammen', () => { const gruppen = gruppieren(monatlich('Allianz Lebensversicherung AG', 42.5, 6)) expect(gruppen).toHaveLength(1) const gruppe = gruppen[0]! expect(gruppe.anzahl).toBe(6) expect(gruppe.rhythmus).toBe('MONATLICH') expect(gruppe.betragCent).toBe(4250n) // Sechs Monate à 42,50 EUR sind hochgerechnet 510 EUR im Jahr. expect(gruppe.jahresbetragCent).toBe(51_000n) expect(gruppe.betragSchwankt).toBe(false) }) it('fasst verschiedene Schreibweisen derselben Stelle zusammen', () => { const gruppen = gruppieren([ zahlung('Allianz Lebensversicherung AG', '2026-01-01', 42.5, 'a'), zahlung('ALLIANZ LEBENSVERSICHERUNG AG', '2026-02-01', 42.5, 'b'), zahlung('allianz lebensversicherung', '2026-03-01', 42.5, 'c'), ]) expect(gruppen).toHaveLength(1) expect(gruppen[0]!.anzahl).toBe(3) // Alle Schreibweisen bleiben sichtbar - man soll nachvollziehen können, // was zusammengeworfen wurde. expect(gruppen[0]!.schreibweisen).toHaveLength(3) }) it('zeigt die lesbare Schreibweise an, nicht die geschriene', () => { const gruppen = gruppieren([ zahlung('Prüfhausen Lebensversicherung AG', '2026-01-01', 42.5, 'a'), zahlung('PRUEFHAUSEN LEBENSVERSICHERUNG AG', '2026-02-01', 42.5, 'b'), ]) expect(gruppen[0]!.anzeigename).toBe('Prüfhausen Lebensversicherung AG') }) it('hält verschiedene Empfänger getrennt', () => { const gruppen = gruppieren([ ...monatlich('Stadtwerke Bamberg', 80, 3), ...monatlich('Stadtwerke Bayreuth', 80, 3), ]) expect(gruppen).toHaveLength(2) }) it('übergeht eine einzelne Zahlung', () => { const gruppen = gruppieren([zahlung('Irgendwer', '2026-01-01', 42.5)]) expect(gruppen).toHaveLength(0) expect(MINDESTANZAHL).toBe(2) }) it('übergeht Buchungen ohne Gegenstelle', () => { // Bargeldabhebungen und handerfasste Zeilen - kein Vertragshinweis. const gruppen = gruppieren([ zahlung(null, '2026-01-01', 100, 'a'), zahlung(null, '2026-02-01', 100, 'b'), zahlung('', '2026-03-01', 100, 'c'), ]) expect(gruppen).toHaveLength(0) }) it('sortiert nach Jahresbetrag, nicht nach Anzahl', () => { const gruppen = gruppieren([ ...monatlich('Kleiner Beitrag', 5, 12), // Zwei Zahlungen im Jahresabstand à 1200 EUR. zahlung('Große Police', '2025-06-01', 1200, 'g1'), zahlung('Große Police', '2026-06-01', 1200, 'g2'), ]) expect(gruppen[0]!.anzeigename).toBe('Große Police') expect(gruppen[0]!.rhythmus).toBe('JAEHRLICH') expect(gruppen[1]!.anzeigename).toBe('Kleiner Beitrag') }) it('erkennt einen hinterlegten Vertrag', () => { const gruppen = gruppieren(monatlich('Debeka AG', 30, 4), { vertraege: [{ id: 'v1', bezeichnung: 'Krankenzusatz', gesellschaft: 'Debeka' }], }) expect(gruppen[0]!.vertragId).toBe('v1') expect(gruppen[0]!.vertragBezeichnung).toBe('Krankenzusatz') }) it('lässt abgehakte Gruppen verschwinden', () => { const abgehakt = new Set([normalisieren('Rundfunk ARD ZDF')]) const gruppen = gruppieren(monatlich('Rundfunk ARD ZDF', 18.36, 6), { abgehakt }) expect(gruppen).toHaveLength(0) }) it('rechnet unregelmäßige Zahlungen nicht hoch', () => { // Ohne erkennbaren Rhythmus wäre jede Hochrechnung geraten. Dann steht // dort, was tatsächlich geflossen ist. const gruppen = gruppieren([ zahlung('Wechselnd', '2026-01-01', 100, 'a'), zahlung('Wechselnd', '2026-01-08', 100, 'b'), zahlung('Wechselnd', '2026-01-20', 100, 'c'), ]) expect(gruppen[0]!.rhythmus).toBe('UNREGELMAESSIG') expect(gruppen[0]!.jahresbetragCent).toBe(30_000n) }) it('sagt, wie viele Zahlungen der Gruppe schon an einem Vertrag hängen', () => { // Grundlage der Sammelzuordnung (Issue #11): Nur der Rest muss noch // verknüpft werden, nicht alles noch einmal. const zahlungen = monatlich('Debeka AG', 30, 4) zahlungen[0]!.vertragId = 'v1' zahlungen[1]!.vertragId = 'v1' const gruppe = gruppieren(zahlungen, { vertraege: [{ id: 'v1', bezeichnung: 'Krankenzusatz', gesellschaft: 'Debeka' }], })[0]! expect(gruppe.anzahl).toBe(4) expect(gruppe.bereitsZugeordnet).toBe(2) expect(gruppe.offeneBuchungIds).toHaveLength(2) }) it('zählt für das Dashboard nur ungeklärte Gruppen', () => { const gruppen = gruppieren( [...monatlich('Debeka AG', 30, 4), ...monatlich('Unbekannt Versicherung', 20, 4)], { vertraege: [{ id: 'v1', bezeichnung: 'Krankenzusatz', gesellschaft: 'Debeka' }] }, ) expect(gruppen).toHaveLength(2) expect(offeneKandidaten(gruppen)).toBe(1) }) }) describe('Einzelne größere Zahlungen', () => { it('findet die jährliche Prämie, die nur einmal im Auszug steht', () => { // Die eigentliche Lücke der Gruppierung: Bei einem importierten Jahr // erscheint ein Jahresbeitrag genau einmal. const treffer = einzelneZahlungen([zahlung('Feuersozietät', '2026-03-15', 480)]) expect(treffer).toHaveLength(1) expect(treffer[0]!.betragCent).toBe(48_000n) }) it('übergeht Kleinbeträge', () => { expect(einzelneZahlungen([zahlung('Bäckerei', '2026-03-15', 12.4)])).toHaveLength(0) expect(EINZELZAHLUNG_AB_CENT).toBe(15_000n) }) it('übergeht, was ohnehin schon als Gruppe erscheint', () => { const zahlungen = monatlich('Allianz', 200, 3) expect(gruppieren(zahlungen)).toHaveLength(1) expect(einzelneZahlungen(zahlungen)).toHaveLength(0) }) it('übergeht Abgehaktes', () => { const abgehakt = new Set([normalisieren('Finanzamt Coburg')]) expect( einzelneZahlungen([zahlung('Finanzamt Coburg', '2026-03-15', 900)], { abgehakt }), ).toHaveLength(0) }) it('nennt den Vertrag, wenn es schon einen gibt', () => { const treffer = einzelneZahlungen([zahlung('Debeka', '2026-03-15', 480)], { vertraege: [{ id: 'v1', bezeichnung: 'Krankenzusatz', gesellschaft: 'Debeka AG' }], }) expect(treffer[0]!.vertragId).toBe('v1') }) }) describe('Erklärung unter der Gruppe', () => { it('nennt die Grundlage, nicht nur das Ergebnis', () => { const gruppe = gruppieren(monatlich('Allianz', 42.5, 4))[0]! expect(rhythmusErklaerung(gruppe)).toBe('4 Zahlungen, etwa monatlich') }) it('formuliert größere Abstände in Monaten', () => { const gruppe = gruppieren([ zahlung('Quartalsweise', '2026-01-01', 100, 'a'), zahlung('Quartalsweise', '2026-04-01', 100, 'b'), zahlung('Quartalsweise', '2026-07-01', 100, 'c'), ])[0]! expect(rhythmusErklaerung(gruppe)).toBe('3 Zahlungen, etwa alle 3 Monate') }) it('sagt offen, wenn kein Abstand erkennbar ist', () => { const gruppe = gruppieren([ zahlung('Wechselnd', '2026-01-01', 100, 'a'), zahlung('Wechselnd', '2026-01-08', 100, 'b'), ])[0]! expect(rhythmusErklaerung(gruppe)).toContain('ohne erkennbaren Abstand') }) }) describe('Eindeutige Vertragszuordnung beim Import', () => { const vertraege = [ { id: 'v1', bezeichnung: 'Hausrat', gesellschaft: 'Allianz Versicherung' }, { id: 'v2', bezeichnung: 'Haftpflicht', gesellschaft: 'Allianz Versicherung' }, { id: 'v3', bezeichnung: 'Krankenzusatz', gesellschaft: 'Debeka AG' }, ] it('ordnet zu, wenn genau ein Vertrag passt', () => { expect(eindeutigerVertrag('Debeka', vertraege)?.id).toBe('v3') }) it('ordnet NICHT zu, wenn zwei Verträge zur selben Gesellschaft gehören', () => { // Hausrat und Haftpflicht bei derselben Gesellschaft. Eine falsche // Zuordnung wäre schlimmer als gar keine — sie behauptet einen Beleg, // und niemand sieht noch einmal hin. expect(eindeutigerVertrag('Allianz Versicherung', vertraege)).toBeNull() }) it('ordnet nichts zu, wenn nichts passt', () => { expect(eindeutigerVertrag('Stadtwerke Bamberg', vertraege)).toBeNull() expect(eindeutigerVertrag(null, vertraege)).toBeNull() expect(eindeutigerVertrag('', vertraege)).toBeNull() }) })