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 && ( + + + )} + + {/* ------------------------------------------------------------- */} + + + 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) && ( + + + )} + + {letztesAbgeschlossen && ( + + + )} +
+
+ + {/* ------------------------------------------------------------- */} + {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 ? ( + + + ) : ( + + + )} + + +
+
+ )} + + +
+ ) +} 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 && ( +
+ +
+ )} + + +
+ ) +} diff --git a/src/app/(app)/finanzen/buchungen/import/page.tsx b/src/app/(app)/finanzen/buchungen/import/page.tsx new file mode 100644 index 0000000..fe137ec --- /dev/null +++ b/src/app/(app)/finanzen/buchungen/import/page.tsx @@ -0,0 +1,103 @@ +import Link from 'next/link' +import { notFound } from 'next/navigation' +import { AlertCircle } from 'lucide-react' +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { SeitenKopf } from '@/components/seiten-kopf' +import { Rechtshinweis } from '@/components/rechtshinweis' +import { Importformular } from '@/components/finanzen/import-formular' +import { db, nichtGeloescht } from '@/lib/db' +import { finanzKontext } from '@/lib/finanzen/seite' + +export const metadata = { title: 'Kontoumsätze einlesen' } + +export default async function ImportSeite() { + const { betreute, darfSchreiben } = await finanzKontext() + if (!darfSchreiben('FINANZEN')) notFound() + + const konten = await db.konto.findMany({ + where: { betreuteId: betreute.id, ...nichtGeloescht }, + select: { id: true, bezeichnung: true, istBetreuerkonto: true }, + orderBy: [{ istBetreuerkonto: 'asc' }, { bezeichnung: 'asc' }], + }) + + return ( +
+ + + {konten.length === 0 ? ( + + + ) : ( + + )} + + + + 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. +
  • +
  • + Spalten: Buchungstag, Wertstellung, + Betrag oder getrennte Soll-/Haben-Spalten, Soll/Haben-Kennzeichen, Begünstigter, + Verwendungszweck, Währung. +
  • +
  • + 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 €. +

+ ) : ( + + )} + + {schreiben && ( +
+

Neuen Wert festhalten

+ +
+ )} +
+
+ + {offen.notizen && ( + + + Notizen + + {offen.notizen} + + )} + + {schreiben && ( + + + Posten ändern + + + + +
+ +
+
+
+ )} + + +
+ ) +} diff --git a/src/app/(app)/finanzen/vermoegen/neu/page.tsx b/src/app/(app)/finanzen/vermoegen/neu/page.tsx new file mode 100644 index 0000000..583b394 --- /dev/null +++ b/src/app/(app)/finanzen/vermoegen/neu/page.tsx @@ -0,0 +1,37 @@ +import { notFound } from 'next/navigation' +import { SeitenKopf } from '@/components/seiten-kopf' +import { Rechtshinweis } from '@/components/rechtshinweis' +import { Postenformular } from '@/components/finanzen/posten-formulare' +import { db, nichtGeloescht } from '@/lib/db' +import { finanzKontext } from '@/lib/finanzen/seite' + +export const metadata = { title: 'Vermögensposten anlegen' } + +export default async function VermoegenNeuSeite() { + const { betreute, darfSchreiben } = await finanzKontext() + if (!darfSchreiben('FINANZEN')) notFound() + + const konten = await db.konto.findMany({ + where: { betreuteId: betreute.id, ...nichtGeloescht }, + select: { id: true, bezeichnung: true }, + orderBy: { bezeichnung: 'asc' }, + }) + + return ( +
+ + + + + +
+ ) +} diff --git a/src/app/(app)/finanzen/vermoegen/page.tsx b/src/app/(app)/finanzen/vermoegen/page.tsx new file mode 100644 index 0000000..0ce3288 --- /dev/null +++ b/src/app/(app)/finanzen/vermoegen/page.tsx @@ -0,0 +1,259 @@ +import Link from 'next/link' +import { Boxes, Plus } from 'lucide-react' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' +import { SeitenKopf, Leerzustand } from '@/components/seiten-kopf' +import { Rechtshinweis } from '@/components/rechtshinweis' +import { Betrag, Hinweisliste, Kennzahl, Summenzeile } from '@/components/finanzen/bausteine' +import { Input } from '@/components/ui/input' +import { centZuZahl } from '@/lib/db' +import { formatCent, formatDatum } from '@/lib/utils' +import { finanzKontext } from '@/lib/finanzen/seite' +import { heute, tagAlsText, tagAusText } from '@/lib/finanzen/datum' +import { + SOZIALHILFE_BEWERTUNG_BESCHRIFTUNG, + VERMOEGENS_ART_BESCHRIFTUNG, + VERMOEGENS_ART_EINZAHL, +} from '@/lib/finanzen/kategorien' +import { vermoegenLaden } from '@/lib/berichte/gemeinsam' +import type { SozialhilfeBewertung } from '@prisma/client' + +export const metadata = { title: 'Vermögen' } + +/** + * Vermögensposten mit ihrem Wert an einem frei wählbaren Stichtag. + * + * Der Stichtag ist hier kein Beiwerk: Genau so entsteht später das + * Vermögensverzeichnis. Wer den Stichtag zurücksetzt, sieht das + * Vermögen so, wie es damals im Verzeichnis stand – und nicht so, wie + * man es heute gerne hätte. + */ +export default async function VermoegenSeite({ + searchParams, +}: { + searchParams: Promise> +}) { + const parameter = await searchParams + const { betreute, darfSchreiben } = await finanzKontext() + const schreiben = darfSchreiben('FINANZEN') + + const gewaehlt = + typeof parameter.stichtag === 'string' ? tagAusText(parameter.stichtag) : null + const stichtag = gewaehlt ?? heute() + + const bild = await vermoegenLaden(betreute.id, stichtag) + + const hinweise = bild.ohneWert.map( + (posten) => + `„${posten.bezeichnung}“ hat zum ${formatDatum(stichtag)} keinen Wert. ` + + (posten.spaetereWertstaende > 0 + ? 'Es gibt nur Wertstände nach diesem Stichtag – die dürfen hier nicht verwendet werden.' + : 'Es ist noch kein Wertstand erfasst.'), + ) + + return ( +
+ + +
+ ) +} + +function Postentabelle({ + posten, +}: { + posten: { + id: string + bezeichnung: string + wertCent: bigint + bewertetAm: Date + wertquelle: string + alterTage: number + nachweisTitel: string | null + bewertungSozialhilfe: string | null + }[] +}) { + return ( + + + + Posten + Wertquelle + Bewertet am + Sozialhilfe + Wert + + + + {posten.map((eintrag) => ( + + + + {eintrag.bezeichnung} + + + + {eintrag.wertquelle} + {eintrag.nachweisTitel && ( + Beleg: {eintrag.nachweisTitel} + )} + + + {formatDatum(eintrag.bewertetAm)} + {eintrag.alterTage > 365 && ( + + über ein Jahr alt + + )} + + + {eintrag.bewertungSozialhilfe + ? SOZIALHILFE_BEWERTUNG_BESCHRIFTUNG[ + eintrag.bewertungSozialhilfe as SozialhilfeBewertung + ] + : '–'} + + + + + + ))} + +
+ ) +} diff --git a/src/lib/berichte/gemeinsam.ts b/src/lib/berichte/gemeinsam.ts index 0bf7e8f..f330923 100644 --- a/src/lib/berichte/gemeinsam.ts +++ b/src/lib/berichte/gemeinsam.ts @@ -1,6 +1,7 @@ import 'server-only' import type { Betreute, User } from '@prisma/client' import { db, nichtGeloescht } from '@/lib/db' +import { protokollieren } from '@/lib/audit' import { oeffnen } from '@/lib/crypto/feld' import type { Berichtskopf } from '@/lib/pdf/rahmen' import { alsTag, heute, monatePlus, tagePlus } from '@/lib/finanzen/datum' @@ -405,9 +406,51 @@ export async function rechnungslegungDaten(eingabe: { } } -/** Der Name, der unter dem Bericht steht. */ -export function erstellerName(benutzer: User): string { - return benutzer.name +/** + * Haelt fest, dass ein Bericht erzeugt wurde. + * + * Die PDF-Datei selbst wird bei jedem Abruf frisch erzeugt; hier steht + * nur, was wann in welcher Fassung erstellt und damit potenziell + * abgegeben wurde. Gegenueber dem Betreuungsgericht und moeglichen + * Miterben muss ich das belegen koennen. + * + * Bewusst keine Server Action: Aufgerufen wird das nur serverseitig aus + * der PDF-Route. Als Action waere es ein zusaetzlicher oeffentlicher + * Endpunkt, ueber den sich Berichtseintraege erzeugen liessen. + */ +export async function berichtVermerken(eingabe: { + betreuteId: string + benutzer: User + art: 'VERMOEGENSVERZEICHNIS' | 'VERMOEGENSUEBERSICHT' | 'RECHNUNGSLEGUNG' + titel: string + stichtag?: Date | null + zeitraumVon?: Date | null + zeitraumBis?: Date | null + dateiname: string +}): Promise { + const bericht = await db.bericht.create({ + data: { + betreuteId: eingabe.betreuteId, + art: eingabe.art, + titel: eingabe.titel, + stichtag: eingabe.stichtag ?? null, + zeitraumVon: eingabe.zeitraumVon ?? null, + zeitraumBis: eingabe.zeitraumBis ?? null, + dateiname: eingabe.dateiname, + erstelltVonId: eingabe.benutzer.id, + }, + }) + + await protokollieren({ + aktion: 'EXPORTIERT', + entitaet: 'Bericht', + entitaetId: bericht.id, + bezeichnung: eingabe.titel, + betreuteId: eingabe.betreuteId, + userId: eingabe.benutzer.id, + userEmail: eingabe.benutzer.email, + userName: eingabe.benutzer.name, + }) } function kurz(datum: Date): string { diff --git a/src/server/actions/finanzen.ts b/src/server/actions/finanzen.ts index d454910..d318c33 100644 --- a/src/server/actions/finanzen.ts +++ b/src/server/actions/finanzen.ts @@ -6,10 +6,9 @@ import { Prisma } from '@prisma/client' import { db, nichtGeloescht } from '@/lib/db' import { erfordereZugang, ZugriffVerweigert } from '@/lib/auth/sitzung' import { aenderungErmitteln, protokollieren } from '@/lib/audit' -import { oeffnen, sealen } from '@/lib/crypto/feld' +import { sealen } from '@/lib/crypto/feld' import { blindindex } from '@/lib/crypto/hash' import { alsTag } from '@/lib/finanzen/datum' -import { ibanMaskieren } from '@/lib/finanzen/iban' import { csvTextLesen, csvZuBuchungen, dublettenMarkieren } from '@/lib/finanzen/import' import { camtZuBuchungen } from '@/lib/finanzen/camt' import { @@ -45,8 +44,6 @@ export type Formularzustand = { meldung?: string[] } -const LEER: Formularzustand = {} - function zugriffsfehler(ausnahme: unknown): Formularzustand | null { if (ausnahme instanceof ZugriffVerweigert) { return { fehler: ausnahme.message } @@ -824,67 +821,3 @@ export async function wertstandLoeschen( revalidatePath('/finanzen/vermoegen') return { erfolg: 'Wert gelöscht.' } } - -// ============================================================================= -// Berichte -// ============================================================================= - -/** - * Haelt fest, dass ein Bericht erzeugt wurde. Die PDF-Datei selbst wird - * beim Abruf erzeugt und ausgeliefert; hier steht nur, was wann in - * welcher Fassung abgegeben wurde. - */ -export async function berichtVermerken(eingabe: { - betreuteId: string - art: 'VERMOEGENSVERZEICHNIS' | 'VERMOEGENSUEBERSICHT' | 'RECHNUNGSLEGUNG' - titel: string - stichtag?: Date | null - zeitraumVon?: Date | null - zeitraumBis?: Date | null - dateiname: string -}): Promise { - const { benutzer } = await erfordereZugang(eingabe.betreuteId, 'FINANZEN', 'lesen') - - const bericht = await db.bericht.create({ - data: { - betreuteId: eingabe.betreuteId, - art: eingabe.art, - titel: eingabe.titel, - stichtag: eingabe.stichtag ?? null, - zeitraumVon: eingabe.zeitraumVon ?? null, - zeitraumBis: eingabe.zeitraumBis ?? null, - dateiname: eingabe.dateiname, - erstelltVonId: benutzer.id, - }, - }) - - await protokollieren({ - aktion: 'EXPORTIERT', - entitaet: 'Bericht', - entitaetId: bericht.id, - bezeichnung: eingabe.titel, - betreuteId: eingabe.betreuteId, - userId: benutzer.id, - userEmail: benutzer.email, - userName: benutzer.name, - }) -} - -/** Kleine Hilfe fuer Formulare, die nur den Anfangszustand brauchen. */ -export async function leererZustand(): Promise { - return LEER -} - -/** Zeigt die IBAN eines Kontos maskiert - fuer Listen ohne Klartext. */ -export async function kontoIbanMaskiert( - betreuteId: string, - kontoId: string, -): Promise { - await erfordereZugang(betreuteId, 'FINANZEN', 'lesen') - const konto = await db.konto.findFirst({ - where: { id: kontoId, betreuteId, ...nichtGeloescht }, - }) - if (!konto) return null - const offen = oeffnen('Konto', konto) - return offen.iban ? ibanMaskieren(offen.iban) : null -}