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() + }) +})