Files
Florian Wilde 18709a8e49 Ein Jahr Kontobewegungen im Beispielfall
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
2026-08-23 21:35:46 +02:00

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()
})
})