docs: README, Datenschutztexte, Dockerfile und Sicherungsskripte

Damit ist Etappe 1 abgeschlossen und betriebsfertig dokumentiert.

README mit beiden Betriebswegen. Docker Compose auf eigenem Server ist
der Hauptweg; Vercel und Neon sind vollstaendig beschrieben, aber mit
einer klaren Begruendung, warum ich davon abrate: beide Anbieter sind
US-Unternehmen und unterliegen auch bei EU-Region potenziell dem
CLOUD-Act. Bei Gesundheitsdaten einer nicht entscheidungsfaehigen Person
ist das eine Abwaegung, die man bewusst treffen muss.

Reverse-Proxy-Beispiele fuer Caddy, nginx und Traefik - der Proxy selbst
ist nicht Teil des Compose-Setups, weil er auf dem Zielserver schon
steht. Die Datenbank hat keine Portfreigabe.

Sicherung: pg_dump schreibt direkt in die Verschluesselung, der Klartext
landet nie auf der Platte. Danach eine Selbstpruefung - laesst sich die
Datei nicht wieder entschluesseln, endet der Lauf mit Fehler statt mit
einem stillen Scheinerfolg. Der jeweils erste Stand eines Monats bleibt
dauerhaft stehen.

Die README sagt ausdruecklich, was das Backup NICHT enthaelt: den
Schluessel ENC_KEY_V1 und die Dateien im Objektspeicher. Dazu eine
Anleitung, die Wiederherstellung einmal im Jahr zu ueben - mit dem
letzten Schritt, in der Anwendung nachzusehen, ob die Diagnosen im
Klartext erscheinen. Erst dann stimmt nicht nur die Datenbank, sondern
auch der Schluessel.

Verarbeitungsverzeichnis und Datenschutzhinweise als ausfuellbare
Textbausteine. Die Hinweise sind in einfacher Sprache geschrieben - sie
sollen der betreuten Person vorgelesen werden koennen. Die Seite
/datenschutz liest denselben Text aus docs/, damit es nicht zwei
Fassungen gibt, die auseinanderlaufen.

Dockerfile in drei Stufen mit Tesseract, deutschen Sprachdaten und
poppler-utils. Zeitzone Europe/Berlin ist gesetzt - ohne sie rechnet der
Server in UTC und verschiebt Fristen um einen Tag.

Etappe: 1
Phase: Fundament
This commit is contained in:
Florian Wilde
2026-08-22 02:35:08 +02:00
parent 6b4df4c9cb
commit 3833a9dd8f
141 changed files with 30302 additions and 66 deletions
+890
View File
@@ -0,0 +1,890 @@
'use server'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
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 { 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 {
buchungSchema,
fehlerAuflisten,
formularZuObjekt,
importSchema,
kontoSchema,
saldostandSchema,
vermoegenspostenSchema,
wertstandSchema,
} from '@/lib/validierung/finanzen'
/**
* Server Actions des Finanzmoduls.
*
* Grundsaetze, die hier ohne Ausnahme gelten:
*
* - Jede Aktion prueft den Zugriff selbst (erfordereZugang), nicht nur
* die Seite davor. Ein Formular laesst sich nachbauen.
* - Jede Aktion validiert mit Zod, bevor irgendetwas geschrieben wird.
* - Jede schreibende Aktion landet im Audit-Log. Das ist mein Nachweis
* gegenueber Gericht und moeglichen Miterben.
* - Fehler werden als Zustand zurueckgegeben, nicht geworfen - das
* Formular soll die Eingaben behalten und die Meldung am Feld zeigen.
*/
export type Formularzustand = {
fehler?: string
felder?: Record<string, string>
erfolg?: string
/** Frei verwendbare Rueckmeldung, z.B. die Zusammenfassung eines Imports. */
meldung?: string[]
}
const LEER: Formularzustand = {}
function zugriffsfehler(ausnahme: unknown): Formularzustand | null {
if (ausnahme instanceof ZugriffVerweigert) {
return { fehler: ausnahme.message }
}
return null
}
/** Prisma meldet eine verletzte Eindeutigkeit mit P2002. */
function istDublette(ausnahme: unknown): boolean {
return (
ausnahme instanceof Prisma.PrismaClientKnownRequestError && ausnahme.code === 'P2002'
)
}
// =============================================================================
// Konten
// =============================================================================
export async function kontoSpeichern(
_zustand: Formularzustand,
daten: FormData,
): Promise<Formularzustand> {
const betreuteId = String(daten.get('betreuteId') ?? '')
const kontoId = String(daten.get('kontoId') ?? '')
if (!betreuteId) return { fehler: 'Es ist keine betreute Person ausgewählt.' }
let ziel: string
try {
const { benutzer } = await erfordereZugang(betreuteId, 'FINANZEN', 'schreiben')
const gepruft = kontoSchema.safeParse(formularZuObjekt(daten))
if (!gepruft.success) {
return { fehler: 'Bitte die markierten Felder prüfen.', felder: fehlerAuflisten(gepruft.error) }
}
const werte = gepruft.data
const ibanIndex = blindindex(werte.iban)
// "Kenne ich dieses Konto schon?" - beantwortbar ohne die IBAN im
// Klartext zu halten.
if (ibanIndex) {
const vorhanden = await db.konto.findFirst({
where: { betreuteId, ibanIndex, ...nichtGeloescht, ...(kontoId ? { id: { not: kontoId } } : {}) },
select: { id: true, bezeichnung: true },
})
if (vorhanden) {
return {
fehler: `Diese IBAN ist bereits als „${vorhanden.bezeichnung}“ erfasst.`,
felder: { iban: 'Dieses Konto gibt es schon.' },
}
}
}
const felder = {
bezeichnung: werte.bezeichnung,
bank: werte.bank,
art: werte.art,
bic: werte.bic,
istBetreuerkonto: werte.istBetreuerkonto,
eroeffnetAm: werte.eroeffnetAm,
geschlossenAm: werte.geschlossenAm,
ibanIndex,
...sealen('Konto', { iban: werte.iban }),
}
if (kontoId) {
const vorher = await db.konto.findFirst({
where: { id: kontoId, betreuteId, ...nichtGeloescht },
})
if (!vorher) return { fehler: 'Dieses Konto gibt es nicht (mehr).' }
const nachher = await db.konto.update({ where: { id: kontoId }, data: felder })
await protokollieren({
aktion: 'GEAENDERT',
entitaet: 'Konto',
entitaetId: kontoId,
bezeichnung: nachher.bezeichnung,
betreuteId,
userId: benutzer.id,
userEmail: benutzer.email,
userName: benutzer.name,
aenderung: aenderungErmitteln(vorher, nachher),
})
ziel = `/finanzen/konten/${kontoId}`
} else {
const neu = await db.konto.create({ data: { betreuteId, ...felder } })
await protokollieren({
aktion: 'ERSTELLT',
entitaet: 'Konto',
entitaetId: neu.id,
bezeichnung: neu.bezeichnung,
betreuteId,
userId: benutzer.id,
userEmail: benutzer.email,
userName: benutzer.name,
})
ziel = `/finanzen/konten/${neu.id}`
}
} catch (ausnahme) {
const verweigert = zugriffsfehler(ausnahme)
if (verweigert) return verweigert
console.error('[finanzen] Konto konnte nicht gespeichert werden:', ausnahme)
return { fehler: 'Das Konto konnte nicht gespeichert werden. Bitte noch einmal versuchen.' }
}
revalidatePath('/finanzen')
revalidatePath('/finanzen/konten')
redirect(ziel)
}
export async function kontoLoeschen(
_zustand: Formularzustand,
daten: FormData,
): Promise<Formularzustand> {
const betreuteId = String(daten.get('betreuteId') ?? '')
const kontoId = String(daten.get('kontoId') ?? '')
try {
const { benutzer } = await erfordereZugang(betreuteId, 'FINANZEN', 'schreiben')
const konto = await db.konto.findFirst({ where: { id: kontoId, betreuteId, ...nichtGeloescht } })
if (!konto) return { fehler: 'Dieses Konto gibt es nicht (mehr).' }
const buchungen = await db.buchung.count({ where: { kontoId, ...nichtGeloescht } })
if (buchungen > 0) {
return {
fehler:
`Zu diesem Konto gibt es noch ${buchungen} Buchung(en). Ein Konto mit Buchungen wird ` +
'nicht gelöscht – trage stattdessen ein Schließungsdatum ein, dann bleibt die ' +
'Rechnungslegung nachvollziehbar.',
}
}
await db.konto.update({ where: { id: kontoId }, data: { geloeschtAm: new Date() } })
await protokollieren({
aktion: 'GELOESCHT',
entitaet: 'Konto',
entitaetId: kontoId,
bezeichnung: konto.bezeichnung,
betreuteId,
userId: benutzer.id,
userEmail: benutzer.email,
userName: benutzer.name,
})
} catch (ausnahme) {
const verweigert = zugriffsfehler(ausnahme)
if (verweigert) return verweigert
console.error('[finanzen] Konto konnte nicht gelöscht werden:', ausnahme)
return { fehler: 'Das Konto konnte nicht in den Papierkorb gelegt werden.' }
}
revalidatePath('/finanzen/konten')
redirect('/finanzen/konten')
}
/**
* Saldostaende werden nie ueberschrieben. Gibt es zum selben Stichtag
* schon einen Stand, wird das gemeldet - wer korrigieren will, loescht
* den alten bewusst.
*/
export async function saldostandAnlegen(
_zustand: Formularzustand,
daten: FormData,
): Promise<Formularzustand> {
const betreuteId = String(daten.get('betreuteId') ?? '')
try {
const { benutzer } = await erfordereZugang(betreuteId, 'FINANZEN', 'schreiben')
const gepruft = saldostandSchema.safeParse(formularZuObjekt(daten))
if (!gepruft.success) {
return { fehler: 'Bitte die markierten Felder prüfen.', felder: fehlerAuflisten(gepruft.error) }
}
const werte = gepruft.data
const konto = await db.konto.findFirst({
where: { id: werte.kontoId, betreuteId, ...nichtGeloescht },
select: { id: true, bezeichnung: true },
})
if (!konto) return { fehler: 'Dieses Konto gehört nicht zu dieser betreuten Person.' }
try {
const stand = await db.saldostand.create({
data: {
kontoId: konto.id,
stichtag: werte.stichtag,
betragCent: werte.betrag,
quelle: werte.quelle,
},
})
await protokollieren({
aktion: 'ERSTELLT',
entitaet: 'Saldostand',
entitaetId: stand.id,
bezeichnung: `${konto.bezeichnung} zum ${werte.stichtag.toISOString().slice(0, 10)}`,
betreuteId,
userId: benutzer.id,
userEmail: benutzer.email,
userName: benutzer.name,
})
} catch (ausnahme) {
if (istDublette(ausnahme)) {
return {
fehler: 'Für dieses Konto gibt es zu diesem Stichtag bereits einen Stand.',
felder: { stichtag: 'Stichtag ist schon belegt.' },
}
}
throw ausnahme
}
} catch (ausnahme) {
const verweigert = zugriffsfehler(ausnahme)
if (verweigert) return verweigert
console.error('[finanzen] Saldostand konnte nicht angelegt werden:', ausnahme)
return { fehler: 'Der Kontostand konnte nicht gespeichert werden.' }
}
revalidatePath('/finanzen')
revalidatePath('/finanzen/konten')
return { erfolg: 'Kontostand gespeichert.' }
}
export async function saldostandLoeschen(
_zustand: Formularzustand,
daten: FormData,
): Promise<Formularzustand> {
const betreuteId = String(daten.get('betreuteId') ?? '')
const saldostandId = String(daten.get('saldostandId') ?? '')
try {
const { benutzer } = await erfordereZugang(betreuteId, 'FINANZEN', 'schreiben')
const stand = await db.saldostand.findFirst({
where: { id: saldostandId, konto: { betreuteId } },
include: { konto: { select: { bezeichnung: true } } },
})
if (!stand) return { fehler: 'Diesen Kontostand gibt es nicht (mehr).' }
await db.saldostand.delete({ where: { id: saldostandId } })
await protokollieren({
aktion: 'GELOESCHT',
entitaet: 'Saldostand',
entitaetId: saldostandId,
bezeichnung: `${stand.konto.bezeichnung} zum ${stand.stichtag.toISOString().slice(0, 10)}`,
betreuteId,
userId: benutzer.id,
userEmail: benutzer.email,
userName: benutzer.name,
})
} catch (ausnahme) {
const verweigert = zugriffsfehler(ausnahme)
if (verweigert) return verweigert
console.error('[finanzen] Saldostand konnte nicht gelöscht werden:', ausnahme)
return { fehler: 'Der Kontostand konnte nicht gelöscht werden.' }
}
revalidatePath('/finanzen')
revalidatePath('/finanzen/konten')
return { erfolg: 'Kontostand gelöscht.' }
}
// =============================================================================
// Buchungen
// =============================================================================
export async function buchungSpeichern(
_zustand: Formularzustand,
daten: FormData,
): Promise<Formularzustand> {
const betreuteId = String(daten.get('betreuteId') ?? '')
const buchungId = String(daten.get('buchungId') ?? '')
const weiterErfassen = daten.get('weiterErfassen') === 'on'
let ziel: string | null = null
try {
const { benutzer } = await erfordereZugang(betreuteId, 'FINANZEN', 'schreiben')
const gepruft = buchungSchema.safeParse(formularZuObjekt(daten))
if (!gepruft.success) {
return { fehler: 'Bitte die markierten Felder prüfen.', felder: fehlerAuflisten(gepruft.error) }
}
const werte = gepruft.data
if (werte.kontoId) {
const konto = await db.konto.findFirst({
where: { id: werte.kontoId, betreuteId, ...nichtGeloescht },
select: { id: true },
})
if (!konto) return { fehler: 'Dieses Konto gehört nicht zu dieser betreuten Person.' }
}
if (werte.belegId) {
const beleg = await db.dokument.findFirst({
where: { id: werte.belegId, betreuteId, ...nichtGeloescht },
select: { id: true },
})
if (!beleg) return { fehler: 'Dieses Dokument gehört nicht zu dieser betreuten Person.' }
}
const felder = {
kontoId: werte.kontoId,
vorgangId: werte.vorgangId,
datum: werte.datum,
wertstellung: werte.wertstellung,
betragCent: werte.betrag,
richtung: werte.richtung,
kategorie: werte.kategorie,
gegenstelle: werte.gegenstelle,
belegId: werte.belegId,
belegFehltBegruendung: werte.belegId ? null : werte.belegFehltBegruendung,
betrifftBetreute: werte.betrifftBetreute,
vermoegenswirksam: werte.vermoegenswirksam,
...sealen('Buchung', { verwendungszweck: werte.verwendungszweck }),
}
if (buchungId) {
const vorher = await db.buchung.findFirst({
where: { id: buchungId, betreuteId, ...nichtGeloescht },
})
if (!vorher) return { fehler: 'Diese Buchung gibt es nicht (mehr).' }
const nachher = await db.buchung.update({ where: { id: buchungId }, data: felder })
await protokollieren({
aktion: 'GEAENDERT',
entitaet: 'Buchung',
entitaetId: buchungId,
bezeichnung: `${werte.datum.toISOString().slice(0, 10)} · ${werte.gegenstelle ?? 'ohne Gegenstelle'}`,
betreuteId,
userId: benutzer.id,
userEmail: benutzer.email,
userName: benutzer.name,
aenderung: aenderungErmitteln(vorher, nachher),
})
ziel = '/finanzen/buchungen'
} else {
const neu = await db.buchung.create({ data: { betreuteId, ...felder } })
await protokollieren({
aktion: 'ERSTELLT',
entitaet: 'Buchung',
entitaetId: neu.id,
bezeichnung: `${werte.datum.toISOString().slice(0, 10)} · ${werte.gegenstelle ?? 'ohne Gegenstelle'}`,
betreuteId,
userId: benutzer.id,
userEmail: benutzer.email,
userName: benutzer.name,
})
ziel = weiterErfassen ? null : '/finanzen/buchungen'
}
} catch (ausnahme) {
const verweigert = zugriffsfehler(ausnahme)
if (verweigert) return verweigert
console.error('[finanzen] Buchung konnte nicht gespeichert werden:', ausnahme)
return { fehler: 'Die Buchung konnte nicht gespeichert werden. Bitte noch einmal versuchen.' }
}
revalidatePath('/finanzen')
revalidatePath('/finanzen/buchungen')
if (ziel) redirect(ziel)
return { erfolg: 'Buchung gespeichert. Du kannst gleich die nächste erfassen.' }
}
export async function buchungLoeschen(
_zustand: Formularzustand,
daten: FormData,
): Promise<Formularzustand> {
const betreuteId = String(daten.get('betreuteId') ?? '')
const buchungId = String(daten.get('buchungId') ?? '')
try {
const { benutzer } = await erfordereZugang(betreuteId, 'FINANZEN', 'schreiben')
const buchung = await db.buchung.findFirst({
where: { id: buchungId, betreuteId, ...nichtGeloescht },
})
if (!buchung) return { fehler: 'Diese Buchung gibt es nicht (mehr).' }
await db.buchung.update({ where: { id: buchungId }, data: { geloeschtAm: new Date() } })
await protokollieren({
aktion: 'GELOESCHT',
entitaet: 'Buchung',
entitaetId: buchungId,
bezeichnung: `${buchung.datum.toISOString().slice(0, 10)} · ${buchung.gegenstelle ?? 'ohne Gegenstelle'}`,
betreuteId,
userId: benutzer.id,
userEmail: benutzer.email,
userName: benutzer.name,
})
} catch (ausnahme) {
const verweigert = zugriffsfehler(ausnahme)
if (verweigert) return verweigert
console.error('[finanzen] Buchung konnte nicht gelöscht werden:', ausnahme)
return { fehler: 'Die Buchung konnte nicht in den Papierkorb gelegt werden.' }
}
revalidatePath('/finanzen')
revalidatePath('/finanzen/buchungen')
return { erfolg: 'Buchung in den Papierkorb gelegt.' }
}
// =============================================================================
// Import
// =============================================================================
const IMPORT_MAX_BYTES = 8 * 1024 * 1024
/**
* Liest eine CSV- oder CAMT-Datei und legt die enthaltenen Umsaetze an.
*
* Was schon einmal importiert wurde, wird uebersprungen (Dublettenschutz
* ueber importReferenz) und im Ergebnis genannt. Importierte Buchungen
* kommen ohne Kategorie herein - die wird nachtraeglich gesetzt; eine
* automatisch geratene Kategorie waere in der Rechnungslegung schlimmer
* als gar keine.
*/
export async function importAusfuehren(
_zustand: Formularzustand,
daten: FormData,
): Promise<Formularzustand> {
const betreuteId = String(daten.get('betreuteId') ?? '')
try {
const { benutzer } = await erfordereZugang(betreuteId, 'FINANZEN', 'schreiben')
const gepruft = importSchema.safeParse(formularZuObjekt(daten))
if (!gepruft.success) {
return { fehler: 'Bitte die markierten Felder prüfen.', felder: fehlerAuflisten(gepruft.error) }
}
const werte = gepruft.data
const datei = daten.get('datei')
if (!(datei instanceof File) || datei.size === 0) {
return { fehler: 'Bitte eine Datei auswählen.', felder: { datei: 'Es wurde keine Datei ausgewählt.' } }
}
if (datei.size > IMPORT_MAX_BYTES) {
return { fehler: 'Die Datei ist größer als 8 MB. Bitte den Zeitraum kleiner wählen.' }
}
const konto = await db.konto.findFirst({
where: { id: werte.kontoId, betreuteId, ...nichtGeloescht },
select: { id: true, bezeichnung: true, istBetreuerkonto: true },
})
if (!konto) return { fehler: 'Dieses Konto gehört nicht zu dieser betreuten Person.' }
const puffer = Buffer.from(await datei.arrayBuffer())
const istXml = /\.xml$/i.test(datei.name) || puffer.subarray(0, 512).toString('utf8').includes('<Document')
let gelesen
try {
gelesen = istXml ? camtZuBuchungen(puffer) : csvZuBuchungen(csvTextLesen(puffer))
} catch (ausnahme) {
return {
fehler:
ausnahme instanceof Error
? ausnahme.message
: 'Die Datei konnte nicht gelesen werden.',
}
}
if (gelesen.zeilen.length === 0) {
return {
fehler: 'In der Datei war kein einziger lesbarer Umsatz.',
meldung: gelesen.fehler.slice(0, 10).map((f) => `Zeile ${f.zeilennummer}: ${f.meldung}`),
}
}
const vorhanden = await db.buchung.findMany({
where: { kontoId: konto.id, importReferenz: { not: null } },
select: { importReferenz: true },
})
const { zeilen, dubletten } = dublettenMarkieren(
gelesen.zeilen,
vorhanden.map((b) => b.importReferenz).filter((r): r is string => r !== null),
)
const anzulegen = zeilen.filter((z) => werte.dublettenUebernehmen || !z.dublette)
let angelegt = 0
for (const zeile of anzulegen) {
try {
await db.buchung.create({
data: {
betreuteId,
kontoId: konto.id,
datum: alsTag(zeile.datum),
wertstellung: zeile.wertstellung ? alsTag(zeile.wertstellung) : null,
betragCent: zeile.betragCent,
richtung: zeile.richtung,
kategorie: zeile.richtung === 'EINNAHME' ? 'SONSTIGE_EINNAHME' : 'SONSTIGE_AUSGABE',
gegenstelle: zeile.gegenstelle,
betrifftBetreute: werte.betrifftBetreute,
importReferenz: werte.dublettenUebernehmen && zeile.dublette ? null : zeile.importReferenz,
...sealen('Buchung', { verwendungszweck: zeile.verwendungszweck }),
},
})
angelegt++
} catch (ausnahme) {
// Die Eindeutigkeit auf (kontoId, importReferenz) faengt Reste ab,
// die zwischen Lesen und Schreiben dazugekommen sind.
if (!istDublette(ausnahme)) throw ausnahme
}
}
await protokollieren({
aktion: 'ERSTELLT',
entitaet: 'Buchung',
bezeichnung: `Import „${datei.name}“ auf ${konto.bezeichnung}: ${angelegt} Buchungen`,
betreuteId,
userId: benutzer.id,
userEmail: benutzer.email,
userName: benutzer.name,
})
revalidatePath('/finanzen')
revalidatePath('/finanzen/buchungen')
const meldung = [
`${angelegt} Buchung(en) angelegt.`,
dubletten > 0
? `${dubletten} Umsatz/Umsätze waren schon vorhanden und wurden übersprungen.`
: 'Es gab keine Dubletten.',
...(gelesen.fehler.length > 0
? [`${gelesen.fehler.length} Zeile(n) konnten nicht gelesen werden:`]
: []),
...gelesen.fehler.slice(0, 10).map((f) => `Zeile ${f.zeilennummer}: ${f.meldung}`),
'Alle importierten Buchungen stehen zunächst in „Sonstige Einnahmen“ bzw. „Sonstige Ausgaben“. ' +
'Bitte die Kategorien nachtragen – danach stimmen die Berichte.',
]
return { erfolg: `Import abgeschlossen: ${angelegt} Buchung(en) angelegt.`, meldung }
} catch (ausnahme) {
const verweigert = zugriffsfehler(ausnahme)
if (verweigert) return verweigert
console.error('[finanzen] Import fehlgeschlagen:', ausnahme)
return { fehler: 'Der Import ist fehlgeschlagen. Bitte die Datei prüfen.' }
}
}
// =============================================================================
// Vermoegensposten und Wertstaende
// =============================================================================
export async function vermoegenspostenSpeichern(
_zustand: Formularzustand,
daten: FormData,
): Promise<Formularzustand> {
const betreuteId = String(daten.get('betreuteId') ?? '')
const postenId = String(daten.get('postenId') ?? '')
let ziel: string
try {
const { benutzer } = await erfordereZugang(betreuteId, 'FINANZEN', 'schreiben')
const gepruft = vermoegenspostenSchema.safeParse(formularZuObjekt(daten))
if (!gepruft.success) {
return { fehler: 'Bitte die markierten Felder prüfen.', felder: fehlerAuflisten(gepruft.error) }
}
const werte = gepruft.data
if (werte.kontoId) {
const konto = await db.konto.findFirst({
where: { id: werte.kontoId, betreuteId, ...nichtGeloescht },
select: { id: true },
})
if (!konto) return { fehler: 'Dieses Konto gehört nicht zu dieser betreuten Person.' }
}
const felder = {
bezeichnung: werte.bezeichnung,
art: werte.art,
kontoId: werte.kontoId,
immobilieId: werte.immobilieId,
vertragId: werte.vertragId,
bewertungSozialhilfe: werte.bewertungSozialhilfe,
...sealen('Vermoegensposten', {
bewertungBegruendung: werte.bewertungBegruendung,
notizen: werte.notizen,
}),
}
if (postenId) {
const vorher = await db.vermoegensposten.findFirst({
where: { id: postenId, betreuteId, ...nichtGeloescht },
})
if (!vorher) return { fehler: 'Diesen Posten gibt es nicht (mehr).' }
const nachher = await db.vermoegensposten.update({ where: { id: postenId }, data: felder })
await protokollieren({
aktion: 'GEAENDERT',
entitaet: 'Vermoegensposten',
entitaetId: postenId,
bezeichnung: nachher.bezeichnung,
betreuteId,
userId: benutzer.id,
userEmail: benutzer.email,
userName: benutzer.name,
aenderung: aenderungErmitteln(vorher, nachher),
})
ziel = `/finanzen/vermoegen/${postenId}`
} else {
const neu = await db.vermoegensposten.create({ data: { betreuteId, ...felder } })
await protokollieren({
aktion: 'ERSTELLT',
entitaet: 'Vermoegensposten',
entitaetId: neu.id,
bezeichnung: neu.bezeichnung,
betreuteId,
userId: benutzer.id,
userEmail: benutzer.email,
userName: benutzer.name,
})
ziel = `/finanzen/vermoegen/${neu.id}`
}
} catch (ausnahme) {
const verweigert = zugriffsfehler(ausnahme)
if (verweigert) return verweigert
console.error('[finanzen] Vermögensposten konnte nicht gespeichert werden:', ausnahme)
return { fehler: 'Der Posten konnte nicht gespeichert werden.' }
}
revalidatePath('/finanzen')
revalidatePath('/finanzen/vermoegen')
redirect(ziel)
}
export async function vermoegenspostenLoeschen(
_zustand: Formularzustand,
daten: FormData,
): Promise<Formularzustand> {
const betreuteId = String(daten.get('betreuteId') ?? '')
const postenId = String(daten.get('postenId') ?? '')
try {
const { benutzer } = await erfordereZugang(betreuteId, 'FINANZEN', 'schreiben')
const posten = await db.vermoegensposten.findFirst({
where: { id: postenId, betreuteId, ...nichtGeloescht },
})
if (!posten) return { fehler: 'Diesen Posten gibt es nicht (mehr).' }
await db.vermoegensposten.update({ where: { id: postenId }, data: { geloeschtAm: new Date() } })
await protokollieren({
aktion: 'GELOESCHT',
entitaet: 'Vermoegensposten',
entitaetId: postenId,
bezeichnung: posten.bezeichnung,
betreuteId,
userId: benutzer.id,
userEmail: benutzer.email,
userName: benutzer.name,
})
} catch (ausnahme) {
const verweigert = zugriffsfehler(ausnahme)
if (verweigert) return verweigert
console.error('[finanzen] Vermögensposten konnte nicht gelöscht werden:', ausnahme)
return { fehler: 'Der Posten konnte nicht in den Papierkorb gelegt werden.' }
}
revalidatePath('/finanzen/vermoegen')
redirect('/finanzen/vermoegen')
}
/**
* Ein Wertstand wird angelegt, nie geaendert. Jede Vermoegensuebersicht
* muss spaeter reproduzierbar bleiben - deshalb bleibt auch ein
* ueberholter Wert stehen.
*/
export async function wertstandAnlegen(
_zustand: Formularzustand,
daten: FormData,
): Promise<Formularzustand> {
const betreuteId = String(daten.get('betreuteId') ?? '')
try {
const { benutzer } = await erfordereZugang(betreuteId, 'FINANZEN', 'schreiben')
const gepruft = wertstandSchema.safeParse(formularZuObjekt(daten))
if (!gepruft.success) {
return { fehler: 'Bitte die markierten Felder prüfen.', felder: fehlerAuflisten(gepruft.error) }
}
const werte = gepruft.data
const posten = await db.vermoegensposten.findFirst({
where: { id: werte.vermoegenspostenId, betreuteId, ...nichtGeloescht },
select: { id: true, bezeichnung: true, art: true },
})
if (!posten) return { fehler: 'Dieser Posten gehört nicht zu dieser betreuten Person.' }
if (posten.art === 'VERBINDLICHKEIT' && werte.wert < 0n) {
return {
fehler: 'Bitte den Betrag der Verbindlichkeit positiv angeben.',
felder: {
wert:
'Verbindlichkeiten werden mit positivem Betrag geführt – dass sie das Vermögen mindern, ' +
'ergibt sich aus der Art des Postens.',
},
}
}
if (werte.nachweisDokumentId) {
const dokument = await db.dokument.findFirst({
where: { id: werte.nachweisDokumentId, betreuteId, ...nichtGeloescht },
select: { id: true },
})
if (!dokument) return { fehler: 'Dieses Dokument gehört nicht zu dieser betreuten Person.' }
}
try {
const stand = await db.wertstand.create({
data: {
vermoegenspostenId: posten.id,
stichtag: werte.stichtag,
wertCent: werte.wert,
wertquelle: werte.wertquelle,
nachweisDokumentId: werte.nachweisDokumentId,
bemerkung: werte.bemerkung,
},
})
await protokollieren({
aktion: 'ERSTELLT',
entitaet: 'Wertstand',
entitaetId: stand.id,
bezeichnung: `${posten.bezeichnung} zum ${werte.stichtag.toISOString().slice(0, 10)}`,
betreuteId,
userId: benutzer.id,
userEmail: benutzer.email,
userName: benutzer.name,
})
} catch (ausnahme) {
if (istDublette(ausnahme)) {
return {
fehler: 'Für diesen Posten gibt es zu diesem Stichtag schon einen Wert.',
felder: { stichtag: 'Stichtag ist schon belegt.' },
}
}
throw ausnahme
}
} catch (ausnahme) {
const verweigert = zugriffsfehler(ausnahme)
if (verweigert) return verweigert
console.error('[finanzen] Wertstand konnte nicht angelegt werden:', ausnahme)
return { fehler: 'Der Wert konnte nicht gespeichert werden.' }
}
revalidatePath('/finanzen')
revalidatePath('/finanzen/vermoegen')
return { erfolg: 'Wert gespeichert. Frühere Werte bleiben erhalten.' }
}
export async function wertstandLoeschen(
_zustand: Formularzustand,
daten: FormData,
): Promise<Formularzustand> {
const betreuteId = String(daten.get('betreuteId') ?? '')
const wertstandId = String(daten.get('wertstandId') ?? '')
try {
const { benutzer } = await erfordereZugang(betreuteId, 'FINANZEN', 'schreiben')
const stand = await db.wertstand.findFirst({
where: { id: wertstandId, vermoegensposten: { betreuteId } },
include: { vermoegensposten: { select: { bezeichnung: true } } },
})
if (!stand) return { fehler: 'Diesen Wert gibt es nicht (mehr).' }
await db.wertstand.delete({ where: { id: wertstandId } })
await protokollieren({
aktion: 'GELOESCHT',
entitaet: 'Wertstand',
entitaetId: wertstandId,
bezeichnung: `${stand.vermoegensposten.bezeichnung} zum ${stand.stichtag.toISOString().slice(0, 10)}`,
betreuteId,
userId: benutzer.id,
userEmail: benutzer.email,
userName: benutzer.name,
})
} catch (ausnahme) {
const verweigert = zugriffsfehler(ausnahme)
if (verweigert) return verweigert
console.error('[finanzen] Wertstand konnte nicht gelöscht werden:', ausnahme)
return { fehler: 'Der Wert konnte nicht gelöscht werden.' }
}
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<void> {
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<Formularzustand> {
return LEER
}
/** Zeigt die IBAN eines Kontos maskiert - fuer Listen ohne Klartext. */
export async function kontoIbanMaskiert(
betreuteId: string,
kontoId: string,
): Promise<string | null> {
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
}