import { z } from 'zod' import { betragParsen, BetragFehler } from '@/lib/finanzen/betrag' import { tagAusText } from '@/lib/finanzen/datum' import { bicGueltig, ibanNormalisieren, ibanPruefen } from '@/lib/finanzen/iban' /** * Eingabepruefung fuer Konten, Buchungen und Vermoegensposten. * * Alles laeuft serverseitig; das Formular im Browser ist Bequemlichkeit. * Betraege werden hier - und nur hier - aus Text zu Cent. Danach gibt es * im ganzen Modul keine Zeichenkette mehr, die Geld bedeutet. */ const KONTO_ARTEN_WERTE = [ 'GIRO', 'SPAR', 'TAGESGELD', 'FESTGELD', 'DEPOT', 'VERWAHRGELD_HEIM', 'BARKASSE', 'SONSTIGES', ] as const const RICHTUNGEN_WERTE = ['EINNAHME', 'AUSGABE', 'UMBUCHUNG'] as const const KATEGORIEN_WERTE = [ 'RENTE', 'PFLEGEKASSE', 'SOZIALHILFE', 'LANDESPFLEGEGELD', 'ZINSEN', 'MIETEINNAHME', 'SONSTIGE_EINNAHME', 'HEIMKOSTEN', 'BARBETRAG', 'BEKLEIDUNG', 'ARZT_UND_MEDIKAMENTE', 'ZUZAHLUNG', 'VERSICHERUNG', 'IMMOBILIE_BETRIEBSKOSTEN', 'IMMOBILIE_INSTANDHALTUNG', 'GRUNDSTEUER', 'STEUERN', 'GEBUEHREN_GERICHT', 'PORTO_UND_KOPIEN', 'FAHRTKOSTEN', 'TELEFON_INTERNET', 'RUNDFUNKBEITRAG', 'BESTATTUNGSVORSORGE', 'BANKGEBUEHREN', 'SONSTIGE_AUSGABE', 'UMBUCHUNG', ] as const const VERMOEGENS_ARTEN_WERTE = [ 'KONTO', 'IMMOBILIE', 'VERSICHERUNG', 'FAHRZEUG', 'WERTGEGENSTAND', 'FORDERUNG', 'VERBINDLICHKEIT', 'SONSTIGES', ] as const const BEWERTUNGEN_WERTE = ['UNGEPRUEFT', 'VERWERTBAR', 'GESCHUETZT', 'STRITTIG'] as const // ----------------------------------------------------------------------------- // Bausteine // ----------------------------------------------------------------------------- const optionalerText = (max = 200) => z .string() .trim() .max(max, `Bitte höchstens ${max} Zeichen.`) .transform((wert) => (wert === '' ? null : wert)) .nullable() .default(null) const optionaleId = z .string() .trim() .transform((wert) => (wert === '' ? null : wert)) .nullable() .default(null) const optionalesDatum = z .string() .trim() .default('') .transform((wert, ctx) => { if (wert === '') return null const datum = tagAusText(wert) if (!datum) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Bitte ein gültiges Datum wählen.' }) return z.NEVER } return datum }) const pflichtDatum = z .string({ required_error: 'Bitte ein Datum angeben.' }) .trim() .transform((wert, ctx) => { const datum = tagAusText(wert) if (!datum) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Bitte ein Datum angeben.' }) return z.NEVER } return datum }) /** Ein Formularfeld mit Geldbetrag wird hier zu Cent - und nie woanders. */ const betragFeld = (optionen: { nurPositiv?: boolean } = {}) => z .string({ required_error: 'Bitte einen Betrag angeben.' }) .trim() .transform((wert, ctx) => { try { const cent = betragParsen(wert) if (optionen.nurPositiv && cent <= 0n) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Bitte einen Betrag größer als 0,00 € angeben. Die Richtung wählst du daneben.', }) return z.NEVER } return cent } catch (ausnahme) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: ausnahme instanceof BetragFehler ? ausnahme.message : 'Bitte einen Betrag im Format 1.234,56 angeben.', }) return z.NEVER } }) /** Checkbox: vorhanden heisst an, fehlend heisst aus. */ const schalter = z .string() .optional() .transform((wert) => wert === 'on' || wert === 'true' || wert === '1' || wert === 'ja') // ----------------------------------------------------------------------------- // Konto // ----------------------------------------------------------------------------- export const kontoSchema = z .object({ bezeichnung: z .string() .trim() .min(1, 'Bitte einen Namen für das Konto angeben, z.B. „Girokonto Sparkasse“.') .max(120, 'Bitte höchstens 120 Zeichen.'), bank: optionalerText(120), art: z.enum(KONTO_ARTEN_WERTE, { message: 'Bitte eine Kontoart wählen.' }), iban: z .string() .trim() .default('') .transform((wert, ctx) => { if (wert === '') return null const pruefung = ibanPruefen(wert) if (!pruefung.gueltig) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: pruefung.meldung }) return z.NEVER } return pruefung.iban }), bic: z .string() .trim() .default('') .transform((wert, ctx) => { if (wert === '') return null if (!bicGueltig(wert)) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Ein BIC hat 8 oder 11 Stellen, z.B. BYLADEM1SBT.', }) return z.NEVER } return wert.replace(/\s+/g, '').toUpperCase() }), istBetreuerkonto: schalter, eroeffnetAm: optionalesDatum, geschlossenAm: optionalesDatum, }) .superRefine((werte, ctx) => { if (werte.eroeffnetAm && werte.geschlossenAm && werte.geschlossenAm < werte.eroeffnetAm) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['geschlossenAm'], message: 'Das Konto kann nicht vor seiner Eröffnung geschlossen worden sein.', }) } }) export type KontoEingabe = z.infer export const saldostandSchema = z.object({ kontoId: z.string().trim().min(1, 'Bitte ein Konto wählen.'), stichtag: pflichtDatum, betrag: betragFeld(), quelle: optionalerText(160), }) export type SaldostandEingabe = z.infer // ----------------------------------------------------------------------------- // Buchung // ----------------------------------------------------------------------------- export const buchungSchema = z .object({ kontoId: optionaleId, vorgangId: optionaleId, datum: pflichtDatum, wertstellung: optionalesDatum, betrag: betragFeld({ nurPositiv: true }), richtung: z.enum(RICHTUNGEN_WERTE, { message: 'Bitte Einnahme, Ausgabe oder Umbuchung wählen.' }), kategorie: z.enum(KATEGORIEN_WERTE, { message: 'Bitte eine Kategorie wählen.' }), gegenstelle: optionalerText(160), verwendungszweck: optionalerText(500), belegId: optionaleId, belegFehltBegruendung: optionalerText(300), vertragId: optionaleId, betrifftBetreute: schalter, vermoegenswirksam: schalter, }) .superRefine((werte, ctx) => { if (werte.richtung === 'UMBUCHUNG' && werte.kategorie !== 'UMBUCHUNG') { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['kategorie'], message: 'Eine Umbuchung bekommt die Kategorie „Umbuchung zwischen eigenen Konten“.', }) } if (werte.richtung !== 'UMBUCHUNG' && werte.kategorie === 'UMBUCHUNG') { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['kategorie'], message: 'Die Kategorie „Umbuchung“ gehört zur Richtung Umbuchung.', }) } }) export type BuchungEingabeFormular = z.infer /** Filter der Buchungsliste - alle Felder optional, alles aus der URL. */ export const buchungsfilterSchema = z.object({ kontoId: optionaleId, kategorie: z .string() .trim() .default('') .transform((wert) => (wert === '' ? null : wert)) .pipe(z.enum(KATEGORIEN_WERTE).nullable()), richtung: z .string() .trim() .default('') .transform((wert) => (wert === '' ? null : wert)) .pipe(z.enum(RICHTUNGEN_WERTE).nullable()), von: optionalesDatum, bis: optionalesDatum, suche: optionalerText(120), nurOhneBeleg: schalter, nurEigeneAuslagen: schalter, }) export type Buchungsfilter = z.infer // ----------------------------------------------------------------------------- // Vermoegensposten und Wertstand // ----------------------------------------------------------------------------- export const vermoegenspostenSchema = z.object({ bezeichnung: z .string() .trim() .min(1, 'Bitte den Posten benennen, z.B. „Hausgrundstück Bayreuther Str. 4“.') .max(160, 'Bitte höchstens 160 Zeichen.'), art: z.enum(VERMOEGENS_ARTEN_WERTE, { message: 'Bitte eine Art wählen.' }), kontoId: optionaleId, immobilieId: optionaleId, vertragId: optionaleId, bewertungSozialhilfe: z.enum(BEWERTUNGEN_WERTE).default('UNGEPRUEFT'), bewertungBegruendung: optionalerText(1000), notizen: optionalerText(1000), }) export type VermoegenspostenEingabe = z.infer /** * Der Wertstand. wertquelle ist Pflicht - ohne Herkunftsangabe ist eine * Zahl im Vermoegensverzeichnis wertlos, weil sich beim Nachfragen nicht * mehr klaeren laesst, woher sie stammt. */ export const wertstandSchema = z.object({ vermoegenspostenId: z.string().trim().min(1, 'Bitte einen Posten wählen.'), stichtag: pflichtDatum, wert: betragFeld(), wertquelle: z .string() .trim() .min( 3, 'Bitte angeben, woher der Wert stammt – z.B. „Rückkaufswertmitteilung vom 12.03.2026“ oder „Bodenrichtwert“.', ) .max(200, 'Bitte höchstens 200 Zeichen.'), nachweisDokumentId: optionaleId, bemerkung: optionalerText(500), }) export type WertstandEingabeFormular = z.infer // ----------------------------------------------------------------------------- // Berichte // ----------------------------------------------------------------------------- export const vermoegensverzeichnisSchema = z.object({ stichtag: pflichtDatum, monateRueckblick: z .string() .trim() .default('12') .transform((wert, ctx) => { const zahl = Number.parseInt(wert === '' ? '12' : wert, 10) if (!Number.isFinite(zahl) || zahl < 1 || zahl > 60) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Der Rückblick für die laufenden Einnahmen liegt zwischen 1 und 60 Monaten.', }) return z.NEVER } return zahl }), }) export const rechnungslegungSchema = z.object({ jahr: z .string() .trim() .transform((wert, ctx) => { const zahl = Number.parseInt(wert, 10) if (!Number.isInteger(zahl) || zahl < 1) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Bitte ein Betreuungsjahr wählen.' }) return z.NEVER } return zahl }), variante: z.enum(['UEBERSICHT_1859', 'VOLL_1865'], { message: 'Bitte eine Variante wählen.', }), }) // ----------------------------------------------------------------------------- // Import // ----------------------------------------------------------------------------- export const importSchema = z.object({ kontoId: z.string().trim().min(1, 'Bitte das Konto wählen, zu dem die Datei gehört.'), betrifftBetreute: schalter, /** Sollen erkannte Dubletten trotzdem angelegt werden? Standard: nein. */ dublettenUebernehmen: schalter, }) // ----------------------------------------------------------------------------- // Hilfen fuer die Server Actions // ----------------------------------------------------------------------------- /** FormData in ein einfaches Objekt - Zod erwartet keine FormData. */ export function formularZuObjekt(daten: FormData): Record { const objekt: Record = {} for (const [schluessel, wert] of daten.entries()) { if (typeof wert === 'string') objekt[schluessel] = wert } return objekt } /** Zod-Fehler in die Form, die erwartet. */ export function fehlerAuflisten(fehler: z.ZodError): Record { const felder: Record = {} for (const ausgabe of fehler.issues) { const feld = ausgabe.path.join('.') || 'formular' if (!felder[feld]) felder[feld] = ausgabe.message } return felder } /** Fuer die Anzeige einer bereits gespeicherten IBAN im Formular. */ export function ibanFuerFormular(iban: string | null | undefined): string { return ibanNormalisieren(iban) }