diff --git a/docs/schema-wuensche/finanzen.md b/docs/schema-wuensche/finanzen.md
new file mode 100644
index 0000000..ecfe025
--- /dev/null
+++ b/docs/schema-wuensche/finanzen.md
@@ -0,0 +1,154 @@
+# Schema-Wünsche aus Etappe 4 „Geld“
+
+Beim Bau des Finanzmoduls sind Stellen aufgefallen, an denen das
+Datenmodell etwas anders aussehen müsste. `prisma/schema.prisma` wurde
+**nicht** angefasst. Sortiert nach Dringlichkeit.
+
+---
+
+## 1. Umbuchungen kennen ihr Gegenkonto nicht
+
+**Modell:** `Buchung`
+
+Eine Umbuchung zwischen zwei eigenen Konten – Giro aufs Sparbuch – lässt
+sich derzeit nur als zwei getrennte Buchungen abbilden. Im Modell fehlt
+die Angabe, wohin das Geld gegangen ist. Für die Rechenprobe ist das
+folgenlos (Umbuchungen sind weder Einnahme noch Ausgabe und werden
+herausgerechnet), für den Nutzer aber unbequem: Er muss zweimal
+erfassen, und niemand prüft, ob beide Seiten zusammenpassen.
+
+```prisma
+model Buchung {
+ gegenkontoId String?
+ gegenkonto Konto? @relation("Umbuchungsgegenkonto", fields: [gegenkontoId], references: [id], onDelete: SetNull)
+}
+```
+
+Damit ließe sich eine Umbuchung als **eine** Zeile erfassen und beim
+Bilden der Kontostände auf beiden Seiten berücksichtigen.
+
+---
+
+## 2. Dublettenschutz greift nicht bei Buchungen ohne Konto
+
+**Modell:** `Buchung`, `@@unique([kontoId, importReferenz])`
+
+In PostgreSQL ist `NULL` nie gleich `NULL`. Die zusammengesetzte
+Eindeutigkeit schützt deshalb nur, solange `kontoId` gesetzt ist. Bei
+einem Import ohne Konto – derzeit über die Oberfläche nicht möglich,
+aber im Modell erlaubt – könnte derselbe Umsatz beliebig oft angelegt
+werden.
+
+Zwei mögliche Auswege:
+
+- `kontoId` bei Buchungen mit `importReferenz` verpflichtend machen
+ (Prüfung in der Anwendung, Constraint im Schema nicht ausdrückbar), oder
+- eine partielle Eindeutigkeit über Roh-SQL ergänzen:
+
+```sql
+CREATE UNIQUE INDEX buchung_import_ohne_konto
+ ON buchung (betreute_id, import_referenz)
+ WHERE konto_id IS NULL AND import_referenz IS NOT NULL;
+```
+
+Die Anwendung erzwingt derzeit ein Konto beim Import; der Schutz hängt
+also allein an der Oberfläche.
+
+---
+
+## 3. Saldostand und Wertstand haben keinen Papierkorb
+
+**Modelle:** `Saldostand`, `Wertstand`
+
+Beide Modelle haben kein `geloeschtAm`. Löschen ist damit endgültig –
+im Gegensatz zu allem anderen im System. Das ist an dieser Stelle
+besonders unangenehm: Wird ein Wertstand entfernt, lässt sich eine
+bereits beim Gericht eingereichte Vermögensübersicht nicht mehr
+nachbilden. Die Oberfläche warnt deshalb ausdrücklich davor, aber ein
+Papierkorb wäre die bessere Absicherung.
+
+```prisma
+model Saldostand {
+ geloeschtAm DateTime?
+}
+
+model Wertstand {
+ geloeschtAm DateTime?
+}
+```
+
+Nebenwirkung, die dabei zu bedenken ist: die bestehenden
+`@@unique([kontoId, stichtag])` bzw. `@@unique([vermoegenspostenId, stichtag])`
+würden dann auch gelöschte Einträge blockieren. Sie müssten auf
+partielle Indizes mit `WHERE geloescht_am IS NULL` umgestellt werden.
+
+---
+
+## 4. Wer hat den Stand erfasst?
+
+**Modelle:** `Saldostand`, `Wertstand`
+
+Beide tragen nur `erstelltAm`, keinen Verweis auf den Benutzer. Bei
+mehreren Betreuern oder Mitlesern mit Schreibrecht steht im Datensatz
+selbst nicht, wer die Zahl eingetragen hat. Das Audit-Log beantwortet
+die Frage, aber nur über eine gesonderte Recherche.
+
+```prisma
+erstelltVonId String?
+erstelltVon User? @relation(fields: [erstelltVonId], references: [id], onDelete: SetNull)
+```
+
+---
+
+## 5. Indizes für die Auswertungen
+
+**Modelle:** `Buchung`, `Saldostand`
+
+Die Berichte lesen Buchungen fast immer entweder nach Konto und Zeitraum
+oder gefiltert auf fehlende Belege. Dafür gibt es bisher keine passenden
+Indizes:
+
+```prisma
+model Buchung {
+ @@index([kontoId, datum])
+ @@index([betreuteId, belegId])
+}
+```
+
+Bei den erwarteten Datenmengen (einige tausend Buchungen je betreuter
+Person) ist das noch nicht spürbar; bei einem Import mehrerer Jahre
+Kontoauszüge wird es das.
+
+---
+
+## 6. Fremdwährung wird abgelehnt, nicht abgebildet
+
+**Modelle:** `Konto`, `Buchung`, `Saldostand`, `Wertstand`
+
+Es gibt kein Währungsfeld. Alle Beträge sind Euro-Cent. Der CSV- und
+CAMT-Import überspringt deshalb Zeilen in fremder Währung und meldet das
+dem Nutzer, statt umzurechnen – ein umgerechneter Betrag im
+Kassenbuch wäre in der Rechnungslegung eine Behauptung ohne Beleg.
+
+Das ist als bewusste Entscheidung dokumentiert und **kein** Wunsch nach
+einem Währungsfeld. Es steht hier nur, damit die Einschränkung nicht
+später für ein Versehen gehalten wird.
+
+---
+
+## 7. Der Betrag einer Buchung sollte positiv sein
+
+**Modell:** `Buchung.betragCent`
+
+Fachlich ist der Betrag immer positiv; die Richtung steht in
+`richtung`. Ein negativer Betrag bei `richtung = AUSGABE` würde die
+Vorzeichen zweimal drehen und die Summen still verfälschen. Die
+Anwendung prüft das beim Speichern (`betragFeld({ nurPositiv: true })`),
+in der Datenbank ist es nicht abgesichert. Ein Check-Constraint über
+Roh-SQL wäre die Ergänzung:
+
+```sql
+ALTER TABLE buchung ADD CONSTRAINT buchung_betrag_positiv CHECK (betrag_cent > 0);
+```
+
+Vor einer solchen Migration müssten Altdaten geprüft werden.
diff --git a/src/app/(app)/finanzen/berichte/page.tsx b/src/app/(app)/finanzen/berichte/page.tsx
new file mode 100644
index 0000000..91656e4
--- /dev/null
+++ b/src/app/(app)/finanzen/berichte/page.tsx
@@ -0,0 +1,285 @@
+import Link from 'next/link'
+import { AlertCircle, FileDown } from 'lucide-react'
+import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
+import { Button } from '@/components/ui/button'
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
+import { Input } from '@/components/ui/input'
+import { Select } from '@/components/ui/select'
+import { Feld } from '@/components/feld'
+import { SeitenKopf } from '@/components/seiten-kopf'
+import { Rechtshinweis } from '@/components/rechtshinweis'
+import { Hinweisliste, Summenzeile } from '@/components/finanzen/bausteine'
+import { centZuZahl } from '@/lib/db'
+import { formatCent, formatDatum } from '@/lib/utils'
+import { finanzKontext } from '@/lib/finanzen/seite'
+import { heute, tagAlsText } from '@/lib/finanzen/datum'
+import {
+ aufwandspauschaleBis,
+ betreuungsjahre,
+ jahrAbgeschlossen,
+} from '@/lib/finanzen/betreuungsjahr'
+import { standardVariante, VARIANTE_BESCHRIFTUNG } from '@/lib/finanzen/rechnungslegung'
+import { rechnungslegungDaten } from '@/lib/berichte/gemeinsam'
+
+export const metadata = { title: 'Berichte' }
+
+/**
+ * Erzeugung der beiden Berichte ans Betreuungsgericht.
+ *
+ * Bevor eine PDF entsteht, steht hier die Rechenprobe des zuletzt
+ * abgeschlossenen Betreuungsjahres. Der Gedanke: Eine Differenz will man
+ * sehen, bevor man das Dokument unterschreibt – nicht danach.
+ */
+export default async function BerichteSeite() {
+ const { betreute } = await finanzKontext()
+ const stichtagHeute = heute()
+
+ const jahre = betreute.bestellungAm ? betreuungsjahre(betreute.bestellungAm, stichtagHeute) : []
+ const abgeschlossene = jahre.filter((jahr) => jahrAbgeschlossen(jahr, stichtagHeute))
+ const letztesAbgeschlossen = abgeschlossene[abgeschlossene.length - 1] ?? null
+ const vorauswahl = letztesAbgeschlossen ?? jahre[jahre.length - 1] ?? null
+ const variante = standardVariante(betreute.befreiterBetreuer)
+
+ // Vorschau der Rechenprobe - nur, wenn es ein Jahr dafuer gibt.
+ const vorschau = vorauswahl
+ ? await rechnungslegungDaten({
+ betreute,
+ jahrNummer: vorauswahl.nummer,
+ variante,
+ erstelltVon: '(Vorschau)',
+ })
+ : null
+
+ return (
+
+
+
+ {!betreute.bestellungAm && (
+
+
+ Das Bestellungsdatum fehlt
+
+ Das Betreuungsjahr läuft ab dem Tag der Bestellung, nicht ab dem 1. Januar. Ohne
+ dieses Datum lässt sich die Rechnungslegung nicht abgrenzen. Trag es in den Stammdaten
+ nach – das Vermögensverzeichnis kannst du auch so erzeugen.
+
+
+ )}
+
+ {/* ------------------------------------------------------------- */}
+
+
+ Vermögensverzeichnis (§ 1835 BGB)
+
+ Binnen drei Monaten nach der Bestellung beim Betreuungsgericht einzureichen. Zeigt
+ Aktiva nach Gruppen, Passiva, Reinvermögen sowie die laufenden Einnahmen und Ausgaben.
+ Jeder Wert trägt seine Quelle und den Tag seiner Bewertung.
+
+
+
+
+
+
+
+ {/* ------------------------------------------------------------- */}
+
+
+ Vermögensübersicht und Rechnungslegung (§§ 1859, 1865 BGB)
+
+ {betreute.befreiterBetreuer
+ ? 'Als befreiter Betreuer schuldest du nach § 1859 BGB nur die knappe Vermögensübersicht. Die vollständige Rechnungslegung kannst du trotzdem erzeugen – etwa, wenn das Gericht sie ausdrücklich verlangt.'
+ : 'Es ist die vollständige Rechnungslegung nach § 1865 BGB geschuldet: jede Einnahme, jede Ausgabe, jeder Beleg mit laufender Nummer.'}
+
+
+
+ {jahre.length === 0 ? (
+
+ Sobald das Bestellungsdatum in den Stammdaten steht, kannst du hier je
+ Betreuungsjahr abrechnen.
+
+ ) : (
+
+ )}
+
+ {vorauswahl && !jahrAbgeschlossen(vorauswahl, stichtagHeute) && (
+
+
+ Noch keine Schlussrechnung möglich
+
+ Das {vorauswahl.nummer}. Betreuungsjahr läuft noch bis zum{' '}
+ {formatDatum(vorauswahl.bis)}. Der Bericht wird als Zwischenaufstellung
+ gekennzeichnet.
+
+
+ )}
+
+ {letztesAbgeschlossen && (
+
+
+ Aufwandspauschale nicht vergessen
+
+ Für das {letztesAbgeschlossen.nummer}. Betreuungsjahr (Ende{' '}
+ {formatDatum(letztesAbgeschlossen.bis)}) kann die Aufwandspauschale nach § 1878
+ BGB bis zum {formatDatum(aufwandspauschaleBis(letztesAbgeschlossen))} beantragt
+ werden. Das ist eine Ausschlussfrist – danach ist der Anspruch weg.
+
+
+ )}
+
+
+
+ {/* ------------------------------------------------------------- */}
+ {vorschau && (
+
+
+ Rechenprobe – {vorschau.jahr.bezeichnung}
+
+ Anfangsbestand zuzüglich Einnahmen abzüglich Ausgaben muss den Endbestand ergeben.
+ Stimmt es nicht, steht die Differenz auch im PDF – sie wird nicht stillschweigend
+ glattgezogen.
+
+
+
+
+
+
+
+
+
+
+
+ {vorschau.rechnung.probe.stimmt ? (
+
+
+ Die Rechnung geht auf
+
+ Bestände und Buchungen passen auf den Cent zusammen.
+
+
+ ) : (
+
+
+
+ Differenz:{' '}
+ {formatCent(centZuZahl(vorschau.rechnung.probe.differenzCent))}
+
+
+ Entweder fehlen Buchungen im Kassenbuch, oder ein Saldostand ist nicht erfasst.
+ Am schnellsten findest du die Lücke über{' '}
+
+ die Kontostände
+ {' '}
+ und die{' '}
+
+ Buchungen dieses Zeitraums
+
+ .
+
+
+ )}
+
+
+
+
+ )}
+
+
+
+ )
+}
diff --git a/src/app/(app)/finanzen/berichte/pdf/route.ts b/src/app/(app)/finanzen/berichte/pdf/route.ts
new file mode 100644
index 0000000..31de5e8
--- /dev/null
+++ b/src/app/(app)/finanzen/berichte/pdf/route.ts
@@ -0,0 +1,150 @@
+import { NextResponse, type NextRequest } from 'next/server'
+import { finanzKontext } from '@/lib/finanzen/seite'
+import { tagAusText } from '@/lib/finanzen/datum'
+import { betreuungsjahr } from '@/lib/finanzen/betreuungsjahr'
+import { standardVariante, type Variante } from '@/lib/finanzen/rechnungslegung'
+import {
+ berichtVermerken,
+ rechnungslegungDaten,
+ vermoegensverzeichnisDaten,
+} from '@/lib/berichte/gemeinsam'
+import {
+ vermoegensverzeichnisDateiname,
+ vermoegensverzeichnisPdf,
+} from '@/lib/berichte/vermoegensverzeichnis'
+import { rechnungslegungDateiname, rechnungslegungPdf } from '@/lib/berichte/rechnungslegung'
+
+/**
+ * Auslieferung der beiden Finanzberichte als PDF.
+ *
+ * Die Datei wird bei jedem Abruf frisch aus den Daten erzeugt - es gibt
+ * keinen zwischengespeicherten Stand, der von der Datenbank abweichen
+ * koennte. Dass ein Bericht erzeugt wurde, wird in der Tabelle Bericht
+ * und im Audit-Log festgehalten: Gegenueber dem Gericht muss ich sagen
+ * koennen, was ich wann in welcher Fassung abgegeben habe.
+ *
+ * Ausgeliefert wird immer als Anhang und mit nosniff - ein Dokument aus
+ * dieser Anwendung wird nie im Browser als HTML ausgefuehrt.
+ */
+export async function GET(anfrage: NextRequest): Promise {
+ const { betreute, benutzer } = await finanzKontext()
+ const parameter = anfrage.nextUrl.searchParams
+ const art = parameter.get('art')
+
+ try {
+ if (art === 'vermoegensverzeichnis') {
+ const stichtag = tagAusText(parameter.get('stichtag'))
+ if (!stichtag) {
+ return fehlerantwort('Es fehlt ein gültiger Stichtag im Format JJJJ-MM-TT.')
+ }
+
+ const monateRoh = Number.parseInt(parameter.get('monate') ?? '12', 10)
+ const monateRueckblick =
+ Number.isInteger(monateRoh) && monateRoh >= 1 && monateRoh <= 60 ? monateRoh : 12
+
+ const daten = await vermoegensverzeichnisDaten({
+ betreute,
+ stichtag,
+ erstelltVon: benutzer.name,
+ monateRueckblick,
+ })
+
+ const pdf = await vermoegensverzeichnisPdf(daten)
+ const dateiname = vermoegensverzeichnisDateiname(daten)
+
+ await berichtVermerken({
+ betreuteId: betreute.id,
+ benutzer,
+ art: 'VERMOEGENSVERZEICHNIS',
+ titel: `Vermögensverzeichnis zum ${dateiname.slice(0, 10)}`,
+ stichtag,
+ dateiname,
+ })
+
+ return pdfAntwort(pdf, dateiname)
+ }
+
+ if (art === 'rechnungslegung') {
+ if (!betreute.bestellungAm) {
+ return fehlerantwort(
+ 'Ohne Bestellungsdatum lässt sich kein Betreuungsjahr bestimmen. Bitte zuerst in den ' +
+ 'Stammdaten das Datum der Bestellung eintragen.',
+ )
+ }
+
+ const jahrNummer = Number.parseInt(parameter.get('jahr') ?? '', 10)
+ if (!Number.isInteger(jahrNummer) || jahrNummer < 1) {
+ return fehlerantwort('Es fehlt die Nummer des Betreuungsjahres.')
+ }
+
+ const gewaehlt = parameter.get('variante')
+ const variante: Variante =
+ gewaehlt === 'UEBERSICHT_1859' || gewaehlt === 'VOLL_1865'
+ ? gewaehlt
+ : standardVariante(betreute.befreiterBetreuer)
+
+ const daten = await rechnungslegungDaten({
+ betreute,
+ jahrNummer,
+ variante,
+ erstelltVon: benutzer.name,
+ })
+
+ const pdf = await rechnungslegungPdf(daten)
+ const dateiname = rechnungslegungDateiname(daten)
+ const jahr = betreuungsjahr(betreute.bestellungAm, jahrNummer)
+
+ await berichtVermerken({
+ betreuteId: betreute.id,
+ benutzer,
+ art: variante === 'UEBERSICHT_1859' ? 'VERMOEGENSUEBERSICHT' : 'RECHNUNGSLEGUNG',
+ titel: daten.kopf.titel,
+ zeitraumVon: jahr.von,
+ zeitraumBis: jahr.bis,
+ dateiname,
+ })
+
+ return pdfAntwort(pdf, dateiname)
+ }
+
+ return fehlerantwort(
+ 'Unbekannte Berichtsart. Erwartet wird „vermoegensverzeichnis“ oder „rechnungslegung“.',
+ )
+ } catch (ausnahme) {
+ console.error('[finanzen] Bericht konnte nicht erzeugt werden:', ausnahme)
+ return fehlerantwort(
+ ausnahme instanceof Error
+ ? ausnahme.message
+ : 'Der Bericht konnte nicht erzeugt werden.',
+ 500,
+ )
+ }
+}
+
+function pdfAntwort(pdf: Buffer, dateiname: string): Response {
+ // Der Dateiname wird zweimal angegeben: einmal auf ASCII reduziert fuer
+ // aeltere Browser, einmal UTF-8-kodiert nach RFC 5987 fuer die uebrigen.
+ const ascii = dateiname.replace(/[^\x20-\x7e]/g, '_').replace(/["\\]/g, '_')
+
+ return new NextResponse(new Uint8Array(pdf), {
+ status: 200,
+ headers: {
+ 'Content-Type': 'application/pdf',
+ 'Content-Length': String(pdf.byteLength),
+ 'Content-Disposition': `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(dateiname)}`,
+ 'X-Content-Type-Options': 'nosniff',
+ 'Cache-Control': 'no-store, max-age=0',
+ },
+ })
+}
+
+function fehlerantwort(meldung: string, status = 400): Response {
+ return new NextResponse(meldung, {
+ status,
+ headers: {
+ 'Content-Type': 'text/plain; charset=utf-8',
+ 'X-Content-Type-Options': 'nosniff',
+ 'Cache-Control': 'no-store, max-age=0',
+ },
+ })
+}
diff --git a/src/app/(app)/finanzen/buchungen/[id]/page.tsx b/src/app/(app)/finanzen/buchungen/[id]/page.tsx
new file mode 100644
index 0000000..f7576d3
--- /dev/null
+++ b/src/app/(app)/finanzen/buchungen/[id]/page.tsx
@@ -0,0 +1,109 @@
+import { notFound } from 'next/navigation'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { SeitenKopf } from '@/components/seiten-kopf'
+import { Rechtshinweis } from '@/components/rechtshinweis'
+import { Buchungsformular } from '@/components/finanzen/buchungs-formular'
+import { Loeschknopf } from '@/components/finanzen/aktionen'
+import { db, nichtGeloescht } from '@/lib/db'
+import { oeffnen } from '@/lib/crypto/feld'
+import { formatDatum, formatDatumZeit } from '@/lib/utils'
+import { finanzKontext } from '@/lib/finanzen/seite'
+import { tagAlsText } from '@/lib/finanzen/datum'
+import { betragFormatieren } from '@/lib/finanzen/betrag'
+import { buchungLoeschen } from '@/server/actions/finanzen'
+
+export const metadata = { title: 'Buchung' }
+
+export default async function BuchungSeite({ params }: { params: Promise<{ id: string }> }) {
+ const { id } = await params
+ const { betreute, darfSchreiben } = await finanzKontext()
+ const schreiben = darfSchreiben('FINANZEN')
+
+ const buchung = await db.buchung.findFirst({
+ where: { id, betreuteId: betreute.id, ...nichtGeloescht },
+ })
+ if (!buchung) notFound()
+
+ const offen = oeffnen('Buchung', buchung)
+
+ const [konten, belege] = await Promise.all([
+ db.konto.findMany({
+ where: { betreuteId: betreute.id, ...nichtGeloescht },
+ select: { id: true, bezeichnung: true, istBetreuerkonto: true },
+ orderBy: [{ istBetreuerkonto: 'asc' }, { bezeichnung: 'asc' }],
+ }),
+ db.dokument.findMany({
+ where: { betreuteId: betreute.id, ...nichtGeloescht },
+ select: { id: true, titel: true },
+ orderBy: { erstelltAm: 'desc' },
+ take: 200,
+ }),
+ ])
+
+ return (
+
+
+
+ {buchung.importReferenz && (
+
+ Diese Buchung stammt aus einem Dateiimport. Der Herkunftsschlüssel verhindert, dass
+ derselbe Umsatz beim nächsten Import ein zweites Mal angelegt wird.
+
+ )}
+
+ {schreiben ? (
+
+ ) : (
+
+
+ Nur lesender Zugriff
+
+
+ Du darfst dieses Modul lesen, aber nicht ändern.
+
+
+ )}
+
+ {schreiben && (
+
+
+
+ {konten.length === 0 ? (
+
+
+ Es gibt noch kein Konto
+
+ Umsätze werden immer einem Konto zugeordnet.{' '}
+
+ Erst ein Konto anlegen
+
+ .
+
+
+ ) : (
+
+ )}
+
+
+
+ Was erkannt wird
+
+ Nichts wird stillschweigend geraten. Was nicht gelesen werden kann, wird dir Zeile für
+ Zeile gezeigt.
+
+
+
+
+
+ Trennzeichen: Semikolon, Komma,
+ Tabulator oder senkrechter Strich – erkannt an der Spaltenzahl, nicht am Raten.
+
+
+ Zeichensatz: UTF-8 und
+ Windows-1252/ISO-8859-1. Umlaute bleiben Umlaute.
+
+
+ Beträge: „1.234,56“, „1234,56“, mit
+ nachgestelltem Minus („1.234,56-“), in Klammern und in englischer Schreibweise.
+
+
+ Datum: TT.MM.JJJJ, TT.MM.JJ,
+ JJJJ-MM-TT und TT/MM/JJJJ.
+
+ Vorspann: Zeilen vor der eigentlichen
+ Tabelle werden übersprungen.
+
+
+ CAMT.053: der elektronische
+ Kontoauszug als XML wird ebenfalls gelesen – dort sind Betrag und Richtung
+ eindeutig ausgezeichnet, das ist der genauere Weg.
+
+
+ Fremdwährung wird nicht übernommen und
+ nicht umgerechnet. Diese Anwendung rechnet in Euro.
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/app/(app)/finanzen/vermoegen/[id]/page.tsx b/src/app/(app)/finanzen/vermoegen/[id]/page.tsx
new file mode 100644
index 0000000..6bc2de8
--- /dev/null
+++ b/src/app/(app)/finanzen/vermoegen/[id]/page.tsx
@@ -0,0 +1,214 @@
+import { notFound } from 'next/navigation'
+import { Badge } from '@/components/ui/badge'
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
+import { SeitenKopf } from '@/components/seiten-kopf'
+import { Rechtshinweis } from '@/components/rechtshinweis'
+import { Loeschknopf } from '@/components/finanzen/aktionen'
+import { Postenformular, Wertstandformular } from '@/components/finanzen/posten-formulare'
+import { Wertverlauf, type Verlaufszeile } from '@/components/finanzen/wertverlauf'
+import { centZuZahl, db, nichtGeloescht } from '@/lib/db'
+import { oeffnen } from '@/lib/crypto/feld'
+import { formatDatum } from '@/lib/utils'
+import { finanzKontext } from '@/lib/finanzen/seite'
+import { heute, tagAlsText } from '@/lib/finanzen/datum'
+import { wertverlauf } from '@/lib/finanzen/vermoegen'
+import {
+ SOZIALHILFE_BEWERTUNG_BESCHRIFTUNG,
+ VERMOEGENS_ART_EINZAHL,
+} from '@/lib/finanzen/kategorien'
+import { vermoegenspostenLoeschen, wertstandLoeschen } from '@/server/actions/finanzen'
+
+export const metadata = { title: 'Vermögensposten' }
+
+/**
+ * Ein Vermögensposten mit seiner vollständigen Wert-Historie.
+ *
+ * Werte werden hier nur hinzugefügt, nie geändert. Das ist der Kern der
+ * Nachvollziehbarkeit: Wenn das Gericht in zwei Jahren fragt, warum im
+ * Verzeichnis 195.000 € standen, muss dieselbe Zahl mit derselben Quelle
+ * wieder herauskommen.
+ */
+export default async function VermoegenspostenSeite({
+ params,
+}: {
+ params: Promise<{ id: string }>
+}) {
+ const { id } = await params
+ const { betreute, darfSchreiben } = await finanzKontext()
+ const schreiben = darfSchreiben('FINANZEN')
+
+ const posten = await db.vermoegensposten.findFirst({
+ where: { id, betreuteId: betreute.id, ...nichtGeloescht },
+ include: {
+ wertstaende: {
+ orderBy: { stichtag: 'asc' },
+ include: { nachweisDokument: { select: { id: true, titel: true } } },
+ },
+ },
+ })
+ if (!posten) notFound()
+
+ const offen = oeffnen('Vermoegensposten', posten)
+
+ const [konten, dokumente] = await Promise.all([
+ db.konto.findMany({
+ where: { betreuteId: betreute.id, ...nichtGeloescht },
+ select: { id: true, bezeichnung: true },
+ orderBy: { bezeichnung: 'asc' },
+ }),
+ db.dokument.findMany({
+ where: { betreuteId: betreute.id, ...nichtGeloescht },
+ select: { id: true, titel: true },
+ orderBy: { erstelltAm: 'desc' },
+ take: 200,
+ }),
+ ])
+
+ const verlauf = wertverlauf(
+ posten.wertstaende.map((wert) => ({
+ stichtag: wert.stichtag,
+ wertCent: wert.wertCent,
+ wertquelle: wert.wertquelle,
+ nachweisDokumentId: wert.nachweisDokumentId,
+ nachweisTitel: wert.nachweisDokument?.titel ?? null,
+ bemerkung: wert.bemerkung,
+ })),
+ )
+
+ // wertverlauf() sortiert aufsteigend nach Stichtag - genau wie die
+ // Abfrage oben. Die Indizes passen deshalb zueinander.
+ const zeilen: Verlaufszeile[] = verlauf.map((punkt, i) => {
+ const roh = posten.wertstaende[i]!
+ return {
+ id: roh.id,
+ stichtag: tagAlsText(punkt.stichtag),
+ cent: centZuZahl(punkt.wertCent) ?? 0,
+ wertquelle: punkt.wertquelle,
+ nachweisTitel: roh.nachweisDokument?.titel ?? null,
+ bemerkung: roh.bemerkung,
+ veraenderungCent: centZuZahl(punkt.veraenderungCent),
+ aktion: schreiben ? (
+
+ ) : undefined,
+ }
+ })
+
+ return (
+
+
+ {VERMOEGENS_ART_EINZAHL[posten.art]}
+
+ Sozialhilfe: {SOZIALHILFE_BEWERTUNG_BESCHRIFTUNG[posten.bewertungSozialhilfe]}
+
+
+ {posten.wertstaende.length} Wertstand/Wertstände erfasst
+
+
+ }
+ pfad={[
+ { titel: 'Vermögen & Kasse', href: '/finanzen' },
+ { titel: 'Vermögen', href: '/finanzen/vermoegen' },
+ { titel: posten.bezeichnung },
+ ]}
+ />
+
+ {offen.bewertungBegruendung && (
+
+
+ Einordnung im Sozialhilfeverfahren
+
+ Deine eigene Dokumentation und Argumentation gegenüber dem Bezirk – keine
+ Rechtsauskunft.
+
+
+
+ {offen.bewertungBegruendung}
+
+
+ )}
+
+
+
+ Wertverlauf
+
+ Jeder Eintrag ist eine Aussage über den Wert an einem bestimmten Tag, mit Angabe der
+ Quelle. Nichts wird überschrieben.
+
+
+
+ {zeilen.length === 0 ? (
+
+ Noch kein Wert erfasst. Solange kein Wertstand da ist, taucht dieser Posten im
+ Vermögensverzeichnis als Lücke auf – nicht mit 0,00 €.
+
+ {bild.aktivaGruppen.map((gruppe) => (
+
+
+ {VERMOEGENS_ART_BESCHRIFTUNG[gruppe.art]}
+
+
+
+
+
+
+ ))}
+
+ {bild.passiva.length > 0 && (
+
+
+ Verbindlichkeiten
+
+ Werden mit positivem Betrag geführt; dass sie das Vermögen mindern, ergibt sich
+ aus der Art des Postens.
+
+
+
+
+
+
+
+ )}
+
+ {bild.ohneWert.length > 0 && (
+
+
+ Ohne Bewertung zum Stichtag
+
+ Diese Posten sind vorhanden, gehen aber in keine Summe ein.
+
+
+
+
+ {bild.ohneWert.map((posten) => (
+
+
+ {posten.bezeichnung}
+
+ {VERMOEGENS_ART_EINZAHL[posten.art]}
+
+ {posten.spaetereWertstaende > 0
+ ? `${posten.spaetereWertstaende} Wertstand/Wertstände, alle nach dem Stichtag`
+ : 'noch kein Wertstand'}
+
+