'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 { 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 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 { 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 { 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 { 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 { 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 { 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.' } } // Wie beim Konto: Der Vertrag muss zu dieser Akte gehoeren. if (werte.vertragId) { const vertrag = await db.vertrag.findFirst({ where: { id: werte.vertragId, betreuteId, ...nichtGeloescht }, select: { id: true }, }) if (!vertrag) return { fehler: 'Dieser Vertrag gehört nicht zu dieser betreuten Person.' } } if (werte.belegId) { const beleg = await db.dokument.findFirst({ where: { id: werte.belegId, betreuteId, ...nichtGeloescht }, 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, 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 { 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 { 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(' `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 { 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.' } } // Dieselbe Pruefung fuer die beiden anderen Verweise. Sie fehlte: // An der Verknuepfung zum Vertrag haengt dessen Rueckkaufswert, und // die darf nicht in eine fremde Akte zeigen. if (werte.vertragId) { const vertrag = await db.vertrag.findFirst({ where: { id: werte.vertragId, betreuteId, ...nichtGeloescht }, select: { id: true }, }) if (!vertrag) return { fehler: 'Dieser Vertrag gehört nicht zu dieser betreuten Person.' } } if (werte.immobilieId) { const immobilie = await db.immobilie.findFirst({ where: { id: werte.immobilieId, betreuteId, ...nichtGeloescht }, select: { id: true }, }) if (!immobilie) { return { fehler: 'Diese Immobilie gehört nicht zu dieser betreuten Person.' } } } const felder = { bezeichnung: werte.bezeichnung, art: werte.art, 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 { 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 { 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 { 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 { 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 { 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 { 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.' } } }