Bei einer monatlichen Praemie gibt es keinen Beleg - es gibt einen Vertrag, und der wird einmal abgeschlossen, nicht monatlich quittiert. Bisher blieb nur ein Freitextfeld pro Buchung: zwoelfmal im Jahr derselbe Satz, mal Anzahl der Vertraege. Buchung.vertragId mit onDelete: SetNull. Der Belegzustand kennt jetzt drei Faelle statt zwei, getrennt ausgewiesen - ein Vertrag belegt, eine Begruendung erklaert nur; zusammengeworfen verliert die Belegquote ihre Aussage gegenueber dem Rechtspfleger. Dazu: automatische Zuordnung beim Import bei eindeutiger Gegenstelle, Sammelzuordnung ueber die Gruppen aus Issue #3, und die Rechnungslegung nennt den Vertrag samt Nummer statt "ohne Beleg". Die Sicherung dagegen: Weicht der Betrag um mehr als ein Fuenftel vom hinterlegten Beitrag ab, faellt die Anerkennung weg und die Buchung wird als "Betrag pruefen" markiert. Sonst waere die Zuordnung eine Hintertuer. Zur Toleranz - ein Entwurf und seine Korrektur: Zuerst verglich ich den Betrag gegen alle ueblichen Zahlweisen, damit es auch passt, wenn die Bank anders abbucht als der Vertrag es fuehrt. Der eigene Test hat das widerlegt: Bei 42,50 monatlich liegen 420 innerhalb von 20 Prozent des Jahresbetrags von 510 - ausgerechnet der Fall aus der Aufgabenstellung waere durchgerutscht. Jetzt wird stur gegen den hinterlegten Beitrag verglichen. Bucht die Bank anders ab, stimmen die Vertragsdaten nicht, und das soll auffallen. Behoben: Die Buchungsliste hatte eine eigene, fast gleiche Datenabfrage neben der aus berichte/gemeinsam.ts. Nur eine von beiden wurde nachgezogen, in der Liste stand weiter "Beleg fehlt". Beide benutzen jetzt BUCHUNG_EINBEZUG und zuBuchungEingabe - das kann nicht halb vergessen werden. Gefunden hat es der Browsertest. Nebenbei: Die Roadmap trug noch "Aktuelle Fassung 1.1.2" - Nachwirkung der parallelen Bearbeitung von #3 und #10. Nachgezogen wie zugesagt. Geprueft: 23 neue Einheitstests, die Haelfte davon darauf gerichtet, dass die Anerkennung auch wirklich wegfaellt. Fuenf End-to-End-Tests auf beiden Geraeteprofilen. 560 Unit-Tests, 63 e2e, null Typfehler. Refs #11
1022 lines
36 KiB
TypeScript
1022 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 { 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.' }
|
||
}
|
||
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.' }
|
||
}
|
||
|
||
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.' }
|
||
}
|
||
}
|