Compare commits

..
6 Commits
Author SHA1 Message Date
florian.wilde 8779cc6a25 Merge #13: Wiederkehrende Zahlungen aufspueren
Behebt Issue #3: Am Anfang einer Betreuung weiss niemand, welche
Vertraege es gibt. Die Antwort steht im Kontoauszug.

Closes #3
2026-08-23 06:13:43 +00:00
florian.wilde 928d3bc408 Merge #12: Roadmap - Korrespondenz nach 1.2a
Behebt Issue #10: Das Thema Schriftverkehr stand als eine Zeile unter
"Spaeter, ohne Datum" und war damit zu tief gehaengt.

Closes #10
2026-08-23 06:13:35 +00:00
Florian Wilde aa0cc66292 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
2026-08-23 02:42:26 +02:00
Florian Wilde d28ff2afc1 Roadmap: Schriftverkehr als eigenen Abschnitt 1.2a fuehren
Das Thema stand als einzelne Zeile unter "Spaeter, ohne Datum" — also
unter "waere schoen, und ehrlich gesagt vielleicht nie". Nach der
Priorisierungsregel der Datei selbst faellt es unter Kriterium 2: Es
kostet jeden Monat Zeit, vor Vermoegensverzeichnis und Jahresbericht
besonders viel.

Neuer Abschnitt 1.2a mit begruendeten Unterpunkten zu #3 (Vertraege
aufspueren), #4 (Schreiben erzeugen, ablegen, nachhalten), #5 (E-Mail),
#6 (Serienabfrage der Rueckkaufswerte), #7 (eigene Vorlagen) und #11
(Dauerzahlungen gelten als belegt). Die Zeile unter "Spaeter, ohne
Datum" entfaellt, damit dasselbe Thema nicht zweimal steht.

"Rueckkaufswert am Vertrag" (1.1, #8) und "Ansprechpartner am Vorgang"
(1.2, #9) bleiben, wo sie stehen, sind aber als zugehoerig kenntlich
gemacht.

Refs #10
2026-08-23 01:52:09 +02:00
florian.wilde 8a594ee4a4 Merge #2: Seitenleiste bleibt stehen, Benutzermenue wieder erreichbar
Behebt Issue #1: "Mein Konto" war am Schreibtisch praktisch nicht zu
finden, weil die Seitenleiste mit der Seite mitwuchs und ihr Fuss am
Seitenende landete.

Closes #1
2026-08-22 23:03:53 +00:00
Florian Wilde 0128bf71e7 Seitenleiste bleibt stehen, Benutzermenue wieder erreichbar
Am Schreibtisch war "Mein Konto" praktisch nicht zu finden - und damit
auch nicht Passwortwechsel, zweiter Faktor und Passkey-Verwaltung.

Die Seitenleiste hatte keine feste Hoehe und wuchs mit der Seite mit. Ihr
Fuss mit dem Benutzermenue landete deshalb am Ende der SEITE statt am
unteren Bildschirmrand: auf /heute bei 1632 Pixel in einem 900 Pixel
hohen Fenster. Nachgemessen, nicht geraten - im Browser auf fuenf Seiten.

Der Fehler sah nach nichts aus. Kein Element fehlte, nichts war kaputt,
die Seite sah richtig aus. Man musste nur ganz nach unten scrollen.

Behoben mit lg:sticky, lg:top-0, lg:h-dvh und lg:self-start. Das
self-start gehoert dazu, weil ein Flex-Kind sonst auf die volle
Zeilenhoehe gestreckt wird und die feste Hoehe wieder verliert. Die
Navigation dazwischen hat bereits overflow-y-auto und scrollt bei Bedarf
in sich selbst.

Am Handy war es nie ein Problem - dort sitzt das Menue in der ohnehin
festen Kopfzeile.

Neu: e2e/benutzermenue.spec.ts fragt nicht, ob das Menue im DOM ist,
sondern ob es im sichtbaren Bereich steht - auf fuenf Seiten, an drei
Scrollpositionen, auf einem flachen Laptopfenster und am Handy. Die
Position in der Mitte ist Absicht: Am Seitenende wird eine mitwachsende
Leiste zufaellig doch sichtbar, der Test waere gruen und der Fehler
trotzdem da. Beide Sichtbarkeitstests sind gegen den kaputten Zustand
gegengeprueft und schlagen dort an.

Refs #1
2026-08-23 01:00:22 +02:00
23 changed files with 2238 additions and 25 deletions
+4
View File
@@ -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/
+108 -1
View File
@@ -26,6 +26,111 @@ 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
- **„Mein Konto" war am Schreibtisch praktisch nicht zu finden**
([#1](https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/issues/1)).
Damit kam man dort auch nicht an den Passwortwechsel, den zweiten Faktor
und die frisch eingebaute Passkey-Verwaltung.
Ursache: Die Seitenleiste hatte keine feste Höhe und wuchs mit der Seite
mit. Ihr Fuß — und damit das Benutzermenü — landete deshalb am **Ende der
Seite** statt am unteren Bildschirmrand. Auf „Heute" waren das 1632 Pixel
bei 900 Pixel Fensterhöhe: Man musste bis ganz nach unten scrollen, um
an sein eigenes Konto zu kommen.
Der Fehler sah nach nichts aus. Kein Element fehlte, nichts war kaputt,
die Seite sah richtig aus — und genau deshalb sucht man an der falschen
Stelle.
Die Leiste bleibt jetzt stehen (`sticky`, Fensterhöhe). Kopf,
Betreutenauswahl und Fuß sind immer sichtbar; die Navigation dazwischen
scrollt bei Bedarf in sich selbst. Am Handy war es nie ein Problem —
dort sitzt das Menü in der ohnehin festen Kopfzeile.
### Geprüft
- Fünf End-to-End-Tests (`e2e/benutzermenue.spec.ts`) fragen nicht, ob das
Menü **im DOM** ist, sondern ob es **im sichtbaren Bereich** steht: auf
fünf verschiedenen Seiten, an drei Scrollpositionen, auf einem flachen
Laptopfenster (1280 × 620) und am Handy.
Die Scrollposition in der Mitte ist Absicht. Nur oben und unten zu messen
hätte nicht gereicht: Am Seitenende wird eine mitwachsende Leiste
zufällig doch sichtbar — der Test wäre grün gewesen und der Fehler
trotzdem da. Beide Sichtbarkeitstests sind gegen den kaputten Zustand
gegengeprüft und schlagen dort an.
## [1.1.1] – 2026-08-23
### Behoben
@@ -511,7 +616,9 @@ 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.1...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
[1.0.5]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.0.4...v1.0.5
+4 -4
View File
@@ -580,11 +580,11 @@ Fehler, die während des Baus aufgefallen sind.
| | | nachzuzählen mit |
|---|---:|---|
| Zeilen TypeScript/TSX | 72.080 | `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) | 37 | `npm run test:e2e` |
| 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
+6 -6
View File
@@ -131,14 +131,14 @@ cd /opt/betreuungs-cockpit
# Auf die letzte veröffentlichte Fassung wechseln,
# nicht auf den laufenden Entwicklungsstand
git checkout v1.1.1
git checkout v1.2.0
```
### Kontrolle
```bash
ls docker-compose.yml Dockerfile .env.example # alle drei da
git describe --tags # v1.1.1
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.1","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.1",...}
# {"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.1 · <commit>`.
- Unten links in der Seitenleiste steht `Version 1.2.0 · <commit>`.
---
@@ -913,7 +913,7 @@ git fetch --tags
git log --oneline HEAD..origin/main
# 3. Umschalten
git checkout v1.1.1
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
+108 -5
View File
@@ -9,7 +9,7 @@ Zeit spart. Er steht weit unten, wenn er nur schön wäre.
Diese Datei ist eine Absichtserklärung, kein Vertrag. Was sich im Betrieb
als wichtiger herausstellt, rutscht nach oben.
Stand: 23.08.2026 · Aktuelle Fassung: **1.1.1** ([CHANGELOG](../CHANGELOG.md))
Stand: 23.08.2026 · Aktuelle Fassung: **1.1.2** ([CHANGELOG](../CHANGELOG.md))
---
@@ -61,9 +61,10 @@ Auszug erzeugt dann doppelte Buchungen.
Der Wert hängt heute am Vermögensposten. In der Vertragsübersicht steht
deshalb kein Betrag — ausgerechnet dort, wo man ihn beim Telefonat mit dem
Versicherer braucht.
Versicherer braucht. Es ist derselbe Wert, um den es im Anschreiben an den
Versicherer geht — siehe 1.2a.
*Aufwand: klein.*
*Aufwand: klein. Issue: [#8](https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/issues/8).*
### Genau eine Zielspalte je Dokumentverknüpfung
@@ -120,7 +121,10 @@ Benutzer. Bei einer Betreuung zu zweit ist das die erste Frage.
### Ansprechpartner am Vorgang
Die Sachstandsanfrage kann namentlich anreden, bekommt den Ansprechpartner
mangels Feld aber nicht übergeben.
mangels Feld aber nicht übergeben. Gehört zum Schriftverkehr in 1.2a: dort
entsteht das Schreiben, hier fehlt ihm die Anrede.
*Issue: [#9](https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/issues/9).*
### Inventarliste als eigenes Modell
@@ -136,6 +140,106 @@ Jahresbericht und bei einer Höherstufung gebraucht.
---
## 1.2a — Schriftverkehr, der sich nicht wiederholt
**Ziel:** Ein Schreiben wird einmal geschrieben, einmal ausgegeben und
einmal abgelegt — statt viermal angefasst.
Die Anschreiben an Versicherer und Behörden gibt es heute nur als Text zum
Markieren und Kopieren. Was danach kommt, macht man von Hand: in Word
nachbauen, ausdrucken, abschicken und anschließend noch einmal als Dokument
in die Akte legen. Vergessen wird regelmäßig der letzte Schritt — und dann
fehlt der Nachweis genau dort, wo er später gebraucht wird.
Nach der Priorisierungsregel am Ende dieser Datei ist das Kriterium 2: Es
kostet jeden Monat Zeit, und einmal im Jahr — vor Vermögensverzeichnis und
Jahresbericht — besonders viel. Bisher stand das Thema als einzelne Zeile
unter „Später, ohne Datum", also unter „wäre schön, und ehrlich gesagt
vielleicht nie". Das war zu tief gehängt.
Die Punkte bauen aufeinander auf und stehen deshalb in dieser Reihenfolge.
### Verträge überhaupt erst finden
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 System: **Wer regelmäßig abbucht,
hat einen Vertrag.** Eine Auswertung der Gegenstellen nach wiederkehrenden
Zahlungen macht aus hunderten Buchungszeilen eine Liste von Gesellschaften,
an die zu schreiben ist. Solange die fehlt, nützt die beste Briefvorlage
nichts — deshalb steht dieser Punkt vor allen anderen.
*Issue: [#3](https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/issues/3).*
### Schreiben erzeugen, ablegen, nachhalten
Das Kernstück: eine Word-Datei statt Text zum Kopieren, Ablage als
Ausgangsdokument mit Empfänger und Versanddatum, Wiedervorlage mit
vorbelegter Frist. Dazu Empfänger, zu denen es noch keinen Datensatz gibt —
am Anfang schreibt man ja gerade an Stellen, von denen man erst erfahren
will, ob dort etwas besteht. Der Text bleibt vor der Ausgabe änderbar, weil
jeder Fall eine Besonderheit hat, die in den Brief gehört. Die Bausteine
liegen bereits im Code; was fehlt, ist der durchgehende Weg. Alles Weitere
in diesem Abschnitt setzt darauf auf.
*Issue: [#4](https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/issues/4).*
### Dasselbe Schreiben als E-Mail
Vieles geht per Mail raus, nicht per Brief. Dafür müssen Betreff und Text
getrennt vorliegen und Absenderblock, Anschrift und Datumszeile
verschwinden. Auseinandergebaut wird das heute von Hand, und dabei fällt
regelmäßig etwas heraus, das drinbleiben sollte.
*Issue: [#5](https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/issues/5).*
### Rückkaufswerte in einem Zug anfragen
Vor Vermögensverzeichnis und Jahresbericht sind es jedes Mal dieselben fünf
bis zehn Briefe an dieselben Gesellschaften — einzeln erzeugt, einzeln
nachgehalten, und bei zwei Dritteln muss nachgefasst werden. Das ist der
Handgriff, der im Jahr am meisten Zeit kostet, und derjenige, der sich am
einfachsten bündeln lässt, sobald ein Schreiben ohnehin auf Knopfdruck
entsteht.
*Issue: [#6](https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/issues/6).*
### Eigene Vorlagen
Die Brieftexte stehen fest im Programmcode. Formulierungen, die sich bei
einer bestimmten Stelle bewährt haben, lassen sich nicht hinterlegen, und
für Schreiben ohne mitgelieferte Vorlage — Auskunftsersuchen,
Vollmachtsnachweis, Adressänderung — fängt man bei null an. Wer eine
Vorlage selbst schreibt, vergisst als Erstes den Bestellungsabsatz; ohne
den antwortet keine Versicherung, und dann ist ein Monat vergangen. Die
Vorlagenverwaltung muss ihn deshalb einfordern, statt ihn nur anzubieten.
*Issue: [#7](https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/issues/7).*
### Dauerzahlungen gelten als belegt
Ist ein Vertrag erst erfasst, ist er auch der Beleg für die Zahlungen
daraus. Für zwölf gleiche Lastschriften will niemand zwölf Quittungen
sehen, sondern die Police. Heute bleibt dafür nur ein Freitextfeld je
Buchung — stumpfe Arbeit mit einem Ergebnis, das schwächer aussieht als
die Aktenlage tatsächlich ist. Das Feld gehört in dieselbe Migration wie
`Buchung.immobilieId` aus 1.1; zwei Wanderungen an derselben Tabelle wären
Verschwendung.
*Issue: [#11](https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/issues/11). Migration: ein Feld, ein Index — gebündelt mit 1.1.*
### Was in 1.1 und 1.2 dazugehört
Zwei Punkte weiter oben sind Teil derselben Sache und bleiben trotzdem
stehen, wo sie stehen: „Rückkaufswert am Vertrag" ist ein möglicher
Fehler und deshalb 1.1, „Ansprechpartner am Vorgang" ein Handgriff und
deshalb 1.2. Gelesen gehören sie hierher — der eine liefert den Wert, um
den das Anschreiben bittet, der andere die namentliche Anrede.
*Issues: [#8](https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/issues/8) und [#9](https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/issues/9).*
---
## 1.2b — Passkeys abrunden
Der Grundstock steht seit 1.1.0. Was noch fehlt, ist Bequemlichkeit, nicht
@@ -254,7 +358,6 @@ Sinnvoll, aber nicht dringend:
- **Kalendarische Wiedervorlage per E-Mail als Wochenübersicht** statt
einzelner Erinnerungen.
- **Vorlagen für eigene Anschreiben**, über die mitgelieferten hinaus.
- **Import eines Grundbuchauszugs** als strukturierte Daten statt als PDF.
- **Mehrere Betreuungsgerichte** je betreuter Person (bei Umzug).
- **Zwei-Personen-Prinzip** für besonders sensible Aktionen — technisch
+117
View File
@@ -0,0 +1,117 @@
import { expect, test } from '@playwright/test'
import type { Page } from '@playwright/test'
import { anmelden } from './hilfen'
/**
* Das Benutzermenü muss erreichbar sein, ohne danach zu suchen.
*
* Anlass ist Issue #1: Am Schreibtisch war „Mein Konto" praktisch nicht
* zu finden — und damit auch nicht der Passwortwechsel, der zweite Faktor
* und die Passkey-Verwaltung. Die Seitenleiste hatte keine feste Höhe und
* wuchs mit der Seite mit; ihr Fuß landete deshalb am Ende der Seite statt
* am unteren Bildschirmrand. Auf `/heute` waren das 1632 Pixel bei 900
* Pixel Fensterhöhe.
*
* Der Fehler sah nach nichts aus: Kein Element fehlte, nichts war kaputt,
* die Seite sah richtig aus. Man musste nur ans Seitenende scrollen — und
* darauf kommt niemand, der eine Seitenleiste vor sich hat.
*
* Geprüft wird deshalb nicht „ist das Element im DOM", sondern „steht es
* im sichtbaren Bereich".
*/
/** Der Auslöser des Benutzermenüs in der Seitenleiste am Schreibtisch. */
function ausloeserInSeitenleiste(seite: Page) {
return seite.locator('aside button[aria-haspopup="menu"]')
}
/** Steht das Element vollständig im Fenster, ohne zu scrollen? */
async function imSichtbarenBereich(seite: Page, auswahl: string): Promise<boolean> {
return seite.evaluate((sel) => {
const el = document.querySelector(sel)
if (!el) return false
const k = el.getBoundingClientRect()
return k.top >= 0 && k.bottom <= window.innerHeight && k.height > 0
}, auswahl)
}
test.describe('Benutzermenü am Schreibtisch', () => {
test.skip(({ isMobile }) => Boolean(isMobile), 'Am Handy sitzt es in der Kopfzeile.')
test('steht auch auf langen Seiten im Bild, ohne zu scrollen', async ({ page }) => {
await anmelden(page)
// Seiten, die laenger sind als das Fenster - genau dort trat der
// Fehler auf. /fristen ist als Gegenprobe kurz.
for (const ziel of ['/heute', '/vorgaenge', '/dokumente', '/finanzen', '/fristen']) {
await page.goto(ziel)
await page.waitForLoadState('networkidle')
const sichtbar = await imSichtbarenBereich(page, 'aside button[aria-haspopup="menu"]')
expect(sichtbar, `Benutzermenü nicht im Bild auf ${ziel}`).toBe(true)
}
})
test('bleibt an jeder Scrollposition stehen', async ({ page }) => {
await anmelden(page)
await page.goto('/heute')
await page.waitForLoadState('networkidle')
// Bewusst auch die Mitte pruefen. Nur oben und unten zu messen genuegt
// nicht: Eine mitwachsende Leiste ist am Seitenende zufaellig doch
// sichtbar - der Test waere dann gruen und der Fehler trotzdem da.
for (const anteil of [0, 0.5, 1]) {
await page.evaluate((a) => {
const hoehe = document.documentElement.scrollHeight - window.innerHeight
window.scrollTo(0, hoehe * a)
}, anteil)
await page.waitForTimeout(250)
const sichtbar = await imSichtbarenBereich(page, 'aside button[aria-haspopup="menu"]')
expect(sichtbar, `Benutzermenü nicht im Bild bei ${anteil * 100}% Scrollposition`).toBe(true)
}
})
test('führt in zwei Klicks nach „Mein Konto"', async ({ page }) => {
await anmelden(page)
await page.goto('/heute')
await ausloeserInSeitenleiste(page).click()
await page.getByRole('menuitem', { name: 'Mein Konto' }).click()
await page.waitForURL('**/konto')
// Und dort steht auch, worum es im Issue ging.
await expect(page.getByRole('heading', { name: 'Mein Konto' })).toBeVisible()
await expect(page.getByRole('heading', { name: 'Passwort ändern' })).toBeVisible()
await expect(page.getByRole('heading', { name: 'Passkeys' })).toBeVisible()
})
test('bleibt auf einem flachen Fenster erreichbar', async ({ page }) => {
// Ein Laptop mit 1280x620 ist knapp: Kopf, Betreutenauswahl,
// Navigation und Fuss muessen zusammen hineinpassen. Die Navigation
// dazwischen darf scrollen, der Fuss nicht verschwinden.
await anmelden(page)
await page.setViewportSize({ width: 1280, height: 620 })
await page.goto('/heute')
await page.waitForLoadState('networkidle')
expect(await imSichtbarenBereich(page, 'aside button[aria-haspopup="menu"]')).toBe(true)
})
})
test.describe('Benutzermenü am Handy', () => {
test.skip(({ isMobile }) => !isMobile, 'Am Schreibtisch sitzt es in der Seitenleiste.')
test('steht in der Kopfzeile und bleibt beim Scrollen stehen', async ({ page }) => {
await anmelden(page)
await page.goto('/heute')
await page.waitForLoadState('networkidle')
expect(await imSichtbarenBereich(page, 'header button[aria-haspopup="menu"]')).toBe(true)
await page.evaluate(() => window.scrollTo(0, document.documentElement.scrollHeight))
await page.waitForTimeout(300)
expect(await imSichtbarenBereich(page, 'header button[aria-haspopup="menu"]')).toBe(true)
})
})
+181
View File
@@ -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<T>(arbeit: (db: PrismaClient) => Promise<T>): Promise<T> {
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<void> {
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<void> {
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)
})
})
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "betreuungs-cockpit",
"version": "1.1.1",
"version": "1.2.0",
"private": true,
"description": "Arbeits- und Dokumentationswerkzeug fuer die ehrenamtliche rechtliche Betreuung",
"engines": {
@@ -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;
+37
View File
@@ -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())
+20 -1
View File
@@ -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() {
/>
</div>
{offeneGruppen > 0 && (
<Alert variant="hinweis">
<Repeat aria-hidden="true" />
<AlertDescription className="flex flex-wrap items-center justify-between gap-3">
<span>
<strong>{offeneGruppen}</strong> regelmäßige Abbuchung
{offeneGruppen === 1 ? '' : 'en'} ohne hinterlegten Vertrag. Wer regelmäßig
abbucht, hat einen — und der fehlt dann im Vermögensverzeichnis.
</span>
<Button asChild size="sm" variant="outline">
<Link href="/finanzen/wiederkehrend">Ansehen</Link>
</Button>
</AlertDescription>
</Alert>
)}
{zahlen.vermischungen > 0 && <Vermischungswarnung />}
<Hinweisliste hinweise={hinweise} titel="Das solltest du nachtragen" />
@@ -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 (
<div className="space-y-6">
<SeitenKopf
titel="Wiederkehrende Zahlungen"
beschreibung="Wer regelmäßig abbucht, hat einen Vertrag. Hier stehen die Kandidaten, zu denen noch keiner in der Akte liegt."
aktionen={
<Button asChild variant="outline">
<Link href="/finanzen/buchungen">Zu den Buchungen</Link>
</Button>
}
/>
{bild.ausgabenGesamt === 0 ? (
<Leerzustand
icon={FileSearch}
titel="Noch keine Ausgaben erfasst"
beschreibung="Diese Auswertung liest die Kontobewegungen. Importiere zuerst einen Kontoauszug, dann findest du hier die regelmäßigen Abbuchungen."
aktion={
<Button asChild>
<Link href="/finanzen/buchungen/import">Kontoauszug einlesen</Link>
</Button>
}
/>
) : (
<>
{/* --- Kandidaten ohne Vertrag ----------------------------------- */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Repeat className="size-5" aria-hidden="true" />
Ohne hinterlegten Vertrag
{ohneVertrag.length > 0 && (
<Badge variant="secondary">{ohneVertrag.length}</Badge>
)}
</CardTitle>
<CardDescription>
Nach Jahresbetrag sortiert — was am meisten kostet, will man zuerst geklärt haben.
</CardDescription>
</CardHeader>
<CardContent>
{ohneVertrag.length === 0 ? (
<p className="text-sm text-muted-foreground">
Zu jeder regelmäßigen Abbuchung liegt ein Vertrag in der Akte. Das ist der
Zustand, den man haben will.
</p>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Empfänger</TableHead>
<TableHead>Rhythmus</TableHead>
<TableHead className="text-right">je Zahlung</TableHead>
<TableHead className="text-right">im Jahr</TableHead>
<TableHead>Zeitraum</TableHead>
{schreiben && <TableHead className="text-right">Und jetzt?</TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{ohneVertrag.map((gruppe) => (
<TableRow key={gruppe.schluessel}>
<TableCell className="align-top">
<p className="font-medium">{gruppe.anzeigename}</p>
{gruppe.schreibweisen.length > 1 && (
<p className="mt-0.5 text-xs text-muted-foreground">
{gruppe.schreibweisen.length} Schreibweisen zusammengefasst
</p>
)}
</TableCell>
<TableCell className="align-top">
<span
className={
gruppe.rhythmus === 'UNREGELMAESSIG'
? 'text-muted-foreground'
: undefined
}
>
{RHYTHMUS_BESCHRIFTUNG[gruppe.rhythmus]}
</span>
<p className="mt-0.5 text-xs text-muted-foreground">
{rhythmusErklaerung(gruppe)}
</p>
</TableCell>
<TableCell className="text-right align-top">
<Betrag cent={centZuZahl(gruppe.betragCent)} />
{gruppe.betragSchwankt && (
<p className="mt-0.5 text-xs text-ampel-orange">
schwankt: {formatCent(centZuZahl(gruppe.kleinsterBetragCent))} bis{' '}
{formatCent(centZuZahl(gruppe.groessterBetragCent))}
</p>
)}
</TableCell>
<TableCell className="text-right align-top">
<Betrag cent={centZuZahl(gruppe.jahresbetragCent)} />
{gruppe.rhythmus === 'UNREGELMAESSIG' && (
<p className="mt-0.5 text-xs text-muted-foreground">
tatsächlich gezahlt
</p>
)}
</TableCell>
<TableCell className="align-top text-sm text-muted-foreground">
{formatDatum(gruppe.ersteZahlung)}
<br />
bis {formatDatum(gruppe.letzteZahlung)}
</TableCell>
{schreiben && (
<TableCell className="align-top">
<div className="flex flex-col items-end gap-2">
<Button asChild size="sm" variant="outline">
<Link
href={`/vertraege/neu?gesellschaft=${encodeURIComponent(
gruppe.anzeigename,
)}&beitragCent=${gruppe.betragCent}&rhythmus=${gruppe.rhythmus}`}
>
<Plus aria-hidden="true" />
Vertrag anlegen
</Link>
</Button>
<AlsGeklaertAbhaken
betreuteId={betreute.id}
schluessel={gruppe.schluessel}
anzeigename={gruppe.anzeigename}
/>
</div>
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
{/* --- Einzelne groessere Zahlungen ------------------------------ */}
{bild.einzelne.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base">Einzelne größere Zahlungen</CardTitle>
<CardDescription>
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))}.
</CardDescription>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Empfänger</TableHead>
<TableHead>Datum</TableHead>
<TableHead className="text-right">Betrag</TableHead>
<TableHead>Vertrag</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{bild.einzelne.map((zahlung) => (
<TableRow key={zahlung.buchungId}>
<TableCell className="font-medium">{zahlung.anzeigename}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{formatDatum(zahlung.datum)}
</TableCell>
<TableCell className="text-right">
<Betrag cent={centZuZahl(zahlung.betragCent)} />
</TableCell>
<TableCell className="text-sm">
{zahlung.vertragBezeichnung ? (
<Badge variant="secondary">{zahlung.vertragBezeichnung}</Badge>
) : (
<span className="text-muted-foreground">keiner</span>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
)}
{/* --- Bereits erklaert ------------------------------------------ */}
{mitVertrag.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base">Schon einem Vertrag zugeordnet</CardTitle>
<CardDescription>
Erkannt am Namen der Gesellschaft. Steht hier etwas Falsches, stimmt die
Schreibweise am Vertrag nicht mit der auf dem Kontoauszug überein.
</CardDescription>
</CardHeader>
<CardContent>
<ul className="divide-y text-sm">
{mitVertrag.map((gruppe) => (
<li
key={gruppe.schluessel}
className="flex flex-wrap items-center justify-between gap-2 py-2"
>
<span>
<span className="font-medium">{gruppe.anzeigename}</span>
<span className="ml-2 text-muted-foreground">
{RHYTHMUS_BESCHRIFTUNG[gruppe.rhythmus]},{' '}
{formatCent(centZuZahl(gruppe.jahresbetragCent))} im Jahr
</span>
</span>
<Button asChild variant="ghost" size="sm">
<Link href={`/vertraege/${gruppe.vertragId}`}>
{gruppe.vertragBezeichnung}
</Link>
</Button>
</li>
))}
</ul>
</CardContent>
</Card>
)}
{/* --- Abgehakt --------------------------------------------------- */}
{bild.abgehakt.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base">Als „kein Vertrag“ abgehakt</CardTitle>
<CardDescription>
Diese Empfänger bleiben aus der Liste heraus, auch nach dem nächsten Import.
</CardDescription>
</CardHeader>
<CardContent>
<ul className="divide-y text-sm">
{bild.abgehakt.map((eintrag) => (
<li
key={eintrag.id}
className="flex flex-wrap items-center justify-between gap-2 py-2"
>
<span>
<span className="font-medium">{eintrag.anzeigename}</span>
{eintrag.begruendung && (
<span className="ml-2 text-muted-foreground">
— {eintrag.begruendung}
</span>
)}
</span>
{schreiben && (
<Zurueckholen betreuteId={betreute.id} eintragId={eintrag.id} />
)}
</li>
))}
</ul>
</CardContent>
</Card>
)}
<Alert variant="hinweis">
<AlertCircle aria-hidden="true" />
<AlertDescription>
Diese Seite wertet nur aus, sie legt nichts an. Gruppiert wird nach dem Empfänger
aus dem Kontoauszug — Groß- und Kleinschreibung, Satzzeichen und die Rechtsform
werden dabei zusammengefasst. Ähnliche, aber verschiedene Namen bleiben bewusst
getrennt: „Stadtwerke Bamberg“ und „Stadtwerke Bayreuth“ sind zwei Rechnungssteller.
</AlertDescription>
</Alert>
</>
)}
</div>
)
}
+66 -2
View File
@@ -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<string, string | string[] | undefined>) {
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<Record<string, string | string[] | undefined>>
}) {
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 (
<div className="space-y-6">
<SeitenKopf
@@ -25,7 +78,18 @@ export default async function VertragAnlegen() {
]}
/>
<VertragFormular betreuteId={aktive.id} werte={LEERER_VERTRAG} />
{etwasUebernommen && (
<Alert variant="hinweis">
<Info aria-hidden="true" />
<AlertDescription>
Gesellschaft, Beitrag und Rhythmus sind aus den wiederkehrenden Zahlungen
übernommen. Bezeichnung und Art musst du selbst wählen — was für ein Vertrag das
ist, steht nicht im Kontoauszug.
</AlertDescription>
</Alert>
)}
<VertragFormular betreuteId={aktive.id} werte={werte} />
</div>
)
}
+15 -2
View File
@@ -56,8 +56,21 @@ export function AppHuelle({
Zum Inhalt springen
</a>
{/* --- Seitenleiste am Schreibtisch --------------------------------- */}
<aside className="no-print hidden w-72 shrink-0 border-r bg-card lg:flex lg:flex-col">
{/* --- Seitenleiste am Schreibtisch ---------------------------------
sticky + h-dvh, nicht einfach mitwachsen lassen: Ohne feste Hoehe
wird die Leiste so hoch wie die laengste Seite. Der Fuss mit dem
Benutzermenue - und damit der einzige Weg zu "Mein Konto",
Passwort, zweitem Faktor und Passkeys - landet dann am Ende der
Seite statt am unteren Bildschirmrand. Auf /heute waren das 1632
Pixel bei 900 Pixel Fensterhoehe: unerreichbar, ohne dass etwas
kaputt aussah.
self-start gehoert dazu, weil ein Flex-Kind sonst auf die volle
Zeilenhoehe gestreckt wird und die feste Hoehe wieder verliert.
Die Navigation dazwischen hat bereits overflow-y-auto und scrollt
bei Bedarf in sich selbst. */}
<aside className="no-print hidden w-72 shrink-0 border-r bg-card lg:sticky lg:top-0 lg:flex lg:h-dvh lg:flex-col lg:self-start">
<div className="flex h-16 items-center border-b px-5">
<Link href="/heute" className="flex items-center gap-2 font-semibold">
<span
+28 -2
View File
@@ -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 (
<Kachel
@@ -72,6 +75,29 @@ export function BelegeKachel({ daten }: { daten: BelegeDaten | null }) {
</section>
)}
{daten.wiederkehrendOhneVertrag > 0 && (
<section aria-labelledby="wiederkehrend-ohne-vertrag">
<h3
id="wiederkehrend-ohne-vertrag"
className="mb-1 text-sm font-semibold uppercase tracking-wide text-muted-foreground"
>
Regelmäßige Abbuchungen ohne Vertrag ({daten.wiederkehrendOhneVertrag})
</h3>
<KachelZeile href="/finanzen/wiederkehrend">
<span className="min-w-0 flex-1">
<span className="flex items-center gap-2 font-medium">
<Repeat className="size-4 shrink-0" aria-hidden="true" />
{daten.wiederkehrendOhneVertrag} Empfänger ansehen
</span>
<span className="block text-sm text-muted-foreground">
Wer regelmäßig abbucht, hat einen Vertrag — und der fehlt dann im
Vermögensverzeichnis.
</span>
</span>
</KachelZeile>
</section>
)}
{daten.buchungenOhneBelegGesamt > 0 && (
<section aria-labelledby="buchungen-ohne-beleg">
<h3
@@ -0,0 +1,128 @@
'use client'
import * as React from 'react'
import { useActionState } from 'react'
import { AlertCircle, CheckCircle2, Undo2, XCircle } from 'lucide-react'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Absendeknopf } from '@/components/finanzen/eingaben'
import {
zahlungsgruppeAbhaken,
zahlungsgruppeZurueckholen,
type Formularzustand,
} from '@/server/actions/finanzen'
/**
* „Kein Vertrag" — eine Gruppe dauerhaft aus der Liste nehmen.
*
* Die Begründung ist freiwillig, steht aber im Formular ganz vorn. In einem
* Jahr weiß niemand mehr, warum „Stadtwerke Bamberg" abgehakt wurde, und
* dann fängt die Prüfung von vorn an. Ein halber Satz jetzt spart das.
*/
export function AlsGeklaertAbhaken({
betreuteId,
schluessel,
anzeigename,
}: {
betreuteId: string
schluessel: string
anzeigename: string
}) {
const [zustand, absenden] = useActionState<Formularzustand, FormData>(
zahlungsgruppeAbhaken,
{},
)
const [offen, setOffen] = React.useState(false)
if (!offen) {
return (
<div className="space-y-2">
{zustand.fehler && (
<Alert variant="fehler">
<AlertCircle aria-hidden="true" />
<AlertDescription>{zustand.fehler}</AlertDescription>
</Alert>
)}
<Button type="button" variant="ghost" size="sm" onClick={() => setOffen(true)}>
<XCircle aria-hidden="true" />
Kein Vertrag
</Button>
</div>
)
}
return (
<form action={absenden} className="space-y-2 rounded-md border p-3">
<input type="hidden" name="betreuteId" value={betreuteId} />
<input type="hidden" name="schluessel" value={schluessel} />
<input type="hidden" name="anzeigename" value={anzeigename} />
<p className="text-sm">
„{anzeigename}“ verschwindet aus der Liste. Zurückholen geht jederzeit.
</p>
<Input
name="begruendung"
placeholder="Warum kein Vertrag? (z. B. Miete, Strom, Steuer)"
maxLength={200}
autoFocus
/>
<div className="flex gap-2">
<Absendeknopf size="sm" wartetext="Wird abgehakt …">
Abhaken
</Absendeknopf>
<Button type="button" variant="ghost" size="sm" onClick={() => setOffen(false)}>
Abbrechen
</Button>
</div>
</form>
)
}
/** Holt eine abgehakte Gruppe zurück in die Liste. */
export function Zurueckholen({
betreuteId,
eintragId,
}: {
betreuteId: string
eintragId: string
}) {
const [zustand, absenden] = useActionState<Formularzustand, FormData>(
zahlungsgruppeZurueckholen,
{},
)
return (
<form action={absenden} className="inline">
<input type="hidden" name="betreuteId" value={betreuteId} />
<input type="hidden" name="eintragId" value={eintragId} />
{zustand.fehler && (
<span className="mr-2 text-xs text-destructive">{zustand.fehler}</span>
)}
<Absendeknopf variant="ghost" size="sm" wartetext="…">
<Undo2 aria-hidden="true" />
Zurückholen
</Absendeknopf>
</form>
)
}
/**
* 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 (
<Alert variant="erfolg">
<CheckCircle2 aria-hidden="true" />
<AlertDescription>{text}</AlertDescription>
</Alert>
)
}
+7
View File
@@ -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
+9
View File
@@ -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
}
// -----------------------------------------------------------------------------
+82
View File
@@ -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<WiederkehrendBild> {
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<number> {
const bild = await wiederkehrendLaden(betreuteId)
return bild.offen
}
+512
View File
@@ -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<Rhythmus, string> = {
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<Rhythmus, number> = {
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<string>
/** Hinterlegte Vertraege, zum Abgleich. */
vertraege?: VertragAbgleich[]
}
/** Ordnet Vertraege ihrem normalisierten Gesellschaftsnamen zu. */
function vertragsIndex(vertraege: VertragAbgleich[]): Map<string, VertragAbgleich> {
const index = new Map<string, VertragAbgleich>()
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<string>()
const index = vertragsIndex(optionen.vertraege ?? [])
const nachSchluessel = new Map<string, ZahlungEingabe[]>()
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<string>()
const index = vertragsIndex(optionen.vertraege ?? [])
const anzahlJeSchluessel = new Map<string, number>()
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
}
+1 -1
View File
@@ -17,7 +17,7 @@
*/
/** Muss zur "version" in der package.json passen. */
export const VERSION = '1.1.1'
export const VERSION = '1.2.0'
/**
* Kurzer Git-Stand, beim Bauen hineingereicht:
+103
View File
@@ -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<Formularzustand> {
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<Formularzustand> {
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.' }
}
+353
View File
@@ -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')
})
})