diff --git a/CHANGELOG.md b/CHANGELOG.md index 2500845..219e4fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,57 @@ Wiederherstellung*. _Noch nichts._ +## [1.8.0] – 2026-08-23 + +### Hinzugefügt + +- **Der Rückkaufswert steht jetzt beim Vertrag** + ([#8](https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/issues/8)). + Am Telefon mit der Gesellschaft braucht man die Zahl dort, wo man sie + gefragt hat — nicht nach einem Umweg über das Vermögensverzeichnis. + Auf der Vertragsseite steht deshalb oben der zuletzt mitgeteilte Wert + mit Stichtag und Quelle, darunter die vollständige Historie. + +- **Ein alter Wert sagt, dass er alt ist.** Ist der jüngste Wert älter + als ein Jahr, steht „über ein Jahr alt" daneben — in der + Vertragsübersicht wie auf der Vertragsseite. Ein veralteter Betrag + ohne erkennbares Alter ist schlimmer als kein Betrag: Man rechnet mit + ihm weiter und merkt es erst, wenn der Bezirk nachfragt. Die Grenze von + einem Jahr gilt jetzt überall dieselbe; im Vermögensverzeichnis stand + sie schon, am Vertrag fehlte sie. + +- **Verträge ohne Wert bleiben nicht stumm.** Wo ein Rückkaufswert zu + erwarten wäre, aber keiner erfasst ist, steht „noch nicht erfasst" + statt einer leeren Zelle. + +### Behoben + +- **Ein Vertrag hatte unter Umständen zwei Wertreihen.** Vertrag und + Vermögensposten wurden über die *Art* des Postens zusammengeführt. + Wer einen Posten von Hand anlegte und ihn als etwas anderes als + „Versicherung" führte, bekam beim ersten Eintragen am Vertrag + klammheimlich einen zweiten Posten — und damit zwei Wahrheiten, von + denen der Bericht die eine und der Vertrag die andere zeigte. + Maßgeblich ist jetzt die Verknüpfung mit dem Vertrag, nicht die Art. + +- **Fehlende Prüfung bei Verweisen auf Vertrag und Immobilie.** Beim + Speichern eines Vermögenspostens wurde geprüft, ob das verknüpfte + *Konto* zur betreuten Person gehört — bei Vertrag und Immobilie fehlte + dieselbe Prüfung, ebenso beim Vertragsverweis einer Buchung. Bei + mehreren betreuten Personen hätte ein manipuliertes Formular eine + Verknüpfung über Aktengrenzen hinweg anlegen können. Jetzt wird jeder + der drei Verweise gleich behandelt. + +### Geprüft + +- Zehn Einheitstests: die Ein-Jahres-Grenze (auch der Tag davor und + danach), das Alter taggenau und ohne Uhrzeiteinfluss, ein künftiger + Stichtag, und die Zusammenführung mehrerer Posten zu einer Historie. +- Vier End-to-End-Tests: Wert und Stichtag in der Übersicht, das + Abzeichen am alten Wert, die Historie am Vertrag, und ein neu + eingetragener Wert, der unverändert im Vermögensverzeichnis + wiederauftaucht. + ## [1.7.0] – 2026-08-23 ### Hinzugefügt @@ -928,7 +979,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.7.0...HEAD +[Unveröffentlicht]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.8.0...HEAD +[1.8.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.7.0...v1.8.0 [1.7.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.6.0...v1.7.0 [1.6.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.5.0...v1.6.0 [1.5.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.4.0...v1.5.0 diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index 86cf027..8d22edb 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.7.0 +git checkout v1.8.0 ``` ### Kontrolle ```bash ls docker-compose.yml Dockerfile .env.example # alle drei da -git describe --tags # v1.7.0 +git describe --tags # v1.8.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.7.0","commit":"..."} +# {"status":"bereit","version":"1.8.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.7.0",...} +# {"status":"bereit","version":"1.8.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.7.0 · `. +- Unten links in der Seitenleiste steht `Version 1.8.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.7.0` | auf eine bestimmte Fassung | +| `./scripts/update.sh v1.8.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.7.0 +git checkout v1.8.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 8efd2fb..9cf78eb 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.7.0** ([CHANGELOG](../CHANGELOG.md)) +Stand: 23.08.2026 · Aktuelle Fassung: **1.8.0** ([CHANGELOG](../CHANGELOG.md)) --- diff --git a/e2e/rueckkaufswert-vertrag.spec.ts b/e2e/rueckkaufswert-vertrag.spec.ts new file mode 100644 index 0000000..a9fc992 --- /dev/null +++ b/e2e/rueckkaufswert-vertrag.spec.ts @@ -0,0 +1,176 @@ +import { expect, test } from '@playwright/test' +import { PrismaClient } from '@prisma/client' +import { anmelden } from './hilfen' + +/** + * Der Rückkaufswert am Vertrag — gegen die echte Anwendung. + * + * Die Zusage, die hier geprüft wird: Der Wert steht dort, wo man ihn + * braucht, sein Stichtag steht daneben, ein alter Wert ist als alt + * erkennbar — und es ist derselbe Wert, den das Vermögensverzeichnis + * ausweist, nicht ein zweiter danebengelegter. + */ + +const KENNUNG = 'Prüfhausen Wert' + +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.vermoegensposten.deleteMany({ where: { bezeichnung: { contains: KENNUNG } } }) + await db.vertrag.deleteMany({ where: { bezeichnung: { contains: KENNUNG } } }) + }) +} + +/** Tag vor N Tagen, als reines Datum. */ +function vorTagen(tage: number): Date { + const heute = new Date() + const ziel = new Date(Date.UTC(heute.getFullYear(), heute.getMonth(), heute.getDate())) + ziel.setUTCDate(ziel.getUTCDate() - tage) + return ziel +} + +test.describe('Rückkaufswert am Vertrag', () => { + test.beforeAll(async () => { + await aufraeumen() + await mitDb(async (db) => { + const betreute = await db.betreute.findMany({ + where: { geloeschtAm: null }, + select: { id: true }, + }) + for (const person of betreute) { + // Ein Vertrag mit frischem Wert, einer mit einem zwei Jahre alten, + // einer ohne jeden Wert. + const frisch = await db.vertrag.create({ + data: { + betreuteId: person.id, + bezeichnung: `${KENNUNG} Frisch`, + gesellschaft: `${KENNUNG} Leben AG`, + art: 'LEBENSVERSICHERUNG', + status: 'AKTIV', + }, + }) + const alt = await db.vertrag.create({ + data: { + betreuteId: person.id, + bezeichnung: `${KENNUNG} Alt`, + gesellschaft: `${KENNUNG} Sterbekasse`, + art: 'STERBEGELDVERSICHERUNG', + status: 'AKTIV', + }, + }) + await db.vertrag.create({ + data: { + betreuteId: person.id, + bezeichnung: `${KENNUNG} Ohne`, + gesellschaft: `${KENNUNG} Renten AG`, + art: 'RENTENVERSICHERUNG_PRIVAT', + status: 'AKTIV', + }, + }) + + await db.vermoegensposten.create({ + data: { + betreuteId: person.id, + vertragId: frisch.id, + art: 'VERSICHERUNG', + bezeichnung: `${KENNUNG} Frisch`, + wertstaende: { + create: [ + { + stichtag: vorTagen(30), + wertCent: 521055n, + wertquelle: 'Rückkaufswertmitteilung', + }, + { stichtag: vorTagen(400), wertCent: 480000n, wertquelle: 'Jahresmitteilung' }, + ], + }, + }, + }) + + // Bewusst als „Sonstiges" verknüpft: Die Zuordnung zum Vertrag + // hängt an der Verknüpfung, nicht an der Art des Postens. + await db.vermoegensposten.create({ + data: { + betreuteId: person.id, + vertragId: alt.id, + art: 'SONSTIGES', + bezeichnung: `${KENNUNG} Alt`, + wertstaende: { + create: [ + { stichtag: vorTagen(800), wertCent: 310000n, wertquelle: 'Jahresmitteilung' }, + ], + }, + }, + }) + } + }) + }) + + test.afterAll(async () => { + await aufraeumen() + }) + + test('zeigt Wert und Stichtag in der Vertragsübersicht', async ({ page }) => { + await anmelden(page) + await page.goto('/vertraege') + + const inhalt = await page.locator('body').innerText() + expect(inhalt).toContain('5.210,55') + // Ohne Wert bleibt die Zelle nicht leer, sondern sagt es. + expect(inhalt).toContain('noch nicht erfasst') + }) + + test('kennzeichnet einen über ein Jahr alten Wert', async ({ page }) => { + await anmelden(page) + await page.goto('/vertraege') + + // Der 800 Tage alte Wert des „Alt"-Vertrags trägt das Abzeichen … + await expect(page.getByText('über ein Jahr alt').first()).toBeVisible() + + // … und er wird trotz Posten-Art „Sonstiges" überhaupt gefunden. + expect(await page.locator('body').innerText()).toContain('3.100,00') + }) + + test('zeigt am Vertrag den letzten Wert und die Historie', async ({ page }) => { + await anmelden(page) + await page.goto('/vertraege') + await page.getByRole('link', { name: `${KENNUNG} Frisch` }).click() + await page.waitForURL(/\/vertraege\/[a-z0-9]+$/) + await expect(page.getByText('Zuletzt mitgeteilt')).toBeVisible() + + const inhalt = await page.locator('body').innerText() + expect(inhalt).toContain('Zuletzt mitgeteilt') + expect(inhalt).toContain('5.210,55') + // Der frühere Wert bleibt abrufbar — die Entwicklung ist die Aussage. + expect(inhalt).toContain('4.800,00') + }) + + test('trägt einen neuen Wert am Vertrag ein — sichtbar auch im Vermögensverzeichnis', async ({ + page, + }) => { + await anmelden(page) + await page.goto('/vertraege') + await page.getByRole('link', { name: `${KENNUNG} Ohne` }).click() + await page.waitForURL(/\/vertraege\/[a-z0-9]+$/) + + await page.getByRole('button', { name: 'Mitgeteilten Wert eintragen' }).click() + await page.locator('input[name="stichtag"]').last().fill('2026-08-01') + await page.locator('input[name="wert"]').fill('1.234,56') + await page.getByRole('button', { name: 'Wert aufnehmen' }).click() + + await expect(page.getByText('Rückkaufswert in die Historie aufgenommen.')).toBeVisible() + expect(await page.locator('body').innerText()).toContain('1.234,56') + + // Dieselbe Zahl, eine Quelle: Das Vermögensverzeichnis kennt sie auch. + await page.goto('/finanzen/vermoegen?stichtag=2026-08-31') + expect(await page.locator('body').innerText()).toContain('1.234,56') + }) +}) diff --git a/package.json b/package.json index 0d33a4a..f155903 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "betreuungs-cockpit", - "version": "1.7.0", + "version": "1.8.0", "private": true, "description": "Arbeits- und Dokumentationswerkzeug fuer die ehrenamtliche rechtliche Betreuung", "engines": { diff --git a/src/app/(app)/finanzen/vermoegen/page.tsx b/src/app/(app)/finanzen/vermoegen/page.tsx index 0ce3288..6ec0fb9 100644 --- a/src/app/(app)/finanzen/vermoegen/page.tsx +++ b/src/app/(app)/finanzen/vermoegen/page.tsx @@ -18,6 +18,7 @@ import { VERMOEGENS_ART_EINZAHL, } from '@/lib/finanzen/kategorien' import { vermoegenLaden } from '@/lib/berichte/gemeinsam' +import { wertVeraltet } from '@/lib/finanzen/wertalter' import type { SozialhilfeBewertung } from '@prisma/client' export const metadata = { title: 'Vermögen' } @@ -235,7 +236,7 @@ function Postentabelle({ {formatDatum(eintrag.bewertetAm)} - {eintrag.alterTage > 365 && ( + {wertVeraltet(eintrag.alterTage) && ( über ein Jahr alt diff --git a/src/app/(app)/vertraege/[id]/page.tsx b/src/app/(app)/vertraege/[id]/page.tsx index db0f353..afba9b5 100644 --- a/src/app/(app)/vertraege/[id]/page.tsx +++ b/src/app/(app)/vertraege/[id]/page.tsx @@ -18,7 +18,12 @@ import { kuendigungsplan, laufzeitAusVertrag, } from '@/lib/vertraege/kuendigung' -import { anfrageStatus, rueckkaufswertAnschreiben } from '@/lib/vertraege/rueckkaufswert' +import { + anfrageStatus, + rueckkaufswertAnschreiben, + wertHistorie, +} from '@/lib/vertraege/rueckkaufswert' +import { wertalterBestimmen } from '@/lib/finanzen/wertalter' import { BEWERTUNG_BESCHRIFTUNG, BEWERTUNG_ERKLAERUNG, @@ -44,8 +49,9 @@ export default async function VertragDetail({ params }: { params: Promise<{ id: const vertrag = await db.vertrag.findFirst({ where: { id, betreuteId: aktive.id, ...nichtGeloescht }, include: { + // Ueber die Verknuepfung, nicht ueber die Art - siehe wertHistorie(). vermoegensposten: { - where: { art: 'VERSICHERUNG', ...nichtGeloescht }, + where: nichtGeloescht, include: { wertstaende: { orderBy: { stichtag: 'desc' } } }, }, }, @@ -76,7 +82,7 @@ export default async function VertragDetail({ params }: { params: Promise<{ id: heute, }) - const wertstaende = vertrag.vermoegensposten[0]?.wertstaende ?? [] + const wertstaende = wertHistorie(vertrag.vermoegensposten) return (
@@ -226,6 +232,9 @@ export default async function VertragDetail({ params }: { params: Promise<{ id: tageSeitAnfrage={anfrage.tageSeitAnfrage} anschreiben={anschreiben} stichtagVorschlag={alsIsoTag(heute)} + alterHinweis={ + wertalterBestimmen(wertstaende[0]?.stichtag ?? null, heute)?.hinweis ?? null + } werte={wertstaende.map((w) => ({ id: w.id, stichtag: w.stichtag.toISOString(), diff --git a/src/app/(app)/vertraege/page.tsx b/src/app/(app)/vertraege/page.tsx index be589af..c736ae8 100644 --- a/src/app/(app)/vertraege/page.tsx +++ b/src/app/(app)/vertraege/page.tsx @@ -22,10 +22,13 @@ import { import { BEWERTUNG_BESCHRIFTUNG, jahresbeitragCent, + kannRueckkaufswertHaben, RHYTHMUS_BESCHRIFTUNG, VERTRAG_ART_BESCHRIFTUNG, VERTRAG_STATUS_BESCHRIFTUNG, } from '@/lib/vertraege/konstanten' +import { juengsterWert } from '@/lib/vertraege/rueckkaufswert' +import { wertalterBestimmen } from '@/lib/finanzen/wertalter' import { formatCent, formatDatum } from '@/lib/utils' export const dynamic = 'force-dynamic' @@ -48,8 +51,10 @@ export default async function VertraegeUebersicht() { const vertraege = await db.vertrag.findMany({ where: { betreuteId: aktive.id, ...nichtGeloescht }, include: { + // Ueber die Verknuepfung, nicht ueber die Art: Ein von Hand als + // "Sonstiges" angelegter Posten meint denselben Vertrag. vermoegensposten: { - where: { art: 'VERSICHERUNG', ...nichtGeloescht }, + where: nichtGeloescht, include: { wertstaende: { orderBy: { stichtag: 'desc' }, take: 1 } }, }, }, @@ -71,6 +76,8 @@ export default async function VertraegeUebersicht() { }) const ampel = kuendigungsampel(plan) + const letzterWert = juengsterWert(vertrag.vermoegensposten) + const jahr = jahresbeitragCent(vertrag.beitragCent, vertrag.rhythmus) if (jahr && vertrag.status === 'AKTIV') jahresbeitragSumme += jahr @@ -97,11 +104,10 @@ export default async function VertraegeUebersicht() { beitragCent: centZuZahl(vertrag.beitragCent), rhythmus: vertrag.rhythmus, bewertung: vertrag.bewertungSozialhilfe, - rueckkaufswertCent: centZuZahl( - vertrag.vermoegensposten[0]?.wertstaende[0]?.wertCent ?? null, - ), - rueckkaufswertStichtag: - vertrag.vermoegensposten[0]?.wertstaende[0]?.stichtag.toISOString() ?? null, + rueckkaufswertCent: centZuZahl(letzterWert?.wertCent ?? null), + rueckkaufswertStichtag: letzterWert?.stichtag.toISOString() ?? null, + rueckkaufswertAlter: wertalterBestimmen(letzterWert?.stichtag ?? null, heute), + wertErwartet: kannRueckkaufswertHaben(vertrag.art), ampel, } }) @@ -201,10 +207,21 @@ export default async function VertraegeUebersicht() { )} - {formatCent(zeile.rueckkaufswertCent)} - {zeile.rueckkaufswertStichtag && ( - - {formatDatum(zeile.rueckkaufswertStichtag)} + {zeile.rueckkaufswertStichtag ? ( + <> + {formatCent(zeile.rueckkaufswertCent)} + + {formatDatum(zeile.rueckkaufswertStichtag)} + + {zeile.rueckkaufswertAlter?.veraltet && ( + + {zeile.rueckkaufswertAlter.hinweis} + + )} + + ) : ( + + {zeile.wertErwartet ? 'noch nicht erfasst' : '–'} )} diff --git a/src/components/vertraege/rueckkaufswert.tsx b/src/components/vertraege/rueckkaufswert.tsx index 855ccd9..a59969c 100644 --- a/src/components/vertraege/rueckkaufswert.tsx +++ b/src/components/vertraege/rueckkaufswert.tsx @@ -4,6 +4,7 @@ import Link from 'next/link' import { useActionState, useState } from 'react' import { Coins, FileDown, Mail, Plus } from 'lucide-react' import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' +import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Input } from '@/components/ui/input' @@ -36,6 +37,7 @@ export function Rueckkaufswert({ anschreiben, stichtagVorschlag, darfSchreiben, + alterHinweis, }: { betreuteId: string vertragId: string @@ -46,6 +48,8 @@ export function Rueckkaufswert({ anschreiben: string stichtagVorschlag: string darfSchreiben: boolean + /** Kurztext, wenn der juengste Wert zu alt ist - sonst null. */ + alterHinweis: string | null }) { const [erfassen, erfassenAktion] = useActionState( rueckkaufswertErfassen, @@ -55,6 +59,9 @@ export function Rueckkaufswert({ rueckkaufswertAnfordern, {}, ) + // Die Liste kommt bereits juengster Wert zuerst von der Seite. + const letzter = werte[0] ?? null + const [briefOffen, setBriefOffen] = useState(false) const [erfassenOffen, setErfassenOffen] = useState(false) @@ -88,7 +95,7 @@ export function Rueckkaufswert({ )} - {werte.length === 0 ? ( + {!letzter ? ( ) : ( - + <> + {/* Der zuletzt mitgeteilte Wert gehoert nach oben: Wer beim + Telefonat mit der Gesellschaft danach gefragt wird, hat + keine Zeit, eine Historie zu lesen. */} +
+

Zuletzt mitgeteilt

+

+ {formatCent(letzter.wertCent)} +

+

+ zum Stichtag {formatDatum(letzter.stichtag)} · {letzter.wertquelle} +

+ {alterHinweis && ( + + {alterHinweis} — vor dem nächsten Bericht neu anfragen + + )} +
+ +
Stichtag @@ -126,7 +152,8 @@ export function Rueckkaufswert({ ))} -
+ + )} {darfSchreiben && ( diff --git a/src/lib/finanzen/wertalter.ts b/src/lib/finanzen/wertalter.ts new file mode 100644 index 0000000..d636bba --- /dev/null +++ b/src/lib/finanzen/wertalter.ts @@ -0,0 +1,52 @@ +import { tageZwischen } from '@/lib/finanzen/datum' + +/** + * Wie alt ist ein Wert - und ab wann ist das ein Problem? + * + * Ein Rueckkaufswert ohne Datum ist schlimmer als kein Rueckkaufswert: + * Man rechnet mit einer Zahl, die drei Jahre alt sein kann, und merkt es + * erst, wenn der Bezirk nachfragt. Deshalb steht neben jedem Betrag der + * Stichtag, und ab einem Jahr steht daneben, dass er alt ist. + * + * Die Grenze liegt bei 365 Tagen, weil der Bezirk jaehrlich prueft und + * das Betreuungsgericht jaehrlich Rechnung legen laesst. Ein Wert, der + * den letzten Jahresbericht ueberlebt hat, gehoert erneuert. + * + * Bewusst eine eigene Datei und nicht in vermoegen.ts: Die Regel gilt am + * Vertrag genauso wie im Vermoegensverzeichnis, und sie soll an beiden + * Stellen dieselbe sein - nicht zweimal als `> 365` hingeschrieben. + */ + +export const WERT_VERALTET_TAGE = 365 + +export type Wertalter = { + /** Tage zwischen Stichtag und heute. Negativ bei kuenftigem Stichtag. */ + alterTage: number + veraltet: boolean + /** Kurztext fuer ein Abzeichen neben dem Betrag, sonst null. */ + hinweis: string | null +} + +/** Ist ein Wert dieses Alters erneuerungsbeduerftig? */ +export function wertVeraltet(alterTage: number): boolean { + return alterTage > WERT_VERALTET_TAGE +} + +/** + * Das Alter eines Werts zum Stichtag. + * + * Ohne Stichtag gibt es kein Alter - dann null, und die Oberflaeche + * zeigt gar nichts an, statt eine Null zu behaupten. + */ +export function wertalterBestimmen(stichtag: Date | null, heute: Date): Wertalter | null { + if (!stichtag) return null + + const alterTage = tageZwischen(stichtag, heute) + const veraltet = wertVeraltet(alterTage) + + return { + alterTage, + veraltet, + hinweis: veraltet ? 'über ein Jahr alt' : null, + } +} diff --git a/src/lib/version.ts b/src/lib/version.ts index a2641d2..27b5899 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.7.0' +export const VERSION = '1.8.0' /** * Kurzer Git-Stand, beim Bauen hineingereicht: diff --git a/src/lib/vertraege/rueckkaufswert.ts b/src/lib/vertraege/rueckkaufswert.ts index 01e1cf4..4a78662 100644 --- a/src/lib/vertraege/rueckkaufswert.ts +++ b/src/lib/vertraege/rueckkaufswert.ts @@ -114,3 +114,31 @@ export function anfrageStatus( tageSeitAnfrage: tage, } } + +/** + * Die Rueckkaufswert-Historie eines Vertrags, juengster Wert zuerst. + * + * Der Wert lebt nicht am Vertrag, sondern als Wertstand am + * Vermoegensposten - genau derselbe, aus dem das Vermoegensverzeichnis + * rechnet. Es gibt also keine zweite Zahl, die abweichen koennte, und + * der Vertrag zeigt nur an, was ohnehin schon in der Akte steht. + * + * Verknuepft wird ueber die Vertrags-Id, nicht ueber die Art des Postens. + * Wer einen Posten von Hand als "Sonstiges" anlegt und mit dem Vertrag + * verknuepft, meint denselben Vertrag - er darf dadurch nicht eine + * zweite, unsichtbare Wahrheit bekommen. + */ +export function wertHistorie( + posten: { wertstaende: T[] }[], +): T[] { + return posten + .flatMap((p) => p.wertstaende) + .sort((a, b) => b.stichtag.getTime() - a.stichtag.getTime()) +} + +/** Der zuletzt mitgeteilte Wert, oder null, wenn noch keiner vorliegt. */ +export function juengsterWert( + posten: { wertstaende: T[] }[], +): T | null { + return wertHistorie(posten)[0] ?? null +} diff --git a/src/server/actions/finanzen.ts b/src/server/actions/finanzen.ts index eea0c55..9ff113b 100644 --- a/src/server/actions/finanzen.ts +++ b/src/server/actions/finanzen.ts @@ -332,6 +332,15 @@ export async function buchungSpeichern( }) if (!konto) return { fehler: 'Dieses Konto gehört nicht zu dieser betreuten Person.' } } + + // Wie beim Konto: Der Vertrag muss zu dieser Akte gehoeren. + if (werte.vertragId) { + const vertrag = await db.vertrag.findFirst({ + where: { id: werte.vertragId, betreuteId, ...nichtGeloescht }, + select: { id: true }, + }) + if (!vertrag) return { fehler: 'Dieser Vertrag gehört nicht zu dieser betreuten Person.' } + } if (werte.belegId) { const beleg = await db.dokument.findFirst({ where: { id: werte.belegId, betreuteId, ...nichtGeloescht }, @@ -634,6 +643,27 @@ export async function vermoegenspostenSpeichern( if (!konto) return { fehler: 'Dieses Konto gehört nicht zu dieser betreuten Person.' } } + // Dieselbe Pruefung fuer die beiden anderen Verweise. Sie fehlte: + // An der Verknuepfung zum Vertrag haengt dessen Rueckkaufswert, und + // die darf nicht in eine fremde Akte zeigen. + if (werte.vertragId) { + const vertrag = await db.vertrag.findFirst({ + where: { id: werte.vertragId, betreuteId, ...nichtGeloescht }, + select: { id: true }, + }) + if (!vertrag) return { fehler: 'Dieser Vertrag gehört nicht zu dieser betreuten Person.' } + } + + if (werte.immobilieId) { + const immobilie = await db.immobilie.findFirst({ + where: { id: werte.immobilieId, betreuteId, ...nichtGeloescht }, + select: { id: true }, + }) + if (!immobilie) { + return { fehler: 'Diese Immobilie gehört nicht zu dieser betreuten Person.' } + } + } + const felder = { bezeichnung: werte.bezeichnung, art: werte.art, diff --git a/src/server/actions/vertraege.ts b/src/server/actions/vertraege.ts index eb3b933..83af4cc 100644 --- a/src/server/actions/vertraege.ts +++ b/src/server/actions/vertraege.ts @@ -145,8 +145,13 @@ export async function vertragLoeschen(betreuteId: string, vertragId: string): Pr // ----------------------------------------------------------------------------- async function vermoegenspostenZumVertrag(betreuteId: string, vertragId: string, bezeichnung: string) { + // Gesucht wird ueber die Verknuepfung, nicht ueber die Art. Sonst + // entsteht neben einem von Hand als "Sonstiges" angelegten Posten ein + // zweiter - und derselbe Vertrag haette zwei Wertreihen, von denen der + // Bericht die eine und der Vertrag die andere zeigt. const vorhanden = await db.vermoegensposten.findFirst({ - where: { betreuteId, vertragId, art: 'VERSICHERUNG', ...nichtGeloescht }, + where: { betreuteId, vertragId, ...nichtGeloescht }, + orderBy: { erstelltAm: 'asc' }, }) if (vorhanden) return vorhanden return db.vermoegensposten.create({ diff --git a/tests/vertraege.test.ts b/tests/vertraege.test.ts index 105a54d..f09b855 100644 --- a/tests/vertraege.test.ts +++ b/tests/vertraege.test.ts @@ -11,7 +11,9 @@ import { import { kuendigungsassistent, genehmigungsfrage } from '@/lib/vertraege/kuendigungsassistent' import { anfrageStatus, + juengsterWert, rueckkaufswertAnschreiben, + wertHistorie, wiedervorlageAm, } from '@/lib/vertraege/rueckkaufswert' import { jahresbeitragCent, kannRueckkaufswertHaben } from '@/lib/vertraege/konstanten' @@ -313,3 +315,29 @@ describe('Vertragsdauer aus Beginn und Ende', () => { expect(plan.naechsterTermin).toEqual(tag(2027, 1, 1)) }) }) + +describe('Rückkaufswert-Historie am Vertrag', () => { + const wert = (jahr: number, cent: number) => ({ stichtag: tag(jahr, 3, 1), wertCent: cent }) + + it('sortiert über alle verknüpften Posten hinweg, jüngster zuerst', () => { + // Zwei Posten am selben Vertrag: einer von Hand angelegt, einer beim + // ersten Erfassen entstanden. Der Vertrag zeigt beide Reihen als eine. + const historie = wertHistorie([ + { wertstaende: [wert(2024, 4000)] }, + { wertstaende: [wert(2026, 5200), wert(2025, 4600)] }, + ]) + + expect(historie.map((w) => w.wertCent)).toEqual([5200, 4600, 4000]) + }) + + it('nennt den zuletzt mitgeteilten Wert', () => { + expect(juengsterWert([{ wertstaende: [wert(2025, 4600), wert(2026, 5200)] }])?.wertCent).toBe( + 5200, + ) + }) + + it('gibt ohne jeden Wert null zurück, statt eine Null zu behaupten', () => { + expect(juengsterWert([{ wertstaende: [] }])).toBeNull() + expect(juengsterWert([])).toBeNull() + }) +}) diff --git a/tests/wertalter.test.ts b/tests/wertalter.test.ts new file mode 100644 index 0000000..2084ef9 --- /dev/null +++ b/tests/wertalter.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest' +import { + WERT_VERALTET_TAGE, + wertalterBestimmen, + wertVeraltet, +} from '@/lib/finanzen/wertalter' + +/** + * Das Alter eines Rueckkaufswerts. + * + * Die Grenze ist eine fachliche Aussage, keine Zierde: Ein Wert, der den + * letzten Jahresbericht ueberlebt hat, gehoert erneuert. + */ + +const tag = (iso: string) => new Date(`${iso}T00:00:00.000Z`) + +describe('Grenze', () => { + it('liegt bei einem Jahr', () => { + expect(WERT_VERALTET_TAGE).toBe(365) + }) + + it('ist am 365. Tag noch nicht ueberschritten', () => { + expect(wertVeraltet(365)).toBe(false) + expect(wertVeraltet(366)).toBe(true) + }) +}) + +describe('Alter bestimmen', () => { + it('rechnet in Kalendertagen', () => { + const alter = wertalterBestimmen(tag('2026-01-01'), tag('2026-01-31')) + expect(alter?.alterTage).toBe(30) + expect(alter?.veraltet).toBe(false) + expect(alter?.hinweis).toBeNull() + }) + + it('meldet einen Wert von vor zwei Jahren als veraltet', () => { + const alter = wertalterBestimmen(tag('2024-03-01'), tag('2026-03-01')) + expect(alter?.veraltet).toBe(true) + expect(alter?.hinweis).toBe('über ein Jahr alt') + }) + + it('behauptet ohne Stichtag kein Alter', () => { + // Kein Datum heisst nicht "heute" - es heisst "unbekannt". + expect(wertalterBestimmen(null, tag('2026-03-01'))).toBeNull() + }) + + it('zaehlt einen kuenftigen Stichtag negativ und nicht als veraltet', () => { + // Kommt vor: Der Wert wird zum Quartalsende angefragt und vorab erfasst. + const alter = wertalterBestimmen(tag('2026-06-30'), tag('2026-06-01')) + expect(alter?.alterTage).toBe(-29) + expect(alter?.veraltet).toBe(false) + }) + + it('ist taggenau und nicht uhrzeitabhaengig', () => { + const spaetAmTag = new Date('2026-03-01T23:30:00.000Z') + expect(wertalterBestimmen(tag('2026-03-01'), spaetAmTag)?.alterTage).toBe(0) + }) +})