From fa994350d615de460677bc83b4292b76a0734e36 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Sun, 23 Aug 2026 09:00:46 +0200 Subject: [PATCH] Dauerzahlungen zu einem Vertrag gelten als belegt Bei einer monatlichen Praemie gibt es keinen Beleg - es gibt einen Vertrag, und der wird einmal abgeschlossen, nicht monatlich quittiert. Bisher blieb nur ein Freitextfeld pro Buchung: zwoelfmal im Jahr derselbe Satz, mal Anzahl der Vertraege. Buchung.vertragId mit onDelete: SetNull. Der Belegzustand kennt jetzt drei Faelle statt zwei, getrennt ausgewiesen - ein Vertrag belegt, eine Begruendung erklaert nur; zusammengeworfen verliert die Belegquote ihre Aussage gegenueber dem Rechtspfleger. Dazu: automatische Zuordnung beim Import bei eindeutiger Gegenstelle, Sammelzuordnung ueber die Gruppen aus Issue #3, und die Rechnungslegung nennt den Vertrag samt Nummer statt "ohne Beleg". Die Sicherung dagegen: Weicht der Betrag um mehr als ein Fuenftel vom hinterlegten Beitrag ab, faellt die Anerkennung weg und die Buchung wird als "Betrag pruefen" markiert. Sonst waere die Zuordnung eine Hintertuer. Zur Toleranz - ein Entwurf und seine Korrektur: Zuerst verglich ich den Betrag gegen alle ueblichen Zahlweisen, damit es auch passt, wenn die Bank anders abbucht als der Vertrag es fuehrt. Der eigene Test hat das widerlegt: Bei 42,50 monatlich liegen 420 innerhalb von 20 Prozent des Jahresbetrags von 510 - ausgerechnet der Fall aus der Aufgabenstellung waere durchgerutscht. Jetzt wird stur gegen den hinterlegten Beitrag verglichen. Bucht die Bank anders ab, stimmen die Vertragsdaten nicht, und das soll auffallen. Behoben: Die Buchungsliste hatte eine eigene, fast gleiche Datenabfrage neben der aus berichte/gemeinsam.ts. Nur eine von beiden wurde nachgezogen, in der Liste stand weiter "Beleg fehlt". Beide benutzen jetzt BUCHUNG_EINBEZUG und zuBuchungEingabe - das kann nicht halb vergessen werden. Gefunden hat es der Browsertest. Nebenbei: Die Roadmap trug noch "Aktuelle Fassung 1.1.2" - Nachwirkung der parallelen Bearbeitung von #3 und #10. Nachgezogen wie zugesagt. Geprueft: 23 neue Einheitstests, die Haelfte davon darauf gerichtet, dass die Anerkennung auch wirklich wegfaellt. Fuenf End-to-End-Tests auf beiden Geraeteprofilen. 560 Unit-Tests, 63 e2e, null Typfehler. Refs #11 --- CHANGELOG.md | 87 +++++++- README.md | 6 +- docs/INSTALLATION.md | 14 +- docs/ROADMAP.md | 2 +- e2e/dauerzahlung.spec.ts | 176 ++++++++++++++++ package.json | 2 +- .../migration.sql | 19 ++ prisma/schema.prisma | 17 ++ .../(app)/finanzen/buchungen/[id]/page.tsx | 9 +- src/app/(app)/finanzen/buchungen/neu/page.tsx | 8 +- src/app/(app)/finanzen/buchungen/page.tsx | 35 +--- src/app/(app)/finanzen/page.tsx | 17 +- src/app/(app)/finanzen/wiederkehrend/page.tsx | 49 +++-- src/components/finanzen/bausteine.tsx | 41 +++- src/components/finanzen/buchungs-formular.tsx | 35 +++- .../finanzen/wiederkehrend-aktionen.tsx | 51 ++++- src/lib/berichte/gemeinsam.ts | 83 +++++--- src/lib/berichte/rechnungslegung.tsx | 9 + src/lib/dashboard/laden.ts | 24 ++- src/lib/finanzen/kassenbuch.ts | 131 +++++++++++- src/lib/finanzen/rechnungslegung.ts | 15 ++ src/lib/finanzen/wiederkehrend-laden.ts | 8 +- src/lib/finanzen/wiederkehrend.ts | 34 ++++ src/lib/validierung/finanzen.ts | 1 + src/lib/version.ts | 2 +- src/server/actions/finanzen.ts | 95 +++++++++ tests/dauerzahlung.test.ts | 189 ++++++++++++++++++ tests/wiederkehrend.test.ts | 42 ++++ 28 files changed, 1105 insertions(+), 96 deletions(-) create mode 100644 e2e/dauerzahlung.spec.ts create mode 100644 prisma/migrations/20260823062000_buchung_vertragszahlung/migration.sql create mode 100644 tests/dauerzahlung.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a882d25..b39e3b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,90 @@ Wiederherstellung*. _Noch nichts._ +## [1.3.0] – 2026-08-23 + +### Hinzugefügt + +- **Dauerzahlungen zu einem Vertrag gelten als belegt** + ([#11](https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/issues/11)). + Bei einer monatlichen Versicherungsprämie gibt es keinen Beleg — es gibt + einen Vertrag, und der wird einmal abgeschlossen, nicht monatlich + quittiert. Bisher blieb nur ein Freitextfeld pro Buchung: zwölfmal im + Jahr derselbe Satz, mal Anzahl der Verträge. + + Eine Buchung lässt sich jetzt einem **Vertrag** zuordnen, und diese + Zuordnung zählt als Nachweis. Bei einem Dauerschuldverhältnis ist der + Vertrag der Beleg: Ein Rechtspfleger will für zwölf gleiche Lastschriften + keine zwölf Quittungen sehen, sondern die Police. + + **Die Belegquote weist drei Fälle getrennt aus** — mit Beleg, durch + Vertrag belegt, begründet ohne Beleg. Zusammengeworfen verlöre die Zahl + ihre Aussage. + +- **Automatisch beim Import**, wenn die Gegenstelle zu genau *einem* + Vertrag passt. Bei Mehrdeutigkeit geschieht nichts: Hausrat und + Haftpflicht bei derselben Gesellschaft lassen sich nicht auseinanderhalten, + und eine falsche Zuordnung wäre schlimmer als gar keine — sie behauptet + einen Beleg, und niemand sieht noch einmal hin. Automatisch gesetzte + Zuordnungen sind in der Liste als solche gekennzeichnet. + +- **Sammelzuordnung** unter *Wiederkehrende Zahlungen*: Liegt zu einer + Gruppe ein Vertrag vor, werden alle ihre Zahlungen mit einem Klick + zugeordnet. Bei drei Jahren Laufzeit sind das 36 Buchungen. + +- **Die Rechnungslegung nennt den Vertrag.** Statt „ohne Beleg – …" steht + dort „Dauerzahlung zu Vertrag «Bezeichnung», Nr. …". Die Lücke wird nicht + versteckt, sondern erklärt: Wer nachprüfen will, weiß sofort, welches + Papier er aus dem Ordner holt. + +### Die Sicherung dagegen + +**Weicht der Betrag um mehr als ein Fünftel vom hinterlegten Beitrag ab, +gilt die Zahlung nicht als belegt**, sondern wird als *Betrag prüfen* +markiert. Sonst wäre die Zuordnung eine Hintertür, durch die auch eine +verzehnfachte Prämie unbemerkt durchginge. + +Ein Fünftel ist bewusst großzügig: Beitragsanpassungen und +Versicherungsteuer bewegen sich in dieser Größenordnung, und dabei soll +die Anwendung nicht meckern. Wer die Grenze enger zieht, bekommt täglich +Warnungen und sieht die eine wichtige nicht mehr. + +Steht am Vertrag **kein** Beitrag, findet keine Prüfung statt. Die +Zuordnung gilt trotzdem — es wird nur nicht so getan, als wäre geprüft +worden. + +Und: **Verschwindet der Vertrag, fällt die Buchung zurück in „Beleg +fehlt".** Ein Nachweis, der auf etwas zeigt, das es nicht mehr gibt, wäre +schlimmer als gar keiner. + +### Zu beachten + +- **Eine Datenbank-Migration ist nötig** + (`20260823062000_buchung_vertragszahlung`). Sie fügt zwei Spalten hinzu + und ändert nichts an vorhandenen Daten. +- Bestehende Buchungen sind nicht betroffen: Ohne Zuordnung bleibt alles, + wie es war. + +### Geprüft + +- 23 neue Einheitstests. Die Hälfte prüft, dass die Anerkennung auch + wirklich **wegfällt** — der gefährliche Fehler wäre hier ein zu mildes + Urteil, nicht ein zu strenges. +- Fünf End-to-End-Tests: Sammelzuordnung, Anzeige in der Buchungsliste, + die abweichende Zahlung, die Kachel auf „Heute", und der Rückfall nach + dem Löschen des Vertrags. + + Der Browsertest hat zwei Fehler gefunden, die alle anderen Prüfungen + durchgelassen haben — siehe unten. + +### Behoben + +- **Die Buchungsliste hatte eine eigene, fast gleiche Datenabfrage** neben + der gemeinsamen aus `berichte/gemeinsam.ts`. Als die Dauerzahlungen + dazukamen, wurde nur eine von beiden nachgezogen: In der Liste stand + weiter „Beleg fehlt", obwohl der Vertrag hinterlegt war. Beide benutzen + jetzt dieselbe Abbildung — die kann nicht halb vergessen werden. + ## [1.2.0] – 2026-08-23 ### Hinzugefügt @@ -616,7 +700,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.2.0...HEAD +[Unveröffentlicht]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.3.0...HEAD +[1.3.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.2.0...v1.3.0 [1.2.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.1.2...v1.2.0 [1.1.2]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.1.1...v1.1.2 [1.1.1]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.1.0...v1.1.1 diff --git a/README.md b/README.md index 086f188..38c1b28 100644 --- a/README.md +++ b/README.md @@ -580,11 +580,11 @@ Fehler, die während des Baus aufgefallen sind. | | | nachzuzählen mit | |---|---:|---| -| Zeilen TypeScript/TSX | 74.119 | `git ls-files "*.ts" "*.tsx" \| xargs wc -l` | +| Zeilen TypeScript/TSX | 76.216 | `git ls-files "*.ts" "*.tsx" \| xargs wc -l` | | Prisma-Modelle | 34 | `grep -c '^model ' prisma/schema.prisma` | | Seiten | 79 | `find src/app -name page.tsx \| wc -l` | -| Unit-Tests (Vitest) | 537 | `npm test` | -| End-to-End-Tests (Playwright) | 53 | `npm run test:e2e` — 30 Tests über zwei Geräteprofile, geräteabhängige übersprungen | +| Unit-Tests (Vitest) | 560 | `npm test` | +| End-to-End-Tests (Playwright) | 63 | `npm run test:e2e` — 35 Tests über zwei Geräteprofile, geräteabhängige übersprungen | | TypeScript-Fehler | 0 | `npm run typecheck` | Die Befehle stehen dabei, weil die Zahlen sonst altern, ohne dass es diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index 42b745e..0948d3c 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -131,14 +131,14 @@ cd /opt/betreuungs-cockpit # Auf die letzte veröffentlichte Fassung wechseln, # nicht auf den laufenden Entwicklungsstand -git checkout v1.2.0 +git checkout v1.3.0 ``` ### Kontrolle ```bash ls docker-compose.yml Dockerfile .env.example # alle drei da -git describe --tags # v1.2.0 +git describe --tags # v1.3.0 ``` --- @@ -299,7 +299,7 @@ betreuungs-cockpit-sicherung-1 Up ```bash # Antwortet die Anwendung? (Port anpassen, falls du APP_PORT gesetzt hast) curl -s http://127.0.0.1:3000/api/gesundheit -# {"status":"bereit","version":"1.2.0","commit":"..."} +# {"status":"bereit","version":"1.3.0","commit":"..."} ``` Kommt hier nichts, sieh in die Logs: @@ -649,7 +649,7 @@ Erst **vom Proxy-Server aus** — kommt er überhaupt an? ```bash curl -s http://10.0.0.5:3000/api/gesundheit -# {"status":"bereit","version":"1.2.0",...} +# {"status":"bereit","version":"1.3.0",...} ``` Kommt hier nichts, liegt es an 7a: falsche `APP_BIND`, Firewall, oder der @@ -700,7 +700,7 @@ Zum Schluss: **Fristen → Pflichtfristen ableiten**. - Du kommst mit E-Mail, Passwort und dem Code aus der App herein. - Unter **Fristen** stehen Einträge mit Paragraphenbezug. - Unter **Protokoll** siehst du deine eigene Anmeldung. -- Unten links in der Seitenleiste steht `Version 1.2.0 · `. +- Unten links in der Seitenleiste steht `Version 1.3.0 · `. --- @@ -875,7 +875,7 @@ alles so vor wie zuvor. | Aufruf | Wirkung | |---|---| | `./scripts/update.sh` | auf die neueste veröffentlichte Fassung | -| `./scripts/update.sh v1.2.0` | auf eine bestimmte Fassung | +| `./scripts/update.sh v1.3.0` | auf eine bestimmte Fassung | | `./scripts/update.sh --pruefen` | nur nachsehen, nichts ändern | | `./scripts/update.sh --ja` | ohne Rückfrage (für cron) | | `./scripts/update.sh --verwerfen` | eigene Änderungen wegwerfen statt beiseitelegen | @@ -913,7 +913,7 @@ git fetch --tags git log --oneline HEAD..origin/main # 3. Umschalten -git checkout v1.2.0 +git checkout v1.3.0 # 4. Neu bauen und starten GIT_COMMIT=$(git rev-parse --short HEAD) BUILD_ZEIT=$(date -Iseconds) docker compose up -d --build diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 25ff527..6fd8d98 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -9,7 +9,7 @@ Zeit spart. Er steht weit unten, wenn er nur schön wäre. Diese Datei ist eine Absichtserklärung, kein Vertrag. Was sich im Betrieb als wichtiger herausstellt, rutscht nach oben. -Stand: 23.08.2026 · Aktuelle Fassung: **1.1.2** ([CHANGELOG](../CHANGELOG.md)) +Stand: 23.08.2026 · Aktuelle Fassung: **1.3.0** ([CHANGELOG](../CHANGELOG.md)) --- diff --git a/e2e/dauerzahlung.spec.ts b/e2e/dauerzahlung.spec.ts new file mode 100644 index 0000000..e3552ea --- /dev/null +++ b/e2e/dauerzahlung.spec.ts @@ -0,0 +1,176 @@ +import { expect, test } from '@playwright/test' +import { PrismaClient } from '@prisma/client' +import { anmelden } from './hilfen' + +/** + * Dauerzahlungen zu einem Vertrag — gegen die echte Anwendung. + * + * Die Einheitstests nageln die Belegzustände fest. Hier geht es um das, was + * sie nicht sehen: Kommt die Zuordnung in der Datenbank an, verschwindet die + * Buchung wirklich aus „Beleg fehlt", und trägt die abweichende Zahlung + * sichtbar die Warnung? + * + * Der Test legt Vertrag und Buchungen selbst an und räumt sie weg. + */ + +const GESELLSCHAFT = 'Prüfhausen Rentenversicherung' +const VERTRAG = 'Prüfhausen Rente (Test)' + +async function mitDb(arbeit: (db: PrismaClient) => Promise): Promise { + const db = new PrismaClient() + try { + return await arbeit(db) + } finally { + await db.$disconnect() + } +} + +async function aufraeumen(): Promise { + await mitDb(async (db) => { + await db.buchung.deleteMany({ where: { gegenstelle: GESELLSCHAFT } }) + await db.vertrag.deleteMany({ where: { bezeichnung: VERTRAG } }) + }) +} + +/** + * Sechs passende Monatsraten plus eine, die zehnmal so hoch ist. + * Die letzte ist der eigentliche Prüffall. + */ +async function testdatenAnlegen(): Promise { + await mitDb(async (db) => { + const betreute = await db.betreute.findMany({ + where: { geloeschtAm: null }, + select: { id: true }, + }) + + for (const person of betreute) { + const vertrag = await db.vertrag.create({ + data: { + betreuteId: person.id, + bezeichnung: VERTRAG, + gesellschaft: GESELLSCHAFT, + art: 'RENTENVERSICHERUNG_PRIVAT', + beitragCent: 4250n, + rhythmus: 'MONATLICH', + }, + select: { id: true }, + }) + + 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, + gegenstelle: GESELLSCHAFT, + } + + for (let monat = 1; monat <= 6; monat++) { + await db.buchung.create({ + data: { + ...gemeinsam, + datum: new Date(Date.UTC(2026, monat - 1, 1)), + betragCent: 4250n, + }, + }) + } + + // Die Ausreißerin: schon zugeordnet, aber zehnmal so hoch. + await db.buchung.create({ + data: { + ...gemeinsam, + datum: new Date(Date.UTC(2026, 6, 1)), + betragCent: 42_500n, + vertragId: vertrag.id, + }, + }) + } + }) +} + +test.describe('Dauerzahlungen', () => { + test.beforeAll(async () => { + await aufraeumen() + await testdatenAnlegen() + }) + + test.afterAll(async () => { + await aufraeumen() + }) + + test('ordnet alle Zahlungen einer Gruppe in einem Schritt zu', async ({ page }) => { + await anmelden(page) + await page.goto('/finanzen/wiederkehrend') + + // Der Vertrag liegt vor, also steht die Gruppe unter „schon zugeordnet". + const eintrag = page.locator('li', { hasText: VERTRAG }).first() + await expect(eintrag).toBeVisible() + + // Sechs offene Zahlungen — die siebte hängt schon am Vertrag. + const knopf = eintrag.getByRole('button', { name: /6 Zahlungen als belegt zuordnen/ }) + await expect(knopf).toBeVisible() + await knopf.click() + + // Die Rückmeldung ist der neue Zustand, nicht eine Meldung: Sind alle + // Zahlungen zugeordnet, verschwindet der Knopf und die Gruppe meldet + // sich als vollständig. + await expect(eintrag).toContainText('Alle 7 Zahlungen sind dem Vertrag zugeordnet') + await expect(eintrag.getByRole('button', { name: /als belegt zuordnen/ })).toHaveCount(0) + }) + + test('zeigt die Zahlungen danach als durch Vertrag belegt', async ({ page }) => { + await anmelden(page) + await page.goto('/finanzen/buchungen') + + const zeilen = page.locator('tr', { hasText: GESELLSCHAFT }) + await expect(zeilen.first()).toBeVisible() + + // Die passenden Raten gelten als belegt … + await expect(page.getByText('durch Vertrag belegt').first()).toBeVisible() + // … und keine davon steht mehr als „Beleg fehlt". + const zeileMitFehlt = page.locator('tr', { hasText: GESELLSCHAFT }).filter({ + hasText: 'Beleg fehlt', + }) + await expect(zeileMitFehlt).toHaveCount(0) + }) + + test('markiert die abweichende Zahlung zur Prüfung, statt sie durchzuwinken', async ({ + page, + }) => { + // Die Zusage, auf der alles steht: Die Zuordnung darf keine Hintertür + // sein, durch die eine verzehnfachte Prämie unbemerkt als belegt gilt. + await anmelden(page) + await page.goto('/finanzen/buchungen') + + const ausreisser = page.locator('tr', { hasText: '425,00' }) + await expect(ausreisser).toContainText('Betrag prüfen') + }) + + test('nimmt die Zuordnung aus der Kachel auf „Heute“ heraus', async ({ page }) => { + await anmelden(page) + await page.goto('/heute') + + // Die sechs zugeordneten Zahlungen dürfen dort nicht mehr auftauchen. + const kachel = page.locator('section, div').filter({ hasText: 'Unverbuchte Belege' }).last() + await expect(kachel).not.toContainText(GESELLSCHAFT) + }) + + test('fällt zurück in „Beleg fehlt“, wenn der Vertrag verschwindet', async ({ page }) => { + // Ein Nachweis, der auf etwas zeigt, das es nicht mehr gibt, wäre + // schlimmer als gar keiner. + await mitDb(async (db) => { + await db.vertrag.deleteMany({ where: { bezeichnung: VERTRAG } }) + }) + + await anmelden(page) + await page.goto('/finanzen/buchungen') + + const zeilen = page.locator('tr', { hasText: GESELLSCHAFT }) + await expect(zeilen.first()).toContainText('Beleg fehlt') + }) +}) diff --git a/package.json b/package.json index 107e65e..aee79c0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "betreuungs-cockpit", - "version": "1.2.0", + "version": "1.3.0", "private": true, "description": "Arbeits- und Dokumentationswerkzeug fuer die ehrenamtliche rechtliche Betreuung", "engines": { diff --git a/prisma/migrations/20260823062000_buchung_vertragszahlung/migration.sql b/prisma/migrations/20260823062000_buchung_vertragszahlung/migration.sql new file mode 100644 index 0000000..0689c3c --- /dev/null +++ b/prisma/migrations/20260823062000_buchung_vertragszahlung/migration.sql @@ -0,0 +1,19 @@ +-- Dauerzahlungen: Buchung.vertragId. +-- +-- ACHTUNG, zum dritten Mal: "prisma migrate diff" wollte wieder die +-- Trigramm-Indizes "dokument_absender_trgm" und "vorgang_titel_trgm" +-- loeschen. Sie stammen aus prisma/sql/suche.sql und sind Prisma +-- unbekannt; ein DROP legt die unscharfe Suche stumm, ohne dass etwas +-- fehlschlaegt. Zeilen entfernt. Wer die naechste Migration erzeugt: +-- nachsehen. + +-- AlterTable +ALTER TABLE "buchung" ADD COLUMN "vertragAutomatisch" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "vertragId" TEXT; + +-- CreateIndex +CREATE INDEX "buchung_vertragId_idx" ON "buchung"("vertragId"); + +-- AddForeignKey +ALTER TABLE "buchung" ADD CONSTRAINT "buchung_vertragId_fkey" FOREIGN KEY ("vertragId") REFERENCES "vertrag"("id") ON DELETE SET NULL ON UPDATE CASCADE; + diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4368282..1af8ef0 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -890,6 +890,21 @@ model Buchung { /// Buchung ohne Beleg wird in der Liste markiert - Belegzwang. belegFehltBegruendung String? + /// Dauerzahlung zu einem Vertrag. Bei einem Dauerschuldverhaeltnis IST + /// der Vertrag der Beleg - fuer zwoelf gleiche Lastschriften gibt es + /// keine zwoelf Quittungen, sondern eine Police. + /// + /// onDelete: SetNull ist Absicht und Teil der Zusage: Verschwindet der + /// Vertrag, faellt die Buchung zurueck in "Beleg fehlt". Ein Nachweis, + /// der auf etwas zeigt, das es nicht mehr gibt, waere schlimmer als + /// gar keiner. + vertragId String? + vertrag Vertrag? @relation("Vertragszahlung", fields: [vertragId], references: [id], onDelete: SetNull) + + /// Wurde die Zuordnung beim Import automatisch gesetzt? Dann ist sie + /// eine Vermutung der Maschine und in der Liste als solche erkennbar. + vertragAutomatisch Boolean @default(false) + /// false = Auslage des Betreuers, die erstattet werden muss. betrifftBetreute Boolean @default(true) /// Verschiebt das Vermoegen (z.B. Verkaufserloes), nicht nur laufende @@ -908,6 +923,7 @@ model Buchung { @@unique([kontoId, importReferenz]) @@index([betreuteId, datum]) @@index([betreuteId, kategorie]) + @@index([vertragId]) @@map("buchung") } @@ -1173,6 +1189,7 @@ model Vertrag { vermoegensposten Vermoegensposten[] verknuepfungen DokumentVerknuepfung[] + zahlungen Buchung[] @relation("Vertragszahlung") @@index([betreuteId, status]) @@map("vertrag") diff --git a/src/app/(app)/finanzen/buchungen/[id]/page.tsx b/src/app/(app)/finanzen/buchungen/[id]/page.tsx index f7576d3..f28c169 100644 --- a/src/app/(app)/finanzen/buchungen/[id]/page.tsx +++ b/src/app/(app)/finanzen/buchungen/[id]/page.tsx @@ -26,7 +26,7 @@ export default async function BuchungSeite({ params }: { params: Promise<{ id: s const offen = oeffnen('Buchung', buchung) - const [konten, belege] = await Promise.all([ + const [konten, belege, vertraege] = await Promise.all([ db.konto.findMany({ where: { betreuteId: betreute.id, ...nichtGeloescht }, select: { id: true, bezeichnung: true, istBetreuerkonto: true }, @@ -38,6 +38,11 @@ export default async function BuchungSeite({ params }: { params: Promise<{ id: s orderBy: { erstelltAm: 'desc' }, take: 200, }), + db.vertrag.findMany({ + where: { betreuteId: betreute.id, ...nichtGeloescht }, + select: { id: true, bezeichnung: true, gesellschaft: true }, + orderBy: { bezeichnung: 'asc' }, + }), ]) return ( @@ -64,6 +69,7 @@ export default async function BuchungSeite({ params }: { params: Promise<{ id: s betreuteId={betreute.id} konten={konten} belege={belege} + vertraege={vertraege} buchung={{ id: buchung.id, kontoId: buchung.kontoId, @@ -75,6 +81,7 @@ export default async function BuchungSeite({ params }: { params: Promise<{ id: s gegenstelle: buchung.gegenstelle, verwendungszweck: offen.verwendungszweck, belegId: buchung.belegId, + vertragId: buchung.vertragId, belegFehltBegruendung: buchung.belegFehltBegruendung, betrifftBetreute: buchung.betrifftBetreute, vermoegenswirksam: buchung.vermoegenswirksam, diff --git a/src/app/(app)/finanzen/buchungen/neu/page.tsx b/src/app/(app)/finanzen/buchungen/neu/page.tsx index 3c285f4..3eace96 100644 --- a/src/app/(app)/finanzen/buchungen/neu/page.tsx +++ b/src/app/(app)/finanzen/buchungen/neu/page.tsx @@ -30,7 +30,7 @@ export default async function BuchungNeuSeite({ const schnell = parameter.schnell === '1' const kontoId = typeof parameter.kontoId === 'string' ? parameter.kontoId : null - const [konten, belege] = await Promise.all([ + const [konten, belege, vertraege] = await Promise.all([ db.konto.findMany({ where: { betreuteId: betreute.id, ...nichtGeloescht }, select: { id: true, bezeichnung: true, istBetreuerkonto: true }, @@ -42,6 +42,11 @@ export default async function BuchungNeuSeite({ orderBy: { erstelltAm: 'desc' }, take: 200, }), + db.vertrag.findMany({ + where: { betreuteId: betreute.id, ...nichtGeloescht }, + select: { id: true, bezeichnung: true, gesellschaft: true }, + orderBy: { bezeichnung: 'asc' }, + }), ]) return ( @@ -93,6 +98,7 @@ export default async function BuchungNeuSeite({ : konten } belege={belege} + vertraege={vertraege} schnellerfassung={schnell} standardDatum={tagAlsText(heute())} /> diff --git a/src/app/(app)/finanzen/buchungen/page.tsx b/src/app/(app)/finanzen/buchungen/page.tsx index 5a912aa..ef6ff1a 100644 --- a/src/app/(app)/finanzen/buchungen/page.tsx +++ b/src/app/(app)/finanzen/buchungen/page.tsx @@ -9,10 +9,10 @@ import { Rechtshinweis } from '@/components/rechtshinweis' import { Belegzeichen, Betrag, Kennzahl, Vermischungswarnung } from '@/components/finanzen/bausteine' import { Buchungsfilter } from '@/components/finanzen/buchungs-filter' import { centZuZahl, db, nichtGeloescht } from '@/lib/db' -import { oeffnen } from '@/lib/crypto/feld' import { formatCent, formatDatum } from '@/lib/utils' import { finanzKontext } from '@/lib/finanzen/seite' -import { belegFehlt, kennzahlen, vermischungsWarnung, type BuchungEingabe } from '@/lib/finanzen/kassenbuch' +import { BUCHUNG_EINBEZUG, zuBuchungEingabe } from '@/lib/berichte/gemeinsam' +import { belegDurchVertrag, betragPruefen, belegFehlt, kennzahlen, vermischungsWarnung, type BuchungEingabe } from '@/lib/finanzen/kassenbuch' import { KATEGORIE_BESCHRIFTUNG, RICHTUNG_BESCHRIFTUNG } from '@/lib/finanzen/kategorien' import { buchungsfilterSchema } from '@/lib/validierung/finanzen' @@ -82,10 +82,7 @@ export default async function BuchungenSeite({ const [zeilen, gesamt, konten] = await Promise.all([ db.buchung.findMany({ where: bedingung, - include: { - konto: { select: { id: true, bezeichnung: true, istBetreuerkonto: true } }, - beleg: { select: { id: true, titel: true } }, - }, + include: BUCHUNG_EINBEZUG, orderBy: [{ datum: 'desc' }, { erstelltAm: 'desc' }], take: HOECHSTZAHL, }), @@ -97,26 +94,7 @@ export default async function BuchungenSeite({ }), ]) - const buchungen: BuchungEingabe[] = zeilen.map((zeile) => { - const offen = oeffnen('Buchung', zeile) - return { - id: zeile.id, - datum: zeile.datum, - betragCent: zeile.betragCent, - richtung: zeile.richtung, - kategorie: zeile.kategorie, - gegenstelle: zeile.gegenstelle, - verwendungszweck: offen.verwendungszweck, - kontoId: zeile.kontoId, - kontoBezeichnung: zeile.konto?.bezeichnung ?? null, - belegId: zeile.belegId, - belegTitel: zeile.beleg?.titel ?? null, - belegFehltBegruendung: zeile.belegFehltBegruendung, - betrifftBetreute: zeile.betrifftBetreute, - vermoegenswirksam: zeile.vermoegenswirksam, - aufBetreuerkonto: zeile.konto?.istBetreuerkonto ?? false, - } - }) + const buchungen: BuchungEingabe[] = zeilen.map(zuBuchungEingabe) const zahlen = kennzahlen(buchungen) @@ -261,6 +239,11 @@ export default async function BuchungenSeite({ belegId={buchung.belegId} belegTitel={buchung.belegTitel ?? null} begruendung={buchung.belegFehltBegruendung} + vertragBezeichnung={ + belegDurchVertrag(buchung) ? (buchung.vertragBezeichnung ?? null) : null + } + vertragAutomatisch={buchung.vertragAutomatisch ?? false} + betragZuPruefen={betragPruefen(buchung)} /> diff --git a/src/app/(app)/finanzen/page.tsx b/src/app/(app)/finanzen/page.tsx index d900a23..0747ca5 100644 --- a/src/app/(app)/finanzen/page.tsx +++ b/src/app/(app)/finanzen/page.tsx @@ -17,7 +17,7 @@ import { centZuZahl } from '@/lib/db' import { formatCent, formatDatum } from '@/lib/utils' import { finanzKontext } from '@/lib/finanzen/seite' import { heute, monatePlus, tagePlus } from '@/lib/finanzen/datum' -import { kennzahlen, vermischungsWarnung } from '@/lib/finanzen/kassenbuch' +import { belegDurchVertrag, betragPruefen, kennzahlen, vermischungsWarnung } from '@/lib/finanzen/kassenbuch' import { KATEGORIE_BESCHRIFTUNG } from '@/lib/finanzen/kategorien' import { laufendesBetreuungsjahr } from '@/lib/finanzen/betreuungsjahr' import { buchungenLaden, kontenLaden, kontostaendeZumStichtag, vermoegenLaden } from '@/lib/berichte/gemeinsam' @@ -121,9 +121,13 @@ export default async function FinanzenSeite() { titel="Belegquote (12 Monate)" wert={`${zahlen.beleg.quoteProzent} %`} hinweis={ - zahlen.beleg.ohneNachweis > 0 - ? `${zahlen.beleg.ohneNachweis} Buchung(en) ohne Beleg und ohne Begründung` - : 'Alle Buchungen sind belegt oder begründet' + zahlen.beleg.zuPruefen > 0 + ? `${zahlen.beleg.zuPruefen} Zahlung(en) weichen vom hinterlegten Beitrag ab` + : zahlen.beleg.ohneNachweis > 0 + ? `${zahlen.beleg.ohneNachweis} Buchung(en) ohne Beleg und ohne Begründung` + : zahlen.beleg.durchVertrag > 0 + ? `Belegt, begründet oder durch Vertrag gedeckt (${zahlen.beleg.durchVertrag} Dauerzahlungen)` + : 'Alle Buchungen sind belegt oder begründet' } ton={zahlen.beleg.ohneNachweis > 0 ? 'achtung' : 'gut'} href="/finanzen/buchungen?nurOhneBeleg=on" @@ -318,6 +322,11 @@ export default async function FinanzenSeite() { belegId={buchung.belegId} belegTitel={buchung.belegTitel ?? null} begruendung={buchung.belegFehltBegruendung} + vertragBezeichnung={ + belegDurchVertrag(buchung) ? (buchung.vertragBezeichnung ?? null) : null + } + vertragAutomatisch={buchung.vertragAutomatisch ?? false} + betragZuPruefen={betragPruefen(buchung)} /> diff --git a/src/app/(app)/finanzen/wiederkehrend/page.tsx b/src/app/(app)/finanzen/wiederkehrend/page.tsx index eaa256b..6702667 100644 --- a/src/app/(app)/finanzen/wiederkehrend/page.tsx +++ b/src/app/(app)/finanzen/wiederkehrend/page.tsx @@ -9,6 +9,7 @@ import { Leerzustand, SeitenKopf } from '@/components/seiten-kopf' import { Betrag } from '@/components/finanzen/bausteine' import { AlsGeklaertAbhaken, + ZahlungenZuordnen, Zurueckholen, } from '@/components/finanzen/wiederkehrend-aktionen' import { finanzKontext } from '@/lib/finanzen/seite' @@ -245,22 +246,40 @@ export default async function WiederkehrendSeite() {
    {mitVertrag.map((gruppe) => ( -
  • - - {gruppe.anzeigename} - - {RHYTHMUS_BESCHRIFTUNG[gruppe.rhythmus]},{' '} - {formatCent(centZuZahl(gruppe.jahresbetragCent))} im Jahr +
  • +
    + + {gruppe.anzeigename} + + {RHYTHMUS_BESCHRIFTUNG[gruppe.rhythmus]},{' '} + {formatCent(centZuZahl(gruppe.jahresbetragCent))} im Jahr + - - + +
    + + {/* Der Vertrag liegt vor - dann belegt er auch die + Zahlungen. Einzeln anzuklicken waeren bei drei + Jahren Laufzeit 36 Klicks. */} + {schreiben && gruppe.offeneBuchungIds.length > 0 && ( + + )} + + {gruppe.offeneBuchungIds.length === 0 && ( +

    + Alle {gruppe.anzahl} Zahlungen sind dem Vertrag zugeordnet und damit + belegt. +

    + )}
  • ))}
diff --git a/src/components/finanzen/bausteine.tsx b/src/components/finanzen/bausteine.tsx index ee6e034..5e63c6e 100644 --- a/src/components/finanzen/bausteine.tsx +++ b/src/components/finanzen/bausteine.tsx @@ -1,6 +1,6 @@ import * as React from 'react' import Link from 'next/link' -import { AlertTriangle, FileWarning, Paperclip } from 'lucide-react' +import { AlertTriangle, FileCheck, FileWarning, Paperclip } from 'lucide-react' import { Badge } from '@/components/ui/badge' import { Card, CardContent } from '@/components/ui/card' import { cn, formatCent } from '@/lib/utils' @@ -104,10 +104,19 @@ export function Belegzeichen({ belegTitel, begruendung, belegId, + vertragBezeichnung = null, + vertragAutomatisch = false, + betragZuPruefen = false, }: { belegId: string | null belegTitel: string | null begruendung: string | null + /** Dauerzahlung: gesetzt, wenn der Vertrag als Beleg gilt. */ + vertragBezeichnung?: string | null + /** Beim Import automatisch zugeordnet - eine Vermutung, kein Urteil. */ + vertragAutomatisch?: boolean + /** Zugeordnet, aber der Betrag weicht ab. */ + betragZuPruefen?: boolean }) { if (belegId) { return ( @@ -118,6 +127,36 @@ export function Belegzeichen({ ) } + // Zugeordnet, aber der Betrag passt nicht. Steht vor allem anderen: Das + // ist der Fall, den jemand ansehen soll. + if (betragZuPruefen) { + return ( + + + ) + } + + if (vertragBezeichnung) { + return ( + + + ) + } + if (begruendung && begruendung.trim() !== '') { return ( diff --git a/src/components/finanzen/buchungs-formular.tsx b/src/components/finanzen/buchungs-formular.tsx index 09ccb53..686f8d9 100644 --- a/src/components/finanzen/buchungs-formular.tsx +++ b/src/components/finanzen/buchungs-formular.tsx @@ -33,6 +33,12 @@ export type BelegAuswahl = { titel: string } +export type VertragAuswahl = { + id: string + bezeichnung: string + gesellschaft: string | null +} + export type BuchungWerte = { id: string kontoId: string | null @@ -45,6 +51,7 @@ export type BuchungWerte = { verwendungszweck: string | null belegId: string | null belegFehltBegruendung: string | null + vertragId: string | null betrifftBetreute: boolean vermoegenswirksam: boolean } @@ -65,6 +72,7 @@ export function Buchungsformular({ betreuteId, konten, belege, + vertraege, buchung, standardDatum, schnellerfassung = false, @@ -72,6 +80,7 @@ export function Buchungsformular({ betreuteId: string konten: KontoAuswahl[] belege: BelegAuswahl[] + vertraege: VertragAuswahl[] buchung?: BuchungWerte /** Vorbelegung des Datumsfeldes bei einer neuen Buchung. */ standardDatum?: string @@ -87,6 +96,7 @@ export function Buchungsformular({ ) const [betrifftBetreute, setBetrifftBetreute] = React.useState(buchung?.betrifftBetreute ?? true) const [belegId, setBelegId] = React.useState(buchung?.belegId ?? '') + const [vertragId, setVertragId] = React.useState(buchung?.vertragId ?? '') const konto = konten.find((k) => k.id === kontoId) const vermischt = Boolean(konto?.istBetreuerkonto) && betrifftBetreute @@ -275,7 +285,30 @@ export function Buchungsformular({ - {belegId === '' && ( + {belegId === '' && vertraege.length > 0 && ( + + + + )} + + {belegId === '' && vertragId === '' && ( ) } + +/** + * Ordnet alle noch offenen Buchungen einer Gruppe dem Vertrag zu. + * + * Der Sinn dieses Knopfes ist die Menge: Eine monatliche Prämie über drei + * Jahre sind 36 Buchungen. Sie einzeln anzuklicken wäre genau die stumpfe + * Arbeit, die diese Zuordnung abschaffen soll. + */ +export function ZahlungenZuordnen({ + betreuteId, + vertragId, + vertragBezeichnung, + buchungIds, +}: { + betreuteId: string + vertragId: string + vertragBezeichnung: string + buchungIds: string[] +}) { + const [zustand, absenden] = useActionState( + zahlungenVertragZuordnen, + {}, + ) + + if (buchungIds.length === 0) return null + + return ( +
+ + + + + {zustand.fehler && ( + {zustand.fehler} + )} + {zustand.erfolg && ( + {zustand.erfolg} + )} + + + + + zu „{vertragBezeichnung}“ +
+ ) +} diff --git a/src/lib/berichte/gemeinsam.ts b/src/lib/berichte/gemeinsam.ts index f330923..f34d97d 100644 --- a/src/lib/berichte/gemeinsam.ts +++ b/src/lib/berichte/gemeinsam.ts @@ -1,4 +1,5 @@ import 'server-only' +import type { Prisma } from '@prisma/client' import type { Betreute, User } from '@prisma/client' import { db, nichtGeloescht } from '@/lib/db' import { protokollieren } from '@/lib/audit' @@ -85,6 +86,62 @@ export function berichtskopf(eingabe: { // Laden // ----------------------------------------------------------------------------- +/** + * Was eine Buchung mitbringen muss, damit sich daraus eine BuchungEingabe + * bauen laesst. + * + * Steht hier und nicht in jeder Seite einzeln: Die Buchungsliste hatte + * lange eine eigene, fast gleiche Abfrage. Als die Dauerzahlungen + * dazukamen, wurde nur eine der beiden nachgezogen - und in der Liste + * stand weiter "Beleg fehlt", obwohl der Vertrag hinterlegt war. Ein + * gemeinsamer Einbezug kann nicht halb vergessen werden. + */ +export const BUCHUNG_EINBEZUG = { + konto: { select: { id: true, bezeichnung: true, istBetreuerkonto: true } }, + beleg: { select: { id: true, titel: true } }, + // Bei einer Dauerzahlung ist der Vertrag der Beleg, und sein Beitrag ist + // der Massstab fuer die Betragspruefung. + vertrag: { + select: { + id: true, + bezeichnung: true, + beitragCent: true, + rhythmus: true, + vertragsnummerVerschluesselt: true, + }, + }, +} as const + +type BuchungMitBezug = Prisma.BuchungGetPayload<{ include: typeof BUCHUNG_EINBEZUG }> + +/** Wandelt eine geladene Zeile in die Form um, mit der die Auswertung rechnet. */ +export function zuBuchungEingabe(zeile: BuchungMitBezug): BuchungEingabe { + const offen = oeffnen('Buchung', zeile) + return { + id: zeile.id, + datum: zeile.datum, + betragCent: zeile.betragCent, + richtung: zeile.richtung, + kategorie: zeile.kategorie, + gegenstelle: zeile.gegenstelle, + verwendungszweck: offen.verwendungszweck, + kontoId: zeile.kontoId, + kontoBezeichnung: zeile.konto?.bezeichnung ?? null, + belegId: zeile.belegId, + belegTitel: zeile.beleg?.titel ?? null, + belegFehltBegruendung: zeile.belegFehltBegruendung, + vertragId: zeile.vertragId, + vertragBezeichnung: zeile.vertrag?.bezeichnung ?? null, + vertragsnummer: zeile.vertrag ? oeffnen('Vertrag', zeile.vertrag).vertragsnummer : null, + vertragBeitragCent: zeile.vertrag?.beitragCent ?? null, + vertragRhythmus: zeile.vertrag?.rhythmus ?? null, + vertragAutomatisch: zeile.vertragAutomatisch, + betrifftBetreute: zeile.betrifftBetreute, + vermoegenswirksam: zeile.vermoegenswirksam, + aufBetreuerkonto: zeile.konto?.istBetreuerkonto ?? false, + } +} + /** * Alle Buchungen einer betreuten Person, entschluesselt und um die * Angaben ergaenzt, die die Auswertung braucht (Kontoname, Belegtitel, @@ -102,33 +159,11 @@ export async function buchungenLaden( ? { datum: { gte: alsTag(zeitraum.von), lte: alsTag(zeitraum.bis) } } : {}), }, - include: { - konto: { select: { id: true, bezeichnung: true, istBetreuerkonto: true } }, - beleg: { select: { id: true, titel: true } }, - }, + include: BUCHUNG_EINBEZUG, orderBy: [{ datum: 'asc' }, { erstelltAm: 'asc' }], }) - return zeilen.map((zeile) => { - const offen = oeffnen('Buchung', zeile) - return { - id: zeile.id, - datum: zeile.datum, - betragCent: zeile.betragCent, - richtung: zeile.richtung, - kategorie: zeile.kategorie, - gegenstelle: zeile.gegenstelle, - verwendungszweck: offen.verwendungszweck, - kontoId: zeile.kontoId, - kontoBezeichnung: zeile.konto?.bezeichnung ?? null, - belegId: zeile.belegId, - belegTitel: zeile.beleg?.titel ?? null, - belegFehltBegruendung: zeile.belegFehltBegruendung, - betrifftBetreute: zeile.betrifftBetreute, - vermoegenswirksam: zeile.vermoegenswirksam, - aufBetreuerkonto: zeile.konto?.istBetreuerkonto ?? false, - } - }) + return zeilen.map(zuBuchungEingabe) } /** Konten mit ihrer vollstaendigen Saldostand-Historie. */ diff --git a/src/lib/berichte/rechnungslegung.tsx b/src/lib/berichte/rechnungslegung.tsx index 1b50d8d..e6202c9 100644 --- a/src/lib/berichte/rechnungslegung.tsx +++ b/src/lib/berichte/rechnungslegung.tsx @@ -444,6 +444,15 @@ export function RechnungslegungDokument({ {beleg.belegTitel ? ( {beleg.belegTitel} + ) : beleg.vertragBezeichnung ? ( + + Dauerzahlung zu Vertrag „{beleg.vertragBezeichnung}“ + {beleg.vertragsnummer ? `, Nr. ${beleg.vertragsnummer}` : ''} + + ) : beleg.betragZuPruefen ? ( + + Beleg fehlt – Betrag weicht vom Vertrag ab + ) : beleg.belegFehltBegruendung ? ( ohne Beleg – {beleg.belegFehltBegruendung} diff --git a/src/lib/dashboard/laden.ts b/src/lib/dashboard/laden.ts index 4e79d24..12fed1e 100644 --- a/src/lib/dashboard/laden.ts +++ b/src/lib/dashboard/laden.ts @@ -167,7 +167,17 @@ export async function dashboardLaden( // 5b. Buchungen ohne Beleg und ohne Begruendung - Belegzwang. darfFinanzen ? db.buchung.findMany({ - where: { betreuteId, belegId: null, belegFehltBegruendung: null, ...nichtGeloescht }, + where: { + betreuteId, + belegId: null, + belegFehltBegruendung: null, + // Dauerzahlungen sind durch ihren Vertrag belegt - sie gehoeren + // nicht in die Liste der offenen Belege. Die Betragspruefung + // laeuft erst spaeter auf den geladenen Zeilen; hier wird nur + // grob vorgefiltert. + vertragId: null, + ...nichtGeloescht, + }, orderBy: { datum: 'desc' }, take: KACHEL_ZEILEN, select: { @@ -182,7 +192,17 @@ export async function dashboardLaden( : null, darfFinanzen ? db.buchung.count({ - where: { betreuteId, belegId: null, belegFehltBegruendung: null, ...nichtGeloescht }, + where: { + betreuteId, + belegId: null, + belegFehltBegruendung: null, + // Dauerzahlungen sind durch ihren Vertrag belegt - sie gehoeren + // nicht in die Liste der offenen Belege. Die Betragspruefung + // laeuft erst spaeter auf den geladenen Zeilen; hier wird nur + // grob vorgefiltert. + vertragId: null, + ...nichtGeloescht, + }, }) : null, diff --git a/src/lib/finanzen/kassenbuch.ts b/src/lib/finanzen/kassenbuch.ts index 0d4e649..b40a6e0 100644 --- a/src/lib/finanzen/kassenbuch.ts +++ b/src/lib/finanzen/kassenbuch.ts @@ -1,4 +1,4 @@ -import type { BuchungKategorie, BuchungRichtung } from '@prisma/client' +import type { BuchungKategorie, BuchungRichtung, ZahlRhythmus } from '@prisma/client' import { summe, teileGerundet } from '@/lib/finanzen/betrag' import { alsTag, imZeitraum, nachTagSortieren } from '@/lib/finanzen/datum' import { @@ -39,6 +39,15 @@ export type BuchungEingabe = { belegId: string | null belegTitel?: string | null belegFehltBegruendung: string | null + /** Dauerzahlung zu einem Vertrag - dann ist der Vertrag der Beleg. */ + vertragId?: string | null + vertragBezeichnung?: string | null + vertragsnummer?: string | null + /** Hinterlegter Beitrag samt Zahlweise - Grundlage der Betragspruefung. */ + vertragBeitragCent?: bigint | null + vertragRhythmus?: ZahlRhythmus | null + /** Wurde die Zuordnung beim Import automatisch gesetzt? */ + vertragAutomatisch?: boolean betrifftBetreute: boolean vermoegenswirksam: boolean /** Laeuft die Buchung ueber ein Konto des Betreuers? */ @@ -54,24 +63,134 @@ export function istVermoegensbuchung(buchung: BuchungEingabe): boolean { // Belegzwang // ----------------------------------------------------------------------------- +/** + * Wie weit darf die Zahlung vom hinterlegten Beitrag abweichen? + * + * Ein Fuenftel. Das ist grosszuegig und mit Absicht: Beitragsanpassungen, + * Versicherungsteuer und ein nachtraeglich geaenderter Selbstbehalt bewegen + * sich in dieser Groessenordnung, und bei jeder davon soll die Anwendung + * nicht meckern. + * + * Was sie fangen soll, ist die andere Groessenordnung: die Praemie, die von + * 42 auf 420 Euro springt, oder die Lastschrift, die versehentlich zweimal + * lief. Wer die Grenze enger zieht, bekommt taeglich Warnungen und sieht + * die eine wichtige nicht mehr. + */ +export const BETRAGS_TOLERANZ_PROZENT = 20n + +/** + * Passt der Betrag zu dem, was am Vertrag steht? + * + * Verglichen wird gegen den hinterlegten Beitrag, unveraendert. Kein + * Umrechnen auf andere Zahlweisen. + * + * Der erste Entwurf tat genau das - Jahresbetrag bilden und gegen + * jaehrlich, halbjaehrlich, vierteljaehrlich und monatlich pruefen, damit + * es auch passt, wenn die Bank anders abbucht als der Vertrag es fuehrt. + * Das Ergebnis war ein Toleranzband mit vier Loechern: Bei einem + * Monatsbeitrag von 42,50 EUR liegen 420 EUR innerhalb von 20 Prozent des + * Jahresbetrags von 510 EUR. Ausgerechnet der Fall, vor dem diese Pruefung + * schuetzen soll, waere durchgerutscht. + * + * Bucht die Bank tatsaechlich jaehrlich ab, waehrend am Vertrag "monatlich" + * steht, dann stimmen die Vertragsdaten nicht - und dann soll die + * Buchung als "zu pruefen" auffallen, statt stillschweigend akzeptiert zu + * werden. Die Zahlweise am Vertrag zu berichtigen dauert zehn Sekunden und + * macht die Akte richtiger. + * + * Ohne hinterlegten Beitrag gibt es nichts zu vergleichen - dann lautet die + * Antwort "unbekannt" (null), nicht "in Ordnung". Der Unterschied zaehlt: + * Es soll nicht so aussehen, als waere geprueft worden. + */ +export function betragPasstZumVertrag( + betragCent: bigint, + beitragCent: bigint | null | undefined, + rhythmus: ZahlRhythmus | null | undefined, +): boolean | null { + if (beitragCent === null || beitragCent === undefined || beitragCent <= 0n) return null + // Zu einer Einmalzahlung gibt es keine wiederkehrende Rate, gegen die + // sich vergleichen liesse. + if (!rhythmus || rhythmus === 'EINMALIG') return null + + const betrag = betragCent < 0n ? -betragCent : betragCent + const abweichung = betrag > beitragCent ? betrag - beitragCent : beitragCent - betrag + + return abweichung * 100n <= beitragCent * BETRAGS_TOLERANZ_PROZENT +} + +/** + * Ist die Buchung durch einen Vertrag belegt? + * + * Bei einem Dauerschuldverhaeltnis ist der Vertrag der Beleg - fuer zwoelf + * gleiche Lastschriften gibt es keine zwoelf Quittungen, sondern eine + * Police. + * + * Die Anerkennung faellt weg, sobald der Betrag deutlich abweicht. Sonst + * waere die Zuordnung eine Hintertuer, durch die auch eine verdreifachte + * Praemie unbemerkt als "belegt" durchginge. + */ +export function belegDurchVertrag(buchung: BuchungEingabe): boolean { + if (buchung.belegId) return false + if (!buchung.vertragId) return false + return ( + betragPasstZumVertrag( + buchung.betragCent, + buchung.vertragBeitragCent, + buchung.vertragRhythmus, + ) !== false + ) +} + +/** + * Zugeordnet, aber der Betrag passt nicht - das will jemand ansehen. + * + * Kein Fehler und kein Vorwurf: Meistens ist es eine Beitragsanpassung, die + * am Vertrag noch nicht nachgetragen wurde. Genau deshalb steht sie hier + * und nicht wortlos unter "Beleg fehlt". + */ +export function betragPruefen(buchung: BuchungEingabe): boolean { + if (buchung.belegId) return false + if (!buchung.vertragId) return false + return ( + betragPasstZumVertrag( + buchung.betragCent, + buchung.vertragBeitragCent, + buchung.vertragRhythmus, + ) === false + ) +} + /** * Fehlt der Beleg? Eine Begruendung heilt das Fehlen fuer die Statistik, * nicht fuer den Aktenordner - aber sie beantwortet die Frage des * Rechtspflegers "und wo ist der Beleg?" schon vorab. */ export function belegFehlt(buchung: BuchungEingabe): boolean { - return !buchung.belegId && !(buchung.belegFehltBegruendung ?? '').trim() + if (buchung.belegId) return false + if (belegDurchVertrag(buchung)) return false + return !(buchung.belegFehltBegruendung ?? '').trim() } export function belegBegruendet(buchung: BuchungEingabe): boolean { - return !buchung.belegId && (buchung.belegFehltBegruendung ?? '').trim() !== '' + if (buchung.belegId) return false + if (belegDurchVertrag(buchung)) return false + return (buchung.belegFehltBegruendung ?? '').trim() !== '' } export type Belegquote = { gesamt: number mitBeleg: number + /** + * Dauerzahlung zu einem Vertrag - eigene Kategorie, NICHT eingerechnet in + * "begruendet". Ein Vertrag belegt, eine Begruendung erklaert nur; das + * zusammenzuwerfen nimmt der Zahl ihre Aussage gegenueber dem + * Rechtspfleger. + */ + durchVertrag: number begruendet: number ohneNachweis: number + /** Zugeordnet, aber der Betrag weicht ab. Teilmenge von ohneNachweis. */ + zuPruefen: number /** 0 bis 100, ganzzahlig. Bei null Buchungen: 100 - es fehlt ja nichts. */ quoteProzent: number } @@ -79,11 +198,13 @@ export type Belegquote = { export function belegquote(buchungen: BuchungEingabe[]): Belegquote { const gesamt = buchungen.length const mitBeleg = buchungen.filter((b) => Boolean(b.belegId)).length + const durchVertrag = buchungen.filter((b) => belegDurchVertrag(b)).length const begruendet = buchungen.filter((b) => belegBegruendet(b)).length const ohneNachweis = buchungen.filter((b) => belegFehlt(b)).length + const zuPruefen = buchungen.filter((b) => betragPruefen(b)).length const quoteProzent = - gesamt === 0 ? 100 : Math.round(((mitBeleg + begruendet) / gesamt) * 100) - return { gesamt, mitBeleg, begruendet, ohneNachweis, quoteProzent } + gesamt === 0 ? 100 : Math.round(((mitBeleg + durchVertrag + begruendet) / gesamt) * 100) + return { gesamt, mitBeleg, durchVertrag, begruendet, ohneNachweis, zuPruefen, quoteProzent } } /** diff --git a/src/lib/finanzen/rechnungslegung.ts b/src/lib/finanzen/rechnungslegung.ts index 1ca8b3b..dda6e62 100644 --- a/src/lib/finanzen/rechnungslegung.ts +++ b/src/lib/finanzen/rechnungslegung.ts @@ -3,7 +3,9 @@ import { alsTag, imZeitraum, nachTagSortieren, tagePlus, vorOderGleich } from '@ import type { Betreuungsjahr } from '@/lib/finanzen/betreuungsjahr' import { ausgabenGruppiert, + belegDurchVertrag, belegFehlt, + betragPruefen, einnahmenGruppiert, istVermoegensbuchung, belegquote, @@ -158,6 +160,16 @@ export type Belegzeile = { belegId: string | null belegTitel: string | null belegFehltBegruendung: string | null + /** + * Dauerzahlung zu einem Vertrag. Steht im Bericht anstelle von + * "Beleg fehlt" - die Luecke wird damit nicht versteckt, sondern + * erklaert: Wer nachpruefen will, weiss, welches Papier er aus dem + * Ordner holt. + */ + vertragBezeichnung: string | null + vertragsnummer: string | null + /** Zugeordnet, aber der Betrag weicht ab - im Bericht ausgewiesen. */ + betragZuPruefen: boolean fehlt: boolean } @@ -179,6 +191,9 @@ export function beleglisteErstellen(buchungen: BuchungEingabe[]): Belegzeile[] { belegId: buchung.belegId, belegTitel: buchung.belegTitel ?? null, belegFehltBegruendung: buchung.belegFehltBegruendung, + vertragBezeichnung: belegDurchVertrag(buchung) ? (buchung.vertragBezeichnung ?? null) : null, + vertragsnummer: belegDurchVertrag(buchung) ? (buchung.vertragsnummer ?? null) : null, + betragZuPruefen: betragPruefen(buchung), fehlt: belegFehlt(buchung), })) } diff --git a/src/lib/finanzen/wiederkehrend-laden.ts b/src/lib/finanzen/wiederkehrend-laden.ts index 15e3692..63dbf0b 100644 --- a/src/lib/finanzen/wiederkehrend-laden.ts +++ b/src/lib/finanzen/wiederkehrend-laden.ts @@ -40,7 +40,13 @@ export async function wiederkehrendLaden(betreuteId: string): Promise p.id), + bereitsZugeordnet: posten.filter((p) => Boolean(p.vertragId)).length, + offeneBuchungIds: sortiert.filter((p) => !p.vertragId).map((p) => p.id), }) } @@ -506,6 +518,28 @@ export function alsZahlRhythmus(rhythmus: Rhythmus): string | null { } } +/** + * Sucht den Vertrag, zu dem eine Gegenstelle EINDEUTIG passt. + * + * Gedacht fuer die automatische Zuordnung beim Import. Die Betonung liegt + * auf eindeutig: Passen zwei Vertraege (Hausrat und Haftpflicht bei + * derselben Gesellschaft), wird nichts zugeordnet. Eine falsche Zuordnung + * waere schlimmer als gar keine - sie behauptet einen Beleg, den es nicht + * gibt, und niemand sieht noch einmal hin. + * + * Gibt null zurueck, wenn nichts oder mehr als eines passt. + */ +export function eindeutigerVertrag( + gegenstelle: string | null | undefined, + vertraege: VertragAbgleich[], +): VertragAbgleich | null { + const schluessel = normalisieren(gegenstelle) + if (!schluessel) return null + + const treffer = vertraege.filter((vertrag) => normalisieren(vertrag.gesellschaft) === schluessel) + return treffer.length === 1 ? treffer[0]! : null +} + /** Wie viele Gruppen warten noch auf eine Entscheidung? Fuer das Dashboard. */ export function offeneKandidaten(gruppen: Zahlungsgruppe[]): number { return gruppen.filter((gruppe) => gruppe.vertragId === null).length diff --git a/src/lib/validierung/finanzen.ts b/src/lib/validierung/finanzen.ts index c993983..970f34b 100644 --- a/src/lib/validierung/finanzen.ts +++ b/src/lib/validierung/finanzen.ts @@ -229,6 +229,7 @@ export const buchungSchema = z verwendungszweck: optionalerText(500), belegId: optionaleId, belegFehltBegruendung: optionalerText(300), + vertragId: optionaleId, betrifftBetreute: schalter, vermoegenswirksam: schalter, }) diff --git a/src/lib/version.ts b/src/lib/version.ts index 57d5daf..13db994 100644 --- a/src/lib/version.ts +++ b/src/lib/version.ts @@ -17,7 +17,7 @@ */ /** Muss zur "version" in der package.json passen. */ -export const VERSION = '1.2.0' +export const VERSION = '1.3.0' /** * Kurzer Git-Stand, beim Bauen hineingereicht: diff --git a/src/server/actions/finanzen.ts b/src/server/actions/finanzen.ts index c0b6d14..eea0c55 100644 --- a/src/server/actions/finanzen.ts +++ b/src/server/actions/finanzen.ts @@ -11,6 +11,7 @@ import { blindindex } from '@/lib/crypto/hash' import { alsTag } from '@/lib/finanzen/datum' import { csvTextLesen, csvZuBuchungen, dublettenMarkieren } from '@/lib/finanzen/import' import { camtZuBuchungen } from '@/lib/finanzen/camt' +import { eindeutigerVertrag } from '@/lib/finanzen/wiederkehrend' import { buchungSchema, fehlerAuflisten, @@ -349,6 +350,10 @@ export async function buchungSpeichern( kategorie: werte.kategorie, gegenstelle: werte.gegenstelle, belegId: werte.belegId, + vertragId: werte.vertragId, + // Von Hand gesetzt heisst: nicht mehr "automatisch". Wer die + // Zuordnung anfasst, uebernimmt sie. + vertragAutomatisch: false, belegFehltBegruendung: werte.belegId ? null : werte.belegFehltBegruendung, betrifftBetreute: werte.betrifftBetreute, vermoegenswirksam: werte.vermoegenswirksam, @@ -515,8 +520,18 @@ export async function importAusfuehren( const anzulegen = zeilen.filter((z) => werte.dublettenUebernehmen || !z.dublette) + // Vertraege einmal holen, nicht je Zeile. Zugeordnet wird nur, wo die + // Gegenstelle zu genau EINEM Vertrag passt - bei Mehrdeutigkeit + // geschieht nichts. + const vertraege = await db.vertrag.findMany({ + where: { betreuteId, ...nichtGeloescht }, + select: { id: true, bezeichnung: true, gesellschaft: true }, + }) + let angelegt = 0 + let zugeordnet = 0 for (const zeile of anzulegen) { + const passenderVertrag = eindeutigerVertrag(zeile.gegenstelle, vertraege) try { await db.buchung.create({ data: { @@ -530,10 +545,17 @@ export async function importAusfuehren( gegenstelle: zeile.gegenstelle, betrifftBetreute: werte.betrifftBetreute, importReferenz: werte.dublettenUebernehmen && zeile.dublette ? null : zeile.importReferenz, + // Nur bei Ausgaben: Eine Gutschrift der Versicherung ist keine + // Praemienzahlung und darf nicht als "durch Vertrag belegt" + // gelten. + ...(zeile.richtung === 'AUSGABE' && passenderVertrag + ? { vertragId: passenderVertrag.id, vertragAutomatisch: true } + : {}), ...sealen('Buchung', { verwendungszweck: zeile.verwendungszweck }), }, }) angelegt++ + if (zeile.richtung === 'AUSGABE' && passenderVertrag) zugeordnet++ } catch (ausnahme) { // Die Eindeutigkeit auf (kontoId, importReferenz) faengt Reste ab, // die zwischen Lesen und Schreiben dazugekommen sind. @@ -563,6 +585,13 @@ export async function importAusfuehren( ? [`${gelesen.fehler.length} Zeile(n) konnten nicht gelesen werden:`] : []), ...gelesen.fehler.slice(0, 10).map((f) => `Zeile ${f.zeilennummer}: ${f.meldung}`), + ...(zugeordnet > 0 + ? [ + `${zugeordnet} Buchung(en) wurden automatisch einem Vertrag zugeordnet und gelten ` + + 'damit als belegt. Sie sind in der Liste als automatisch zugeordnet erkennbar – ' + + 'bitte einmal durchsehen.', + ] + : []), 'Alle importierten Buchungen stehen zunächst in „Sonstige Einnahmen“ bzw. „Sonstige Ausgaben“. ' + 'Bitte die Kategorien nachtragen – danach stimmen die Berichte.', ] @@ -924,3 +953,69 @@ export async function zahlungsgruppeZurueckholen( revalidatePath('/heute') return { erfolg: 'Die Gruppe steht wieder in der Liste.' } } + +/** + * Ordnet mehrere Buchungen auf einmal einem Vertrag zu. + * + * Der Weg dorthin fuehrt ueber "Wiederkehrende Zahlungen": Dort steht eine + * Gruppe gleichartiger Abbuchungen beisammen, und wenn dazu ein Vertrag in + * der Akte liegt, sind es genau die Buchungen, die er belegt. Sie einzeln + * anzuklicken waere die Arbeit, die dieses Issue abschaffen soll. + * + * Die IDs kommen aus dem Formular und werden NICHT geglaubt: Geschrieben + * wird nur, was zu dieser betreuten Person gehoert. + */ +export async function zahlungenVertragZuordnen( + _zustand: Formularzustand, + daten: FormData, +): Promise { + const betreuteId = String(daten.get('betreuteId') ?? '') + const vertragId = String(daten.get('vertragId') ?? '') + const buchungIds = String(daten.get('buchungIds') ?? '') + .split(',') + .map((id) => id.trim()) + .filter(Boolean) + + if (!vertragId || buchungIds.length === 0) { + return { fehler: 'Es ist nichts zum Zuordnen ausgewählt.' } + } + + try { + const { benutzer } = await erfordereZugang(betreuteId, 'FINANZEN', 'schreiben') + + const vertrag = await db.vertrag.findFirst({ + where: { id: vertragId, betreuteId, ...nichtGeloescht }, + select: { id: true, bezeichnung: true }, + }) + if (!vertrag) return { fehler: 'Diesen Vertrag gibt es nicht (mehr).' } + + // betreuteId im WHERE ist der eigentliche Schutz: Eine fremde + // Buchungs-ID trifft dann auf null Zeilen. + const { count } = await db.buchung.updateMany({ + where: { id: { in: buchungIds }, betreuteId, ...nichtGeloescht }, + data: { vertragId: vertrag.id, vertragAutomatisch: false }, + }) + + await protokollieren({ + aktion: 'GEAENDERT', + entitaet: 'Buchung', + bezeichnung: `${count} Buchung(en) als Dauerzahlung zu „${vertrag.bezeichnung}“`, + betreuteId, + userId: benutzer.id, + userEmail: benutzer.email, + userName: benutzer.name, + }) + + revalidatePath('/finanzen') + revalidatePath('/finanzen/buchungen') + revalidatePath('/finanzen/wiederkehrend') + revalidatePath('/heute') + + return { erfolg: `${count} Buchung(en) gelten jetzt als durch den Vertrag belegt.` } + } catch (ausnahme) { + const verweigert = zugriffsfehler(ausnahme) + if (verweigert) return verweigert + console.error('[finanzen] Sammelzuordnung fehlgeschlagen:', ausnahme) + return { fehler: 'Die Zuordnung konnte nicht gespeichert werden.' } + } +} diff --git a/tests/dauerzahlung.test.ts b/tests/dauerzahlung.test.ts new file mode 100644 index 0000000..32d3288 --- /dev/null +++ b/tests/dauerzahlung.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, it } from 'vitest' +import { + BETRAGS_TOLERANZ_PROZENT, + belegBegruendet, + belegDurchVertrag, + belegFehlt, + belegquote, + betragPasstZumVertrag, + betragPruefen, + type BuchungEingabe, +} from '@/lib/finanzen/kassenbuch' + +/** + * Dauerzahlungen zu einem Vertrag. + * + * Bei einem Dauerschuldverhältnis ist der Vertrag der Beleg — für zwölf + * gleiche Lastschriften gibt es keine zwölf Quittungen, sondern eine Police. + * + * Der gefährliche Fehler wäre hier nicht ein zu strenges Urteil, sondern ein + * zu mildes: Wenn eine verdreifachte Prämie als „belegt" durchgeht, ist die + * Zuordnung eine Hintertür geworden. Die Hälfte dieser Prüfungen zielt + * deshalb darauf, dass die Anerkennung auch wirklich **wegfällt**. + */ + +function buchung(teile: Partial = {}): BuchungEingabe { + return { + id: 'b1', + datum: new Date('2026-03-01T00:00:00Z'), + betragCent: 4250n, + richtung: 'AUSGABE', + kategorie: 'VERSICHERUNG', + gegenstelle: 'Allianz', + verwendungszweck: null, + kontoId: 'k1', + belegId: null, + belegFehltBegruendung: null, + betrifftBetreute: true, + vermoegenswirksam: false, + ...teile, + } +} + +/** Monatlicher Vertrag über 42,50 EUR. */ +const MONATLICH = { vertragId: 'v1', vertragBeitragCent: 4250n, vertragRhythmus: 'MONATLICH' as const } + +describe('Betrag gegen den hinterlegten Beitrag', () => { + it('erkennt die passende monatliche Rate', () => { + expect(betragPasstZumVertrag(4250n, 4250n, 'MONATLICH')).toBe(true) + }) + + it('lässt eine Beitragsanpassung durchgehen', () => { + // 46,00 statt 42,50 - knapp acht Prozent, das ist eine normale Anpassung. + expect(betragPasstZumVertrag(4600n, 4250n, 'MONATLICH')).toBe(true) + }) + + it('meldet die Prämie, die von 42 auf 420 springt', () => { + expect(betragPasstZumVertrag(42_000n, 4250n, 'MONATLICH')).toBe(false) + }) + + it('meldet auch die Zahlweise, die nicht zum Vertrag passt', () => { + // Am Vertrag steht die Monatsrate, die Bank bucht jährlich ab + // (12 x 42,50 = 510,00). Das gilt bewusst NICHT als in Ordnung: Dann + // stimmen die Vertragsdaten nicht, und das soll auffallen. + // + // Ein früherer Entwurf hat solche Umrechnungen zugelassen — und damit + // ein Toleranzband mit vier Löchern geschaffen. 420 EUR wären als + // "nahe am Jahresbetrag von 510" durchgegangen, also ausgerechnet der + // Fall, den diese Prüfung fangen soll. + expect(betragPasstZumVertrag(51_000n, 4250n, 'MONATLICH')).toBe(false) + }) + + it('akzeptiert die Rate, sobald die Zahlweise am Vertrag stimmt', () => { + // Derselbe Fall, nachdem die Zahlweise berichtigt wurde. + expect(betragPasstZumVertrag(51_000n, 51_000n, 'JAEHRLICH')).toBe(true) + }) + + it('antwortet „unbekannt“, wenn kein Beitrag hinterlegt ist', () => { + // Nicht "in Ordnung". Es soll nicht so aussehen, als wäre geprüft worden. + expect(betragPasstZumVertrag(4250n, null, 'MONATLICH')).toBeNull() + expect(betragPasstZumVertrag(4250n, 0n, 'MONATLICH')).toBeNull() + }) + + it('antwortet „unbekannt“ ohne Zahlweise und bei EINMALIG', () => { + expect(betragPasstZumVertrag(4250n, 4250n, null)).toBeNull() + // Zu einer Einmalzahlung gibt es keine wiederkehrende Rate. + expect(betragPasstZumVertrag(4250n, 4250n, 'EINMALIG')).toBeNull() + }) + + it('rechnet mit dem Betrag, nicht mit dem Vorzeichen', () => { + expect(betragPasstZumVertrag(-4250n, 4250n, 'MONATLICH')).toBe(true) + }) + + it('hält die dokumentierte Toleranz ein', () => { + expect(BETRAGS_TOLERANZ_PROZENT).toBe(20n) + // Genau an der Grenze: 42,50 + 20 % = 51,00. + expect(betragPasstZumVertrag(5100n, 4250n, 'MONATLICH')).toBe(true) + // Knapp darüber nicht mehr. + expect(betragPasstZumVertrag(5200n, 4250n, 'MONATLICH')).toBe(false) + }) +}) + +describe('Belegzustand', () => { + it('zählt eine Dauerzahlung als belegt', () => { + const b = buchung(MONATLICH) + expect(belegDurchVertrag(b)).toBe(true) + expect(belegFehlt(b)).toBe(false) + expect(belegBegruendet(b)).toBe(false) + }) + + it('lässt die Anerkennung bei abweichendem Betrag wegfallen', () => { + // Der wichtigste Test: Sonst wäre die Zuordnung eine Hintertür. + const b = buchung({ ...MONATLICH, betragCent: 42_000n }) + expect(belegDurchVertrag(b)).toBe(false) + expect(betragPruefen(b)).toBe(true) + expect(belegFehlt(b)).toBe(true) + }) + + it('erkennt eine Zuordnung an, wenn am Vertrag kein Beitrag steht', () => { + // Zuordnung gilt, aber es wird nicht behauptet, sie sei geprüft. + const b = buchung({ vertragId: 'v1', vertragBeitragCent: null, vertragRhythmus: null }) + expect(belegDurchVertrag(b)).toBe(true) + expect(betragPruefen(b)).toBe(false) + }) + + it('lässt einen echten Beleg vorgehen', () => { + const b = buchung({ ...MONATLICH, belegId: 'd1' }) + expect(belegDurchVertrag(b)).toBe(false) + expect(belegFehlt(b)).toBe(false) + }) + + it('bleibt ohne Zuordnung beim alten Verhalten', () => { + expect(belegFehlt(buchung())).toBe(true) + expect(belegBegruendet(buchung({ belegFehltBegruendung: 'Barzahlung' }))).toBe(true) + expect(belegFehlt(buchung({ belegFehltBegruendung: 'Barzahlung' }))).toBe(false) + }) + + it('lässt eine Begründung nicht neben den Vertrag treten', () => { + // Der Vertrag ist die stärkere Aussage; doppelt gezählt würde die + // Summe der Kategorien die Gesamtzahl übersteigen. + const b = buchung({ ...MONATLICH, belegFehltBegruendung: 'Dauerauftrag' }) + expect(belegDurchVertrag(b)).toBe(true) + expect(belegBegruendet(b)).toBe(false) + }) +}) + +describe('Belegquote', () => { + it('weist die drei Fälle getrennt aus', () => { + const quote = belegquote([ + buchung({ id: '1', belegId: 'd1' }), + buchung({ id: '2', ...MONATLICH }), + buchung({ id: '3', belegFehltBegruendung: 'Barzahlung' }), + buchung({ id: '4' }), + ]) + + expect(quote.gesamt).toBe(4) + expect(quote.mitBeleg).toBe(1) + expect(quote.durchVertrag).toBe(1) + expect(quote.begruendet).toBe(1) + expect(quote.ohneNachweis).toBe(1) + expect(quote.quoteProzent).toBe(75) + }) + + it('rechnet die Kategorien überschneidungsfrei', () => { + // Jede Buchung zählt in genau eine Kategorie - sonst ergäbe die Summe + // mehr als das Ganze. + const buchungen = [ + buchung({ id: '1', belegId: 'd1' }), + buchung({ id: '2', ...MONATLICH }), + buchung({ id: '3', ...MONATLICH, belegFehltBegruendung: 'egal' }), + buchung({ id: '4', belegFehltBegruendung: 'Barzahlung' }), + buchung({ id: '5' }), + buchung({ id: '6', ...MONATLICH, betragCent: 99_000n }), + ] + const q = belegquote(buchungen) + expect(q.mitBeleg + q.durchVertrag + q.begruendet + q.ohneNachweis).toBe(q.gesamt) + }) + + it('zählt eine abweichende Zahlung als zu prüfen und nicht als belegt', () => { + const quote = belegquote([buchung({ ...MONATLICH, betragCent: 42_000n })]) + expect(quote.durchVertrag).toBe(0) + expect(quote.zuPruefen).toBe(1) + expect(quote.ohneNachweis).toBe(1) + expect(quote.quoteProzent).toBe(0) + }) + + it('bleibt bei null Buchungen bei 100 Prozent', () => { + expect(belegquote([]).quoteProzent).toBe(100) + }) +}) diff --git a/tests/wiederkehrend.test.ts b/tests/wiederkehrend.test.ts index 7c96e26..0594b70 100644 --- a/tests/wiederkehrend.test.ts +++ b/tests/wiederkehrend.test.ts @@ -8,6 +8,7 @@ import { einzelneZahlungen, gruppieren, normalisieren, + eindeutigerVertrag, offeneKandidaten, rhythmusAusAbstand, rhythmusErklaerung, @@ -281,6 +282,22 @@ describe('Gruppieren', () => { expect(gruppen[0]!.jahresbetragCent).toBe(30_000n) }) + it('sagt, wie viele Zahlungen der Gruppe schon an einem Vertrag hängen', () => { + // Grundlage der Sammelzuordnung (Issue #11): Nur der Rest muss noch + // verknüpft werden, nicht alles noch einmal. + const zahlungen = monatlich('Debeka AG', 30, 4) + zahlungen[0]!.vertragId = 'v1' + zahlungen[1]!.vertragId = 'v1' + + const gruppe = gruppieren(zahlungen, { + vertraege: [{ id: 'v1', bezeichnung: 'Krankenzusatz', gesellschaft: 'Debeka' }], + })[0]! + + expect(gruppe.anzahl).toBe(4) + expect(gruppe.bereitsZugeordnet).toBe(2) + expect(gruppe.offeneBuchungIds).toHaveLength(2) + }) + it('zählt für das Dashboard nur ungeklärte Gruppen', () => { const gruppen = gruppieren( [...monatlich('Debeka AG', 30, 4), ...monatlich('Unbekannt Versicherung', 20, 4)], @@ -351,3 +368,28 @@ describe('Erklärung unter der Gruppe', () => { expect(rhythmusErklaerung(gruppe)).toContain('ohne erkennbaren Abstand') }) }) + +describe('Eindeutige Vertragszuordnung beim Import', () => { + const vertraege = [ + { id: 'v1', bezeichnung: 'Hausrat', gesellschaft: 'Allianz Versicherung' }, + { id: 'v2', bezeichnung: 'Haftpflicht', gesellschaft: 'Allianz Versicherung' }, + { id: 'v3', bezeichnung: 'Krankenzusatz', gesellschaft: 'Debeka AG' }, + ] + + it('ordnet zu, wenn genau ein Vertrag passt', () => { + expect(eindeutigerVertrag('Debeka', vertraege)?.id).toBe('v3') + }) + + it('ordnet NICHT zu, wenn zwei Verträge zur selben Gesellschaft gehören', () => { + // Hausrat und Haftpflicht bei derselben Gesellschaft. Eine falsche + // Zuordnung wäre schlimmer als gar keine — sie behauptet einen Beleg, + // und niemand sieht noch einmal hin. + expect(eindeutigerVertrag('Allianz Versicherung', vertraege)).toBeNull() + }) + + it('ordnet nichts zu, wenn nichts passt', () => { + expect(eindeutigerVertrag('Stadtwerke Bamberg', vertraege)).toBeNull() + expect(eindeutigerVertrag(null, vertraege)).toBeNull() + expect(eindeutigerVertrag('', vertraege)).toBeNull() + }) +}) -- 2.54.0