Wiederkehrende Zahlungen aufspueren
Am Anfang einer Betreuung weiss niemand, welche Vertraege es gibt. Die Antwort steht aber im Kontoauszug: Wer regelmaessig abbucht, hat einen Vertrag. Neu unter Vermoegen & Kasse wird aus hunderten Buchungszeilen eine Arbeitsliste. Aufbau: - Die Erkennung liegt als reine Funktionen in src/lib/finanzen/wiederkehrend.ts, ohne Datenbank. Nur so laesst sich jede Einordnung einzeln nachrechnen. - Der Datenzugriff steht getrennt in wiederkehrend-laden.ts. - Ein kleines Modell haelt fest, was bewusst KEIN Vertrag ist. Es haengt am normalisierten Empfaengernamen, nicht an Buchungen - nur so ueberlebt die Entscheidung den naechsten Import. Zwei Haltungen liegen der Erkennung zugrunde: Im Zweifel getrennt lassen. Zusammengefasst wird nur das Unstrittige: Gross- und Kleinschreibung, Satzzeichen, Umlaute, Rechtsform am Ende. Keine Aehnlichkeitssuche - "Stadtwerke Bamberg" und "Stadtwerke Bayreuth" trennen drei Zeichen und zwei Rechnungssteller. Zwei Zeilen zu viel sind harmlos; eine falsche Gruppe verbirgt einen Vertrag. Nichts behaupten, was nicht belegt ist. Statt "monatlich" steht dort "6 Zahlungen, etwa monatlich". Schwankt der Betrag, wird die Spanne genannt. Ohne erkennbaren Abstand wird nicht hochgerechnet, sondern gezeigt, was geflossen ist. Dazu eine zweite Liste fuer einzelne groessere Zahlungen: Die Gruppierung braucht zwei Zahlungen, ein Jahresbeitrag erscheint bei einem importierten Jahr aber nur einmal - und ausgerechnet Versicherungen zahlt man oft jaehrlich. Geprueft: 37 Einheitstests, die Haelfte davon in der Gegenrichtung, plus vier End-to-End-Tests auf beiden Geraeteprofilen. Der Browsertest hat zwei Fehler gefunden, die Typpruefung und Einheitstests durchgelassen haben - darunter ein verlorener Backslash, der die Vorbelegung des Beitrags stillschweigend verschluckte. Nebenbei: .claude/ ins .gitignore. Dort legen parallel arbeitende Agenten ihre Worktrees ab; ein "git add -A" haette einen kompletten zweiten Checkout eingecheckt. Refs #3
This commit is contained in:
@@ -0,0 +1,353 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
EINZELZAHLUNG_AB_CENT,
|
||||
MINDESTANZAHL,
|
||||
abstandInTagen,
|
||||
besteSchreibweise,
|
||||
betraegeSchwanken,
|
||||
einzelneZahlungen,
|
||||
gruppieren,
|
||||
normalisieren,
|
||||
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('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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user