Files
betreuungs-cockpit/src/server/actions/finanzen.ts
T
Florian Wilde 91a9aca0d8 Verweise auf fremde Akten an einer Stelle pruefen
Wenn ein Formular einen Fremdschluessel mitschickt, kommt der aus einer
Auswahlliste im Browser. Der Server darf sich darauf nicht verlassen -
eine veraenderte Anfrage kann jede beliebige Id enthalten.

Alle 22 Dateien unter src/server/actions/ durchgesehen. Das Ergebnis ist
besser als befuerchtet: Bis auf eine Stelle war jeder Verweis geprueft.

  buchung.vorgangId ging ungeprueft durch. Konto, Beleg und Vertrag
  wurden geprueft, der Vorgang nicht.

Die Pruefung liegt jetzt in lib/auth/verweise.ts statt verstreut in den
Aktionen. Die acht erlaubten Ziele sind eine ausgeschriebene Aufzaehlung
und kein dynamischer Tabellenzugriff: So sieht der Typpruefer jede
Abfrage, und welche Tabellen erreichbar sind, ist eine Entscheidung und
keine Nebenwirkung. Mehrere Verweise laufen nebeneinander - bei vier
Verweisen eine Wartezeit statt vier.

Umgestellt: die vier Einzelpruefungen der Buchung, die drei des
Vermoegenspostens, die eigene Hilfsfunktion fuer den Ansprechpartner.
Nicht angefasst wurden die Stellen, die ihre eigene, passendere Form
haben - etwa das Kontaktprotokoll, das beide Verweise parallel laedt und
feldgenaue Meldungen zurueckgibt.

Zum Test: Der erste Entwurf rief die Server Action per fetch nach. Der
waere auch ohne jede Pruefung gruen gewesen, weil Next einen Aufruf ohne
gueltige Action-Id ohnehin ablehnt - er haette nichts bewiesen. Der
jetzige schiebt dem echten Auswahlfeld einen Eintrag mit fremder Id
unter und schickt das Formular normal ab. Gegenprobe mit ausgebauter
Pruefung: faellt um, wie er soll.

Refs #23
2026-08-23 23:20:26 +02:00

1019 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use server'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import { Prisma } from '@prisma/client'
import { db, nichtGeloescht } from '@/lib/db'
import { verweisePruefen } from '@/lib/auth/verweise'
import { erfordereZugang, ZugriffVerweigert } from '@/lib/auth/sitzung'
import { aenderungErmitteln, protokollieren } from '@/lib/audit'
import { sealen } from '@/lib/crypto/feld'
import { blindindex } from '@/lib/crypto/hash'
import { alsTag } from '@/lib/finanzen/datum'
import { csvTextLesen, csvZuBuchungen, dublettenMarkieren } from '@/lib/finanzen/import'
import { camtZuBuchungen } from '@/lib/finanzen/camt'
import { eindeutigerVertrag } from '@/lib/finanzen/wiederkehrend'
import {
buchungSchema,
fehlerAuflisten,
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[]
}
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
// Alle vier Verweise auf einmal - der Vorgang war bisher der einzige,
// der ungeprueft durchging.
const verweisfehler = await verweisePruefen(betreuteId, {
konto: werte.kontoId,
vertrag: werte.vertragId,
dokument: werte.belegId,
vorgang: werte.vorgangId,
})
if (verweisfehler) return { fehler: verweisfehler }
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,
vertragId: werte.vertragId,
// Von Hand gesetzt heisst: nicht mehr "automatisch". Wer die
// Zuordnung anfasst, uebernimmt sie.
vertragAutomatisch: false,
belegFehltBegruendung: werte.belegId ? null : werte.belegFehltBegruendung,
betrifftBetreute: werte.betrifftBetreute,
vermoegenswirksam: werte.vermoegenswirksam,
...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)
// Vertraege einmal holen, nicht je Zeile. Zugeordnet wird nur, wo die
// Gegenstelle zu genau EINEM Vertrag passt - bei Mehrdeutigkeit
// geschieht nichts.
const vertraege = await db.vertrag.findMany({
where: { betreuteId, ...nichtGeloescht },
select: { id: true, bezeichnung: true, gesellschaft: true },
})
let angelegt = 0
let zugeordnet = 0
for (const zeile of anzulegen) {
const passenderVertrag = eindeutigerVertrag(zeile.gegenstelle, vertraege)
try {
await db.buchung.create({
data: {
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,
// Nur bei Ausgaben: Eine Gutschrift der Versicherung ist keine
// Praemienzahlung und darf nicht als "durch Vertrag belegt"
// gelten.
...(zeile.richtung === 'AUSGABE' && passenderVertrag
? { vertragId: passenderVertrag.id, vertragAutomatisch: true }
: {}),
...sealen('Buchung', { verwendungszweck: zeile.verwendungszweck }),
},
})
angelegt++
if (zeile.richtung === 'AUSGABE' && passenderVertrag) zugeordnet++
} catch (ausnahme) {
// Die Eindeutigkeit auf (kontoId, importReferenz) faengt Reste ab,
// die zwischen Lesen und Schreiben dazugekommen sind.
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}`),
...(zugeordnet > 0
? [
`${zugeordnet} Buchung(en) wurden automatisch einem Vertrag zugeordnet und gelten ` +
'damit als belegt. Sie sind in der Liste als automatisch zugeordnet erkennbar – ' +
'bitte einmal durchsehen.',
]
: []),
'Alle importierten Buchungen stehen zunächst in „Sonstige Einnahmen“ bzw. „Sonstige Ausgaben“. ' +
'Bitte die Kategorien nachtragen – danach stimmen die Berichte.',
]
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
// An der Verknuepfung zum Vertrag haengt dessen Rueckkaufswert - sie
// darf nicht in eine fremde Akte zeigen.
const verweisfehler = await verweisePruefen(betreuteId, {
konto: werte.kontoId,
vertrag: werte.vertragId,
immobilie: werte.immobilieId,
})
if (verweisfehler) return { fehler: verweisfehler }
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.' }
}
// -----------------------------------------------------------------------------
// Wiederkehrende Zahlungen
// -----------------------------------------------------------------------------
/**
* Hakt eine Gruppe wiederkehrender Zahlungen als "kein Vertrag" ab.
*
* Der Schluessel kommt aus normalisieren() und wird hier NICHT noch einmal
* gebildet: Was der Benutzer auf dem Bildschirm gesehen hat, soll auch
* abgehakt werden. Bildeten Anzeige und Aktion den Schluessel getrennt,
* koennten sie auseinanderlaufen - und dann verschwaende eine andere
* Gruppe als die angeklickte.
*/
export async function zahlungsgruppeAbhaken(
_zustand: Formularzustand,
daten: FormData,
): Promise<Formularzustand> {
const betreuteId = String(daten.get('betreuteId') ?? '')
const schluessel = String(daten.get('schluessel') ?? '').trim()
const anzeigename = String(daten.get('anzeigename') ?? '').trim()
const begruendung = String(daten.get('begruendung') ?? '').trim()
if (!schluessel || !anzeigename) {
return { fehler: 'Diese Zahlungsgruppe lässt sich nicht zuordnen.' }
}
try {
const { benutzer } = await erfordereZugang(betreuteId, 'FINANZEN', 'schreiben')
await db.zahlungsgruppeGeklaert.upsert({
where: { betreuteId_schluessel: { betreuteId, schluessel } },
create: {
betreuteId,
schluessel,
anzeigename,
begruendung: begruendung === '' ? null : begruendung,
},
update: { begruendung: begruendung === '' ? null : begruendung },
})
await protokollieren({
aktion: 'ERSTELLT',
entitaet: 'ZahlungsgruppeGeklaert',
bezeichnung: `${anzeigename} — kein Vertrag`,
betreuteId,
userId: benutzer.id,
userEmail: benutzer.email,
userName: benutzer.name,
})
} catch (ausnahme) {
const verweigert = zugriffsfehler(ausnahme)
if (verweigert) return verweigert
console.error('[finanzen] Zahlungsgruppe konnte nicht abgehakt werden:', ausnahme)
return { fehler: 'Die Zahlungsgruppe konnte nicht abgehakt werden.' }
}
revalidatePath('/finanzen/wiederkehrend')
revalidatePath('/heute')
return { erfolg: `„${anzeigename}“ gilt als geklärt.` }
}
/** Holt eine abgehakte Gruppe zurueck in die Liste. */
export async function zahlungsgruppeZurueckholen(
_zustand: Formularzustand,
daten: FormData,
): Promise<Formularzustand> {
const betreuteId = String(daten.get('betreuteId') ?? '')
const eintragId = String(daten.get('eintragId') ?? '')
try {
const { benutzer } = await erfordereZugang(betreuteId, 'FINANZEN', 'schreiben')
// Zeilenweise Pruefung: Die ID muss zu DIESER betreuten Person gehoeren.
const eintrag = await db.zahlungsgruppeGeklaert.findFirst({
where: { id: eintragId, betreuteId },
select: { id: true, anzeigename: true },
})
if (!eintrag) return { fehler: 'Diesen Eintrag gibt es nicht (mehr).' }
await db.zahlungsgruppeGeklaert.delete({ where: { id: eintrag.id } })
await protokollieren({
aktion: 'GELOESCHT',
entitaet: 'ZahlungsgruppeGeklaert',
entitaetId: eintrag.id,
bezeichnung: `${eintrag.anzeigename} — wieder offen`,
betreuteId,
userId: benutzer.id,
userEmail: benutzer.email,
userName: benutzer.name,
})
} catch (ausnahme) {
const verweigert = zugriffsfehler(ausnahme)
if (verweigert) return verweigert
console.error('[finanzen] Zahlungsgruppe konnte nicht zurückgeholt werden:', ausnahme)
return { fehler: 'Der Eintrag konnte nicht zurückgeholt werden.' }
}
revalidatePath('/finanzen/wiederkehrend')
revalidatePath('/heute')
return { erfolg: 'Die Gruppe steht wieder in der Liste.' }
}
/**
* Ordnet mehrere Buchungen auf einmal einem Vertrag zu.
*
* Der Weg dorthin fuehrt ueber "Wiederkehrende Zahlungen": Dort steht eine
* Gruppe gleichartiger Abbuchungen beisammen, und wenn dazu ein Vertrag in
* der Akte liegt, sind es genau die Buchungen, die er belegt. Sie einzeln
* anzuklicken waere die Arbeit, die dieses Issue abschaffen soll.
*
* Die IDs kommen aus dem Formular und werden NICHT geglaubt: Geschrieben
* wird nur, was zu dieser betreuten Person gehoert.
*/
export async function zahlungenVertragZuordnen(
_zustand: Formularzustand,
daten: FormData,
): Promise<Formularzustand> {
const betreuteId = String(daten.get('betreuteId') ?? '')
const vertragId = String(daten.get('vertragId') ?? '')
const buchungIds = String(daten.get('buchungIds') ?? '')
.split(',')
.map((id) => id.trim())
.filter(Boolean)
if (!vertragId || buchungIds.length === 0) {
return { fehler: 'Es ist nichts zum Zuordnen ausgewählt.' }
}
try {
const { benutzer } = await erfordereZugang(betreuteId, 'FINANZEN', 'schreiben')
const vertrag = await db.vertrag.findFirst({
where: { id: vertragId, betreuteId, ...nichtGeloescht },
select: { id: true, bezeichnung: true },
})
if (!vertrag) return { fehler: 'Diesen Vertrag gibt es nicht (mehr).' }
// betreuteId im WHERE ist der eigentliche Schutz: Eine fremde
// Buchungs-ID trifft dann auf null Zeilen.
const { count } = await db.buchung.updateMany({
where: { id: { in: buchungIds }, betreuteId, ...nichtGeloescht },
data: { vertragId: vertrag.id, vertragAutomatisch: false },
})
await protokollieren({
aktion: 'GEAENDERT',
entitaet: 'Buchung',
bezeichnung: `${count} Buchung(en) als Dauerzahlung zu „${vertrag.bezeichnung}“`,
betreuteId,
userId: benutzer.id,
userEmail: benutzer.email,
userName: benutzer.name,
})
revalidatePath('/finanzen')
revalidatePath('/finanzen/buchungen')
revalidatePath('/finanzen/wiederkehrend')
revalidatePath('/heute')
return { erfolg: `${count} Buchung(en) gelten jetzt als durch den Vertrag belegt.` }
} catch (ausnahme) {
const verweigert = zugriffsfehler(ausnahme)
if (verweigert) return verweigert
console.error('[finanzen] Sammelzuordnung fehlgeschlagen:', ausnahme)
return { fehler: 'Die Zuordnung konnte nicht gespeichert werden.' }
}
}