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
1019 lines
36 KiB
TypeScript
1019 lines
36 KiB
TypeScript
'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.' }
|
||
}
|
||
}
|