Files
betreuungs-cockpit/src/server/actions/finanzen.ts
T
Florian Wilde 09a4528c4b Rueckkaufswert am Vertrag sichtbar machen
Am Telefon mit der Gesellschaft braucht man die Zahl dort, wo man sie
gefragt hat. Auf der Vertragsseite steht jetzt oben der zuletzt
mitgeteilte Wert mit Stichtag und Quelle, darunter die Historie; in der
Uebersicht traegt ein Wert, der aelter als ein Jahr ist, ein Abzeichen.

Ohne Migration, und das ist keine Einsparung, sondern der Punkt: Die
Werthistorie gibt es schon als Wertstand am Vermoegensposten, und der
kann auf einen Vertrag zeigen. Ein Feld am Vertrag waere genau die
zweite Wahrheit, die das Issue ausschliesst - dieselbe Zahl steht im
Vermoegensverzeichnis, weil es dieselbe Zahl ist.

Aufbau:
- Die Ein-Jahres-Grenze als eine getestete Regel in
  finanzen/wertalter.ts. Sie stand bisher als "> 365" mitten in der
  Vermoegensseite; jetzt gilt sie am Vertrag genauso.
- wertHistorie() fuehrt die Wertstaende aller mit dem Vertrag
  verknuepften Posten zu einer Reihe zusammen.
- Wo ein Rueckkaufswert zu erwarten waere, aber keiner erfasst ist,
  steht "noch nicht erfasst" statt einer leeren Zelle.

Zwei Fehler dabei behoben:
- Vertrag und Vermoegensposten wurden ueber die ART des Postens
  zusammengefuehrt. Ein von Hand als "Sonstiges" angelegter Posten wurde
  nicht gefunden, und beim ersten Erfassen am Vertrag entstand still ein
  zweiter - Bericht und Vertrag haetten verschiedene Zahlen gezeigt.
  Massgeblich ist jetzt die Verknuepfung.
- Beim Speichern eines Vermoegenspostens wurde geprueft, ob das
  verknuepfte Konto zur betreuten Person gehoert; bei Vertrag und
  Immobilie fehlte die Pruefung, beim Vertragsverweis einer Buchung
  ebenso. Bei mehreren betreuten Personen haette ein manipuliertes
  Formular ueber Aktengrenzen hinweg verknuepfen koennen.

Geprueft: zehn Einheitstests, vier End-to-End-Tests - darunter der neu
eingetragene Wert, der unveraendert im Vermoegensverzeichnis
wiederauftaucht, und der Posten mit abweichender Art.

Refs #8
2026-08-23 15:06:25 +02:00

1052 lines
37 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 { 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
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<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
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<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.' }
}
}