Bei einer monatlichen Praemie gibt es keinen Beleg - es gibt einen Vertrag, und der wird einmal abgeschlossen, nicht monatlich quittiert. Bisher blieb nur ein Freitextfeld pro Buchung: zwoelfmal im Jahr derselbe Satz, mal Anzahl der Vertraege. Buchung.vertragId mit onDelete: SetNull. Der Belegzustand kennt jetzt drei Faelle statt zwei, getrennt ausgewiesen - ein Vertrag belegt, eine Begruendung erklaert nur; zusammengeworfen verliert die Belegquote ihre Aussage gegenueber dem Rechtspfleger. Dazu: automatische Zuordnung beim Import bei eindeutiger Gegenstelle, Sammelzuordnung ueber die Gruppen aus Issue #3, und die Rechnungslegung nennt den Vertrag samt Nummer statt "ohne Beleg". Die Sicherung dagegen: Weicht der Betrag um mehr als ein Fuenftel vom hinterlegten Beitrag ab, faellt die Anerkennung weg und die Buchung wird als "Betrag pruefen" markiert. Sonst waere die Zuordnung eine Hintertuer. Zur Toleranz - ein Entwurf und seine Korrektur: Zuerst verglich ich den Betrag gegen alle ueblichen Zahlweisen, damit es auch passt, wenn die Bank anders abbucht als der Vertrag es fuehrt. Der eigene Test hat das widerlegt: Bei 42,50 monatlich liegen 420 innerhalb von 20 Prozent des Jahresbetrags von 510 - ausgerechnet der Fall aus der Aufgabenstellung waere durchgerutscht. Jetzt wird stur gegen den hinterlegten Beitrag verglichen. Bucht die Bank anders ab, stimmen die Vertragsdaten nicht, und das soll auffallen. Behoben: Die Buchungsliste hatte eine eigene, fast gleiche Datenabfrage neben der aus berichte/gemeinsam.ts. Nur eine von beiden wurde nachgezogen, in der Liste stand weiter "Beleg fehlt". Beide benutzen jetzt BUCHUNG_EINBEZUG und zuBuchungEingabe - das kann nicht halb vergessen werden. Gefunden hat es der Browsertest. Nebenbei: Die Roadmap trug noch "Aktuelle Fassung 1.1.2" - Nachwirkung der parallelen Bearbeitung von #3 und #10. Nachgezogen wie zugesagt. Geprueft: 23 neue Einheitstests, die Haelfte davon darauf gerichtet, dass die Anerkennung auch wirklich wegfaellt. Fuenf End-to-End-Tests auf beiden Geraeteprofilen. 560 Unit-Tests, 63 e2e, null Typfehler. Refs #11
396 lines
14 KiB
TypeScript
396 lines
14 KiB
TypeScript
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()
|
|
})
|
|
})
|