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
This commit was merged in pull request #13.
This commit is contained in:
2026-08-23 06:13:43 +00:00
20 changed files with 1960 additions and 18 deletions
+4
View File
@@ -43,3 +43,7 @@ npm-debug.log*
# Bildschirmfotos entstehen beim Entwickeln neu # Bildschirmfotos entstehen beim Entwickeln neu
screenshots/ screenshots/
daten/dokumente/ daten/dokumente/
# Arbeitsbereiche paralleler Agenten (git worktrees). Enthalten einen
# vollstaendigen zweiten Checkout - der gehoert nicht ins Repository.
.claude/
+70 -1
View File
@@ -26,6 +26,74 @@ Wiederherstellung*.
_Noch nichts._ _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 ## [1.1.2] – 2026-08-23
### Behoben ### 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.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.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.1.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.0.5...v1.1.0
+4 -4
View File
@@ -580,11 +580,11 @@ Fehler, die während des Baus aufgefallen sind.
| | | nachzuzählen mit | | | | nachzuzählen mit |
|---|---:|---| |---|---:|---|
| Zeilen TypeScript/TSX | 73.774 | `git ls-files "*.ts" "*.tsx" \| xargs wc -l` | | Zeilen TypeScript/TSX | 74.119 | `git ls-files "*.ts" "*.tsx" \| xargs wc -l` |
| Prisma-Modelle | 33 | `grep -c '^model ' prisma/schema.prisma` | | Prisma-Modelle | 34 | `grep -c '^model ' prisma/schema.prisma` |
| Seiten | 79 | `find src/app -name page.tsx \| wc -l` | | Seiten | 79 | `find src/app -name page.tsx \| wc -l` |
| Unit-Tests (Vitest) | 500 | `npm test` | | Unit-Tests (Vitest) | 537 | `npm test` |
| End-to-End-Tests (Playwright) | 45 | `npm run test:e2e` — 26 Tests über zwei Geräteprofile, geräteabhängige übersprungen | | 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` | | TypeScript-Fehler | 0 | `npm run typecheck` |
Die Befehle stehen dabei, weil die Zahlen sonst altern, ohne dass es 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, # Auf die letzte veröffentlichte Fassung wechseln,
# nicht auf den laufenden Entwicklungsstand # nicht auf den laufenden Entwicklungsstand
git checkout v1.1.2 git checkout v1.2.0
``` ```
### Kontrolle ### Kontrolle
```bash ```bash
ls docker-compose.yml Dockerfile .env.example # alle drei da 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 ```bash
# Antwortet die Anwendung? (Port anpassen, falls du APP_PORT gesetzt hast) # Antwortet die Anwendung? (Port anpassen, falls du APP_PORT gesetzt hast)
curl -s http://127.0.0.1:3000/api/gesundheit 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: Kommt hier nichts, sieh in die Logs:
@@ -649,7 +649,7 @@ Erst **vom Proxy-Server aus** — kommt er überhaupt an?
```bash ```bash
curl -s http://10.0.0.5:3000/api/gesundheit 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 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. - Du kommst mit E-Mail, Passwort und dem Code aus der App herein.
- Unter **Fristen** stehen Einträge mit Paragraphenbezug. - Unter **Fristen** stehen Einträge mit Paragraphenbezug.
- Unter **Protokoll** siehst du deine eigene Anmeldung. - Unter **Protokoll** siehst du deine eigene Anmeldung.
- Unten links in der Seitenleiste steht `Version 1.1.2 · <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 git log --oneline HEAD..origin/main
# 3. Umschalten # 3. Umschalten
git checkout v1.1.2 git checkout v1.2.0
# 4. Neu bauen und starten # 4. Neu bauen und starten
GIT_COMMIT=$(git rev-parse --short HEAD) BUILD_ZEIT=$(date -Iseconds) docker compose up -d --build GIT_COMMIT=$(git rev-parse --short HEAD) BUILD_ZEIT=$(date -Iseconds) docker compose up -d --build
+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", "name": "betreuungs-cockpit",
"version": "1.1.2", "version": "1.2.0",
"private": true, "private": true,
"description": "Arbeits- und Dokumentationswerkzeug fuer die ehrenamtliche rechtliche Betreuung", "description": "Arbeits- und Dokumentationswerkzeug fuer die ehrenamtliche rechtliche Betreuung",
"engines": { "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[] dokumente Dokument[]
konten Konto[] konten Konto[]
buchungen Buchung[] buchungen Buchung[]
zahlungsgruppen ZahlungsgruppeGeklaert[]
vermoegensposten Vermoegensposten[] vermoegensposten Vermoegensposten[]
immobilien Immobilie[] immobilien Immobilie[]
vertraege Vertrag[] vertraege Vertrag[]
@@ -828,6 +829,42 @@ enum BuchungKategorie {
UMBUCHUNG 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 { model Buchung {
id String @id @default(cuid()) id String @id @default(cuid())
+20 -1
View File
@@ -1,5 +1,6 @@
import Link from 'next/link' 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 { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' 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 { KATEGORIE_BESCHRIFTUNG } from '@/lib/finanzen/kategorien'
import { laufendesBetreuungsjahr } from '@/lib/finanzen/betreuungsjahr' import { laufendesBetreuungsjahr } from '@/lib/finanzen/betreuungsjahr'
import { buchungenLaden, kontenLaden, kontostaendeZumStichtag, vermoegenLaden } from '@/lib/berichte/gemeinsam' import { buchungenLaden, kontenLaden, kontostaendeZumStichtag, vermoegenLaden } from '@/lib/berichte/gemeinsam'
import { offeneZahlungsgruppen } from '@/lib/finanzen/wiederkehrend-laden'
export const metadata = { title: 'Vermögen & Kasse' } export const metadata = { title: 'Vermögen & Kasse' }
@@ -48,6 +50,7 @@ export default async function FinanzenSeite() {
const zwoelfMonate = tagePlus(monatePlus(stichtag, -12), 1) const zwoelfMonate = tagePlus(monatePlus(stichtag, -12), 1)
const letzteZwoelf = buchungen.filter((b) => b.datum >= zwoelfMonate) const letzteZwoelf = buchungen.filter((b) => b.datum >= zwoelfMonate)
const zahlen = kennzahlen(letzteZwoelf) 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) 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> </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 />} {zahlen.vermischungen > 0 && <Vermischungswarnung />}
<Hinweisliste hinweise={hinweise} titel="Das solltest du nachtragen" /> <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 { redirect } from 'next/navigation'
import { Info } from 'lucide-react'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { SeitenKopf } from '@/components/seiten-kopf' import { SeitenKopf } from '@/components/seiten-kopf'
import { LEERER_VERTRAG, VertragFormular } from '@/components/vertraege/vertrag-formular' import { LEERER_VERTRAG, VertragFormular } from '@/components/vertraege/vertrag-formular'
import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung' 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 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 benutzer = await erfordereBenutzer('/vertraege/neu')
const aktive = await aktiveBetreute(benutzer) const aktive = await aktiveBetreute(benutzer)
if (!aktive) return null if (!aktive) return null
@@ -13,6 +64,8 @@ export default async function VertragAnlegen() {
const { darfSchreiben } = await erfordereZugang(aktive.id, 'VERTRAEGE', 'lesen') const { darfSchreiben } = await erfordereZugang(aktive.id, 'VERTRAEGE', 'lesen')
if (!darfSchreiben('VERTRAEGE')) redirect('/vertraege') if (!darfSchreiben('VERTRAEGE')) redirect('/vertraege')
const { werte, etwasUebernommen } = vorbelegung(parameter)
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<SeitenKopf <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> </div>
) )
} }
+28 -2
View File
@@ -1,5 +1,5 @@
import Link from 'next/link' import Link from 'next/link'
import { ReceiptText } from 'lucide-react' import { Repeat, ReceiptText } from 'lucide-react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Leerzustand } from '@/components/seiten-kopf' import { Leerzustand } from '@/components/seiten-kopf'
import { Kachel, KachelZeile } from '@/components/dashboard/kachel' import { Kachel, KachelZeile } from '@/components/dashboard/kachel'
@@ -17,7 +17,10 @@ import { formatCent, formatDatum } from '@/lib/utils'
export function BelegeKachel({ daten }: { daten: BelegeDaten | null }) { export function BelegeKachel({ daten }: { daten: BelegeDaten | null }) {
if (!daten) return null if (!daten) return null
const offen = daten.belegeOhneBuchungGesamt + daten.buchungenOhneBelegGesamt const offen =
daten.belegeOhneBuchungGesamt +
daten.buchungenOhneBelegGesamt +
daten.wiederkehrendOhneVertrag
return ( return (
<Kachel <Kachel
@@ -72,6 +75,29 @@ export function BelegeKachel({ daten }: { daten: BelegeDaten | null }) {
</section> </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 && ( {daten.buchungenOhneBelegGesamt > 0 && (
<section aria-labelledby="buchungen-ohne-beleg"> <section aria-labelledby="buchungen-ohne-beleg">
<h3 <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, PosteingangDaten,
WartetAufAndereDaten, WartetAufAndereDaten,
} from '@/lib/dashboard/typen' } from '@/lib/dashboard/typen'
import { offeneZahlungsgruppen } from '@/lib/finanzen/wiederkehrend-laden'
/** /**
* Alles, was das Dashboard "Heute" braucht - in einem Rutsch. * Alles, was das Dashboard "Heute" braucht - in einem Rutsch.
@@ -267,6 +268,11 @@ export async function dashboardLaden(
} }
: null : 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 = const belege: BelegeDaten | null =
buchungenOhneBeleg && buchungenOhneBelegGesamt !== null buchungenOhneBeleg && buchungenOhneBelegGesamt !== null
? { ? {
@@ -286,6 +292,7 @@ export async function dashboardLaden(
gegenstelle: b.gegenstelle, gegenstelle: b.gegenstelle,
})), })),
buchungenOhneBelegGesamt, buchungenOhneBelegGesamt,
wiederkehrendOhneVertrag,
} }
: null : null
+9
View File
@@ -149,6 +149,15 @@ export type BelegeDaten = {
belegeOhneBuchungGesamt: number belegeOhneBuchungGesamt: number
buchungenOhneBeleg: BuchungOhneBeleg[] buchungenOhneBeleg: BuchungOhneBeleg[]
buchungenOhneBelegGesamt: number 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. */ /** 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: * Kurzer Git-Stand, beim Bauen hineingereicht:
+103
View File
@@ -821,3 +821,106 @@ export async function wertstandLoeschen(
revalidatePath('/finanzen/vermoegen') revalidatePath('/finanzen/vermoegen')
return { erfolg: 'Wert gelöscht.' } 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')
})
})