diff --git a/.gitignore b/.gitignore index 77b9218..d0ce196 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,7 @@ npm-debug.log* # Bildschirmfotos entstehen beim Entwickeln neu screenshots/ daten/dokumente/ + +# Arbeitsbereiche paralleler Agenten (git worktrees). Enthalten einen +# vollstaendigen zweiten Checkout - der gehoert nicht ins Repository. +.claude/ diff --git a/CHANGELOG.md b/CHANGELOG.md index c982cbb..a882d25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,74 @@ Wiederherstellung*. _Noch nichts._ +## [1.2.0] – 2026-08-23 + +### Hinzugefügt + +- **Wiederkehrende Zahlungen aufspüren** + ([#3](https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/issues/3)). + Am Anfang einer Betreuung weiß niemand, welche Verträge es gibt — es liegt + kein Ordner bereit, und die betreute Person kann es oft selbst nicht mehr + sagen. Die Antwort steht aber schon im Kontoauszug: **Wer regelmäßig + abbucht, hat einen Vertrag.** + + Neu unter *Vermögen & Kasse → Wiederkehrende Zahlungen*: Die importierten + Kontobewegungen werden nach Empfänger gruppiert und nach Jahresbetrag + sortiert. Je Zeile stehen Rhythmus, Betrag, Zeitraum und ob dazu schon ein + Vertrag in der Akte liegt. Aus der Zeile heraus geht es weiter — *Vertrag + anlegen* mit vorbelegter Gesellschaft und vorbelegtem Beitrag, oder *kein + Vertrag* mit Begründung. + + Die Seite wertet nur aus. Sie legt von sich aus nichts an; jede Zeile + endet in einer Entscheidung, die ein Mensch trifft. + + **Was sie nennt, begründet sie.** Statt „monatlich" zu behaupten, steht + daneben „6 Zahlungen, etwa monatlich" — man soll widersprechen können. + Schwankt der Betrag, wird die Spanne genannt statt eines Mittelwerts, der + Genauigkeit vortäuscht. Und ohne erkennbaren Abstand wird nicht aufs Jahr + hochgerechnet, sondern gezeigt, was tatsächlich geflossen ist. + + **Zusammengefasst wird nur, was sicher dasselbe ist:** Groß- und + Kleinschreibung, Satzzeichen, Umlaute und die Rechtsform am Ende. + „Stadtwerke Bamberg" und „Stadtwerke Bayreuth" unterscheiden sich in drei + Zeichen und bleiben zwei Zeilen. Zwei Zeilen zu viel sind harmlos — eine + falsch zusammengefasste Gruppe verbirgt dagegen einen Vertrag, und der + fehlt dann im Vermögensverzeichnis. + +- **Einzelne größere Zahlungen** stehen in einem eigenen Abschnitt darunter. + Grund: Die Gruppierung braucht mindestens zwei Zahlungen, eine jährlich + abgebuchte Prämie erscheint bei einem importierten Jahr aber nur einmal — + und ausgerechnet Versicherungen werden oft jährlich gezahlt. Ohne diese + zweite Liste wäre die Suche genau dort blind, wo sie am meisten bringen + soll. + +- **Hinweis auf „Heute"**, sobald es unbearbeitete Kandidaten gibt. Er steht + in der Kachel *Unverbuchte Belege*, nicht in einer eigenen: Es ist + dieselbe Frage — wozu gehört diese Zahlung? —, nur eine Ebene höher. + +### Zu beachten + +- **Eine Datenbank-Migration ist nötig** + (`20260823000500_zahlungsgruppe_geklaert`). Sie läuft beim Start des + Behälters automatisch mit und legt nur eine neue Tabelle an; bestehende + Daten bleiben unberührt. +- Ausgewertet werden **nur Ausgaben der betreuten Person**. Umbuchungen + zwischen eigenen Konten und Auslagen des Betreuers bleiben außen vor — + wer monatlich aufs Sparbuch legt, hat deswegen keinen Vertrag. + +### Geprüft + +- 37 Einheitstests (`tests/wiederkehrend.test.ts`), die Hälfte davon in der + Gegenrichtung: dass ähnliche, aber verschiedene Namen **nicht** + zusammenfallen. +- Vier End-to-End-Tests gegen einen echten Browser: Erkennung, getrennte + Liste der Einzelzahlungen, Übernahme ins Vertragsformular, Abhaken und + Zurückholen samt Neuladen. + + Zwei Fehler hat erst der Browsertest gefunden. Einer davon hätte die + Vorbelegung des Beitrags stillschweigend verschluckt — Typprüfung und + Einheitstests waren grün. + ## [1.1.2] – 2026-08-23 ### Behoben @@ -548,7 +616,8 @@ Was davon wann angegangen wird, steht in [`docs/ROADMAP.md`](docs/ROADMAP.md). --- -[Unveröffentlicht]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.1.2...HEAD +[Unveröffentlicht]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.2.0...HEAD +[1.2.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.1.2...v1.2.0 [1.1.2]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.1.1...v1.1.2 [1.1.1]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.1.0...v1.1.1 [1.1.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.0.5...v1.1.0 diff --git a/README.md b/README.md index 4f3f0ec..086f188 100644 --- a/README.md +++ b/README.md @@ -580,11 +580,11 @@ Fehler, die während des Baus aufgefallen sind. | | | nachzuzählen mit | |---|---:|---| -| Zeilen TypeScript/TSX | 73.774 | `git ls-files "*.ts" "*.tsx" \| xargs wc -l` | -| Prisma-Modelle | 33 | `grep -c '^model ' prisma/schema.prisma` | +| Zeilen TypeScript/TSX | 74.119 | `git ls-files "*.ts" "*.tsx" \| xargs wc -l` | +| Prisma-Modelle | 34 | `grep -c '^model ' prisma/schema.prisma` | | Seiten | 79 | `find src/app -name page.tsx \| wc -l` | -| Unit-Tests (Vitest) | 500 | `npm test` | -| End-to-End-Tests (Playwright) | 45 | `npm run test:e2e` — 26 Tests über zwei Geräteprofile, geräteabhängige übersprungen | +| Unit-Tests (Vitest) | 537 | `npm test` | +| End-to-End-Tests (Playwright) | 53 | `npm run test:e2e` — 30 Tests über zwei Geräteprofile, geräteabhängige übersprungen | | TypeScript-Fehler | 0 | `npm run typecheck` | Die Befehle stehen dabei, weil die Zahlen sonst altern, ohne dass es diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index 5f1b0f8..42b745e 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -131,14 +131,14 @@ cd /opt/betreuungs-cockpit # Auf die letzte veröffentlichte Fassung wechseln, # nicht auf den laufenden Entwicklungsstand -git checkout v1.1.2 +git checkout v1.2.0 ``` ### Kontrolle ```bash ls docker-compose.yml Dockerfile .env.example # alle drei da -git describe --tags # v1.1.2 +git describe --tags # v1.2.0 ``` --- @@ -299,7 +299,7 @@ betreuungs-cockpit-sicherung-1 Up ```bash # Antwortet die Anwendung? (Port anpassen, falls du APP_PORT gesetzt hast) curl -s http://127.0.0.1:3000/api/gesundheit -# {"status":"bereit","version":"1.1.2","commit":"..."} +# {"status":"bereit","version":"1.2.0","commit":"..."} ``` Kommt hier nichts, sieh in die Logs: @@ -649,7 +649,7 @@ Erst **vom Proxy-Server aus** — kommt er überhaupt an? ```bash curl -s http://10.0.0.5:3000/api/gesundheit -# {"status":"bereit","version":"1.1.2",...} +# {"status":"bereit","version":"1.2.0",...} ``` Kommt hier nichts, liegt es an 7a: falsche `APP_BIND`, Firewall, oder der @@ -700,7 +700,7 @@ Zum Schluss: **Fristen → Pflichtfristen ableiten**. - Du kommst mit E-Mail, Passwort und dem Code aus der App herein. - Unter **Fristen** stehen Einträge mit Paragraphenbezug. - Unter **Protokoll** siehst du deine eigene Anmeldung. -- Unten links in der Seitenleiste steht `Version 1.1.2 · `. +- Unten links in der Seitenleiste steht `Version 1.2.0 · `. --- @@ -913,7 +913,7 @@ git fetch --tags git log --oneline HEAD..origin/main # 3. Umschalten -git checkout v1.1.2 +git checkout v1.2.0 # 4. Neu bauen und starten GIT_COMMIT=$(git rev-parse --short HEAD) BUILD_ZEIT=$(date -Iseconds) docker compose up -d --build diff --git a/e2e/wiederkehrend.spec.ts b/e2e/wiederkehrend.spec.ts new file mode 100644 index 0000000..b014b6f --- /dev/null +++ b/e2e/wiederkehrend.spec.ts @@ -0,0 +1,181 @@ +import { expect, test } from '@playwright/test' +import { PrismaClient } from '@prisma/client' +import { anmelden } from './hilfen' + +/** + * Wiederkehrende Zahlungen gegen die echte Anwendung. + * + * Die Einheitstests nageln die Erkennung fest (tests/wiederkehrend.test.ts). + * Hier geht es um das, was sie nicht sehen können: Kommt die Auswertung mit + * Daten aus der Datenbank überein, führt die Zeile wirklich ins + * Vertragsformular, und überlebt ein Abhaken das Neuladen? + * + * Der Test legt sich seine Buchungen selbst an und räumt sie hinterher weg. + * Er hängt damit nicht am Demofall — der hat heute gar keine Buchungen, und + * ein Test, der auf fremde Daten hofft, wird irgendwann grundlos rot. + */ + +/** Eigener Name, damit das Aufräumen nichts Fremdes trifft. */ +const EMPFAENGER = 'Prüfhausen Lebensversicherung AG' +const EMPFAENGER_ZWEITE_SCHREIBWEISE = 'PRUEFHAUSEN LEBENSVERSICHERUNG AG' +const EINZELNE = 'Prüfhausen Feuersozietät' + +async function mitDb(arbeit: (db: PrismaClient) => Promise): Promise { + const db = new PrismaClient() + try { + return await arbeit(db) + } finally { + await db.$disconnect() + } +} + +/** + * Legt für JEDE betreute Person dieselben Buchungen an. + * + * Welche Person nach dem Anmelden aktiv ist, hängt an einem Cookie. Statt + * das im Test nachzubauen, bekommen alle dieselben Daten — es sind ein paar + * Zeilen, und der Test wird dadurch unabhängig von der Reihenfolge. + */ +async function testdatenAnlegen(): Promise { + await mitDb(async (db) => { + const betreute = await db.betreute.findMany({ + where: { geloeschtAm: null }, + select: { id: true }, + }) + + for (const person of betreute) { + const konto = await db.konto.findFirst({ + where: { betreuteId: person.id, geloeschtAm: null }, + select: { id: true }, + }) + + const gemeinsam = { + betreuteId: person.id, + kontoId: konto?.id ?? null, + richtung: 'AUSGABE' as const, + kategorie: 'VERSICHERUNG' as const, + betrifftBetreute: true, + } + + // Sechs monatliche Abbuchungen, zwei davon in anderer Schreibweise. + for (let monat = 1; monat <= 6; monat++) { + await db.buchung.create({ + data: { + ...gemeinsam, + datum: new Date(Date.UTC(2026, monat - 1, 1)), + betragCent: 4250n, + gegenstelle: monat > 4 ? EMPFAENGER_ZWEITE_SCHREIBWEISE : EMPFAENGER, + }, + }) + } + + // Eine einzelne größere Zahlung - der Fall "Jahresbeitrag". + await db.buchung.create({ + data: { + ...gemeinsam, + datum: new Date(Date.UTC(2026, 2, 15)), + betragCent: 48_000n, + gegenstelle: EINZELNE, + }, + }) + } + }) +} + +async function testdatenEntfernen(): Promise { + await mitDb(async (db) => { + await db.buchung.deleteMany({ + where: { gegenstelle: { in: [EMPFAENGER, EMPFAENGER_ZWEITE_SCHREIBWEISE, EINZELNE] } }, + }) + await db.zahlungsgruppeGeklaert.deleteMany({ + where: { anzeigename: { contains: 'Prüfhausen' } }, + }) + }) +} + +test.describe('Wiederkehrende Zahlungen', () => { + test.beforeAll(async () => { + await testdatenEntfernen() + await testdatenAnlegen() + }) + + test.afterAll(async () => { + await testdatenEntfernen() + }) + + test('findet die monatliche Abbuchung und fasst die Schreibweisen zusammen', async ({ + page, + }) => { + await anmelden(page) + await page.goto('/finanzen/wiederkehrend') + + const zeile = page.locator('tr', { hasText: 'Prüfhausen Lebensversicherung' }).first() + await expect(zeile).toBeVisible() + + // Erkannter Rhythmus samt Begründung - nicht nur das Ergebnis. + await expect(zeile).toContainText('monatlich') + await expect(zeile).toContainText('6 Zahlungen') + // 42,50 EUR mal zwölf. + await expect(zeile).toContainText('510,00') + // Zwei Schreibweisen desselben Empfängers, in einer Zeile. + await expect(zeile).toContainText('Schreibweisen zusammengefasst') + // Und angezeigt wird die lesbare, nicht die geschriene aus dem Auszug. + await expect(zeile).toContainText(EMPFAENGER) + }) + + test('listet die einzelne größere Zahlung getrennt', async ({ page }) => { + // Die Lücke der Gruppierung: Ein Jahresbeitrag erscheint nur einmal. + await anmelden(page) + await page.goto('/finanzen/wiederkehrend') + + // Die Überschrift steht, und die Zahlung dazu in einer eigenen Zeile. + await expect(page.getByText('Einzelne größere Zahlungen')).toBeVisible() + + const zeile = page.locator('tr', { hasText: EINZELNE }) + await expect(zeile).toContainText('480,00') + // Ohne Vertrag - genau deshalb steht sie hier. + await expect(zeile).toContainText('keiner') + }) + + test('übernimmt Gesellschaft und Beitrag ins Vertragsformular', async ({ page }) => { + await anmelden(page) + await page.goto('/finanzen/wiederkehrend') + + const zeile = page.locator('tr', { hasText: 'Prüfhausen Lebensversicherung' }).first() + await zeile.getByRole('link', { name: 'Vertrag anlegen' }).click() + + await page.waitForURL('**/vertraege/neu**') + await expect(page.getByText('aus den wiederkehrenden Zahlungen')).toBeVisible() + + // Was aus dem Auszug ablesbar ist, steht drin. + await expect(page.locator('input[name="gesellschaft"]')).toHaveValue( + /Prüfhausen Lebensversicherung/, + ) + await expect(page.locator('input[name="beitrag"]')).toHaveValue('42,50') + + // Was NICHT ablesbar ist, bleibt leer - eine geratene Vorbelegung + // würde übersehen statt korrigiert. + await expect(page.locator('input[name="bezeichnung"]')).toHaveValue('') + }) + + test('hakt eine Gruppe ab und holt sie wieder zurück', async ({ page }) => { + await anmelden(page) + await page.goto('/finanzen/wiederkehrend') + + const zeile = page.locator('tr', { hasText: 'Prüfhausen Lebensversicherung' }).first() + await zeile.getByRole('button', { name: 'Kein Vertrag' }).click() + await zeile.getByPlaceholder('Warum kein Vertrag?').fill('Nur zum Prüfen') + await zeile.getByRole('button', { name: 'Abhaken', exact: true }).click() + + // Aus der Kandidatenliste verschwunden, unter „abgehakt" wieder da. + await expect(page.getByRole('cell', { name: /Prüfhausen Lebensversicherung/ })).toHaveCount(0) + await expect(page.getByText('Nur zum Prüfen')).toBeVisible() + + // Und die Entscheidung überlebt das Neuladen. + await page.reload() + await expect(page.getByText('Nur zum Prüfen')).toBeVisible() + + await page.getByRole('button', { name: 'Zurückholen' }).first().click() + await expect(page.getByRole('cell', { name: /Prüfhausen Lebensversicherung/ })).toHaveCount(1) + }) +}) diff --git a/package.json b/package.json index 55ebf16..107e65e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "betreuungs-cockpit", - "version": "1.1.2", + "version": "1.2.0", "private": true, "description": "Arbeits- und Dokumentationswerkzeug fuer die ehrenamtliche rechtliche Betreuung", "engines": { diff --git a/prisma/migrations/20260823000500_zahlungsgruppe_geklaert/migration.sql b/prisma/migrations/20260823000500_zahlungsgruppe_geklaert/migration.sql new file mode 100644 index 0000000..6d40ccd --- /dev/null +++ b/prisma/migrations/20260823000500_zahlungsgruppe_geklaert/migration.sql @@ -0,0 +1,30 @@ +-- Abgehakte Zahlungsgruppen: "das ist kein Vertrag". +-- +-- ACHTUNG, zum zweiten Mal in diesem Projekt: "prisma migrate diff" wollte +-- hier wieder die Trigramm-Indizes "dokument_absender_trgm" und +-- "vorgang_titel_trgm" loeschen. Die stammen aus prisma/sql/suche.sql und +-- sind Prisma unbekannt; ein DROP haette die unscharfe Suche stillgelegt, +-- ohne dass irgendetwas fehlgeschlagen waere. Die Zeilen sind entfernt. +-- Wer die naechste Migration erzeugt: nachsehen und wieder herausnehmen. + +-- CreateTable +CREATE TABLE "zahlungsgruppe_geklaert" ( + "id" TEXT NOT NULL, + "betreuteId" TEXT NOT NULL, + "schluessel" TEXT NOT NULL, + "anzeigename" TEXT NOT NULL, + "begruendung" TEXT, + "erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "zahlungsgruppe_geklaert_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "zahlungsgruppe_geklaert_betreuteId_idx" ON "zahlungsgruppe_geklaert"("betreuteId"); + +-- CreateIndex +CREATE UNIQUE INDEX "zahlungsgruppe_geklaert_betreuteId_schluessel_key" ON "zahlungsgruppe_geklaert"("betreuteId", "schluessel"); + +-- AddForeignKey +ALTER TABLE "zahlungsgruppe_geklaert" ADD CONSTRAINT "zahlungsgruppe_geklaert_betreuteId_fkey" FOREIGN KEY ("betreuteId") REFERENCES "betreute"("id") ON DELETE CASCADE ON UPDATE CASCADE; + diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2cd5a75..4368282 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -312,6 +312,7 @@ model Betreute { dokumente Dokument[] konten Konto[] buchungen Buchung[] + zahlungsgruppen ZahlungsgruppeGeklaert[] vermoegensposten Vermoegensposten[] immobilien Immobilie[] vertraege Vertrag[] @@ -828,6 +829,42 @@ enum BuchungKategorie { UMBUCHUNG } +/// Eine Gruppe wiederkehrender Zahlungen, zu der bewusst KEIN Vertrag +/// gefuehrt wird - Miete, Strom, Rundfunkbeitrag, Finanzamt. +/// +/// Die Entscheidung haengt am normalisierten Namen der Gegenstelle, nicht +/// an den Buchungen. Nur so ueberlebt sie den naechsten Kontoauszug: Beim +/// Import entstehen neue Buchungszeilen, der Empfaenger bleibt derselbe. +/// +/// ACHTUNG: Der Schluessel wird von normalisieren() in +/// src/lib/finanzen/wiederkehrend.ts gebildet. Aendert sich diese Funktion, +/// passen bestehende Schluessel nicht mehr und abgehakte Gruppen tauchen +/// wieder auf. Das ist unangenehm, aber ungefaehrlich - schlimmer waere, +/// eine Gruppe stillschweigend weiter zu verbergen, die inzwischen anders +/// zugeschnitten ist. +model ZahlungsgruppeGeklaert { + id String @id @default(cuid()) + + betreuteId String + betreute Betreute @relation(fields: [betreuteId], references: [id], onDelete: Cascade) + + /// Normalisierter Name der Gegenstelle. + schluessel String + + /// Die Schreibweise, wie sie beim Abhaken auf dem Bildschirm stand - + /// damit spaeter noch erkennbar ist, worum es ging. + anzeigename String + + /// Warum kein Vertrag? Freiwillig, aber in einem Jahr Gold wert. + begruendung String? + + erstelltAm DateTime @default(now()) + + @@unique([betreuteId, schluessel]) + @@index([betreuteId]) + @@map("zahlungsgruppe_geklaert") +} + model Buchung { id String @id @default(cuid()) diff --git a/src/app/(app)/finanzen/page.tsx b/src/app/(app)/finanzen/page.tsx index 7b97c63..d900a23 100644 --- a/src/app/(app)/finanzen/page.tsx +++ b/src/app/(app)/finanzen/page.tsx @@ -1,5 +1,6 @@ import Link from 'next/link' -import { ArrowRight, Banknote, FileDown, Landmark, Plus, Upload } from 'lucide-react' +import { ArrowRight, Banknote, FileDown, Landmark, Plus, Repeat, Upload } from 'lucide-react' +import { Alert, AlertDescription } from '@/components/ui/alert' import { Button } from '@/components/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' @@ -20,6 +21,7 @@ import { kennzahlen, vermischungsWarnung } from '@/lib/finanzen/kassenbuch' import { KATEGORIE_BESCHRIFTUNG } from '@/lib/finanzen/kategorien' import { laufendesBetreuungsjahr } from '@/lib/finanzen/betreuungsjahr' import { buchungenLaden, kontenLaden, kontostaendeZumStichtag, vermoegenLaden } from '@/lib/berichte/gemeinsam' +import { offeneZahlungsgruppen } from '@/lib/finanzen/wiederkehrend-laden' export const metadata = { title: 'Vermögen & Kasse' } @@ -48,6 +50,7 @@ export default async function FinanzenSeite() { const zwoelfMonate = tagePlus(monatePlus(stichtag, -12), 1) const letzteZwoelf = buchungen.filter((b) => b.datum >= zwoelfMonate) const zahlen = kennzahlen(letzteZwoelf) + const offeneGruppen = await offeneZahlungsgruppen(betreute.id) const letzte = [...buchungen].sort((a, b) => b.datum.getTime() - a.datum.getTime()).slice(0, 8) @@ -134,6 +137,22 @@ export default async function FinanzenSeite() { /> + {offeneGruppen > 0 && ( + + + )} + {zahlen.vermischungen > 0 && } diff --git a/src/app/(app)/finanzen/wiederkehrend/page.tsx b/src/app/(app)/finanzen/wiederkehrend/page.tsx new file mode 100644 index 0000000..eaa256b --- /dev/null +++ b/src/app/(app)/finanzen/wiederkehrend/page.tsx @@ -0,0 +1,318 @@ +import Link from 'next/link' +import { AlertCircle, FileSearch, Plus, Repeat } from 'lucide-react' +import { Alert, AlertDescription } from '@/components/ui/alert' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' +import { Leerzustand, SeitenKopf } from '@/components/seiten-kopf' +import { Betrag } from '@/components/finanzen/bausteine' +import { + AlsGeklaertAbhaken, + Zurueckholen, +} from '@/components/finanzen/wiederkehrend-aktionen' +import { finanzKontext } from '@/lib/finanzen/seite' +import { wiederkehrendLaden } from '@/lib/finanzen/wiederkehrend-laden' +import { + EINZELZAHLUNG_AB_CENT, + RHYTHMUS_BESCHRIFTUNG, + rhythmusErklaerung, +} from '@/lib/finanzen/wiederkehrend' +import { centZuZahl } from '@/lib/db' +import { formatCent, formatDatum } from '@/lib/utils' + +export const metadata = { title: 'Wiederkehrende Zahlungen' } +export const dynamic = 'force-dynamic' + +/** + * Wiederkehrende Zahlungen — die Suche nach unbekannten Verträgen. + * + * Am Anfang einer Betreuung weiß niemand, welche Verträge es gibt. Die + * Antwort steht im Kontoauszug: Wer regelmäßig abbucht, hat einen Vertrag. + * Diese Seite macht aus hunderten Buchungszeilen eine Arbeitsliste. + * + * Sie legt von sich aus nichts an. Jede Zeile endet in einer Entscheidung, + * die ein Mensch trifft — Vertrag anlegen oder abhaken. + */ +export default async function WiederkehrendSeite() { + const { betreute, darfSchreiben } = await finanzKontext() + const schreiben = darfSchreiben('FINANZEN') + const bild = await wiederkehrendLaden(betreute.id) + + const ohneVertrag = bild.gruppen.filter((gruppe) => gruppe.vertragId === null) + const mitVertrag = bild.gruppen.filter((gruppe) => gruppe.vertragId !== null) + + return ( +
+ + Zu den Buchungen + + } + /> + + {bild.ausgabenGesamt === 0 ? ( + + Kontoauszug einlesen + + } + /> + ) : ( + <> + {/* --- Kandidaten ohne Vertrag ----------------------------------- */} + + + + + + Nach Jahresbetrag sortiert — was am meisten kostet, will man zuerst geklärt haben. + + + + {ohneVertrag.length === 0 ? ( +

+ Zu jeder regelmäßigen Abbuchung liegt ein Vertrag in der Akte. Das ist der + Zustand, den man haben will. +

+ ) : ( +
+ + + + Empfänger + Rhythmus + je Zahlung + im Jahr + Zeitraum + {schreiben && Und jetzt?} + + + + {ohneVertrag.map((gruppe) => ( + + +

{gruppe.anzeigename}

+ {gruppe.schreibweisen.length > 1 && ( +

+ {gruppe.schreibweisen.length} Schreibweisen zusammengefasst +

+ )} +
+ + + + {RHYTHMUS_BESCHRIFTUNG[gruppe.rhythmus]} + +

+ {rhythmusErklaerung(gruppe)} +

+
+ + + + {gruppe.betragSchwankt && ( +

+ schwankt: {formatCent(centZuZahl(gruppe.kleinsterBetragCent))} bis{' '} + {formatCent(centZuZahl(gruppe.groessterBetragCent))} +

+ )} +
+ + + + {gruppe.rhythmus === 'UNREGELMAESSIG' && ( +

+ tatsächlich gezahlt +

+ )} +
+ + + {formatDatum(gruppe.ersteZahlung)} +
+ bis {formatDatum(gruppe.letzteZahlung)} +
+ + {schreiben && ( + +
+ + +
+
+ )} +
+ ))} +
+
+
+ )} +
+
+ + {/* --- Einzelne groessere Zahlungen ------------------------------ */} + {bild.einzelne.length > 0 && ( + + + Einzelne größere Zahlungen + + Kein Vertragsverdacht, sondern eine Merkliste. Die Gruppierung oben braucht + mindestens zwei Zahlungen — eine jährlich abgebuchte Prämie erscheint bei + einem importierten Jahr aber nur einmal. Ausgerechnet Versicherungen werden + oft jährlich gezahlt, deshalb steht hier alles ab{' '} + {formatCent(centZuZahl(EINZELZAHLUNG_AB_CENT))}. + + + +
+ + + + Empfänger + Datum + Betrag + Vertrag + + + + {bild.einzelne.map((zahlung) => ( + + {zahlung.anzeigename} + + {formatDatum(zahlung.datum)} + + + + + + {zahlung.vertragBezeichnung ? ( + {zahlung.vertragBezeichnung} + ) : ( + keiner + )} + + + ))} + +
+
+
+
+ )} + + {/* --- Bereits erklaert ------------------------------------------ */} + {mitVertrag.length > 0 && ( + + + Schon einem Vertrag zugeordnet + + Erkannt am Namen der Gesellschaft. Steht hier etwas Falsches, stimmt die + Schreibweise am Vertrag nicht mit der auf dem Kontoauszug überein. + + + +
    + {mitVertrag.map((gruppe) => ( +
  • + + {gruppe.anzeigename} + + {RHYTHMUS_BESCHRIFTUNG[gruppe.rhythmus]},{' '} + {formatCent(centZuZahl(gruppe.jahresbetragCent))} im Jahr + + + +
  • + ))} +
+
+
+ )} + + {/* --- Abgehakt --------------------------------------------------- */} + {bild.abgehakt.length > 0 && ( + + + Als „kein Vertrag“ abgehakt + + Diese Empfänger bleiben aus der Liste heraus, auch nach dem nächsten Import. + + + +
    + {bild.abgehakt.map((eintrag) => ( +
  • + + {eintrag.anzeigename} + {eintrag.begruendung && ( + + — {eintrag.begruendung} + + )} + + {schreiben && ( + + )} +
  • + ))} +
+
+
+ )} + + + + + )} +
+ ) +} diff --git a/src/app/(app)/vertraege/neu/page.tsx b/src/app/(app)/vertraege/neu/page.tsx index 0a43f0e..940e58f 100644 --- a/src/app/(app)/vertraege/neu/page.tsx +++ b/src/app/(app)/vertraege/neu/page.tsx @@ -1,11 +1,62 @@ import { redirect } from 'next/navigation' +import { Info } from 'lucide-react' +import { Alert, AlertDescription } from '@/components/ui/alert' import { SeitenKopf } from '@/components/seiten-kopf' import { LEERER_VERTRAG, VertragFormular } from '@/components/vertraege/vertrag-formular' import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung' +import { alsZahlRhythmus, type Rhythmus } from '@/lib/finanzen/wiederkehrend' +import { betragFormatieren } from '@/lib/finanzen/betrag' export const dynamic = 'force-dynamic' -export default async function VertragAnlegen() { +/** + * Uebernimmt eine Vorbelegung aus der Adresszeile. + * + * Sie kommt von "Wiederkehrende Zahlungen": Dort steht schon, wer da + * abbucht und wie viel. Diese Angaben noch einmal abzutippen waere genau + * die Arbeit, die die Auswertung ersparen soll. + * + * Uebernommen wird nur, was aus dem Kontoauszug ablesbar ist - Gesellschaft, + * Beitrag, Rhythmus. Die Bezeichnung und die Vertragsart bleiben leer: Was + * fuer ein Vertrag das ist, steht nicht im Auszug, und eine geratene + * Vorbelegung wird uebersehen statt korrigiert. + */ +function vorbelegung(parameter: Record) { + const text = (name: string): string => { + const wert = parameter[name] + return typeof wert === 'string' ? wert.trim() : '' + } + + const werte = { ...LEERER_VERTRAG } + let etwasUebernommen = false + + const gesellschaft = text('gesellschaft') + if (gesellschaft) { + werte.gesellschaft = gesellschaft.slice(0, 200) + etwasUebernommen = true + } + + const cent = text('beitragCent') + if (/^[0-9]{1,15}$/.test(cent)) { + werte.beitrag = betragFormatieren(BigInt(cent)) + etwasUebernommen = true + } + + const rhythmus = alsZahlRhythmus(text('rhythmus') as Rhythmus) + if (rhythmus) { + werte.rhythmus = rhythmus as typeof werte.rhythmus + etwasUebernommen = true + } + + return { werte, etwasUebernommen } +} + +export default async function VertragAnlegen({ + searchParams, +}: { + searchParams: Promise> +}) { + const parameter = await searchParams const benutzer = await erfordereBenutzer('/vertraege/neu') const aktive = await aktiveBetreute(benutzer) if (!aktive) return null @@ -13,6 +64,8 @@ export default async function VertragAnlegen() { const { darfSchreiben } = await erfordereZugang(aktive.id, 'VERTRAEGE', 'lesen') if (!darfSchreiben('VERTRAEGE')) redirect('/vertraege') + const { werte, etwasUebernommen } = vorbelegung(parameter) + return (
- + {etwasUebernommen && ( + + + )} + +
) } diff --git a/src/components/dashboard/belege-kachel.tsx b/src/components/dashboard/belege-kachel.tsx index 4c7fe94..7879cc1 100644 --- a/src/components/dashboard/belege-kachel.tsx +++ b/src/components/dashboard/belege-kachel.tsx @@ -1,5 +1,5 @@ import Link from 'next/link' -import { ReceiptText } from 'lucide-react' +import { Repeat, ReceiptText } from 'lucide-react' import { Button } from '@/components/ui/button' import { Leerzustand } from '@/components/seiten-kopf' import { Kachel, KachelZeile } from '@/components/dashboard/kachel' @@ -17,7 +17,10 @@ import { formatCent, formatDatum } from '@/lib/utils' export function BelegeKachel({ daten }: { daten: BelegeDaten | null }) { if (!daten) return null - const offen = daten.belegeOhneBuchungGesamt + daten.buchungenOhneBelegGesamt + const offen = + daten.belegeOhneBuchungGesamt + + daten.buchungenOhneBelegGesamt + + daten.wiederkehrendOhneVertrag return ( )} + {daten.wiederkehrendOhneVertrag > 0 && ( +
+

+ Regelmäßige Abbuchungen ohne Vertrag ({daten.wiederkehrendOhneVertrag}) +

+ + + + + + Wer regelmäßig abbucht, hat einen Vertrag — und der fehlt dann im + Vermögensverzeichnis. + + + +
+ )} + {daten.buchungenOhneBelegGesamt > 0 && (

( + zahlungsgruppeAbhaken, + {}, + ) + const [offen, setOffen] = React.useState(false) + + if (!offen) { + return ( +
+ {zustand.fehler && ( + + + )} + +
+ ) + } + + return ( +
+ + + + +

+ „{anzeigename}“ verschwindet aus der Liste. Zurückholen geht jederzeit. +

+ + + +
+ + Abhaken + + +
+
+ ) +} + +/** Holt eine abgehakte Gruppe zurück in die Liste. */ +export function Zurueckholen({ + betreuteId, + eintragId, +}: { + betreuteId: string + eintragId: string +}) { + const [zustand, absenden] = useActionState( + zahlungsgruppeZurueckholen, + {}, + ) + + return ( +
+ + + {zustand.fehler && ( + {zustand.fehler} + )} + + +
+ ) +} + +/** + * Zeigt die Rückmeldung der letzten Aktion. + * + * Bewusst getrennt von den Knöpfen: Nach dem Abhaken verschwindet die + * ganze Zeile samt Knopf, und mit ihr die Erfolgsmeldung. Sie hier + * anzuzeigen wäre sinnlos — deshalb steht der Erfolg als kurzer Hinweis + * oben auf der Seite, sobald die Liste neu geladen ist. + */ +export function Rueckmeldung({ text }: { text: string | null }) { + if (!text) return null + return ( + + + ) +} diff --git a/src/lib/dashboard/laden.ts b/src/lib/dashboard/laden.ts index 34ae402..4e79d24 100644 --- a/src/lib/dashboard/laden.ts +++ b/src/lib/dashboard/laden.ts @@ -21,6 +21,7 @@ import type { PosteingangDaten, WartetAufAndereDaten, } from '@/lib/dashboard/typen' +import { offeneZahlungsgruppen } from '@/lib/finanzen/wiederkehrend-laden' /** * Alles, was das Dashboard "Heute" braucht - in einem Rutsch. @@ -267,6 +268,11 @@ export async function dashboardLaden( } : null + // Regelmaessige Abbuchungen ohne Vertrag. Eigene Abfrage, weil sie andere + // Spalten braucht als der Belegzwang - und weil sie ganz entfaellt, wenn + // das Finanzmodul nicht lesbar ist. + const wiederkehrendOhneVertrag = darfFinanzen ? await offeneZahlungsgruppen(betreuteId) : 0 + const belege: BelegeDaten | null = buchungenOhneBeleg && buchungenOhneBelegGesamt !== null ? { @@ -286,6 +292,7 @@ export async function dashboardLaden( gegenstelle: b.gegenstelle, })), buchungenOhneBelegGesamt, + wiederkehrendOhneVertrag, } : null diff --git a/src/lib/dashboard/typen.ts b/src/lib/dashboard/typen.ts index 8123a3c..af4fc3d 100644 --- a/src/lib/dashboard/typen.ts +++ b/src/lib/dashboard/typen.ts @@ -149,6 +149,15 @@ export type BelegeDaten = { belegeOhneBuchungGesamt: number buchungenOhneBeleg: BuchungOhneBeleg[] buchungenOhneBelegGesamt: number + /** + * Regelmaessige Abbuchungen ohne hinterlegten Vertrag. + * + * Steht bewusst in dieser Kachel und nicht in einer eigenen: Es ist + * dieselbe Frage - "wozu gehoert diese Zahlung?" -, nur eine Ebene + * hoeher. Eine sechste Kachel auf "Heute" waere eine Kachel, die man + * nach zwei Wochen nicht mehr sieht. + */ + wiederkehrendOhneVertrag: number } // ----------------------------------------------------------------------------- diff --git a/src/lib/finanzen/wiederkehrend-laden.ts b/src/lib/finanzen/wiederkehrend-laden.ts new file mode 100644 index 0000000..15e3692 --- /dev/null +++ b/src/lib/finanzen/wiederkehrend-laden.ts @@ -0,0 +1,82 @@ +import 'server-only' +import { db, nichtGeloescht } from '@/lib/db' +import { + einzelneZahlungen, + gruppieren, + offeneKandidaten, + type Einzelzahlung, + type Zahlungsgruppe, +} from '@/lib/finanzen/wiederkehrend' + +/** + * Holt die Daten fuer die Ansicht "Wiederkehrende Zahlungen". + * + * Getrennt von wiederkehrend.ts, damit die Erkennung selbst ohne Datenbank + * bleibt und sich mit Einheitstests festnageln laesst. Hier steht nur, was + * ohne Postgres nicht geht. + */ + +export type WiederkehrendBild = { + gruppen: Zahlungsgruppe[] + einzelne: Einzelzahlung[] + /** Wie viele Gruppen noch keinem Vertrag zugeordnet sind. */ + offen: number + /** Bereits abgehakte Gruppen - zum Zurueckholen. */ + abgehakt: { id: string; schluessel: string; anzeigename: string; begruendung: string | null }[] + /** Wurde ueberhaupt schon etwas importiert? Sonst ist die Seite leer aus gutem Grund. */ + ausgabenGesamt: number +} + +export async function wiederkehrendLaden(betreuteId: string): Promise { + const [buchungen, vertraege, abgehakt] = await Promise.all([ + db.buchung.findMany({ + where: { + betreuteId, + // Nur echte Ausgaben der betreuten Person. Umbuchungen zwischen + // eigenen Konten sind kein Vertragshinweis - wer monatlich aufs + // Sparbuch legt, hat deswegen keinen Vertrag. Auslagen des + // Betreuers gehoeren ebenfalls nicht hierher. + richtung: 'AUSGABE', + betrifftBetreute: true, + ...nichtGeloescht, + }, + select: { id: true, datum: true, betragCent: true, gegenstelle: true }, + orderBy: { datum: 'asc' }, + }), + db.vertrag.findMany({ + where: { betreuteId, ...nichtGeloescht }, + select: { id: true, bezeichnung: true, gesellschaft: true }, + }), + db.zahlungsgruppeGeklaert.findMany({ + where: { betreuteId }, + select: { id: true, schluessel: true, anzeigename: true, begruendung: true }, + orderBy: { anzeigename: 'asc' }, + }), + ]) + + const optionen = { + abgehakt: new Set(abgehakt.map((eintrag) => eintrag.schluessel)), + vertraege, + } + + const gruppen = gruppieren(buchungen, optionen) + + return { + gruppen, + einzelne: einzelneZahlungen(buchungen, optionen), + offen: offeneKandidaten(gruppen), + abgehakt, + ausgabenGesamt: buchungen.length, + } +} + +/** + * Nur die Zahl fuer das Dashboard - ohne die vollen Listen aufzubauen. + * + * Laedt dieselben Buchungen, gibt aber nur die Anzahl offener Gruppen + * zurueck. Die Kachel soll die Seite nicht spuerbar verlangsamen. + */ +export async function offeneZahlungsgruppen(betreuteId: string): Promise { + const bild = await wiederkehrendLaden(betreuteId) + return bild.offen +} diff --git a/src/lib/finanzen/wiederkehrend.ts b/src/lib/finanzen/wiederkehrend.ts new file mode 100644 index 0000000..05afe59 --- /dev/null +++ b/src/lib/finanzen/wiederkehrend.ts @@ -0,0 +1,512 @@ +import { summe } from '@/lib/finanzen/betrag' +import { nachTagSortieren, tageZwischen } from '@/lib/finanzen/datum' + +/** + * Wiederkehrende Zahlungen aufspueren - ohne Datenbank, damit sich jede + * Entscheidung einzeln nachrechnen laesst. + * + * Der Anlass: Am Anfang einer Betreuung weiss niemand, welche Vertraege es + * gibt. Die Antwort steht aber im Kontoauszug - wer regelmaessig abbucht, + * hat einen Vertrag. Diese Datei macht aus hunderten Buchungszeilen eine + * Arbeitsliste. + * + * Zwei Haltungen liegen allem zugrunde: + * + * 1. **Im Zweifel getrennt lassen.** Zwei Zeilen zu viel sind harmlos - man + * sieht hin und hakt ab. Eine falsch zusammengefasste Gruppe verbirgt + * dagegen einen Vertrag, und der fehlt dann im Vermoegensverzeichnis. + * Deshalb wird nur zusammengefasst, was sicher dasselbe ist. + * + * 2. **Nichts behaupten, was nicht belegt ist.** Die Erkennung nennt immer + * mit, worauf sie sich stuetzt ("4 Zahlungen, etwa alle 3 Monate"). + * Eine Einordnung ohne nachvollziehbare Grundlage waere geraten, und + * geratene Zahlen landen sonst im Bericht ans Gericht. + */ + +// ----------------------------------------------------------------------------- +// Eingabe und Ergebnis +// ----------------------------------------------------------------------------- + +export type ZahlungEingabe = { + id: string + datum: Date + betragCent: bigint + gegenstelle: string | null +} + +/** Ein hinterlegter Vertrag - zum Abgleich, ob die Zahlung schon erklaert ist. */ +export type VertragAbgleich = { + id: string + bezeichnung: string + gesellschaft: string | null +} + +export type Rhythmus = + | 'MONATLICH' + | 'ZWEIMONATLICH' + | 'VIERTELJAEHRLICH' + | 'HALBJAEHRLICH' + | 'JAEHRLICH' + | 'UNREGELMAESSIG' + +export const RHYTHMUS_BESCHRIFTUNG: Record = { + MONATLICH: 'monatlich', + ZWEIMONATLICH: 'zweimonatlich', + VIERTELJAEHRLICH: 'vierteljährlich', + HALBJAEHRLICH: 'halbjährlich', + JAEHRLICH: 'jährlich', + UNREGELMAESSIG: 'unregelmäßig', +} + +/** Wie oft der Rhythmus im Jahr zuschlaegt. Fuer die Hochrechnung. */ +const ZAHLUNGEN_JE_JAHR: Record = { + MONATLICH: 12, + ZWEIMONATLICH: 6, + VIERTELJAEHRLICH: 4, + HALBJAEHRLICH: 2, + JAEHRLICH: 1, + UNREGELMAESSIG: 0, +} + +export type Zahlungsgruppe = { + /** Normalisierter Name - der Schluessel, unter dem abgehakt wird. */ + schluessel: string + /** Die laengste vorgefundene Schreibweise, fuer die Anzeige. */ + anzeigename: string + /** Alle vorgefundenen Schreibweisen, falls mehrere zusammengefasst wurden. */ + schreibweisen: string[] + anzahl: number + ersteZahlung: Date + letzteZahlung: Date + /** Median der Betraege - unempfindlicher gegen einen Ausreisser als der Schnitt. */ + betragCent: bigint + kleinsterBetragCent: bigint + groessterBetragCent: bigint + /** Schwanken die Betraege stark? Dann ist es eher keine feste Praemie. */ + betragSchwankt: boolean + rhythmus: Rhythmus + /** Medianabstand in Tagen - die Grundlage der Rhythmuserkennung. */ + abstandTage: number | null + /** Hochgerechnet auf ein Jahr. Bei unregelmaessigem Rhythmus: das + * tatsaechlich Gezahlte, nicht hochgerechnet. */ + jahresbetragCent: bigint + /** Passt der Name zu einem hinterlegten Vertrag? */ + vertragId: string | null + vertragBezeichnung: string | null + buchungIds: string[] +} + +// ----------------------------------------------------------------------------- +// Namen vergleichbar machen +// ----------------------------------------------------------------------------- + +/** + * Rechtsformen und Zusaetze, die keinen Unterschied machen. + * + * Bewusst kurz gehalten. Jeder Eintrag hier ist eine Behauptung "das darf + * weg" - und jede falsche Behauptung wirft zwei verschiedene Empfaenger + * zusammen. "Versicherung" steht deshalb NICHT in der Liste: Zwischen + * "Allianz Versicherung" und "Allianz Lebensversicherung" liegt ein + * Unterschied, der im Vermoegensverzeichnis zaehlt. + */ +const RECHTSFORMEN = [ + 'gmbh & co kg', + 'gmbh und co kg', + 'gmbh', + 'mbh', + 'ohg', + 'kgaa', + 'kg', + 'ag', + 'se', + 'ev', + 'vvag', + 'ag', + 'aoer', + 'koer', +] + +/** Ersetzt Umlaute, damit "Muenchener" und "Münchener" zusammenfallen. */ +function umlauteAufloesen(text: string): string { + return text + .replace(/ä/g, 'ae') + .replace(/ö/g, 'oe') + .replace(/ü/g, 'ue') + .replace(/ß/g, 'ss') +} + +/** + * Macht zwei Schreibweisen desselben Empfaengers vergleichbar. + * + * Was hier zusammenfaellt, wird spaeter in einer Zeile angezeigt. Deshalb + * nur das Unstrittige: Gross- und Kleinschreibung, Satzzeichen, doppelte + * Leerzeichen, Umlaute und die Rechtsform am Ende. + * + * Was ausdruecklich NICHT passiert: Aehnlichkeitssuche ueber Abstandsmasse. + * "Stadtwerke Bamberg" und "Stadtwerke Bayreuth" unterscheiden sich in drei + * Zeichen und sind zwei voellig verschiedene Rechnungssteller. + */ +export function normalisieren(gegenstelle: string | null | undefined): string { + if (!gegenstelle) return '' + + let text = umlauteAufloesen(gegenstelle.toLowerCase()) + + // Satzzeichen zu Leerzeichen, damit "vers.ag" zu "vers ag" wird und die + // Rechtsform anschliessend als eigenes Wort erkennbar ist. + text = text.replace(/[.,;:/\\()[\]{}"'`*+_-]/g, ' ') + text = text.replace(/\s+/g, ' ').trim() + + // Rechtsform nur am ENDE entfernen. Mitten im Namen kann dieselbe + // Zeichenfolge etwas anderes sein - "ag" steckt in "Agentur". + let geaendert = true + while (geaendert) { + geaendert = false + for (const form of RECHTSFORMEN) { + if (text.endsWith(` ${form}`)) { + text = text.slice(0, -(form.length + 1)).trim() + geaendert = true + break + } + } + } + + return text +} + +// ----------------------------------------------------------------------------- +// Rhythmus +// ----------------------------------------------------------------------------- + +function median(werte: number[]): number { + if (werte.length === 0) return 0 + const sortiert = [...werte].sort((a, b) => a - b) + const mitte = Math.floor(sortiert.length / 2) + return sortiert.length % 2 === 1 + ? sortiert[mitte]! + : Math.round((sortiert[mitte - 1]! + sortiert[mitte]!) / 2) +} + +function medianBigInt(werte: bigint[]): bigint { + if (werte.length === 0) return 0n + const sortiert = [...werte].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)) + const mitte = Math.floor(sortiert.length / 2) + return sortiert.length % 2 === 1 + ? sortiert[mitte]! + : (sortiert[mitte - 1]! + sortiert[mitte]!) / 2n +} + +/** + * Ordnet einen Medianabstand einem Rhythmus zu. + * + * Die Spannen sind bewusst weit: Lastschriften kommen am ersten Werktag, + * und der wandert. Zwischen dem 1. Februar und dem 1. Maerz liegen 28 Tage, + * zwischen dem 1. Juli und dem 1. August 31 - beides ist monatlich. + */ +export function rhythmusAusAbstand(tage: number): Rhythmus { + if (tage >= 24 && tage <= 38) return 'MONATLICH' + if (tage >= 50 && tage <= 70) return 'ZWEIMONATLICH' + if (tage >= 80 && tage <= 100) return 'VIERTELJAEHRLICH' + if (tage >= 160 && tage <= 200) return 'HALBJAEHRLICH' + if (tage >= 330 && tage <= 400) return 'JAEHRLICH' + return 'UNREGELMAESSIG' +} + +/** Der Medianabstand zwischen aufeinanderfolgenden Zahlungen, in Tagen. */ +export function abstandInTagen(daten: Date[]): number | null { + if (daten.length < 2) return null + const sortiert = nachTagSortieren(daten, (d) => d) + const abstaende: number[] = [] + for (let i = 1; i < sortiert.length; i++) { + abstaende.push(Math.abs(tageZwischen(sortiert[i - 1]!, sortiert[i]!))) + } + return median(abstaende) +} + +/** + * Schwanken die Betraege so stark, dass es keine feste Zahlung mehr ist? + * + * Ein Fuenftel Abweichung vom Median ist grosszuegig - Beitragsanpassungen + * und Stromabschlaege bewegen sich in dieser Groessenordnung. Wer darueber + * liegt, zahlt eher nach Verbrauch als nach Vertrag. + */ +export function betraegeSchwanken(betraege: bigint[]): boolean { + if (betraege.length < 2) return false + const mitte = medianBigInt(betraege) + if (mitte === 0n) return true + + return betraege.some((betrag) => { + const abweichung = betrag > mitte ? betrag - mitte : mitte - betrag + // Ganzzahlig gerechnet: |x - m| * 100 > m * 20 + return abweichung * 100n > mitte * 20n + }) +} + +// ----------------------------------------------------------------------------- +// Gruppieren +// ----------------------------------------------------------------------------- + +/** + * Ab wann gilt eine Zahlung als wiederkehrend? + * + * Zwei ist das Minimum, das ueberhaupt einen Abstand ergibt. Damit bleibt + * eine Luecke, die man kennen muss: Eine JAEHRLICH abgebuchte Praemie + * erscheint bei nur einem importierten Jahr genau einmal und wird hier + * nicht gefunden. Deshalb gibt es daneben die Liste der einzelnen groesseren + * Zahlungen - siehe einzelneZahlungen(). + */ +export const MINDESTANZAHL = 2 + +export type GruppierungsOptionen = { + /** Schon abgehakte Schluessel - "das ist kein Vertrag". */ + abgehakt?: ReadonlySet + /** Hinterlegte Vertraege, zum Abgleich. */ + vertraege?: VertragAbgleich[] +} + +/** Ordnet Vertraege ihrem normalisierten Gesellschaftsnamen zu. */ +function vertragsIndex(vertraege: VertragAbgleich[]): Map { + const index = new Map() + for (const vertrag of vertraege) { + const schluessel = normalisieren(vertrag.gesellschaft) + // Erster Treffer gewinnt. Zwei Vertraege bei derselben Gesellschaft sind + // moeglich (Hausrat und Haftpflicht bei der Allianz); fuer die Frage + // "ist das schon erklaert?" genuegt einer. + if (schluessel && !index.has(schluessel)) index.set(schluessel, vertrag) + } + return index +} + +/** + * Waehlt aus mehreren Schreibweisen die, die man jemandem zeigen kann. + * + * Banken liefern denselben Empfaenger mal als "Allianz Lebensversicherung + * AG", mal als "ALLIANZ LEBENSVERS AG". Die laengste zu nehmen waere + * naheliegend - und faellt regelmaessig auf die geschriene Fassung herein, + * weil Versalien nicht kuerzer sind. + * + * Das ist mehr als Kosmetik: Dieser Name wird beim "Vertrag anlegen" in das + * Feld Gesellschaft uebernommen. Steht dort alles in Grossbuchstaben, tippt + * man es von Hand neu - und dann war die Vorbelegung wertlos. + * + * Also: erst die Schreibweisen mit Klein- und Grossbuchstaben, unter denen + * die laengste. Gibt es nur geschriene, nimmt man eben die laengste davon. + */ +export function besteSchreibweise(schreibweisen: string[]): string { + if (schreibweisen.length === 0) return '' + + const gemischt = schreibweisen.filter((name) => name !== name.toUpperCase()) + const auswahl = gemischt.length > 0 ? gemischt : schreibweisen + + return auswahl.reduce((a, b) => (b.length > a.length ? b : a), auswahl[0]!) +} + +/** + * Fasst Zahlungen zu Gruppen zusammen und bewertet sie. + * + * Erwartet werden ausschliesslich Ausgaben, die die betreute Person + * betreffen - die Auswahl trifft der Aufrufer, weil sie von der Datenbank + * kommt. Umbuchungen zwischen eigenen Konten gehoeren nicht hierher: Wer + * monatlich aufs Sparbuch legt, hat deswegen keinen Vertrag. + */ +export function gruppieren( + zahlungen: ZahlungEingabe[], + optionen: GruppierungsOptionen = {}, +): Zahlungsgruppe[] { + const abgehakt = optionen.abgehakt ?? new Set() + const index = vertragsIndex(optionen.vertraege ?? []) + + const nachSchluessel = new Map() + for (const zahlung of zahlungen) { + const schluessel = normalisieren(zahlung.gegenstelle) + // Ohne Gegenstelle gibt es nichts zu gruppieren. Solche Buchungen sind + // Bargeldabhebungen oder handerfasste Zeilen - kein Vertragshinweis. + if (!schluessel) continue + if (abgehakt.has(schluessel)) continue + + const bisher = nachSchluessel.get(schluessel) + if (bisher) bisher.push(zahlung) + else nachSchluessel.set(schluessel, [zahlung]) + } + + const gruppen: Zahlungsgruppe[] = [] + + for (const [schluessel, posten] of nachSchluessel) { + if (posten.length < MINDESTANZAHL) continue + + const sortiert = nachTagSortieren(posten, (p) => p.datum) + const betraege = posten.map((p) => p.betragCent) + const abstandTage = abstandInTagen(posten.map((p) => p.datum)) + const rhythmus = abstandTage === null ? 'UNREGELMAESSIG' : rhythmusAusAbstand(abstandTage) + const betragCent = medianBigInt(betraege) + + const jeJahr = ZAHLUNGEN_JE_JAHR[rhythmus] + const jahresbetragCent = + jeJahr > 0 ? betragCent * BigInt(jeJahr) : summe(betraege) + + const schreibweisen = [ + ...new Set(posten.map((p) => (p.gegenstelle ?? '').trim()).filter(Boolean)), + ].sort() + + const vertrag = index.get(schluessel) ?? null + + gruppen.push({ + schluessel, + anzeigename: besteSchreibweise(schreibweisen) || schluessel, + schreibweisen, + anzahl: posten.length, + ersteZahlung: sortiert[0]!.datum, + letzteZahlung: sortiert[sortiert.length - 1]!.datum, + betragCent, + kleinsterBetragCent: betraege.reduce((a, b) => (b < a ? b : a), betraege[0]!), + groessterBetragCent: betraege.reduce((a, b) => (b > a ? b : a), betraege[0]!), + betragSchwankt: betraegeSchwanken(betraege), + rhythmus, + abstandTage, + jahresbetragCent, + vertragId: vertrag?.id ?? null, + vertragBezeichnung: vertrag?.bezeichnung ?? null, + buchungIds: sortiert.map((p) => p.id), + }) + } + + // Absteigend nach Jahresbetrag: Was am meisten kostet, will man zuerst + // geklaert haben. + return gruppen.sort((a, b) => + a.jahresbetragCent > b.jahresbetragCent + ? -1 + : a.jahresbetragCent < b.jahresbetragCent + ? 1 + : a.anzeigename.localeCompare(b.anzeigename, 'de'), + ) +} + +// ----------------------------------------------------------------------------- +// Die Luecke: einmalige Zahlungen +// ----------------------------------------------------------------------------- + +/** + * Ab welchem Betrag eine einzelne Zahlung erwaehnenswert ist. + * + * 150 EUR ist die Grenze, ab der sich das Nachsehen lohnt. Darunter stehen + * Einkaeufe und Gebuehren, darueber faengt es an, nach Jahresbeitrag + * auszusehen. + */ +export const EINZELZAHLUNG_AB_CENT = 15_000n + +export type Einzelzahlung = { + schluessel: string + anzeigename: string + buchungId: string + datum: Date + betragCent: bigint + vertragId: string | null + vertragBezeichnung: string | null +} + +/** + * Einzelne groessere Zahlungen ohne hinterlegten Vertrag. + * + * Der Grund fuer diese zweite Liste: Die Gruppierung braucht mindestens + * zwei Zahlungen. Eine jaehrlich abgebuchte Versicherungspraemie erscheint + * bei einem importierten Jahr aber genau einmal - und ausgerechnet + * Versicherungen werden oft jaehrlich gezahlt. Ohne diese Liste waere die + * Suche genau dort blind, wo sie am meisten bringen soll. + * + * Sie ist ausdruecklich kein Vertragsverdacht, sondern eine Merkliste. In + * der Oberflaeche steht sie deshalb getrennt und mit eigener Ueberschrift. + */ +export function einzelneZahlungen( + zahlungen: ZahlungEingabe[], + optionen: GruppierungsOptionen = {}, +): Einzelzahlung[] { + const abgehakt = optionen.abgehakt ?? new Set() + const index = vertragsIndex(optionen.vertraege ?? []) + + const anzahlJeSchluessel = new Map() + for (const zahlung of zahlungen) { + const schluessel = normalisieren(zahlung.gegenstelle) + if (!schluessel) continue + anzahlJeSchluessel.set(schluessel, (anzahlJeSchluessel.get(schluessel) ?? 0) + 1) + } + + const treffer: Einzelzahlung[] = [] + + for (const zahlung of zahlungen) { + const schluessel = normalisieren(zahlung.gegenstelle) + if (!schluessel) continue + if (abgehakt.has(schluessel)) continue + // Alles, was mehrfach vorkommt, steht bereits in der Gruppenliste. + if ((anzahlJeSchluessel.get(schluessel) ?? 0) >= MINDESTANZAHL) continue + if (zahlung.betragCent < EINZELZAHLUNG_AB_CENT) continue + + const vertrag = index.get(schluessel) ?? null + + treffer.push({ + schluessel, + anzeigename: (zahlung.gegenstelle ?? '').trim() || schluessel, + buchungId: zahlung.id, + datum: zahlung.datum, + betragCent: zahlung.betragCent, + vertragId: vertrag?.id ?? null, + vertragBezeichnung: vertrag?.bezeichnung ?? null, + }) + } + + return treffer.sort((a, b) => + a.betragCent > b.betragCent ? -1 : a.betragCent < b.betragCent ? 1 : 0, + ) +} + +// ----------------------------------------------------------------------------- +// Fuer die Anzeige +// ----------------------------------------------------------------------------- + +/** + * Der Satz, der unter einer Gruppe steht. + * + * Er nennt die Grundlage der Einordnung, statt nur das Ergebnis zu + * behaupten - damit man widersprechen kann, wenn es nicht stimmt. + */ +export function rhythmusErklaerung(gruppe: Zahlungsgruppe): string { + const zahlungen = `${gruppe.anzahl} ${gruppe.anzahl === 1 ? 'Zahlung' : 'Zahlungen'}` + + if (gruppe.rhythmus === 'UNREGELMAESSIG' || gruppe.abstandTage === null) { + return `${zahlungen}, ohne erkennbaren Abstand` + } + + const tage = gruppe.abstandTage + const monate = Math.round(tage / 30) + const abstand = + monate <= 1 ? 'etwa monatlich' : `etwa alle ${monate} Monate` + + return `${zahlungen}, ${abstand}` +} + +/** + * Uebersetzt den erkannten Rhythmus in den Zahlrhythmus des Vertrags. + * + * Zwei Faelle haben dort keine Entsprechung: ZWEIMONATLICH kennt das + * Vertragsmodell nicht, und UNREGELMAESSIG ist keine Aussage, sondern das + * Eingestaendnis, keine zu haben. Beide liefern null - dann bleibt im + * Formular die Vorgabe stehen, statt etwas Falsches vorzublenden. Eine + * vorbelegte falsche Angabe wird uebersehen; ein leeres Feld nicht. + */ +export function alsZahlRhythmus(rhythmus: Rhythmus): string | null { + switch (rhythmus) { + case 'MONATLICH': + return 'MONATLICH' + case 'VIERTELJAEHRLICH': + return 'VIERTELJAEHRLICH' + case 'HALBJAEHRLICH': + return 'HALBJAEHRLICH' + case 'JAEHRLICH': + return 'JAEHRLICH' + default: + return null + } +} + +/** Wie viele Gruppen warten noch auf eine Entscheidung? Fuer das Dashboard. */ +export function offeneKandidaten(gruppen: Zahlungsgruppe[]): number { + return gruppen.filter((gruppe) => gruppe.vertragId === null).length +} diff --git a/src/lib/version.ts b/src/lib/version.ts index bb09d77..57d5daf 100644 --- a/src/lib/version.ts +++ b/src/lib/version.ts @@ -17,7 +17,7 @@ */ /** Muss zur "version" in der package.json passen. */ -export const VERSION = '1.1.2' +export const VERSION = '1.2.0' /** * Kurzer Git-Stand, beim Bauen hineingereicht: diff --git a/src/server/actions/finanzen.ts b/src/server/actions/finanzen.ts index d318c33..c0b6d14 100644 --- a/src/server/actions/finanzen.ts +++ b/src/server/actions/finanzen.ts @@ -821,3 +821,106 @@ export async function wertstandLoeschen( revalidatePath('/finanzen/vermoegen') return { erfolg: 'Wert gelöscht.' } } + +// ----------------------------------------------------------------------------- +// Wiederkehrende Zahlungen +// ----------------------------------------------------------------------------- + +/** + * Hakt eine Gruppe wiederkehrender Zahlungen als "kein Vertrag" ab. + * + * Der Schluessel kommt aus normalisieren() und wird hier NICHT noch einmal + * gebildet: Was der Benutzer auf dem Bildschirm gesehen hat, soll auch + * abgehakt werden. Bildeten Anzeige und Aktion den Schluessel getrennt, + * koennten sie auseinanderlaufen - und dann verschwaende eine andere + * Gruppe als die angeklickte. + */ +export async function zahlungsgruppeAbhaken( + _zustand: Formularzustand, + daten: FormData, +): Promise { + const betreuteId = String(daten.get('betreuteId') ?? '') + const schluessel = String(daten.get('schluessel') ?? '').trim() + const anzeigename = String(daten.get('anzeigename') ?? '').trim() + const begruendung = String(daten.get('begruendung') ?? '').trim() + + if (!schluessel || !anzeigename) { + return { fehler: 'Diese Zahlungsgruppe lässt sich nicht zuordnen.' } + } + + try { + const { benutzer } = await erfordereZugang(betreuteId, 'FINANZEN', 'schreiben') + + await db.zahlungsgruppeGeklaert.upsert({ + where: { betreuteId_schluessel: { betreuteId, schluessel } }, + create: { + betreuteId, + schluessel, + anzeigename, + begruendung: begruendung === '' ? null : begruendung, + }, + update: { begruendung: begruendung === '' ? null : begruendung }, + }) + + await protokollieren({ + aktion: 'ERSTELLT', + entitaet: 'ZahlungsgruppeGeklaert', + bezeichnung: `${anzeigename} — kein Vertrag`, + betreuteId, + userId: benutzer.id, + userEmail: benutzer.email, + userName: benutzer.name, + }) + } catch (ausnahme) { + const verweigert = zugriffsfehler(ausnahme) + if (verweigert) return verweigert + console.error('[finanzen] Zahlungsgruppe konnte nicht abgehakt werden:', ausnahme) + return { fehler: 'Die Zahlungsgruppe konnte nicht abgehakt werden.' } + } + + revalidatePath('/finanzen/wiederkehrend') + revalidatePath('/heute') + return { erfolg: `„${anzeigename}“ gilt als geklärt.` } +} + +/** Holt eine abgehakte Gruppe zurueck in die Liste. */ +export async function zahlungsgruppeZurueckholen( + _zustand: Formularzustand, + daten: FormData, +): Promise { + const betreuteId = String(daten.get('betreuteId') ?? '') + const eintragId = String(daten.get('eintragId') ?? '') + + try { + const { benutzer } = await erfordereZugang(betreuteId, 'FINANZEN', 'schreiben') + + // Zeilenweise Pruefung: Die ID muss zu DIESER betreuten Person gehoeren. + const eintrag = await db.zahlungsgruppeGeklaert.findFirst({ + where: { id: eintragId, betreuteId }, + select: { id: true, anzeigename: true }, + }) + if (!eintrag) return { fehler: 'Diesen Eintrag gibt es nicht (mehr).' } + + await db.zahlungsgruppeGeklaert.delete({ where: { id: eintrag.id } }) + + await protokollieren({ + aktion: 'GELOESCHT', + entitaet: 'ZahlungsgruppeGeklaert', + entitaetId: eintrag.id, + bezeichnung: `${eintrag.anzeigename} — wieder offen`, + betreuteId, + userId: benutzer.id, + userEmail: benutzer.email, + userName: benutzer.name, + }) + } catch (ausnahme) { + const verweigert = zugriffsfehler(ausnahme) + if (verweigert) return verweigert + console.error('[finanzen] Zahlungsgruppe konnte nicht zurückgeholt werden:', ausnahme) + return { fehler: 'Der Eintrag konnte nicht zurückgeholt werden.' } + } + + revalidatePath('/finanzen/wiederkehrend') + revalidatePath('/heute') + return { erfolg: 'Die Gruppe steht wieder in der Liste.' } +} diff --git a/tests/wiederkehrend.test.ts b/tests/wiederkehrend.test.ts new file mode 100644 index 0000000..7c96e26 --- /dev/null +++ b/tests/wiederkehrend.test.ts @@ -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') + }) +})