Wiederkehrende Zahlungen aufspueren
Am Anfang einer Betreuung weiss niemand, welche Vertraege es gibt. Die Antwort steht aber im Kontoauszug: Wer regelmaessig abbucht, hat einen Vertrag. Neu unter Vermoegen & Kasse wird aus hunderten Buchungszeilen eine Arbeitsliste. Aufbau: - Die Erkennung liegt als reine Funktionen in src/lib/finanzen/wiederkehrend.ts, ohne Datenbank. Nur so laesst sich jede Einordnung einzeln nachrechnen. - Der Datenzugriff steht getrennt in wiederkehrend-laden.ts. - Ein kleines Modell haelt fest, was bewusst KEIN Vertrag ist. Es haengt am normalisierten Empfaengernamen, nicht an Buchungen - nur so ueberlebt die Entscheidung den naechsten Import. Zwei Haltungen liegen der Erkennung zugrunde: Im Zweifel getrennt lassen. Zusammengefasst wird nur das Unstrittige: Gross- und Kleinschreibung, Satzzeichen, Umlaute, Rechtsform am Ende. Keine Aehnlichkeitssuche - "Stadtwerke Bamberg" und "Stadtwerke Bayreuth" trennen drei Zeichen und zwei Rechnungssteller. Zwei Zeilen zu viel sind harmlos; eine falsche Gruppe verbirgt einen Vertrag. Nichts behaupten, was nicht belegt ist. Statt "monatlich" steht dort "6 Zahlungen, etwa monatlich". Schwankt der Betrag, wird die Spanne genannt. Ohne erkennbaren Abstand wird nicht hochgerechnet, sondern gezeigt, was geflossen ist. Dazu eine zweite Liste fuer einzelne groessere Zahlungen: Die Gruppierung braucht zwei Zahlungen, ein Jahresbeitrag erscheint bei einem importierten Jahr aber nur einmal - und ausgerechnet Versicherungen zahlt man oft jaehrlich. Geprueft: 37 Einheitstests, die Haelfte davon in der Gegenrichtung, plus vier End-to-End-Tests auf beiden Geraeteprofilen. Der Browsertest hat zwei Fehler gefunden, die Typpruefung und Einheitstests durchgelassen haben - darunter ein verlorener Backslash, der die Vorbelegung des Beitrags stillschweigend verschluckte. Nebenbei: .claude/ ins .gitignore. Dort legen parallel arbeitende Agenten ihre Worktrees ab; ein "git add -A" haette einen kompletten zweiten Checkout eingecheckt. Refs #3
This commit is contained in:
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|
||||||
@@ -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())
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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.' }
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,353 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
EINZELZAHLUNG_AB_CENT,
|
||||||
|
MINDESTANZAHL,
|
||||||
|
abstandInTagen,
|
||||||
|
besteSchreibweise,
|
||||||
|
betraegeSchwanken,
|
||||||
|
einzelneZahlungen,
|
||||||
|
gruppieren,
|
||||||
|
normalisieren,
|
||||||
|
offeneKandidaten,
|
||||||
|
rhythmusAusAbstand,
|
||||||
|
rhythmusErklaerung,
|
||||||
|
type ZahlungEingabe,
|
||||||
|
} from '@/lib/finanzen/wiederkehrend'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wiederkehrende Zahlungen aufspüren.
|
||||||
|
*
|
||||||
|
* Der teuerste Fehler dieser Funktion ist nicht eine übersehene Zahlung —
|
||||||
|
* die sieht man beim Durchgehen der Liste. Es ist eine **falsch
|
||||||
|
* zusammengefasste Gruppe**: Dann verschwindet ein Vertrag hinter einem
|
||||||
|
* fremden Namen, und im Vermögensverzeichnis fehlt er.
|
||||||
|
*
|
||||||
|
* Die Prüfungen sind deshalb in beide Richtungen gebaut: Was
|
||||||
|
* zusammengehört, muss zusammenfallen — und was nur ähnlich aussieht, muss
|
||||||
|
* getrennt bleiben.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function zahlung(
|
||||||
|
gegenstelle: string | null,
|
||||||
|
datum: string,
|
||||||
|
euro: number,
|
||||||
|
id = `${gegenstelle}-${datum}`,
|
||||||
|
): ZahlungEingabe {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
gegenstelle,
|
||||||
|
datum: new Date(`${datum}T00:00:00.000Z`),
|
||||||
|
betragCent: BigInt(Math.round(euro * 100)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eine Reihe monatlicher Abbuchungen, jeweils am Monatsersten. */
|
||||||
|
function monatlich(gegenstelle: string, euro: number, monate: number, jahr = 2026) {
|
||||||
|
return Array.from({ length: monate }, (_, i) =>
|
||||||
|
zahlung(
|
||||||
|
gegenstelle,
|
||||||
|
`${jahr}-${String(i + 1).padStart(2, '0')}-01`,
|
||||||
|
euro,
|
||||||
|
`${gegenstelle}-${i}`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Schreibweisen vergleichbar machen', () => {
|
||||||
|
it('fasst Groß- und Kleinschreibung, Satzzeichen und Rechtsform zusammen', () => {
|
||||||
|
const erwartet = normalisieren('Allianz Lebensversicherung AG')
|
||||||
|
expect(normalisieren('ALLIANZ LEBENSVERSICHERUNG AG')).toBe(erwartet)
|
||||||
|
expect(normalisieren('allianz lebensversicherung ag')).toBe(erwartet)
|
||||||
|
expect(normalisieren('Allianz Lebensversicherung-AG')).toBe(erwartet)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('löst Umlaute auf', () => {
|
||||||
|
expect(normalisieren('Münchener Verein')).toBe(normalisieren('Muenchener Verein'))
|
||||||
|
expect(normalisieren('Süddeutsche Krankenversicherung')).toBe(
|
||||||
|
normalisieren('Sueddeutsche Krankenversicherung'),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('entfernt die Rechtsform nur am Ende, nicht mitten im Namen', () => {
|
||||||
|
// "ag" steckt in "Agentur" - wer stumpf ersetzt, macht daraus "entur".
|
||||||
|
expect(normalisieren('Agentur für Arbeit')).toContain('agentur')
|
||||||
|
expect(normalisieren('Debeka AG')).toBe(normalisieren('Debeka'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('entfernt auch mehrteilige Rechtsformen', () => {
|
||||||
|
expect(normalisieren('Stadtwerke Bamberg GmbH & Co KG')).toBe(
|
||||||
|
normalisieren('Stadtwerke Bamberg'),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hält verschiedene Empfänger auseinander, auch bei ähnlichem Namen', () => {
|
||||||
|
// Der wichtigste Test der Datei. Drei Zeichen Unterschied, zwei völlig
|
||||||
|
// verschiedene Rechnungssteller.
|
||||||
|
expect(normalisieren('Stadtwerke Bamberg')).not.toBe(normalisieren('Stadtwerke Bayreuth'))
|
||||||
|
// Und: "Versicherung" ist kein wegwerfbarer Zusatz.
|
||||||
|
expect(normalisieren('Allianz Versicherung')).not.toBe(
|
||||||
|
normalisieren('Allianz Lebensversicherung'),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('verträgt leere Angaben', () => {
|
||||||
|
expect(normalisieren(null)).toBe('')
|
||||||
|
expect(normalisieren(undefined)).toBe('')
|
||||||
|
expect(normalisieren(' ')).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Welche Schreibweise angezeigt wird', () => {
|
||||||
|
it('zieht die gemischte Schreibweise der geschrienen vor', () => {
|
||||||
|
// Nicht Kosmetik: Dieser Name landet beim „Vertrag anlegen" im Feld
|
||||||
|
// Gesellschaft. Steht dort alles in Versalien, tippt man es neu — und
|
||||||
|
// dann war die Vorbelegung wertlos.
|
||||||
|
expect(
|
||||||
|
besteSchreibweise(['ALLIANZ LEBENSVERSICHERUNG AG', 'Allianz Lebensvers. AG']),
|
||||||
|
).toBe('Allianz Lebensvers. AG')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('nimmt unter mehreren gemischten die längste', () => {
|
||||||
|
expect(besteSchreibweise(['Allianz', 'Allianz Lebensversicherung AG'])).toBe(
|
||||||
|
'Allianz Lebensversicherung AG',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('nimmt die längste geschriene, wenn es nichts anderes gibt', () => {
|
||||||
|
expect(besteSchreibweise(['ALLIANZ AG', 'ALLIANZ LEBENSVERSICHERUNG AG'])).toBe(
|
||||||
|
'ALLIANZ LEBENSVERSICHERUNG AG',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('verträgt eine leere Liste', () => {
|
||||||
|
expect(besteSchreibweise([])).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Rhythmus', () => {
|
||||||
|
it('erkennt monatlich trotz unterschiedlich langer Monate', () => {
|
||||||
|
// Februar auf März sind 28 Tage, Juli auf August 31 - beides monatlich.
|
||||||
|
expect(rhythmusAusAbstand(28)).toBe('MONATLICH')
|
||||||
|
expect(rhythmusAusAbstand(31)).toBe('MONATLICH')
|
||||||
|
expect(rhythmusAusAbstand(30)).toBe('MONATLICH')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('erkennt die übrigen üblichen Rhythmen', () => {
|
||||||
|
expect(rhythmusAusAbstand(61)).toBe('ZWEIMONATLICH')
|
||||||
|
expect(rhythmusAusAbstand(91)).toBe('VIERTELJAEHRLICH')
|
||||||
|
expect(rhythmusAusAbstand(182)).toBe('HALBJAEHRLICH')
|
||||||
|
expect(rhythmusAusAbstand(365)).toBe('JAEHRLICH')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('nennt Unpassendes unregelmäßig, statt es zu erzwingen', () => {
|
||||||
|
expect(rhythmusAusAbstand(3)).toBe('UNREGELMAESSIG')
|
||||||
|
expect(rhythmusAusAbstand(45)).toBe('UNREGELMAESSIG')
|
||||||
|
expect(rhythmusAusAbstand(250)).toBe('UNREGELMAESSIG')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('nimmt den Median, nicht den Durchschnitt', () => {
|
||||||
|
// Vier monatliche Zahlungen, dann eine Lücke von einem Jahr. Der
|
||||||
|
// Durchschnitt läge bei über 100 Tagen und ergäbe Unsinn.
|
||||||
|
const daten = [
|
||||||
|
new Date('2026-01-01T00:00:00Z'),
|
||||||
|
new Date('2026-02-01T00:00:00Z'),
|
||||||
|
new Date('2026-03-01T00:00:00Z'),
|
||||||
|
new Date('2026-04-01T00:00:00Z'),
|
||||||
|
new Date('2027-04-01T00:00:00Z'),
|
||||||
|
]
|
||||||
|
const abstand = abstandInTagen(daten)
|
||||||
|
expect(abstand).not.toBeNull()
|
||||||
|
expect(rhythmusAusAbstand(abstand!)).toBe('MONATLICH')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('liefert ohne zweite Zahlung keinen Abstand', () => {
|
||||||
|
expect(abstandInTagen([new Date('2026-01-01T00:00:00Z')])).toBeNull()
|
||||||
|
expect(abstandInTagen([])).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Schwankende Beträge', () => {
|
||||||
|
it('lässt eine Beitragsanpassung durchgehen', () => {
|
||||||
|
expect(betraegeSchwanken([4250n, 4250n, 4390n])).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('meldet einen Stromabschlag, der nach Verbrauch schwankt', () => {
|
||||||
|
expect(betraegeSchwanken([8000n, 12000n, 4000n])).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('meldet nichts bei einer einzelnen Zahlung', () => {
|
||||||
|
expect(betraegeSchwanken([4250n])).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Gruppieren', () => {
|
||||||
|
it('fasst monatliche Abbuchungen zu einer Gruppe zusammen', () => {
|
||||||
|
const gruppen = gruppieren(monatlich('Allianz Lebensversicherung AG', 42.5, 6))
|
||||||
|
|
||||||
|
expect(gruppen).toHaveLength(1)
|
||||||
|
const gruppe = gruppen[0]!
|
||||||
|
expect(gruppe.anzahl).toBe(6)
|
||||||
|
expect(gruppe.rhythmus).toBe('MONATLICH')
|
||||||
|
expect(gruppe.betragCent).toBe(4250n)
|
||||||
|
// Sechs Monate à 42,50 EUR sind hochgerechnet 510 EUR im Jahr.
|
||||||
|
expect(gruppe.jahresbetragCent).toBe(51_000n)
|
||||||
|
expect(gruppe.betragSchwankt).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fasst verschiedene Schreibweisen derselben Stelle zusammen', () => {
|
||||||
|
const gruppen = gruppieren([
|
||||||
|
zahlung('Allianz Lebensversicherung AG', '2026-01-01', 42.5, 'a'),
|
||||||
|
zahlung('ALLIANZ LEBENSVERSICHERUNG AG', '2026-02-01', 42.5, 'b'),
|
||||||
|
zahlung('allianz lebensversicherung', '2026-03-01', 42.5, 'c'),
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(gruppen).toHaveLength(1)
|
||||||
|
expect(gruppen[0]!.anzahl).toBe(3)
|
||||||
|
// Alle Schreibweisen bleiben sichtbar - man soll nachvollziehen können,
|
||||||
|
// was zusammengeworfen wurde.
|
||||||
|
expect(gruppen[0]!.schreibweisen).toHaveLength(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('zeigt die lesbare Schreibweise an, nicht die geschriene', () => {
|
||||||
|
const gruppen = gruppieren([
|
||||||
|
zahlung('Prüfhausen Lebensversicherung AG', '2026-01-01', 42.5, 'a'),
|
||||||
|
zahlung('PRUEFHAUSEN LEBENSVERSICHERUNG AG', '2026-02-01', 42.5, 'b'),
|
||||||
|
])
|
||||||
|
expect(gruppen[0]!.anzeigename).toBe('Prüfhausen Lebensversicherung AG')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hält verschiedene Empfänger getrennt', () => {
|
||||||
|
const gruppen = gruppieren([
|
||||||
|
...monatlich('Stadtwerke Bamberg', 80, 3),
|
||||||
|
...monatlich('Stadtwerke Bayreuth', 80, 3),
|
||||||
|
])
|
||||||
|
expect(gruppen).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('übergeht eine einzelne Zahlung', () => {
|
||||||
|
const gruppen = gruppieren([zahlung('Irgendwer', '2026-01-01', 42.5)])
|
||||||
|
expect(gruppen).toHaveLength(0)
|
||||||
|
expect(MINDESTANZAHL).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('übergeht Buchungen ohne Gegenstelle', () => {
|
||||||
|
// Bargeldabhebungen und handerfasste Zeilen - kein Vertragshinweis.
|
||||||
|
const gruppen = gruppieren([
|
||||||
|
zahlung(null, '2026-01-01', 100, 'a'),
|
||||||
|
zahlung(null, '2026-02-01', 100, 'b'),
|
||||||
|
zahlung('', '2026-03-01', 100, 'c'),
|
||||||
|
])
|
||||||
|
expect(gruppen).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sortiert nach Jahresbetrag, nicht nach Anzahl', () => {
|
||||||
|
const gruppen = gruppieren([
|
||||||
|
...monatlich('Kleiner Beitrag', 5, 12),
|
||||||
|
// Zwei Zahlungen im Jahresabstand à 1200 EUR.
|
||||||
|
zahlung('Große Police', '2025-06-01', 1200, 'g1'),
|
||||||
|
zahlung('Große Police', '2026-06-01', 1200, 'g2'),
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(gruppen[0]!.anzeigename).toBe('Große Police')
|
||||||
|
expect(gruppen[0]!.rhythmus).toBe('JAEHRLICH')
|
||||||
|
expect(gruppen[1]!.anzeigename).toBe('Kleiner Beitrag')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('erkennt einen hinterlegten Vertrag', () => {
|
||||||
|
const gruppen = gruppieren(monatlich('Debeka AG', 30, 4), {
|
||||||
|
vertraege: [{ id: 'v1', bezeichnung: 'Krankenzusatz', gesellschaft: 'Debeka' }],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(gruppen[0]!.vertragId).toBe('v1')
|
||||||
|
expect(gruppen[0]!.vertragBezeichnung).toBe('Krankenzusatz')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lässt abgehakte Gruppen verschwinden', () => {
|
||||||
|
const abgehakt = new Set([normalisieren('Rundfunk ARD ZDF')])
|
||||||
|
const gruppen = gruppieren(monatlich('Rundfunk ARD ZDF', 18.36, 6), { abgehakt })
|
||||||
|
expect(gruppen).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rechnet unregelmäßige Zahlungen nicht hoch', () => {
|
||||||
|
// Ohne erkennbaren Rhythmus wäre jede Hochrechnung geraten. Dann steht
|
||||||
|
// dort, was tatsächlich geflossen ist.
|
||||||
|
const gruppen = gruppieren([
|
||||||
|
zahlung('Wechselnd', '2026-01-01', 100, 'a'),
|
||||||
|
zahlung('Wechselnd', '2026-01-08', 100, 'b'),
|
||||||
|
zahlung('Wechselnd', '2026-01-20', 100, 'c'),
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(gruppen[0]!.rhythmus).toBe('UNREGELMAESSIG')
|
||||||
|
expect(gruppen[0]!.jahresbetragCent).toBe(30_000n)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('zählt für das Dashboard nur ungeklärte Gruppen', () => {
|
||||||
|
const gruppen = gruppieren(
|
||||||
|
[...monatlich('Debeka AG', 30, 4), ...monatlich('Unbekannt Versicherung', 20, 4)],
|
||||||
|
{ vertraege: [{ id: 'v1', bezeichnung: 'Krankenzusatz', gesellschaft: 'Debeka' }] },
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(gruppen).toHaveLength(2)
|
||||||
|
expect(offeneKandidaten(gruppen)).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Einzelne größere Zahlungen', () => {
|
||||||
|
it('findet die jährliche Prämie, die nur einmal im Auszug steht', () => {
|
||||||
|
// Die eigentliche Lücke der Gruppierung: Bei einem importierten Jahr
|
||||||
|
// erscheint ein Jahresbeitrag genau einmal.
|
||||||
|
const treffer = einzelneZahlungen([zahlung('Feuersozietät', '2026-03-15', 480)])
|
||||||
|
|
||||||
|
expect(treffer).toHaveLength(1)
|
||||||
|
expect(treffer[0]!.betragCent).toBe(48_000n)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('übergeht Kleinbeträge', () => {
|
||||||
|
expect(einzelneZahlungen([zahlung('Bäckerei', '2026-03-15', 12.4)])).toHaveLength(0)
|
||||||
|
expect(EINZELZAHLUNG_AB_CENT).toBe(15_000n)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('übergeht, was ohnehin schon als Gruppe erscheint', () => {
|
||||||
|
const zahlungen = monatlich('Allianz', 200, 3)
|
||||||
|
expect(gruppieren(zahlungen)).toHaveLength(1)
|
||||||
|
expect(einzelneZahlungen(zahlungen)).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('übergeht Abgehaktes', () => {
|
||||||
|
const abgehakt = new Set([normalisieren('Finanzamt Coburg')])
|
||||||
|
expect(
|
||||||
|
einzelneZahlungen([zahlung('Finanzamt Coburg', '2026-03-15', 900)], { abgehakt }),
|
||||||
|
).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('nennt den Vertrag, wenn es schon einen gibt', () => {
|
||||||
|
const treffer = einzelneZahlungen([zahlung('Debeka', '2026-03-15', 480)], {
|
||||||
|
vertraege: [{ id: 'v1', bezeichnung: 'Krankenzusatz', gesellschaft: 'Debeka AG' }],
|
||||||
|
})
|
||||||
|
expect(treffer[0]!.vertragId).toBe('v1')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Erklärung unter der Gruppe', () => {
|
||||||
|
it('nennt die Grundlage, nicht nur das Ergebnis', () => {
|
||||||
|
const gruppe = gruppieren(monatlich('Allianz', 42.5, 4))[0]!
|
||||||
|
expect(rhythmusErklaerung(gruppe)).toBe('4 Zahlungen, etwa monatlich')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formuliert größere Abstände in Monaten', () => {
|
||||||
|
const gruppe = gruppieren([
|
||||||
|
zahlung('Quartalsweise', '2026-01-01', 100, 'a'),
|
||||||
|
zahlung('Quartalsweise', '2026-04-01', 100, 'b'),
|
||||||
|
zahlung('Quartalsweise', '2026-07-01', 100, 'c'),
|
||||||
|
])[0]!
|
||||||
|
expect(rhythmusErklaerung(gruppe)).toBe('3 Zahlungen, etwa alle 3 Monate')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sagt offen, wenn kein Abstand erkennbar ist', () => {
|
||||||
|
const gruppe = gruppieren([
|
||||||
|
zahlung('Wechselnd', '2026-01-01', 100, 'a'),
|
||||||
|
zahlung('Wechselnd', '2026-01-08', 100, 'b'),
|
||||||
|
])[0]!
|
||||||
|
expect(rhythmusErklaerung(gruppe)).toContain('ohne erkennbaren Abstand')
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user