Der Demofall brachte Konten und Kontostaende mit, aber keine einzige Buchung. Kassenbuch, Belegpflicht, Dauerzahlungen und die Erkennung wiederkehrender Zahlungen liefen darin also leer - wer die Anwendung zum ersten Mal ansah, musste erst eine CSV importieren. Neu sind 107 Buchungen ueber zwoelf Monate, erzeugt aus einem Muster statt einzeln abgetippt: Rente, Betriebsrente und Pflegekasse als Einnahmen, Heimentgelt, Barbetrag, Sterbegeldbeitrag, Telefon und Kontofuehrung monatlich, dazu Rundfunkbeitrag und Grundsteuer vierteljaehrlich sowie eine Handvoll Einzelposten. Was der Fall damit zeigt: - Das Heimentgelt steht in drei Schreibweisen auf dem Auszug und wird trotzdem als eine monatliche Zahlung gefuehrt. - Genau eine Buchung hat weder Quittung noch Begruendung. Eine, nicht zwanzig: Die Liste soll etwas zu zeigen haben und nicht rot leuchten. - Die Sterbegeld-Lastschriften bleiben unzugeordnet, damit sich der Handgriff "zwoelf Zahlungen als belegt zuordnen" ausprobieren laesst. Der Beitrag steigt im Mai von 18,40 auf 19,80 - damit auch die Abweichungspruefung sichtbar wird. - Der Kontostand zum 31.07. wird aus Anfangsstand und Buchungen gerechnet, nicht behauptet. Ein Beispielfall, der sich selbst widerspricht, waere schlimmer als einer ohne Buchungen. Zwei Funde beim Bauen: Der Unique-Index auf (kontoId, importReferenz) hat einen echten Fehler in den Daten gefangen: Zwei Renten am Monatsersten ergaben dieselbe Referenz. Genau dafuer ist er da. "gGmbH" fehlte in der Rechtsformliste. Dadurch galten "Seniorenzentrum St. Katharina" und "... gGmbH" als zwei Empfaenger - und die Gruppe zerfiel ausgerechnet in dem Fachgebiet, in dem fast jeder Traeger eine gGmbH ist. Ergaenzt, mit Test. Geprueft: ein Einheitstest fuer die Rechtsform, vier End-to-End-Tests am Beispielfall - Umfang und Zeitraum, Kontostand gegen Buchungssumme, die erkannten Gruppen und das Kassenbuch. Refs #22
407 lines
14 KiB
TypeScript
407 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('kennt die gGmbH', () => {
|
|
// Heime, Sozialdienste und Träger sind fast durchweg gGmbH — ohne
|
|
// sie zerfiele die Zusammenfassung genau dort, wo sie zählt.
|
|
expect(normalisieren('Seniorenzentrum St. Katharina gGmbH')).toBe(
|
|
normalisieren('Seniorenzentrum St. Katharina'),
|
|
)
|
|
expect(normalisieren('SENIORENZENTRUM ST KATHARINA GGMBH')).toBe(
|
|
normalisieren('Seniorenzentrum St. Katharina'),
|
|
)
|
|
})
|
|
|
|
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()
|
|
})
|
|
})
|