feat: Stammdaten, Ersteinrichtung, Zweitfaktor und Protokollansicht

Damit ist Etappe 1 fachlich vollstaendig.

Ersteinrichtung in drei Schritten, bewusst ausserhalb des
Anwendungsrahmens: der setzt Benutzer, zweiten Faktor UND eine betreute
Person voraus - beim allerersten Start gibt es nichts davon. Die Aktion
fuer den ersten Zugang sperrt sich selbst, sobald ein Benutzer existiert.

Zweiter Faktor: Das Geheimnis wird zuerst nur abgelegt, ohne
totpAktiviertAm. Erst ein echter Code aus der App schaltet ihn scharf.
Wer sich beim Abtippen vertut oder die falsche App nimmt, wuerde sich
sonst dauerhaft aussperren. Erst danach gibt es die
Wiederherstellungscodes - einmal angezeigt, danach nur noch als Hash.

Betreuungsjahr (src/lib/stammdaten/betreuungsjahr.ts): laeuft ab dem Tag
der Bestellung, nicht ab dem Kalenderjahr. Schrittweise vorwaerts
gerechnet statt ueber eine Differenz, damit Schaltjahre und der
29. Februar von selbst stimmen. Daran haengen Jahresbericht,
Rechnungslegung und die Ausschlussfrist der Aufwandspauschale.

Aendert sich das Bestellungsdatum, werden die Pflichtfristen neu
abgeleitet - ueber einen dynamischen Import, damit die Stammdaten auch
dann speicherbar bleiben, wenn das Fristenmodul fehlt.

Protokollansicht zeigt je Eintrag die geaenderten Felder als alt -> neu.
Die Aenderungen liegen verschluesselt, weil dort Diagnosen und
Kontostaende stehen koennen.

Seed legt bewusst keine echten Personendaten an, nur den ersten Zugang.
Mit SEED_DEMO=true kommt ein vollstaendiger Beispielfall dazu - Erika
Musterfrau, COPD GOLD III, Pflegegrad 3, Heimeinzug Januar 2026,
leerstehendes Haus, zwei Versicherungen, laufender Antrag beim Bezirk
mit acht Nachforderungen. Nur ausserhalb der Produktion.

Docker Compose ohne Reverse Proxy - der ist auf dem Zielserver
vorhanden. Die Datenbank hat keine Portfreigabe und ist nur ueber das
interne Netz erreichbar; die Anwendung horcht auf 127.0.0.1.

Etappe: 1
Phase: Fundament
This commit is contained in:
Florian Wilde
2026-08-22 02:16:22 +02:00
parent dcd3a8ce71
commit 22b3a9b9e9
134 changed files with 30753 additions and 17 deletions
+5 -1
View File
@@ -156,7 +156,11 @@ export const VERSCHLUESSELTE_FELDER = {
Buchung: ['verwendungszweckVerschluesselt'],
Vermoegensposten: ['bewertungBegruendungVerschluesselt', 'notizenVerschluesselt'],
Immobilie: ['statusBegruendungVerschluesselt', 'notizenVerschluesselt'],
Vertrag: ['bewertungBegruendungVerschluesselt', 'notizenVerschluesselt'],
Vertrag: [
'vertragsnummerVerschluesselt',
'bewertungBegruendungVerschluesselt',
'notizenVerschluesselt',
],
Kontakt: ['notizenVerschluesselt'],
Kontaktprotokoll: [
'inhaltVerschluesselt',
+383
View File
@@ -0,0 +1,383 @@
import { differenceInCalendarDays, endOfMonth, endOfWeek, startOfDay } from 'date-fns'
import type { FristTyp, NachforderungStatus, VermoegensArt, VorgangKategorie } from '@prisma/client'
import type {
Ampelstufe,
FristEintrag,
FristenAmpelDaten,
NachforderungEintrag,
NachforderungenDaten,
WartendEintrag,
WartetAufAndereDaten,
} from '@/lib/dashboard/typen'
/**
* Die Rechenregeln hinter dem Dashboard - ohne Datenbank, ohne React.
*
* Alles, was hier steht, laesst sich in tests/dashboard.test.ts pruefen.
* laden.ts holt nur die Datensaetze und reicht sie hier durch; die
* Bewertung "ist das dringend?" faellt an genau einer Stelle.
*/
/** Ab so vielen Tagen ohne Rueckmeldung wird eine Sachstandsanfrage faellig. */
export const WARTEZEIT_DRINGEND_TAGE = 21
/** Wie viele Ausschlussfristen zeigt die Warnung hoechstens? */
const AUSSCHLUSSFRISTEN_MAX = 3
/**
* Datumsfelder ohne Uhrzeit (@db.Date) kommen als UTC-Mitternacht aus der
* Datenbank. Wer sie unbesehen mit einem lokalen "heute" vergleicht,
* verschiebt eine Frist um einen Tag. Deshalb: auf den lokalen Tagesanfang
* ziehen, und zwar anhand der UTC-Bestandteile.
*/
export function tagAusDatenbank(datum: Date): Date {
return new Date(datum.getUTCFullYear(), datum.getUTCMonth(), datum.getUTCDate())
}
/** Tage von heute bis zum Datum. Negativ = liegt in der Vergangenheit. */
export function tageBisTag(datum: Date, heute: Date): number {
return differenceInCalendarDays(datum, startOfDay(heute))
}
// -----------------------------------------------------------------------------
// 1. Fristen-Ampel
// -----------------------------------------------------------------------------
export type FristRoh = {
id: string
titel: string
datum: Date
typ: FristTyp
rechtsgrundlage: string | null
ausschlussfrist: boolean
vorgangId: string | null
}
/**
* Konventionen aus docs/KONVENTIONEN.md:
* rot = ueberfaellig, orange = diese Woche, gelb = dieser Monat,
* gruen = weiter in der Zukunft.
*
* "Diese Woche" endet am Sonntag, "dieser Monat" am Monatsletzten - nicht
* "in 7 Tagen" bzw. "in 30 Tagen". Wer montags auf das Dashboard schaut,
* will wissen, was bis Sonntag liegen bleibt.
*/
export function fristStufe(datum: Date, heute: Date): Ampelstufe {
const tag = startOfDay(datum)
const heuteTag = startOfDay(heute)
if (tag < heuteTag) return 'rot'
if (tag <= endOfWeek(heuteTag, { weekStartsOn: 1 })) return 'orange'
if (tag <= endOfMonth(heuteTag)) return 'gelb'
return 'gruen'
}
/** "seit 3 Tagen ueberfaellig" - der Text, der neben der Farbe stehen muss. */
export function fristLage(tage: number): string {
if (tage < -1) return `seit ${Math.abs(tage)} Tagen überfällig`
if (tage === -1) return 'seit gestern überfällig'
if (tage === 0) return 'heute fällig'
if (tage === 1) return 'morgen fällig'
return `in ${tage} Tagen fällig`
}
/**
* Die Verfallwarnung. Eine Ausschlussfrist kennt keine Nachsicht: laeuft
* sie ab, ist der Anspruch weg - bei der Aufwandspauschale nach § 1878 BGB
* z.B. das ganze Jahresgeld.
*/
export function verfallwarnung(frist: { tage: number; rechtsgrundlage: string | null }): string {
const grundlage = frist.rechtsgrundlage ? `${frist.rechtsgrundlage}: ` : ''
if (frist.tage < 0) {
const tage = Math.abs(frist.tage)
return (
`${grundlage}Diese Ausschlussfrist ist seit ${tage} ${tage === 1 ? 'Tag' : 'Tagen'} ` +
`abgelaufen. Läuft der Anspruch ab, ist er ersatzlos weg. Bitte umgehend beim Gericht ` +
`bzw. bei der Behörde nachfragen.`
)
}
return (
`${grundlage}Ausschlussfrist – keine Verlängerung, keine Nachfrist. ` +
`Läuft der Anspruch ab, ist er ersatzlos weg.`
)
}
export function fristenEinordnen(fristen: FristRoh[], heute: Date): FristenAmpelDaten {
const eintraege: FristEintrag[] = fristen.map((frist) => {
const datum = tagAusDatenbank(frist.datum)
const tage = tageBisTag(datum, heute)
return {
id: frist.id,
titel: frist.titel,
datum,
typ: frist.typ,
rechtsgrundlage: frist.rechtsgrundlage,
ausschlussfrist: frist.ausschlussfrist,
vorgangId: frist.vorgangId,
tage,
stufe: fristStufe(datum, heute),
}
})
// Frueheste zuerst - damit steht die aelteste ueberfaellige ganz oben.
eintraege.sort((a, b) => a.datum.getTime() - b.datum.getTime())
return {
ueberfaellig: eintraege.filter((e) => e.stufe === 'rot'),
dieseWoche: eintraege.filter((e) => e.stufe === 'orange'),
diesenMonat: eintraege.filter((e) => e.stufe === 'gelb'),
weitereOffen: eintraege.filter((e) => e.stufe === 'gruen').length,
ausschlussfristen: eintraege.filter((e) => e.ausschlussfrist).slice(0, AUSSCHLUSSFRISTEN_MAX),
gesamtOffen: eintraege.length,
}
}
// -----------------------------------------------------------------------------
// 2. Wartet auf andere
// -----------------------------------------------------------------------------
export type WartendRoh = {
id: string
titel: string
kategorie: VorgangKategorie
gegenstelle: string | null
aktenzeichen: string | null
naechsterSchritt: string | null
letzterKontaktAm: Date | null
erstelltAm: Date
}
/**
* Sortiert nach Tagen seit dem letzten Kontakt, laengste Wartezeit zuerst.
* Ist nie ein Kontakt vermerkt worden, zaehlt das Anlegedatum - sonst
* rutschte ausgerechnet der Vorgang nach unten, um den sich noch nie
* jemand gekuemmert hat.
*/
export function wartendeEinordnen(vorgaenge: WartendRoh[], heute: Date): WartetAufAndereDaten {
const eintraege: WartendEintrag[] = vorgaenge.map((vorgang) => {
const bezug = vorgang.letzterKontaktAm
? tagAusDatenbank(vorgang.letzterKontaktAm)
: startOfDay(vorgang.erstelltAm)
const tageOhneRueckmeldung = Math.max(0, -tageBisTag(bezug, heute))
return {
id: vorgang.id,
titel: vorgang.titel,
kategorie: vorgang.kategorie,
gegenstelle: vorgang.gegenstelle,
aktenzeichen: vorgang.aktenzeichen,
naechsterSchritt: vorgang.naechsterSchritt,
letzterKontaktAm: vorgang.letzterKontaktAm
? tagAusDatenbank(vorgang.letzterKontaktAm)
: null,
tageOhneRueckmeldung,
dringend: tageOhneRueckmeldung >= WARTEZEIT_DRINGEND_TAGE,
}
})
eintraege.sort((a, b) => b.tageOhneRueckmeldung - a.tageOhneRueckmeldung)
return {
eintraege,
gesamt: eintraege.length,
dringende: eintraege.filter((e) => e.dringend).length,
}
}
// -----------------------------------------------------------------------------
// 3. Nachforderungen
// -----------------------------------------------------------------------------
/** Diese Zustaende sind aus meiner Sicht abgearbeitet. */
const NACHFORDERUNG_ERLEDIGT: NachforderungStatus[] = [
'UEBERSANDT',
'ANERKANNT',
'NICHT_VORHANDEN',
]
export type NachforderungRoh = {
id: string
bezeichnung: string
status: NachforderungStatus
fristBis: Date | null
reihenfolge: number
antragId: string
antragTraeger: string | null
antragAktenzeichen: string | null
}
export function nachforderungenAuswerten(
zeilen: NachforderungRoh[],
heute: Date,
): NachforderungenDaten {
const offenRoh = zeilen.filter((z) => !NACHFORDERUNG_ERLEDIGT.includes(z.status))
const offen: NachforderungEintrag[] = offenRoh.map((zeile) => {
const fristBis = zeile.fristBis ? tagAusDatenbank(zeile.fristBis) : null
return {
id: zeile.id,
bezeichnung: zeile.bezeichnung,
status: zeile.status,
fristBis,
tageBisFrist: fristBis ? tageBisTag(fristBis, heute) : null,
}
})
// Zuerst, was eine Frist hat, und davon das Dringendste.
offen.sort((a, b) => {
if (a.tageBisFrist === null && b.tageBisFrist === null) return 0
if (a.tageBisFrist === null) return 1
if (b.tageBisFrist === null) return -1
return a.tageBisFrist - b.tageBisFrist
})
const erster = zeilen[0]
return {
antragId: erster?.antragId ?? null,
traeger: erster?.antragTraeger ?? null,
aktenzeichen: erster?.antragAktenzeichen ?? null,
gesamt: zeilen.length,
erledigt: zeilen.filter((z) => NACHFORDERUNG_ERLEDIGT.includes(z.status)).length,
offen,
ueberfaellig: offen.filter((o) => o.tageBisFrist !== null && o.tageBisFrist < 0).length,
}
}
// -----------------------------------------------------------------------------
// 6. Kennzahlen: Vermoegen und Heimkosten
// -----------------------------------------------------------------------------
export type PostenRoh = {
id: string
art: VermoegensArt
wertstaende: { stichtag: Date; wertCent: bigint }[]
}
export type VermoegenslageRoh = {
stichtag: Date | null
aktivaCent: bigint
verbindlichkeitenCent: bigint
nettoCent: bigint
postenGesamt: number
postenOhneWert: number
}
/**
* Vermoegen zum letzten Stichtag: je Posten zaehlt der juengste Wertstand,
* Verbindlichkeiten werden abgezogen.
*
* Der ausgewiesene Stichtag ist der juengste tatsaechlich verwendete - die
* Posten koennen zu unterschiedlichen Terminen bewertet worden sein, und
* genau das muss die Kachel sagen duerfen.
*/
export function vermoegenBerechnen(posten: PostenRoh[]): VermoegenslageRoh {
let aktivaCent = 0n
let verbindlichkeitenCent = 0n
let stichtag: Date | null = null
let postenOhneWert = 0
for (const p of posten) {
let juengster: { stichtag: Date; wertCent: bigint } | null = null
for (const wert of p.wertstaende) {
if (juengster === null || wert.stichtag.getTime() > juengster.stichtag.getTime()) {
juengster = wert
}
}
if (juengster === null) {
postenOhneWert += 1
continue
}
if (p.art === 'VERBINDLICHKEIT') {
verbindlichkeitenCent += juengster.wertCent
} else {
aktivaCent += juengster.wertCent
}
const tag = tagAusDatenbank(juengster.stichtag)
if (stichtag === null || tag.getTime() > stichtag.getTime()) stichtag = tag
}
return {
stichtag,
aktivaCent,
verbindlichkeitenCent,
nettoCent: aktivaCent - verbindlichkeitenCent,
postenGesamt: posten.length,
postenOhneWert,
}
}
export type HeimkostenmonatRoh = {
jahr: number
monat: number
pflegeEigenanteilCent: bigint
unterkunftVerpflegungCent: bigint
investitionskostenCent: bigint
ausbildungsumlageCent: bigint
zusatzleistungenCent: bigint
pflegekasseCent: bigint
leistungszuschlagCent: bigint
renteCent: bigint
sonstigeEinkuenfteCent: bigint
sozialhilfeCent: bigint
eigenmittelCent: bigint
}
export type HeimkostenlageRoh = {
jahr: number
monat: number
gesamtkostenCent: bigint
deckungCent: bigint
deckungsluecke: bigint
}
/**
* Deckungsluecke = Kostenseite minus Deckungsseite, genau nach der
* Gruppierung im Schema. Bleibt etwas uebrig, muss es jemand zahlen - das
* ist die Zahl, mit der ich beim Bezirk vorstellig werde.
*/
export function heimkostenlageBerechnen(monat: HeimkostenmonatRoh): HeimkostenlageRoh {
const gesamtkostenCent =
monat.pflegeEigenanteilCent +
monat.unterkunftVerpflegungCent +
monat.investitionskostenCent +
monat.ausbildungsumlageCent +
monat.zusatzleistungenCent
const deckungCent =
monat.pflegekasseCent +
monat.leistungszuschlagCent +
monat.renteCent +
monat.sonstigeEinkuenfteCent +
monat.sozialhilfeCent +
monat.eigenmittelCent
return {
jahr: monat.jahr,
monat: monat.monat,
gesamtkostenCent,
deckungCent,
deckungsluecke: gesamtkostenCent - deckungCent,
}
}
export const MONATSNAMEN = [
'Januar',
'Februar',
'März',
'April',
'Mai',
'Juni',
'Juli',
'August',
'September',
'Oktober',
'November',
'Dezember',
] as const
export function monatsname(monat: number): string {
return MONATSNAMEN[monat - 1] ?? String(monat)
}
+336
View File
@@ -0,0 +1,336 @@
import 'server-only'
import { startOfDay } from 'date-fns'
import type { Modul } from '@prisma/client'
import { centZuZahl, db, nichtGeloescht } from '@/lib/db'
import {
fristenEinordnen,
heimkostenlageBerechnen,
nachforderungenAuswerten,
tagAusDatenbank,
tageBisTag,
vermoegenBerechnen,
wartendeEinordnen,
type NachforderungRoh,
} from '@/lib/dashboard/bewertung'
import type {
BelegeDaten,
DashboardDaten,
FristenAmpelDaten,
KennzahlenDaten,
NachforderungenDaten,
PosteingangDaten,
WartetAufAndereDaten,
} from '@/lib/dashboard/typen'
/**
* Alles, was das Dashboard "Heute" braucht - in einem Rutsch.
*
* Die Abfragen laufen nebeneinander in einem Promise.all. Keine Schleife
* fragt je Datensatz nach; wo eine Beziehung gebraucht wird (juengster
* Wertstand je Vermoegensposten), holt Prisma sie in derselben Runde mit.
*
* Wer ein Modul nicht lesen darf, bekommt dafuer null zurueck statt
* leerer Listen - die Kachel entfaellt dann ganz, statt faelschlich "0"
* zu behaupten.
*/
/** Wie viele Zeilen zeigt eine Kachel hoechstens? Der Rest steht im Modul. */
const KACHEL_ZEILEN = 5
/** Sicherung gegen ausufernde Abfragen bei sehr langen Akten. */
const FRISTEN_MAX = 200
export type Lesepruefung = (modul: Modul) => boolean
export async function dashboardLaden(
betreute: { id: string; vorname: string; nachname: string },
darfLesen: Lesepruefung,
jetzt: Date = new Date(),
): Promise<DashboardDaten> {
const heute = startOfDay(jetzt)
const betreuteId = betreute.id
const darfFristen = darfLesen('FRISTEN')
const darfVorgaenge = darfLesen('VORGAENGE')
const darfSozialhilfe = darfLesen('SOZIALHILFE')
const darfDokumente = darfLesen('DOKUMENTE')
const darfFinanzen = darfLesen('FINANZEN')
const darfGesundheit = darfLesen('GESUNDHEIT')
const [
fristenRoh,
wartendRoh,
nachforderungenRoh,
posteingang,
posteingangAnzahl,
belegeOhneBuchung,
belegeOhneBuchungGesamt,
buchungenOhneBeleg,
buchungenOhneBelegGesamt,
postenRoh,
heimkostenRoh,
jahresberichtRoh,
] = await Promise.all([
// 1. Fristen - alle offenen, die Einordnung passiert danach im Speicher.
darfFristen
? db.frist.findMany({
where: { betreuteId, status: 'OFFEN', ...nichtGeloescht },
orderBy: { datum: 'asc' },
take: FRISTEN_MAX,
select: {
id: true,
titel: true,
datum: true,
typ: true,
rechtsgrundlage: true,
ausschlussfrist: true,
vorgangId: true,
},
})
: null,
// 2. Vorgaenge, bei denen jemand anderes am Zug ist.
darfVorgaenge
? db.vorgang.findMany({
where: { betreuteId, status: 'WARTET_AUF_DRITTE', ...nichtGeloescht },
orderBy: { letzterKontaktAm: 'asc' },
select: {
id: true,
titel: true,
kategorie: true,
gegenstelle: true,
aktenzeichen: true,
naechsterSchritt: true,
letzterKontaktAm: true,
erstelltAm: true,
},
})
: null,
// 3. Nachforderungen: alle Zeilen, damit "x von y" stimmt.
darfSozialhilfe
? db.nachforderung.findMany({
where: { antrag: { betreuteId, ...nichtGeloescht }, ...nichtGeloescht },
orderBy: [{ reihenfolge: 'asc' }, { erstelltAm: 'asc' }],
select: {
id: true,
bezeichnung: true,
status: true,
fristBis: true,
reihenfolge: true,
antragId: true,
antrag: { select: { traeger: true, aktenzeichen: true } },
},
})
: null,
// 4. Ungeprüfter Posteingang.
darfDokumente
? db.dokument.findMany({
where: { betreuteId, posteingangStatus: 'NEU', ...nichtGeloescht },
orderBy: [{ eingangAm: 'desc' }, { erstelltAm: 'desc' }],
take: KACHEL_ZEILEN,
select: { id: true, titel: true, art: true, absender: true, eingangAm: true },
})
: null,
darfDokumente
? db.dokument.count({
where: { betreuteId, posteingangStatus: 'NEU', ...nichtGeloescht },
})
: null,
// 5a. Rechnungen und Quittungen, an denen keine Buchung haengt.
darfDokumente && darfFinanzen
? db.dokument.findMany({
where: {
betreuteId,
art: { in: ['RECHNUNG', 'QUITTUNG'] },
belegFuer: { none: { geloeschtAm: null } },
...nichtGeloescht,
},
orderBy: [{ eingangAm: 'desc' }, { erstelltAm: 'desc' }],
take: KACHEL_ZEILEN,
select: { id: true, titel: true, art: true, eingangAm: true },
})
: null,
darfDokumente && darfFinanzen
? db.dokument.count({
where: {
betreuteId,
art: { in: ['RECHNUNG', 'QUITTUNG'] },
belegFuer: { none: { geloeschtAm: null } },
...nichtGeloescht,
},
})
: null,
// 5b. Buchungen ohne Beleg und ohne Begruendung - Belegzwang.
darfFinanzen
? db.buchung.findMany({
where: { betreuteId, belegId: null, belegFehltBegruendung: null, ...nichtGeloescht },
orderBy: { datum: 'desc' },
take: KACHEL_ZEILEN,
select: {
id: true,
datum: true,
betragCent: true,
richtung: true,
kategorie: true,
gegenstelle: true,
},
})
: null,
darfFinanzen
? db.buchung.count({
where: { betreuteId, belegId: null, belegFehltBegruendung: null, ...nichtGeloescht },
})
: null,
// 6a. Vermoegen: je Posten der juengste Wertstand.
darfFinanzen
? db.vermoegensposten.findMany({
where: { betreuteId, ...nichtGeloescht },
select: {
id: true,
art: true,
wertstaende: {
orderBy: { stichtag: 'desc' },
take: 1,
select: { stichtag: true, wertCent: true },
},
},
})
: null,
// 6b. Der zuletzt erfasste Heimkostenmonat.
darfGesundheit
? db.heimkostenmonat.findFirst({
where: { betreuteId },
orderBy: [{ jahr: 'desc' }, { monat: 'desc' }],
})
: null,
// 6c. Der naechste Jahresbericht.
darfFristen
? db.frist.findFirst({
where: {
betreuteId,
status: 'OFFEN',
systemSchluessel: { startsWith: 'jahresbericht-' },
...nichtGeloescht,
},
orderBy: { datum: 'asc' },
select: { id: true, titel: true, datum: true },
})
: null,
])
// --- Einordnen -----------------------------------------------------------
const fristen: FristenAmpelDaten | null = fristenRoh
? fristenEinordnen(fristenRoh, heute)
: null
const wartet: WartetAufAndereDaten | null = wartendRoh
? (() => {
const alle = wartendeEinordnen(wartendRoh, heute)
return { ...alle, eintraege: alle.eintraege.slice(0, KACHEL_ZEILEN) }
})()
: null
const nachforderungen: NachforderungenDaten | null = nachforderungenRoh
? nachforderungenAuswerten(
nachforderungenRoh.map<NachforderungRoh>((zeile) => ({
id: zeile.id,
bezeichnung: zeile.bezeichnung,
status: zeile.status,
fristBis: zeile.fristBis,
reihenfolge: zeile.reihenfolge,
antragId: zeile.antragId,
antragTraeger: zeile.antrag.traeger,
antragAktenzeichen: zeile.antrag.aktenzeichen,
})),
heute,
)
: null
const posteingangDaten: PosteingangDaten | null =
posteingang && posteingangAnzahl !== null
? {
anzahl: posteingangAnzahl,
neueste: posteingang.map((d) => ({
id: d.id,
titel: d.titel,
art: d.art,
absender: d.absender,
eingangAm: d.eingangAm ? tagAusDatenbank(d.eingangAm) : null,
})),
}
: null
const belege: BelegeDaten | null =
buchungenOhneBeleg && buchungenOhneBelegGesamt !== null
? {
belegeOhneBuchung: (belegeOhneBuchung ?? []).map((d) => ({
id: d.id,
titel: d.titel,
art: d.art,
eingangAm: d.eingangAm ? tagAusDatenbank(d.eingangAm) : null,
})),
belegeOhneBuchungGesamt: belegeOhneBuchungGesamt ?? 0,
buchungenOhneBeleg: buchungenOhneBeleg.map((b) => ({
id: b.id,
datum: tagAusDatenbank(b.datum),
betragCent: centZuZahl(b.betragCent) ?? 0,
richtung: b.richtung,
kategorie: b.kategorie,
gegenstelle: b.gegenstelle,
})),
buchungenOhneBelegGesamt,
}
: null
const vermoegenRoh = postenRoh ? vermoegenBerechnen(postenRoh) : null
const heimkosten = heimkostenRoh ? heimkostenlageBerechnen(heimkostenRoh) : null
const kennzahlen: KennzahlenDaten = {
vermoegen: vermoegenRoh
? {
stichtag: vermoegenRoh.stichtag,
aktivaCent: centZuZahl(vermoegenRoh.aktivaCent) ?? 0,
verbindlichkeitenCent: centZuZahl(vermoegenRoh.verbindlichkeitenCent) ?? 0,
nettoCent: centZuZahl(vermoegenRoh.nettoCent) ?? 0,
postenGesamt: vermoegenRoh.postenGesamt,
postenOhneWert: vermoegenRoh.postenOhneWert,
}
: null,
heimkosten: heimkosten
? {
jahr: heimkosten.jahr,
monat: heimkosten.monat,
gesamtkostenCent: centZuZahl(heimkosten.gesamtkostenCent) ?? 0,
deckungCent: centZuZahl(heimkosten.deckungCent) ?? 0,
deckungsluecke: centZuZahl(heimkosten.deckungsluecke) ?? 0,
}
: null,
jahresbericht: jahresberichtRoh
? {
id: jahresberichtRoh.id,
titel: jahresberichtRoh.titel,
datum: tagAusDatenbank(jahresberichtRoh.datum),
tage: tageBisTag(tagAusDatenbank(jahresberichtRoh.datum), heute),
}
: null,
}
return {
betreuteId,
name: `${betreute.vorname} ${betreute.nachname}`.trim(),
heute,
fristen,
wartet,
nachforderungen,
posteingang: posteingangDaten,
belege,
kennzahlen,
}
}
+209
View File
@@ -0,0 +1,209 @@
import type {
BuchungKategorie,
BuchungRichtung,
DokumentArt,
FristTyp,
NachforderungStatus,
VorgangKategorie,
} from '@prisma/client'
/**
* Die Form, in der das Dashboard seine Daten an die Kacheln reicht.
*
* Hier stehen bewusst keine BigInt mehr: Cent-Betraege sind bereits ueber
* centZuZahl() in Number gewandelt, damit die Kacheln die Werte auch an
* Client Components weitergeben koennen, ohne dass die Serialisierung
* bricht. Datumswerte sind auf den lokalen Tagesanfang normalisiert - ein
* @db.Date kommt als UTC-Mitternacht aus der Datenbank und wuerde sonst je
* nach Zeitzone einen Tag zu frueh oder zu spaet angezeigt.
*/
export type Ampelstufe = 'rot' | 'orange' | 'gelb' | 'gruen'
export const AMPEL_BESCHRIFTUNG: Record<Ampelstufe, string> = {
rot: 'überfällig',
orange: 'diese Woche fällig',
gelb: 'diesen Monat fällig',
gruen: 'im Plan',
}
// -----------------------------------------------------------------------------
// 1. Fristen-Ampel
// -----------------------------------------------------------------------------
export type FristEintrag = {
id: string
titel: string
datum: Date
typ: FristTyp
rechtsgrundlage: string | null
ausschlussfrist: boolean
vorgangId: string | null
/** Tage bis zum Termin. Negativ bedeutet: so viele Tage ueberfaellig. */
tage: number
stufe: Ampelstufe
}
export type FristenAmpelDaten = {
ueberfaellig: FristEintrag[]
dieseWoche: FristEintrag[]
diesenMonat: FristEintrag[]
/** Offene Fristen, die weiter in der Zukunft liegen. */
weitereOffen: number
/**
* Ausschlussfristen - unabhaengig davon, in welchem Topf sie liegen.
* Sie verfallen ersatzlos und bekommen deshalb eine eigene Warnung.
*/
ausschlussfristen: FristEintrag[]
gesamtOffen: number
}
// -----------------------------------------------------------------------------
// 2. Wartet auf andere
// -----------------------------------------------------------------------------
export type WartendEintrag = {
id: string
titel: string
kategorie: VorgangKategorie
gegenstelle: string | null
aktenzeichen: string | null
naechsterSchritt: string | null
letzterKontaktAm: Date | null
/** Tage seit dem letzten Kontakt - ersatzweise seit dem Anlegen. */
tageOhneRueckmeldung: number
/** Ab WARTEZEIT_DRINGEND_TAGE Tagen: sichtbar hervorheben. */
dringend: boolean
}
export type WartetAufAndereDaten = {
eintraege: WartendEintrag[]
gesamt: number
dringende: number
}
// -----------------------------------------------------------------------------
// 3. Nachforderungen des Bezirks
// -----------------------------------------------------------------------------
export type NachforderungEintrag = {
id: string
bezeichnung: string
status: NachforderungStatus
fristBis: Date | null
/** Tage bis zur gesetzten Frist, negativ = ueberschritten. */
tageBisFrist: number | null
}
export type NachforderungenDaten = {
antragId: string | null
traeger: string | null
aktenzeichen: string | null
gesamt: number
/** Uebersandt, anerkannt oder mit Begruendung als nicht vorhanden gemeldet. */
erledigt: number
offen: NachforderungEintrag[]
/** Wie viele davon haben eine bereits abgelaufene Frist? */
ueberfaellig: number
}
// -----------------------------------------------------------------------------
// 4. Ungeprüfter Posteingang
// -----------------------------------------------------------------------------
export type PosteingangEintrag = {
id: string
titel: string
art: DokumentArt
absender: string | null
eingangAm: Date | null
}
export type PosteingangDaten = {
anzahl: number
neueste: PosteingangEintrag[]
}
// -----------------------------------------------------------------------------
// 5. Unverbuchte Belege
// -----------------------------------------------------------------------------
export type BelegOhneBuchung = {
id: string
titel: string
art: DokumentArt
eingangAm: Date | null
}
export type BuchungOhneBeleg = {
id: string
datum: Date
betragCent: number
richtung: BuchungRichtung
kategorie: BuchungKategorie
gegenstelle: string | null
}
export type BelegeDaten = {
belegeOhneBuchung: BelegOhneBuchung[]
belegeOhneBuchungGesamt: number
buchungenOhneBeleg: BuchungOhneBeleg[]
buchungenOhneBelegGesamt: number
}
// -----------------------------------------------------------------------------
// 6. Kennzahlen
// -----------------------------------------------------------------------------
export type Vermoegenslage = {
/** Juengster Stichtag, aus dem sich die Summe speist. */
stichtag: Date | null
aktivaCent: number
verbindlichkeitenCent: number
nettoCent: number
postenGesamt: number
/** Posten ohne einen einzigen Wertstand - die fehlen in der Summe. */
postenOhneWert: number
}
export type Heimkostenlage = {
jahr: number
monat: number
gesamtkostenCent: number
deckungCent: number
/** Positiv = ungedeckt, negativ = Ueberdeckung. */
deckungsluecke: number
}
export type Berichtslage = {
id: string
titel: string
datum: Date
tage: number
}
export type KennzahlenDaten = {
vermoegen: Vermoegenslage | null
heimkosten: Heimkostenlage | null
jahresbericht: Berichtslage | null
}
// -----------------------------------------------------------------------------
// Das Ganze
// -----------------------------------------------------------------------------
/**
* Ein Feld ist null, wenn der angemeldete Benutzer das zugehoerige Modul
* nicht lesen darf. Die Kachel entfaellt dann ganz - sie zeigt nicht "0".
*/
export type DashboardDaten = {
betreuteId: string
name: string
heute: Date
fristen: FristenAmpelDaten | null
wartet: WartetAufAndereDaten | null
nachforderungen: NachforderungenDaten | null
posteingang: PosteingangDaten | null
belege: BelegeDaten | null
kennzahlen: KennzahlenDaten
}
+826
View File
@@ -0,0 +1,826 @@
import { addDays, addMonths, addWeeks, addYears } from 'date-fns'
import type { DokumentArt, DokumentRichtung } from '@prisma/client'
/**
* Vorschlaege aus dem erkannten Text ziehen.
*
* Der Grundgedanke: Die Anwendung liest den Brief mit und schlaegt vor,
* was drinsteht - Absender, Datum, Aktenzeichen, Art des Schreibens und
* eine moegliche Frist. **Bestaetigt wird immer von Hand.** Nichts davon
* wird automatisch angelegt.
*
* Warum so streng? Eine automatisch angelegte, falsch berechnete Frist ist
* schlimmer als gar keine: Man verlaesst sich darauf. Eine
* Widerspruchsfrist zu verpassen kostet den Anspruch, und die betreute
* Person kann sich dagegen nicht wehren. Deshalb steht neben jedem
* Vorschlag die Textstelle, aus der er stammt - der Nutzer sieht, worauf
* sich die Maschine beruft, und entscheidet selbst.
*
* Alle Funktionen hier sind rein: gleicher Text, gleiche Uhrzeitangabe,
* gleiches Ergebnis. Kein Datenbankzugriff, kein Netz.
*/
export type Sicherheit = 'hoch' | 'mittel' | 'niedrig'
export type Vorschlag<T> = {
wert: T
/** Der Ausschnitt aus dem Text, auf den sich der Vorschlag stuetzt. */
textstelle: string
sicherheit: Sicherheit
}
export type Fristvorschlag = {
/** Berechnetes Datum. null, wenn die Bezugsgroesse fehlt. */
datum: Date | null
/** Wovon aus gerechnet wurde. */
basis: Date | null
/** Wie die Frist im Text formuliert ist. */
formulierung: string
/** Ein Satz, den der Nutzer als Fristtitel uebernehmen kann. */
titel: string
/** Erklaerung inklusive Rechenweg - der Nutzer soll nachrechnen koennen. */
begruendung: string
textstelle: string
sicherheit: Sicherheit
/** Ist es eine Rechtsbehelfsfrist (Widerspruch, Klage)? */
rechtsbehelf: boolean
}
export type Erkennungsergebnis = {
absender: Vorschlag<string> | null
schreibenVom: Vorschlag<Date> | null
aktenzeichen: Vorschlag<string> | null
art: Vorschlag<DokumentArt> | null
richtung: Vorschlag<DokumentRichtung> | null
titel: string | null
fristen: Fristvorschlag[]
}
export type Erkennungsoptionen = {
/** Namen aus Kontakten und frueheren Dokumenten - schlagen alles andere. */
bekannteAbsender?: string[]
/** Tag des Zugangs; massgeblich fuer Rechtsbehelfsfristen. */
zugangAm?: Date | null
/** Nur fuer Tests: heutiges Datum. */
heute?: Date
}
// -----------------------------------------------------------------------------
// Hilfen
// -----------------------------------------------------------------------------
/** Datum ohne Uhrzeit, in UTC - so wie @db.Date es speichert. */
function tag(jahr: number, monat: number, tagImMonat: number): Date {
return new Date(Date.UTC(jahr, monat - 1, tagImMonat, 0, 0, 0, 0))
}
function aufTag(datum: Date): Date {
return tag(datum.getUTCFullYear(), datum.getUTCMonth() + 1, datum.getUTCDate())
}
/** Schneidet einen lesbaren Ausschnitt um eine Fundstelle herum aus. */
export function textstelleSchneiden(text: string, von: number, bis: number, rand = 60): string {
const anfang = Math.max(0, von - rand)
const ende = Math.min(text.length, bis + rand)
const roh = text.slice(anfang, ende).replace(/\s+/g, ' ').trim()
return `${anfang > 0 ? '… ' : ''}${roh}${ende < text.length ? ' …' : ''}`
}
const MONATE: Record<string, number> = {
januar: 1,
jaenner: 1,
februar: 2,
maerz: 3,
april: 4,
mai: 5,
juni: 6,
juli: 7,
august: 8,
september: 9,
oktober: 10,
november: 11,
dezember: 12,
jan: 1,
feb: 2,
mrz: 3,
apr: 4,
jun: 6,
jul: 7,
aug: 8,
sep: 9,
sept: 9,
okt: 10,
nov: 11,
dez: 12,
}
/** Umlaute und Versalien vereinheitlichen - OCR liefert beides gemischt. */
export function normalisieren(text: string): string {
return text
.toLowerCase()
.replace(/ä/g, 'ae')
.replace(/ö/g, 'oe')
.replace(/ü/g, 'ue')
.replace(/ß/g, 'ss')
}
function jahrVervollstaendigen(roh: string): number {
const zahl = Number.parseInt(roh, 10)
if (roh.length === 4) return zahl
// Zweistellig: 00-79 -> 2000er, 80-99 -> 1900er. Betreuungsakten reichen
// selten weiter zurueck, und Fristen liegen nie in den 1980ern.
return zahl <= 79 ? 2000 + zahl : 1900 + zahl
}
function plausibel(datum: Date | null): boolean {
if (!datum) return false
const jahr = datum.getUTCFullYear()
return jahr >= 1900 && jahr <= 2100
}
// -----------------------------------------------------------------------------
// Datum
// -----------------------------------------------------------------------------
export type Datumsfund = {
datum: Date
text: string
von: number
bis: number
}
const DATUM_ZIFFERN = /\b(\d{1,2})\s*\.\s*(\d{1,2})\s*\.\s*(\d{2,4})\b/g
const DATUM_ISO = /\b(\d{4})-(\d{2})-(\d{2})\b/g
const DATUM_MONATSNAME =
/\b(\d{1,2})\s*\.?\s*(Januar|Jänner|Februar|März|Maerz|April|Mai|Juni|Juli|August|September|Oktober|November|Dezember|Jan|Feb|Mrz|Apr|Jun|Jul|Aug|Sep|Sept|Okt|Nov|Dez)\.?\s*(\d{2,4})\b/gi
/**
* Findet alle Datumsangaben im Text, in der Reihenfolge ihres Auftretens.
* Erkannt werden 12.03.2026, 12.3.26, "12. März 2026" und 2026-03-12.
*/
export function datenFinden(text: string): Datumsfund[] {
const funde: Datumsfund[] = []
const aufnehmen = (datum: Date | null, treffer: RegExpExecArray) => {
if (!plausibel(datum)) return
funde.push({
datum: datum!,
text: treffer[0]!.trim(),
von: treffer.index,
bis: treffer.index + treffer[0]!.length,
})
}
for (const muster of [DATUM_ZIFFERN, DATUM_ISO, DATUM_MONATSNAME]) {
muster.lastIndex = 0
let treffer: RegExpExecArray | null
while ((treffer = muster.exec(text)) !== null) {
if (muster === DATUM_ZIFFERN) {
aufnehmen(
sicheresDatum(
jahrVervollstaendigen(treffer[3]!),
Number.parseInt(treffer[2]!, 10),
Number.parseInt(treffer[1]!, 10),
),
treffer,
)
} else if (muster === DATUM_ISO) {
aufnehmen(
sicheresDatum(
Number.parseInt(treffer[1]!, 10),
Number.parseInt(treffer[2]!, 10),
Number.parseInt(treffer[3]!, 10),
),
treffer,
)
} else {
const monat = MONATE[normalisieren(treffer[2]!.replace(/\./g, ''))]
if (monat === undefined) continue
aufnehmen(
sicheresDatum(
jahrVervollstaendigen(treffer[3]!),
monat,
Number.parseInt(treffer[1]!, 10),
),
treffer,
)
}
}
}
// Ueberlappungen (ISO-Treffer innerhalb eines Monatsnamen-Treffers o.ae.)
// aussortieren und nach Position sortieren.
funde.sort((a, b) => a.von - b.von)
return funde.filter((fund, i) => i === 0 || fund.von >= funde[i - 1]!.bis)
}
/** Legt nie einen 31. Februar an - Rollover wird abgewiesen. */
function sicheresDatum(jahr: number, monat: number, tagImMonat: number): Date | null {
if (monat < 1 || monat > 12 || tagImMonat < 1 || tagImMonat > 31) return null
const datum = tag(jahr, monat, tagImMonat)
if (datum.getUTCMonth() + 1 !== monat || datum.getUTCDate() !== tagImMonat) return null
return datum
}
/** Einzelnes Datum parsen - fuer Formularfelder und Tests. */
export function datumLesen(text: string): Date | null {
const funde = datenFinden(text)
return funde[0]?.datum ?? null
}
/**
* Das Datum des Schreibens: das erste plausible Datum im Kopf des Briefes,
* das nicht in der Zukunft liegt. Behoerdenbriefe tragen es rechts oben,
* meist innerhalb der ersten 1200 Zeichen.
*/
export function schreibdatumErkennen(
text: string,
heute: Date = new Date(),
): Vorschlag<Date> | null {
const kopfbereich = text.slice(0, 1500)
const grenze = aufTag(heute)
// Ein ausdruecklich benanntes Datum schlaegt jedes gefundene.
const benannt =
/(?:vom|Datum|Schreiben vom|Bescheid vom|Ihr Schreiben vom|Rechnungsdatum|Ausstellungsdatum)\s*:?\s*/gi
let treffer: RegExpExecArray | null
while ((treffer = benannt.exec(kopfbereich)) !== null) {
const rest = kopfbereich.slice(treffer.index + treffer[0]!.length, treffer.index + treffer[0]!.length + 40)
const fund = datenFinden(rest)[0]
if (fund && fund.datum <= grenze) {
return {
wert: fund.datum,
textstelle: textstelleSchneiden(kopfbereich, treffer.index, treffer.index + treffer[0]!.length + fund.bis),
sicherheit: 'hoch',
}
}
}
const fund = datenFinden(kopfbereich).find((f) => f.datum <= grenze)
if (!fund) return null
return {
wert: fund.datum,
textstelle: textstelleSchneiden(kopfbereich, fund.von, fund.bis),
sicherheit: 'mittel',
}
}
// -----------------------------------------------------------------------------
// Aktenzeichen
// -----------------------------------------------------------------------------
type AzMuster = {
regex: RegExp
sicherheit: Sicherheit
/** Welche Gruppe traegt den Wert? */
gruppe: number
}
/**
* Aktenzeichen, Kunden- und Vertragsnummern.
*
* Reihenfolge ist Rangfolge: ausdruecklich beschriftete Nummern
* ("Az.: …", "Kd.-Nr. …") vor dem blossen Muster eines Gerichtsaktenzeichens.
*/
const AZ_MUSTER: AzMuster[] = [
{
// "Az.: XVII 123/25", "Aktenzeichen: 4711/26", "Unser Zeichen: AB/12345"
regex:
/(?:Aktenzeichen|Az\.?|Gesch\.?[-\s]?Z\.?|Geschäftszeichen|Geschaeftszeichen|Unser Zeichen|Ihr Zeichen)\s*:?\s*([A-Za-zÄÖÜäöü0-9][A-Za-zÄÖÜäöü0-9 .\-/]{2,39}?)(?=\s{2,}|[\n\r]|$|\s*(?:Ihr|Unser|Datum|Seite|Betreff)\b)/g,
sicherheit: 'hoch',
gruppe: 1,
},
{
// "Kd.-Nr. 123456", "Kundennummer: 987", "Versicherungsschein-Nr. 4711"
regex:
/(?:Kd\.?[-\s]?Nr\.?|Kunden[-\s]?Nr\.?|Kundennummer|Versicherungsschein[-\s]?Nr\.?|Versicherungsscheinnummer|Vertrags[-\s]?Nr\.?|Vertragsnummer|Rechnungs[-\s]?Nr\.?|Rechnungsnummer|Renten[-\s]?Nr\.?|Mitglieds[-\s]?Nr\.?|Beleg[-\s]?Nr\.?)\s*:?\s*([A-Za-z0-9][A-Za-z0-9 .\-/]{2,39}?)(?=\s{2,}|[\n\r]|$)/g,
sicherheit: 'hoch',
gruppe: 1,
},
{
// Betreuungsgericht: "XVII 123/25", auch "XVII 0123/2025"
regex: /\b((?:X{0,3}(?:IX|IV|V?I{0,3}))\s?\d{1,5}\s*\/\s*\d{2,4})\b/g,
sicherheit: 'mittel',
gruppe: 1,
},
]
/** Endet das Aktenzeichen sauber? Schleppende Wortreste abschneiden. */
function azAufraeumen(roh: string): string {
return roh
.replace(/\s+/g, ' ')
.replace(/[.,;:\-]+$/, '')
.trim()
}
export function aktenzeichenErkennen(text: string): Vorschlag<string> | null {
for (const muster of AZ_MUSTER) {
muster.regex.lastIndex = 0
let treffer: RegExpExecArray | null
while ((treffer = muster.regex.exec(text)) !== null) {
const wert = azAufraeumen(treffer[muster.gruppe] ?? '')
// Zu kurz oder rein alphabetisch: das ist kein Aktenzeichen.
if (wert.length < 3 || !/\d/.test(wert)) continue
if (wert.length > 40) continue
return {
wert,
textstelle: textstelleSchneiden(text, treffer.index, treffer.index + treffer[0]!.length),
sicherheit: muster.sicherheit,
}
}
}
return null
}
/** Alle gefundenen Nummern - fuer die Anzeige "weitere erkannte Nummern". */
export function alleAktenzeichen(text: string): string[] {
const gefunden = new Set<string>()
for (const muster of AZ_MUSTER) {
muster.regex.lastIndex = 0
let treffer: RegExpExecArray | null
while ((treffer = muster.regex.exec(text)) !== null) {
const wert = azAufraeumen(treffer[muster.gruppe] ?? '')
if (wert.length >= 3 && wert.length <= 40 && /\d/.test(wert)) gefunden.add(wert)
}
}
return [...gefunden]
}
// -----------------------------------------------------------------------------
// Dokumentart
// -----------------------------------------------------------------------------
type ArtRegel = { art: DokumentArt; woerter: string[]; gewicht: number }
/**
* Schluesselwoerter je Dokumentart. Laengere, spezifischere Begriffe wiegen
* schwerer: "Widerspruchsbescheid" ist ein Bescheid, auch wenn im selben
* Text zehnmal "Antrag" steht.
*/
const ART_REGELN: ArtRegel[] = [
{ art: 'BESCHEID', woerter: ['widerspruchsbescheid'], gewicht: 12 },
{ art: 'BESCHEID', woerter: ['aenderungsbescheid', 'bewilligungsbescheid', 'ablehnungsbescheid'], gewicht: 10 },
{ art: 'BESCHEID', woerter: ['bescheid', 'bescheiden wir', 'leistungsbescheid'], gewicht: 6 },
{ art: 'BESCHLUSS', woerter: ['beschluss', 'beschlossen', 'im namen des volkes'], gewicht: 8 },
{ art: 'MAHNUNG', woerter: ['mahnung', 'zahlungserinnerung', 'letzte mahnung', 'mahnbescheid'], gewicht: 10 },
{ art: 'RECHNUNG', woerter: ['rechnung', 'rechnungsnummer', 'zahlbetrag', 'rechnungsbetrag'], gewicht: 7 },
{ art: 'QUITTUNG', woerter: ['quittung', 'zahlungsbeleg', 'barquittung'], gewicht: 8 },
{ art: 'KONTOAUSZUG', woerter: ['kontoauszug', 'umsatzuebersicht', 'auszug nr'], gewicht: 10 },
{ art: 'GRUNDBUCHAUSZUG', woerter: ['grundbuchauszug', 'grundbuch von', 'grundbuchamt', 'abteilung iii'], gewicht: 10 },
{ art: 'VERTRAG', woerter: ['kaufvertrag', 'mietvertrag', 'heimvertrag', 'darlehensvertrag', 'vertrag ueber'], gewicht: 9 },
{ art: 'VERTRAG', woerter: ['versicherungsschein', 'police', 'nachtrag zum vertrag'], gewicht: 8 },
{ art: 'ANTRAG', woerter: ['antrag auf', 'antragsformular', 'hiermit beantrage', 'wir beantragen'], gewicht: 8 },
{ art: 'GUTACHTEN', woerter: ['gutachten', 'sachverstaendiger', 'pflegegutachten', 'medizinischer dienst'], gewicht: 9 },
{ art: 'ATTEST', woerter: ['attest', 'aerztliche bescheinigung', 'arztbrief', 'befundbericht'], gewicht: 9 },
{ art: 'VOLLMACHT', woerter: ['vollmacht', 'bevollmaechtige hiermit', 'betreuerausweis'], gewicht: 9 },
{ art: 'URKUNDE', woerter: ['urkunde', 'notarielle', 'beglaubigung', 'sterbeurkunde', 'geburtsurkunde'], gewicht: 8 },
{ art: 'PROTOKOLL', woerter: ['protokoll', 'niederschrift', 'aktenvermerk', 'gespraechsnotiz'], gewicht: 8 },
{ art: 'AUSWEIS', woerter: ['personalausweis', 'schwerbehindertenausweis', 'ausweis nr'], gewicht: 8 },
{ art: 'SCHREIBEN', woerter: ['sehr geehrte', 'mit freundlichen gruessen', 'betreff'], gewicht: 2 },
]
export function dokumentartErkennen(text: string): Vorschlag<DokumentArt> | null {
// Der Betreff und die ersten Zeilen wiegen doppelt - dort steht, worum es geht.
const ganz = normalisieren(text)
const kopf = normalisieren(text.slice(0, 900))
const punkte = new Map<DokumentArt, { punkte: number; wort: string }>()
for (const regel of ART_REGELN) {
for (const wort of regel.woerter) {
const imKopf = kopf.includes(wort)
const imText = ganz.includes(wort)
if (!imText) continue
const wert = regel.gewicht * (imKopf ? 2 : 1)
const bisher = punkte.get(regel.art)
if (!bisher || wert > bisher.punkte) punkte.set(regel.art, { punkte: wert, wort })
}
}
if (punkte.size === 0) return null
const [art, treffer] = [...punkte.entries()].sort((a, b) => b[1].punkte - a[1].punkte)[0]!
const stelle = normalisieren(text).indexOf(treffer.wort)
return {
wert: art,
textstelle:
stelle >= 0
? textstelleSchneiden(text, stelle, stelle + treffer.wort.length, 45)
: treffer.wort,
sicherheit: treffer.punkte >= 12 ? 'hoch' : treffer.punkte >= 6 ? 'mittel' : 'niedrig',
}
}
// -----------------------------------------------------------------------------
// Absender
// -----------------------------------------------------------------------------
const ORGANISATIONS_WOERTER = [
'amtsgericht',
'landgericht',
'sozialgericht',
'finanzamt',
'landratsamt',
'bezirk',
'stadt ',
'gemeinde',
'jobcenter',
'agentur fuer arbeit',
'rentenversicherung',
'krankenkasse',
'pflegekasse',
'aok',
'barmer',
'techniker krankenkasse',
'versicherung',
'sparkasse',
'volksbank',
'raiffeisen',
'bank',
'gmbh',
'ag ',
'e.v.',
'e. v.',
'kg ',
'mbh',
'seniorenheim',
'pflegeheim',
'seniorenzentrum',
'klinik',
'krankenhaus',
'praxis',
'notar',
'rechtsanwalt',
'rechtsanwaelte',
'kanzlei',
'stadtwerke',
'sozialamt',
'betreuungsstelle',
'versorgungsamt',
]
const KEIN_ABSENDER =
/^(sehr geehrte|betreff|betr\.|datum|seite|az|aktenzeichen|unser zeichen|ihr zeichen|ihre nachricht|anlage|anlagen|mit freundlichen|hochachtungsvoll)/i
/**
* Der Absender steht in deutschen Geschaeftsbriefen ganz oben - entweder im
* Briefkopf oder in der kleinen Ruecksendezeile ueber der Anschrift.
*
* Bekannte Namen (aus den Kontakten und aus frueheren Dokumenten) schlagen
* jede Heuristik: wer schon einmal geschrieben hat, schreibt wieder.
*/
export function absenderErkennen(
text: string,
bekannte: string[] = [],
): Vorschlag<string> | null {
const kopfbereich = text.slice(0, 1200)
const normKopf = normalisieren(kopfbereich)
// 1. Abgleich mit bekannten Absendern
let bester: { name: string; stelle: number } | null = null
for (const name of bekannte) {
const gekuerzt = name.trim()
if (gekuerzt.length < 4) continue
const stelle = normKopf.indexOf(normalisieren(gekuerzt))
if (stelle >= 0 && (!bester || gekuerzt.length > bester.name.length)) {
bester = { name: gekuerzt, stelle }
}
}
if (bester) {
return {
wert: bester.name,
textstelle: textstelleSchneiden(kopfbereich, bester.stelle, bester.stelle + bester.name.length, 40),
sicherheit: 'hoch',
}
}
const zeilen = kopfbereich
.split('\n')
.map((z) => z.trim())
.filter((z) => z.length >= 3 && z.length <= 90)
.slice(0, 12)
// 2. Zeile mit einem typischen Organisationswort
for (const zeile of zeilen) {
if (KEIN_ABSENDER.test(zeile)) continue
const norm = normalisieren(zeile)
if (ORGANISATIONS_WOERTER.some((wort) => norm.includes(wort))) {
const stelle = kopfbereich.indexOf(zeile)
return {
wert: zeile.replace(/\s+/g, ' '),
textstelle: textstelleSchneiden(kopfbereich, stelle, stelle + zeile.length, 30),
sicherheit: 'mittel',
}
}
}
// 3. Notfalls die erste Zeile, die nach einem Namen aussieht
for (const zeile of zeilen) {
if (KEIN_ABSENDER.test(zeile)) continue
if (/^\d/.test(zeile)) continue // Datum, Postleitzahl, Seitenzahl
if (!/[A-ZÄÖÜ]/.test(zeile)) continue
const stelle = kopfbereich.indexOf(zeile)
return {
wert: zeile.replace(/\s+/g, ' '),
textstelle: textstelleSchneiden(kopfbereich, stelle, stelle + zeile.length, 30),
sicherheit: 'niedrig',
}
}
return null
}
// -----------------------------------------------------------------------------
// Fristen
// -----------------------------------------------------------------------------
const ZAHLWOERTER: Record<string, number> = {
ein: 1,
eine: 1,
einer: 1,
eines: 1,
einem: 1,
einen: 1,
zwei: 2,
drei: 3,
vier: 4,
fuenf: 5,
sechs: 6,
sieben: 7,
acht: 8,
neun: 9,
zehn: 10,
elf: 11,
zwoelf: 12,
vierzehn: 14,
}
type Einheit = 'tage' | 'wochen' | 'monate' | 'jahre'
function einheitLesen(roh: string): Einheit | null {
const norm = normalisieren(roh)
if (norm.startsWith('tag') || norm.startsWith('werktag') || norm.startsWith('kalendertag')) return 'tage'
if (norm.startsWith('woch')) return 'wochen'
if (norm.startsWith('monat')) return 'monate'
if (norm.startsWith('jahr')) return 'jahre'
return null
}
function anzahlLesen(roh: string): number | null {
const gekuerzt = roh.trim()
if (/^\d{1,3}$/.test(gekuerzt)) return Number.parseInt(gekuerzt, 10)
const wort = ZAHLWOERTER[normalisieren(gekuerzt)]
return wort ?? null
}
function rechnen(basis: Date, anzahl: number, einheit: Einheit): Date {
switch (einheit) {
case 'tage':
return aufTag(addDays(basis, anzahl))
case 'wochen':
return aufTag(addWeeks(basis, anzahl))
case 'monate':
return aufTag(addMonths(basis, anzahl))
case 'jahre':
return aufTag(addYears(basis, anzahl))
}
}
function einheitText(anzahl: number, einheit: Einheit): string {
const namen: Record<Einheit, [string, string]> = {
tage: ['Tag', 'Tagen'],
wochen: ['Woche', 'Wochen'],
monate: ['Monat', 'Monaten'],
jahre: ['Jahr', 'Jahren'],
}
return `${anzahl} ${anzahl === 1 ? namen[einheit][0] : namen[einheit][1]}`
}
const RECHTSBEHELF = /widerspruch|klage|rechtsbehelf|rechtsmittel|beschwerde|einspruch/i
/**
* Relative Fristen: "innerhalb von vier Wochen", "binnen eines Monats",
* "einen Monat nach Bekanntgabe", "Frist von 14 Tagen".
*/
const RELATIV_MUSTER: RegExp[] = [
/\b(?:innerhalb|binnen)\s+(?:von\s+|einer\s+Frist\s+von\s+)?([A-Za-zÄÖÜäöü]+|\d{1,3})\s+(Tagen?|Werktagen?|Kalendertagen?|Wochen?|Monaten?|Monats|Jahren?|Jahres)\b/gi,
/\bFrist\s+von\s+([A-Za-zÄÖÜäöü]+|\d{1,3})\s+(Tagen?|Werktagen?|Wochen?|Monaten?|Monats|Jahren?)\b/gi,
/\b([A-Za-zÄÖÜäöü]+|\d{1,3})\s+(Tage|Tagen|Wochen|Monat|Monate|Monaten|Jahr|Jahre|Jahren)\s+nach\s+(?:Bekanntgabe|Zugang|Zustellung|Erhalt|Empfang|Bekanntwerden)\b/gi,
]
/** Absolute Fristen: "bis zum 30.04.2026", "spätestens am 30.04.2026". */
const ABSOLUT_MUSTER =
/\b(?:bis\s+(?:zum|spätestens|spaetestens)?|bis|spätestens\s+(?:am|zum)?|spaetestens\s+(?:am|zum)?|fällig\s+am|faellig\s+am|Zahlungsziel|zahlbar\s+bis|Termin\s+am)\s*:?\s*/gi
/**
* Sucht Fristen im Text und rechnet sie aus.
*
* `basis` ist der Tag, ab dem gerechnet wird - in aller Regel der Zugang
* des Schreibens, nicht das Briefdatum. Bei Bescheiden ist das der Tag der
* Bekanntgabe; die Drei-Tages-Fiktion des § 37 Abs. 2 SGB X wird hier
* bewusst NICHT angewandt, weil sie vom Versandweg abhaengt und die
* Anwendung den nicht kennt. Der Hinweis darauf steht in der Begruendung.
*/
export function fristenErkennen(text: string, basis: Date | null): Fristvorschlag[] {
const vorschlaege: Fristvorschlag[] = []
const basisTag = basis ? aufTag(basis) : null
for (const muster of RELATIV_MUSTER) {
muster.lastIndex = 0
let treffer: RegExpExecArray | null
while ((treffer = muster.exec(text)) !== null) {
const anzahl = anzahlLesen(treffer[1]!)
const einheit = einheitLesen(treffer[2]!)
if (anzahl === null || einheit === null || anzahl < 1 || anzahl > 120) continue
const umgebung = text.slice(Math.max(0, treffer.index - 200), treffer.index + 200)
const rechtsbehelf = RECHTSBEHELF.test(umgebung)
const spanne = einheitText(anzahl, einheit)
vorschlaege.push({
datum: basisTag ? rechnen(basisTag, anzahl, einheit) : null,
basis: basisTag,
formulierung: treffer[0]!.replace(/\s+/g, ' ').trim(),
titel: rechtsbehelf
? `Widerspruch/Rechtsbehelf einlegen (${spanne})`
: `Frist aus dem Schreiben: ${spanne}`,
begruendung: basisTag
? `${spanne} ab dem ${formatKurz(basisTag)} (angenommener Zugang). ` +
`Prüfe das Zugangsdatum – bei Bescheiden zählt die Bekanntgabe, nicht das Briefdatum.`
: `${spanne} ab Zugang. Trage zuerst das Eingangsdatum ein, dann wird die Frist berechnet.`,
textstelle: textstelleSchneiden(text, treffer.index, treffer.index + treffer[0]!.length),
sicherheit: rechtsbehelf ? 'hoch' : 'mittel',
rechtsbehelf,
})
}
}
ABSOLUT_MUSTER.lastIndex = 0
let treffer: RegExpExecArray | null
while ((treffer = ABSOLUT_MUSTER.exec(text)) !== null) {
const ab = treffer.index + treffer[0]!.length
const rest = text.slice(ab, ab + 40)
const fund = datenFinden(rest)[0]
if (!fund || fund.von > 3) continue
const umgebung = text.slice(Math.max(0, treffer.index - 200), ab + 200)
const rechtsbehelf = RECHTSBEHELF.test(umgebung)
const zahlung = /zahl|betrag|überweis|ueberweis|konto|rechnung/i.test(umgebung)
vorschlaege.push({
datum: fund.datum,
basis: null,
formulierung: `${treffer[0]!.trim()} ${fund.text}`.replace(/\s+/g, ' '),
titel: rechtsbehelf
? 'Widerspruch/Rechtsbehelf einlegen'
: zahlung
? 'Zahlung bis zum genannten Termin'
: 'Im Schreiben genannter Termin',
begruendung: `Im Text ausdrücklich genanntes Datum (${formatKurz(fund.datum)}).`,
textstelle: textstelleSchneiden(text, treffer.index, ab + fund.bis),
sicherheit: 'hoch',
rechtsbehelf,
})
}
return entdoppeln(vorschlaege)
}
function formatKurz(datum: Date): string {
const tagZahl = String(datum.getUTCDate()).padStart(2, '0')
const monat = String(datum.getUTCMonth() + 1).padStart(2, '0')
return `${tagZahl}.${monat}.${datum.getUTCFullYear()}`
}
function entdoppeln(liste: Fristvorschlag[]): Fristvorschlag[] {
const gesehen = new Set<string>()
const rang: Record<Sicherheit, number> = { hoch: 0, mittel: 1, niedrig: 2 }
return liste
.slice()
.sort((a, b) => rang[a.sicherheit] - rang[b.sicherheit])
.filter((vorschlag) => {
const schluessel = `${vorschlag.datum?.toISOString() ?? 'offen'}|${normalisieren(vorschlag.formulierung)}`
if (gesehen.has(schluessel)) return false
gesehen.add(schluessel)
return true
})
.slice(0, 6)
}
// -----------------------------------------------------------------------------
// Richtung und Titel
// -----------------------------------------------------------------------------
/**
* Eingang oder Ausgang? Beim Handy-Foto eines Briefes ist es fast immer
* ein Eingang; ein selbst geschriebener Antrag ist ein Ausgang.
*/
export function richtungErkennen(text: string): Vorschlag<DokumentRichtung> | null {
const kopf = normalisieren(text.slice(0, 900))
if (/hiermit beantrage ich|hiermit lege ich widerspruch|als betreuer der|in meiner eigenschaft als betreuer/.test(kopf)) {
return { wert: 'AUSGANG', textstelle: 'Formulierung in der Ich-Form', sicherheit: 'mittel' }
}
if (/sehr geehrte|wir teilen ihnen mit|wir haben ihren antrag|hiermit erhalten sie/.test(kopf)) {
return { wert: 'EINGANG', textstelle: 'Anrede eines fremden Absenders', sicherheit: 'mittel' }
}
return null
}
/** Ein brauchbarer Titel: Betreffzeile, sonst Art plus Absender. */
export function titelVorschlagen(
text: string,
art: DokumentArt | null,
absender: string | null,
): string | null {
const betreff = /(?:Betreff|Betr\.|Betrifft)\s*:?\s*(.{4,90})/i.exec(text.slice(0, 1500))
if (betreff) {
const zeile = betreff[1]!.split('\n')[0]!.replace(/\s+/g, ' ').trim()
if (zeile.length >= 4) return zeile.slice(0, 90)
}
if (art && absender) return `${ART_BESCHRIFTUNG[art]} – ${absender}`.slice(0, 90)
if (absender) return `Schreiben – ${absender}`.slice(0, 90)
if (art) return ART_BESCHRIFTUNG[art]
return null
}
/** Beschriftungen der Dokumentarten - auch von der Oberflaeche benutzt. */
export const ART_BESCHRIFTUNG: Record<DokumentArt, string> = {
BESCHEID: 'Bescheid',
ANTRAG: 'Antrag',
SCHREIBEN: 'Schreiben',
RECHNUNG: 'Rechnung',
KONTOAUSZUG: 'Kontoauszug',
VERTRAG: 'Vertrag',
BESCHLUSS: 'Beschluss',
URKUNDE: 'Urkunde',
GUTACHTEN: 'Gutachten',
AUSWEIS: 'Ausweis',
PROTOKOLL: 'Protokoll',
QUITTUNG: 'Quittung',
MAHNUNG: 'Mahnung',
VOLLMACHT: 'Vollmacht',
ATTEST: 'Attest',
GRUNDBUCHAUSZUG: 'Grundbuchauszug',
FOTO: 'Foto',
SONSTIGES: 'Sonstiges',
}
export const RICHTUNG_BESCHRIFTUNG: Record<DokumentRichtung, string> = {
EINGANG: 'Eingang',
AUSGANG: 'Ausgang',
INTERN: 'Intern',
}
// -----------------------------------------------------------------------------
// Alles zusammen
// -----------------------------------------------------------------------------
/**
* Wertet einen OCR-Text aus und liefert die Vorschlaege, die der Nutzer
* anschliessend bestaetigt. Es wird nichts angelegt und nichts gespeichert.
*/
export function erkennungsvorschlaege(
text: string,
optionen: Erkennungsoptionen = {},
): Erkennungsergebnis {
const heute = optionen.heute ?? new Date()
if (!text || text.trim().length < 10) {
return {
absender: null,
schreibenVom: null,
aktenzeichen: null,
art: null,
richtung: null,
titel: null,
fristen: [],
}
}
const absender = absenderErkennen(text, optionen.bekannteAbsender ?? [])
const schreibenVom = schreibdatumErkennen(text, heute)
const aktenzeichen = aktenzeichenErkennen(text)
const art = dokumentartErkennen(text)
const richtung = richtungErkennen(text)
// Fuer Fristen zaehlt der Zugang. Fehlt er, ist das Briefdatum die beste
// verfuegbare Naeherung - der Nutzer sieht in der Begruendung, worauf
// gerechnet wurde, und kann es korrigieren.
const basis = optionen.zugangAm ?? schreibenVom?.wert ?? null
return {
absender,
schreibenVom,
aktenzeichen,
art,
richtung,
titel: titelVorschlagen(text, art?.wert ?? null, absender?.wert ?? null),
fristen: fristenErkennen(text, basis),
}
}
+375
View File
@@ -0,0 +1,375 @@
import 'server-only'
import { Prisma, type DokumentArt, type DokumentRichtung, type PosteingangStatus } from '@prisma/client'
import { db, nichtGeloescht } from '@/lib/db'
/**
* Volltextsuche ueber die Dokumente einer betreuten Person.
*
* Gesucht wird in der von Postgres gepflegten Spalte `suchvektor`
* (prisma/sql/volltextsuche.sql, deutsche Konfiguration, Trigger auf
* Titel/Absender/Aktenzeichen/Dateiname/OCR-Text).
*
* Zwei Dinge sind hier nicht verhandelbar:
*
* 1. Jede Abfrage ist auf `betreuteId` eingegrenzt und blendet den
* Papierkorb aus. Es gibt keinen Weg durch diese Datei, der Dokumente
* einer anderen Person zurueckgeben koennte.
* 2. Der Suchbegriff wird als **Parameter** uebergeben, nie in den
* SQL-Text geklebt. `websearch_to_tsquery` nimmt beliebigen Nutzertext
* entgegen, ohne bei Anfuehrungszeichen oder Klammern zu stolpern.
*/
export type Suchtreffer = {
id: string
titel: string
art: DokumentArt
richtung: DokumentRichtung
absender: string | null
aktenzeichen: string | null
dateiname: string | null
eingangAm: Date | null
schreibenVom: Date | null
posteingangStatus: PosteingangStatus
/** Fundstelle mit hervorgehobenem Treffer (<mark>…</mark>). */
ausschnitt: string
/** Rangwert von ts_rank_cd - nur zum Sortieren und Anzeigen. */
rang: number
}
/**
* Bereitet die Eingabe des Nutzers auf.
*
* `websearch_to_tsquery` versteht die Schreibweise, die man aus
* Suchmaschinen kennt: Woerter, "Wortgruppen in Anfuehrungszeichen",
* `oder`, ein vorangestelltes Minus zum Ausschliessen. Deshalb wird hier
* bewusst wenig veraendert - nur Steuerzeichen und uebermaessige Laenge
* werden abgeraeumt.
*
* Gibt null zurueck, wenn nichts Suchbares uebrig bleibt.
*/
export function suchanfrageAufbereiten(roh: string | null | undefined): string | null {
if (!roh) return null
const sauber = roh
// eslint-disable-next-line no-control-regex
.replace(/[\u0000-\u001f\u007f]/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 200)
if (sauber.length < 2) return null
// Ein einzelnes offenes Anfuehrungszeichen wuerde die halbe Anfrage zur
// Wortgruppe machen - lieber entfernen als den Nutzer raten lassen.
const anfuehrungszeichen = (sauber.match(/"/g) ?? []).length
return anfuehrungszeichen % 2 === 0 ? sauber : sauber.replace(/"/g, '')
}
export type Suchoptionen = {
grenze?: number
art?: DokumentArt | null
richtung?: DokumentRichtung | null
posteingangStatus?: PosteingangStatus | null
von?: Date | null
bis?: Date | null
tagId?: string | null
}
/**
* Volltextsuche. Rangfolge ueber ts_rank_cd, Fundstelle ueber ts_headline.
* Leere oder unbrauchbare Anfrage -> leeres Ergebnis, kein Fehler.
*/
export async function dokumenteSuchen(
betreuteId: string,
anfrage: string,
optionen: Suchoptionen = {},
): Promise<Suchtreffer[]> {
const begriff = suchanfrageAufbereiten(anfrage)
if (!begriff) return []
const grenze = Math.min(Math.max(optionen.grenze ?? 50, 1), 200)
const bedingungen: Prisma.Sql[] = [
Prisma.sql`d."betreuteId" = ${betreuteId}`,
Prisma.sql`d."geloeschtAm" IS NULL`,
Prisma.sql`d.suchvektor @@ q.abfrage`,
]
if (optionen.art) bedingungen.push(Prisma.sql`d.art = ${optionen.art}::"DokumentArt"`)
if (optionen.richtung) {
bedingungen.push(Prisma.sql`d.richtung = ${optionen.richtung}::"DokumentRichtung"`)
}
if (optionen.posteingangStatus) {
bedingungen.push(
Prisma.sql`d."posteingangStatus" = ${optionen.posteingangStatus}::"PosteingangStatus"`,
)
}
if (optionen.von) {
bedingungen.push(Prisma.sql`coalesce(d."eingangAm", d."schreibenVom") >= ${optionen.von}`)
}
if (optionen.bis) {
bedingungen.push(Prisma.sql`coalesce(d."eingangAm", d."schreibenVom") <= ${optionen.bis}`)
}
if (optionen.tagId) {
bedingungen.push(
Prisma.sql`EXISTS (
SELECT 1 FROM "_DokumentTags" v
WHERE v."A" = d.id AND v."B" = ${optionen.tagId}
)`,
)
}
const zeilen = await db.$queryRaw<
(Omit<Suchtreffer, 'rang'> & { rang: number })[]
>(Prisma.sql`
SELECT
d.id,
d.titel,
d.art,
d.richtung,
d.absender,
d.aktenzeichen,
d.dateiname,
d."eingangAm",
d."schreibenVom",
d."posteingangStatus",
ts_headline(
'german',
coalesce(nullif(d."ocrText", ''), concat_ws(' ', d.titel, d.absender, d.aktenzeichen)),
q.abfrage,
'StartSel=<mark>, StopSel=</mark>, MaxWords=32, MinWords=12, ShortWord=3, MaxFragments=2, FragmentDelimiter=" … "'
) AS ausschnitt,
ts_rank_cd(d.suchvektor, q.abfrage) AS rang
FROM dokument d,
websearch_to_tsquery('german', ${begriff}) AS q(abfrage)
WHERE ${Prisma.join(bedingungen, ' AND ')}
ORDER BY rang DESC, coalesce(d."eingangAm", d."schreibenVom", d."erstelltAm"::date) DESC
LIMIT ${grenze}
`)
return zeilen.map((zeile) => ({ ...zeile, rang: Number(zeile.rang) }))
}
/** Zaehlt die Treffer, ohne sie zu laden - fuer "127 Treffer". */
export async function trefferZaehlen(betreuteId: string, anfrage: string): Promise<number> {
const begriff = suchanfrageAufbereiten(anfrage)
if (!begriff) return 0
const ergebnis = await db.$queryRaw<{ anzahl: bigint }[]>(Prisma.sql`
SELECT count(*) AS anzahl
FROM dokument d, websearch_to_tsquery('german', ${begriff}) AS q(abfrage)
WHERE d."betreuteId" = ${betreuteId}
AND d."geloeschtAm" IS NULL
AND d.suchvektor @@ q.abfrage
`)
return Number(ergebnis[0]?.anzahl ?? 0)
}
/**
* Vorschlaege fuer das Absenderfeld: unscharf ueber den Trigram-Index, so
* dass "Bezirk Oberfr" auch "Bezirk Oberfranken" findet.
*/
export async function absenderVorschlagen(
betreuteId: string,
teil: string,
grenze = 8,
): Promise<string[]> {
const sauber = (teil ?? '').trim().slice(0, 80)
if (sauber.length < 2) return []
const zeilen = await db.$queryRaw<{ absender: string }[]>(Prisma.sql`
SELECT DISTINCT d.absender
FROM dokument d
WHERE d."betreuteId" = ${betreuteId}
AND d."geloeschtAm" IS NULL
AND d.absender IS NOT NULL
AND d.absender % ${sauber}
ORDER BY d.absender
LIMIT ${Math.min(Math.max(grenze, 1), 25)}
`)
return zeilen.map((z) => z.absender)
}
/**
* Alle Absender, die in dieser Akte schon einmal vorkamen - Futter fuer
* die Erkennung beim naechsten Upload.
*/
export async function bekannteAbsender(betreuteId: string, grenze = 250): Promise<string[]> {
const [ausDokumenten, ausKontakten] = await Promise.all([
db.dokument.findMany({
where: { betreuteId, absender: { not: null }, ...nichtGeloescht },
select: { absender: true },
distinct: ['absender'],
take: grenze,
}),
db.kontakt.findMany({
where: { betreuteId, ...nichtGeloescht },
select: { name: true, organisation: true },
take: grenze,
}),
])
const namen = new Set<string>()
for (const eintrag of ausDokumenten) {
if (eintrag.absender) namen.add(eintrag.absender.trim())
}
for (const kontakt of ausKontakten) {
if (kontakt.organisation) namen.add(kontakt.organisation.trim())
if (kontakt.name) namen.add(kontakt.name.trim())
}
// Lange Namen zuerst: "Bezirk Oberfranken, Sozialverwaltung" ist ein
// besserer Treffer als das blosse "Bezirk Oberfranken".
return [...namen].filter((n) => n.length >= 3).sort((a, b) => b.length - a.length)
}
// -----------------------------------------------------------------------------
// Liste ohne Suchbegriff
// -----------------------------------------------------------------------------
export type Listenfilter = {
suche?: string | null
art?: DokumentArt | null
richtung?: DokumentRichtung | null
posteingangStatus?: PosteingangStatus | null
von?: Date | null
bis?: Date | null
tagId?: string | null
seite?: number
proSeite?: number
}
export function filterWhere(betreuteId: string, filter: Listenfilter): Prisma.DokumentWhereInput {
const where: Prisma.DokumentWhereInput = { betreuteId, ...nichtGeloescht }
if (filter.art) where.art = filter.art
if (filter.richtung) where.richtung = filter.richtung
if (filter.posteingangStatus) where.posteingangStatus = filter.posteingangStatus
if (filter.tagId) where.tags = { some: { id: filter.tagId } }
if (filter.von || filter.bis) {
const spanne: Prisma.DateTimeFilter = {}
if (filter.von) spanne.gte = filter.von
if (filter.bis) spanne.lte = filter.bis
where.OR = [{ eingangAm: spanne }, { eingangAm: null, schreibenVom: spanne }]
}
return where
}
export type Listeneintrag = {
id: string
titel: string
art: DokumentArt
richtung: DokumentRichtung
absender: string | null
aktenzeichen: string | null
dateiname: string | null
mimeTyp: string | null
groesseBytes: number | null
seitenzahl: number | null
eingangAm: Date | null
schreibenVom: Date | null
posteingangStatus: PosteingangStatus
ablageortPapier: string | null
ocrStatus: string | null
erstelltAm: Date
tags: { id: string; name: string; farbe: string }[]
anzahlVerknuepfungen: number
ausschnitt?: string
}
/**
* Die Dokumentenliste mit Filtern - und, wenn ein Suchbegriff gesetzt ist,
* in der Rangfolge der Volltextsuche.
*/
export async function dokumenteListe(
betreuteId: string,
filter: Listenfilter = {},
): Promise<{ eintraege: Listeneintrag[]; gesamt: number }> {
const proSeite = Math.min(Math.max(filter.proSeite ?? 25, 5), 100)
const seite = Math.max(filter.seite ?? 1, 1)
const begriff = suchanfrageAufbereiten(filter.suche)
if (begriff) {
const treffer = await dokumenteSuchen(betreuteId, begriff, {
grenze: 200,
art: filter.art,
richtung: filter.richtung,
posteingangStatus: filter.posteingangStatus,
von: filter.von,
bis: filter.bis,
tagId: filter.tagId,
})
const seitenTreffer = treffer.slice((seite - 1) * proSeite, seite * proSeite)
if (seitenTreffer.length === 0) return { eintraege: [], gesamt: treffer.length }
const datensaetze = await db.dokument.findMany({
where: { id: { in: seitenTreffer.map((t) => t.id) } },
include: {
tags: { select: { id: true, name: true, farbe: true } },
_count: { select: { verknuepfungen: true } },
},
})
const nachId = new Map(datensaetze.map((d) => [d.id, d]))
const eintraege = seitenTreffer
.map((t) => {
const datensatz = nachId.get(t.id)
if (!datensatz) return null
return { ...zuEintrag(datensatz), ausschnitt: t.ausschnitt }
})
.filter((e): e is Listeneintrag => e !== null)
return { eintraege, gesamt: treffer.length }
}
const where = filterWhere(betreuteId, filter)
const [datensaetze, gesamt] = await Promise.all([
db.dokument.findMany({
where,
include: {
tags: { select: { id: true, name: true, farbe: true } },
_count: { select: { verknuepfungen: true } },
},
orderBy: [{ eingangAm: 'desc' }, { erstelltAm: 'desc' }],
skip: (seite - 1) * proSeite,
take: proSeite,
}),
db.dokument.count({ where }),
])
return { eintraege: datensaetze.map(zuEintrag), gesamt }
}
type RohDatensatz = Prisma.DokumentGetPayload<{
include: {
tags: { select: { id: true; name: true; farbe: true } }
_count: { select: { verknuepfungen: true } }
}
}>
function zuEintrag(d: RohDatensatz): Listeneintrag {
return {
id: d.id,
titel: d.titel,
art: d.art,
richtung: d.richtung,
absender: d.absender,
aktenzeichen: d.aktenzeichen,
dateiname: d.dateiname,
mimeTyp: d.mimeTyp,
groesseBytes: d.groesseBytes,
seitenzahl: d.seitenzahl,
eingangAm: d.eingangAm,
schreibenVom: d.schreibenVom,
posteingangStatus: d.posteingangStatus,
ablageortPapier: d.ablageortPapier,
ocrStatus: d.ocrStatus,
erstelltAm: d.erstelltAm,
tags: d.tags,
anzahlVerknuepfungen: d._count.verknuepfungen,
}
}
+189
View File
@@ -0,0 +1,189 @@
/**
* Geldbetraege.
*
* In dieser Anwendung gibt es nur eine Darstellung von Geld: BigInt in
* Cent. Kein Float, kein Decimal, keine Zwischenrechnung in Euro. Der
* Grund steht im Schema: die Rechnungslegung nach § 1865 BGB darf keine
* Rundungsfehler erben, und beim Gericht wird auf den Cent geprueft.
*
* Hier stehen deshalb nur zwei Sorten von Funktionen: solche, die Text
* in Cent verwandeln (Formular, CSV-Import), und solche, die auf Cent
* rechnen, ohne dass ein Cent verloren geht.
*/
export class BetragFehler extends Error {
constructor(nachricht: string) {
super(nachricht)
this.name = 'BetragFehler'
}
}
const NICHT_ZIFFER_ODER_TRENNER = /[^0-9.,+-]/g
/**
* Liest einen Betrag aus Text und liefert Cent.
*
* Erkannt werden die Schreibweisen, die in deutschen Kontoauszuegen und
* CSV-Ausleitungen tatsaechlich vorkommen:
*
* "1.234,56" "1234,56" "-1.234,56" "1.234,56-" "1234,5"
* "1234" "€ 1.234,56" "1 234,56" "(1.234,56)"
* "1234.56" (englische Schreibweise, z.B. aus CAMT oder Excel)
*
* Regeln bei mehrdeutigen Trennzeichen:
*
* - Kommen Punkt UND Komma vor, ist das zuletzt stehende Zeichen das
* Dezimaltrennzeichen, das andere das Tausendertrennzeichen.
* - Ein alleinstehendes Komma ist immer Dezimaltrennzeichen (deutsch).
* - Ein alleinstehender Punkt ist Tausendertrennzeichen, wenn genau drei
* Ziffern folgen und Ziffern davorstehen ("1.234"), sonst
* Dezimaltrennzeichen ("1234.56"). Diese Anwendung liest deutsche
* Dateien; "1.234" heisst hier eintausendzweihundertvierunddreissig.
*
* Mehr als zwei Nachkommastellen sind ein Fehler und keine Rundung -
* wer aus 1,005 EUR eigenmaechtig 1,01 EUR macht, verschiebt Geld.
*/
export function betragParsen(eingabe: string | null | undefined): bigint {
if (eingabe === null || eingabe === undefined) {
throw new BetragFehler('Bitte einen Betrag angeben.')
}
let text = eingabe.trim()
if (text === '') throw new BetragFehler('Bitte einen Betrag angeben.')
// Buchhalterische Klammer: (12,34) bedeutet minus 12,34.
let negativ = false
if (/^\(.*\)$/.test(text)) {
negativ = true
text = text.slice(1, -1)
}
text = text.replace(NICHT_ZIFFER_ODER_TRENNER, '')
// Vorzeichen darf vorne oder hinten stehen ("1.234,56-" ist in
// Bankausleitungen ueblich).
if (text.startsWith('-')) {
negativ = !negativ
text = text.slice(1)
} else if (text.startsWith('+')) {
text = text.slice(1)
}
if (text.endsWith('-')) {
negativ = !negativ
text = text.slice(0, -1)
} else if (text.endsWith('+')) {
text = text.slice(0, -1)
}
if (text === '' || /[+-]/.test(text)) {
throw new BetragFehler(`"${eingabe}" ist kein Betrag. Beispiel: 1.234,56`)
}
const letztesKomma = text.lastIndexOf(',')
const letzterPunkt = text.lastIndexOf('.')
let dezimaltrenner: '.' | ',' | null = null
if (letztesKomma >= 0 && letzterPunkt >= 0) {
dezimaltrenner = letztesKomma > letzterPunkt ? ',' : '.'
} else if (letztesKomma >= 0) {
dezimaltrenner = ','
} else if (letzterPunkt >= 0) {
const nachkomma = text.length - letzterPunkt - 1
const punkteAnzahl = text.split('.').length - 1
const tausender = punkteAnzahl > 1 || (nachkomma === 3 && letzterPunkt > 0)
dezimaltrenner = tausender ? null : '.'
}
let ganz: string
let bruch: string
if (dezimaltrenner === null) {
ganz = text.replace(/[.,]/g, '')
bruch = ''
} else {
const teiler = dezimaltrenner === ',' ? letztesKomma : letzterPunkt
ganz = text.slice(0, teiler).replace(/[.,]/g, '')
bruch = text.slice(teiler + 1).replace(/[.,]/g, '')
}
if (!/^\d*$/.test(ganz) || !/^\d*$/.test(bruch) || ganz + bruch === '') {
throw new BetragFehler(`"${eingabe}" ist kein Betrag. Beispiel: 1.234,56`)
}
if (bruch.length > 2) {
throw new BetragFehler(
`"${eingabe}" hat mehr als zwei Nachkommastellen. Beträge werden auf den Cent geführt.`,
)
}
const cent = BigInt(ganz === '' ? '0' : ganz) * 100n + BigInt(bruch.padEnd(2, '0') || '0')
return negativ ? -cent : cent
}
/** Wie betragParsen, gibt aber null statt eines Fehlers zurueck. */
export function betragParsenWennMoeglich(eingabe: string | null | undefined): bigint | null {
try {
return betragParsen(eingabe)
} catch {
return null
}
}
/**
* Cent als deutsche Zahl ohne Waehrungszeichen ("1.234,56").
* Fuer Eingabefelder gedacht - zum Anzeigen gibt es formatCent().
*/
export function betragFormatieren(cent: bigint): string {
const negativ = cent < 0n
const betrag = negativ ? -cent : cent
const ganz = betrag / 100n
const rest = betrag % 100n
const ganzText = ganz
.toString()
.replace(/\B(?=(\d{3})+(?!\d))/g, '.')
return `${negativ ? '-' : ''}${ganzText},${rest.toString().padStart(2, '0')}`
}
/** Summe, ohne dass jemals ein Zwischenwert in Number landet. */
export function summe(werte: Iterable<bigint>): bigint {
let ergebnis = 0n
for (const wert of werte) ergebnis += wert
return ergebnis
}
/**
* Ganzzahlige Division mit kaufmaennischer Rundung (halbe Cent vom
* Nullpunkt weg). Wird fuer Durchschnitte gebraucht - etwa "laufende
* Einnahmen je Monat" im Vermoegensverzeichnis.
*/
export function teileGerundet(zaehler: bigint, nenner: bigint): bigint {
if (nenner === 0n) throw new BetragFehler('Division durch null.')
const negativ = zaehler < 0n !== nenner < 0n
const a = zaehler < 0n ? -zaehler : zaehler
const b = nenner < 0n ? -nenner : nenner
const ganz = a / b
const rest = a % b
const ergebnis = rest * 2n >= b ? ganz + 1n : ganz
return negativ ? -ergebnis : ergebnis
}
/**
* Verteilt einen Betrag auf n Teile, ohne dass ein Cent verschwindet:
* die Restcents werden auf die vorderen Teile verteilt. Die Summe der
* Rueckgabe ist immer exakt der Eingabebetrag.
*/
export function centAufteilen(gesamtCent: bigint, teile: number): bigint[] {
if (!Number.isInteger(teile) || teile <= 0) {
throw new BetragFehler('Ein Betrag lässt sich nur auf mindestens einen Teil aufteilen.')
}
const n = BigInt(teile)
const negativ = gesamtCent < 0n
const betrag = negativ ? -gesamtCent : gesamtCent
const grundanteil = betrag / n
const rest = betrag % n
const ergebnis: bigint[] = []
for (let i = 0; i < teile; i++) {
const anteil = grundanteil + (BigInt(i) < rest ? 1n : 0n)
ergebnis.push(negativ ? -anteil : anteil)
}
return ergebnis
}
+130
View File
@@ -0,0 +1,130 @@
import { alsTag, heute, jahrePlus, monatePlus, tagePlus, vorOderGleich } from '@/lib/finanzen/datum'
/**
* Das Betreuungsjahr.
*
* Es laeuft ab dem Tag der Bestellung, nicht ab dem 1. Januar. Wer am
* 15.03.2025 bestellt wurde, legt fuer den Zeitraum 15.03.2025 bis
* 14.03.2026 Rechnung (§ 1865 Abs. 1 BGB), und die Aufwandspauschale
* nach § 1878 BGB verfaellt sechs Monate nach dessen Ende.
*
* Das ist der haeufigste Rechenfehler in selbstgebauten Tabellen: man
* rechnet das Kalenderjahr ab und wundert sich, warum das Gericht
* nachfragt. Deshalb steht die Rechnung hier an genau einer Stelle.
*
* Schaltjahrfall: Ist die Bestellung am 29.02. erfolgt, gibt es in
* Nicht-Schaltjahren keinen 29.02. Das Jahr endet dann am 27.02. und das
* folgende beginnt am 28.02. - so entsteht weder eine Luecke noch eine
* Ueberschneidung, und jeder Tag gehoert zu genau einem Betreuungsjahr.
*/
export type Betreuungsjahr = {
/** 1 = das erste Jahr ab Bestellung. */
nummer: number
/** Erster Tag, einschliesslich. */
von: Date
/** Letzter Tag, einschliesslich. */
bis: Date
/** "1. Betreuungsjahr (15.03.2025 – 14.03.2026)" - fuer Auswahllisten. */
bezeichnung: string
}
function formatKurz(datum: Date): string {
const tag = String(datum.getUTCDate()).padStart(2, '0')
const monat = String(datum.getUTCMonth() + 1).padStart(2, '0')
return `${tag}.${monat}.${datum.getUTCFullYear()}`
}
function bauen(nummer: number, von: Date, bis: Date): Betreuungsjahr {
return {
nummer,
von,
bis,
bezeichnung: `${nummer}. Betreuungsjahr (${formatKurz(von)} – ${formatKurz(bis)})`,
}
}
/**
* Das n-te Betreuungsjahr. nummer ist 1-basiert; 1 ist das Jahr, das am
* Tag der Bestellung beginnt.
*/
export function betreuungsjahr(bestellungAm: Date, nummer: number): Betreuungsjahr {
if (!Number.isInteger(nummer) || nummer < 1) {
throw new Error('Das Betreuungsjahr wird ab 1 gezählt.')
}
const start = alsTag(bestellungAm)
const von = jahrePlus(start, nummer - 1)
const bis = tagePlus(jahrePlus(start, nummer), -1)
return bauen(nummer, von, bis)
}
/**
* In welches Betreuungsjahr faellt ein Datum? Null, wenn es vor der
* Bestellung liegt - dafuer gibt es keine Rechnungslegung.
*/
export function betreuungsjahrZuDatum(bestellungAm: Date, datum: Date): Betreuungsjahr | null {
const start = alsTag(bestellungAm)
const ziel = alsTag(datum)
if (ziel.getTime() < start.getTime()) return null
// Grobe Schaetzung ueber die Jahresdifferenz, danach hoechstens zwei
// Korrekturschritte - das haelt auch den Schaltjahrfall aus.
let nummer = ziel.getUTCFullYear() - start.getUTCFullYear() + 1
if (nummer < 1) nummer = 1
for (let versuch = 0; versuch < 4; versuch++) {
const jahr = betreuungsjahr(start, nummer)
if (ziel.getTime() < jahr.von.getTime()) {
nummer -= 1
if (nummer < 1) return null
continue
}
if (ziel.getTime() > jahr.bis.getTime()) {
nummer += 1
continue
}
return jahr
}
return null
}
/** Das Betreuungsjahr, in dem wir heute stehen. */
export function laufendesBetreuungsjahr(
bestellungAm: Date,
stichtag: Date = heute(),
): Betreuungsjahr | null {
return betreuungsjahrZuDatum(bestellungAm, stichtag)
}
/**
* Alle Betreuungsjahre von der Bestellung bis einschliesslich des Jahres,
* in dem "bis" liegt. Fuer die Auswahlliste im Berichtsformular - das
* laufende Jahr steht mit dabei, denn eine Zwischenaufstellung ist
* jederzeit erlaubt.
*/
export function betreuungsjahre(bestellungAm: Date, bis: Date = heute()): Betreuungsjahr[] {
const letztes = betreuungsjahrZuDatum(bestellungAm, bis)
if (!letztes) return []
const liste: Betreuungsjahr[] = []
for (let nummer = 1; nummer <= letztes.nummer; nummer++) {
liste.push(betreuungsjahr(bestellungAm, nummer))
}
return liste
}
/**
* Ist das Betreuungsjahr abgeschlossen? Nur dann ist die Rechnungslegung
* faellig; vorher ist der Bericht eine Zwischenaufstellung.
*/
export function jahrAbgeschlossen(jahr: Betreuungsjahr, stichtag: Date = heute()): boolean {
return vorOderGleich(jahr.bis, stichtag)
}
/**
* Ende der Ausschlussfrist fuer die Aufwandspauschale nach § 1878 BGB:
* sechs Monate nach dem Ende des Betreuungsjahres. Danach ist der
* Anspruch weg - kein Ermessen, keine Nachsicht.
*/
export function aufwandspauschaleBis(jahr: Betreuungsjahr): Date {
return monatePlus(jahr.bis, 6)
}
+357
View File
@@ -0,0 +1,357 @@
import type { BuchungRichtung } from '@prisma/client'
import { ImportFehler, importReferenzBilden, type ImportZeile } from '@/lib/finanzen/import'
/**
* CAMT.053 (Kontoauszug im ISO-20022-Format).
*
* Fast jede Bank bietet den elektronischen Kontoauszug neben CSV auch
* als CAMT-XML an. Der Vorteil: Betrag, Waehrung, Richtung und Datum
* sind dort eindeutig ausgezeichnet - es gibt kein "ist der Punkt jetzt
* ein Tausendertrennzeichen?" mehr. Deshalb ist dieser Weg der genauere.
*
* Gelesen wird bewusst nur ein kleiner, klar umrissener Ausschnitt:
* die <Ntry>-Elemente eines <Stmt>. Alles andere - Zwischensalden,
* Gebuehrendetails, strukturierte Referenzen - wird uebergangen.
*
* Der XML-Leser unten ist absichtlich klein gehalten und kennt keine
* Entity-Deklarationen und keine externen Referenzen. Das ist keine
* Bequemlichkeit, sondern Absicht: ein XML-Parser mit Entity-Aufloesung
* ist die klassische Stelle fuer XXE-Angriffe, und eine Kontoauszugs-
* datei kommt hier aus einem Download, den niemand geprueft hat.
*/
export type XmlKnoten = {
name: string
attribute: Record<string, string>
kinder: XmlKnoten[]
text: string
}
const ENTITAETEN: Record<string, string> = {
amp: '&',
lt: '<',
gt: '>',
quot: '"',
apos: "'",
}
function entitaetenAufloesen(text: string): string {
return text.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (treffer, name: string) => {
if (name.startsWith('#x') || name.startsWith('#X')) {
const code = Number.parseInt(name.slice(2), 16)
return Number.isFinite(code) ? String.fromCodePoint(code) : treffer
}
if (name.startsWith('#')) {
const code = Number.parseInt(name.slice(1), 10)
return Number.isFinite(code) ? String.fromCodePoint(code) : treffer
}
return ENTITAETEN[name] ?? treffer
})
}
/** Namensraumpraefix abschneiden: "ns:Ntry" wird zu "Ntry". */
function ortsname(name: string): string {
const doppelpunkt = name.indexOf(':')
return doppelpunkt >= 0 ? name.slice(doppelpunkt + 1) : name
}
function attributeLesen(roh: string): Record<string, string> {
const attribute: Record<string, string> = {}
const muster = /([A-Za-z_][\w.:-]*)\s*=\s*("([^"]*)"|'([^']*)')/g
let treffer: RegExpExecArray | null
while ((treffer = muster.exec(roh)) !== null) {
attribute[ortsname(treffer[1]!)] = entitaetenAufloesen(treffer[3] ?? treffer[4] ?? '')
}
return attribute
}
/**
* Minimaler XML-Leser. Kennt Elemente, Attribute, Text und CDATA.
* Prolog, Kommentare und Verarbeitungsanweisungen werden uebersprungen,
* Doctype-Deklarationen ausdruecklich abgelehnt.
*/
export function xmlLesen(quelle: string): XmlKnoten {
const text = quelle.charCodeAt(0) === 0xfeff ? quelle.slice(1) : quelle
if (/<!DOCTYPE/i.test(text)) {
throw new ImportFehler(
'Die Datei enthält eine DOCTYPE-Deklaration. Solche Dateien werden aus Sicherheitsgründen ' +
'nicht gelesen. Bitte den Kontoauszug erneut ohne Zusätze herunterladen.',
)
}
const wurzel: XmlKnoten = { name: '#dokument', attribute: {}, kinder: [], text: '' }
const stapel: XmlKnoten[] = [wurzel]
let i = 0
while (i < text.length) {
const start = text.indexOf('<', i)
if (start < 0) break
if (start > i) {
const inhalt = entitaetenAufloesen(text.slice(i, start))
const aktuell = stapel[stapel.length - 1]!
if (inhalt.trim() !== '') aktuell.text += inhalt
}
if (text.startsWith('<!--', start)) {
const ende = text.indexOf('-->', start)
if (ende < 0) break
i = ende + 3
continue
}
if (text.startsWith('<![CDATA[', start)) {
const ende = text.indexOf(']]>', start)
if (ende < 0) break
stapel[stapel.length - 1]!.text += text.slice(start + 9, ende)
i = ende + 3
continue
}
if (text.startsWith('<?', start)) {
const ende = text.indexOf('?>', start)
if (ende < 0) break
i = ende + 2
continue
}
const ende = text.indexOf('>', start)
if (ende < 0) break
const roh = text.slice(start + 1, ende)
i = ende + 1
if (roh.startsWith('/')) {
if (stapel.length > 1) stapel.pop()
continue
}
const selbstschliessend = roh.endsWith('/')
const koerper = selbstschliessend ? roh.slice(0, -1) : roh
const leerzeichen = koerper.search(/\s/)
const name = ortsname(leerzeichen < 0 ? koerper : koerper.slice(0, leerzeichen))
const attribute = leerzeichen < 0 ? {} : attributeLesen(koerper.slice(leerzeichen))
const knoten: XmlKnoten = { name, attribute, kinder: [], text: '' }
stapel[stapel.length - 1]!.kinder.push(knoten)
if (!selbstschliessend) stapel.push(knoten)
}
return wurzel
}
/** Erstes Kind mit diesem Namen, beliebig tief. */
export function knotenSuchen(knoten: XmlKnoten, name: string): XmlKnoten | null {
for (const kind of knoten.kinder) {
if (kind.name === name) return kind
const tiefer = knotenSuchen(kind, name)
if (tiefer) return tiefer
}
return null
}
/** Alle Knoten mit diesem Namen, beliebig tief. */
export function alleKnoten(knoten: XmlKnoten, name: string): XmlKnoten[] {
const gefunden: XmlKnoten[] = []
for (const kind of knoten.kinder) {
if (kind.name === name) gefunden.push(kind)
gefunden.push(...alleKnoten(kind, name))
}
return gefunden
}
function textVon(knoten: XmlKnoten | null): string | null {
if (!knoten) return null
const wert = knoten.text.replace(/\s+/g, ' ').trim()
return wert === '' ? null : wert
}
/**
* Betraege in ISO-20022 sind eindeutig: Punkt als Dezimaltrennzeichen,
* kein Tausendertrennzeichen, hoechstens fuenf Nachkommastellen. Fuer
* Euro erwarten wir zwei - mehr waere ein Bruchteil eines Cents und
* gehoert nicht in ein Kassenbuch.
*/
export function dezimalZuCent(text: string): bigint {
const bereinigt = text.trim()
const treffer = /^(-?)(\d+)(?:\.(\d+))?$/.exec(bereinigt)
if (!treffer) {
throw new ImportFehler(`"${text}" ist kein gültiger Betrag im CAMT-Format.`)
}
const bruch = treffer[3] ?? ''
if (bruch.replace(/0+$/, '').length > 2) {
throw new ImportFehler(
`"${text}" hat mehr als zwei Nachkommastellen. Bruchteile eines Cents sind nicht buchbar.`,
)
}
const cent = BigInt(treffer[2]!) * 100n + BigInt(bruch.padEnd(2, '0').slice(0, 2) || '0')
return treffer[1] === '-' ? -cent : cent
}
export type CamtErgebnis = {
/** IBAN des Kontos aus dem Auszug - hilft beim Zuordnen. */
iban: string | null
/** Kontowaehrung, sofern angegeben. */
waehrung: string | null
zeilen: ImportZeile[]
fehler: { zeilennummer: number; meldung: string; rohzeile: string[] }[]
fremdwaehrung: number
}
/**
* Liest die Umsaetze aus einer CAMT.053-Datei.
*
* Die Zeilennummer ist hier die laufende Nummer des <Ntry>-Elements -
* eine Zeilennummer im Sinne einer Textdatei gibt es bei XML nicht.
*/
export function camtZuBuchungen(inhalt: Buffer | Uint8Array | string): CamtErgebnis {
const quelle =
typeof inhalt === 'string'
? inhalt
: Buffer.isBuffer(inhalt)
? inhalt.toString('utf8')
: Buffer.from(inhalt).toString('utf8')
const dokument = xmlLesen(quelle)
const auszug = knotenSuchen(dokument, 'Stmt')
if (!auszug) {
throw new ImportFehler(
'In der Datei ist kein Kontoauszug (<Stmt>) zu finden. Erwartet wird CAMT.053.',
)
}
const konto = knotenSuchen(auszug, 'Acct')
const iban = konto ? textVon(knotenSuchen(konto, 'IBAN')) : null
const waehrung = konto ? textVon(knotenSuchen(konto, 'Ccy')) : null
const zeilen: ImportZeile[] = []
const fehler: CamtErgebnis['fehler'] = []
let fremdwaehrung = 0
const schluesselZaehler = new Map<string, number>()
const eintraege = alleKnoten(auszug, 'Ntry')
eintraege.forEach((eintrag, i) => {
const nummer = i + 1
const betragKnoten = eintrag.kinder.find((k) => k.name === 'Amt') ?? knotenSuchen(eintrag, 'Amt')
const betragText = textVon(betragKnoten)
if (!betragText) {
fehler.push({ zeilennummer: nummer, meldung: 'Im Umsatz fehlt der Betrag.', rohzeile: [] })
return
}
const ccy = betragKnoten?.attribute['Ccy'] ?? waehrung
if (ccy && ccy.toUpperCase() !== 'EUR') {
fremdwaehrung++
fehler.push({
zeilennummer: nummer,
meldung: `Währung "${ccy}" wird nicht übernommen. Diese Anwendung rechnet in Euro.`,
rohzeile: [],
})
return
}
let betragCent: bigint
try {
betragCent = dezimalZuCent(betragText)
} catch (ausnahme) {
fehler.push({
zeilennummer: nummer,
meldung: ausnahme instanceof Error ? ausnahme.message : 'Der Betrag ist nicht lesbar.',
rohzeile: [],
})
return
}
if (betragCent < 0n) betragCent = -betragCent
if (betragCent === 0n) {
fehler.push({
zeilennummer: nummer,
meldung: 'Der Betrag ist 0,00 € – der Umsatz wurde übersprungen.',
rohzeile: [],
})
return
}
const kennzeichen = textVon(
eintrag.kinder.find((k) => k.name === 'CdtDbtInd') ?? knotenSuchen(eintrag, 'CdtDbtInd'),
)
if (kennzeichen !== 'CRDT' && kennzeichen !== 'DBIT') {
fehler.push({
zeilennummer: nummer,
meldung: 'Im Umsatz fehlt die Angabe, ob es eine Gutschrift oder eine Belastung ist.',
rohzeile: [],
})
return
}
const richtung: BuchungRichtung = kennzeichen === 'CRDT' ? 'EINNAHME' : 'AUSGABE'
const buchungsdatum = datumAusKnoten(eintrag, 'BookgDt')
const valuta = datumAusKnoten(eintrag, 'ValDt')
const datum = buchungsdatum ?? valuta
if (!datum) {
fehler.push({ zeilennummer: nummer, meldung: 'Im Umsatz fehlt das Buchungsdatum.', rohzeile: [] })
return
}
const gegenstelle = gegenstelleLesen(eintrag, richtung)
const zweckTeile = alleKnoten(eintrag, 'Ustrd')
.map((k) => textVon(k))
.filter((t): t is string => t !== null)
const zusatz = textVon(knotenSuchen(eintrag, 'AddtlNtryInf'))
const verwendungszweck =
zweckTeile.length > 0 ? zweckTeile.join(' ') : zusatz
const grundschluessel = importReferenzBilden({
datum,
betragCent,
richtung,
gegenstelle,
verwendungszweck,
}).replace(/^csv:/, 'camt:')
const bisher = schluesselZaehler.get(grundschluessel) ?? 0
schluesselZaehler.set(grundschluessel, bisher + 1)
zeilen.push({
zeilennummer: nummer,
datum,
wertstellung: valuta,
betragCent,
richtung,
gegenstelle,
verwendungszweck,
importReferenz: bisher === 0 ? grundschluessel : `${grundschluessel}#${bisher + 1}`,
dublette: false,
})
})
return { iban, waehrung, zeilen, fehler, fremdwaehrung }
}
function datumAusKnoten(eintrag: XmlKnoten, name: string): Date | null {
const knoten = knotenSuchen(eintrag, name)
if (!knoten) return null
const text = textVon(knotenSuchen(knoten, 'Dt')) ?? textVon(knotenSuchen(knoten, 'DtTm'))
if (!text) return null
const treffer = /^(\d{4})-(\d{2})-(\d{2})/.exec(text)
if (!treffer) return null
return new Date(
Date.UTC(
Number.parseInt(treffer[1]!, 10),
Number.parseInt(treffer[2]!, 10) - 1,
Number.parseInt(treffer[3]!, 10),
),
)
}
/**
* Bei einer Gutschrift ist die Gegenstelle der Auftraggeber (Dbtr),
* bei einer Belastung der Empfaenger (Cdtr). Wer das vertauscht, hat im
* Kassenbuch ueberall den eigenen Namen stehen.
*/
function gegenstelleLesen(eintrag: XmlKnoten, richtung: BuchungRichtung): string | null {
const parteien = knotenSuchen(eintrag, 'RltdPties')
if (!parteien) return null
const gesucht = richtung === 'EINNAHME' ? 'Dbtr' : 'Cdtr'
const partei = knotenSuchen(parteien, gesucht)
return partei ? textVon(knotenSuchen(partei, 'Nm')) : null
}
+100
View File
@@ -0,0 +1,100 @@
import { addDays, addMonths, addYears, differenceInCalendarDays } from 'date-fns'
/**
* Tagesdatum-Rechnung fuer die @db.Date-Felder des Finanzmoduls.
*
* Stichtage, Buchungsdaten und Bestellungsdatum sind reine Kalendertage
* ohne Uhrzeit. Prisma liefert sie als Date auf UTC-Mitternacht. Rechnet
* man darauf mit den lokalen date-fns-Funktionen, verschiebt sich in
* jeder Zeitzone westlich von Greenwich der Tag - und eine Frist, ein
* Betreuungsjahr oder ein Stichtag waere um einen Tag falsch.
*
* Deshalb laeuft jede Rechnung ueber einen "Spiegel": das Datum wird auf
* die lokale Mittagszeit desselben Kalendertages gelegt, dort mit
* date-fns gerechnet (so bleibt die Konvention "Datumsrechnung immer ueber
* date-fns" gewahrt) und danach wieder auf UTC-Mitternacht zurueckgelegt.
* Mittag statt Mitternacht, weil es Zeitzonen gibt, in denen die
* Sommerzeit um 00:00 umgestellt wird und die lokale Mitternacht dann
* gar nicht existiert.
*/
function inSpiegel(datum: Date): Date {
return new Date(datum.getUTCFullYear(), datum.getUTCMonth(), datum.getUTCDate(), 12, 0, 0, 0)
}
function ausSpiegel(datum: Date): Date {
return new Date(Date.UTC(datum.getFullYear(), datum.getMonth(), datum.getDate()))
}
/** Schneidet eine eventuelle Uhrzeit ab: UTC-Mitternacht desselben Tages. */
export function alsTag(datum: Date): Date {
return new Date(Date.UTC(datum.getUTCFullYear(), datum.getUTCMonth(), datum.getUTCDate()))
}
/** Der heutige Kalendertag - gelesen aus der lokalen Uhr des Servers. */
export function heute(): Date {
const jetzt = new Date()
return new Date(Date.UTC(jetzt.getFullYear(), jetzt.getMonth(), jetzt.getDate()))
}
/** "2026-03-15" aus einem Datumsfeld des Formulars. Wirft bei Unsinn nicht. */
export function tagAusText(text: string | null | undefined): Date | null {
if (!text) return null
const treffer = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text.trim())
if (!treffer) return null
const jahr = Number.parseInt(treffer[1]!, 10)
const monat = Number.parseInt(treffer[2]!, 10)
const tag = Number.parseInt(treffer[3]!, 10)
if (monat < 1 || monat > 12 || tag < 1 || tag > 31) return null
const datum = new Date(Date.UTC(jahr, monat - 1, tag))
// 31.02. faengt die Rueckrechnung ab.
if (datum.getUTCMonth() !== monat - 1 || datum.getUTCDate() !== tag) return null
return datum
}
/** Gegenstueck fuer den value eines <input type="date">. */
export function tagAlsText(datum: Date | null | undefined): string {
if (!datum) return ''
const jahr = String(datum.getUTCFullYear()).padStart(4, '0')
const monat = String(datum.getUTCMonth() + 1).padStart(2, '0')
const tag = String(datum.getUTCDate()).padStart(2, '0')
return `${jahr}-${monat}-${tag}`
}
export function tagePlus(datum: Date, tage: number): Date {
return ausSpiegel(addDays(inSpiegel(datum), tage))
}
export function monatePlus(datum: Date, monate: number): Date {
return ausSpiegel(addMonths(inSpiegel(datum), monate))
}
export function jahrePlus(datum: Date, jahre: number): Date {
return ausSpiegel(addYears(inSpiegel(datum), jahre))
}
/** Ganze Kalendertage zwischen zwei Tagesdaten. Negativ, wenn b vor a liegt. */
export function tageZwischen(a: Date, b: Date): number {
return differenceInCalendarDays(inSpiegel(b), inSpiegel(a))
}
export function vorOderGleich(a: Date, b: Date): boolean {
return alsTag(a).getTime() <= alsTag(b).getTime()
}
export function nachOderGleich(a: Date, b: Date): boolean {
return alsTag(a).getTime() >= alsTag(b).getTime()
}
/** Beide Grenzen einschliesslich. */
export function imZeitraum(datum: Date, von: Date, bis: Date): boolean {
const t = alsTag(datum).getTime()
return t >= alsTag(von).getTime() && t <= alsTag(bis).getTime()
}
/** Aeltestes zuerst. */
export function nachTagSortieren<T>(werte: T[], schluessel: (wert: T) => Date): T[] {
return [...werte].sort(
(a, b) => alsTag(schluessel(a)).getTime() - alsTag(schluessel(b)).getTime(),
)
}
+149
View File
@@ -0,0 +1,149 @@
/**
* IBAN-Pruefung nach ISO 13616 - selbst gerechnet, ohne Paket.
*
* Warum ueberhaupt pruefen? Weil eine falsch abgetippte IBAN im
* Vermoegensverzeichnis beim Gericht landet und weil der Blindindex
* (siehe blindindex() in src/lib/crypto/hash.ts) nur dann als "kenne ich
* dieses Konto schon?" taugt, wenn die IBAN vorher normalisiert wurde.
* Ein Leerzeichen zu viel wuerde sonst ein zweites, scheinbar neues
* Konto erzeugen.
*
* Das Verfahren: die ersten vier Zeichen (Laendercode + Pruefziffer) ans
* Ende stellen, jeden Buchstaben durch seine Position im Alphabet + 9
* ersetzen (A=10 ... Z=35) und den entstandenen Riesen-Zahlstring modulo
* 97 rechnen. Ergebnis muss 1 sein.
*/
/**
* Laenge der IBAN je Land. Ohne diese Tabelle wuerde eine zu kurze oder
* zu lange IBAN durchrutschen, wenn die Pruefziffer zufaellig passt.
* Quelle: IBAN-Registry des SWIFT (Stand 2024).
*/
export const IBAN_LAENGEN: Record<string, number> = {
AD: 24, AE: 23, AL: 28, AT: 20, AZ: 28, BA: 20, BE: 16, BG: 22, BH: 22, BI: 27,
BR: 29, BY: 28, CH: 21, CR: 22, CY: 28, CZ: 24, DE: 22, DJ: 27, DK: 18, DO: 28,
EE: 20, EG: 29, ES: 24, FI: 18, FO: 18, FR: 27, GB: 22, GE: 22, GI: 23, GL: 18,
GR: 27, GT: 28, HR: 21, HU: 28, IE: 22, IL: 23, IQ: 23, IS: 26, IT: 27, JO: 30,
KW: 30, KZ: 20, LB: 28, LC: 32, LI: 21, LT: 20, LU: 20, LV: 21, LY: 25, MC: 27,
MD: 24, ME: 22, MK: 19, MR: 27, MT: 31, MU: 30, NL: 18, NO: 15, PK: 24, PL: 28,
PS: 29, PT: 25, QA: 29, RO: 24, RS: 22, RU: 33, SA: 24, SC: 31, SD: 18, SE: 24,
SI: 19, SK: 24, SM: 27, SO: 23, ST: 25, SV: 28, TL: 23, TN: 24, TR: 26, UA: 29,
VA: 22, VG: 24, XK: 20,
}
/** Grossbuchstaben, keine Leerzeichen - so und nicht anders wird gespeichert. */
export function ibanNormalisieren(iban: string | null | undefined): string {
if (!iban) return ''
return iban.replace(/[\s  -]/g, '').toUpperCase()
}
/** Vierergruppen, wie sie auf jedem Kontoauszug stehen. */
export function ibanFormatieren(iban: string | null | undefined): string {
const roh = ibanNormalisieren(iban)
if (roh === '') return ''
return roh.replace(/(.{4})/g, '$1 ').trim()
}
/**
* Fuer Listen und Berichte: DE12 **** **** 3456. Die vollstaendige IBAN
* steht nur dort, wo sie gebraucht wird - im Vermoegensverzeichnis und
* im Kontodetail.
*/
export function ibanMaskieren(iban: string | null | undefined): string {
const roh = ibanNormalisieren(iban)
if (roh.length < 8) return roh
const anfang = roh.slice(0, 4)
const ende = roh.slice(-4)
const versteckt = '*'.repeat(Math.max(0, roh.length - 8))
return ibanFormatieren(`${anfang}${versteckt}${ende}`)
}
/** Modulo 97 ueber den Zahlstring, ziffernweise - kein BigInt noetig. */
function modulo97(zahlstring: string): number {
let rest = 0
for (const zeichen of zahlstring) {
rest = (rest * 10 + (zeichen.charCodeAt(0) - 48)) % 97
}
return rest
}
export type IbanPruefung =
| { gueltig: true; iban: string; land: string; formatiert: string }
| { gueltig: false; meldung: string }
/**
* Prueft eine IBAN vollstaendig: Zeichenvorrat, Laenderlaenge und
* Pruefziffer. Die Meldungen sagen, was zu tun ist, nicht was falsch war.
*/
export function ibanPruefen(eingabe: string | null | undefined): IbanPruefung {
const iban = ibanNormalisieren(eingabe)
if (iban === '') {
return { gueltig: false, meldung: 'Bitte eine IBAN angeben.' }
}
if (!/^[A-Z0-9]+$/.test(iban)) {
return {
gueltig: false,
meldung: 'Die IBAN darf nur Buchstaben und Ziffern enthalten.',
}
}
if (!/^[A-Z]{2}[0-9]{2}[A-Z0-9]+$/.test(iban)) {
return {
gueltig: false,
meldung: 'Eine IBAN beginnt mit zwei Buchstaben für das Land und zwei Prüfziffern, z.B. DE89.',
}
}
const land = iban.slice(0, 2)
const erwartet = IBAN_LAENGEN[land]
if (erwartet === undefined) {
return {
gueltig: false,
meldung: `Das Länderkürzel "${land}" kenne ich nicht. Bitte die IBAN prüfen.`,
}
}
if (iban.length !== erwartet) {
return {
gueltig: false,
meldung: `Eine ${land}-IBAN hat ${erwartet} Zeichen, diese hat ${iban.length}.`,
}
}
const umgestellt = iban.slice(4) + iban.slice(0, 4)
let zahlstring = ''
for (const zeichen of umgestellt) {
const code = zeichen.charCodeAt(0)
zahlstring += code >= 65 ? String(code - 55) : zeichen
}
if (modulo97(zahlstring) !== 1) {
return {
gueltig: false,
meldung: 'Die Prüfziffer stimmt nicht. Vermutlich ein Zahlendreher – bitte noch einmal vergleichen.',
}
}
return { gueltig: true, iban, land, formatiert: ibanFormatieren(iban) }
}
/** Kurzform fuer Bedingungen. */
export function ibanGueltig(eingabe: string | null | undefined): boolean {
return ibanPruefen(eingabe).gueltig
}
/**
* BIC nach ISO 9362: 8 oder 11 Stellen. Wird nur geprueft, nicht
* erzwungen - fuer eine Barkasse oder ein Verwahrgeldkonto im Heim gibt
* es keinen BIC.
*/
export function bicGueltig(eingabe: string | null | undefined): boolean {
if (!eingabe || eingabe.trim() === '') return true
const bic = eingabe.replace(/\s+/g, '').toUpperCase()
return /^[A-Z]{6}[A-Z0-9]{2}([A-Z0-9]{3})?$/.test(bic)
}
export function bicNormalisieren(eingabe: string | null | undefined): string | null {
if (!eingabe) return null
const bic = eingabe.replace(/\s+/g, '').toUpperCase()
return bic === '' ? null : bic
}
+572
View File
@@ -0,0 +1,572 @@
import { createHash } from 'node:crypto'
import type { BuchungRichtung } from '@prisma/client'
import { betragParsen, BetragFehler } from '@/lib/finanzen/betrag'
import { tagAlsText } from '@/lib/finanzen/datum'
/**
* Import von Kontoumsaetzen aus CSV.
*
* Kontoauszuege abzutippen ist die Stelle, an der in der Rechnungslegung
* die Fehler entstehen - ein Zahlendreher pro hundert Zeilen reicht, und
* die Rechenprobe geht nicht mehr auf. Deshalb dieser Import.
*
* Er macht bewusst keine Annahmen ueber "das Format der Sparkasse" oder
* "das Format der Volksbank". Stattdessen wird erkannt:
*
* - das Trennzeichen (Semikolon, Komma, Tabulator, senkrechter Strich),
* - die Kodierung (UTF-8 oder Windows-1252/ISO-8859-1),
* - die Spalten anhand der Ueberschriften,
* - deutsche Zahlen ("1.234,56", auch mit nachgestelltem Minus),
* - die ueblichen Datumsformate.
*
* Nichts wird stillschweigend geraten: was nicht erkannt wird, kommt als
* Fehlerzeile zurueck und wird dem Nutzer gezeigt.
*
* Dublettenschutz laeuft ueber importReferenz. Der Schluessel ist ein
* Hash ueber Datum, Betrag, Gegenstelle und Verwendungszweck; kommt
* dieselbe Kombination in einer Datei mehrfach vor - zwei gleiche
* Barabhebungen am selben Tag sind voellig normal -, bekommt jede
* Wiederholung eine laufende Nummer angehaengt. So werden echte
* Doppelbuchungen erkannt, ohne echte Wiederholungen zu verschlucken.
*/
export type Trennzeichen = ';' | ',' | '\t' | '|'
const TRENNZEICHEN_KANDIDATEN: Trennzeichen[] = [';', ',', '\t', '|']
export type Spaltenzuordnung = {
datum: number
wertstellung: number | null
betrag: number | null
/** Getrennte Soll-/Haben-Spalten, wie sie manche Ausleitungen haben. */
soll: number | null
haben: number | null
/** Spalte mit "S"/"H" bzw. "Soll"/"Haben". */
sollHaben: number | null
gegenstelle: number | null
verwendungszweck: number | null
buchungstext: number | null
waehrung: number | null
}
export type CsvErkennung = {
trennzeichen: Trennzeichen
kopfzeile: string[]
zuordnung: Spaltenzuordnung
datenzeilen: string[][]
/** Zeilennummer der Kopfzeile in der Datei, ab 1 gezaehlt. */
kopfzeileNummer: number
}
export class ImportFehler extends Error {
constructor(nachricht: string) {
super(nachricht)
this.name = 'ImportFehler'
}
}
// -----------------------------------------------------------------------------
// Kodierung und Zerlegung
// -----------------------------------------------------------------------------
/**
* Liest den Dateiinhalt als Text. Viele Banken liefern bis heute
* Windows-1252; kommt beim UTF-8-Versuch ein Ersatzzeichen heraus, wird
* auf latin1 zurueckgeschaltet - dann stimmen die Umlaute wieder.
*/
export function csvTextLesen(inhalt: Buffer | Uint8Array | string): string {
if (typeof inhalt === 'string') return bomEntfernen(inhalt)
const puffer = Buffer.isBuffer(inhalt) ? inhalt : Buffer.from(inhalt)
const alsUtf8 = puffer.toString('utf8')
if (alsUtf8.includes('�')) return bomEntfernen(puffer.toString('latin1'))
return bomEntfernen(alsUtf8)
}
function bomEntfernen(text: string): string {
return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text
}
/**
* Zerlegt CSV nach RFC 4180: Anfuehrungszeichen schuetzen Trennzeichen
* und Zeilenumbrueche, ein doppeltes Anfuehrungszeichen im Feld steht
* fuer ein einzelnes.
*/
export function csvZerlegen(text: string, trennzeichen: Trennzeichen): string[][] {
const zeilen: string[][] = []
let zeile: string[] = []
let feld = ''
let inAnfuehrung = false
for (let i = 0; i < text.length; i++) {
const zeichen = text[i]!
if (inAnfuehrung) {
if (zeichen === '"') {
if (text[i + 1] === '"') {
feld += '"'
i++
} else {
inAnfuehrung = false
}
} else {
feld += zeichen
}
continue
}
if (zeichen === '"' && feld === '') {
inAnfuehrung = true
} else if (zeichen === trennzeichen) {
zeile.push(feld)
feld = ''
} else if (zeichen === '\n' || zeichen === '\r') {
if (zeichen === '\r' && text[i + 1] === '\n') i++
zeile.push(feld)
feld = ''
zeilen.push(zeile)
zeile = []
} else {
feld += zeichen
}
}
if (feld !== '' || zeile.length > 0) {
zeile.push(feld)
zeilen.push(zeile)
}
return zeilen.filter((z) => z.some((f) => f.trim() !== ''))
}
/**
* Welches Trennzeichen? Gewaehlt wird das, bei dem die meisten Zeilen
* dieselbe Spaltenzahl haben - das ist verlaesslicher als blosses
* Zaehlen, weil in Verwendungszwecken oft Kommas stehen.
*/
export function trennzeichenErkennen(text: string): Trennzeichen {
let bestes: Trennzeichen = ';'
let bewertung = -1
for (const kandidat of TRENNZEICHEN_KANDIDATEN) {
const zeilen = csvZerlegen(text, kandidat).slice(0, 20)
if (zeilen.length === 0) continue
const spalten = zeilen.map((z) => z.length)
const haeufigste = spalten
.sort((a, b) => a - b)
[Math.floor(spalten.length / 2)]!
if (haeufigste < 2) continue
const passend = zeilen.filter((z) => z.length === haeufigste).length
const punkte = haeufigste * 100 + passend
if (punkte > bewertung) {
bewertung = punkte
bestes = kandidat
}
}
return bestes
}
// -----------------------------------------------------------------------------
// Spaltenerkennung
// -----------------------------------------------------------------------------
function normalisieren(text: string): string {
return text
.toLowerCase()
.replace(/ä/g, 'ae')
.replace(/ö/g, 'oe')
.replace(/ü/g, 'ue')
.replace(/ß/g, 'ss')
.replace(/[^a-z0-9]/g, '')
}
/** Reihenfolge zaehlt: der erste Treffer gewinnt. */
const SPALTEN_MUSTER: { feld: keyof Spaltenzuordnung; begriffe: string[] }[] = [
{ feld: 'wertstellung', begriffe: ['wertstellung', 'valutadatum', 'valuta', 'wertstellungsdatum'] },
{ feld: 'datum', begriffe: ['buchungstag', 'buchungsdatum', 'belegdatum', 'datum', 'bookingdate'] },
{ feld: 'soll', begriffe: ['soll', 'debit', 'ausgang', 'ausgabe', 'lastschrift'] },
{ feld: 'haben', begriffe: ['haben', 'credit', 'eingang', 'einnahme', 'gutschrift'] },
{ feld: 'sollHaben', begriffe: ['sollhaben', 'sh', 'sollhabenkennzeichen', 'debitcredit'] },
{
feld: 'betrag',
begriffe: ['betrag', 'umsatz', 'amount', 'betrageur', 'betragineur', 'umsatzineur'],
},
{
feld: 'gegenstelle',
begriffe: [
'beguenstigterzahlungspflichtiger',
'beguenstigter',
'zahlungspflichtiger',
'auftraggeber',
'empfaenger',
'zahlungsempfaenger',
'name',
'kontoinhaber',
'partnername',
],
},
{
feld: 'verwendungszweck',
begriffe: ['verwendungszweck', 'vorgangverwendungszweck', 'zweck', 'referenz', 'remittanceinformation'],
},
{ feld: 'buchungstext', begriffe: ['buchungstext', 'vorgang', 'umsatzart', 'transaktionsart'] },
{ feld: 'waehrung', begriffe: ['waehrung', 'currency', 'waehrungskennzeichen'] },
]
function spaltenZuordnen(kopf: string[]): Spaltenzuordnung {
const normalisiert = kopf.map((k) => normalisieren(k))
const belegt = new Set<number>()
const treffer: Partial<Record<keyof Spaltenzuordnung, number>> = {}
/**
* Zwei Durchlaeufe: erst exakte Ueberschriften, dann Teiltreffer. Ohne
* diese Reihenfolge wuerde "Soll/Haben" als Betragsspalte "Soll"
* gelesen - und jede Gutschrift waere eine Ausgabe.
*/
const suchen = (passt: (ueberschrift: string, begriff: string) => boolean) => {
for (const muster of SPALTEN_MUSTER) {
if (treffer[muster.feld] !== undefined) continue
for (const begriff of muster.begriffe) {
const index = normalisiert.findIndex(
(k, i) => !belegt.has(i) && k !== '' && passt(k, begriff),
)
if (index >= 0) {
treffer[muster.feld] = index
belegt.add(index)
break
}
}
}
}
suchen((k, begriff) => k === begriff)
// Kurze Begriffe wie "sh" oder "name" nur exakt - sonst treffen sie
// in halben Ueberschriften mit.
suchen((k, begriff) => begriff.length >= 5 && k.includes(begriff))
// Gibt es nur eine Valuta-Spalte und kein Buchungsdatum, ist die
// Valuta das Datum. Besser eine belegte Spalte als ein Abbruch.
if (treffer.datum === undefined && treffer.wertstellung !== undefined) {
treffer.datum = treffer.wertstellung
treffer.wertstellung = undefined
}
return {
datum: treffer.datum ?? -1,
wertstellung: treffer.wertstellung ?? null,
betrag: treffer.betrag ?? null,
soll: treffer.soll ?? null,
haben: treffer.haben ?? null,
sollHaben: treffer.sollHaben ?? null,
gegenstelle: treffer.gegenstelle ?? null,
verwendungszweck: treffer.verwendungszweck ?? null,
buchungstext: treffer.buchungstext ?? null,
waehrung: treffer.waehrung ?? null,
}
}
/**
* Findet die Kopfzeile. Manche Ausleitungen stellen der Tabelle noch
* zwei, drei Zeilen mit Kontonummer und Zeitraum voran - deshalb wird
* nicht blind die erste Zeile genommen, sondern die erste, in der sich
* eine Datums- und eine Betragsspalte erkennen laesst.
*/
export function csvErkennen(inhalt: Buffer | Uint8Array | string): CsvErkennung {
const text = csvTextLesen(inhalt)
if (text.trim() === '') throw new ImportFehler('Die Datei ist leer.')
const trennzeichen = trennzeichenErkennen(text)
const zeilen = csvZerlegen(text, trennzeichen)
if (zeilen.length === 0) throw new ImportFehler('Die Datei enthält keine lesbaren Zeilen.')
for (let i = 0; i < Math.min(zeilen.length, 10); i++) {
const kopf = zeilen[i]!
const zuordnung = spaltenZuordnen(kopf)
const hatBetrag =
zuordnung.betrag !== null || zuordnung.soll !== null || zuordnung.haben !== null
if (zuordnung.datum >= 0 && hatBetrag) {
return {
trennzeichen,
kopfzeile: kopf,
zuordnung,
datenzeilen: zeilen.slice(i + 1),
kopfzeileNummer: i + 1,
}
}
}
throw new ImportFehler(
'In der Datei ist keine Kopfzeile mit einer Datums- und einer Betragsspalte zu finden. ' +
'Bitte die Datei aus dem Online-Banking noch einmal als CSV mit Spaltenüberschriften ausgeben.',
)
}
// -----------------------------------------------------------------------------
// Datum
// -----------------------------------------------------------------------------
/**
* Liest die Datumsformate, die in Bankausleitungen vorkommen.
* Zweistellige Jahre: 00-79 heisst 20xx, 80-99 heisst 19xx - vor 1980
* gab es keine CSV-Ausleitung, und ein Kontoauszug von 2079 kommt fuer
* diese Anwendung nicht mehr in Betracht.
*/
export function datumParsen(eingabe: string | null | undefined): Date | null {
if (!eingabe) return null
const text = eingabe.trim()
if (text === '') return null
let jahr: number
let monat: number
let tag: number
const deutsch = /^(\d{1,2})[.\/-](\d{1,2})[.\/-](\d{2}|\d{4})$/.exec(text)
const iso = /^(\d{4})-(\d{1,2})-(\d{1,2})(?:[T ].*)?$/.exec(text)
if (deutsch) {
tag = Number.parseInt(deutsch[1]!, 10)
monat = Number.parseInt(deutsch[2]!, 10)
const jahrText = deutsch[3]!
jahr =
jahrText.length === 4
? Number.parseInt(jahrText, 10)
: Number.parseInt(jahrText, 10) < 80
? 2000 + Number.parseInt(jahrText, 10)
: 1900 + Number.parseInt(jahrText, 10)
} else if (iso) {
jahr = Number.parseInt(iso[1]!, 10)
monat = Number.parseInt(iso[2]!, 10)
tag = Number.parseInt(iso[3]!, 10)
} else {
return null
}
if (monat < 1 || monat > 12 || tag < 1 || tag > 31) return null
const datum = new Date(Date.UTC(jahr, monat - 1, tag))
if (datum.getUTCMonth() !== monat - 1 || datum.getUTCDate() !== tag) return null
return datum
}
// -----------------------------------------------------------------------------
// Umwandlung in Buchungen
// -----------------------------------------------------------------------------
export type ImportZeile = {
/** Zeilennummer in der Datei, ab 1 - damit der Nutzer sie wiederfindet. */
zeilennummer: number
datum: Date
wertstellung: Date | null
/** Immer positiv; die Richtung steht daneben. */
betragCent: bigint
richtung: BuchungRichtung
gegenstelle: string | null
verwendungszweck: string | null
importReferenz: string
/** Wird von dublettenMarkieren gesetzt. */
dublette: boolean
}
export type ImportZeilenfehler = {
zeilennummer: number
meldung: string
rohzeile: string[]
}
export type ImportErgebnis = {
erkennung: CsvErkennung
zeilen: ImportZeile[]
fehler: ImportZeilenfehler[]
/** Zeilen, deren Waehrung nicht EUR ist - werden nicht uebernommen. */
fremdwaehrung: number
}
function textOderNull(werte: string[], index: number | null): string | null {
if (index === null || index < 0) return null
const wert = (werte[index] ?? '').replace(/\s+/g, ' ').trim()
return wert === '' ? null : wert
}
/**
* Stabiler Schluessel je Umsatz. Absichtlich NICHT die Referenznummer der
* Bank: die ist nicht in jedem Format enthalten und aendert sich beim
* erneuten Ausleiten. Datum, Betrag, Gegenstelle und Zweck dagegen
* bleiben gleich.
*/
export function importReferenzBilden(teile: {
datum: Date
betragCent: bigint
richtung: BuchungRichtung
gegenstelle: string | null
verwendungszweck: string | null
}): string {
const roh = [
tagAlsText(teile.datum),
teile.richtung,
teile.betragCent.toString(),
(teile.gegenstelle ?? '').toLowerCase(),
(teile.verwendungszweck ?? '').toLowerCase(),
].join('|')
return `csv:${createHash('sha256').update(roh, 'utf8').digest('hex').slice(0, 32)}`
}
export function csvZuBuchungen(inhalt: Buffer | Uint8Array | string): ImportErgebnis {
const erkennung = csvErkennen(inhalt)
const { zuordnung } = erkennung
const zeilen: ImportZeile[] = []
const fehler: ImportZeilenfehler[] = []
let fremdwaehrung = 0
// Zaehlt, wie oft ein Schluessel innerhalb dieser Datei vorkommt -
// zwei gleiche Abhebungen am selben Tag sind kein Fehler.
const schluesselZaehler = new Map<string, number>()
erkennung.datenzeilen.forEach((werte, i) => {
const zeilennummer = erkennung.kopfzeileNummer + 1 + i
const waehrung = textOderNull(werte, zuordnung.waehrung)
if (waehrung && !/^(eur|€)$/i.test(waehrung)) {
fremdwaehrung++
fehler.push({
zeilennummer,
meldung: `Währung "${waehrung}" wird nicht übernommen. Diese Anwendung rechnet in Euro.`,
rohzeile: werte,
})
return
}
const datum = datumParsen(textOderNull(werte, zuordnung.datum))
if (!datum) {
fehler.push({
zeilennummer,
meldung: `Das Datum "${werte[zuordnung.datum] ?? ''}" ist nicht lesbar. Erwartet wird TT.MM.JJJJ.`,
rohzeile: werte,
})
return
}
let betragCent: bigint
let richtung: BuchungRichtung
try {
const ergebnis = betragUndRichtung(werte, zuordnung)
betragCent = ergebnis.betragCent
richtung = ergebnis.richtung
} catch (ausnahme) {
fehler.push({
zeilennummer,
meldung:
ausnahme instanceof BetragFehler || ausnahme instanceof ImportFehler
? ausnahme.message
: 'Der Betrag ist nicht lesbar.',
rohzeile: werte,
})
return
}
if (betragCent === 0n) {
fehler.push({
zeilennummer,
meldung: 'Der Betrag ist 0,00 € – die Zeile wurde übersprungen.',
rohzeile: werte,
})
return
}
const gegenstelle = textOderNull(werte, zuordnung.gegenstelle)
const zweckTeile = [
textOderNull(werte, zuordnung.buchungstext),
textOderNull(werte, zuordnung.verwendungszweck),
].filter((t): t is string => t !== null)
const verwendungszweck = zweckTeile.length > 0 ? zweckTeile.join(' · ') : null
const grundschluessel = importReferenzBilden({
datum,
betragCent,
richtung,
gegenstelle,
verwendungszweck,
})
const bisher = schluesselZaehler.get(grundschluessel) ?? 0
schluesselZaehler.set(grundschluessel, bisher + 1)
const importReferenz = bisher === 0 ? grundschluessel : `${grundschluessel}#${bisher + 1}`
zeilen.push({
zeilennummer,
datum,
wertstellung: datumParsen(textOderNull(werte, zuordnung.wertstellung)),
betragCent,
richtung,
gegenstelle,
verwendungszweck,
importReferenz,
dublette: false,
})
})
return { erkennung, zeilen, fehler, fremdwaehrung }
}
function betragUndRichtung(
werte: string[],
zuordnung: Spaltenzuordnung,
): { betragCent: bigint; richtung: BuchungRichtung } {
// Getrennte Soll-/Haben-Spalten
if (zuordnung.soll !== null || zuordnung.haben !== null) {
const sollText = textOderNull(werte, zuordnung.soll)
const habenText = textOderNull(werte, zuordnung.haben)
const soll = sollText ? betragParsen(sollText) : 0n
const haben = habenText ? betragParsen(habenText) : 0n
if (soll !== 0n && haben !== 0n) {
throw new ImportFehler('In dieser Zeile stehen Soll und Haben gleichzeitig.')
}
if (haben !== 0n) {
return { betragCent: haben < 0n ? -haben : haben, richtung: 'EINNAHME' }
}
return { betragCent: soll < 0n ? -soll : soll, richtung: 'AUSGABE' }
}
if (zuordnung.betrag === null) {
throw new ImportFehler('In dieser Zeile ist keine Betragsspalte zu finden.')
}
const roh = textOderNull(werte, zuordnung.betrag)
if (roh === null) throw new ImportFehler('Der Betrag fehlt.')
let betrag = betragParsen(roh)
// Eigene Soll/Haben-Spalte hat Vorrang vor dem Vorzeichen.
const kennzeichen = textOderNull(werte, zuordnung.sollHaben)
if (kennzeichen) {
const normal = normalisieren(kennzeichen)
if (normal === 's' || normal === 'soll' || normal === 'd' || normal === 'debit') {
betrag = betrag < 0n ? betrag : -betrag
} else if (normal === 'h' || normal === 'haben' || normal === 'c' || normal === 'credit') {
betrag = betrag < 0n ? -betrag : betrag
}
}
return {
betragCent: betrag < 0n ? -betrag : betrag,
richtung: betrag < 0n ? 'AUSGABE' : 'EINNAHME',
}
}
/**
* Markiert die Zeilen, deren importReferenz schon in der Datenbank
* steht. Sie werden angezeigt, aber nicht uebernommen - so sieht der
* Nutzer, dass die Datei sich mit einer frueheren ueberschneidet, statt
* sich zu wundern, warum weniger Zeilen ankommen.
*/
export function dublettenMarkieren(
zeilen: ImportZeile[],
vorhandeneReferenzen: Iterable<string>,
): { zeilen: ImportZeile[]; dubletten: number } {
const vorhanden = new Set(vorhandeneReferenzen)
let dubletten = 0
const markiert = zeilen.map((zeile) => {
const dublette = vorhanden.has(zeile.importReferenz)
if (dublette) dubletten++
return { ...zeile, dublette }
})
return { zeilen: markiert, dubletten }
}
+282
View File
@@ -0,0 +1,282 @@
import type { BuchungKategorie, BuchungRichtung } from '@prisma/client'
import { summe, teileGerundet } from '@/lib/finanzen/betrag'
import { alsTag, imZeitraum, nachTagSortieren } from '@/lib/finanzen/datum'
import {
AUSGABE_KATEGORIEN,
EINNAHME_KATEGORIEN,
KATEGORIE_BESCHRIFTUNG,
} from '@/lib/finanzen/kategorien'
/**
* Auswertung des Kassenbuchs - ohne Datenbank, damit sich jede Zahl
* einzeln nachrechnen laesst.
*
* Drei Regeln liegen allem zugrunde:
*
* 1. Umbuchungen zwischen eigenen Konten sind weder Einnahme noch
* Ausgabe. Wer 500 EUR vom Giro aufs Sparbuch legt, hat nichts
* eingenommen. Sie tauchen in keiner Summe auf.
* 2. Buchungen mit betrifftBetreute = false gehoeren nicht zum Vermoegen
* der betreuten Person. Das sind eigene Auslagen des Betreuers - sie
* werden getrennt gefuehrt und getrennt abgerechnet.
* 3. Vermoegenswirksame Buchungen (Verkaufserloes, Rueckkaufswert) sind
* keine laufenden Einnahmen. Sie veraendern aber sehr wohl den
* Kontostand und muessen deshalb in der Rechenprobe mitlaufen -
* sonst geht die Probe nicht auf. Im Bericht stehen sie eigens
* ausgewiesen.
*/
export type BuchungEingabe = {
id: string
datum: Date
betragCent: bigint
richtung: BuchungRichtung
kategorie: BuchungKategorie
gegenstelle: string | null
verwendungszweck: string | null
kontoId: string | null
kontoBezeichnung?: string | null
belegId: string | null
belegTitel?: string | null
belegFehltBegruendung: string | null
betrifftBetreute: boolean
vermoegenswirksam: boolean
/** Laeuft die Buchung ueber ein Konto des Betreuers? */
aufBetreuerkonto?: boolean
}
/** Zaehlt nur, was das Vermoegen der betreuten Person beruehrt. */
export function istVermoegensbuchung(buchung: BuchungEingabe): boolean {
return buchung.betrifftBetreute && buchung.richtung !== 'UMBUCHUNG'
}
// -----------------------------------------------------------------------------
// Belegzwang
// -----------------------------------------------------------------------------
/**
* Fehlt der Beleg? Eine Begruendung heilt das Fehlen fuer die Statistik,
* nicht fuer den Aktenordner - aber sie beantwortet die Frage des
* Rechtspflegers "und wo ist der Beleg?" schon vorab.
*/
export function belegFehlt(buchung: BuchungEingabe): boolean {
return !buchung.belegId && !(buchung.belegFehltBegruendung ?? '').trim()
}
export function belegBegruendet(buchung: BuchungEingabe): boolean {
return !buchung.belegId && (buchung.belegFehltBegruendung ?? '').trim() !== ''
}
export type Belegquote = {
gesamt: number
mitBeleg: number
begruendet: number
ohneNachweis: number
/** 0 bis 100, ganzzahlig. Bei null Buchungen: 100 - es fehlt ja nichts. */
quoteProzent: number
}
export function belegquote(buchungen: BuchungEingabe[]): Belegquote {
const gesamt = buchungen.length
const mitBeleg = buchungen.filter((b) => Boolean(b.belegId)).length
const begruendet = buchungen.filter((b) => belegBegruendet(b)).length
const ohneNachweis = buchungen.filter((b) => belegFehlt(b)).length
const quoteProzent =
gesamt === 0 ? 100 : Math.round(((mitBeleg + begruendet) / gesamt) * 100)
return { gesamt, mitBeleg, begruendet, ohneNachweis, quoteProzent }
}
/**
* Der Fehler, der in der Rechnungslegung um die Ohren fliegt: eine
* Buchung, die auf einem Konto des Betreuers laeuft und trotzdem als
* Vermoegen der betreuten Person gefuehrt wird. Das vermischt zwei
* Vermoegensmassen - genau das, was § 1835 BGB verhindern soll.
*/
export function vermischungsWarnung(buchung: {
aufBetreuerkonto?: boolean
betrifftBetreute: boolean
}): string | null {
if (buchung.aufBetreuerkonto && buchung.betrifftBetreute) {
return (
'Diese Buchung läuft über ein Konto, das als Konto des Betreuers gekennzeichnet ist, ' +
'gilt aber als Vermögen der betreuten Person. Beides zusammen vermischt zwei ' +
'Vermögensmassen. Entweder ist das Konto falsch gekennzeichnet, oder die Buchung ist ' +
'eine eigene Auslage – dann bitte „betrifft Vermögen der Betreuten“ abwählen.'
)
}
return null
}
// -----------------------------------------------------------------------------
// Gruppierung nach Kategorien
// -----------------------------------------------------------------------------
export type KategorieGruppe = {
kategorie: BuchungKategorie
beschriftung: string
summeCent: bigint
davonVermoegenswirksamCent: bigint
anzahl: number
buchungen: BuchungEingabe[]
}
function gruppieren(
buchungen: BuchungEingabe[],
reihenfolge: BuchungKategorie[],
): KategorieGruppe[] {
const nachKategorie = new Map<BuchungKategorie, BuchungEingabe[]>()
for (const buchung of buchungen) {
const liste = nachKategorie.get(buchung.kategorie) ?? []
liste.push(buchung)
nachKategorie.set(buchung.kategorie, liste)
}
// Erst die Kategorien in Berichtsreihenfolge, danach alles, was sonst
// noch auftaucht (z.B. eine Rueckerstattung in einer Ausgabekategorie).
const bekannt = reihenfolge.filter((k) => nachKategorie.has(k))
const uebrig = [...nachKategorie.keys()].filter((k) => !reihenfolge.includes(k))
return [...bekannt, ...uebrig].map((kategorie) => {
const liste = nachTagSortieren(nachKategorie.get(kategorie) ?? [], (b) => b.datum)
return {
kategorie,
beschriftung: KATEGORIE_BESCHRIFTUNG[kategorie],
summeCent: summe(liste.map((b) => b.betragCent)),
davonVermoegenswirksamCent: summe(
liste.filter((b) => b.vermoegenswirksam).map((b) => b.betragCent),
),
anzahl: liste.length,
buchungen: liste,
}
})
}
export function einnahmenGruppiert(buchungen: BuchungEingabe[]): KategorieGruppe[] {
return gruppieren(
buchungen.filter((b) => istVermoegensbuchung(b) && b.richtung === 'EINNAHME'),
EINNAHME_KATEGORIEN,
)
}
export function ausgabenGruppiert(buchungen: BuchungEingabe[]): KategorieGruppe[] {
return gruppieren(
buchungen.filter((b) => istVermoegensbuchung(b) && b.richtung === 'AUSGABE'),
AUSGABE_KATEGORIEN,
)
}
// -----------------------------------------------------------------------------
// Laufende Einnahmen und Ausgaben (fuer das Vermoegensverzeichnis)
// -----------------------------------------------------------------------------
export type LaufenderPosten = {
kategorie: BuchungKategorie
beschriftung: string
/** Summe im betrachteten Zeitraum. */
summeCent: bigint
/** Auf den Monat heruntergerechnet, kaufmaennisch auf den Cent gerundet. */
monatlichCent: bigint
anzahl: number
}
export type LaufendeBewegungen = {
von: Date
bis: Date
monate: number
einnahmen: LaufenderPosten[]
ausgaben: LaufenderPosten[]
summeEinnahmenCent: bigint
summeAusgabenCent: bigint
monatlichEinnahmenCent: bigint
monatlichAusgabenCent: bigint
}
/**
* Was laeuft regelmaessig? Das Vermoegensverzeichnis verlangt neben dem
* Bestand auch die laufenden Einnahmen und Ausgaben. Grundlage sind die
* Buchungen des angegebenen Zeitraums (in der Regel die letzten zwoelf
* Monate), heruntergerechnet auf den Monat.
*
* Vermoegenswirksame Buchungen bleiben hier aussen vor: ein einmaliger
* Verkaufserloes ist keine laufende Einnahme und wuerde die Hochrechnung
* verfaelschen.
*/
export function laufendeBewegungen(
buchungen: BuchungEingabe[],
von: Date,
bis: Date,
monate = 12,
): LaufendeBewegungen {
const imFenster = buchungen.filter(
(b) => istVermoegensbuchung(b) && !b.vermoegenswirksam && imZeitraum(b.datum, von, bis),
)
const umrechnen = (gruppen: KategorieGruppe[]): LaufenderPosten[] =>
gruppen.map((g) => ({
kategorie: g.kategorie,
beschriftung: g.beschriftung,
summeCent: g.summeCent,
monatlichCent: teileGerundet(g.summeCent, BigInt(monate)),
anzahl: g.anzahl,
}))
const einnahmen = umrechnen(einnahmenGruppiert(imFenster))
const ausgaben = umrechnen(ausgabenGruppiert(imFenster))
const summeEinnahmenCent = summe(einnahmen.map((p) => p.summeCent))
const summeAusgabenCent = summe(ausgaben.map((p) => p.summeCent))
return {
von: alsTag(von),
bis: alsTag(bis),
monate,
einnahmen,
ausgaben,
summeEinnahmenCent,
summeAusgabenCent,
// Bewusst aus der Gesamtsumme gerechnet und nicht aus den gerundeten
// Einzelwerten - sonst weicht die Summe der Zeilen vom Gesamtwert ab.
monatlichEinnahmenCent: teileGerundet(summeEinnahmenCent, BigInt(monate)),
monatlichAusgabenCent: teileGerundet(summeAusgabenCent, BigInt(monate)),
}
}
// -----------------------------------------------------------------------------
// Kennzahlen fuer die Uebersichtsseite
// -----------------------------------------------------------------------------
export type Kassenkennzahlen = {
anzahl: number
einnahmenCent: bigint
ausgabenCent: bigint
saldoCent: bigint
vermoegenswirksamCent: bigint
eigeneAuslagenCent: bigint
beleg: Belegquote
vermischungen: number
}
export function kennzahlen(buchungen: BuchungEingabe[]): Kassenkennzahlen {
const relevant = buchungen.filter(istVermoegensbuchung)
const einnahmenCent = summe(
relevant.filter((b) => b.richtung === 'EINNAHME').map((b) => b.betragCent),
)
const ausgabenCent = summe(
relevant.filter((b) => b.richtung === 'AUSGABE').map((b) => b.betragCent),
)
return {
anzahl: buchungen.length,
einnahmenCent,
ausgabenCent,
saldoCent: einnahmenCent - ausgabenCent,
vermoegenswirksamCent: summe(
relevant.filter((b) => b.vermoegenswirksam).map((b) => b.betragCent),
),
eigeneAuslagenCent: summe(
buchungen.filter((b) => !b.betrifftBetreute).map((b) => b.betragCent),
),
beleg: belegquote(buchungen),
vermischungen: buchungen.filter((b) => vermischungsWarnung(b) !== null).length,
}
}
+219
View File
@@ -0,0 +1,219 @@
import type {
BuchungKategorie,
BuchungRichtung,
KontoArt,
VermoegensArt,
} from '@prisma/client'
/**
* Beschriftungen und Ordnung der Finanz-Enums.
*
* Die Reihenfolge in den Listen ist nicht alphabetisch, sondern die
* Reihenfolge, in der die Posten im Vermoegensverzeichnis und in der
* Rechnungslegung beim Gericht stehen sollen. Wer sie aendert, aendert
* das Aussehen beider Berichte.
*/
// -----------------------------------------------------------------------------
// Konten
// -----------------------------------------------------------------------------
export const KONTO_ARTEN: KontoArt[] = [
'GIRO',
'SPAR',
'TAGESGELD',
'FESTGELD',
'DEPOT',
'VERWAHRGELD_HEIM',
'BARKASSE',
'SONSTIGES',
]
export const KONTO_ART_BESCHRIFTUNG: Record<KontoArt, string> = {
GIRO: 'Girokonto',
SPAR: 'Sparkonto',
TAGESGELD: 'Tagesgeld',
FESTGELD: 'Festgeld',
DEPOT: 'Wertpapierdepot',
VERWAHRGELD_HEIM: 'Verwahrgeld im Heim',
BARKASSE: 'Barkasse',
SONSTIGES: 'Sonstiges Konto',
}
export const KONTO_ART_ERKLAERUNG: Record<KontoArt, string> = {
GIRO: 'Laufendes Konto, über das Rente, Heimkosten und Alltagszahlungen laufen.',
SPAR: 'Sparbuch oder Sparkonto. Zählt beim Sozialhilfeträger voll zum Vermögen.',
TAGESGELD: 'Täglich verfügbares Guthaben, meist als Unterkonto zum Girokonto.',
FESTGELD: 'Angelegtes Guthaben mit fester Laufzeit. Kündigungsfrist im Blick behalten.',
DEPOT: 'Wertpapiere. Der Wert schwankt – Stichtagswerte deshalb belegen.',
VERWAHRGELD_HEIM:
'Taschengeldkonto, das die Einrichtung führt. Auch dieses Geld gehört zum Vermögen und muss abgerechnet werden.',
BARKASSE: 'Bargeld, das du selbst verwahrst. Jede Entnahme braucht eine Quittung.',
SONSTIGES: 'Alles, was in keine der anderen Arten passt.',
}
/** Konten ohne IBAN - dort ist das Feld optional und wird nicht erzwungen. */
export const KONTO_ARTEN_OHNE_IBAN: KontoArt[] = ['VERWAHRGELD_HEIM', 'BARKASSE', 'SONSTIGES']
// -----------------------------------------------------------------------------
// Buchungen
// -----------------------------------------------------------------------------
export const BUCHUNG_RICHTUNGEN: BuchungRichtung[] = ['EINNAHME', 'AUSGABE', 'UMBUCHUNG']
export const RICHTUNG_BESCHRIFTUNG: Record<BuchungRichtung, string> = {
EINNAHME: 'Einnahme',
AUSGABE: 'Ausgabe',
UMBUCHUNG: 'Umbuchung',
}
/** Einnahmen in der Reihenfolge, in der sie im Bericht stehen. */
export const EINNAHME_KATEGORIEN: BuchungKategorie[] = [
'RENTE',
'PFLEGEKASSE',
'SOZIALHILFE',
'LANDESPFLEGEGELD',
'ZINSEN',
'MIETEINNAHME',
'SONSTIGE_EINNAHME',
]
export const AUSGABE_KATEGORIEN: BuchungKategorie[] = [
'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',
]
export const ALLE_KATEGORIEN: BuchungKategorie[] = [
...EINNAHME_KATEGORIEN,
...AUSGABE_KATEGORIEN,
'UMBUCHUNG',
]
export const KATEGORIE_BESCHRIFTUNG: Record<BuchungKategorie, string> = {
RENTE: 'Rente',
PFLEGEKASSE: 'Leistungen der Pflegekasse',
SOZIALHILFE: 'Hilfe zur Pflege / Sozialhilfe',
LANDESPFLEGEGELD: 'Landespflegegeld',
ZINSEN: 'Zinsen und Kapitalerträge',
MIETEINNAHME: 'Mieteinnahmen',
SONSTIGE_EINNAHME: 'Sonstige Einnahmen',
HEIMKOSTEN: 'Heimkosten',
BARBETRAG: 'Barbetrag / Taschengeld',
BEKLEIDUNG: 'Bekleidung',
ARZT_UND_MEDIKAMENTE: 'Arzt und Medikamente',
ZUZAHLUNG: 'Zuzahlungen',
VERSICHERUNG: 'Versicherungsbeiträge',
IMMOBILIE_BETRIEBSKOSTEN: 'Immobilie: Betriebskosten',
IMMOBILIE_INSTANDHALTUNG: 'Immobilie: Instandhaltung',
GRUNDSTEUER: 'Grundsteuer',
STEUERN: 'Steuern',
GEBUEHREN_GERICHT: 'Gerichtskosten und Gebühren',
PORTO_UND_KOPIEN: 'Porto und Kopien',
FAHRTKOSTEN: 'Fahrtkosten',
TELEFON_INTERNET: 'Telefon und Internet',
RUNDFUNKBEITRAG: 'Rundfunkbeitrag',
BESTATTUNGSVORSORGE: 'Bestattungsvorsorge',
BANKGEBUEHREN: 'Bankgebühren',
SONSTIGE_AUSGABE: 'Sonstige Ausgaben',
UMBUCHUNG: 'Umbuchung zwischen eigenen Konten',
}
/**
* Passt die Kategorie zur Richtung? Wird nicht erzwungen (eine
* Rueckerstattung von Heimkosten ist eine Einnahme in der Kategorie
* HEIMKOSTEN), aber die Oberflaeche sortiert danach vor.
*/
export function kategorienFuerRichtung(richtung: BuchungRichtung): BuchungKategorie[] {
if (richtung === 'EINNAHME') return EINNAHME_KATEGORIEN
if (richtung === 'AUSGABE') return AUSGABE_KATEGORIEN
return ['UMBUCHUNG']
}
export function standardKategorie(richtung: BuchungRichtung): BuchungKategorie {
if (richtung === 'EINNAHME') return 'SONSTIGE_EINNAHME'
if (richtung === 'AUSGABE') return 'SONSTIGE_AUSGABE'
return 'UMBUCHUNG'
}
/**
* Vorzeichen fuer die Anzeige und fuer den Kontostand: Einnahmen erhoehen,
* Ausgaben mindern. Umbuchungen bekommen kein Vorzeichen, weil sie am
* Gesamtvermoegen nichts aendern - sie schieben nur zwischen Konten.
*/
export function vorzeichen(richtung: BuchungRichtung): 1n | -1n | 0n {
if (richtung === 'EINNAHME') return 1n
if (richtung === 'AUSGABE') return -1n
return 0n
}
// -----------------------------------------------------------------------------
// Vermoegensposten
// -----------------------------------------------------------------------------
/**
* Reihenfolge der Gruppen im Vermoegensverzeichnis nach § 1835 BGB:
* erst das, was sofort verfuegbar ist, dann Sachwerte, zuletzt
* Forderungen. VERBINDLICHKEIT steht nicht in dieser Liste - Passiva
* bekommen einen eigenen Abschnitt.
*/
export const AKTIVA_ARTEN: VermoegensArt[] = [
'KONTO',
'IMMOBILIE',
'VERSICHERUNG',
'FAHRZEUG',
'WERTGEGENSTAND',
'FORDERUNG',
'SONSTIGES',
]
export const VERMOEGENS_ARTEN: VermoegensArt[] = [...AKTIVA_ARTEN, 'VERBINDLICHKEIT']
export const VERMOEGENS_ART_BESCHRIFTUNG: Record<VermoegensArt, string> = {
KONTO: 'Konten und Guthaben',
IMMOBILIE: 'Grundbesitz',
VERSICHERUNG: 'Versicherungen',
FAHRZEUG: 'Fahrzeuge',
WERTGEGENSTAND: 'Wertgegenstände',
FORDERUNG: 'Forderungen',
SONSTIGES: 'Sonstiges Vermögen',
VERBINDLICHKEIT: 'Verbindlichkeiten',
}
/** Einzahl, fuer die Kopfzeile eines einzelnen Postens. */
export const VERMOEGENS_ART_EINZAHL: Record<VermoegensArt, string> = {
KONTO: 'Konto',
IMMOBILIE: 'Grundbesitz',
VERSICHERUNG: 'Versicherung',
FAHRZEUG: 'Fahrzeug',
WERTGEGENSTAND: 'Wertgegenstand',
FORDERUNG: 'Forderung',
SONSTIGES: 'Sonstiges',
VERBINDLICHKEIT: 'Verbindlichkeit',
}
export function istPassiv(art: VermoegensArt): boolean {
return art === 'VERBINDLICHKEIT'
}
export const SOZIALHILFE_BEWERTUNG_BESCHRIFTUNG = {
UNGEPRUEFT: 'noch nicht geprüft',
VERWERTBAR: 'verwertbar',
GESCHUETZT: 'geschützt (Schonvermögen)',
STRITTIG: 'strittig',
} as const
+356
View File
@@ -0,0 +1,356 @@
import { summe } from '@/lib/finanzen/betrag'
import { alsTag, imZeitraum, nachTagSortieren, tagePlus, vorOderGleich } from '@/lib/finanzen/datum'
import type { Betreuungsjahr } from '@/lib/finanzen/betreuungsjahr'
import {
ausgabenGruppiert,
belegFehlt,
einnahmenGruppiert,
istVermoegensbuchung,
belegquote,
type Belegquote,
type BuchungEingabe,
type KategorieGruppe,
} from '@/lib/finanzen/kassenbuch'
import { KONTO_ART_BESCHRIFTUNG } from '@/lib/finanzen/kategorien'
import type { KontoArt } from '@prisma/client'
/**
* Rechnungslegung nach §§ 1859, 1865 BGB.
*
* Aufbau, den das Gericht erwartet:
*
* Anfangsbestand
* + Einnahmen (nach Kategorien geordnet)
* - Ausgaben (nach Kategorien geordnet)
* = Endbestand
*
* Die letzte Zeile ist eine Behauptung, und sie wird geprueft. Der
* Endbestand aus den Saldostaenden muss dem herausgerechneten Wert
* entsprechen. Tut er das nicht, fehlen Buchungen oder Saldostaende.
*
* Diese Abweichung wird ausgewiesen, nicht versteckt. Ein Bericht, der
* eine Differenz stillschweigend glattzieht, ist schlimmer als einer,
* der sie benennt: die Zahl waere dann falsch, ohne dass es jemand
* merkt - und unterschrieben wird sie trotzdem.
*
* Zwei Varianten:
*
* - § 1859 BGB - befreiter Betreuer (Angehoerige). Es genuegt eine
* Vermoegensuebersicht: Bestaende und Summen je Kategorie, keine
* Einzelbelegliste.
* - § 1865 BGB - vollstaendige Rechnungslegung. Jede Buchung einzeln,
* mit laufender Belegnummer.
*/
export type Variante = 'UEBERSICHT_1859' | 'VOLL_1865'
export const VARIANTE_BESCHRIFTUNG: Record<Variante, string> = {
UEBERSICHT_1859: 'Vermögensübersicht (§ 1859 BGB, befreiter Betreuer)',
VOLL_1865: 'Vollständige Rechnungslegung (§ 1865 BGB)',
}
export const VARIANTE_RECHTSGRUNDLAGE: Record<Variante, string> = {
UEBERSICHT_1859: '§ 1859 BGB',
VOLL_1865: '§ 1865 BGB',
}
export function standardVariante(befreiterBetreuer: boolean): Variante {
return befreiterBetreuer ? 'UEBERSICHT_1859' : 'VOLL_1865'
}
// -----------------------------------------------------------------------------
// Bestaende
// -----------------------------------------------------------------------------
export type SaldoEingabe = { stichtag: Date; betragCent: bigint; quelle: string | null }
export type KontoEingabe = {
id: string
bezeichnung: string
bank: string | null
art: KontoArt
istBetreuerkonto: boolean
ibanMaskiert?: string | null
saldostaende: SaldoEingabe[]
}
export type Bestandszeile = {
kontoId: string
bezeichnung: string
bank: string | null
art: KontoArt
artBeschriftung: string
ibanMaskiert: string | null
anfangCent: bigint
/** Stichtag des tatsaechlich benutzten Saldostands - null, wenn keiner da war. */
anfangStichtag: Date | null
endeCent: bigint
endeStichtag: Date | null
}
/** Der juengste Saldostand, der nicht nach dem Stichtag liegt. */
export function saldoZumStichtag(
saldostaende: SaldoEingabe[],
stichtag: Date,
): SaldoEingabe | null {
let treffer: SaldoEingabe | null = null
for (const saldo of saldostaende) {
if (!vorOderGleich(saldo.stichtag, stichtag)) continue
if (treffer === null || alsTag(saldo.stichtag).getTime() > alsTag(treffer.stichtag).getTime()) {
treffer = saldo
}
}
return treffer
}
/**
* Bestaende zu Beginn und zum Ende des Zeitraums.
*
* Ein Saldostand gilt als Bestand am Ende seines Stichtages. Der
* Anfangsbestand eines Zeitraums ist deshalb der Bestand am Tag VOR dem
* ersten Tag - sonst waeren die Buchungen des ersten Tages doppelt
* enthalten.
*
* Konten des Betreuers bleiben aussen vor: sie gehoeren nicht zum
* Vermoegen der betreuten Person.
*/
export function bestaendeErmitteln(
konten: KontoEingabe[],
von: Date,
bis: Date,
): Bestandszeile[] {
const vortag = tagePlus(von, -1)
return konten
.filter((konto) => !konto.istBetreuerkonto)
.map((konto) => {
const anfang = saldoZumStichtag(konto.saldostaende, vortag)
const ende = saldoZumStichtag(konto.saldostaende, bis)
return {
kontoId: konto.id,
bezeichnung: konto.bezeichnung,
bank: konto.bank,
art: konto.art,
artBeschriftung: KONTO_ART_BESCHRIFTUNG[konto.art],
ibanMaskiert: konto.ibanMaskiert ?? null,
anfangCent: anfang?.betragCent ?? 0n,
anfangStichtag: anfang ? alsTag(anfang.stichtag) : null,
endeCent: ende?.betragCent ?? 0n,
endeStichtag: ende ? alsTag(ende.stichtag) : null,
}
})
}
// -----------------------------------------------------------------------------
// Belegliste
// -----------------------------------------------------------------------------
export type Belegzeile = {
/** Laufende Nummer im Bericht, ab 1. Danach wird im Ordner sortiert. */
nummer: number
buchungId: string
datum: Date
betragCent: bigint
richtung: BuchungEingabe['richtung']
beschriftung: string
gegenstelle: string | null
verwendungszweck: string | null
belegId: string | null
belegTitel: string | null
belegFehltBegruendung: string | null
fehlt: boolean
}
/**
* Laufende Belegnummern. Sortiert nach Datum - so laesst sich die
* Belegmappe in derselben Reihenfolge abheften, in der die Zahlen im
* Bericht stehen.
*/
export function beleglisteErstellen(buchungen: BuchungEingabe[]): Belegzeile[] {
return nachTagSortieren(buchungen, (b) => b.datum).map((buchung, i) => ({
nummer: i + 1,
buchungId: buchung.id,
datum: alsTag(buchung.datum),
betragCent: buchung.betragCent,
richtung: buchung.richtung,
beschriftung: buchung.gegenstelle ?? buchung.verwendungszweck ?? 'ohne Bezeichnung',
gegenstelle: buchung.gegenstelle,
verwendungszweck: buchung.verwendungszweck,
belegId: buchung.belegId,
belegTitel: buchung.belegTitel ?? null,
belegFehltBegruendung: buchung.belegFehltBegruendung,
fehlt: belegFehlt(buchung),
}))
}
// -----------------------------------------------------------------------------
// Die Rechnung selbst
// -----------------------------------------------------------------------------
export type Rechenprobe = {
anfangsbestandCent: bigint
einnahmenCent: bigint
ausgabenCent: bigint
/** Anfangsbestand + Einnahmen - Ausgaben. */
rechnerischerEndbestandCent: bigint
/** Summe der Saldostaende zum Ende des Zeitraums. */
gebuchterEndbestandCent: bigint
/** gebucht - rechnerisch. Positiv: es ist mehr da, als die Buchungen erklären. */
differenzCent: bigint
stimmt: boolean
}
export type Rechnungsdaten = {
von: Date
bis: Date
variante: Variante
/** Gesetzt, wenn der Zeitraum einem Betreuungsjahr entspricht. */
betreuungsjahr: Betreuungsjahr | null
/** Ein noch laufendes Jahr ergibt eine Zwischenaufstellung, keine Schlussrechnung. */
zwischenaufstellung: boolean
bestaende: Bestandszeile[]
anfangsbestandCent: bigint
endbestandCent: bigint
einnahmen: KategorieGruppe[]
ausgaben: KategorieGruppe[]
summeEinnahmenCent: bigint
summeAusgabenCent: bigint
vermoegenswirksamEinnahmenCent: bigint
vermoegenswirksamAusgabenCent: bigint
umbuchungen: BuchungEingabe[]
eigeneAuslagen: BuchungEingabe[]
eigeneAuslagenCent: bigint
probe: Rechenprobe
belege: Belegzeile[]
beleg: Belegquote
/** Klartexthinweise fuer den Bericht - jede Luecke wird benannt. */
hinweise: string[]
}
export function rechnungslegungBerechnen(eingabe: {
von: Date
bis: Date
variante: Variante
konten: KontoEingabe[]
buchungen: BuchungEingabe[]
betreuungsjahr?: Betreuungsjahr | null
/** Fuer die Frage, ob das Jahr schon abgeschlossen ist. */
heute?: Date
}): Rechnungsdaten {
const von = alsTag(eingabe.von)
const bis = alsTag(eingabe.bis)
const vortag = tagePlus(von, -1)
const imZeitraumListe = eingabe.buchungen.filter((b) => imZeitraum(b.datum, von, bis))
const bestaende = bestaendeErmitteln(eingabe.konten, von, bis)
const anfangsbestandCent = summe(bestaende.map((b) => b.anfangCent))
const endbestandCent = summe(bestaende.map((b) => b.endeCent))
const einnahmen = einnahmenGruppiert(imZeitraumListe)
const ausgaben = ausgabenGruppiert(imZeitraumListe)
const summeEinnahmenCent = summe(einnahmen.map((g) => g.summeCent))
const summeAusgabenCent = summe(ausgaben.map((g) => g.summeCent))
const rechnerischerEndbestandCent = anfangsbestandCent + summeEinnahmenCent - summeAusgabenCent
const differenzCent = endbestandCent - rechnerischerEndbestandCent
const relevante = imZeitraumListe.filter(istVermoegensbuchung)
const eigeneAuslagen = imZeitraumListe.filter((b) => !b.betrifftBetreute)
const hinweise: string[] = []
for (const zeile of bestaende) {
if (zeile.anfangStichtag === null) {
hinweise.push(
`Für „${zeile.bezeichnung}“ gibt es keinen Saldostand zum ${formatKurz(vortag)} oder davor. ` +
`Der Anfangsbestand wurde mit 0,00 € angesetzt.`,
)
} else if (zeile.anfangStichtag.getTime() !== vortag.getTime()) {
hinweise.push(
`Der Anfangsbestand von „${zeile.bezeichnung}“ stammt vom ${formatKurz(zeile.anfangStichtag)} ` +
`statt vom ${formatKurz(vortag)}. Buchungen dazwischen sind in der Rechenprobe nicht gedeckt.`,
)
}
if (zeile.endeStichtag === null) {
hinweise.push(
`Für „${zeile.bezeichnung}“ gibt es keinen Saldostand zum ${formatKurz(bis)} oder davor. ` +
`Der Endbestand wurde mit 0,00 € angesetzt.`,
)
} else if (zeile.endeStichtag.getTime() !== bis.getTime()) {
hinweise.push(
`Der Endbestand von „${zeile.bezeichnung}“ stammt vom ${formatKurz(zeile.endeStichtag)} ` +
`statt vom ${formatKurz(bis)}.`,
)
}
}
if (differenzCent !== 0n) {
hinweise.push(
'Die Rechenprobe geht nicht auf: Anfangsbestand zuzüglich Einnahmen abzüglich Ausgaben ' +
'ergibt nicht den Endbestand. Entweder fehlen Buchungen, oder ein Saldostand ist nicht ' +
'erfasst. Die Differenz ist im Bericht ausgewiesen und sollte vor der Abgabe geklärt werden.',
)
}
const fehlendeBelege = relevante.filter(belegFehlt).length
if (fehlendeBelege > 0) {
hinweise.push(
`${fehlendeBelege} Buchung(en) haben weder einen Beleg noch eine Begründung, warum er fehlt.`,
)
}
const vermischt = imZeitraumListe.filter((b) => b.aufBetreuerkonto && b.betrifftBetreute).length
if (vermischt > 0) {
hinweise.push(
`${vermischt} Buchung(en) laufen über ein Konto des Betreuers und sind trotzdem als ` +
'Vermögen der betreuten Person gekennzeichnet. Das ist zu klären, bevor der Bericht abgeht.',
)
}
const jahr = eingabe.betreuungsjahr ?? null
const stichtagHeute = eingabe.heute ? alsTag(eingabe.heute) : null
const zwischenaufstellung =
stichtagHeute !== null && stichtagHeute.getTime() < bis.getTime()
return {
von,
bis,
variante: eingabe.variante,
betreuungsjahr: jahr,
zwischenaufstellung,
bestaende,
anfangsbestandCent,
endbestandCent,
einnahmen,
ausgaben,
summeEinnahmenCent,
summeAusgabenCent,
vermoegenswirksamEinnahmenCent: summe(einnahmen.map((g) => g.davonVermoegenswirksamCent)),
vermoegenswirksamAusgabenCent: summe(ausgaben.map((g) => g.davonVermoegenswirksamCent)),
umbuchungen: imZeitraumListe.filter((b) => b.richtung === 'UMBUCHUNG' && b.betrifftBetreute),
eigeneAuslagen,
eigeneAuslagenCent: summe(eigeneAuslagen.map((b) => b.betragCent)),
probe: {
anfangsbestandCent,
einnahmenCent: summeEinnahmenCent,
ausgabenCent: summeAusgabenCent,
rechnerischerEndbestandCent,
gebuchterEndbestandCent: endbestandCent,
differenzCent,
stimmt: differenzCent === 0n,
},
belege: beleglisteErstellen(relevante),
beleg: belegquote(relevante),
hinweise,
}
}
function formatKurz(datum: Date): string {
const tag = String(datum.getUTCDate()).padStart(2, '0')
const monat = String(datum.getUTCMonth() + 1).padStart(2, '0')
return `${tag}.${monat}.${datum.getUTCFullYear()}`
}
+269
View File
@@ -0,0 +1,269 @@
import type { VermoegensArt } from '@prisma/client'
import { summe } from '@/lib/finanzen/betrag'
import { alsTag, nachTagSortieren, tageZwischen, vorOderGleich } from '@/lib/finanzen/datum'
import { AKTIVA_ARTEN, istPassiv } from '@/lib/finanzen/kategorien'
/**
* Vermoegen zu einem Stichtag.
*
* Diese Datei ist das Herz beider Berichte. Das Vermoegensverzeichnis
* nach § 1835 BGB und die Vermoegensuebersicht nach § 1859 BGB fragen
* dasselbe: "Was war an diesem Tag da?" Beantwortet wird das nie aus
* einem aktuellen Wertfeld, sondern immer aus der Wertstand-Historie -
* je Posten der juengste Wertstand, der nicht nach dem Stichtag liegt.
*
* Der Grund ist praktisch: Wenn das Gericht zwei Jahre spaeter nachfragt,
* warum im Verzeichnis 42.000 EUR standen, muss genau dieselbe Zahl
* wieder herauskommen. Wuerden Werte ueberschrieben, waere das nicht
* mehr moeglich - dann stuende Aussage gegen Aussage.
*
* Ein Wertstand, der nach dem Stichtag liegt, wird nicht benutzt. Auch
* dann nicht, wenn er "besser" waere. Rueckwaerts wissen gilt nicht.
*/
export type WertstandEingabe = {
stichtag: Date
wertCent: bigint
wertquelle: string
nachweisDokumentId?: string | null
bemerkung?: string | null
}
export type PostenEingabe = {
id: string
bezeichnung: string
art: VermoegensArt
wertstaende: WertstandEingabe[]
/** Optionale Verweise, damit der Bericht auf das Objekt zeigen kann. */
kontoId?: string | null
immobilieId?: string | null
vertragId?: string | null
bewertungSozialhilfe?: string | null
}
export type PostenZumStichtag = {
id: string
bezeichnung: string
art: VermoegensArt
wertCent: bigint
/** Stichtag des tatsaechlich verwendeten Wertstands. */
bewertetAm: Date
wertquelle: string
nachweisDokumentId: string | null
/** Wie alt ist der verwendete Wert am Stichtag? Null Tage = taggenau. */
alterTage: number
kontoId: string | null
immobilieId: string | null
vertragId: string | null
bewertungSozialhilfe: string | null
}
export type PostenOhneWert = {
id: string
bezeichnung: string
art: VermoegensArt
/** Gibt es ueberhaupt Wertstaende, nur eben spaetere? */
spaetereWertstaende: number
}
export type Vermoegensgruppe = {
art: VermoegensArt
posten: PostenZumStichtag[]
summeCent: bigint
}
export type Vermoegensbild = {
stichtag: Date
/** Alles ausser VERBINDLICHKEIT, nach Gruppen in Berichtsreihenfolge. */
aktivaGruppen: Vermoegensgruppe[]
aktiva: PostenZumStichtag[]
passiva: PostenZumStichtag[]
/** Posten, fuer die es am Stichtag keinen Wert gibt - stehen im Bericht als Luecke. */
ohneWert: PostenOhneWert[]
summeAktivaCent: bigint
summePassivaCent: bigint
/** Aktiva minus Passiva. Das ist die Zahl, nach der das Gericht fragt. */
reinvermoegenCent: bigint
/** Aeltester verwendeter Wert - Hinweis darauf, dass nachbewertet werden sollte. */
aeltesterWertTage: number | null
}
/**
* Der juengste Wertstand, der nicht nach dem Stichtag liegt.
* Null, wenn es zu diesem Tag noch keine Bewertung gab.
*/
export function wertZumStichtag(
wertstaende: WertstandEingabe[],
stichtag: Date,
): WertstandEingabe | null {
let treffer: WertstandEingabe | null = null
for (const wert of wertstaende) {
if (!vorOderGleich(wert.stichtag, stichtag)) continue
if (treffer === null || alsTag(wert.stichtag) > alsTag(treffer.stichtag)) {
treffer = wert
}
}
return treffer
}
/**
* Reine Rechnung ohne Datenbank - hier laesst sich der Fall
* "mehrere Wertstaende, Stichtag mittendrin" pruefen, ohne Postgres zu
* starten.
*/
export function vermoegenBerechnen(posten: PostenEingabe[], stichtag: Date): Vermoegensbild {
const tag = alsTag(stichtag)
const bewertet: PostenZumStichtag[] = []
const ohneWert: PostenOhneWert[] = []
for (const p of posten) {
const wert = wertZumStichtag(p.wertstaende, tag)
if (!wert) {
ohneWert.push({
id: p.id,
bezeichnung: p.bezeichnung,
art: p.art,
spaetereWertstaende: p.wertstaende.length,
})
continue
}
bewertet.push({
id: p.id,
bezeichnung: p.bezeichnung,
art: p.art,
wertCent: wert.wertCent,
bewertetAm: alsTag(wert.stichtag),
wertquelle: wert.wertquelle,
nachweisDokumentId: wert.nachweisDokumentId ?? null,
alterTage: tageZwischen(wert.stichtag, tag),
kontoId: p.kontoId ?? null,
immobilieId: p.immobilieId ?? null,
vertragId: p.vertragId ?? null,
bewertungSozialhilfe: p.bewertungSozialhilfe ?? null,
})
}
const aktiva = bewertet.filter((p) => !istPassiv(p.art))
const passiva = bewertet.filter((p) => istPassiv(p.art))
const aktivaGruppen: Vermoegensgruppe[] = AKTIVA_ARTEN.map((art) => {
const gruppe = aktiva
.filter((p) => p.art === art)
.sort((a, b) => a.bezeichnung.localeCompare(b.bezeichnung, 'de'))
return { art, posten: gruppe, summeCent: summe(gruppe.map((p) => p.wertCent)) }
}).filter((gruppe) => gruppe.posten.length > 0)
const summeAktivaCent = summe(aktiva.map((p) => p.wertCent))
const summePassivaCent = summe(passiva.map((p) => p.wertCent))
const alter = bewertet.map((p) => p.alterTage)
return {
stichtag: tag,
aktivaGruppen,
aktiva,
passiva: passiva.sort((a, b) => a.bezeichnung.localeCompare(b.bezeichnung, 'de')),
ohneWert,
summeAktivaCent,
summePassivaCent,
reinvermoegenCent: summeAktivaCent - summePassivaCent,
aeltesterWertTage: alter.length > 0 ? Math.max(...alter) : null,
}
}
/**
* Dieselbe Rechnung, aber mit den Daten aus der Datenbank.
*
* Der Zugriff auf db geschieht bewusst erst im Funktionsrumpf: so bleibt
* dieses Modul fuer die Tests ohne Datenbank ladbar, und die reine
* Rechnung oben ist unabhaengig pruefbar.
*/
export async function vermoegenZumStichtag(
betreuteId: string,
stichtag: Date,
): Promise<Vermoegensbild> {
const { db, nichtGeloescht } = await import('@/lib/db')
const posten = await db.vermoegensposten.findMany({
where: { betreuteId, ...nichtGeloescht },
include: { wertstaende: { orderBy: { stichtag: 'asc' } } },
orderBy: [{ art: 'asc' }, { bezeichnung: 'asc' }],
})
return vermoegenBerechnen(
posten.map((p) => ({
id: p.id,
bezeichnung: p.bezeichnung,
art: p.art,
kontoId: p.kontoId,
immobilieId: p.immobilieId,
vertragId: p.vertragId,
bewertungSozialhilfe: p.bewertungSozialhilfe,
wertstaende: p.wertstaende.map((w) => ({
stichtag: w.stichtag,
wertCent: w.wertCent,
wertquelle: w.wertquelle,
nachweisDokumentId: w.nachweisDokumentId,
bemerkung: w.bemerkung,
})),
})),
stichtag,
)
}
// -----------------------------------------------------------------------------
// Wertverlauf eines einzelnen Postens
// -----------------------------------------------------------------------------
export type Verlaufspunkt = {
stichtag: Date
wertCent: bigint
wertquelle: string
/** Veraenderung gegenueber dem vorherigen Wertstand. */
veraenderungCent: bigint | null
}
/** Aeltester Wert zuerst, mit der Veraenderung zum Vorgaenger. */
export function wertverlauf(wertstaende: WertstandEingabe[]): Verlaufspunkt[] {
const sortiert = nachTagSortieren(wertstaende, (w) => w.stichtag)
return sortiert.map((wert, i) => {
const vorher = i > 0 ? sortiert[i - 1]! : null
return {
stichtag: alsTag(wert.stichtag),
wertCent: wert.wertCent,
wertquelle: wert.wertquelle,
veraenderungCent: vorher ? wert.wertCent - vorher.wertCent : null,
}
})
}
/**
* Punkte fuer eine kleine Inline-SVG-Kurve, normiert auf ein Rechteck.
* Bewusst kein Diagrammpaket: eine Handvoll Stuetzstellen als Polyline
* reicht, laedt nichts nach und druckt sauber.
*/
export function verlaufsPunkte(
verlauf: Verlaufspunkt[],
breite: number,
hoehe: number,
): { x: number; y: number }[] {
if (verlauf.length === 0) return []
if (verlauf.length === 1) return [{ x: breite / 2, y: hoehe / 2 }]
const zeiten = verlauf.map((p) => p.stichtag.getTime())
const werte = verlauf.map((p) => Number(p.wertCent))
const tMin = Math.min(...zeiten)
const tMax = Math.max(...zeiten)
const wMin = Math.min(...werte)
const wMax = Math.max(...werte)
const tSpanne = tMax - tMin || 1
const wSpanne = wMax - wMin || 1
return verlauf.map((punkt, i) => ({
x: ((zeiten[i]! - tMin) / tSpanne) * breite,
// y ist in SVG nach unten gerichtet - deshalb gespiegelt.
y: hoehe - ((werte[i]! - wMin) / wSpanne) * hoehe,
}))
}
+115
View File
@@ -0,0 +1,115 @@
import { heuteAlsTag, tageZwischen, tagesdatum } from '@/lib/fristen/datum'
/**
* Die Fristenampel.
*
* Farbe ist hier nie die einzige Information: Jede Stufe bringt ihren
* eigenen Text mit ("Überfällig seit 3 Tagen", "Heute fällig"). Wer die
* Farben nicht unterscheiden kann - und rot/grün ist die häufigste
* Farbfehlsichtigkeit -, liest denselben Sachverhalt im Klartext.
*/
export type Ampelstufe = 'ueberfaellig' | 'diese_woche' | 'dieser_monat' | 'spaeter' | 'erledigt'
export type Ampelvariante = 'rot' | 'orange' | 'gelb' | 'gruen' | 'secondary'
export type Ampel = {
stufe: Ampelstufe
variante: Ampelvariante
/** Klartext, der neben der Farbe steht. Nie weglassen. */
text: string
/** Tage bis zum Stichtag; negativ, wenn er vorbei ist. */
tage: number
}
/** Ab hier gilt eine Frist als "diese Woche" - eine volle Woche Vorlauf. */
const GRENZE_WOCHE = 7
/** Ab hier gilt sie als "dieser Monat". 31 Tage decken jeden Monat ab. */
const GRENZE_MONAT = 31
export const AMPEL_REIHENFOLGE: Ampelstufe[] = [
'ueberfaellig',
'diese_woche',
'dieser_monat',
'spaeter',
'erledigt',
]
export const AMPEL_UEBERSCHRIFT: Record<Ampelstufe, string> = {
ueberfaellig: 'Überfällig',
diese_woche: 'In den nächsten 7 Tagen',
dieser_monat: 'Im nächsten Monat',
spaeter: 'Später',
erledigt: 'Abgeschlossen',
}
export const AMPEL_ERKLAERUNG: Record<Ampelstufe, string> = {
ueberfaellig: 'Der Stichtag ist vorbei. Das gehört zuerst erledigt.',
diese_woche: 'Fällig innerhalb einer Woche.',
dieser_monat: 'Fällig innerhalb eines Monats.',
spaeter: 'Liegt im Plan, nichts zu tun.',
erledigt: 'Erledigt, verschoben oder entfallen.',
}
type Status = 'OFFEN' | 'ERLEDIGT' | 'VERSCHOBEN' | 'ENTFALLEN'
/**
* Stufe, Farbvariante und Text zu einem Stichtag.
*
* `heute` ist nur zum Testen gedacht - im Betrieb bleibt es beim aktuellen
* Tag.
*/
export function fristAmpel(datum: Date, status: Status = 'OFFEN', heute?: Date): Ampel {
const stichtag = tagesdatum(datum)
const bezug = heute ? tagesdatum(heute) : heuteAlsTag()
const tage = tageZwischen(bezug, stichtag)
// Abgeschlossene Fristen bekommen bewusst KEINE Ampelfarbe, sondern das
// neutrale Grau: Sie sollen in der Liste zurücktreten. Grün ist hier für
// "liegt im Plan" reserviert - also für etwas, das noch zu tun ist.
if (status === 'ERLEDIGT') {
return { stufe: 'erledigt', variante: 'secondary', text: 'Erledigt', tage }
}
if (status === 'ENTFALLEN') {
return { stufe: 'erledigt', variante: 'secondary', text: 'Entfallen', tage }
}
if (status === 'VERSCHOBEN') {
return { stufe: 'erledigt', variante: 'secondary', text: 'Verschoben', tage }
}
if (tage < 0) {
const dauer = tage === -1 ? 'seit gestern' : `seit ${Math.abs(tage)} Tagen`
return { stufe: 'ueberfaellig', variante: 'rot', text: `Überfällig ${dauer}`, tage }
}
if (tage === 0) {
return { stufe: 'diese_woche', variante: 'orange', text: 'Heute fällig', tage }
}
if (tage === 1) {
return { stufe: 'diese_woche', variante: 'orange', text: 'Morgen fällig', tage }
}
if (tage <= GRENZE_WOCHE) {
return { stufe: 'diese_woche', variante: 'orange', text: `Fällig in ${tage} Tagen`, tage }
}
if (tage <= GRENZE_MONAT) {
return { stufe: 'dieser_monat', variante: 'gelb', text: `Fällig in ${tage} Tagen`, tage }
}
const wochen = Math.round(tage / 7)
const text =
tage <= 70 ? `Fällig in ${wochen} Wochen` : `Fällig in ${Math.round(tage / 30)} Monaten`
return { stufe: 'spaeter', variante: 'gruen', text, tage }
}
/**
* Ausschlussfristen bekommen einen eigenen, deutlicheren Text: Hier geht
* es nicht um eine Mahnung, sondern um einen Anspruch, der ersatzlos weg
* ist. § 1878 Abs. 2 BGB kennt keine Wiedereinsetzung.
*/
export function ampelText(ampel: Ampel, ausschlussfrist: boolean): string {
if (!ausschlussfrist) return ampel.text
if (ampel.stufe === 'erledigt') return ampel.text
if (ampel.stufe === 'ueberfaellig') return `Anspruch verfallen — ${ampel.text.toLowerCase()}`
return `${ampel.text} — danach verfällt der Anspruch`
}
+218
View File
@@ -0,0 +1,218 @@
import { addDays, addMonths, addYears, differenceInCalendarDays } from 'date-fns'
/**
* Tagesdatum-Arithmetik fuer Fristen.
*
* Fristen liegen als @db.Date in der Datenbank, Prisma liefert sie als
* Date um UTC-Mitternacht. Rechnet man darauf direkt mit date-fns, rechnet
* date-fns in der Ortszeit: Aus dem 15.04. um 00:00 UTC wird in Berlin der
* 15.04. um 02:00 Ortszeit - und "+3 Monate" landet dann je nach Sommer-
* zeitwechsel auf dem 14. statt auf dem 15. Eine Frist, die um einen Tag
* verrutscht, ist im Betreuungsrecht keine Kleinigkeit.
*
* Deshalb der Umweg hier: Zum Rechnen wird das Datum auf die Ortszeit-
* Mittagsstunde gehoben (12 Uhr liegt in keiner Zeitzone in einer
* Umstellungsluecke), dort mit date-fns kalendarisch gerechnet, und das
* Ergebnis wieder auf UTC-Mitternacht zurueckgesetzt.
*/
/** Ein Datum auf UTC-Mitternacht normalisieren - so liegt es in der Datenbank. */
export function tagesdatum(datum: Date): Date {
return new Date(
Date.UTC(datum.getUTCFullYear(), datum.getUTCMonth(), datum.getUTCDate(), 0, 0, 0, 0),
)
}
/** Tagesdatum aus Jahr/Monat(1-12)/Tag. */
export function tag(jahr: number, monat: number, tagImMonat: number): Date {
return new Date(Date.UTC(jahr, monat - 1, tagImMonat, 0, 0, 0, 0))
}
/** Heute als Tagesdatum, aus Sicht der Ortszeit des Servers. */
export function heuteAlsTag(jetzt = new Date()): Date {
return new Date(Date.UTC(jetzt.getFullYear(), jetzt.getMonth(), jetzt.getDate(), 0, 0, 0, 0))
}
/** In die Ortszeit-Mittagsstunde heben, damit date-fns kalendarisch rechnet. */
function zumRechnen(datum: Date): Date {
return new Date(datum.getUTCFullYear(), datum.getUTCMonth(), datum.getUTCDate(), 12, 0, 0, 0)
}
/** Zurueck auf UTC-Mitternacht. */
function vomRechnen(datum: Date): Date {
return new Date(Date.UTC(datum.getFullYear(), datum.getMonth(), datum.getDate(), 0, 0, 0, 0))
}
export function plusTage(datum: Date, anzahl: number): Date {
return vomRechnen(addDays(zumRechnen(datum), anzahl))
}
/**
* Kalendarisch, nicht in Tagen: 31.01. + 1 Monat ist der 28./29.02.
* date-fns kappt auf den letzten Tag des Zielmonats - genau so rechnet
* auch § 188 Abs. 3 BGB.
*/
export function plusMonate(datum: Date, anzahl: number): Date {
return vomRechnen(addMonths(zumRechnen(datum), anzahl))
}
/** Kalendarisch: 29.02. + 1 Jahr ist der 28.02. */
export function plusJahre(datum: Date, anzahl: number): Date {
return vomRechnen(addYears(zumRechnen(datum), anzahl))
}
/** Ganze Tage zwischen zwei Tagesdaten. Negativ, wenn "bis" schon vorbei ist. */
export function tageZwischen(von: Date, bis: Date): number {
return differenceInCalendarDays(zumRechnen(bis), zumRechnen(von))
}
export function istVor(a: Date, b: Date): boolean {
return tagesdatum(a).getTime() < tagesdatum(b).getTime()
}
export function istGleicherTag(a: Date, b: Date): boolean {
return tagesdatum(a).getTime() === tagesdatum(b).getTime()
}
/** "2026-09-14" - fuer Formularfelder vom Typ date und fuer ICS-Schluessel. */
export function alsIsoTag(datum: Date): string {
const d = tagesdatum(datum)
const monat = String(d.getUTCMonth() + 1).padStart(2, '0')
const tagImMonat = String(d.getUTCDate()).padStart(2, '0')
return `${d.getUTCFullYear()}-${monat}-${tagImMonat}`
}
/** Liest "2026-09-14" aus einem HTML-Datumsfeld als Tagesdatum. */
export function ausIsoTag(text: string): Date | null {
const treffer = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text.trim())
if (!treffer) return null
const jahr = Number(treffer[1])
const monat = Number(treffer[2])
const tagImMonat = Number(treffer[3])
const datum = tag(jahr, monat, tagImMonat)
// Fängt "2026-02-31" ab, das sonst still zum 03.03. würde.
if (datum.getUTCMonth() !== monat - 1 || datum.getUTCDate() !== tagImMonat) return null
return datum
}
// -----------------------------------------------------------------------------
// Wochenenden und Feiertage
// -----------------------------------------------------------------------------
const WOCHENTAGE = [
'Sonntag',
'Montag',
'Dienstag',
'Mittwoch',
'Donnerstag',
'Freitag',
'Samstag',
] as const
export function wochentagName(datum: Date): string {
return WOCHENTAGE[tagesdatum(datum).getUTCDay()]!
}
export function istWochenende(datum: Date): boolean {
const wochentag = tagesdatum(datum).getUTCDay()
return wochentag === 0 || wochentag === 6
}
/**
* Ostersonntag nach der Gaussschen Osterformel in der Fassung von Lichtenberg.
* Ohne ihn liessen sich Karfreitag, Ostermontag, Christi Himmelfahrt,
* Pfingstmontag und Fronleichnam nicht bestimmen.
*/
function ostersonntag(jahr: number): Date {
const a = jahr % 19
const b = Math.floor(jahr / 100)
const c = jahr % 100
const d = Math.floor(b / 4)
const e = b % 4
const f = Math.floor((b + 8) / 25)
const g = Math.floor((b - f + 1) / 3)
const h = (19 * a + b - d - g + 15) % 30
const i = Math.floor(c / 4)
const k = c % 4
const l = (32 + 2 * e + 2 * i - h - k) % 7
const m = Math.floor((a + 11 * h + 22 * l) / 451)
const monat = Math.floor((h + l - 7 * m + 114) / 31)
const tagImMonat = ((h + l - 7 * m + 114) % 31) + 1
return tag(jahr, monat, tagImMonat)
}
/**
* Gesetzliche Feiertage in Bayern. Die betreute Person lebt in Bayern,
* das zustaendige Betreuungsgericht und der Bezirk sitzen dort - fuer die
* Frage "ist an diesem Tag jemand zu erreichen?" zaehlt dieses Bundesland.
*
* Mariae Himmelfahrt gilt nur in ueberwiegend katholischen Gemeinden. Da
* der Hinweis nur warnt und nichts verschiebt, ist er hier bewusst
* grosszuegig mitgenommen: lieber einmal zu viel gewarnt.
*/
export function feiertagName(datum: Date): string | null {
const d = tagesdatum(datum)
const jahr = d.getUTCFullYear()
const schluessel = alsIsoTag(d)
const ostern = ostersonntag(jahr)
const beweglich: Record<string, string> = {
[alsIsoTag(plusTage(ostern, -2))]: 'Karfreitag',
[alsIsoTag(ostern)]: 'Ostersonntag',
[alsIsoTag(plusTage(ostern, 1))]: 'Ostermontag',
[alsIsoTag(plusTage(ostern, 39))]: 'Christi Himmelfahrt',
[alsIsoTag(plusTage(ostern, 49))]: 'Pfingstsonntag',
[alsIsoTag(plusTage(ostern, 50))]: 'Pfingstmontag',
[alsIsoTag(plusTage(ostern, 60))]: 'Fronleichnam',
}
if (beweglich[schluessel]) return beweglich[schluessel]!
const fest: Record<string, string> = {
'01-01': 'Neujahr',
'01-06': 'Heilige Drei Könige',
'05-01': 'Tag der Arbeit',
'08-15': 'Mariä Himmelfahrt',
'10-03': 'Tag der Deutschen Einheit',
'11-01': 'Allerheiligen',
'12-25': '1. Weihnachtsfeiertag',
'12-26': '2. Weihnachtsfeiertag',
}
return fest[schluessel.slice(5)] ?? null
}
export function istFeiertag(datum: Date): boolean {
return feiertagName(datum) !== null
}
/**
* Liefert einen Hinweistext, wenn die Frist auf einen Tag faellt, an dem
* bei Gericht, Bezirk und Kasse niemand sitzt - sonst null.
*
* Verschoben wird bewusst NICHT. § 193 BGB verlaengert zwar das Fristende
* auf den naechsten Werktag, wenn der letzte Tag ein Samstag, Sonntag oder
* gesetzlicher Feiertag ist. Fuer die Arbeitsplanung waere es aber genau
* der falsche Schluss: Wer die Unterlagen erst am Montag fertigstellt, hat
* sie am Freitag nicht abgeschickt. Die Anwendung soll frueher fertig
* machen lassen, nicht spaeter. Ausserdem gilt § 193 BGB nicht fuer jede
* Frist gleichermassen (bei materiell-rechtlichen Ausschlussfristen ist die
* Anwendung umstritten) - sich darauf zu verlassen, kostet im Zweifel den
* Anspruch. Also: Datum stehen lassen, Nutzer warnen.
*/
export function nichtWerktagHinweis(datum: Date): string | null {
const feiertag = feiertagName(datum)
if (feiertag) {
return (
`Der Stichtag ist ein Feiertag (${feiertag}). Bitte vorher erledigen — ` +
`§ 193 BGB würde den Ablauf zwar auf den nächsten Werktag schieben, ` +
`verlass dich aber nicht darauf.`
)
}
if (istWochenende(datum)) {
return (
`Der Stichtag fällt auf einen ${wochentagName(datum)}. Bitte vorher erledigen — ` +
`§ 193 BGB würde den Ablauf zwar auf den nächsten Werktag schieben, ` +
`verlass dich aber nicht darauf.`
)
}
return null
}
+242
View File
@@ -0,0 +1,242 @@
import { createHmac, timingSafeEqual } from 'node:crypto'
import { env } from '@/lib/env'
import { tagesdatum } from '@/lib/fristen/datum'
/**
* Abonnierbarer Kalender (iCalendar, RFC 5545).
*
* Warum von Hand statt mit einer Bibliothek? Der Feed ist der einzige Weg,
* auf dem die Fristen ohne Anmeldung im Handykalender landen - er muss
* daher genau das enthalten, was noetig ist, und nichts weiter. Namen und
* Diagnosen haben in einem Kalender, den ein Mobilfunkanbieter mitliest,
* nichts verloren.
*
* Zugangsschutz ohne zusaetzliches Schemafeld: Die Adresse traegt eine
* Signatur aus betreuteId, userId und einem HMAC-SHA256 darueber mit dem
* AUTH_SECRET. Wer den Schluessel nicht hat, kann keine gueltige Adresse
* bauen. Der Nachteil ist bekannt: Die Adresse laesst sich nur widerrufen,
* indem AUTH_SECRET gewechselt wird - dafuer gibt es keinen Datensatz, der
* heimlich mitgelesen werden koennte.
*/
// -----------------------------------------------------------------------------
// Signatur
// -----------------------------------------------------------------------------
function hmacBerechnen(betreuteId: string, userId: string): string {
const geheim = process.env.AUTH_SECRET ?? ''
return createHmac('sha256', geheim).update(`${betreuteId}.${userId}`).digest('base64url')
}
/** `<betreuteId>.<userId>.<hmac>`, insgesamt base64url - ein Pfadsegment. */
export function signaturErzeugen(betreuteId: string, userId: string): string {
const roh = `${betreuteId}.${userId}.${hmacBerechnen(betreuteId, userId)}`
return Buffer.from(roh, 'utf8').toString('base64url')
}
/**
* Prueft die Signatur und gibt zurueck, wer da eigentlich fragt.
* Null heisst: nicht anfassen, nichts ausliefern.
*/
export function signaturPruefen(signatur: string): { betreuteId: string; userId: string } | null {
let roh: string
try {
roh = Buffer.from(signatur.replace(/\.ics$/i, ''), 'base64url').toString('utf8')
} catch {
return null
}
const teile = roh.split('.')
if (teile.length !== 3) return null
const [betreuteId, userId, mitgeliefert] = teile as [string, string, string]
if (!betreuteId || !userId || !mitgeliefert) return null
const erwartet = Buffer.from(hmacBerechnen(betreuteId, userId), 'utf8')
const vorhanden = Buffer.from(mitgeliefert, 'utf8')
// Laengenvergleich vorab, weil timingSafeEqual sonst wirft.
if (erwartet.length !== vorhanden.length) return null
if (!timingSafeEqual(erwartet, vorhanden)) return null
return { betreuteId, userId }
}
/** Die Adresse, die der Nutzer in seinen Kalender eintraegt. */
export function feedUrlErzeugen(betreuteId: string, userId: string): string {
return `${env.basisUrl}/api/kalender/${signaturErzeugen(betreuteId, userId)}.ics`
}
// -----------------------------------------------------------------------------
// Formatierung nach RFC 5545
// -----------------------------------------------------------------------------
/**
* Zeilenfaltung. RFC 5545 Abschnitt 3.1: Eine Inhaltszeile darf hoechstens
* 75 Oktette lang sein; laengere werden umgebrochen, und jede Folgezeile
* beginnt mit einem einzelnen Leerzeichen (das beim Lesen wieder entfaellt).
*
* Gezaehlt werden Oktette, nicht Zeichen - "Vermoegensuebersicht" mit
* Umlauten braucht in UTF-8 mehr Platz, als die Zeichenzahl vermuten
* laesst. Und ein Mehrbytezeichen darf dabei nicht mittendrin zerteilt
* werden, sonst zeigt der Kalender Fragezeichen an.
*/
export function zeileFalten(zeile: string): string {
const bytes = Buffer.from(zeile, 'utf8')
if (bytes.length <= 75) return zeile
const stuecke: string[] = []
let pos = 0
// Erste Zeile 75 Oktette, Folgezeilen 74 - das fuehrende Leerzeichen
// zaehlt mit.
let grenze = 75
while (pos < bytes.length) {
let ende = Math.min(pos + grenze, bytes.length)
// Nicht in ein UTF-8-Folgebyte (10xxxxxx) hineinschneiden.
while (ende > pos + 1 && ende < bytes.length && (bytes[ende]! & 0xc0) === 0x80) ende--
stuecke.push(bytes.subarray(pos, ende).toString('utf8'))
pos = ende
grenze = 74
}
return stuecke.join('\r\n ')
}
/** RFC 5545 Abschnitt 3.3.11: Backslash, Semikolon, Komma und Zeilenumbruch. */
export function textEscapen(wert: string): string {
return wert
.replace(/\\/g, '\\\\')
.replace(/;/g, '\\;')
.replace(/,/g, '\\,')
.replace(/\r\n|\r|\n/g, '\\n')
}
/** "20260914" - Datumswert ohne Uhrzeit. */
export function alsIcsDatum(datum: Date): string {
const d = tagesdatum(datum)
return (
`${d.getUTCFullYear()}` +
`${String(d.getUTCMonth() + 1).padStart(2, '0')}` +
`${String(d.getUTCDate()).padStart(2, '0')}`
)
}
/** "20260822T101500Z" - Zeitstempel in UTC. */
export function alsIcsZeitstempel(zeitpunkt: Date): string {
return `${zeitpunkt.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '')}`
}
// -----------------------------------------------------------------------------
// Kalender bauen
// -----------------------------------------------------------------------------
export type IcsFrist = {
id: string
titel: string
beschreibung?: string | null
datum: Date
rechtsgrundlage?: string | null
typ: string
ausschlussfrist: boolean
vorwarnungTage: number[]
aktualisiertAm?: Date | null
}
export type IcsOptionen = {
/** Erscheint als Kalendername im Handy. Bewusst ohne vollen Namen. */
kalendername: string
fristen: IcsFrist[]
/** Nur zum Testen; sonst der Zeitpunkt der Anfrage. */
jetzt?: Date
/** Basisadresse fuer die Rueckverweise in die Anwendung. */
basisUrl?: string
}
/**
* Baut den vollstaendigen Kalender. Rein - kein Datenbankzugriff, damit
* sich die Ausgabe testen laesst.
*/
export function icsErzeugen({
kalendername,
fristen,
jetzt = new Date(),
basisUrl,
}: IcsOptionen): string {
const zeitstempel = alsIcsZeitstempel(jetzt)
const zeilen: string[] = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//Betreuungs-Cockpit//Fristen//DE',
'CALSCALE:GREGORIAN',
'METHOD:PUBLISH',
`X-WR-CALNAME:${textEscapen(kalendername)}`,
'X-WR-TIMEZONE:Europe/Berlin',
// Beide Angaben: die eine kennt Apple, die andere der Rest der Welt.
'REFRESH-INTERVAL;VALUE=DURATION:PT6H',
'X-PUBLISHED-TTL:PT6H',
]
for (const frist of fristen) {
zeilen.push(...ereignisZeilen(frist, zeitstempel, basisUrl))
}
zeilen.push('END:VCALENDAR')
// RFC 5545 verlangt CRLF - und zwar auch am Ende der letzten Zeile.
return zeilen.map(zeileFalten).join('\r\n') + '\r\n'
}
function ereignisZeilen(frist: IcsFrist, zeitstempel: string, basisUrl?: string): string[] {
const beginn = tagesdatum(frist.datum)
// Ganztaegige Ereignisse enden nach RFC 5545 am Folgetag ("nicht
// einschliessend"). Ohne das +1 zeigen manche Kalender gar nichts an.
const ende = new Date(beginn.getTime() + 86_400_000)
const titel = frist.ausschlussfrist ? `${frist.titel} — Ausschlussfrist!` : frist.titel
const beschreibungsteile: string[] = []
if (frist.beschreibung) beschreibungsteile.push(frist.beschreibung)
if (frist.rechtsgrundlage) beschreibungsteile.push(`Rechtsgrundlage: ${frist.rechtsgrundlage}`)
if (frist.ausschlussfrist) {
beschreibungsteile.push(
'Ausschlussfrist: Läuft sie ab, ist der Anspruch ersatzlos verloren.',
)
}
beschreibungsteile.push('Keine Rechtsberatung — im Zweifel bei Gericht oder Bezirk nachfragen.')
if (basisUrl) beschreibungsteile.push(`${basisUrl}/fristen/${frist.id}`)
const zeilen = [
'BEGIN:VEVENT',
`UID:frist-${frist.id}@betreuungs-cockpit`,
`DTSTAMP:${zeitstempel}`,
`DTSTART;VALUE=DATE:${alsIcsDatum(beginn)}`,
`DTEND;VALUE=DATE:${alsIcsDatum(ende)}`,
`SUMMARY:${textEscapen(titel)}`,
`DESCRIPTION:${textEscapen(beschreibungsteile.join('\n\n'))}`,
`CATEGORIES:${textEscapen(frist.typ)}`,
// Der Termin blockt keine Zeit - er ist eine Erinnerung, kein Termin
// beim Amt. Sonst erscheint der Kalender den ganzen Tag als belegt.
'TRANSP:TRANSPARENT',
'CLASS:PRIVATE',
'STATUS:CONFIRMED',
`PRIORITY:${frist.ausschlussfrist ? 1 : 5}`,
]
if (basisUrl) zeilen.push(`URL:${basisUrl}/fristen/${frist.id}`)
if (frist.aktualisiertAm) {
zeilen.push(`LAST-MODIFIED:${alsIcsZeitstempel(frist.aktualisiertAm)}`)
}
// Je Vorwarnstufe ein eigener Wecker. "-P0D" waere keine gueltige Dauer,
// deshalb faellt die Stufe 0 heraus.
for (const tage of [...new Set(frist.vorwarnungTage)].filter((t) => t > 0).sort((a, b) => b - a)) {
zeilen.push(
'BEGIN:VALARM',
'ACTION:DISPLAY',
`TRIGGER;RELATED=START:-P${tage}D`,
`DESCRIPTION:${textEscapen(`In ${tage} Tagen fällig: ${titel}`)}`,
'END:VALARM',
)
}
zeilen.push('END:VEVENT')
return zeilen
}
+36
View File
@@ -0,0 +1,36 @@
import 'server-only'
import type { Betreute, User } from '@prisma/client'
import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung'
import type { Zugriffsart } from '@/lib/auth/rollen'
/**
* Der immer gleiche Vorspann jeder Fristenseite: angemeldeter Benutzer,
* aktive betreute Person, geprueftes Zugriffsrecht auf das Modul FRISTEN.
*
* Die Pruefung laeuft ueber erfordereZugang() und damit gegen die
* betreuteId - nicht gegen das, was im Cookie steht. Ein manipuliertes
* Cookie kaeme hoechstens bis hierher.
*/
export type Fristenkontext = {
benutzer: User
betreute: Betreute
darfSchreiben: boolean
}
export async function fristenKontext(art: Zugriffsart = 'lesen'): Promise<Fristenkontext> {
const benutzer = await erfordereBenutzer('/fristen')
const aktive = await aktiveBetreute(benutzer)
if (!aktive) {
// Ohne betreute Person gibt es keine Fristen. Das App-Layout leitet in
// diesem Fall bereits in die Ersteinrichtung um; hier steht der Fall
// nur zur Sicherheit.
throw new Error('Es ist keine betreute Person ausgewählt.')
}
const kontext = await erfordereZugang(aktive.id, 'FRISTEN', art)
return {
benutzer: kontext.benutzer,
betreute: kontext.betreute,
darfSchreiben: kontext.darfSchreiben('FRISTEN'),
}
}
+528
View File
@@ -0,0 +1,528 @@
import {
heuteAlsTag,
istVor,
nichtWerktagHinweis,
plusJahre,
plusMonate,
plusTage,
tag,
tagesdatum,
} from '@/lib/fristen/datum'
/**
* Ableitung der gesetzlichen Pflichtfristen aus den Stammdaten.
*
* Das ist das Herzstueck der Anwendung. Ein ehrenamtlicher Betreuer bekommt
* vom Gericht keine Erinnerung; wer den Jahresbericht vergisst, bekommt
* Post vom Rechtspfleger, und wer die Aufwandspauschale vergisst, bekommt
* gar nichts mehr - der Anspruch ist dann weg (§ 1878 Abs. 2 BGB).
*
* Alles hier haengt am Bestellungsdatum. Ohne Bestellungsdatum gibt es
* keine ableitbare Pflichtfrist, und die Funktion liefert eine leere Liste.
*/
export type Pflichtfrist = {
/**
* Stabiler Schluessel, verhindert Dubletten: "anfangsbericht",
* "jahresbericht-2027", "aufwandspauschale-2026", ...
*/
systemSchluessel: string
titel: string
beschreibung: string
datum: Date
rechtsgrundlage: string
typ: 'GESETZLICH' | 'BEHOERDLICH' | 'SELBST_GESETZT' | 'WIEDERVORLAGE'
ausschlussfrist: boolean
vorwarnungTage: number[]
wiederholungRrule?: string
}
export type Pflichtfristenstammdaten = {
bestellungAm: Date | null
befreiterBetreuer: boolean
ehrenamtlich: boolean
pflegegrad: number | null
/** Wie viele kuenftige Faelligkeiten je wiederkehrender Pflicht? Standard 3. */
jahreVoraus?: number
/** Bezugstag fuer "im Voraus". Nur zum Testen zu setzen. */
heute?: Date
/** Seit wann besteht der Pflegegrad? Basis der Wiedervorlage nach § 18 SGB XI. */
pflegegradSeit?: Date | null
/** Abstand der Pflegegrad-Wiedervorlage in Monaten. Standard 12. */
pflegegradPruefungNachMonaten?: number
}
/** Regelvorwarnung: zwei Monate, ein Monat, eine Woche, ein Tag vorher. */
const VORWARNUNG_STANDARD = [60, 30, 7, 1]
/**
* Ausschlussfristen bekommen zusaetzlich eine Vorwarnung ein Vierteljahr
* vorher: Wer die Aufwandspauschale geltend machen will, braucht dafuer
* das abgelaufene Betreuungsjahr sauber abgerechnet - das macht niemand
* an einem Nachmittag.
*/
const VORWARNUNG_AUSSCHLUSS = [90, 60, 30, 14, 7, 1]
// -----------------------------------------------------------------------------
// Betreuungsjahr
// -----------------------------------------------------------------------------
/**
* Das Betreuungsjahr laeuft ab dem Tag der Bestellung, nicht ab dem
* Kalenderjahr (§ 1878 Abs. 1 Satz 2 BGB spricht ausdruecklich vom
* "Betreuungsjahr"). Bestellung am 15.03.2025 heisst: erstes
* Betreuungsjahr 15.03.2025 bis 14.03.2026.
*/
export function betreuungsjahrBeginn(bestellungAm: Date, nummer: number): Date {
if (nummer <= 1) return tagesdatum(bestellungAm)
// Der Tag nach dem Ende des vorigen Jahres - so bleibt die Kette auch
// ueber den 29. Februar hinweg lueckenlos.
return plusTage(betreuungsjahrEnde(bestellungAm, nummer - 1), 1)
}
/**
* Letzter Tag des Betreuungsjahres.
*
* Im Regelfall ist das der Tag vor dem naechsten Jahrestag: § 188 Abs. 2
* BGB laesst eine nach § 187 Abs. 2 BGB berechnete Frist (der Anfangstag
* zaehlt mit, so ist es beim Bestellungstag) mit dem Tag ablaufen, der dem
* Anfangstag vorhergeht. Bestellung am 15.03. heisst also Ende am 14.03.
*
* Ausnahme: Bestellung am 29. Februar. Im Folgejahr gibt es diesen Tag
* nicht; § 188 Abs. 3 BGB laesst die Frist dann mit dem letzten Tag des
* Monats enden, also am 28. Februar. date-fns kappt beim Jahresschritt
* ohnehin auf den 28. - dann ist genau dieser Tag das Ende, und es darf
* kein weiterer Tag abgezogen werden.
*/
export function betreuungsjahrEnde(bestellungAm: Date, nummer: number): Date {
const start = tagesdatum(bestellungAm)
const jahrestag = plusJahre(start, nummer)
const gekappt = jahrestag.getUTCDate() !== start.getUTCDate()
return gekappt ? jahrestag : plusTage(jahrestag, -1)
}
/** Nummer des Betreuungsjahres, in dem "heute" liegt (>= 1). */
export function laufendesBetreuungsjahr(bestellungAm: Date, heute: Date): number {
const start = tagesdatum(bestellungAm)
const bezug = tagesdatum(heute)
if (istVor(bezug, start)) return 1
// Grobschaetzung ueber die Kalenderjahre, danach exakt nachjustieren -
// das kostet hoechstens zwei Schleifendurchlaeufe.
let nummer = Math.max(1, bezug.getUTCFullYear() - start.getUTCFullYear())
while (istVor(betreuungsjahrEnde(start, nummer), bezug)) nummer++
while (nummer > 1 && !istVor(betreuungsjahrEnde(start, nummer - 1), bezug)) nummer--
return nummer
}
// -----------------------------------------------------------------------------
// Ableitung
// -----------------------------------------------------------------------------
function mitWerktagshinweis(beschreibung: string, datum: Date): string {
const hinweis = nichtWerktagHinweis(datum)
return hinweis ? `${beschreibung}\n\n${hinweis}` : beschreibung
}
function frist(
rohdaten: Omit<Pflichtfrist, 'vorwarnungTage'> & { vorwarnungTage?: number[] },
): Pflichtfrist {
return {
...rohdaten,
beschreibung: mitWerktagshinweis(rohdaten.beschreibung, rohdaten.datum),
vorwarnungTage:
rohdaten.vorwarnungTage ??
(rohdaten.ausschlussfrist ? VORWARNUNG_AUSSCHLUSS : VORWARNUNG_STANDARD),
}
}
/**
* Leitet alle Pflichtfristen aus den Stammdaten ab. Rein, ohne Datenbank.
*
* Wiederkehrende Pflichten werden nur fuer die naechsten `jahreVoraus`
* faelligen Termine erzeugt. Bereits verstrichene Termine kommen nicht in
* die Liste - mit einer Ausnahme, die weiter unten beschrieben ist.
*/
export function pflichtfristenAbleiten(stammdaten: Pflichtfristenstammdaten): Pflichtfrist[] {
const {
bestellungAm,
befreiterBetreuer,
ehrenamtlich,
pflegegrad,
jahreVoraus = 3,
pflegegradSeit = null,
pflegegradPruefungNachMonaten = 12,
} = stammdaten
// Ohne Bestellungsdatum haengt nichts in der Luft, sondern gar nichts:
// jede gesetzliche Frist rechnet von diesem Tag aus.
if (!bestellungAm) return []
const bestellung = tagesdatum(bestellungAm)
const heute = stammdaten.heute ? tagesdatum(stammdaten.heute) : heuteAlsTag()
const anzahl = Math.max(1, Math.trunc(jahreVoraus))
const ergebnis: Pflichtfrist[] = []
// --- Einmalige Fristen aus der Bestellung --------------------------------
// Sie werden auch dann erzeugt, wenn sie laengst verstrichen sind: Sie
// gehoeren zur Akte, und der Betreuer soll sehen koennen, dass er sie
// erledigt hat (oder eben nicht).
const dreiMonate = plusMonate(bestellung, 3)
ergebnis.push(
frist({
systemSchluessel: 'vermoegensverzeichnis',
titel: 'Vermögensverzeichnis beim Gericht einreichen',
beschreibung:
'Bestandsaufnahme des gesamten Vermögens zum Tag der Bestellung: Konten, ' +
'Bargeld, Versicherungen mit Rückkaufswert, Immobilien, Forderungen und ' +
'Verbindlichkeiten. Das Verzeichnis ist die Grundlage jeder späteren ' +
'Rechnungslegung — sitzt es schief, zieht sich der Fehler durch alle Jahre.',
datum: dreiMonate,
rechtsgrundlage: '§ 1835 BGB',
typ: 'GESETZLICH',
ausschlussfrist: false,
vorwarnungTage: [60, 30, 14, 7, 1],
}),
frist({
systemSchluessel: 'anfangsbericht',
titel: 'Anfangsbericht beim Betreuungsgericht einreichen',
beschreibung:
'Bericht über die persönlichen Verhältnisse: Lebenssituation, Gesundheits- ' +
'zustand, Wohnsituation, die Wünsche der betreuten Person und die Ziele der ' +
'Betreuung. Der Bericht ist zusammen mit der betreuten Person zu besprechen, ' +
'soweit das möglich ist.',
datum: dreiMonate,
rechtsgrundlage: '§ 1863 Abs. 1 BGB',
typ: 'GESETZLICH',
ausschlussfrist: false,
vorwarnungTage: [60, 30, 14, 7, 1],
}),
)
// --- Wiederkehrende Fristen am Betreuungsjahr ----------------------------
const laufend = laufendesBetreuungsjahr(bestellung, heute)
// Kandidaten grosszuegig erzeugen und danach filtern: das vorige
// Betreuungsjahr kommt bewusst mit, weil seine Aufwandspauschale noch
// sechs Monate lang geltend gemacht werden kann.
const kandidaten: number[] = []
for (let n = Math.max(1, laufend - 1); n <= laufend + anzahl; n++) kandidaten.push(n)
const jahresberichte: Pflichtfrist[] = []
const uebersichten: Pflichtfrist[] = []
const pauschalen: Pflichtfrist[] = []
const rueckkaufswerte: Pflichtfrist[] = []
for (const n of kandidaten) {
const ende = betreuungsjahrEnde(bestellung, n)
const beginn = betreuungsjahrBeginn(bestellung, n)
const jahr = ende.getUTCFullYear()
const zeitraum = `${formatKurz(beginn)}–${formatKurz(ende)}`
jahresberichte.push(
frist({
systemSchluessel: `jahresbericht-${jahr}`,
titel: `Jahresbericht ${jahr} an das Betreuungsgericht`,
beschreibung:
`Bericht über das Betreuungsjahr ${zeitraum}: Was hat sich bei Gesundheit, ` +
`Wohnen und persönlichen Verhältnissen verändert, welche Wünsche wurden ` +
`umgesetzt, wie oft gab es persönlichen Kontakt, wie soll die Betreuung ` +
`weitergehen. Stichtag ist das Ende des Betreuungsjahres.`,
datum: ende,
rechtsgrundlage: '§ 1863 Abs. 3 BGB',
typ: 'GESETZLICH',
ausschlussfrist: false,
}),
)
// Befreite Betreuer - typischerweise Angehoerige, § 1859 Abs. 1 BGB -
// schulden nur die knappe Vermoegensuebersicht statt der vollen
// Rechnungslegung nach § 1865 BGB. Der Schluessel bleibt trotzdem
// gleich, damit ein Wechsel der Einstufung keine Dublette erzeugt.
uebersichten.push(
frist({
systemSchluessel: `vermoegensuebersicht-${jahr}`,
titel: befreiterBetreuer
? `Vermögensübersicht ${jahr} einreichen`
: `Rechnungslegung ${jahr} einreichen`,
beschreibung: befreiterBetreuer
? `Als befreiter Betreuer genügt die Übersicht über den Bestand des ` +
`Vermögens zum Ende des Betreuungsjahres ${zeitraum} — keine ` +
`Einzelabrechnung jeder Buchung. Kontostände, Versicherungen, ` +
`Verbindlichkeiten, jeweils mit Nachweis.`
: `Vollständige Rechnungslegung für das Betreuungsjahr ${zeitraum}: ` +
`geordnete Zusammenstellung aller Einnahmen und Ausgaben mit Belegen, ` +
`Anfangs- und Endbestand.`,
datum: ende,
rechtsgrundlage: befreiterBetreuer ? '§ 1859 Abs. 1 BGB' : '§ 1865 BGB',
typ: 'GESETZLICH',
ausschlussfrist: false,
}),
)
// Vorbereitung, keine gesetzliche Frist: Rueckkaufswerte kommen von den
// Versicherern erst nach Wochen. Sechs Wochen vor dem Stichtag anfragen.
rueckkaufswerte.push(
frist({
systemSchluessel: `rueckkaufswerte-${jahr}`,
titel: `Rückkaufswerte für ${jahr} anfordern`,
beschreibung:
`Bei allen Lebens-, Renten- und Sterbegeldversicherungen den aktuellen ` +
`Rückkaufswert zum Stichtag ${formatKurz(ende)} anfordern. Die Versicherer ` +
`brauchen dafür erfahrungsgemäß mehrere Wochen; ohne die Werte lässt sich ` +
`die Vermögensübersicht nicht abschließen.`,
datum: plusTage(ende, -42),
rechtsgrundlage: 'Vorbereitung der Vermögensübersicht',
typ: 'SELBST_GESETZT',
ausschlussfrist: false,
vorwarnungTage: [14, 7, 1],
}),
)
// Die Aufwandspauschale steht nur ehrenamtlichen Betreuern zu; ein
// Berufsbetreuer rechnet nach VBVG ab.
if (ehrenamtlich) {
// § 1878 Abs. 2 BGB: Der Anspruch erlischt, wenn er nicht binnen
// sechs Monaten nach Ablauf des Betreuungsjahres beim Betreuungs-
// gericht geltend gemacht wird. Keine Wiedereinsetzung, kein
// Nachfassen - das Geld ist dann weg. Deshalb ausschlussfrist=true.
const verfall = plusMonate(ende, 6)
pauschalen.push(
frist({
systemSchluessel: `aufwandspauschale-${jahr}`,
titel: `Aufwandspauschale ${jahr} geltend machen (Ausschlussfrist)`,
beschreibung:
`Die Pauschale für das Betreuungsjahr ${zeitraum} muss bis zum ` +
`${formatKurz(verfall)} beim Betreuungsgericht beantragt sein. ` +
`Danach erlischt der Anspruch ersatzlos — eine Verlängerung oder ` +
`Nachsicht gibt es nicht. Ein formloses Schreiben mit Aktenzeichen ` +
`und Betreuungsjahr genügt.`,
datum: verfall,
rechtsgrundlage: '§ 1878 BGB',
typ: 'GESETZLICH',
ausschlussfrist: true,
}),
)
}
}
ergebnis.push(
...kuenftige(jahresberichte, heute, anzahl),
...kuenftige(uebersichten, heute, anzahl),
...kuenftige(rueckkaufswerte, heute, anzahl),
...kuenftige(pauschalen, heute, anzahl + 1),
)
// --- Wiederkehrende Fristen am Kalenderjahr ------------------------------
const kalenderjahre: number[] = []
for (let j = heute.getUTCFullYear() - 1; j <= heute.getUTCFullYear() + anzahl; j++) {
kalenderjahre.push(j)
}
// Landespflegegeld gibt es in Bayern ab Pflegegrad 2. Unter Pflegegrad 2
// waere die Frist eine leere Erinnerung an einen Antrag ohne Anspruch.
if ((pflegegrad ?? 0) >= 2) {
const landespflegegeld = kalenderjahre.map((j) =>
frist({
systemSchluessel: `landespflegegeld-${j}`,
titel: `Bayerisches Landespflegegeld ${j} beantragen`,
beschreibung:
`1.000 € jährlich, unabhängig von Einkommen und Vermögen, ab Pflegegrad 2. ` +
`Der Antrag für das Pflegegeldjahr muss bis zum 31.12.${j} beim Landesamt ` +
`für Pflege gestellt sein. Das Geld steht der betreuten Person zu und ist ` +
`im Kassenbuch als Einnahme zu führen.`,
datum: tag(j, 12, 31),
rechtsgrundlage: 'Art. 2 BayLPflGG',
typ: 'BEHOERDLICH',
ausschlussfrist: false,
}),
)
ergebnis.push(...kuenftige(landespflegegeld, heute, anzahl))
}
// Zuzahlungsbefreiung gilt immer nur fuer ein Kalenderjahr und muss
// deshalb jedes Jahr neu beantragt werden. Frueh im Jahr beantragen, dann
// fallen die Zuzahlungen des laufenden Jahres nicht erst an.
const zuzahlung = kalenderjahre.map((j) =>
frist({
systemSchluessel: `zuzahlungsbefreiung-${j}`,
titel: `Zuzahlungsbefreiung ${j} bei der Krankenkasse beantragen`,
beschreibung:
`Die Belastungsgrenze liegt bei 2 % der jährlichen Bruttoeinnahmen, bei ` +
`schwerwiegend chronisch Kranken bei 1 %. Wird sie erreicht, befreit die ` +
`Kasse für den Rest des Kalenderjahres ${j} von allen Zuzahlungen. ` +
`Die Befreiung gilt nicht automatisch weiter — sie ist jedes Jahr neu zu ` +
`beantragen. Nachweise: Rentenbescheid und gesammelte Quittungen.`,
datum: tag(j, 1, 31),
rechtsgrundlage: '§ 62 SGB V',
typ: 'BEHOERDLICH',
ausschlussfrist: false,
vorwarnungTage: [30, 14, 7, 1],
}),
)
ergebnis.push(...kuenftige(zuzahlung, heute, anzahl))
// --- Wiedervorlage Pflegegrad -------------------------------------------
// Nur sinnvoll, solange noch Luft nach oben ist: bei Pflegegrad 5 gibt es
// nichts mehr hoeherzustufen.
if (pflegegrad !== null && pflegegrad >= 1 && pflegegrad < 5) {
const basis = pflegegradSeit ? tagesdatum(pflegegradSeit) : bestellung
ergebnis.push(
frist({
systemSchluessel: 'pflegegrad-pruefung',
titel: 'Pflegegrad überprüfen — Höherstufung nötig?',
beschreibung:
`Aktuell Pflegegrad ${pflegegrad}. Hat sich der Hilfebedarf erhöht — mehr ` +
`Unterstützung beim Waschen, Anziehen, Essen, häufigere Atemnot, mehr ` +
`nächtliche Hilfe? Dann lohnt ein Höherstufungsantrag bei der Pflegekasse. ` +
`Vorher ein Pflegetagebuch über zwei Wochen führen und die Pflegedoku des ` +
`Heims anfordern — ohne beides wird der MD-Termin zum Ratespiel.`,
datum: plusMonate(basis, Math.max(1, Math.trunc(pflegegradPruefungNachMonaten))),
rechtsgrundlage: '§ 18 SGB XI',
typ: 'WIEDERVORLAGE',
ausschlussfrist: false,
vorwarnungTage: [30, 7],
}),
)
}
return ergebnis.sort((a, b) => a.datum.getTime() - b.datum.getTime())
}
/** Nur noch nicht verstrichene Termine, hoechstens `anzahl` Stueck. */
function kuenftige(liste: Pflichtfrist[], heute: Date, anzahl: number): Pflichtfrist[] {
return liste
.filter((f) => !istVor(f.datum, heute))
.sort((a, b) => a.datum.getTime() - b.datum.getTime())
.slice(0, anzahl)
}
function formatKurz(datum: Date): string {
const d = tagesdatum(datum)
return `${String(d.getUTCDate()).padStart(2, '0')}.${String(d.getUTCMonth() + 1).padStart(2, '0')}.${d.getUTCFullYear()}`
}
// -----------------------------------------------------------------------------
// Schreiben
// -----------------------------------------------------------------------------
export type Ableitungsergebnis = {
angelegt: number
aktualisiert: number
unveraendert: number
}
/**
* Schreibt die abgeleiteten Pflichtfristen in die Datenbank, ohne Dubletten.
*
* Der Schutz vor Dubletten sitzt im Schema: @@unique([betreuteId,
* systemSchluessel]). Hier wird zusaetzlich unterschieden, was ueberhaupt
* angefasst werden darf:
*
* - noch nicht vorhanden -> anlegen
* - vorhanden und OFFEN -> Datum und Texte nachziehen (z.B. nachdem das
* Bestellungsdatum in den Stammdaten korrigiert
* wurde)
* - erledigt, verschoben, entfallen oder im Papierkorb -> in Ruhe lassen.
* Was der Betreuer abgehakt hat, darf ihm die Automatik nicht wieder
* aufmachen.
*
* Der Rueckgabewert ist die Zahl der neu angelegten Fristen - das ist die
* Zahl, die der Nutzer nach dem Klick sehen will.
*/
export async function pflichtfristenSchreiben(
betreuteId: string,
jahreVoraus = 3,
): Promise<number> {
const ergebnis = await pflichtfristenSchreibenAusfuehrlich(betreuteId, jahreVoraus)
return ergebnis.angelegt
}
/** Wie pflichtfristenSchreiben, liefert aber die volle Bilanz fuer die Meldung. */
export async function pflichtfristenSchreibenAusfuehrlich(
betreuteId: string,
jahreVoraus = 3,
): Promise<Ableitungsergebnis> {
// Der Prisma-Client wird erst hier geladen, nicht beim Import des Moduls.
// So laesst sich pflichtfristenAbleiten() in Vitest testen, ohne dass eine
// Datenbankverbindung aufgebaut oder ein Client erzeugt werden muss.
const { db } = await import('@/lib/db')
const betreute = await db.betreute.findUnique({
where: { id: betreuteId },
select: {
bestellungAm: true,
befreiterBetreuer: true,
ehrenamtlich: true,
pflegegrad: true,
pflegegradSeit: true,
},
})
if (!betreute) return { angelegt: 0, aktualisiert: 0, unveraendert: 0 }
const abgeleitet = pflichtfristenAbleiten({ ...betreute, jahreVoraus })
if (abgeleitet.length === 0) return { angelegt: 0, aktualisiert: 0, unveraendert: 0 }
// Bewusst OHNE nichtGeloescht: eine im Papierkorb liegende Frist belegt
// den eindeutigen Schluessel weiterhin. Wuerde sie hier fehlen, liefe das
// Anlegen in eine Verletzung der Eindeutigkeit.
const vorhandene = await db.frist.findMany({
where: { betreuteId, systemSchluessel: { in: abgeleitet.map((f) => f.systemSchluessel) } },
select: { id: true, systemSchluessel: true, status: true, geloeschtAm: true, datum: true },
})
const nachSchluessel = new Map(vorhandene.map((f) => [f.systemSchluessel!, f]))
let angelegt = 0
let aktualisiert = 0
let unveraendert = 0
for (const pflicht of abgeleitet) {
const alt = nachSchluessel.get(pflicht.systemSchluessel)
if (!alt) {
await db.frist.create({
data: {
betreuteId,
titel: pflicht.titel,
beschreibung: pflicht.beschreibung,
datum: pflicht.datum,
typ: pflicht.typ,
rechtsgrundlage: pflicht.rechtsgrundlage,
vorwarnungTage: pflicht.vorwarnungTage,
ausschlussfrist: pflicht.ausschlussfrist,
systemSchluessel: pflicht.systemSchluessel,
wiederholungRrule: pflicht.wiederholungRrule ?? null,
},
})
angelegt++
continue
}
if (alt.geloeschtAm || alt.status !== 'OFFEN') {
unveraendert++
continue
}
await db.frist.update({
where: { id: alt.id },
data: {
titel: pflicht.titel,
beschreibung: pflicht.beschreibung,
datum: pflicht.datum,
typ: pflicht.typ,
rechtsgrundlage: pflicht.rechtsgrundlage,
vorwarnungTage: pflicht.vorwarnungTage,
ausschlussfrist: pflicht.ausschlussfrist,
},
})
if (alt.datum.getTime() === pflicht.datum.getTime()) unveraendert++
else aktualisiert++
}
return { angelegt, aktualisiert, unveraendert }
}
+189
View File
@@ -0,0 +1,189 @@
import { Frequency, RRule } from 'rrule'
import { tagesdatum } from '@/lib/fristen/datum'
/**
* Wiederkehrende Fristen.
*
* Die Wiederholung steht als iCalendar-RRULE an der Frist
* ("FREQ=YEARLY;INTERVAL=1"). Gespeichert wird immer nur die naechste
* faellige Frist, nicht eine Reihe im Voraus - erst beim Erledigen entsteht
* die naechste. Sonst haette man nach fuenf Jahren eine Liste voller
* Termine, die nie jemand angefasst hat.
*
* Die neue Frist zeigt ueber vorgaengerFristId auf die erledigte. Damit
* laesst sich die Kette rueckwaerts lesen: "wann habe ich das zuletzt
* gemacht?" ist gegenueber dem Gericht eine haeufige Frage.
*
* Achtung, bewusste Entscheidung: Die abgeleiteten Pflichtfristen aus
* pflichtfristen.ts tragen KEINE RRULE. Sie werden aus dem Bestellungsdatum
* neu berechnet. Haetten sie zusaetzlich eine RRULE, entstuende beim
* Erledigen eine zweite Frist neben der ohnehin abgeleiteten - eine
* Dublette, die der eindeutige systemSchluessel nicht abfaengt, weil die
* Nachfolgefrist keinen Systemschluessel bekommt.
*/
export type FristFuerWiederholung = {
id: string
betreuteId: string
vorgangId?: string | null
titel: string
beschreibung?: string | null
datum: Date
typ: 'GESETZLICH' | 'BEHOERDLICH' | 'SELBST_GESETZT' | 'WIEDERVORLAGE'
rechtsgrundlage?: string | null
vorwarnungTage: number[]
wiederholungRrule?: string | null
ausschlussfrist: boolean
}
/** Bauplan der Nachfolgefrist, fertig fuer db.frist.create({ data }). */
export type Nachfolgefrist = {
betreuteId: string
vorgangId: string | null
titel: string
beschreibung: string | null
datum: Date
typ: FristFuerWiederholung['typ']
rechtsgrundlage: string | null
vorwarnungTage: number[]
wiederholungRrule: string
ausschlussfrist: boolean
vorgaengerFristId: string
}
/**
* Baut die Regel neu auf und setzt DTSTART auf das uebergebene Datum.
*
* Ein in der Regel mitgeliefertes DTSTART wird dabei absichtlich verworfen:
* Massgeblich ist immer der Termin der zuletzt erledigten Frist, sonst
* wandert eine monatliche Wiedervorlage nach dem ersten Verschieben wieder
* auf das urspruengliche Raster zurueck.
*/
function regelMitStart(regel: string, dtstart: Date): RRule {
const nurRrule = regel
.split(/\r?\n/)
.map((zeile) => zeile.trim())
.filter((zeile) => zeile !== '' && !/^DTSTART/i.test(zeile))
.join('\n')
.replace(/^RRULE:/i, '')
const geparst = RRule.parseString(nurRrule)
if (geparst.freq === undefined || geparst.freq === null) {
throw new Error('Die Wiederholungsregel enthält kein FREQ.')
}
// rrule rechnet ohne TZID in UTC. Da unsere Fristen ohnehin auf
// UTC-Mitternacht liegen, passt das genau zusammen.
return new RRule({ ...geparst, dtstart: tagesdatum(dtstart) })
}
/** Ist die Regel ueberhaupt lesbar? Fuer die Formularpruefung. */
export function rruleGueltig(regel: string): boolean {
try {
regelMitStart(regel, new Date(Date.UTC(2026, 0, 1)))
return true
} catch {
return false
}
}
/**
* Der naechste Termin nach `nachDatum` - der Tag selbst zaehlt nicht mit,
* sonst bekaeme man beim Erledigen dieselbe Frist noch einmal.
* Liefert null, wenn die Regel endet (COUNT/UNTIL erschoepft).
*/
export function naechsterTermin(regel: string, nachDatum: Date): Date | null {
let rule: RRule
try {
rule = regelMitStart(regel, nachDatum)
} catch {
return null
}
const treffer = rule.after(tagesdatum(nachDatum), false)
return treffer ? tagesdatum(treffer) : null
}
/**
* Der Bauplan der Frist, die beim Erledigen einer wiederkehrenden Frist
* entsteht. Null, wenn die Frist nicht wiederkehrt oder die Regel
* ausgelaufen ist.
*
* Gerechnet wird ab dem Solltermin der erledigten Frist, nicht ab dem Tag
* der Erledigung: Wer den Bericht drei Tage zu spaet abgibt, soll den
* naechsten trotzdem wieder zum Stichtag abgeben und nicht dauerhaft drei
* Tage nach hinten rutschen.
*/
export function naechsteFristAusErledigter(frist: FristFuerWiederholung): Nachfolgefrist | null {
if (!frist.wiederholungRrule) return null
const naechstes = naechsterTermin(frist.wiederholungRrule, frist.datum)
if (!naechstes) return null
return {
betreuteId: frist.betreuteId,
vorgangId: frist.vorgangId ?? null,
titel: frist.titel,
beschreibung: frist.beschreibung ?? null,
datum: naechstes,
typ: frist.typ,
rechtsgrundlage: frist.rechtsgrundlage ?? null,
vorwarnungTage: frist.vorwarnungTage,
wiederholungRrule: frist.wiederholungRrule,
ausschlussfrist: frist.ausschlussfrist,
vorgaengerFristId: frist.id,
}
}
// -----------------------------------------------------------------------------
// Auswahl fuer die Oberflaeche
// -----------------------------------------------------------------------------
/**
* Die Wiederholungen, die im Betreueralltag tatsaechlich vorkommen. Ein
* freies RRULE-Eingabefeld waere fuer die Zielgruppe eine Zumutung; wer
* eine exotische Regel braucht, kann sie ueber "Eigene Regel" setzen.
*/
export const WIEDERHOLUNGEN: { wert: string; beschriftung: string }[] = [
{ wert: '', beschriftung: 'Einmalig — wiederholt sich nicht' },
{ wert: 'FREQ=WEEKLY;INTERVAL=1', beschriftung: 'Wöchentlich' },
{ wert: 'FREQ=WEEKLY;INTERVAL=2', beschriftung: 'Alle zwei Wochen' },
{ wert: 'FREQ=MONTHLY;INTERVAL=1', beschriftung: 'Monatlich' },
{ wert: 'FREQ=MONTHLY;INTERVAL=3', beschriftung: 'Vierteljährlich' },
{ wert: 'FREQ=MONTHLY;INTERVAL=6', beschriftung: 'Halbjährlich' },
{ wert: 'FREQ=YEARLY;INTERVAL=1', beschriftung: 'Jährlich' },
{ wert: 'FREQ=YEARLY;INTERVAL=2', beschriftung: 'Alle zwei Jahre' },
]
const FREQUENZ_TEXT: Record<number, [string, string]> = {
[Frequency.DAILY]: ['Täglich', 'Tage'],
[Frequency.WEEKLY]: ['Wöchentlich', 'Wochen'],
[Frequency.MONTHLY]: ['Monatlich', 'Monate'],
[Frequency.YEARLY]: ['Jährlich', 'Jahre'],
}
/** Deutsche Klartextbeschreibung einer Regel, z.B. "Alle 3 Monate". */
export function rruleBeschreiben(regel: string | null | undefined): string {
if (!regel) return 'Einmalig'
const bekannt = WIEDERHOLUNGEN.find((w) => w.wert === regel)
if (bekannt && bekannt.wert !== '') return bekannt.beschriftung
try {
const geparst = RRule.parseString(regel.replace(/^RRULE:/i, ''))
const freq = geparst.freq
if (freq === undefined || freq === null) return 'Wiederkehrend'
const text = FREQUENZ_TEXT[freq]
if (!text) return 'Wiederkehrend'
const intervall = geparst.interval ?? 1
const grund = intervall === 1 ? text[0] : `Alle ${intervall} ${text[1]}`
if (geparst.count) return `${grund}, ${geparst.count}-mal`
if (geparst.until) {
return `${grund}, bis ${tagesdatum(geparst.until).toLocaleDateString('de-DE', { timeZone: 'UTC' })}`
}
return grund
} catch {
return 'Wiederkehrend'
}
}
+203
View File
@@ -0,0 +1,203 @@
import { readFileSync } from 'node:fs'
import path from 'node:path'
import { z } from 'zod'
/**
* Hinweis-Wissensbasis.
*
* Die Karten stehen in daten/hinweise.json und nicht im Code. Wer eine
* Rechtslage nachtraegt oder eine Formulierung schaerft, aendert eine
* JSON-Datei - kein Deployment noetig, kein TypeScript noetig.
*
* Bewusst ohne "server-only": die Pruefung und die Kontextzuordnung sind
* reine Funktionen und werden getestet. Nur hinweiseLaden() liest wirklich
* vom Dateisystem.
*
* Grundsatz beim Fehlerfall: Eine kaputte JSON-Datei darf die Anwendung
* nicht anhalten. Dann gibt es eben keine Hinweise und eine Warnung im
* Serverlog - die Fristen und Vorgaenge funktionieren weiter.
*/
export const HinweiskarteSchema = z.object({
/** Stabiler Schluessel, nur Kleinbuchstaben, Ziffern und Bindestriche. */
schluessel: z
.string()
.min(3)
.max(60)
.regex(/^[a-z0-9]+(-[a-z0-9]+)*$/, 'Nur Kleinbuchstaben, Ziffern und Bindestriche.'),
titel: z.string().min(5).max(120),
text: z.string().min(40).max(2000),
rechtsgrundlage: z.string().min(2).max(120),
link: z
.string()
.url()
.refine((wert) => wert.startsWith('https://'), 'Nur https-Links.'),
/** Ausloeser: Modul, VORGANG:<Kategorie>, VORLAGE:<schluessel>, FRIST:<systemSchluessel>. */
kontext: z.array(z.string().min(1).max(80)).min(1),
gewicht: z.number().int().min(0).max(100),
})
export const HinweisdateiSchema = z.object({
version: z.number().int().positive(),
karten: z.array(HinweiskarteSchema).min(1),
})
export type Hinweiskarte = z.infer<typeof HinweiskarteSchema>
export type Hinweisdatei = z.infer<typeof HinweisdateiSchema>
export type Ladeergebnis = {
karten: Hinweiskarte[]
/** Leer, wenn alles in Ordnung war. Sonst lesbare Meldungen. */
fehler: string[]
}
/**
* Prueft rohe Daten gegen das Schema. Reine Funktion - kein Dateisystem,
* kein Werfen. Doppelte Schluessel sind ein Fehler: sonst ueberschreibt
* eine spaete Karte klammheimlich eine fruehere.
*/
export function hinweiseAusDaten(roh: unknown): Ladeergebnis {
const ergebnis = HinweisdateiSchema.safeParse(roh)
if (!ergebnis.success) {
return {
karten: [],
fehler: ergebnis.error.issues.map(
(problem) => `${problem.path.join('.') || '(Wurzel)'}: ${problem.message}`,
),
}
}
const gesehen = new Set<string>()
const doppelte: string[] = []
for (const karte of ergebnis.data.karten) {
if (gesehen.has(karte.schluessel)) doppelte.push(karte.schluessel)
gesehen.add(karte.schluessel)
}
if (doppelte.length > 0) {
return {
karten: [],
fehler: [`Doppelte Schlüssel in der Wissensbasis: ${doppelte.join(', ')}.`],
}
}
return { karten: ergebnis.data.karten, fehler: [] }
}
/** Wie hinweiseAusDaten, nimmt aber den Dateiinhalt als Text entgegen. */
export function hinweiseAusText(inhalt: string): Ladeergebnis {
let roh: unknown
try {
roh = JSON.parse(inhalt)
} catch (fehler) {
return {
karten: [],
fehler: [`Die Datei ist kein gültiges JSON: ${(fehler as Error).message}`],
}
}
return hinweiseAusDaten(roh)
}
/** Wo liegt die Wissensbasis? Relativ zum Projektverzeichnis. */
export const HINWEISE_DATEI = path.join(process.cwd(), 'daten', 'hinweise.json')
let zwischenspeicher: Ladeergebnis | null = null
/**
* Liest die Wissensbasis. Das Ergebnis wird gemerkt, damit nicht bei jedem
* Seitenaufruf die Platte angefasst wird; in der Entwicklung reicht ein
* Neustart des Servers, um Aenderungen zu sehen.
*/
export function hinweiseLaden(datei: string = HINWEISE_DATEI): Ladeergebnis {
if (datei === HINWEISE_DATEI && zwischenspeicher) return zwischenspeicher
let ergebnis: Ladeergebnis
try {
ergebnis = hinweiseAusText(readFileSync(datei, 'utf8'))
} catch (fehler) {
ergebnis = {
karten: [],
fehler: [`Die Wissensbasis ${datei} lässt sich nicht lesen: ${(fehler as Error).message}`],
}
}
if (ergebnis.fehler.length > 0) {
console.warn(
`[hinweise] Wissensbasis nicht verwendbar, es werden keine Hinweise angezeigt:\n` +
ergebnis.fehler.map((z) => ` - ${z}`).join('\n'),
)
}
if (datei === HINWEISE_DATEI) zwischenspeicher = ergebnis
return ergebnis
}
/** Nur fuer Tests und fuer ein spaeteres Neuladen zur Laufzeit. */
export function hinweiseZwischenspeicherLeeren(): void {
zwischenspeicher = null
}
// -----------------------------------------------------------------------------
// Kontextzuordnung
// -----------------------------------------------------------------------------
/**
* Passt ein Ausloeser der Karte auf einen Kontext der Seite?
*
* Verglichen wird ohne Ruecksicht auf Gross- und Kleinschreibung. Zusaetzlich
* gilt eine Praefixregel mit Bindestrich: Die Karte traegt
* "FRIST:jahresbericht" und passt damit auch auf "FRIST:jahresbericht-2027".
* Ohne diese Regel muesste jede Jahreszahl einzeln gepflegt werden.
*/
export function ausloeserPasst(ausloeser: string, kontext: string): boolean {
const a = ausloeser.trim().toLowerCase()
const k = kontext.trim().toLowerCase()
if (a === '' || k === '') return false
if (a === k) return true
return k.startsWith(`${a}-`)
}
/**
* Alle Karten, die zu mindestens einem der Kontexte passen - sortiert nach
* Gewicht absteigend, bei Gleichstand alphabetisch nach Titel, damit die
* Reihenfolge stabil bleibt.
*/
export function passendeHinweise(
karten: Hinweiskarte[],
kontexte: readonly string[],
grenze?: number,
): Hinweiskarte[] {
if (kontexte.length === 0) return []
const treffer = karten.filter((karte) =>
karte.kontext.some((ausloeser) => kontexte.some((k) => ausloeserPasst(ausloeser, k))),
)
treffer.sort((a, b) => b.gewicht - a.gewicht || a.titel.localeCompare(b.titel, 'de'))
return grenze === undefined ? treffer : treffer.slice(0, grenze)
}
/** Bequemer Aufruf aus einer Server Component. */
export function hinweiseFuerKontext(kontexte: readonly string[], grenze?: number): Hinweiskarte[] {
return passendeHinweise(hinweiseLaden().karten, kontexte, grenze)
}
/** Volltextsuche ueber Titel, Text und Rechtsgrundlage - fuer /hinweise. */
export function hinweiseSuchen(karten: Hinweiskarte[], suche: string): Hinweiskarte[] {
const begriff = suche.trim().toLowerCase()
const sortiert = [...karten].sort(
(a, b) => b.gewicht - a.gewicht || a.titel.localeCompare(b.titel, 'de'),
)
if (begriff === '') return sortiert
return sortiert.filter((karte) =>
[karte.titel, karte.text, karte.rechtsgrundlage, karte.schluessel, ...karte.kontext]
.join(' ')
.toLowerCase()
.includes(begriff),
)
}
/** Eine einzelne Karte - z.B. um sie gezielt neben ein Formular zu stellen. */
export function hinweisFinden(schluessel: string): Hinweiskarte | undefined {
return hinweiseLaden().karten.find((karte) => karte.schluessel === schluessel)
}
+88
View File
@@ -0,0 +1,88 @@
/**
* Inventarliste der Wertgegenstaende.
*
* Das Schema kennt kein eigenes Inventarstueck (siehe
* docs/schema-wuensche/substanz.md, Punkt 3). Gefuehrt wird jedes Stueck
* deshalb als Vermoegensposten mit der Art WERTGEGENSTAND, verknuepft mit
* der Immobilie; der geschaetzte Wert liegt als Wertstand mit der Quelle
* "eigene Schaetzung".
*
* Raum und Verbleib passen in kein vorhandenes Feld. Sie stehen deshalb
* als beschriftete Zeilen im ohnehin verschluesselten Notizfeld -
* geschrieben und gelesen ausschliesslich hier, damit das Format an genau
* einer Stelle steht und sich spaeter in eine echte Tabelle migrieren
* laesst.
*/
export const VERBLEIB_MOEGLICHKEITEN = [
'im Haus',
'im Heimzimmer',
'bei Angehörigen',
'eingelagert',
'verkauft',
'verschenkt',
'entsorgt',
'unbekannt',
] as const
export type Verbleib = (typeof VERBLEIB_MOEGLICHKEITEN)[number]
export type InventarNotiz = {
raum: string | null
verbleib: string | null
bemerkung: string | null
}
const MARKE_RAUM = 'Raum:'
const MARKE_VERBLEIB = 'Verbleib:'
const MARKE_BEMERKUNG = 'Bemerkung:'
/** Baut den Notiztext, der verschluesselt gespeichert wird. */
export function inventarNotizSchreiben(notiz: InventarNotiz): string | null {
const zeilen: string[] = []
if (notiz.raum) zeilen.push(`${MARKE_RAUM} ${notiz.raum}`)
if (notiz.verbleib) zeilen.push(`${MARKE_VERBLEIB} ${notiz.verbleib}`)
if (notiz.bemerkung) zeilen.push(`${MARKE_BEMERKUNG} ${notiz.bemerkung}`)
return zeilen.length > 0 ? zeilen.join('\n') : null
}
/**
* Liest den Notiztext zurueck. Was nicht dem Format entspricht - etwa eine
* von Hand geschriebene Notiz aus einem anderen Modul - landet in
* "bemerkung", damit nichts verloren geht.
*/
export function inventarNotizLesen(text: string | null | undefined): InventarNotiz {
const notiz: InventarNotiz = { raum: null, verbleib: null, bemerkung: null }
if (!text) return notiz
const rest: string[] = []
for (const zeile of text.split('\n')) {
const getrimmt = zeile.trim()
if (getrimmt.startsWith(MARKE_RAUM)) {
notiz.raum = getrimmt.slice(MARKE_RAUM.length).trim() || null
} else if (getrimmt.startsWith(MARKE_VERBLEIB)) {
notiz.verbleib = getrimmt.slice(MARKE_VERBLEIB.length).trim() || null
} else if (getrimmt.startsWith(MARKE_BEMERKUNG)) {
notiz.bemerkung = getrimmt.slice(MARKE_BEMERKUNG.length).trim() || null
} else if (getrimmt !== '') {
rest.push(getrimmt)
}
}
if (rest.length > 0) {
notiz.bemerkung = [notiz.bemerkung, ...rest].filter(Boolean).join(' ')
}
return notiz
}
/** Die Quelle, mit der ein geschaetzter Inventarwert abgelegt wird. */
export const INVENTAR_WERTQUELLE = 'eigene Schätzung'
/**
* Summe der geschaetzten Werte. Wichtig fuer die Frage, ob sich eine
* Haushaltsaufloesung ueber einen Verwerter ueberhaupt lohnt - meist tut
* sie es nicht, und dann gehoert das begruendet in die Akte.
*/
export function inventarSumme(stuecke: { wertCent: bigint | null }[]): bigint {
return stuecke.reduce((summe, stueck) => summe + (stueck.wertCent ?? 0n), 0n)
}
+192
View File
@@ -0,0 +1,192 @@
import type { BuchungKategorie, ImmobilienStatusSozialhilfe } from '@prisma/client'
/**
* Feste Begriffe rund um das Hausgrundstueck.
*
* Alles, was in der Oberflaeche als Auswahl erscheint, steht hier - und
* daneben die Erklaerung, was der Begriff bedeutet. Grundbuchrecht ist
* nichts, was man als ehrenamtlicher Betreuer im Kopf hat.
*/
// -----------------------------------------------------------------------------
// Grundbuch
// -----------------------------------------------------------------------------
export const GRUNDBUCH_ABTEILUNGEN = ['II', 'III'] as const
export type Grundbuchabteilung = (typeof GRUNDBUCH_ABTEILUNGEN)[number]
/**
* Warum die Unterscheidung wichtig ist: Eine Grundschuld in Abteilung III
* mindert den Wert des Grundstuecks um den noch valutierenden Betrag. Ein
* Wohnrecht in Abteilung II mindert ihn ebenfalls, laesst sich aber nicht
* einfach abloesen - es haengt am Leben des Berechtigten. Fuer die Frage,
* was das Sozialamt als verwertbares Vermoegen ansieht, ist das der
* Unterschied zwischen "verkaufen und Erloes einsetzen" und "praktisch
* unverkaeuflich".
*/
export const GRUNDBUCH_ABTEILUNG_ERKLAERUNG: Record<Grundbuchabteilung, string> = {
II:
'Abteilung II: Lasten und Beschränkungen — alles, was jemand anderes am ' +
'Grundstück darf oder was den Eigentümer einschränkt. Zum Beispiel ' +
'Wohnrecht, Nießbrauch, Wegerecht, Reallast, Vorkaufsrecht, aber auch ' +
'ein Betreuungs- oder Nacherbenvermerk.',
III:
'Abteilung III: Grundpfandrechte — Grundschulden und Hypotheken, also ' +
'Sicherheiten für Geld. Der eingetragene Betrag ist die Höchstsumme, ' +
'nicht die tatsächliche Restschuld: Ein längst getilgtes Darlehen kann ' +
'mit voller Grundschuld im Grundbuch stehen. Für den Wert zählt, was die ' +
'Bank noch zu fordern hat.',
}
export const BELASTUNG_ARTEN = [
'Grundschuld',
'Hypothek',
'Wohnrecht',
'Nießbrauch',
'Reallast',
'Wegerecht',
'Vorkaufsrecht',
'Nacherbenvermerk',
'Sonstiges',
] as const
export type Belastungsart = (typeof BELASTUNG_ARTEN)[number]
/** Welche Art gehoert ueblicherweise in welche Abteilung? Nur ein Vorschlag. */
export const BELASTUNG_ART_ABTEILUNG: Record<Belastungsart, Grundbuchabteilung> = {
Grundschuld: 'III',
Hypothek: 'III',
Wohnrecht: 'II',
Nießbrauch: 'II',
Reallast: 'II',
Wegerecht: 'II',
Vorkaufsrecht: 'II',
Nacherbenvermerk: 'II',
Sonstiges: 'II',
}
// -----------------------------------------------------------------------------
// Bewohnt-Status
// -----------------------------------------------------------------------------
export const BEWOHNT_STATUS = ['leerstehend', 'vermietet', 'selbst bewohnt'] as const
export type BewohntStatus = (typeof BEWOHNT_STATUS)[number]
export const BEWOHNT_STATUS_ERKLAERUNG: Record<BewohntStatus, string> = {
leerstehend:
'Niemand wohnt darin. Damit greifen die Pflichten bei Leerstand — ' +
'Versicherer informieren, Frostschutz, Verkehrssicherung.',
vermietet: 'Ein Mietverhältnis besteht. Mieteinnahmen gehören ins Kassenbuch.',
'selbst bewohnt':
'Die betreute Person bewohnt das Objekt selbst. Das ist die Voraussetzung ' +
'für den Schutz als angemessenes Hausgrundstück nach § 90 Abs. 2 Nr. 8 SGB XII.',
}
// -----------------------------------------------------------------------------
// Wertquellen
// -----------------------------------------------------------------------------
/**
* Pflichtangabe bei jedem Wertstand. Ein Wert ohne Quelle ist gegenueber
* Gericht und Bezirk wertlos - die erste Rueckfrage ist immer "woher haben
* Sie die Zahl?".
*/
export const WERTQUELLEN_IMMOBILIE = [
'Bodenrichtwert',
'Maklergutachten',
'Verkehrswertgutachten',
'eigene Schätzung',
] as const
export type WertquelleImmobilie = (typeof WERTQUELLEN_IMMOBILIE)[number]
export const WERTQUELLE_ERKLAERUNG: Record<WertquelleImmobilie, string> = {
Bodenrichtwert:
'Amtlicher Durchschnittswert je Quadratmeter Boden, kostenlos beim ' +
'Gutachterausschuss bzw. im BORIS-Portal Bayern. Er bewertet nur den ' +
'Grund und Boden, nicht das Gebäude — als Untergrenze brauchbar, als ' +
'Verkehrswert nicht.',
Maklergutachten:
'Unverbindliche Einschätzung eines Maklers, meist kostenlos. Gut für ' +
'einen ersten Anhalt und für das Gespräch mit dem Bezirk, aber kein ' +
'Nachweis gegenüber dem Gericht.',
Verkehrswertgutachten:
'Gutachten eines öffentlich bestellten Sachverständigen nach § 194 BauGB. ' +
'Kostet Geld, ist aber die belastbare Zahl — bei Verkauf und beim ' +
'Genehmigungsantrag ans Betreuungsgericht in der Regel unverzichtbar.',
'eigene Schätzung':
'Meine eigene Einschätzung. Immer als solche kennzeichnen und ' +
'begründen, damit später niemand sie für eine Bewertung hält.',
}
// -----------------------------------------------------------------------------
// Status im Sozialhilfeverfahren
// -----------------------------------------------------------------------------
export const IMMOBILIEN_STATUS_BESCHRIFTUNG: Record<ImmobilienStatusSozialhilfe, string> = {
UNGEKLAERT: 'ungeklärt',
SELBST_BEWOHNT_GESCHUETZT: 'selbst bewohnt und geschützt',
HAERTEFALL_GELTEND_GEMACHT: 'Härtefall geltend gemacht',
DARLEHEN_91_SGB_XII: 'Darlehen nach § 91 SGB XII',
VERKAUF_LAEUFT: 'Verkauf läuft',
VERWERTET: 'verwertet',
}
export const IMMOBILIEN_STATUS_ERKLAERUNG: Record<ImmobilienStatusSozialhilfe, string> = {
UNGEKLAERT:
'Mit dem Sozialhilfeträger ist noch nicht besprochen, wie das Grundstück ' +
'behandelt wird. Das ist der Ausgangszustand — er sollte nicht lange so bleiben.',
SELBST_BEWOHNT_GESCHUETZT:
'Ein angemessenes Hausgrundstück, das die betreute Person selbst bewohnt, ' +
'bleibt geschützt (§ 90 Abs. 2 Nr. 8 SGB XII). Nach dem dauerhaften Einzug ' +
'ins Heim entfällt dieser Schutz nach Auffassung der meisten Träger — ' +
'genau hier wird gestritten.',
HAERTEFALL_GELTEND_GEMACHT:
'Die Verwertung wäre eine Härte (§ 90 Abs. 3 SGB XII), zum Beispiel weil ' +
'ein Angehöriger dort lebt oder eine Rückkehr nicht ausgeschlossen ist. ' +
'Die Begründung gehört schriftlich zur Akte.',
DARLEHEN_91_SGB_XII:
'Der Träger leistet vorerst als Darlehen und sichert es meist durch eine ' +
'Grundschuld (§ 91 SGB XII). Das Haus bleibt zunächst, die Schuld wächst — ' +
'die Höhe im Blick behalten.',
VERKAUF_LAEUFT:
'Der Verkauf ist eingeleitet. Achtung: Der Verkauf eines Grundstücks ist ' +
'genehmigungspflichtig — die Genehmigung des Betreuungsgerichts muss vor ' +
'dem Notartermin vorliegen.',
VERWERTET: 'Das Objekt ist verkauft oder anderweitig verwertet, der Erlös vereinnahmt.',
}
export const IMMOBILIEN_STATUS_RECHTSGRUNDLAGE = '§ 90 Abs. 2 Nr. 8, § 91 SGB XII'
// -----------------------------------------------------------------------------
// Kostenkonto
// -----------------------------------------------------------------------------
/**
* Buchungskategorien, die zum Objekt gehoeren. Buchungen legt das
* Finanzmodul an; hier wird nur ausgewertet.
*
* Eine Zuordnung "Buchung gehoert zu diesem Objekt" gibt es im Schema
* nicht (siehe docs/schema-wuensche/substanz.md). Solange nur ein
* Grundstueck gefuehrt wird, ist die Zuordnung ueber die Kategorie
* eindeutig - die Auswertung sagt das ausdruecklich dazu.
*/
export const IMMOBILIEN_AUSGABE_KATEGORIEN = [
'GRUNDSTEUER',
'IMMOBILIE_BETRIEBSKOSTEN',
'IMMOBILIE_INSTANDHALTUNG',
] as const satisfies readonly BuchungKategorie[]
export const IMMOBILIEN_EINNAHME_KATEGORIEN = [
'MIETEINNAHME',
] as const satisfies readonly BuchungKategorie[]
export const IMMOBILIEN_KATEGORIEN: BuchungKategorie[] = [
...IMMOBILIEN_AUSGABE_KATEGORIEN,
...IMMOBILIEN_EINNAHME_KATEGORIEN,
]
export const IMMOBILIEN_KATEGORIE_BESCHRIFTUNG: Partial<Record<BuchungKategorie, string>> = {
GRUNDSTEUER: 'Grundsteuer',
IMMOBILIE_BETRIEBSKOSTEN: 'Betriebskosten (Gebäudeversicherung, Energie, Wasser, Schornsteinfeger)',
IMMOBILIE_INSTANDHALTUNG: 'Instandhaltung und Reparatur',
MIETEINNAHME: 'Mieteinnahme',
}
+119
View File
@@ -0,0 +1,119 @@
import type { BuchungKategorie, BuchungRichtung } from '@prisma/client'
import {
IMMOBILIEN_AUSGABE_KATEGORIEN,
IMMOBILIEN_EINNAHME_KATEGORIEN,
} from '@/lib/immobilie/konstanten'
/**
* Kostenkonto des Objekts.
*
* Nur lesend: Die Buchungen legt das Finanzmodul an. Hier werden sie je
* Jahr und Kategorie zusammengezaehlt, damit im Jahresbericht und im
* Gespraech mit dem Bezirk eine Zahl steht: "Das Haus kostet im Jahr X
* Euro, ohne dass jemand darin wohnt."
*
* Genau diese Zahl ist das Argument in der Haertefall- und
* Verwertungsdiskussion - und sie ist auch das Argument dafuer, ein
* leerstehendes Haus nicht jahrelang stehen zu lassen.
*/
export type KostenBuchung = {
datum: Date
betragCent: bigint
richtung: BuchungRichtung
kategorie: BuchungKategorie
}
export type Kostenzeile = {
kategorie: BuchungKategorie
summeCent: bigint
anzahl: number
}
export type Kostenjahr = {
jahr: number
ausgabenCent: bigint
einnahmenCent: bigint
/** Einnahmen minus Ausgaben. Negativ heisst: Das Objekt kostet Geld. */
saldoCent: bigint
ausgaben: Kostenzeile[]
einnahmen: Kostenzeile[]
anzahlBuchungen: number
}
const AUSGABEN = new Set<string>(IMMOBILIEN_AUSGABE_KATEGORIEN)
const EINNAHMEN = new Set<string>(IMMOBILIEN_EINNAHME_KATEGORIEN)
export function istImmobilienbuchung(kategorie: BuchungKategorie): boolean {
return AUSGABEN.has(kategorie) || EINNAHMEN.has(kategorie)
}
/**
* Baut die Jahresuebersicht. Neueste Jahre zuerst - danach schaut man
* zuerst.
*
* Umbuchungen bleiben aussen vor: Sie verschieben Geld zwischen eigenen
* Konten und waeren in einer Kostenaufstellung eine Doppelzaehlung.
*/
export function kostenkontoAufbauen(buchungen: KostenBuchung[]): Kostenjahr[] {
const jahre = new Map<number, Map<BuchungKategorie, Kostenzeile>>()
for (const buchung of buchungen) {
if (buchung.richtung === 'UMBUCHUNG') continue
if (!istImmobilienbuchung(buchung.kategorie)) continue
const jahr = buchung.datum.getUTCFullYear()
let zeilen = jahre.get(jahr)
if (!zeilen) {
zeilen = new Map()
jahre.set(jahr, zeilen)
}
const vorhanden = zeilen.get(buchung.kategorie)
if (vorhanden) {
vorhanden.summeCent += buchung.betragCent
vorhanden.anzahl += 1
} else {
zeilen.set(buchung.kategorie, {
kategorie: buchung.kategorie,
summeCent: buchung.betragCent,
anzahl: 1,
})
}
}
const ergebnis: Kostenjahr[] = []
for (const [jahr, zeilen] of jahre) {
const alle = [...zeilen.values()]
const ausgaben = alle
.filter((z) => AUSGABEN.has(z.kategorie))
.sort((a, b) => Number(b.summeCent - a.summeCent))
const einnahmen = alle
.filter((z) => EINNAHMEN.has(z.kategorie))
.sort((a, b) => Number(b.summeCent - a.summeCent))
const ausgabenCent = ausgaben.reduce((summe, z) => summe + z.summeCent, 0n)
const einnahmenCent = einnahmen.reduce((summe, z) => summe + z.summeCent, 0n)
ergebnis.push({
jahr,
ausgaben,
einnahmen,
ausgabenCent,
einnahmenCent,
saldoCent: einnahmenCent - ausgabenCent,
anzahlBuchungen: alle.reduce((summe, z) => summe + z.anzahl, 0),
})
}
return ergebnis.sort((a, b) => b.jahr - a.jahr)
}
/** Durchschnittliche Jahreskosten ueber alle vollstaendig erfassten Jahre. */
export function durchschnittlicheJahreskosten(jahre: Kostenjahr[], laufendesJahr: number): bigint {
const abgeschlossen = jahre.filter((j) => j.jahr < laufendesJahr)
if (abgeschlossen.length === 0) return 0n
const summe = abgeschlossen.reduce((wert, j) => wert + j.ausgabenCent, 0n)
return summe / BigInt(abgeschlossen.length)
}
+245
View File
@@ -0,0 +1,245 @@
import { heuteAlsTag, plusJahre, plusTage, tag, tagesdatum } from '@/lib/fristen/datum'
/**
* Pflichten bei Leerstand.
*
* Das ist der Teil, der einem als Betreuer wirklich um die Ohren fliegen
* kann. Ein leerstehendes Haus ist kein ruhender Vermoegensposten, sondern
* eine laufende Verpflichtung: Der Gebaeudeversicherer wird leistungsfrei,
* wenn ihm der Leerstand nicht angezeigt wurde. Ein geplatztes Rohr im
* Januar kostet dann den Schaden aus dem eigenen Vermoegen der betreuten
* Person - und die Frage, warum niemand Bescheid gesagt hat, landet im
* Jahresbericht.
*
* Deshalb traegt jeder Punkt hier nicht nur einen Titel, sondern auch die
* Begruendung, warum er wichtig ist. Wer den Punkt abhakt, soll wissen,
* wovor er sich gerade geschuetzt hat.
*
* Reine Logik, keine Datenbank: Die Liste wird beim Anlegen einer
* leerstehenden Immobilie in ImmobilienCheckpunkt uebernommen.
*/
export type LeerstandPunkt = {
/** Stabiler Schluessel, damit die Uebernahme keine Dubletten erzeugt. */
schluessel: string
titel: string
/** Warum ist der Punkt wichtig? Steht so in der Oberflaeche. */
beschreibung: string
faelligAm: Date
/** iCalendar-RRULE fuer wiederkehrende Punkte, sonst undefined. */
wiederholungRrule?: string
reihenfolge: number
/**
* Punkte, bei denen ein Versaeumnis unmittelbar Geld kostet oder eine
* Haftung ausloest. In der Oberflaeche rot.
*/
dringend: boolean
}
/** Der Monat, in dem der Frostschutz stehen muss: Oktober. */
export const FROSTSCHUTZ_MONAT = 10
export const FROSTSCHUTZ_TAG = 1
/** Der Winterdienst muss organisiert sein, bevor der Frostschutz greift. */
export const WINTERDIENST_MONAT = 10
export const WINTERDIENST_TAG = 15
/**
* Der naechste 1. Oktober ab einem Stichtag. Faellt der Stichtag selbst
* auf den 1. Oktober, ist es dieser Tag - nicht erst der des Folgejahres.
*/
export function naechsterStichtagImJahr(
ab: Date,
monat: number,
tagImMonat: number,
): Date {
const basis = tagesdatum(ab)
const inDiesemJahr = tag(basis.getUTCFullYear(), monat, tagImMonat)
if (inDiesemJahr.getTime() >= basis.getTime()) return inDiesemJahr
return tag(basis.getUTCFullYear() + 1, monat, tagImMonat)
}
/**
* Erzeugt die Checkliste fuer ein leerstehendes Objekt.
*
* @param leerstandSeit Beginn des Leerstands. Fehlt er, wird ab heute gerechnet.
* @param heute Bezugstag (Test).
*/
export function leerstandCheckliste(
leerstandSeit?: Date | null,
heute: Date = heuteAlsTag(),
): LeerstandPunkt[] {
const start = leerstandSeit ? tagesdatum(leerstandSeit) : tagesdatum(heute)
// Faelligkeiten liegen nie in der Vergangenheit: Ein Punkt, der beim
// Anlegen schon ueberfaellig waere, hilft niemandem weiter.
const abHeute = (datum: Date) => (datum.getTime() < heute.getTime() ? tagesdatum(heute) : datum)
return [
{
schluessel: 'gebaeudeversicherung-leerstand-melden',
titel: 'Gebäudeversicherer über den Leerstand informieren',
beschreibung:
'Schriftlich, am besten per Einschreiben, und die Antwort zur Akte nehmen. ' +
'Leerstand ist eine Gefahrerhöhung: Wer sie nicht anzeigt, riskiert, dass ' +
'der Versicherer im Schadensfall leistungsfrei wird — dann zahlt niemand ' +
'den Wasserschaden. Viele Verträge verlangen bei Leerstand zusätzlich ' +
'wöchentliche Kontrollgänge und abgestellte Wasserzufuhr. Frag ausdrücklich ' +
'nach, welche Auflagen gelten, und halte sie schriftlich fest.',
faelligAm: abHeute(plusTage(start, 7)),
reihenfolge: 10,
dringend: true,
},
{
schluessel: 'kontrollgang',
titel: 'Kontrollgang durch das Haus',
beschreibung:
'Einmal pro Woche durch alle Räume, Keller und Dachboden: Wasser, Schimmel, ' +
'eingeschlagene Scheiben, Einbruchsspuren. Datum und Ergebnis notieren — ' +
'die meisten Gebäudeversicherer verlangen bei Leerstand regelmäßige ' +
'Kontrollen und wollen im Schadensfall den Nachweis sehen.',
faelligAm: abHeute(plusTage(start, 7)),
wiederholungRrule: 'FREQ=WEEKLY;INTERVAL=1',
reihenfolge: 20,
dringend: true,
},
{
schluessel: 'frostschutz',
titel: 'Frostschutz: Wasser abstellen, Leitungen entleeren, Heizung auf Frostschutz',
beschreibung:
'Haupthahn zu, Leitungen und Heizkörper entleeren oder die Heizung dauerhaft ' +
'auf Frostschutzstellung — beides zusammen geht nicht. Ein einziges geplatztes ' +
'Rohr richtet in einem leeren Haus über Wochen unbemerkt Schaden an. Jedes Jahr ' +
'vor der ersten Frostnacht erledigen, deshalb im Oktober.',
faelligAm: naechsterStichtagImJahr(heute, FROSTSCHUTZ_MONAT, FROSTSCHUTZ_TAG),
wiederholungRrule: `FREQ=YEARLY;BYMONTH=${FROSTSCHUTZ_MONAT};BYMONTHDAY=${FROSTSCHUTZ_TAG}`,
reihenfolge: 30,
dringend: true,
},
{
schluessel: 'winterdienst-verkehrssicherung',
titel: 'Winterdienst und Verkehrssicherungspflicht organisieren',
beschreibung:
'Räum- und Streupflicht auf Gehweg und Grundstückszufahrt trifft den ' +
'Eigentümer, auch wenn niemand im Haus wohnt. Dazu kommen lose Dachziegel, ' +
'morsche Äste und der Zustand der Treppe. Wer hier nichts veranlasst, haftet ' +
'persönlich für den Sturz eines Postboten. Schriftliche Beauftragung eines ' +
'Dienstleisters oder Nachbarn, Nachweis zur Akte.',
faelligAm: naechsterStichtagImJahr(heute, WINTERDIENST_MONAT, WINTERDIENST_TAG),
wiederholungRrule: `FREQ=YEARLY;BYMONTH=${WINTERDIENST_MONAT};BYMONTHDAY=${WINTERDIENST_TAG}`,
reihenfolge: 40,
dringend: true,
},
{
schluessel: 'zaehlerstaende',
titel: 'Zählerstände ablesen und dokumentieren',
beschreibung:
'Strom, Gas, Wasser mit Datum und Foto festhalten. Nur so lassen sich ' +
'Abschläge senken, Nachzahlungen prüfen und ein schleichender Wasserverlust ' +
'überhaupt bemerken. Bei einem leeren Haus ist jeder Verbrauch erklärungs' +
'bedürftig — auch gegenüber dem Bezirk.',
faelligAm: abHeute(plusTage(start, 7)),
wiederholungRrule: 'FREQ=MONTHLY;INTERVAL=3',
reihenfolge: 50,
dringend: false,
},
{
schluessel: 'schluesselverzeichnis',
titel: 'Schlüsselverzeichnis anlegen: wer hat welchen Schlüssel',
beschreibung:
'Jeder Schlüssel mit Nummer, Art (Haustür, Keller, Garage) und Person, ' +
'Übergabedatum und Unterschrift. Bei einem Einbruch ohne Aufbruchspuren ' +
'fragt der Versicherer als Erstes danach. Und beim späteren Verkauf muss ' +
'vollständig übergeben werden.',
faelligAm: abHeute(plusTage(start, 14)),
reihenfolge: 60,
dringend: false,
},
{
schluessel: 'fotodokumentation',
titel: 'Fotodokumentation vor der Räumung',
beschreibung:
'Jeden Raum vollständig fotografieren, bevor etwas bewegt wird, dazu ' +
'Wertgegenstände einzeln. Das schützt vor dem Vorwurf, es sei etwas ' +
'verschwunden, belegt den Zustand des Hauses gegenüber Käufern und ' +
'Versicherern und ist die Grundlage der Inventarliste. Fotos als Dokument ' +
'mit der Art „Foto“ hochladen und mit dem Objekt verknüpfen.',
faelligAm: abHeute(plusTage(start, 14)),
reihenfolge: 70,
dringend: false,
},
{
schluessel: 'briefkasten-nachsendeauftrag',
titel: 'Briefkasten leeren und Nachsendeauftrag einrichten',
beschreibung:
'Ein überquellender Briefkasten zeigt jedem, dass niemand da ist — und ' +
'wichtiger: Bescheide mit Rechtsbehelfsfristen gelten als zugegangen, auch ' +
'wenn sie ungelesen im Kasten liegen. Nachsendeauftrag auf die eigene ' +
'Anschrift, zusätzlich die Absender direkt umschreiben lassen.',
faelligAm: abHeute(plusTage(start, 7)),
reihenfolge: 80,
dringend: true,
},
{
schluessel: 'rauchmelder',
titel: 'Rauchmelder prüfen',
beschreibung:
'In Bayern sind Rauchwarnmelder in Schlaf- und Kinderzimmern sowie in ' +
'Fluren Pflicht (Art. 46 Abs. 4 BayBO), die jährliche Prüfung ebenso. ' +
'Der Eigentümer bleibt dafür verantwortlich, auch bei Leerstand. Batterien ' +
'wechseln, Funktionsprüfung mit Datum notieren.',
faelligAm: abHeute(plusTage(start, 30)),
wiederholungRrule: 'FREQ=YEARLY;INTERVAL=1',
reihenfolge: 90,
dringend: false,
},
{
schluessel: 'heizungswartung',
titel: 'Heizungswartung und Schornsteinfegertermin',
beschreibung:
'Läuft die Heizung im Frostschutzbetrieb weiter, muss sie gewartet bleiben, ' +
'sonst fällt sie im ungünstigsten Moment aus. Der Schornsteinfeger kommt ' +
'ohnehin und braucht Zugang — Termin und Schlüsselübergabe organisieren, ' +
'sonst kommt eine kostenpflichtige zweite Anfahrt.',
faelligAm: abHeute(plusTage(start, 30)),
wiederholungRrule: 'FREQ=YEARLY;INTERVAL=1',
reihenfolge: 100,
dringend: false,
},
]
}
/**
* Alle Punkte, die naechstes Jahr wieder anstehen - fuer den Hinweis in
* der Oberflaeche, dass die Liste kein einmaliges Abhaken ist.
*/
export function wiederkehrendePunkte(punkte: LeerstandPunkt[]): LeerstandPunkt[] {
return punkte.filter((p) => Boolean(p.wiederholungRrule))
}
/**
* Der naechste Termin eines wiederkehrenden Punktes nach dem Erledigen.
* Bewusst schlicht gehalten: Woche, Quartal, Jahr - mehr kommt in dieser
* Liste nicht vor.
*/
export function naechsteFaelligkeit(rrule: string, erledigtAm: Date): Date | null {
const regel = rrule.toUpperCase()
if (regel.includes('FREQ=WEEKLY')) return plusTage(erledigtAm, 7)
if (regel.includes('FREQ=MONTHLY')) {
const treffer = /INTERVAL=(\d+)/.exec(regel)
const monate = treffer?.[1] ? Number(treffer[1]) : 1
return plusTage(erledigtAm, monate * 30)
}
if (regel.includes('FREQ=YEARLY')) {
const monat = /BYMONTH=(\d+)/.exec(regel)?.[1]
const tagImMonat = /BYMONTHDAY=(\d+)/.exec(regel)?.[1]
if (monat && tagImMonat) {
return naechsterStichtagImJahr(
plusTage(erledigtAm, 1),
Number(monat),
Number(tagImMonat),
)
}
return plusJahre(erledigtAm, 1)
}
return null
}
+47
View File
@@ -0,0 +1,47 @@
import 'server-only'
import { db, nichtGeloescht } from '@/lib/db'
import {
statistikBerechnen,
textbausteinErzeugen,
textkontextAusAnrede,
type Kontaktstatistik,
} from '@/lib/kontakte/statistik'
/**
* Die Auswertung, aus der spaeter der Jahresbericht nach § 1863 BGB
* gespeist wird: wie oft bestand Kontakt, wie oft davon persoenlich, wie
* lange insgesamt - und ein fertiger Absatz in ganzen Saetzen.
*
* Gerechnet wird auf den unverschluesselten Feldern (Datum, Art, Dauer,
* mitBetreuter). Die Freitexte bleiben unangetastet: fuer die Statistik
* werden sie nicht gebraucht, und was nicht entschluesselt wird, kann auch
* nicht versehentlich irgendwo landen.
*/
export type KontaktAuswertung = Kontaktstatistik & {
/** Fertige Saetze zur Uebernahme in den Jahresbericht. */
textbaustein: string
}
export async function kontaktstatistik(
betreuteId: string,
von: Date,
bis: Date,
): Promise<KontaktAuswertung> {
const [protokolle, betreute] = await Promise.all([
db.kontaktprotokoll.findMany({
where: { betreuteId, datum: { gte: von, lte: bis }, ...nichtGeloescht },
orderBy: { datum: 'asc' },
select: { datum: true, art: true, dauerMinuten: true, mitBetreuter: true },
}),
db.betreute.findFirst({
where: { id: betreuteId, ...nichtGeloescht },
select: { anrede: true, heimName: true },
}),
])
const statistik = statistikBerechnen(protokolle, von, bis)
const kontext = textkontextAusAnrede(betreute?.anrede, betreute?.heimName)
return { ...statistik, textbaustein: textbausteinErzeugen(statistik, kontext) }
}
+299
View File
@@ -0,0 +1,299 @@
import { differenceInCalendarMonths } from 'date-fns'
import type { KontaktArt } from '@prisma/client'
/**
* Auswertung der Kontaktprotokolle - ohne Datenbank, ohne React.
*
* Der Jahresbericht nach § 1863 BGB will wissen, ob und wie oft
* persoenlicher Kontakt zur betreuten Person bestand. Diese Datei zaehlt
* das aus und schreibt daraus ganze Saetze, die sich in den Bericht
* uebernehmen lassen. Was hier steht, ist eine Zusammenfassung meiner
* eigenen Aufzeichnungen - keine Rechtsauskunft darueber, ob der Umfang
* ausreicht.
*/
export type ProtokollFuerStatistik = {
datum: Date
art: KontaktArt
dauerMinuten: number | null
mitBetreuter: boolean
}
export type Monatszeile = {
jahr: number
monat: number
gesamt: number
/** Davon persoenlicher Kontakt zur betreuten Person selbst. */
persoenlich: number
}
export type Kontaktstatistik = {
von: Date
bis: Date
/** Volle bzw. angefangene Kalendermonate im Zeitraum. */
monateImZeitraum: number
gesamt: number
/** Protokolle mit mitBetreuter = true. */
persoenlich: number
/** Kalendertage, an denen ein persoenlicher Kontakt stattfand. */
persoenlicheTage: number
/** Besuchstage bei der betreuten Person (Besuch oder Vorsprache vor Ort). */
besuchstage: number
telefonate: number
schriftlich: number
/** Monate mit mindestens einem persoenlichen Kontakt. */
monateMitPersoenlichemKontakt: number
jeArt: Record<KontaktArt, number>
jeMonat: Monatszeile[]
gesamtdauerMinuten: number
/** Zu wie vielen Protokollen ist ueberhaupt eine Dauer erfasst? */
mitDauerErfasst: number
}
/** Persoenliche Begegnung vor Ort. */
const ARTEN_BESUCH: KontaktArt[] = ['BESUCH', 'VORSPRACHE']
/** Gespraech auf Distanz. */
const ARTEN_TELEFON: KontaktArt[] = ['TELEFONAT', 'VIDEOCALL']
/** Schriftverkehr. */
const ARTEN_SCHRIFTLICH: KontaktArt[] = ['EMAIL', 'BRIEF', 'FAX']
const ALLE_ARTEN: KontaktArt[] = [
'BESUCH',
'TELEFONAT',
'EMAIL',
'BRIEF',
'VIDEOCALL',
'FAX',
'VORSPRACHE',
]
function tagesschluessel(datum: Date): string {
return `${datum.getUTCFullYear()}-${datum.getUTCMonth()}-${datum.getUTCDate()}`
}
/**
* Ab welchem Anteil von Monaten mit persoenlichem Kontakt formuliere ich
* "regelmaessig"? Drei Viertel - wer in neun von zwoelf Monaten da war,
* war regelmaessig da.
*/
const REGELMAESSIG_AB_ANTEIL = 0.75
export function statistikBerechnen(
protokolle: ProtokollFuerStatistik[],
von: Date,
bis: Date,
): Kontaktstatistik {
const jeArt = Object.fromEntries(ALLE_ARTEN.map((art) => [art, 0])) as Record<KontaktArt, number>
const monate = new Map<string, Monatszeile>()
const persoenlicheTage = new Set<string>()
const besuchstage = new Set<string>()
const monateMitPersoenlich = new Set<string>()
let gesamt = 0
let persoenlich = 0
let telefonate = 0
let schriftlich = 0
let gesamtdauerMinuten = 0
let mitDauerErfasst = 0
for (const eintrag of protokolle) {
gesamt += 1
jeArt[eintrag.art] = (jeArt[eintrag.art] ?? 0) + 1
if (ARTEN_TELEFON.includes(eintrag.art)) telefonate += 1
if (ARTEN_SCHRIFTLICH.includes(eintrag.art)) schriftlich += 1
if (eintrag.dauerMinuten !== null && eintrag.dauerMinuten > 0) {
gesamtdauerMinuten += eintrag.dauerMinuten
mitDauerErfasst += 1
}
const jahr = eintrag.datum.getUTCFullYear()
const monat = eintrag.datum.getUTCMonth() + 1
const monatsschluessel = `${jahr}-${String(monat).padStart(2, '0')}`
const zeile = monate.get(monatsschluessel) ?? { jahr, monat, gesamt: 0, persoenlich: 0 }
zeile.gesamt += 1
if (eintrag.mitBetreuter) {
persoenlich += 1
zeile.persoenlich += 1
persoenlicheTage.add(tagesschluessel(eintrag.datum))
monateMitPersoenlich.add(monatsschluessel)
if (ARTEN_BESUCH.includes(eintrag.art)) besuchstage.add(tagesschluessel(eintrag.datum))
}
monate.set(monatsschluessel, zeile)
}
const jeMonat = [...monate.values()].sort((a, b) =>
a.jahr === b.jahr ? a.monat - b.monat : a.jahr - b.jahr,
)
return {
von,
bis,
monateImZeitraum: Math.max(1, differenceInCalendarMonths(bis, von) + 1),
gesamt,
persoenlich,
persoenlicheTage: persoenlicheTage.size,
besuchstage: besuchstage.size,
telefonate,
schriftlich,
monateMitPersoenlichemKontakt: monateMitPersoenlich.size,
jeArt,
jeMonat,
gesamtdauerMinuten,
mitDauerErfasst,
}
}
// -----------------------------------------------------------------------------
// Textbaustein fuer den Jahresbericht
// -----------------------------------------------------------------------------
export type Textkontext = {
/** "die Betreute", "der Betreute", "die betreute Person". */
nominativ: string
/** "die Betreute", "den Betreuten", "die betreute Person". */
akkusativ: string
/** Fertige Praepositionalphrase: "zur Betreuten", "zum Betreuten". */
kontaktZu: string
/** "das Pflegeheim Sonnenhof" - wird zu "im Pflegeheim Sonnenhof". */
heimName: string | null
}
export const TEXTKONTEXT_NEUTRAL: Textkontext = {
nominativ: 'die betreute Person',
akkusativ: 'die betreute Person',
kontaktZu: 'zur betreuten Person',
heimName: null,
}
/** Aus der Anrede der Stammdaten die passende Bezeichnung bilden. */
export function textkontextAusAnrede(
anrede: string | null | undefined,
heimName: string | null | undefined,
): Textkontext {
const gereinigt = (anrede ?? '').trim().toLowerCase()
if (gereinigt.startsWith('frau')) {
return {
nominativ: 'die Betreute',
akkusativ: 'die Betreute',
kontaktZu: 'zur Betreuten',
heimName: heimName ?? null,
}
}
if (gereinigt.startsWith('herr')) {
return {
nominativ: 'der Betreute',
akkusativ: 'den Betreuten',
kontaktZu: 'zum Betreuten',
heimName: heimName ?? null,
}
}
return { ...TEXTKONTEXT_NEUTRAL, heimName: heimName ?? null }
}
function datumDeutsch(datum: Date): string {
const tag = String(datum.getUTCDate()).padStart(2, '0')
const monat = String(datum.getUTCMonth() + 1).padStart(2, '0')
return `${tag}.${monat}.${datum.getUTCFullYear()}`
}
function mehrzahl(anzahl: number, einzahl: string, mehrzahlForm: string): string {
return `${anzahl} ${anzahl === 1 ? einzahl : mehrzahlForm}`
}
/** "3 Stunden 15 Minuten", "45 Minuten". */
export function dauerAusgeschrieben(minuten: number): string {
if (minuten <= 0) return '0 Minuten'
const stunden = Math.floor(minuten / 60)
const rest = minuten % 60
if (stunden === 0) return mehrzahl(rest, 'Minute', 'Minuten')
if (rest === 0) return mehrzahl(stunden, 'Stunde', 'Stunden')
return `${mehrzahl(stunden, 'Stunde', 'Stunden')} ${mehrzahl(rest, 'Minute', 'Minuten')}`
}
/**
* Ganze Saetze fuer den Jahresbericht. Bewusst zurueckhaltend formuliert:
* der Text beschreibt, was dokumentiert ist, und behauptet nichts darueber
* hinaus. Fehlt persoenlicher Kontakt, sagt der Text das auch - ein
* geschoenter Bericht hilft niemandem.
*/
export function textbausteinErzeugen(
statistik: Kontaktstatistik,
kontext: Textkontext = TEXTKONTEXT_NEUTRAL,
): string {
const zeitraum = `Im Berichtszeitraum vom ${datumDeutsch(statistik.von)} bis ${datumDeutsch(
statistik.bis,
)}`
if (statistik.gesamt === 0) {
return (
`${zeitraum} ist kein Kontakt dokumentiert. Sofern Kontakte stattgefunden haben, ` +
`sind sie noch nachzutragen.`
)
}
const saetze: string[] = []
// Satz 1: Gab es persoenlichen Kontakt, und war er regelmaessig?
if (statistik.persoenlich === 0) {
saetze.push(`${zeitraum} ist kein persönlicher Kontakt ${kontext.kontaktZu} dokumentiert.`)
} else {
const anteil = statistik.monateMitPersoenlichemKontakt / statistik.monateImZeitraum
const regelmaessig = anteil >= REGELMAESSIG_AB_ANTEIL
saetze.push(
`${zeitraum} bestand ${regelmaessig ? 'regelmäßiger ' : ''}persönlicher Kontakt ` +
`${kontext.kontaktZu}.`,
)
}
// Satz 2: Besuche, Telefonate, Schriftverkehr in Zahlen.
const teile: string[] = []
if (statistik.besuchstage > 0) {
const ort = kontext.heimName ? ` in ${kontext.heimName}` : ''
teile.push(
`habe ${kontext.akkusativ} an ${mehrzahl(
statistik.besuchstage,
'Tag',
'Tagen',
)} persönlich${ort} besucht`,
)
}
if (statistik.telefonate > 0) {
teile.push(`${mehrzahl(statistik.telefonate, 'Telefonat', 'Telefonate')} geführt`)
}
if (statistik.schriftlich > 0) {
teile.push(
`${mehrzahl(statistik.schriftlich, 'schriftlichen Kontakt', 'schriftliche Kontakte')} gehabt`,
)
}
if (teile.length === 1) {
saetze.push(`Ich ${teile[0]}.`)
} else if (teile.length > 1) {
const letzter = teile[teile.length - 1]!
saetze.push(`Ich ${teile.slice(0, -1).join(', ')} und ${letzter}.`)
}
// Satz 3: Umfang insgesamt.
saetze.push(
`Insgesamt sind ${mehrzahl(statistik.gesamt, 'Kontakt', 'Kontakte')} dokumentiert` +
(statistik.gesamtdauerMinuten > 0
? `, davon ${mehrzahl(statistik.mitDauerErfasst, 'Kontakt', 'Kontakte')} mit einer ` +
`erfassten Dauer von zusammen ${dauerAusgeschrieben(statistik.gesamtdauerMinuten)}.`
: '.'),
)
// Satz 4: Verteilung ueber die Monate - die Frage "war es regelmaessig?".
if (statistik.persoenlich > 0) {
saetze.push(
`Persönlicher Kontakt fand in ${statistik.monateMitPersoenlichemKontakt} von ` +
`${mehrzahl(statistik.monateImZeitraum, 'Monat', 'Monaten')} des Berichtszeitraums statt.`,
)
}
return saetze.join(' ')
}
+321
View File
@@ -0,0 +1,321 @@
import 'server-only'
import { execFile } from 'node:child_process'
import { mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { promisify } from 'node:util'
import { env } from '@/lib/env'
import { typErkennen } from '@/lib/speicher/dateityp'
import { pdfSeitenzahlSchaetzen, pdfTextLesen, textAufraeumen } from '@/lib/ocr/pdf-text'
/**
* Texterkennung ueber das System-Binary `tesseract` (Sprache deutsch).
*
* Vertrag: docs/SCHNITTSTELLEN.md, Abschnitt 3.
*
* Die wichtigste Eigenschaft dieser Datei: **sie wirft nie**. Ein fehlendes
* Tesseract auf dem Entwicklungsrechner, ein kaputtes Foto oder ein
* verschluesseltes PDF darf niemals dazu fuehren, dass ein Beleg nicht
* gespeichert wird. Im Zweifel kommt `nicht_verfuegbar` zurueck, das
* Dokument liegt trotzdem in der Akte, und die Metadaten traegt der Nutzer
* von Hand ein.
*
* Ablauf:
* Bild -> tesseract
* PDF -> pdftoppm (Seiten als PNG) -> tesseract je Seite
* PDF ohne pdftoppm -> eingebetteten Text lesen
*/
const ausfuehren = promisify(execFile)
export type OcrStatus = 'fertig' | 'fehlgeschlagen' | 'nicht_verfuegbar'
export type OcrErgebnis = {
text: string
status: OcrStatus
seitenzahl?: number
meldung?: string
}
/** Ein Scan mit 20 Seiten darf den Server nicht eine Stunde beschaeftigen. */
const ZEITGRENZE_JE_SEITE_MS = 60_000
const MAX_SEITEN = 30
/** Mehr als das braucht keine Volltextsuche - und die Spalte bleibt handhabbar. */
const MAX_ZEICHEN = 400_000
// -----------------------------------------------------------------------------
// Verfuegbarkeit
// -----------------------------------------------------------------------------
let tesseractGemerkt: boolean | null = null
let pdftoppmGemerkt: boolean | null = null
async function binaerVorhanden(befehl: string, argumente: string[]): Promise<boolean> {
try {
await ausfuehren(befehl, argumente, { timeout: 10_000, windowsHide: true })
return true
} catch (fehler) {
// Manche Binaries melden --version mit Exitcode != 0, liefern aber
// Ausgabe. Nur "gibt es nicht" zaehlt als nicht vorhanden.
const code = (fehler as NodeJS.ErrnoException | undefined)?.code
if (code === 'ENOENT') return false
const stdout = (fehler as { stdout?: string } | undefined)?.stdout ?? ''
const stderr = (fehler as { stderr?: string } | undefined)?.stderr ?? ''
return `${stdout}${stderr}`.trim().length > 0
}
}
/** Ist Tesseract auf diesem System vorhanden? */
export async function ocrVerfuegbar(): Promise<boolean> {
if (!env.ocrAktiv) return false
if (tesseractGemerkt !== null) return tesseractGemerkt
tesseractGemerkt = await binaerVorhanden(env.tesseractBefehl, ['--version'])
return tesseractGemerkt
}
/** Kann pdftoppm PDF-Seiten in Bilder wandeln? (Teil von poppler-utils) */
export async function pdfWandlerVerfuegbar(): Promise<boolean> {
if (pdftoppmGemerkt !== null) return pdftoppmGemerkt
pdftoppmGemerkt = await binaerVorhanden('pdftoppm', ['-v'])
return pdftoppmGemerkt
}
/** Nur fuer Tests und den Diagnosebereich: gemerkte Antworten vergessen. */
export function verfuegbarkeitVergessen(): void {
tesseractGemerkt = null
pdftoppmGemerkt = null
}
/** Welche Sprachpakete kennt Tesseract? Fuer die Diagnoseseite. */
export async function ocrSprachen(): Promise<string[]> {
try {
const { stdout } = await ausfuehren(env.tesseractBefehl, ['--list-langs'], {
timeout: 10_000,
windowsHide: true,
})
return stdout
.split(/\r?\n/)
.slice(1)
.map((z) => z.trim())
.filter(Boolean)
} catch {
return []
}
}
// -----------------------------------------------------------------------------
// Erkennung
// -----------------------------------------------------------------------------
async function tesseractAufDatei(pfad: string): Promise<{ text: string; meldung?: string }> {
const grundArgumente = [pfad, 'stdout', '--psm', '3']
try {
const { stdout } = await ausfuehren(env.tesseractBefehl, ['-l', 'deu', ...grundArgumente], {
timeout: ZEITGRENZE_JE_SEITE_MS,
maxBuffer: 32 * 1024 * 1024,
windowsHide: true,
})
return { text: stdout }
} catch (fehler) {
const meldung = String((fehler as { stderr?: string } | undefined)?.stderr ?? fehler)
// Haeufigster Stolperstein: Tesseract ist da, das deutsche Sprachpaket
// fehlt. Dann lieber englisch erkennen als gar nichts - mit Hinweis.
if (/Failed loading language|Could not initialize tesseract|tessdata/i.test(meldung)) {
try {
const { stdout } = await ausfuehren(env.tesseractBefehl, grundArgumente, {
timeout: ZEITGRENZE_JE_SEITE_MS,
maxBuffer: 32 * 1024 * 1024,
windowsHide: true,
})
return {
text: stdout,
meldung:
'Das deutsche Sprachpaket (tesseract-ocr-deu) fehlt. Erkannt wurde mit der ' +
'Standardsprache - Umlaute und Fachbegriffe sind dadurch oft falsch.',
}
} catch {
throw fehler
}
}
throw fehler
}
}
async function pdfInBilder(verzeichnis: string, pdfPfad: string): Promise<string[]> {
await ausfuehren(
'pdftoppm',
['-r', '300', '-gray', '-png', '-l', String(MAX_SEITEN), pdfPfad, join(verzeichnis, 'seite')],
{ timeout: ZEITGRENZE_JE_SEITE_MS * 4, windowsHide: true },
)
const dateien = await readdir(verzeichnis)
return dateien
.filter((name) => name.startsWith('seite') && name.endsWith('.png'))
.sort()
.map((name) => join(verzeichnis, name))
}
/**
* Ruft Tesseract (deutsch) auf. Nie werfen - Status zurueckgeben.
*/
export async function ocrLesen(inhalt: Buffer, mimeTyp: string): Promise<OcrErgebnis> {
if (!env.ocrAktiv) {
return { text: '', status: 'nicht_verfuegbar', meldung: 'Die Texterkennung ist abgeschaltet.' }
}
// Der gemeldete MIME-Typ ist nur ein Hinweis; entschieden wird am Inhalt.
const erkannt = typErkennen(inhalt)
const istPdf = erkannt ? erkannt.mimeTyp === 'application/pdf' : mimeTyp === 'application/pdf'
if (istPdf) return pdfLesen(inhalt)
if (erkannt && erkannt.istBild) return bildLesen(inhalt, erkannt.endung)
return {
text: '',
status: 'nicht_verfuegbar',
meldung: 'Aus diesem Dateityp lässt sich kein Text lesen.',
}
}
async function bildLesen(inhalt: Buffer, endung: string): Promise<OcrErgebnis> {
if (!(await ocrVerfuegbar())) {
return {
text: '',
status: 'nicht_verfuegbar',
seitenzahl: 1,
meldung:
'Tesseract ist auf diesem System nicht installiert. Das Dokument ist gespeichert, ' +
'nur der Volltext fehlt - trage Absender und Datum bitte von Hand ein.',
}
}
let verzeichnis: string | null = null
try {
verzeichnis = await mkdtemp(join(tmpdir(), 'bc-ocr-'))
const pfad = join(verzeichnis, `bild.${endung}`)
await writeFile(pfad, inhalt)
const { text, meldung } = await tesseractAufDatei(pfad)
return {
text: kuerzen(textAufraeumen(text)),
status: 'fertig',
seitenzahl: 1,
meldung,
}
} catch (fehler) {
return {
text: '',
status: 'fehlgeschlagen',
seitenzahl: 1,
meldung: fehlermeldung(fehler),
}
} finally {
await aufraeumen(verzeichnis)
}
}
async function pdfLesen(inhalt: Buffer): Promise<OcrErgebnis> {
const seitenzahl = pdfSeitenzahlSchaetzen(inhalt) ?? undefined
// Weg 1: Seiten rendern und erkennen - funktioniert auch bei reinen Scans.
if ((await ocrVerfuegbar()) && (await pdfWandlerVerfuegbar())) {
let verzeichnis: string | null = null
try {
verzeichnis = await mkdtemp(join(tmpdir(), 'bc-ocr-'))
const pdfPfad = join(verzeichnis, 'quelle.pdf')
await writeFile(pdfPfad, inhalt)
const bilder = await pdfInBilder(verzeichnis, pdfPfad)
if (bilder.length === 0) throw new Error('pdftoppm hat keine Seiten erzeugt.')
const teile: string[] = []
let hinweis: string | undefined
for (const bild of bilder) {
const ergebnis = await tesseractAufDatei(bild)
teile.push(ergebnis.text)
hinweis ??= ergebnis.meldung
}
const text = kuerzen(textAufraeumen(teile.join('\n\n')))
const abgeschnitten =
seitenzahl !== undefined && seitenzahl > bilder.length
? `Erkannt wurden die ersten ${bilder.length} von ${seitenzahl} Seiten.`
: undefined
return {
text,
status: 'fertig',
seitenzahl: seitenzahl ?? bilder.length,
meldung: [hinweis, abgeschnitten].filter(Boolean).join(' ') || undefined,
}
} catch (fehler) {
// Weg 2 als Rettung: vielleicht steckt ohnehin Text im PDF.
const eingebettet = pdfTextLesen(inhalt)
if (eingebettet.length > 40) {
return {
text: kuerzen(eingebettet),
status: 'fertig',
seitenzahl,
meldung:
'Die Bilderkennung ist fehlgeschlagen; gelesen wurde der im PDF eingebettete Text.',
}
}
return { text: '', status: 'fehlgeschlagen', seitenzahl, meldung: fehlermeldung(fehler) }
} finally {
await aufraeumen(verzeichnis)
}
}
// Weg 2: kein Tesseract oder kein pdftoppm - eingebetteten Text lesen.
const eingebettet = pdfTextLesen(inhalt)
if (eingebettet.length > 40) {
return {
text: kuerzen(eingebettet),
status: 'fertig',
seitenzahl,
meldung:
'Gelesen wurde der im PDF eingebettete Text. Für gescannte Seiten ohne Text ' +
'wird zusätzlich Tesseract benötigt.',
}
}
return {
text: '',
status: 'nicht_verfuegbar',
seitenzahl,
meldung:
'Dieses PDF enthält keinen lesbaren Text, und für die Bilderkennung fehlen ' +
'Tesseract bzw. pdftoppm. Das Dokument ist gespeichert - trage Absender und ' +
'Datum bitte von Hand ein.',
}
}
function kuerzen(text: string): string {
return text.length > MAX_ZEICHEN ? `${text.slice(0, MAX_ZEICHEN)}\n…` : text
}
function fehlermeldung(fehler: unknown): string {
const roh =
(fehler as { stderr?: string } | undefined)?.stderr ||
(fehler instanceof Error ? fehler.message : String(fehler))
return roh.trim().slice(0, 300) || 'Unbekannter Fehler bei der Texterkennung.'
}
async function aufraeumen(verzeichnis: string | null): Promise<void> {
if (!verzeichnis) return
try {
await rm(verzeichnis, { recursive: true, force: true })
} catch {
// Temporaeres Verzeichnis bleibt liegen - kein Grund, die Aktion abzubrechen.
}
}
/** Kleiner Helfer fuer die Diagnoseseite: liest eine Datei und erkennt sie. */
export async function ocrAusDatei(pfad: string, mimeTyp: string): Promise<OcrErgebnis> {
try {
return await ocrLesen(await readFile(pfad), mimeTyp)
} catch (fehler) {
return { text: '', status: 'fehlgeschlagen', meldung: fehlermeldung(fehler) }
}
}
+227
View File
@@ -0,0 +1,227 @@
import { inflateSync, inflateRawSync, unzipSync } from 'node:zlib'
/**
* Notnagel: den bereits im PDF eingebetteten Text lesen.
*
* Der Regelweg ist Tesseract. Fehlt auf dem System auch `pdftoppm`, um das
* PDF in Bilder zu wandeln, bleibt nur dieser Weg - und er reicht
* ueberraschend oft: Bescheide von Behoerden und Rechnungen kommen heute
* meist als "digitales" PDF mit echtem Text, nicht als Scan.
*
* Es ist bewusst ein einfacher Extraktor, kein PDF-Renderer:
* unkomprimierte und Flate-komprimierte Inhaltsstroeme werden ausgepackt
* und die Text-Operatoren (Tj, TJ, ', ") daraus gelesen. Was er nicht kann,
* meldet er als "kein Text" - dann bleibt der ocrStatus eben
* "nicht_verfuegbar", und der Nutzer traegt die Metadaten von Hand ein.
*
* Rein, ohne Seiteneffekte, ohne Netz.
*/
/** Oktal-Escapes und Standard-Escapes in PDF-Literalstrings. */
function literalEntpacken(roh: string): string {
let ergebnis = ''
for (let i = 0; i < roh.length; i++) {
const zeichen = roh[i]!
if (zeichen !== '\\') {
ergebnis += zeichen
continue
}
const naechstes = roh[++i]
if (naechstes === undefined) break
switch (naechstes) {
case 'n':
ergebnis += '\n'
break
case 'r':
ergebnis += '\n'
break
case 't':
ergebnis += '\t'
break
case 'b':
case 'f':
ergebnis += ' '
break
case '\n':
break // Zeilenumbruch im Quelltext, kein Zeichen
default:
if (naechstes >= '0' && naechstes <= '7') {
let oktal = naechstes
while (oktal.length < 3 && roh[i + 1] !== undefined && roh[i + 1]! >= '0' && roh[i + 1]! <= '7') {
oktal += roh[++i]!
}
ergebnis += String.fromCharCode(Number.parseInt(oktal, 8))
} else {
ergebnis += naechstes
}
}
}
return ergebnis
}
/**
* PDF-Bytes in Zeichen wandeln. Ohne die Font-Tabelle des Dokuments ist
* das eine Naeherung; fuer WinAnsi (der Normalfall bei deutschen Texten)
* stimmt sie, inklusive Umlauten.
*/
const WINANSI_SONDER: Record<number, string> = {
0x80: '€',
0x82: '‚',
0x84: '„',
0x85: '…',
0x91: '‘',
0x92: '’',
0x93: '“',
0x94: '”',
0x95: '•',
0x96: '–',
0x97: '—',
0x9c: 'œ',
0x9f: 'Ÿ',
}
function bytesZuText(roh: string): string {
let ergebnis = ''
for (let i = 0; i < roh.length; i++) {
const code = roh.charCodeAt(i)
ergebnis += WINANSI_SONDER[code] ?? String.fromCharCode(code)
}
return ergebnis
}
/** Hex-String <48616C6C6F> */
function hexEntpacken(roh: string): string {
const sauber = roh.replace(/[^0-9a-fA-F]/g, '')
let ergebnis = ''
for (let i = 0; i + 1 < sauber.length; i += 2) {
ergebnis += String.fromCharCode(Number.parseInt(sauber.slice(i, i + 2), 16))
}
// Ungerade Laenge: das letzte Nibble wird laut Spezifikation mit 0 aufgefuellt.
if (sauber.length % 2 === 1) {
ergebnis += String.fromCharCode(Number.parseInt(sauber.slice(-1) + '0', 16))
}
return ergebnis
}
/** Holt alle Streams aus dem PDF und packt sie aus, soweit moeglich. */
function stroemeAuspacken(pdf: Buffer): string[] {
const roh = pdf.toString('latin1')
const stroeme: string[] = []
let suchAb = 0
while (stroeme.length < 2000) {
const beginn = roh.indexOf('stream', suchAb)
if (beginn < 0) break
let inhaltVon = beginn + 'stream'.length
if (roh[inhaltVon] === '\r') inhaltVon++
if (roh[inhaltVon] === '\n') inhaltVon++
const ende = roh.indexOf('endstream', inhaltVon)
if (ende < 0) break
suchAb = ende + 'endstream'.length
const daten = Buffer.from(roh.slice(inhaltVon, ende), 'latin1')
if (daten.length === 0) continue
// Nur der Kopf des Objekts direkt davor sagt, wie komprimiert wurde.
const kopf = roh.slice(Math.max(0, beginn - 500), beginn)
if (/\/Filter\s*\/(DCTDecode|JPXDecode|CCITTFaxDecode|JBIG2Decode|RunLengthDecode)/.test(kopf)) {
continue // Bilddaten - hier ist kein Text zu holen
}
if (/\/Filter\s*(\/FlateDecode|\[\s*\/FlateDecode)/.test(kopf)) {
const entpackt = flateVersuchen(daten)
if (entpackt) stroeme.push(entpackt.toString('latin1'))
continue
}
if (!/\/Filter/.test(kopf)) {
stroeme.push(daten.toString('latin1'))
continue
}
// Unbekannter Filter: trotzdem einen Flate-Versuch wagen.
const entpackt = flateVersuchen(daten)
if (entpackt) stroeme.push(entpackt.toString('latin1'))
}
return stroeme
}
function flateVersuchen(daten: Buffer): Buffer | null {
for (const fn of [inflateSync, unzipSync, inflateRawSync]) {
try {
return fn(daten)
} catch {
// naechster Versuch
}
}
return null
}
/** Liest die Text-Operatoren aus einem ausgepackten Inhaltsstrom. */
function textAusStrom(strom: string): string {
if (!/\bTj\b|\bTJ\b|\bTd\b|\bTf\b/.test(strom)) return ''
let ergebnis = ''
const muster = /\((?:\\.|[^\\()])*\)|<[0-9a-fA-F\s]*>|\bTJ\b|\bTj\b|\bT\*\b|\bTd\b|\bTD\b|\bET\b|'|"/g
let treffer: RegExpExecArray | null
while ((treffer = muster.exec(strom)) !== null) {
const wert = treffer[0]!
if (wert.startsWith('(')) {
ergebnis += bytesZuText(literalEntpacken(wert.slice(1, -1)))
} else if (wert.startsWith('<')) {
ergebnis += bytesZuText(hexEntpacken(wert.slice(1, -1)))
} else if (wert === 'T*' || wert === 'TD' || wert === 'Td' || wert === 'ET' || wert === "'" || wert === '"') {
ergebnis += '\n'
} else if (wert === 'TJ' || wert === 'Tj') {
ergebnis += ' '
}
}
return ergebnis
}
/**
* Zieht den eingebetteten Text aus einem PDF. Gibt eine leere Zeichenkette
* zurueck, wenn nichts zu holen ist (reiner Scan, verschluesseltes PDF,
* exotische Kodierung) - nie einen Fehler.
*/
export function pdfTextLesen(pdf: Buffer): string {
try {
const teile = stroemeAuspacken(pdf).map(textAusStrom).filter((t) => t.trim().length > 0)
return textAufraeumen(teile.join('\n'))
} catch {
return ''
}
}
/** Vereinheitlicht Leerraum, ohne Zeilenstruktur zu zerstoeren. */
export function textAufraeumen(roh: string): string {
return roh
.replace(/\r\n?/g, '\n')
// Nullbytes, weiche Trennstriche und Zero-Width-Zeichen entfernen.
// eslint-disable-next-line no-control-regex
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u00ad\u200b-\u200d\ufeff]/g, '')
.split('\n')
.map((zeile) => zeile.replace(/[ \t\u00a0]+/g, ' ').trim())
.filter((zeile, i, alle) => zeile !== '' || (alle[i - 1] ?? '') !== '')
.join('\n')
.trim()
}
/**
* Grobe Seitenzahl aus der PDF-Struktur. Nutzt zuerst /Count aus dem
* Seitenbaum, sonst die Zahl der /Type /Page-Objekte.
*/
export function pdfSeitenzahlSchaetzen(pdf: Buffer): number | null {
const roh = pdf.toString('latin1')
const zaehler = [...roh.matchAll(/\/Type\s*\/Pages[^>]*?\/Count\s+(\d+)/g)].map((t) =>
Number.parseInt(t[1]!, 10),
)
if (zaehler.length > 0) return Math.max(...zaehler)
const seiten = roh.match(/\/Type\s*\/Page[^s]/g)
return seiten ? seiten.length : null
}
+206
View File
@@ -0,0 +1,206 @@
import 'server-only'
import type * as React from 'react'
import { renderToBuffer } from '@react-pdf/renderer'
import { PDFDocument, StandardFonts, degrees, rgb } from 'pdf-lib'
import { typErkennen } from '@/lib/speicher/dateityp'
/**
* PDF-Werkzeuge, auf die alle Ausgabemodule aufsetzen.
*
* Vertrag: docs/SCHNITTSTELLEN.md, Abschnitt 2.
*
* Zwei Wege fuehren hier zusammen:
* - pdfErzeugen() rendert ein react-pdf-Dokument (eigene Berichte)
* - pdfsZusammenfuehren() klebt fremde PDFs und Fotos aneinander
* (Belegmappe, Unterlagenpaket fuer das Gericht)
* und paginieren() legt am Ende eine durchgehende Zaehlung darueber - das
* ist die Zahl, auf die sich ein Inhaltsverzeichnis berufen kann.
*/
/** A4 in PDF-Punkten (72 dpi). */
export const A4_BREITE = 595.28
export const A4_HOEHE = 841.89
const RAND = 36
/** Rendert ein react-pdf-Dokument zu einem Buffer. */
export async function pdfErzeugen(dokument: React.ReactElement): Promise<Buffer> {
// renderToBuffer erwartet ein <Document>-Element; die Typen von
// @react-pdf/renderer sind hier enger als React.ReactElement.
const roh = await renderToBuffer(dokument as Parameters<typeof renderToBuffer>[0])
return Buffer.from(roh)
}
/**
* Wandelt ein Bild in ein einseitiges PDF.
*
* JPEG und PNG kann pdf-lib direkt einbetten. WEBP, TIFF und HEIC (die
* Formate moderner Handykameras) gehen vorher durch sharp - sonst waere
* jedes iPhone-Foto eines Bescheids fuer die Belegmappe verloren.
*/
export async function bildAlsPdf(inhalt: Buffer, beschriftung?: string): Promise<Buffer> {
const typ = typErkennen(inhalt)
if (!typ || !typ.istBild) {
throw new Error('Diese Datei ist kein Bild, das sich in ein PDF wandeln ließe.')
}
let daten = inhalt
let format: 'jpg' | 'png' = typ.endung === 'jpg' ? 'jpg' : 'png'
if (typ.endung !== 'jpg' && typ.endung !== 'png') {
const sharp = (await import('sharp')).default
daten = await sharp(inhalt).rotate().png().toBuffer()
format = 'png'
}
const pdf = await PDFDocument.create()
const bild = format === 'jpg' ? await pdf.embedJpg(daten) : await pdf.embedPng(daten)
const beschriftungshoehe = beschriftung ? 16 : 0
const nutzbareBreite = A4_BREITE - 2 * RAND
const nutzbareHoehe = A4_HOEHE - 2 * RAND - beschriftungshoehe
// Hochkant lassen, aber quer drehen, wenn das Blatt sonst winzig wuerde.
const querformat = bild.width > bild.height
const seitenBreite = querformat ? A4_HOEHE : A4_BREITE
const seitenHoehe = querformat ? A4_BREITE : A4_HOEHE
const platzBreite = querformat ? nutzbareHoehe : nutzbareBreite
const platzHoehe = querformat ? nutzbareBreite : nutzbareHoehe
const massstab = Math.min(platzBreite / bild.width, platzHoehe / bild.height, 1)
const breite = bild.width * massstab
const hoehe = bild.height * massstab
const seite = pdf.addPage([seitenBreite, seitenHoehe])
seite.drawImage(bild, {
x: (seitenBreite - breite) / 2,
y: (seitenHoehe - hoehe) / 2 + beschriftungshoehe / 2,
width: breite,
height: hoehe,
})
if (beschriftung) {
const schrift = await pdf.embedFont(StandardFonts.Helvetica)
seite.drawText(kuerzenFuerSchrift(beschriftung, 110), {
x: RAND,
y: RAND / 2,
size: 8,
font: schrift,
color: rgb(0.3, 0.3, 0.3),
})
}
return Buffer.from(await pdf.save())
}
/**
* Fuegt mehrere PDFs zu einem zusammen (Belegmappe, Unterlagenpaket).
* Bilder werden vorher in einseitige PDFs gewandelt. Nutzt pdf-lib.
*/
export async function pdfsZusammenfuehren(teile: Buffer[]): Promise<Buffer> {
const ziel = await PDFDocument.create()
ziel.setProducer('Betreuungs-Cockpit')
ziel.setCreator('Betreuungs-Cockpit')
for (const teil of teile) {
const typ = typErkennen(teil)
const alsPdf = typ && typ.istBild ? await bildAlsPdf(teil) : teil
const quelle = await PDFDocument.load(alsPdf, { ignoreEncryption: true })
const seiten = await ziel.copyPages(quelle, quelle.getPageIndices())
for (const seite of seiten) ziel.addPage(seite)
}
if (ziel.getPageCount() === 0) {
// Ein leeres PDF laesst sich weder oeffnen noch abheften.
ziel.addPage([A4_BREITE, A4_HOEHE])
}
return Buffer.from(await ziel.save())
}
/**
* Wie viele Seiten hat dieses PDF? Wird fuer das Inhaltsverzeichnis der
* Belegmappe gebraucht, bevor zusammengefuehrt wird.
*/
export async function seitenzahl(pdf: Buffer): Promise<number> {
const dokument = await PDFDocument.load(pdf, { ignoreEncryption: true })
return dokument.getPageCount()
}
/**
* Seitenzahlen und Fusszeile ueber ein zusammengefuehrtes PDF legen.
*
* Wichtig fuer die Belegmappe: erst nach dem Zusammenfuehren steht fest,
* auf welcher Seite ein Beleg liegt. Die Zaehlung hier ist die, auf die
* sich das Inhaltsverzeichnis und spaeter das Gericht beziehen.
*/
export async function paginieren(pdf: Buffer, fusszeile: string): Promise<Buffer> {
const dokument = await PDFDocument.load(pdf, { ignoreEncryption: true })
const schrift = await dokument.embedFont(StandardFonts.Helvetica)
const seiten = dokument.getPages()
const gesamt = seiten.length
seiten.forEach((seite, i) => {
const { width, height } = seite.getSize()
const drehung = seite.getRotation().angle % 360
const zaehlung = `Seite ${i + 1} von ${gesamt}`
const text = kuerzenFuerSchrift(fusszeile, 120)
const groesse = 7.5
// Gedrehte Seiten (Querformat-Scans) brauchen die Fusszeile ebenfalls
// gedreht, sonst steht sie quer im Blatt.
if (drehung === 90 || drehung === 270) {
seite.drawText(`${text} · ${zaehlung}`, {
x: drehung === 90 ? 18 : width - 18,
y: drehung === 90 ? 20 : height - 20,
size: groesse,
font: schrift,
color: rgb(0.35, 0.35, 0.35),
rotate: degrees(drehung),
})
return
}
seite.drawText(text, {
x: 36,
y: 18,
size: groesse,
font: schrift,
color: rgb(0.35, 0.35, 0.35),
})
const breite = schrift.widthOfTextAtSize(zaehlung, groesse)
seite.drawText(zaehlung, {
x: width - 36 - breite,
y: 18,
size: groesse,
font: schrift,
color: rgb(0.35, 0.35, 0.35),
})
})
return Buffer.from(await dokument.save())
}
/**
* Helvetica in pdf-lib kann nur WinAnsi. Zeichen ausserhalb davon (etwa
* ein Gedankenstrich aus einem OCR-Text) wuerden das Speichern abbrechen -
* deshalb hier ersetzen statt riskieren.
*/
export function fuerStandardschrift(text: string): string {
return text
.replace(/[\u2013\u2014]/g, '-')
.replace(/[\u2018\u2019\u201a]/g, "'")
.replace(/[\u201c\u201d\u201e]/g, '"')
.replace(/\u2026/g, '...')
.replace(/\u20ac/g, 'EUR')
.replace(/[\u00a0\u2007\u202f]/g, ' ')
// Alles ausserhalb von WinAnsi (Latin-1) wird ersetzt, statt das
// Speichern des PDFs scheitern zu lassen.
// eslint-disable-next-line no-control-regex
.replace(/[^\u0020-\u007e\u00a1-\u00ff]/g, '?')
}
function kuerzenFuerSchrift(text: string, maxZeichen: number): string {
const sauber = fuerStandardschrift(text)
return sauber.length > maxZeichen ? `${sauber.slice(0, maxZeichen - 1)}...` : sauber
}
+370
View File
@@ -0,0 +1,370 @@
import * as React from 'react'
import { Document, Page, StyleSheet, Text, View } from '@react-pdf/renderer'
import { formatCent, formatDatum } from '@/lib/utils'
/**
* Der gemeinsame Seitenrahmen aller PDF-Ausgaben.
*
* Vertrag: docs/SCHNITTSTELLEN.md, Abschnitt 2. Jeder Bericht, jede
* Belegmappe und jedes Unterlagenpaket benutzt ihn - damit alles, was aus
* dieser Anwendung an Gericht oder Bezirk geht, gleich aussieht und
* dieselben Pflichtangaben traegt: wer ist gemeint, welches Aktenzeichen,
* welcher Zeitraum, wann erstellt, von wem, Seite x von y.
*
* Schrift: ausschliesslich die in @react-pdf/renderer eingebauten
* Standardschriften (Helvetica). Kein Nachladen von Fonts - das waere ein
* Aufruf nach draussen, und diese Anwendung ruft niemanden an.
*/
export type Berichtskopf = {
titel: string
betreute: { name: string; geburtsdatum: Date | null }
/** z.B. "Amtsgericht Bayreuth · Az. XVII 123/25" */
behoerde?: string
aktenzeichen?: string
zeitraumVon?: Date | null
zeitraumBis?: Date | null
stichtag?: Date | null
rechtsgrundlage?: string
erstelltAm: Date
erstelltVon: string
}
export const FARBEN = {
text: '#111827',
gedaempft: '#4b5563',
linie: '#d1d5db',
linieHell: '#e5e7eb',
hintergrund: '#f3f4f6',
} as const
export const pdfStile = StyleSheet.create({
seite: {
paddingTop: 40,
paddingBottom: 60,
paddingHorizontal: 46,
fontSize: 10,
fontFamily: 'Helvetica',
color: FARBEN.text,
lineHeight: 1.4,
},
kopfLeiste: {
borderBottomWidth: 1.5,
borderBottomColor: FARBEN.text,
paddingBottom: 8,
marginBottom: 14,
},
titel: { fontSize: 16, fontFamily: 'Helvetica-Bold', marginBottom: 4 },
kopfZeile: { flexDirection: 'row', flexWrap: 'wrap', gap: 3 },
kopfPaar: { flexDirection: 'row', marginRight: 16, marginTop: 2 },
kopfBezeichnung: { fontSize: 8.5, color: FARBEN.gedaempft, marginRight: 4 },
kopfWert: { fontSize: 8.5, fontFamily: 'Helvetica-Bold' },
fusszeile: {
position: 'absolute',
bottom: 26,
left: 46,
right: 46,
borderTopWidth: 0.5,
borderTopColor: FARBEN.linie,
paddingTop: 5,
flexDirection: 'row',
justifyContent: 'space-between',
fontSize: 7.5,
color: FARBEN.gedaempft,
},
abschnitt: { marginTop: 14, marginBottom: 4 },
abschnittTitel: {
fontSize: 11.5,
fontFamily: 'Helvetica-Bold',
marginBottom: 5,
paddingBottom: 3,
borderBottomWidth: 0.5,
borderBottomColor: FARBEN.linieHell,
},
absatz: { marginBottom: 6 },
tabelleKopf: {
flexDirection: 'row',
backgroundColor: FARBEN.hintergrund,
paddingVertical: 4,
paddingHorizontal: 4,
fontFamily: 'Helvetica-Bold',
fontSize: 8.5,
},
tabelleZeile: {
flexDirection: 'row',
paddingVertical: 3.5,
paddingHorizontal: 4,
borderBottomWidth: 0.5,
borderBottomColor: FARBEN.linieHell,
fontSize: 9,
},
tabelleZelle: { paddingRight: 6 },
paarZeile: { flexDirection: 'row', marginBottom: 2.5 },
paarBezeichnung: { width: 150, color: FARBEN.gedaempft },
paarWert: { flex: 1 },
hinweis: {
marginTop: 10,
padding: 7,
backgroundColor: FARBEN.hintergrund,
fontSize: 8.5,
color: FARBEN.gedaempft,
},
unterschrift: { marginTop: 34, flexDirection: 'row', justifyContent: 'space-between' },
unterschriftFeld: { width: '45%' },
unterschriftLinie: {
borderTopWidth: 0.75,
borderTopColor: FARBEN.text,
marginTop: 30,
paddingTop: 3,
fontSize: 8,
color: FARBEN.gedaempft,
},
})
function KopfPaar({ bezeichnung, wert }: { bezeichnung: string; wert: string }) {
return (
<View style={pdfStile.kopfPaar}>
<Text style={pdfStile.kopfBezeichnung}>{bezeichnung}:</Text>
<Text style={pdfStile.kopfWert}>{wert}</Text>
</View>
)
}
function zeitraumText(kopf: Berichtskopf): string | null {
if (kopf.zeitraumVon && kopf.zeitraumBis) {
return `${formatDatum(kopf.zeitraumVon)} bis ${formatDatum(kopf.zeitraumBis)}`
}
if (kopf.zeitraumVon) return `ab ${formatDatum(kopf.zeitraumVon)}`
if (kopf.zeitraumBis) return `bis ${formatDatum(kopf.zeitraumBis)}`
return null
}
/**
* Seitenrahmen mit Kopf, Fusszeile (Seite x von y, Erstelldatum) und
* optionalem Unterschriftenfeld. Jeder Bericht benutzt ihn.
*
* Rueckgabe ist eine <Page>; der Aufrufer wickelt sie in ein <Document>
* (oder benutzt Berichtsdokument() weiter unten).
*/
export function Berichtsseite({
kopf,
mitUnterschrift,
querformat,
children,
}: {
kopf: Berichtskopf
mitUnterschrift?: boolean
/** Fuer breite Tabellen (Kassenbuch, Vermoegensuebersicht). */
querformat?: boolean
children: React.ReactNode
}): React.JSX.Element {
const zeitraum = zeitraumText(kopf)
return (
<Page size="A4" orientation={querformat ? 'landscape' : 'portrait'} style={pdfStile.seite}>
{/* Der Kopf wiederholt sich auf jeder Seite - ein einzelnes Blatt darf
nie ohne Zuordnung zur betreuten Person in der Akte landen. */}
<View style={pdfStile.kopfLeiste} fixed>
<Text style={pdfStile.titel}>{kopf.titel}</Text>
<View style={pdfStile.kopfZeile}>
<KopfPaar bezeichnung="Betreute Person" wert={kopf.betreute.name} />
{kopf.betreute.geburtsdatum && (
<KopfPaar bezeichnung="geboren am" wert={formatDatum(kopf.betreute.geburtsdatum)} />
)}
{kopf.behoerde && <KopfPaar bezeichnung="Behörde" wert={kopf.behoerde} />}
{kopf.aktenzeichen && <KopfPaar bezeichnung="Aktenzeichen" wert={kopf.aktenzeichen} />}
{zeitraum && <KopfPaar bezeichnung="Zeitraum" wert={zeitraum} />}
{kopf.stichtag && <KopfPaar bezeichnung="Stichtag" wert={formatDatum(kopf.stichtag)} />}
{kopf.rechtsgrundlage && (
<KopfPaar bezeichnung="Grundlage" wert={kopf.rechtsgrundlage} />
)}
</View>
</View>
{children}
{mitUnterschrift && <Unterschriftenfeld erstelltVon={kopf.erstelltVon} />}
<View style={pdfStile.fusszeile} fixed>
<Text>
{kopf.betreute.name} · {kopf.titel}
</Text>
<Text
render={({ pageNumber, totalPages }) =>
`Seite ${pageNumber} von ${totalPages} · erstellt am ${formatDatum(kopf.erstelltAm)} von ${kopf.erstelltVon}`
}
/>
</View>
</Page>
)
}
/** Ort/Datum und Unterschrift - ohne das nimmt kein Gericht ein Blatt an. */
export function Unterschriftenfeld({
erstelltVon,
ort,
}: {
erstelltVon: string
ort?: string
}): React.JSX.Element {
return (
<View style={pdfStile.unterschrift} wrap={false}>
<View style={pdfStile.unterschriftFeld}>
<Text style={pdfStile.unterschriftLinie}>Ort, Datum{ort ? ` (${ort})` : ''}</Text>
</View>
<View style={pdfStile.unterschriftFeld}>
<Text style={pdfStile.unterschriftLinie}>
Unterschrift der Betreuerin / des Betreuers ({erstelltVon})
</Text>
</View>
</View>
)
}
/**
* Bequeme Hulle: ein vollstaendiges Dokument mit einer fortlaufenden
* Berichtsseite. Fuer Berichte, die keine Sonderseiten brauchen.
*/
export function Berichtsdokument({
kopf,
mitUnterschrift,
querformat,
children,
}: {
kopf: Berichtskopf
mitUnterschrift?: boolean
querformat?: boolean
children: React.ReactNode
}): React.JSX.Element {
return (
<Document
title={`${kopf.titel} – ${kopf.betreute.name}`}
author={kopf.erstelltVon}
subject={kopf.aktenzeichen ?? undefined}
creator="Betreuungs-Cockpit"
producer="Betreuungs-Cockpit"
>
<Berichtsseite kopf={kopf} mitUnterschrift={mitUnterschrift} querformat={querformat}>
{children}
</Berichtsseite>
</Document>
)
}
// -----------------------------------------------------------------------------
// Bausteine, die jeder Bericht braucht
// -----------------------------------------------------------------------------
export function Abschnitt({
titel,
children,
}: {
titel: string
children: React.ReactNode
}): React.JSX.Element {
return (
<View style={pdfStile.abschnitt}>
<Text style={pdfStile.abschnittTitel}>{titel}</Text>
{children}
</View>
)
}
export function Absatz({ children }: { children: React.ReactNode }): React.JSX.Element {
return <Text style={pdfStile.absatz}>{children}</Text>
}
/** Bezeichnung links, Wert rechts - fuer Stammdaten und Kennzahlen. */
export function Angabe({
bezeichnung,
wert,
}: {
bezeichnung: string
wert: React.ReactNode
}): React.JSX.Element {
return (
<View style={pdfStile.paarZeile}>
<Text style={pdfStile.paarBezeichnung}>{bezeichnung}</Text>
<Text style={pdfStile.paarWert}>{wert}</Text>
</View>
)
}
export type Spalte<T> = {
kopf: string
/** Anteil an der Breite, z.B. 2 bei insgesamt 10. */
breite: number
rechts?: boolean
zelle: (zeile: T, index: number) => React.ReactNode
}
/**
* Schlichte Tabelle mit wiederholtem Kopf. react-pdf kann keine
* HTML-Tabellen, deshalb Flexbox mit Anteilen.
*/
export function Tabelle<T>({
spalten,
zeilen,
leerText = 'Keine Einträge.',
}: {
spalten: Spalte<T>[]
zeilen: T[]
leerText?: string
}): React.JSX.Element {
const summe = spalten.reduce((s, spalte) => s + spalte.breite, 0) || 1
if (zeilen.length === 0) {
return <Text style={{ fontSize: 9, color: FARBEN.gedaempft }}>{leerText}</Text>
}
return (
<View>
<View style={pdfStile.tabelleKopf} fixed>
{spalten.map((spalte, i) => (
<Text
key={i}
style={[
pdfStile.tabelleZelle,
{ width: `${(spalte.breite / summe) * 100}%` },
spalte.rechts ? { textAlign: 'right' } : {},
]}
>
{spalte.kopf}
</Text>
))}
</View>
{zeilen.map((zeile, z) => (
<View key={z} style={pdfStile.tabelleZeile} wrap={false}>
{spalten.map((spalte, i) => (
<Text
key={i}
style={[
pdfStile.tabelleZelle,
{ width: `${(spalte.breite / summe) * 100}%` },
spalte.rechts ? { textAlign: 'right' } : {},
]}
>
{spalte.zelle(zeile, z)}
</Text>
))}
</View>
))}
</View>
)
}
/** Grauer Kasten fuer Rechtshinweise und Erlaeuterungen unter einer Tabelle. */
export function Hinweiskasten({ children }: { children: React.ReactNode }): React.JSX.Element {
return <Text style={pdfStile.hinweis}>{children}</Text>
}
/** Betrag rechtsbuendig - Cent-BigInt, damit nichts gerundet wird. */
export function Betrag({ cent }: { cent: bigint | number | null }): React.JSX.Element {
return <Text style={{ textAlign: 'right' }}>{formatCent(cent)}</Text>
}
+109
View File
@@ -0,0 +1,109 @@
import type { GesundheitsArt } from '@prisma/client'
import { tag, tageZwischen, tagesdatum } from '@/lib/fristen/datum'
/**
* Abwesenheitstage.
*
* Warum das gezaehlt wird: Liegt die betreute Person im Krankenhaus, wird
* der Heimplatz freigehalten, aber nicht voll berechnet. Ab einer im
* Heimvertrag geregelten Zahl von Tagen mindert sich der Eigenanteil.
* Die Einrichtung zieht das nicht immer von selbst ab, und der
* Sozialhilfetraeger rechnet es erst recht nicht nach - wer die Tage nicht
* mitzaehlt, zahlt zu viel.
*
* Gezaehlt werden die Tage zwischen Aufnahme und Entlassung; der
* Entlassungstag zaehlt nicht mit, weil die betreute Person an diesem Tag
* wieder im Heim verpflegt wird. Ob der Aufnahmetag mitzaehlt, regelt der
* Heimvertrag unterschiedlich - deshalb laesst sich die Zahl an jedem
* Ereignis von Hand ueberschreiben.
*/
export type AbwesenheitsEreignis = {
datum: Date
bisDatum: Date | null
abwesenheitstage: number | null
art: GesundheitsArt
}
export type Abwesenheitsjahr = {
jahr: number
tage: number
/** Wie viele Ereignisse haben zu diesen Tagen beigetragen? */
ereignisse: number
}
/**
* Tage eines einzelnen Ereignisses. Ist die Zahl von Hand eingetragen,
* gilt sie; sonst wird aus dem Zeitraum gerechnet.
*/
export function tageEines(ereignis: AbwesenheitsEreignis): number {
if (ereignis.abwesenheitstage !== null && ereignis.abwesenheitstage >= 0) {
return ereignis.abwesenheitstage
}
if (!ereignis.bisDatum) return 0
return Math.max(0, tageZwischen(ereignis.datum, ereignis.bisDatum))
}
/**
* Summe je Kalenderjahr.
*
* Ein Aufenthalt ueber den Jahreswechsel wird auf beide Jahre aufgeteilt -
* die Heimrechnung kommt monatlich, und der Jahresbericht braucht die Tage
* dort, wo sie angefallen sind. Nur wenn die Tageszahl von Hand
* eingetragen wurde, wird sie ungeteilt dem Jahr des Beginns zugeordnet:
* Dann hat jemand bewusst eine Zahl gesetzt, und die soll nicht durch eine
* Automatik zerlegt werden.
*/
export function abwesenheitstageJeJahr(
ereignisse: AbwesenheitsEreignis[],
): Abwesenheitsjahr[] {
const jahre = new Map<number, Abwesenheitsjahr>()
const dazu = (jahr: number, tage: number) => {
if (tage <= 0) return
const vorhanden = jahre.get(jahr)
if (vorhanden) {
vorhanden.tage += tage
} else {
jahre.set(jahr, { jahr, tage, ereignisse: 0 })
}
}
for (const ereignis of ereignisse) {
const beginn = tagesdatum(ereignis.datum)
const handgezaehlt = ereignis.abwesenheitstage !== null && ereignis.abwesenheitstage >= 0
if (handgezaehlt) {
dazu(beginn.getUTCFullYear(), ereignis.abwesenheitstage!)
zaehleEreignis(jahre, beginn.getUTCFullYear())
continue
}
if (!ereignis.bisDatum) continue
const ende = tagesdatum(ereignis.bisDatum)
if (ende.getTime() <= beginn.getTime()) continue
// Jahresweise zerlegen.
let abschnittVon = beginn
while (abschnittVon.getTime() < ende.getTime()) {
const jahr = abschnittVon.getUTCFullYear()
const jahresende = tag(jahr + 1, 1, 1)
const abschnittBis = jahresende.getTime() < ende.getTime() ? jahresende : ende
dazu(jahr, tageZwischen(abschnittVon, abschnittBis))
zaehleEreignis(jahre, jahr)
abschnittVon = abschnittBis
}
}
return [...jahre.values()].sort((a, b) => b.jahr - a.jahr)
}
function zaehleEreignis(jahre: Map<number, Abwesenheitsjahr>, jahr: number): void {
const eintrag = jahre.get(jahr)
if (eintrag) eintrag.ereignisse += 1
}
/** Gesamtsumme ueber alle Jahre - fuer die Kachel auf der Uebersicht. */
export function abwesenheitstageGesamt(jahre: Abwesenheitsjahr[]): number {
return jahre.reduce((summe, j) => summe + j.tage, 0)
}
+181
View File
@@ -0,0 +1,181 @@
import type { GesundheitsArt } from '@prisma/client'
/**
* Zahlen und Staffeln aus dem Pflegerecht - an EINER Stelle, jede mit
* Stand-Datum.
*
* Der Gesetzgeber aendert diese Werte. Zuletzt zum 01.01.2024, als die
* Leistungszuschlaege nach § 43c SGB XI von 5/25/45/70 auf 15/30/50/75
* Prozent angehoben wurden. Wer solche Zahlen im Code verstreut, findet
* beim naechsten Mal nicht alle - deshalb stehen sie hier und nirgends
* sonst.
*/
// -----------------------------------------------------------------------------
// Leistungszuschlaege nach § 43c SGB XI
// -----------------------------------------------------------------------------
/**
* Stand der Staffel. Bei jeder Anzeige mit ausgegeben, damit sofort
* auffaellt, wenn die Zahlen alt sind.
*/
export const LEISTUNGSZUSCHLAG_STAND = '01.01.2024'
/**
* Quelle: § 43c Abs. 1 SGB XI in der Fassung des Pflegeunterstuetzungs-
* und -entlastungsgesetzes, gueltig ab 01.01.2024.
*
* ACHTUNG bei der Auslegung der Grenzen: Das Gesetz spricht von Jahren
* ("im ersten Jahr", "im zweiten Jahr" ...), gerechnet ab dem Beginn der
* vollstationaeren Pflege. Das erste Jahr sind die Monate 0 bis 11; ab dem
* zwoelften vollendeten Monat greift die naechste Stufe.
*
* Der Zuschlag bezieht sich AUSSCHLIESSLICH auf den pflegebedingten
* Eigenanteil (den einrichtungseinheitlichen Eigenanteil, EEE) - nicht auf
* Unterkunft und Verpflegung, nicht auf die Investitionskosten und nicht
* auf die Ausbildungsumlage. Das wird regelmaessig falsch gerechnet.
*/
export type Zuschlagsstufe = {
/** Ab wie vielen vollendeten Monaten in der Einrichtung? */
abMonat: number
/** Bis einschliesslich; null heisst: nach oben offen. */
bisMonat: number | null
prozent: number
bezeichnung: string
}
export const LEISTUNGSZUSCHLAG_STAFFEL: readonly Zuschlagsstufe[] = [
{ abMonat: 0, bisMonat: 11, prozent: 15, bezeichnung: 'erstes Jahr' },
{ abMonat: 12, bisMonat: 23, prozent: 30, bezeichnung: 'zweites Jahr' },
{ abMonat: 24, bisMonat: 35, prozent: 50, bezeichnung: 'drittes Jahr' },
{ abMonat: 36, bisMonat: null, prozent: 75, bezeichnung: 'ab dem vierten Jahr' },
] as const
export const LEISTUNGSZUSCHLAG_RECHTSGRUNDLAGE = '§ 43c SGB XI'
/**
* Ab welchem Pflegegrad gibt es den Zuschlag ueberhaupt? Ab Pflegegrad 2 -
* bei Pflegegrad 1 zahlt die Pflegekasse in der vollstationaeren Pflege
* nur den Zuschuss nach § 43 Abs. 3 SGB XI, und § 43c greift nicht.
*/
export const LEISTUNGSZUSCHLAG_AB_PFLEGEGRAD = 2
// -----------------------------------------------------------------------------
// Pflegegrad
// -----------------------------------------------------------------------------
export const PFLEGEGRAD_BESCHRIFTUNG: Record<number, string> = {
1: 'Pflegegrad 1 — geringe Beeinträchtigung',
2: 'Pflegegrad 2 — erhebliche Beeinträchtigung',
3: 'Pflegegrad 3 — schwere Beeinträchtigung',
4: 'Pflegegrad 4 — schwerste Beeinträchtigung',
5: 'Pflegegrad 5 — schwerste Beeinträchtigung mit besonderen Anforderungen',
}
export const PFLEGEGRAD_RECHTSGRUNDLAGE_BEGUTACHTUNG = '§ 18 SGB XI'
/**
* Wie lange sollte zwischen zwei Hoeherstufungsantraegen mindestens
* liegen? Keine gesetzliche Frist - ein Antrag ist jederzeit moeglich.
* Sechs Monate sind der Erfahrungswert, ab dem eine Verschlechterung
* dokumentiert genug ist, um nicht sofort wieder abgelehnt zu werden.
*/
export const HOEHERSTUFUNG_ABSTAND_MONATE = 6
// -----------------------------------------------------------------------------
// Abwesenheit
// -----------------------------------------------------------------------------
/**
* Wie viele Abwesenheitstage am Stueck werden ueblicherweise voll
* berechnet, bevor der Heimplatz reduziert abgerechnet wird? Das steht im
* HEIMVERTRAG, nicht im Gesetz - der Rahmenvertrag nach § 75 SGB XI
* regelt es je Bundesland unterschiedlich. Die Zahl hier ist ein
* Anhaltspunkt fuer den Hinweistext, keine Berechnungsgrundlage.
*/
export const ABWESENHEIT_HINWEIS_AB_TAGEN = 3
export const ABWESENHEIT_HINWEIS =
'Abwesenheitstage — vor allem Krankenhausaufenthalte — können den Eigenanteil ' +
'mindern: Der Heimplatz wird ab einer im Vertrag geregelten Zahl von Tagen nur ' +
'noch teilweise berechnet. Maßgeblich ist der Heimvertrag, nicht das Gesetz. ' +
'Sieh dort nach, ab wann gekürzt wird und wie hoch der Abschlag ist, und prüfe ' +
'jede Heimrechnung daraufhin. Das wird regelmäßig übersehen.'
// -----------------------------------------------------------------------------
// Gesundheitsereignisse
// -----------------------------------------------------------------------------
export const GESUNDHEITS_ART_BESCHRIFTUNG: Record<GesundheitsArt, string> = {
ARZTBESUCH: 'Arztbesuch',
KRANKENHAUS: 'Krankenhausaufenthalt',
EXAZERBATION: 'Exazerbation (COPD-Verschlechterung)',
STURZ: 'Sturz',
MEDIKATIONSAENDERUNG: 'Medikationsänderung',
SAUERSTOFFVERSORGUNG: 'Sauerstoffversorgung',
INHALATION: 'Inhalation',
HILFSMITTEL: 'Hilfsmittel',
IMPFUNG: 'Impfung',
REHA: 'Rehabilitation',
PFLEGEBEGUTACHTUNG: 'Pflegebegutachtung',
HEILMITTEL: 'Heilmittel (Physio-, Ergo-, Logopädie)',
SONSTIGES: 'Sonstiges',
}
/** Reihenfolge in der Schnellerfassung: das Häufigste zuerst. */
export const SCHNELLERFASSUNG_REIHENFOLGE: GesundheitsArt[] = [
'EXAZERBATION',
'KRANKENHAUS',
'SAUERSTOFFVERSORGUNG',
'MEDIKATIONSAENDERUNG',
'INHALATION',
'STURZ',
'HILFSMITTEL',
'IMPFUNG',
'ARZTBESUCH',
'PFLEGEBEGUTACHTUNG',
'REHA',
'HEILMITTEL',
'SONSTIGES',
]
export const EXAZERBATION_SCHWEREGRADE = [
{ wert: 'leicht', beschriftung: 'leicht — nur Bedarfsmedikation gesteigert' },
{ wert: 'mittel', beschriftung: 'mittel — Antibiotikum und/oder Kortison' },
{ wert: 'schwer', beschriftung: 'schwer — Krankenhausbehandlung nötig' },
] as const
export const EXAZERBATION_AUSLOESER = [
'Atemwegsinfekt',
'Lungenentzündung',
'unklar',
'Kälte / Witterung',
'Belastung',
'Medikament abgesetzt',
'Sonstiges',
] as const
export const HILFSMITTEL_TYPISCH = [
'Rollator',
'Rollstuhl',
'Sauerstoffkonzentrator',
'mobile Sauerstoffflasche',
'Pulsoxymeter',
'Inhalationsgerät',
'Pflegebett',
'Toilettenstuhl',
'Hausnotruf',
] as const
/**
* Warum die COPD hier ueberall auftaucht: Der Verlauf ist die Grundlage
* fuer zwei Dinge, die Geld und Versorgung bedeuten - den Jahresbericht
* ans Betreuungsgericht und den Antrag auf Hoeherstufung des Pflegegrades.
* Wer Exazerbationen und Krankenhaustage nicht mitzaehlt, kann bei der
* Begutachtung nicht belegen, dass sich der Zustand verschlechtert hat.
*/
export const COPD_HINWEIS =
'Eine COPD verläuft in Schüben. Zwischen zwei Exazerbationen wirkt der Zustand ' +
'stabil — die Verschlechterung zeigt sich erst in der Zahl der Schübe und der ' +
'Krankenhaustage über das Jahr. Genau diese Zahlen brauchst du für den ' +
'Jahresbericht und für einen Höherstufungsantrag.'
+181
View File
@@ -0,0 +1,181 @@
import { differenceInMonths } from 'date-fns'
import { heuteAlsTag, plusMonate, tageZwischen, tagesdatum } from '@/lib/fristen/datum'
import {
LEISTUNGSZUSCHLAG_AB_PFLEGEGRAD,
LEISTUNGSZUSCHLAG_RECHTSGRUNDLAGE,
LEISTUNGSZUSCHLAG_STAFFEL,
LEISTUNGSZUSCHLAG_STAND,
type Zuschlagsstufe,
} from '@/lib/pflege/konstanten'
/**
* Leistungszuschlag nach § 43c SGB XI.
*
* Die Pflegekasse uebernimmt einen wachsenden Anteil des pflegebedingten
* Eigenanteils, je laenger jemand in der Einrichtung lebt. Der Sprung von
* 15 auf 30 Prozent nach zwoelf Monaten ist bares Geld - bei einem
* Eigenanteil von 1.500 Euro sind das 225 Euro im Monat.
*
* Er wird oft uebersehen, weil er nicht beantragt werden muss: Die
* Einrichtung rechnet ihn mit der Pflegekasse ab. Genau deshalb lohnt der
* Blick auf die Heimrechnung - wenn die Stufe nicht umgestellt wurde,
* merkt es sonst niemand.
*
* Hier wird nur gerechnet und angezeigt. Die monatliche Gegenueberstellung
* von Heimrechnung und Deckungsquellen baut das Sozialhilfemodul unter
* /sozialhilfe/heimkosten - diese Datei doppelt sie ausdruecklich nicht.
*/
export { LEISTUNGSZUSCHLAG_STAFFEL, LEISTUNGSZUSCHLAG_STAND, LEISTUNGSZUSCHLAG_RECHTSGRUNDLAGE }
/**
* Vollendete Monate in der Einrichtung. Der Einzugstag zaehlt mit: Wer am
* 15.01. eingezogen ist, hat am 15.02. einen vollen Monat hinter sich.
*/
export function verweildauerMonate(einzugAm: Date, stichtag: Date): number {
const von = tagesdatum(einzugAm)
const bis = tagesdatum(stichtag)
if (bis.getTime() < von.getTime()) return 0
// date-fns rechnet auf Ortszeit - beide Daten auf die Mittagsstunde
// heben, sonst verschiebt die Sommerzeit den Monatswechsel.
const mittags = (d: Date) => new Date(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), 12)
return Math.max(0, differenceInMonths(mittags(bis), mittags(von)))
}
export function stufeFuerMonate(monate: number): Zuschlagsstufe {
for (const stufe of LEISTUNGSZUSCHLAG_STAFFEL) {
if (monate >= stufe.abMonat && (stufe.bisMonat === null || monate <= stufe.bisMonat)) {
return stufe
}
}
// Die Staffel ist nach oben offen; der Fall kann nicht eintreten.
return LEISTUNGSZUSCHLAG_STAFFEL[LEISTUNGSZUSCHLAG_STAFFEL.length - 1]!
}
export function naechsteStufeNach(stufe: Zuschlagsstufe): Zuschlagsstufe | null {
const index = LEISTUNGSZUSCHLAG_STAFFEL.indexOf(stufe)
if (index < 0 || index + 1 >= LEISTUNGSZUSCHLAG_STAFFEL.length) return null
return LEISTUNGSZUSCHLAG_STAFFEL[index + 1] ?? null
}
/** Prozentsatz auf einen Cent-Betrag, kaufmaennisch gerundet. */
export function anteilCent(betragCent: bigint, prozent: number): bigint {
const zaehler = betragCent * BigInt(prozent) * 2n
const nenner = 200n
const rest = zaehler % nenner
const ganz = zaehler / nenner
return rest * 2n >= nenner ? ganz + 1n : ganz
}
export type Zuschlagsergebnis = {
/** Laesst sich rechnen? Ohne Einzugsdatum nicht. */
berechenbar: boolean
verweildauerMonate: number
stufe: Zuschlagsstufe | null
prozent: number
/** Was die Pflegekasse vom Eigenanteil uebernimmt. */
zuschlagCent: bigint | null
/** Was nach dem Zuschlag vom pflegebedingten Eigenanteil bleibt. */
verbleibtCent: bigint | null
naechsteStufe: {
stufe: Zuschlagsstufe
/** Ab diesem Tag greift die naechste Stufe. */
abDatum: Date
tageBis: number
/** Wie viel mehr uebernimmt die Kasse dann im Monat? */
mehrCent: bigint | null
} | null
hinweise: string[]
}
export type ZuschlagEingabe = {
einzugAm: Date | null
/** Der pflegebedingte Eigenanteil (EEE) im Monat. */
eigenanteilCent: bigint | null
pflegegrad?: number | null
stichtag?: Date
}
export function leistungszuschlagBerechnen(eingabe: ZuschlagEingabe): Zuschlagsergebnis {
const stichtag = tagesdatum(eingabe.stichtag ?? heuteAlsTag())
const hinweise: string[] = []
if (!eingabe.einzugAm) {
return {
berechenbar: false,
verweildauerMonate: 0,
stufe: null,
prozent: 0,
zuschlagCent: null,
verbleibtCent: null,
naechsteStufe: null,
hinweise: [
'Ohne Einzugsdatum lässt sich der Leistungszuschlag nicht bestimmen. ' +
'Trag es im Heimvertrag nach.',
],
}
}
if (
eingabe.pflegegrad !== null &&
eingabe.pflegegrad !== undefined &&
eingabe.pflegegrad < LEISTUNGSZUSCHLAG_AB_PFLEGEGRAD
) {
hinweise.push(
`Der Leistungszuschlag setzt mindestens Pflegegrad ${LEISTUNGSZUSCHLAG_AB_PFLEGEGRAD} ` +
`voraus. Bei Pflegegrad 1 zahlt die Pflegekasse in der vollstationären Pflege ` +
`nur den Zuschuss nach § 43 Abs. 3 SGB XI.`,
)
}
const monate = verweildauerMonate(eingabe.einzugAm, stichtag)
const stufe = stufeFuerMonate(monate)
const eigenanteil = eingabe.eigenanteilCent
const zuschlag = eigenanteil !== null ? anteilCent(eigenanteil, stufe.prozent) : null
const folge = naechsteStufeNach(stufe)
let naechsteStufe: Zuschlagsergebnis['naechsteStufe'] = null
if (folge) {
const abDatum = plusMonate(tagesdatum(eingabe.einzugAm), folge.abMonat)
const mehr =
eigenanteil !== null
? anteilCent(eigenanteil, folge.prozent) - anteilCent(eigenanteil, stufe.prozent)
: null
naechsteStufe = {
stufe: folge,
abDatum,
tageBis: tageZwischen(stichtag, abDatum),
mehrCent: mehr,
}
}
if (eigenanteil === null) {
hinweise.push(
'Ohne den pflegebedingten Eigenanteil aus dem Heimvertrag lässt sich nur der ' +
'Prozentsatz anzeigen, nicht der Betrag.',
)
}
hinweise.push(
'Der Zuschlag gilt nur für den pflegebedingten Eigenanteil (den ' +
'einrichtungseinheitlichen Eigenanteil). Unterkunft, Verpflegung, ' +
'Investitionskosten und Ausbildungsumlage bleiben davon unberührt — dort ' +
'ändert sich mit der Stufe nichts.',
)
hinweise.push(
'Der Zuschlag muss nicht beantragt werden; die Einrichtung rechnet ihn mit der ' +
'Pflegekasse ab. Prüf trotzdem die Heimrechnung nach dem Stufenwechsel — ' +
'nicht umgestellte Stufen fallen sonst niemandem auf.',
)
return {
berechenbar: true,
verweildauerMonate: monate,
stufe,
prozent: stufe.prozent,
zuschlagCent: zuschlag,
verbleibtCent: eigenanteil !== null && zuschlag !== null ? eigenanteil - zuschlag : null,
naechsteStufe,
hinweise,
}
}
+202
View File
@@ -0,0 +1,202 @@
import type { GesundheitsArt } from '@prisma/client'
import { tagesdatum } from '@/lib/fristen/datum'
import {
abwesenheitstageJeJahr,
tageEines,
type AbwesenheitsEreignis,
} from '@/lib/pflege/abwesenheit'
/**
* Auswertung des Gesundheitsverlaufs.
*
* Zwei Zahlen wandern in den Jahresbericht und in jeden
* Hoeherstufungsantrag: Wie viele Exazerbationen gab es im Jahr, und wie
* viele Tage lag die betreute Person im Krankenhaus. Beides ist bei einer
* COPD der belastbare Beleg dafuer, dass sich der Zustand verschlechtert -
* "es geht ihr schlechter" allein ueberzeugt keinen Gutachter.
*
* Reine Logik ohne Datenbank; die Beschreibungen der Ereignisse bleiben
* verschluesselt und werden hier gar nicht angefasst.
*/
export type VerlaufEreignis = {
datum: Date
bisDatum: Date | null
art: GesundheitsArt
abwesenheitstage: number | null
/** Titel im Klartext - er enthaelt keine Diagnose, nur eine Bezeichnung. */
titel?: string
}
export type Verlaufsjahr = {
jahr: number
exazerbationen: number
/** Exazerbationen, die zu einem Krankenhausaufenthalt gefuehrt haben. */
krankenhausaufenthalte: number
krankenhaustage: number
abwesenheitstage: number
stuerze: number
ereignisseGesamt: number
}
/**
* Exazerbationen je Kalenderjahr.
*
* Gezaehlt wird das Jahr des Beginns. Ein Schub, der ueber Silvester
* laeuft, ist ein Ereignis und gehoert in das Jahr, in dem er begann.
*/
export function exazerbationenJeJahr(
ereignisse: VerlaufEreignis[],
): { jahr: number; anzahl: number }[] {
const jahre = new Map<number, number>()
for (const ereignis of ereignisse) {
if (ereignis.art !== 'EXAZERBATION') continue
const jahr = tagesdatum(ereignis.datum).getUTCFullYear()
jahre.set(jahr, (jahre.get(jahr) ?? 0) + 1)
}
return [...jahre.entries()]
.map(([jahr, anzahl]) => ({ jahr, anzahl }))
.sort((a, b) => b.jahr - a.jahr)
}
/**
* Krankenhaustage je Kalenderjahr. Gerechnet wird wie bei den
* Abwesenheitstagen: Tage zwischen Aufnahme und Entlassung, ueber den
* Jahreswechsel aufgeteilt.
*/
export function krankenhaustageJeJahr(
ereignisse: VerlaufEreignis[],
): { jahr: number; tage: number }[] {
const nurKrankenhaus: AbwesenheitsEreignis[] = ereignisse
.filter((e) => e.art === 'KRANKENHAUS')
.map((e) => ({
datum: e.datum,
bisDatum: e.bisDatum,
abwesenheitstage: e.abwesenheitstage,
art: e.art,
}))
return abwesenheitstageJeJahr(nurKrankenhaus).map((j) => ({ jahr: j.jahr, tage: j.tage }))
}
/** Die vollstaendige Jahresauswertung, wie sie auf /pflege/verlauf steht. */
export function verlaufAuswerten(ereignisse: VerlaufEreignis[]): Verlaufsjahr[] {
const jahre = new Map<number, Verlaufsjahr>()
const holen = (jahr: number): Verlaufsjahr => {
let eintrag = jahre.get(jahr)
if (!eintrag) {
eintrag = {
jahr,
exazerbationen: 0,
krankenhausaufenthalte: 0,
krankenhaustage: 0,
abwesenheitstage: 0,
stuerze: 0,
ereignisseGesamt: 0,
}
jahre.set(jahr, eintrag)
}
return eintrag
}
for (const ereignis of ereignisse) {
const jahr = tagesdatum(ereignis.datum).getUTCFullYear()
const eintrag = holen(jahr)
eintrag.ereignisseGesamt += 1
if (ereignis.art === 'EXAZERBATION') eintrag.exazerbationen += 1
if (ereignis.art === 'STURZ') eintrag.stuerze += 1
if (ereignis.art === 'KRANKENHAUS') eintrag.krankenhausaufenthalte += 1
}
for (const zeile of krankenhaustageJeJahr(ereignisse)) {
holen(zeile.jahr).krankenhaustage = zeile.tage
}
const abwesend = abwesenheitstageJeJahr(
ereignisse.map((e) => ({
datum: e.datum,
bisDatum: e.bisDatum,
abwesenheitstage: e.abwesenheitstage,
art: e.art,
})),
)
for (const zeile of abwesend) {
holen(zeile.jahr).abwesenheitstage = zeile.tage
}
return [...jahre.values()].sort((a, b) => b.jahr - a.jahr)
}
/**
* Kurzer Satz fuer den Jahresbericht - fertig zum Hineinkopieren.
* Bewusst nuechtern und ohne Diagnosen: Der Bericht geht ans Gericht,
* nicht an die Krankenkasse.
*/
export function verlaufZusammenfassung(jahr: Verlaufsjahr): string {
const teile: string[] = []
if (jahr.exazerbationen > 0) {
teile.push(
jahr.exazerbationen === 1
? 'eine akute Verschlechterung der Atemwegserkrankung'
: `${jahr.exazerbationen} akute Verschlechterungen der Atemwegserkrankung`,
)
}
if (jahr.krankenhausaufenthalte > 0) {
teile.push(
`${jahr.krankenhausaufenthalte} Krankenhausaufenthalt${
jahr.krankenhausaufenthalte === 1 ? '' : 'e'
} mit insgesamt ${jahr.krankenhaustage} Tagen`,
)
}
if (jahr.stuerze > 0) {
teile.push(`${jahr.stuerze} dokumentierte${jahr.stuerze === 1 ? 'r' : ''} Sturz${jahr.stuerze === 1 ? '' : 'e'}`)
}
if (teile.length === 0) {
return `Im Jahr ${jahr.jahr} wurden keine besonderen gesundheitlichen Ereignisse erfasst.`
}
return `Im Jahr ${jahr.jahr} sind dokumentiert: ${teile.join(', ')}.`
}
/**
* Steigt die Zahl der Exazerbationen gegenueber dem Vorjahr? Genau das ist
* das Argument fuer eine Neubegutachtung nach § 18 SGB XI.
*/
export function verschlechterungErkannt(
jahre: Verlaufsjahr[],
bezugsjahr: number,
): { erkannt: boolean; begruendung: string | null } {
const aktuell = jahre.find((j) => j.jahr === bezugsjahr)
const vorjahr = jahre.find((j) => j.jahr === bezugsjahr - 1)
if (!aktuell || !vorjahr) return { erkannt: false, begruendung: null }
const mehrSchuebe = aktuell.exazerbationen > vorjahr.exazerbationen
const mehrTage = aktuell.krankenhaustage > vorjahr.krankenhaustage
if (!mehrSchuebe && !mehrTage) return { erkannt: false, begruendung: null }
const teile: string[] = []
if (mehrSchuebe) {
teile.push(
`${vorjahr.exazerbationen} Exazerbationen im Jahr ${vorjahr.jahr} gegenüber ` +
`${aktuell.exazerbationen} im Jahr ${aktuell.jahr}`,
)
}
if (mehrTage) {
teile.push(
`${vorjahr.krankenhaustage} Krankenhaustage im Jahr ${vorjahr.jahr} gegenüber ` +
`${aktuell.krankenhaustage} im Jahr ${aktuell.jahr}`,
)
}
return {
erkannt: true,
begruendung:
`Der erfasste Verlauf zeigt eine Zunahme: ${teile.join(' und ')}. ` +
`Das kann eine erneute Begutachtung rechtfertigen.`,
}
}
/** Summe der Tage eines einzelnen Ereignisses - fuer die Zeitstrahl-Zeile. */
export { tageEines }
+205
View File
@@ -0,0 +1,205 @@
/**
* Vorbefüllte Checkliste der Standardunterlagen.
*
* Der Bezirk fordert bei einem Antrag auf Hilfe zur Pflege fast immer
* denselben Stapel an. Wer ihn kennt, kann ihn beschaffen, bevor die
* Nachforderung kommt - das spart im Verfahren Wochen.
*
* Zu jeder Position steht, WARUM der Träger sie will. Das ist kein
* Beiwerk: Wer den Zweck kennt, kann Rückfragen vorwegnehmen ("die
* Kontoauszüge sind lückenlos, die fehlende Seite 3 ist der Werbeteil")
* und erkennt, wenn eine Nachforderung über das Nötige hinausgeht.
*
* Die Liste ist eine Arbeitshilfe, keine abschließende Aufzählung. Was der
* Träger im Einzelfall verlangen darf, richtet sich nach § 60 SGB I
* (Mitwirkungspflichten) und den Grenzen des § 65 SGB I.
*/
export type Checklistenposition = {
/** Stabiler Schlüssel - verhindert Dubletten beim erneuten Übernehmen. */
schluessel: string
bezeichnung: string
/** Warum will der Träger das? Wandert als Erläuterung in den Tracker. */
erlaeuterung: string
rechtsgrundlage?: string
reihenfolge: number
}
export const STANDARDUNTERLAGEN: readonly Checklistenposition[] = [
{
schluessel: 'rentenbescheide',
bezeichnung: 'Rentenbescheide aller Träger',
erlaeuterung:
'Die Rente ist das Haupteinkommen und wird in voller Höhe auf den Bedarf angerechnet. ' +
'Der Träger braucht jeden Bescheid einzeln — gesetzliche Rente, Betriebsrente, ' +
'Witwenrente, Zusatzversorgung. Der aktuellste Anpassungsbescheid (jeweils zum 01.07.) ' +
'zählt, weil daraus der Zahlbetrag nach Abzug der Kranken- und Pflegeversicherung ' +
'hervorgeht.',
rechtsgrundlage: '§§ 82 ff. SGB XII',
reihenfolge: 10,
},
{
schluessel: 'kontoauszuege',
bezeichnung: 'Kontoauszüge aller Konten der letzten 3 Monate, lückenlos',
erlaeuterung:
'Damit prüft der Träger zweierlei: welches Vermögen vorhanden ist und ob in den ' +
'letzten Monaten Geld abgeflossen ist, das er zurückfordern könnte. "Lückenlos" ist ' +
'wörtlich gemeint — fehlende Blattnummern führen zuverlässig zur Nachforderung. ' +
'Alle Konten heißt: auch Sparbücher, Tagesgeld, Depot und das Verwahrgeldkonto im ' +
'Heim. Einzelne Buchungen dürfen nicht geschwärzt werden.',
rechtsgrundlage: '§ 60 Abs. 1 SGB I',
reihenfolge: 20,
},
{
schluessel: 'versicherungsunterlagen',
bezeichnung: 'Sämtliche Versicherungsunterlagen mit aktuellen Rückkaufswerten',
erlaeuterung:
'Bei Lebens-, Renten- und Sterbegeldversicherungen zählt nicht die Versicherungssumme, ' +
'sondern der heutige Rückkaufswert — das ist der Betrag, der als verwertbares Vermögen ' +
'in Betracht kommt. Die Mitteilung fordert man formlos bei der Gesellschaft an, sie ' +
'braucht meist zwei bis vier Wochen. Deshalb: als Erstes anfordern.',
rechtsgrundlage: '§ 90 Abs. 1 SGB XII',
reihenfolge: 30,
},
{
schluessel: 'notarielle-uebertragungen',
bezeichnung: 'Notarielle Verträge früherer Grundstücksübertragungen',
erlaeuterung:
'Stichwort Rückforderung wegen Verarmung: Wer ein Grundstück verschenkt hat und ' +
'binnen zehn Jahren bedürftig wird, kann das Geschenk zurückfordern (§ 528 BGB). ' +
'Diesen Anspruch leitet der Träger auf sich über (§ 93 SGB XII) und macht ihn gegen ' +
'die Beschenkten geltend. Der Träger will deshalb jede Übertragung der letzten zehn ' +
'Jahre sehen — mit allen Gegenleistungen wie Wohnrecht, Nießbrauch oder Pflegeleistung, ' +
'denn die mindern den Schenkungsanteil.',
rechtsgrundlage: '§ 528 BGB, § 93 SGB XII',
reihenfolge: 40,
},
{
schluessel: 'pflegegradbescheid',
bezeichnung: 'Pflegegradbescheid der Pflegekasse',
erlaeuterung:
'Der Pflegegrad entscheidet über die Höhe der Pauschale der Pflegekasse und damit ' +
'darüber, welcher Teil der Heimkosten überhaupt beim Sozialhilfeträger ankommt. ' +
'Ohne den Bescheid kann er den Bedarf nicht beziffern. Beizulegen ist auch das ' +
'Gutachten des Medizinischen Dienstes, wenn es vorliegt.',
rechtsgrundlage: '§ 15 SGB XI, § 61a SGB XII',
reihenfolge: 50,
},
{
schluessel: 'betreuerausweis',
bezeichnung: 'Betreuerausweis / Bestellungsurkunde',
erlaeuterung:
'Der Nachweis, dass du für die betreute Person handeln darfst — und für welche ' +
'Aufgabenkreise. Ohne Vermögenssorge und Behördenangelegenheiten im Ausweis nimmt ' +
'der Träger den Antrag nicht entgegen. Eine beglaubigte Kopie genügt; das Original ' +
'gibst du nie aus der Hand.',
rechtsgrundlage: '§ 1815 BGB',
reihenfolge: 60,
},
{
schluessel: 'heimvertrag',
bezeichnung: 'Heimvertrag mit aktueller Entgeltvereinbarung',
erlaeuterung:
'Aus ihm ergeben sich die vier Bausteine der Heimrechnung: einrichtungseinheitlicher ' +
'Eigenanteil, Unterkunft und Verpflegung, Investitionskosten und Ausbildungsumlage. ' +
'Der Träger prüft, ob die Sätze den Vereinbarungen nach § 75 SGB XI entsprechen — ' +
'darüber hinausgehende Zusatzleistungen übernimmt er in der Regel nicht.',
rechtsgrundlage: '§ 75 SGB XI, WBVG',
reihenfolge: 70,
},
{
schluessel: 'grundbuchauszug',
bezeichnung: 'Aktueller Grundbuchauszug',
erlaeuterung:
'Er belegt, wem das Grundstück heute gehört und was in Abteilung II und III steht — ' +
'Wohnrecht, Nießbrauch, Grundschulden. Diese Belastungen mindern den verwertbaren ' +
'Wert erheblich. "Aktuell" heißt: nicht älter als etwa drei Monate. Erhältlich beim ' +
'Grundbuchamt des Amtsgerichts, für Betreuer gebührenfrei bei berechtigtem Interesse.',
rechtsgrundlage: '§ 90 Abs. 1 SGB XII',
reihenfolge: 80,
},
{
schluessel: 'krankenversicherung',
bezeichnung: 'Nachweis der Krankenversicherung',
erlaeuterung:
'Mitgliedsbescheinigung oder Kopie der Karte. Der Träger prüft, ob der ' +
'Krankenversicherungsschutz lückenlos besteht — andernfalls müsste er die ' +
'Krankenbehandlung selbst übernehmen (§ 264 SGB V) und die Beiträge zahlen.',
rechtsgrundlage: '§ 32 SGB XII, § 264 SGB V',
reihenfolge: 90,
},
{
schluessel: 'schwerbehindertenausweis',
bezeichnung: 'Schwerbehindertenausweis',
erlaeuterung:
'Grad der Behinderung und Merkzeichen können Mehrbedarfe auslösen (§ 30 SGB XII) ' +
'und öffnen weitere Leistungen — Rundfunkbeitragsermäßigung bei Merkzeichen RF, ' +
'Freifahrt bei G oder aG. Liegt kein Ausweis vor, lohnt der Antrag beim ZBFS fast ' +
'immer.',
rechtsgrundlage: '§ 152 SGB IX, § 30 SGB XII',
reihenfolge: 100,
},
{
schluessel: 'einkommensnachweise',
bezeichnung: 'Einkommensnachweise (alle Einkünfte)',
erlaeuterung:
'Neben der Rente alles Weitere: Mieteinnahmen, Zinsen, Wohngeld, Pflegegeld, ' +
'Unterhaltszahlungen, Kapitalerträge. Einkommen ist alles, was in Geld oder ' +
'Geldeswert zufließt (§ 82 SGB XII). Was nicht angegeben wird und später auffällt, ' +
'führt zur Rückforderung — und im schlimmsten Fall zum Vorwurf des Sozialleistungs- ' +
'betrugs gegen dich als Betreuer.',
rechtsgrundlage: '§ 82 SGB XII',
reihenfolge: 110,
},
{
schluessel: 'mietvertrag-fruehere-wohnung',
bezeichnung: 'Mietvertrag oder Nachweis der früheren Wohnung',
erlaeuterung:
'Der Träger will wissen, ob neben dem Heimplatz noch eine Wohnung Kosten verursacht. ' +
'Solange die Rückkehr ernsthaft möglich ist, kann er die Wohnungskosten für eine ' +
'Übergangszeit übernehmen; steht sie dauerhaft leer, verlangt er die Aufgabe. ' +
'Achtung: Die Aufgabe von Wohnraum ist dem Betreuungsgericht anzuzeigen (§ 1833 BGB) ' +
'— erst das Gericht fragen, dann kündigen.',
rechtsgrundlage: '§ 35 SGB XII, § 1833 BGB',
reihenfolge: 120,
},
{
schluessel: 'unterhaltspflichtige',
bezeichnung: 'Erklärung zu Unterhaltspflichtigen',
erlaeuterung:
'Angaben zu Kindern und Ehegatten. Wichtig zu wissen: Seit dem ' +
'Angehörigen-Entlastungsgesetz greift der Übergang des Unterhaltsanspruchs erst ' +
'ab einem Jahresbruttoeinkommen des Kindes von über 100.000 Euro (§ 94 Abs. 1a ' +
'SGB XII), und dafür gilt eine gesetzliche Vermutung, dass die Grenze nicht ' +
'überschritten wird. Der Träger darf die Namen erfragen; Einkommensnachweise der ' +
'Kinder darf er erst verlangen, wenn er hinreichende Anhaltspunkte für ein höheres ' +
'Einkommen hat.',
rechtsgrundlage: '§ 94 Abs. 1a SGB XII',
reihenfolge: 130,
},
] as const
/** Alle Schlüssel - für den Abgleich mit bereits übernommenen Positionen. */
export const STANDARDUNTERLAGEN_SCHLUESSEL: readonly string[] = STANDARDUNTERLAGEN.map(
(p) => p.schluessel,
)
export function positionZuSchluessel(schluessel: string): Checklistenposition | undefined {
return STANDARDUNTERLAGEN.find((p) => p.schluessel === schluessel)
}
/**
* Welche Standardunterlagen fehlen noch im Tracker?
*
* Verglichen wird über die Bezeichnung, weil die Nachforderung im
* Datenmodell keinen eigenen Schlüssel trägt (siehe
* docs/schema-wuensche/sozialhilfe.md - dort ist ein Feld
* checklistenSchluessel gewünscht). Bis dahin ist die Bezeichnung der
* Schlüssel; sie wird beim Übernehmen wortgleich gesetzt.
*/
export function nochNichtUebernommen(
vorhandeneBezeichnungen: readonly string[],
): Checklistenposition[] {
const vorhanden = new Set(vorhandeneBezeichnungen.map((b) => b.trim().toLowerCase()))
return STANDARDUNTERLAGEN.filter((p) => !vorhanden.has(p.bezeichnung.trim().toLowerCase()))
}
+208
View File
@@ -0,0 +1,208 @@
import { alsIsoTag, istWochenende, plusTage, tag, tagesdatum, wochentagName } from '@/lib/fristen/datum'
/**
* Gesetzliche Feiertage in Bayern - für die Fristberechnung, nicht für die
* Arbeitsplanung.
*
* Warum eine eigene Datei neben src/lib/fristen/datum.ts?
*
* Weil beide Module in *entgegengesetzte* Richtungen sicher sein müssen.
* Bei den Betreuerfristen warnt die Anwendung nur und verschiebt bewusst
* nichts: lieber zu früh fertig sein. Hier ist es umgekehrt - die
* Widerspruchsfrist endet nach § 64 Abs. 3 SGG (und wortgleich
* § 26 Abs. 3 SGB X, § 193 BGB) tatsächlich erst mit Ablauf des
* nächstfolgenden Werktags, und diese Verschiebung ist geltendes Recht.
*
* Daraus folgt eine harte Regel für diese Datei: Ein Tag zählt hier nur
* dann als Feiertag, wenn er in ganz Bayern zweifelsfrei gesetzlicher
* Feiertag ist. Jeder zweifelhafte Tag verschiebt das Fristende NICHT -
* denn ein zu spät berechnetes Fristende kostet den Rechtsbehelf, ein zu
* früh berechnetes kostet nur einen Tag Bequemlichkeit.
*/
export type BayerischerFeiertag = {
name: string
/**
* Gilt der Tag in ganz Bayern? Nur dann verschiebt er ein Fristende.
* Bei false steht in "vorbehalt", warum nicht.
*/
landesweit: boolean
vorbehalt?: string
}
/**
* Ostersonntag nach der Gaußschen Osterformel (Fassung von Lichtenberg).
* Ohne ihn lassen sich Karfreitag, Ostermontag, Christi Himmelfahrt,
* Pfingstmontag und Fronleichnam nicht bestimmen.
*/
function ostersonntag(jahr: number): Date {
const a = jahr % 19
const b = Math.floor(jahr / 100)
const c = jahr % 100
const d = Math.floor(b / 4)
const e = b % 4
const f = Math.floor((b + 8) / 25)
const g = Math.floor((b - f + 1) / 3)
const h = (19 * a + b - d - g + 15) % 30
const i = Math.floor(c / 4)
const k = c % 4
const l = (32 + 2 * e + 2 * i - h - k) % 7
const m = Math.floor((a + 11 * h + 22 * l) / 451)
const monat = Math.floor((h + l - 7 * m + 114) / 31)
const tagImMonat = ((h + l - 7 * m + 114) % 31) + 1
return tag(jahr, monat, tagImMonat)
}
/**
* Buß- und Bettag: der Mittwoch vor dem 23. November.
*
* SONDERFALL - er verschiebt in Bayern KEINE Frist.
* Der Buß- und Bettag wurde 1995 zur Gegenfinanzierung der Pflege-
* versicherung bundesweit als gesetzlicher Feiertag gestrichen; einzig
* Sachsen hat ihn behalten (§ 1 Abs. 1 SächsSFG). In Bayern ist er nach
* Art. 1 BayFTG kein gesetzlicher Feiertag, sondern nur schulfrei
* (Art. 3 Abs. 1 BayEUG) - Behörden, Gerichte und Post arbeiten normal.
* Die Funktion existiert trotzdem, weil der Tag im Kalender vieler
* Beteiligter auftaucht und die Oberfläche ihn erklären können soll.
*/
export function bussUndBettag(jahr: number): Date {
// Vom 23.11. rückwärts zum vorangehenden Mittwoch (Wochentag 3).
const bezug = tag(jahr, 11, 23)
const wochentag = bezug.getUTCDay()
const abstand = (wochentag + 4) % 7 || 7 // Abstand rückwärts zum Mittwoch, mind. 1
return plusTage(bezug, -abstand)
}
/**
* Liefert den Feiertag zu einem Datum - oder null.
*
* Mariä Himmelfahrt (15.08.) ist der zweite SONDERFALL: Nach
* Art. 1 Abs. 1 Satz 2 BayFTG ist der Tag nur in den Gemeinden
* gesetzlicher Feiertag, in denen überwiegend katholische Einwohner leben
* - das Landesamt für Statistik führt die Liste. Für die Fristberechnung
* kommt es auf den Ort an, an dem die Handlung vorzunehmen ist, also auf
* den Sitz der Behörde. Der Bezirk Oberfranken sitzt in Bayreuth, einer
* überwiegend evangelischen Stadt - dort ist der 15.08. ein normaler
* Arbeitstag.
*
* Deshalb: landesweit = false. Die Frist wird nicht verschoben, der
* Sachverhalt aber ausdrücklich als Hinweis ausgegeben.
*/
export function bayerischerFeiertag(datum: Date): BayerischerFeiertag | null {
const d = tagesdatum(datum)
const jahr = d.getUTCFullYear()
const schluessel = alsIsoTag(d)
const ostern = ostersonntag(jahr)
const beweglich: Record<string, BayerischerFeiertag> = {
[alsIsoTag(plusTage(ostern, -2))]: { name: 'Karfreitag', landesweit: true },
[alsIsoTag(ostern)]: { name: 'Ostersonntag', landesweit: true },
[alsIsoTag(plusTage(ostern, 1))]: { name: 'Ostermontag', landesweit: true },
[alsIsoTag(plusTage(ostern, 39))]: { name: 'Christi Himmelfahrt', landesweit: true },
[alsIsoTag(plusTage(ostern, 49))]: { name: 'Pfingstsonntag', landesweit: true },
[alsIsoTag(plusTage(ostern, 50))]: { name: 'Pfingstmontag', landesweit: true },
[alsIsoTag(plusTage(ostern, 60))]: { name: 'Fronleichnam', landesweit: true },
}
const gefunden = beweglich[schluessel]
if (gefunden) return gefunden
if (schluessel === alsIsoTag(bussUndBettag(jahr))) {
return {
name: 'Buß- und Bettag',
landesweit: false,
vorbehalt:
'In Bayern seit 1995 kein gesetzlicher Feiertag mehr, sondern nur schulfrei. ' +
'Behörden und Gerichte arbeiten an diesem Tag. Die Frist verschiebt sich nicht.',
}
}
const fest: Record<string, BayerischerFeiertag> = {
'01-01': { name: 'Neujahr', landesweit: true },
'01-06': { name: 'Heilige Drei Könige', landesweit: true },
'05-01': { name: 'Tag der Arbeit', landesweit: true },
'08-15': {
name: 'Mariä Himmelfahrt',
landesweit: false,
vorbehalt:
'Nur in bayerischen Gemeinden mit überwiegend katholischer Bevölkerung gesetzlicher ' +
'Feiertag (Art. 1 Abs. 1 Satz 2 BayFTG). Bayreuth, der Sitz des Bezirks Oberfranken, ' +
'gehört nicht dazu. Die Frist wird deshalb nicht verschoben — wenn die zuständige ' +
'Stelle in einer katholischen Gemeinde sitzt, kann das Fristende einen Tag später ' +
'liegen. Verlass dich nicht darauf und gib vorher ab.',
},
'10-03': { name: 'Tag der Deutschen Einheit', landesweit: true },
'11-01': { name: 'Allerheiligen', landesweit: true },
'12-25': { name: '1. Weihnachtsfeiertag', landesweit: true },
'12-26': { name: '2. Weihnachtsfeiertag', landesweit: true },
}
return fest[schluessel.slice(5)] ?? null
}
/**
* Ist der Tag ein Werktag im Sinne von § 64 Abs. 3 SGG?
* Samstag zählt dort ausdrücklich nicht als Werktag ("Sonnabend").
*/
export function istWerktag(datum: Date): boolean {
if (istWochenende(datum)) return false
const feiertag = bayerischerFeiertag(datum)
return !(feiertag && feiertag.landesweit)
}
export type Werktagsverschiebung = {
datum: Date
verschoben: boolean
/** Warum wurde verschoben? Für die Anzeige des Rechenwegs. */
grund: string | null
/** Hinweise zu Tagen, die bewusst NICHT verschoben haben. */
hinweise: string[]
}
/**
* Schiebt ein Fristende auf den nächstfolgenden Werktag, wenn es auf einen
* Samstag, Sonntag oder gesetzlichen Feiertag fällt.
*
* § 64 Abs. 3 SGG für das sozialgerichtliche Verfahren, wortgleich
* § 26 Abs. 3 Satz 1 SGB X für das Verwaltungsverfahren und § 193 BGB als
* allgemeine Regel. Alle drei führen hier zum selben Ergebnis.
*/
export function aufWerktagSchieben(fristende: Date): Werktagsverschiebung {
const hinweise: string[] = []
let laufend = tagesdatum(fristende)
let grund: string | null = null
// Auch der übersprungene Tag kann einen Vorbehalt tragen (z.B. der
// 15.08., wenn er ohnehin auf einen Sonntag fällt) - deshalb wird bei
// jedem betrachteten Tag geprüft, nicht nur beim ersten.
const vorbehaltPruefen = (datum: Date) => {
const feiertag = bayerischerFeiertag(datum)
if (feiertag && !feiertag.landesweit && feiertag.vorbehalt) {
const text = `${feiertag.name} am ${alsIsoTag(datum)}: ${feiertag.vorbehalt}`
if (!hinweise.includes(text)) hinweise.push(text)
}
}
vorbehaltPruefen(laufend)
let schritte = 0
while (!istWerktag(laufend)) {
if (grund === null) {
const feiertag = bayerischerFeiertag(laufend)
grund =
feiertag && feiertag.landesweit
? `Das errechnete Fristende fiel auf ${feiertag.name} (${wochentagName(laufend)}).`
: `Das errechnete Fristende fiel auf einen ${wochentagName(laufend)}.`
}
laufend = plusTage(laufend, 1)
vorbehaltPruefen(laufend)
// Reißleine: mehr als zehn Tage am Stück ohne Werktag gibt es nicht.
if (++schritte > 10) break
}
return {
datum: laufend,
verschoben: grund !== null,
grund,
hinweise,
}
}
+368
View File
@@ -0,0 +1,368 @@
import {
ABWESENHEIT_KARENZTAGE,
ABWESENHEIT_MINDERUNG_PROZENT,
ABWESENHEIT_PLATZFREIHALTUNG_TAGE,
LEISTUNGSZUSCHLAG_STAFFEL,
PFLEGEKASSE_RECHTSGRUNDLAGE,
PFLEGEKASSE_VOLLSTATIONAER_CENT,
type Pflegegrad,
} from '@/lib/sozialhilfe/konstanten'
/**
* Heimkosten-Gegenüberstellung: Was kostet der Monat, wer zahlt was, und
* was bleibt offen?
*
* Die Heimrechnung besteht aus vier Bausteinen, von denen nur der erste
* pflegebedingt ist:
*
* 1. einrichtungseinheitlicher Eigenanteil (EEE) - der pflegebedingte
* Anteil, in jedem Pflegegrad gleich hoch. Nur auf ihn wirkt der
* Leistungszuschlag nach § 43c SGB XI.
* 2. Unterkunft und Verpflegung - trägt die betreute Person selbst,
* hilfsweise die Sozialhilfe.
* 3. Investitionskosten - Gebäude, Instandhaltung. Laufen auch bei
* Abwesenheit weiter.
* 4. Ausbildungsumlage - Umlage zur Finanzierung der Pflegeausbildung.
*
* Dazu kommen Zusatzleistungen (Friseur, besondere Komfortleistungen), die
* der Träger in aller Regel nicht übernimmt.
*
* Alle Beträge BigInt in Cent. Kein Fließkomma, nirgends.
*/
// -----------------------------------------------------------------------------
// Leistungszuschlag, § 43c SGB XI
// -----------------------------------------------------------------------------
/**
* Prozentsatz des Leistungszuschlags nach der Dauer der
* Leistungsinanspruchnahme.
*
* Gezählt werden VOLLENDETE Monate in vollstationärer Pflege. Nach zwölf
* vollen Monaten - also im dreizehnten - greift die zweite Stufe, weil
* § 43c SGB XI von "mehr als 12 Monaten" spricht.
*/
export function leistungszuschlagProzent(vollendeteMonate: number): number {
const monate = Math.max(0, Math.floor(vollendeteMonate))
let prozent = LEISTUNGSZUSCHLAG_STAFFEL[0]!.prozent
for (const stufe of LEISTUNGSZUSCHLAG_STAFFEL) {
if (monate >= stufe.abVollendetenMonaten) prozent = stufe.prozent
}
return prozent
}
/** Beschriftung der Stufe, für die Anzeige neben der Zahl. */
export function leistungszuschlagStufe(vollendeteMonate: number): string {
const prozent = leistungszuschlagProzent(vollendeteMonate)
return LEISTUNGSZUSCHLAG_STAFFEL.find((s) => s.prozent === prozent)?.beschriftung ?? ''
}
/**
* Der Zuschlag in Cent - immer nur auf den pflegebedingten Eigenanteil.
*
* Gerundet wird kaufmännisch auf ganze Cent. Bei ganzen Prozentsätzen und
* Cent-Beträgen ist das eine Rundung von höchstens einem Cent, aber sie
* gehört ausgeschrieben, damit niemand später über einen Cent Differenz
* zur Heimrechnung rätselt.
*/
export function leistungszuschlagCent(
pflegeEigenanteilCent: bigint,
vollendeteMonate: number,
): bigint {
const prozent = BigInt(leistungszuschlagProzent(vollendeteMonate))
const roh = pflegeEigenanteilCent * prozent
return (roh + 50n) / 100n
}
/**
* Vollendete Monate im Heim zwischen Einzug und Stichtag. Bricht die
* Zählung nicht auf Tage herunter - § 43c SGB XI stellt auf Monate ab.
*/
export function verweildauerMonate(einzugAm: Date, stichtag: Date): number {
const jahre = stichtag.getUTCFullYear() - einzugAm.getUTCFullYear()
const monate = stichtag.getUTCMonth() - einzugAm.getUTCMonth()
let gesamt = jahre * 12 + monate
if (stichtag.getUTCDate() < einzugAm.getUTCDate()) gesamt -= 1
return Math.max(0, gesamt)
}
// -----------------------------------------------------------------------------
// Pflegekasse, § 43 SGB XI
// -----------------------------------------------------------------------------
export function pflegekassePauschaleCent(pflegegrad: number | null): bigint | null {
if (pflegegrad === null) return null
if (pflegegrad < 1 || pflegegrad > 5) return null
return PFLEGEKASSE_VOLLSTATIONAER_CENT[pflegegrad as Pflegegrad]
}
export function pflegekasseRechtsgrundlage(pflegegrad: number | null): string {
if (pflegegrad === null || pflegegrad < 1 || pflegegrad > 5) return '§ 43 SGB XI'
return PFLEGEKASSE_RECHTSGRUNDLAGE[pflegegrad as Pflegegrad]
}
// -----------------------------------------------------------------------------
// Abwesenheit
// -----------------------------------------------------------------------------
export type Abwesenheitsschaetzung = {
abwesenheitstage: number
/** Tage, für die überhaupt gemindert wird (nach Abzug der Karenztage). */
minderungstage: number
geschaetzteMinderungCent: bigint
/** Wurden mehr Tage erfasst, als die Platzfreihaltung abdeckt? */
ueberPlatzfreihaltung: boolean
erklaerung: string
}
/**
* Schätzt, um wie viel die Heimrechnung wegen Abwesenheit sinkt.
*
* Bewusst nur eine Schätzung: Die Minderung steht nicht im Gesetz, sondern
* im Rahmenvertrag nach § 75 SGB XI und im Heimvertrag. Die Anwendung
* rechnet mit den in konstanten.ts hinterlegten Werten (drei Karenztage,
* danach 25 % auf Pflegeanteil sowie Unterkunft und Verpflegung) und sagt
* dazu, dass die Heimrechnung das letzte Wort hat.
*
* Die Investitionskosten bleiben ausdrücklich außen vor: Der Platz wird
* freigehalten, das Gebäude kostet weiter.
*
* Gerundet wird abwärts (BigInt-Division schneidet ab). Die geschätzte
* Minderung fällt damit im Zweifel einen Cent zu klein und die
* Deckungslücke einen Cent zu groß aus - die vorsichtigere Richtung.
*/
export function abwesenheitSchaetzen(eingabe: {
abwesenheitstage: number
pflegeEigenanteilCent: bigint
unterkunftVerpflegungCent: bigint
tageImMonat: number
}): Abwesenheitsschaetzung {
const tage = Math.max(0, Math.floor(eingabe.abwesenheitstage))
const minderungstage = Math.max(0, tage - ABWESENHEIT_KARENZTAGE)
const basisCent = eingabe.pflegeEigenanteilCent + eingabe.unterkunftVerpflegungCent
const geschaetzteMinderungCent =
minderungstage === 0 || eingabe.tageImMonat <= 0
? 0n
: (basisCent * BigInt(minderungstage) * BigInt(ABWESENHEIT_MINDERUNG_PROZENT)) /
(BigInt(eingabe.tageImMonat) * 100n)
const erklaerung =
tage === 0
? 'Keine Abwesenheitstage erfasst.'
: `${tage} Abwesenheitstage, davon ${ABWESENHEIT_KARENZTAGE} ohne Minderung. ` +
`Für die restlichen ${minderungstage} Tage sind ${ABWESENHEIT_MINDERUNG_PROZENT} % ` +
`von Pflegeanteil sowie Unterkunft und Verpflegung angesetzt. ` +
`Die Investitionskosten laufen weiter, weil der Platz freigehalten wird ` +
`(§ 87a Abs. 1 SGB XI). Das ist eine Schätzung nach dem Rahmenvertrag ` +
`(§ 75 SGB XI) — maßgeblich ist die Heimrechnung.`
return {
abwesenheitstage: tage,
minderungstage,
geschaetzteMinderungCent,
ueberPlatzfreihaltung: tage > ABWESENHEIT_PLATZFREIHALTUNG_TAGE,
erklaerung,
}
}
/** Kalendertage eines Monats. Monat ist 1..12. */
export function tageImMonat(jahr: number, monat: number): number {
return new Date(Date.UTC(jahr, monat, 0)).getUTCDate()
}
// -----------------------------------------------------------------------------
// Gegenüberstellung
// -----------------------------------------------------------------------------
export type HeimkostenEingabe = {
jahr: number
monat: number
// Kostenseite
pflegeEigenanteilCent: bigint
unterkunftVerpflegungCent: bigint
investitionskostenCent: bigint
ausbildungsumlageCent: bigint
zusatzleistungenCent: bigint
// Deckungsseite
pflegekasseCent: bigint
leistungszuschlagCent: bigint
renteCent: bigint
sonstigeEinkuenfteCent: bigint
sozialhilfeCent: bigint
eigenmittelCent: bigint
// Was der betreuten Person verbleiben muss
barbetragCent: bigint
bekleidungspauschaleCent: bigint
abwesenheitstage: number
}
export type HeimkostenErgebnis = {
jahr: number
monat: number
heimkostenCent: bigint
/** Heimkosten plus Barbetrag und Bekleidungspauschale. */
gesamtbedarfCent: bigint
deckungCent: bigint
verbleibendCent: bigint
/**
* Positiv = ungedeckt, negativ = Überdeckung. Für die Anzeige stehen
* daneben deckungsluecke Cent und ueberdeckungCent, jeweils >= 0.
*/
saldoCent: bigint
deckungslueckeCent: bigint
ueberdeckungCent: bigint
gedeckt: boolean
abwesenheit: Abwesenheitsschaetzung
/** Deckungslücke, wenn die Abwesenheitsschätzung zuträfe. */
deckungslueckeNachAbwesenheitCent: bigint
hinweise: string[]
}
/**
* Rechnet einen Monat durch.
*
* Bedarfsbegriff: Zum Bedarf zählen nicht nur die Heimkosten, sondern auch
* der Barbetrag nach § 27b Abs. 2 SGB XII und die Bekleidungspauschale.
* Beides muss der betreuten Person tatsächlich verbleiben - wer es aus der
* Rechnung herauslässt, hält die Deckung für besser, als sie ist, und
* plündert am Monatsende das Taschengeld.
*/
export function heimkostenAuswerten(eingabe: HeimkostenEingabe): HeimkostenErgebnis {
const heimkostenCent =
eingabe.pflegeEigenanteilCent +
eingabe.unterkunftVerpflegungCent +
eingabe.investitionskostenCent +
eingabe.ausbildungsumlageCent +
eingabe.zusatzleistungenCent
const verbleibendCent = eingabe.barbetragCent + eingabe.bekleidungspauschaleCent
const gesamtbedarfCent = heimkostenCent + verbleibendCent
const deckungCent =
eingabe.pflegekasseCent +
eingabe.leistungszuschlagCent +
eingabe.renteCent +
eingabe.sonstigeEinkuenfteCent +
eingabe.sozialhilfeCent +
eingabe.eigenmittelCent
const saldoCent = gesamtbedarfCent - deckungCent
const abwesenheit = abwesenheitSchaetzen({
abwesenheitstage: eingabe.abwesenheitstage,
pflegeEigenanteilCent: eingabe.pflegeEigenanteilCent,
unterkunftVerpflegungCent: eingabe.unterkunftVerpflegungCent,
tageImMonat: tageImMonat(eingabe.jahr, eingabe.monat),
})
const nachAbwesenheit = saldoCent - abwesenheit.geschaetzteMinderungCent
const hinweise: string[] = []
if (eingabe.zusatzleistungenCent > 0n) {
hinweise.push(
'Zusatzleistungen (Friseur, Komfortleistungen) übernimmt der Träger in aller Regel ' +
'nicht. Sie sind aus dem Barbetrag zu bestreiten — prüfe, ob sie hier richtig stehen.',
)
}
if (eingabe.barbetragCent === 0n) {
hinweise.push(
'Kein Barbetrag erfasst. Der Barbetrag nach § 27b Abs. 2 SGB XII muss der betreuten ' +
'Person verbleiben und gehört in die Rechnung.',
)
}
if (abwesenheit.ueberPlatzfreihaltung) {
hinweise.push(
`Mehr als ${ABWESENHEIT_PLATZFREIHALTUNG_TAGE} Abwesenheitstage: Die Pflegekasse hält ` +
`den Platz nach § 87a Abs. 1 SGB XI nur bis zu dieser Grenze im Kalenderjahr frei. ` +
`Kläre mit der Einrichtung, wie darüber hinaus abgerechnet wird.`,
)
}
if (eingabe.leistungszuschlagCent === 0n && eingabe.pflegeEigenanteilCent > 0n) {
hinweise.push(
'Kein Leistungszuschlag erfasst. Ab dem ersten Monat im Heim mindert der Zuschlag nach ' +
'§ 43c SGB XI den pflegebedingten Eigenanteil — er wird von der Pflegekasse ' +
'automatisch gewährt und steht auf der Heimrechnung.',
)
}
return {
jahr: eingabe.jahr,
monat: eingabe.monat,
heimkostenCent,
gesamtbedarfCent,
deckungCent,
verbleibendCent,
saldoCent,
deckungslueckeCent: saldoCent > 0n ? saldoCent : 0n,
ueberdeckungCent: saldoCent < 0n ? -saldoCent : 0n,
gedeckt: saldoCent <= 0n,
abwesenheit,
deckungslueckeNachAbwesenheitCent: nachAbwesenheit > 0n ? nachAbwesenheit : 0n,
hinweise,
}
}
export type Jahresverlauf = {
jahr: number
monate: HeimkostenErgebnis[]
summeBedarfCent: bigint
summeDeckungCent: bigint
summeDeckungslueckeCent: bigint
/** Monat mit der größten Lücke - dort lohnt der Blick zuerst. */
groessteLuecke: HeimkostenErgebnis | null
}
export function jahresverlaufBilden(jahr: number, monate: HeimkostenErgebnis[]): Jahresverlauf {
const sortiert = [...monate].sort((a, b) => a.monat - b.monat)
let summeBedarfCent = 0n
let summeDeckungCent = 0n
let summeDeckungslueckeCent = 0n
let groessteLuecke: HeimkostenErgebnis | null = null
for (const m of sortiert) {
summeBedarfCent += m.gesamtbedarfCent
summeDeckungCent += m.deckungCent
summeDeckungslueckeCent += m.deckungslueckeCent
if (!groessteLuecke || m.deckungslueckeCent > groessteLuecke.deckungslueckeCent) {
groessteLuecke = m
}
}
return {
jahr,
monate: sortiert,
summeBedarfCent,
summeDeckungCent,
summeDeckungslueckeCent,
groessteLuecke: groessteLuecke && groessteLuecke.deckungslueckeCent > 0n ? groessteLuecke : null,
}
}
export const MONATSNAMEN = [
'Januar',
'Februar',
'März',
'April',
'Mai',
'Juni',
'Juli',
'August',
'September',
'Oktober',
'November',
'Dezember',
] as const
export function monatsname(monat: number): string {
return MONATSNAMEN[monat - 1] ?? String(monat)
}
+256
View File
@@ -0,0 +1,256 @@
/**
* Beträge, Staffeln und Grenzwerte des Sozialhilfeverfahrens.
*
* ACHTUNG - EINZIGE STELLE.
* Alles, was sich jährlich (oder mit dem nächsten Gesetzespaket) ändert,
* steht hier und nirgendwo sonst. Jede Konstante trägt ihr Stand-Datum und
* die Fundstelle. Wer einen Wert im Fachcode findet, hat einen Fehler
* gefunden.
*
* Und der wichtigste Satz vorweg: Diese Zahlen sind Arbeitsgrundlage für
* die eigene Vorbereitung, nicht die Rechtslage. Maßgeblich ist immer der
* Bescheid des Trägers. Vor jedem Antrag und zu jedem Jahreswechsel
* gehören die Werte hier nachgeprüft.
*/
/** Wann wurden die Werte in dieser Datei zuletzt geprüft? */
export const KONSTANTEN_STAND = '2025-01-01'
/**
* Nächster Termin, zu dem die Werte hier durchzusehen sind. Die Oberfläche
* blendet ab diesem Datum einen Prüfhinweis ein.
*/
export const KONSTANTEN_NAECHSTE_PRUEFUNG = '2027-01-01'
// -----------------------------------------------------------------------------
// Regelbedarf - Bezugsgröße für Barbetrag und Zuzahlungsbefreiung
// -----------------------------------------------------------------------------
/**
* Regelbedarfsstufe 1 nach der Regelbedarfsstufen-Fortschreibungsverordnung.
* Stand 01.01.2025: 563,00 EUR monatlich (unverändert gegenüber 2024, die
* Fortschreibung ergab rechnerisch keine Erhöhung).
*
* Aus diesem Wert leiten sich unten der Barbetrag und die Belastungsgrenze
* der Zuzahlungsbefreiung ab - deshalb steht er hier oben und wird nicht
* doppelt gepflegt.
*/
export const REGELBEDARFSSTUFE_1_CENT = 56_300n
/**
* Barbetrag zur persönlichen Verfügung ("Taschengeld") für Menschen in
* Einrichtungen, § 27b Abs. 2 SGB XII: mindestens 27 % der
* Regelbedarfsstufe 1.
*
* 27 % von 563,00 EUR = 152,01 EUR monatlich (Stand 01.01.2025).
*
* "Mindestens": Der Träger darf mehr festsetzen, und einzelne Bezirke tun
* das auch. Steht im Bescheid ein höherer Betrag, gilt der Bescheid.
*/
export const BARBETRAG_ANTEIL_PROZENT = 27
export const BARBETRAG_CENT = (REGELBEDARFSSTUFE_1_CENT * BigInt(BARBETRAG_ANTEIL_PROZENT)) / 100n
/**
* Bekleidungspauschale.
*
* Bewusst ohne Zahl. Es gibt keinen bundeseinheitlichen Betrag - die
* Bekleidungsbeihilfe nach § 27b Abs. 1 SGB XII setzt der jeweilige Träger
* fest, der Bezirk Oberfranken zahlt sie üblicherweise als Pauschale je
* Quartal oder Jahr. Der Betrag ist dem Bewilligungsbescheid zu entnehmen
* und im Heimkostenmonat einzutragen. Eine erfundene Zahl wäre hier
* schädlicher als gar keine.
*/
export const BEKLEIDUNGSPAUSCHALE_CENT: bigint | null = null
export const BEKLEIDUNGSPAUSCHALE_HINWEIS =
'Die Höhe der Bekleidungspauschale legt der Träger fest (§ 27b Abs. 1 SGB XII). ' +
'Übernimm den Betrag aus dem Bewilligungsbescheid, er ist regional unterschiedlich.'
// -----------------------------------------------------------------------------
// Leistungen der Pflegekasse, § 43 SGB XI
// -----------------------------------------------------------------------------
export type Pflegegrad = 1 | 2 | 3 | 4 | 5
/**
* Monatliche Pauschalen der Pflegekasse bei vollstationärer Pflege,
* § 43 Abs. 2 SGB XI. Stand 01.01.2025 (Erhöhung um 4,5 % durch das
* Pflegeunterstützungs- und -entlastungsgesetz).
*
* Pflegegrad 1 ist der Sonderfall: § 43 Abs. 2 SGB XI greift dort nicht.
* Wer mit Pflegegrad 1 vollstationär gepflegt wird, erhält nach
* § 43 Abs. 3 SGB XI einen Zuschuss von 125 EUR monatlich - deshalb steht
* er hier mit ebendiesem Betrag, aber unter eigener Rechtsgrundlage.
*
* Die nächste turnusmäßige Anpassung nach § 30 SGB XI ist gesetzlich für
* 2028 vorgesehen. Trotzdem: zum Jahreswechsel nachsehen.
*/
export const PFLEGEKASSE_VOLLSTATIONAER_CENT: Record<Pflegegrad, bigint> = {
1: 12_500n, // § 43 Abs. 3 SGB XI - Zuschuss, keine Pauschale
2: 80_500n,
3: 131_900n,
4: 185_500n,
5: 209_600n,
}
export const PFLEGEKASSE_RECHTSGRUNDLAGE: Record<Pflegegrad, string> = {
1: '§ 43 Abs. 3 SGB XI',
2: '§ 43 Abs. 2 SGB XI',
3: '§ 43 Abs. 2 SGB XI',
4: '§ 43 Abs. 2 SGB XI',
5: '§ 43 Abs. 2 SGB XI',
}
// -----------------------------------------------------------------------------
// Leistungszuschlag, § 43c SGB XI
// -----------------------------------------------------------------------------
/**
* Der Leistungszuschlag mindert den *pflegebedingten* Eigenanteil
* (einrichtungseinheitlicher Eigenanteil, EEE) - nicht Unterkunft und
* Verpflegung, nicht die Investitionskosten und nicht die
* Ausbildungsumlage. Wer ihn auf die ganze Heimrechnung anwendet, rechnet
* die Deckungslücke zu klein.
*
* Die Staffel richtet sich nach der Dauer der Leistungsinanspruchnahme in
* vollstationärer Pflege. Stand 01.01.2024 (angehoben durch das PUEG,
* vorher 5/25/45/70 %).
*
* Lesart der Stufen: § 43c SGB XI spricht von "mehr als 12 Monaten". Die
* höhere Stufe greift also, sobald die genannte Monatszahl *vollendet*
* ist - nach 12 vollen Monaten, also im 13. Monat, gelten 30 %.
*/
export type Leistungszuschlagstufe = {
/** Ab wie vielen vollendeten Monaten im Heim gilt diese Stufe? */
abVollendetenMonaten: number
prozent: number
beschriftung: string
}
export const LEISTUNGSZUSCHLAG_STAFFEL: readonly Leistungszuschlagstufe[] = [
{ abVollendetenMonaten: 0, prozent: 15, beschriftung: 'bis 12 Monate' },
{ abVollendetenMonaten: 12, prozent: 30, beschriftung: 'mehr als 12 Monate' },
{ abVollendetenMonaten: 24, prozent: 50, beschriftung: 'mehr als 24 Monate' },
{ abVollendetenMonaten: 36, prozent: 75, beschriftung: 'mehr als 36 Monate' },
] as const
export const LEISTUNGSZUSCHLAG_STAND = '2024-01-01'
// -----------------------------------------------------------------------------
// Abwesenheit (Krankenhaus, Reha, Urlaub)
// -----------------------------------------------------------------------------
/**
* Abwesenheitstage mindern die Heimrechnung nicht ab dem ersten Tag.
*
* Die Rahmenverträge nach § 75 SGB XI regeln das landesweit: Die ersten
* drei Abwesenheitstage sind voll zu zahlen, erst ab dem vierten
* zusammenhängenden Tag wird das Entgelt gemindert - in Bayern
* üblicherweise um 25 % von Pflegesatz sowie Unterkunft und Verpflegung.
* Die Investitionskosten laufen weiter, der Platz wird ja freigehalten
* (§ 87a Abs. 1 SGB XI, Platzfreihaltung bis 42 Tage im Kalenderjahr).
*
* Weil die Prozentsätze im Rahmenvertrag und im Heimvertrag stehen und
* nicht im Gesetz, rechnet die Anwendung damit nur eine *Schätzung*. Was
* gilt, steht in der Heimrechnung.
*/
export const ABWESENHEIT_KARENZTAGE = 3
export const ABWESENHEIT_MINDERUNG_PROZENT = 25
export const ABWESENHEIT_PLATZFREIHALTUNG_TAGE = 42
// -----------------------------------------------------------------------------
// Vermögen, § 90 SGB XII
// -----------------------------------------------------------------------------
/**
* Vermögensfreibetrag ("kleinere Barbeträge"), § 90 Abs. 2 Nr. 9 SGB XII
* i.V.m. § 1 Abs. 1 Nr. 1 lit. b der Verordnung zur Durchführung des
* § 90 Abs. 2 Nr. 9 SGB XII: 10.000 EUR.
*
* Stand 01.01.2023 (bis dahin 5.000 EUR). Für jede Person, die von der
* leistungsberechtigten Person überwiegend unterhalten wird, kommen
* weitere 500 EUR hinzu (§ 1 Abs. 2 der Verordnung).
*/
export const VERMOEGENSFREIBETRAG_CENT = 1_000_000n
export const VERMOEGENSFREIBETRAG_JE_UNTERHALTENE_PERSON_CENT = 50_000n
export const VERMOEGENSFREIBETRAG_STAND = '2023-01-01'
/**
* Rückforderung einer Schenkung wegen Verarmung, § 528 BGB.
* Der Anspruch ist ausgeschlossen, wenn zum Zeitpunkt des Eintritts der
* Bedürftigkeit seit der Leistung des geschenkten Gegenstands zehn Jahre
* verstrichen sind (§ 529 Abs. 1 BGB). Deshalb fragt der Träger nach
* notariellen Übertragungen der letzten zehn Jahre.
*/
export const SCHENKUNG_RUECKFORDERUNG_JAHRE = 10
// -----------------------------------------------------------------------------
// Rechtsbehelf
// -----------------------------------------------------------------------------
/** Regelfall: ein Monat ab Bekanntgabe, § 84 Abs. 1 Satz 1 SGG. */
export const WIDERSPRUCHSFRIST_MONATE_REGEL = 1
/**
* Fehlt die Rechtsbehelfsbelehrung oder ist sie unrichtig erteilt, beträgt
* die Frist ein Jahr seit Bekanntgabe (§ 66 Abs. 2 Satz 1 SGG).
* Das wird ausdrücklich ausgewählt und nie automatisch angenommen.
*/
export const WIDERSPRUCHSFRIST_MONATE_OHNE_BELEHRUNG = 12
/**
* Zugangsvermutung bei Übermittlung durch die Post im Inland:
* bekanntgegeben am dritten Tag nach der Aufgabe zur Post
* (§ 37 Abs. 2 Satz 1 SGB X).
*/
export const ZUGANGSVERMUTUNG_TAGE = 3
/** Vorwarnstufen für die Frist "Widerspruch möglich bis" (Tage vorher). */
export const WIDERSPRUCH_VORWARNUNG_TAGE = [21, 14, 7, 3, 1]
/** Vorwarnstufen für eine vom Träger gesetzte Nachforderungsfrist. */
export const NACHFORDERUNG_VORWARNUNG_TAGE = [14, 7, 3, 1]
// -----------------------------------------------------------------------------
// Weitere Leistungen
// -----------------------------------------------------------------------------
/**
* Bayerisches Landespflegegeld: 1.000 EUR im Jahr, ab Pflegegrad 2,
* Antrag bis 31.12. des jeweiligen Jahres (Bayerisches
* Landespflegegeldgesetz - BayLPflGG).
*
* Der Betrag steht im Haushalt des Freistaats und wurde politisch schon
* mehrfach in Frage gestellt. Vor der Antragstellung die aktuelle Höhe
* beim Landesamt für Pflege prüfen.
*/
export const LANDESPFLEGEGELD_CENT = 100_000n
export const LANDESPFLEGEGELD_AB_PFLEGEGRAD = 2
export const LANDESPFLEGEGELD_ANTRAGSSCHLUSS = '31.12.'
/**
* Zuzahlungsbefreiung, § 62 SGB V: Die Belastungsgrenze liegt bei 2 % der
* jährlichen Bruttoeinnahmen zum Lebensunterhalt, bei schwerwiegend
* chronisch Kranken bei 1 %.
*
* Für Menschen, die Leistungen nach dem SGB XII in einer Einrichtung
* erhalten, ist als Bruttoeinnahme der Regelbedarf der Regelbedarfsstufe 1
* anzusetzen (§ 62 Abs. 2 Satz 5 und 6 SGB V) - nicht die tatsächliche
* Rente. Das macht die Befreiung meist schon im Januar erreichbar.
*
* 2 % von 12 x 563,00 EUR = 135,12 EUR; 1 % = 67,56 EUR (Stand 2025).
*/
export const ZUZAHLUNG_BELASTUNGSGRENZE_PROZENT = 2
export const ZUZAHLUNG_BELASTUNGSGRENZE_CHRONIKER_PROZENT = 1
export const ZUZAHLUNG_BELASTUNGSGRENZE_CENT =
(REGELBEDARFSSTUFE_1_CENT * 12n * BigInt(ZUZAHLUNG_BELASTUNGSGRENZE_PROZENT)) / 100n
export const ZUZAHLUNG_BELASTUNGSGRENZE_CHRONIKER_CENT =
(REGELBEDARFSSTUFE_1_CENT * 12n * BigInt(ZUZAHLUNG_BELASTUNGSGRENZE_CHRONIKER_PROZENT)) / 100n
/**
* Rundfunkbeitrag je Wohnung, § 8 Rundfunkfinanzierungsstaatsvertrag:
* 18,36 EUR monatlich, unverändert seit 01.08.2021. Die Ermäßigung auf ein
* Drittel (§ 4 Abs. 2 RBStV, Merkzeichen RF) beträgt 6,12 EUR.
*/
export const RUNDFUNKBEITRAG_CENT = 1_836n
export const RUNDFUNKBEITRAG_ERMAESSIGT_CENT = 612n
export const RUNDFUNKBEITRAG_STAND = '2021-08-01'
+285
View File
@@ -0,0 +1,285 @@
import type { LeistungStatus } from '@prisma/client'
import {
LANDESPFLEGEGELD_AB_PFLEGEGRAD,
LANDESPFLEGEGELD_ANTRAGSSCHLUSS,
LANDESPFLEGEGELD_CENT,
RUNDFUNKBEITRAG_CENT,
RUNDFUNKBEITRAG_ERMAESSIGT_CENT,
ZUZAHLUNG_BELASTUNGSGRENZE_CENT,
ZUZAHLUNG_BELASTUNGSGRENZE_CHRONIKER_CENT,
} from '@/lib/sozialhilfe/konstanten'
/**
* Katalog weiterer Leistungen.
*
* Der Bezirk zahlt die Hilfe zur Pflege - aber daneben liegt eine Handvoll
* Ansprüche, die niemand von sich aus anbietet. Sie gehen verloren, weil
* man sie nicht kennt oder weil die jährliche Wiederholung untergeht. Das
* bayerische Landespflegegeld etwa verfällt ersatzlos, wenn der Antrag
* nicht bis zum 31.12. gestellt ist - 1.000 Euro für einen vergessenen
* Brief.
*
* Die Schlüssel sind stabil (Feld katalogSchluessel im Modell Leistung).
* Sie dürfen nie umbenannt werden, sonst entstehen beim nächsten
* Übernehmen Dubletten.
*/
export type Katalogleistung = {
/** Stabil. Wird als Leistung.katalogSchluessel gespeichert. */
katalogSchluessel: string
bezeichnung: string
traeger: string
rechtsgrundlage: string
/** Was bringt die Leistung, in einem Satz? */
kurz: string
/** Muss jedes Jahr neu beantragt werden? */
jaehrlichNeuBeantragen: boolean
/** Wenn es eine feste Antragsfrist gibt: welche? */
antragsschluss?: string
/** Ungefährer Betrag, sofern bekannt und bezifferbar. */
betragCent?: bigint
betragHinweis?: string
/** Was braucht man für den Antrag? */
benoetigt: readonly string[]
/** Voraussetzung in einem Satz - hilft beim Aussortieren. */
voraussetzung: string
reihenfolge: number
}
function euro(cent: bigint): string {
return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(
Number(cent) / 100,
)
}
export const LEISTUNGSKATALOG: readonly Katalogleistung[] = [
{
katalogSchluessel: 'hilfe-zur-pflege',
bezeichnung: 'Hilfe zur Pflege',
traeger: 'Bezirk Oberfranken',
rechtsgrundlage: '7. Kapitel SGB XII (§§ 61 ff.)',
kurz:
'Übernimmt den Teil der Heimkosten, den Pflegekasse, Rente und eigenes Vermögen nicht ' +
'decken. Das ist das Hauptverfahren, um das sich dieses Modul dreht.',
jaehrlichNeuBeantragen: false,
voraussetzung:
'Pflegebedürftigkeit und ein Bedarf, der aus Einkommen und Vermögen nicht gedeckt ' +
'werden kann. In Bayern ist für Leistungen in Einrichtungen der Bezirk zuständig, ' +
'nicht der Landkreis.',
benoetigt: [
'Antragsformular des Bezirks',
'Betreuerausweis',
'Pflegegradbescheid',
'Heimvertrag mit Entgeltvereinbarung',
'Renten- und Einkommensnachweise',
'Kontoauszüge der letzten drei Monate',
'Vermögensaufstellung mit Rückkaufswerten',
],
reihenfolge: 10,
},
{
katalogSchluessel: 'grundsicherung',
bezeichnung: 'Grundsicherung im Alter und bei Erwerbsminderung',
traeger: 'Bezirk Oberfranken (bei Leistung in einer Einrichtung)',
rechtsgrundlage: '4. Kapitel SGB XII (§§ 41 ff.)',
kurz:
'Deckt den notwendigen Lebensunterhalt, wenn die Rente dafür nicht reicht — in der ' +
'Einrichtung insbesondere Barbetrag und Bekleidungspauschale.',
jaehrlichNeuBeantragen: false,
voraussetzung:
'Ab der Regelaltersgrenze oder bei dauerhafter voller Erwerbsminderung. Wird oft ' +
'zusammen mit der Hilfe zur Pflege in einem Antrag gestellt — trotzdem zwei ' +
'Leistungen mit eigenen Bescheiden. Unterhaltsrückgriff auf Kinder erst ab ' +
'100.000 Euro Jahresbruttoeinkommen (§ 94 Abs. 1a SGB XII).',
benoetigt: [
'Rentenbescheid',
'Nachweis über Vermögen',
'Heimvertrag',
'Erklärung zu unterhaltspflichtigen Kindern',
],
reihenfolge: 20,
},
{
katalogSchluessel: 'landespflegegeld-bayern',
bezeichnung: 'Bayerisches Landespflegegeld',
traeger: 'Bayerisches Landesamt für Pflege, Amberg',
rechtsgrundlage: 'Bayerisches Landespflegegeldgesetz (BayLPflGG)',
kurz: `Einmal im Jahr ${euro(LANDESPFLEGEGELD_CENT)}, einkommens- und vermögensunabhängig.`,
jaehrlichNeuBeantragen: true,
antragsschluss: `${LANDESPFLEGEGELD_ANTRAGSSCHLUSS} des jeweiligen Jahres`,
betragCent: LANDESPFLEGEGELD_CENT,
betragHinweis:
'Die Höhe steht im Haushalt des Freistaats und kann sich ändern. Vor dem Antrag beim ' +
'Landesamt für Pflege prüfen.',
voraussetzung:
`Mindestens Pflegegrad ${LANDESPFLEGEGELD_AB_PFLEGEGRAD} und Hauptwohnsitz in Bayern. ` +
`Der Heimplatz zählt als Hauptwohnsitz. Der Antrag muss bis zum ` +
`${LANDESPFLEGEGELD_ANTRAGSSCHLUSS} gestellt sein — danach ist das Jahr verloren, ` +
`eine Nachholung gibt es nicht. Wichtig: Das Landespflegegeld ist beim ` +
`Sozialhilfeträger nicht als Einkommen anzurechnen.`,
benoetigt: ['Pflegegradbescheid', 'Meldebestätigung oder Heimbescheinigung', 'Bankverbindung'],
reihenfolge: 30,
},
{
katalogSchluessel: 'zuzahlungsbefreiung',
bezeichnung: 'Zuzahlungsbefreiung der Krankenkasse',
traeger: 'Krankenkasse',
rechtsgrundlage: '§ 62 SGB V',
kurz:
'Nach Erreichen der Belastungsgrenze entfallen Zuzahlungen für Medikamente, Hilfsmittel ' +
'und Krankenhaus für den Rest des Jahres.',
jaehrlichNeuBeantragen: true,
antragsschluss: 'jährlich neu, am besten im Januar',
betragHinweis:
`Belastungsgrenze bei Leistungsbezug nach SGB XII in einer Einrichtung: ` +
`${euro(ZUZAHLUNG_BELASTUNGSGRENZE_CENT)} im Jahr (2 % der Regelbedarfsstufe 1), ` +
`bei schwerwiegend chronisch Kranken ` +
`${euro(ZUZAHLUNG_BELASTUNGSGRENZE_CHRONIKER_CENT)} (1 %).`,
voraussetzung:
'Bei Bezug von Leistungen nach dem SGB XII wird als Einkommen nur der Regelbedarf der ' +
'Regelbedarfsstufe 1 angesetzt (§ 62 Abs. 2 SGB V), nicht die tatsächliche Rente. Die ' +
'Grenze ist deshalb meist schnell erreicht. Bei COPD lohnt der Chroniker-Nachweis ' +
'besonders — er halbiert die Grenze.',
benoetigt: [
'Bescheid über die Sozialhilfeleistung',
'gesammelte Zuzahlungsquittungen des Jahres',
'Formular "Chronisch krank" vom Hausarzt, wenn zutreffend',
],
reihenfolge: 40,
},
{
katalogSchluessel: 'rundfunkbeitrag',
bezeichnung: 'Rundfunkbeitrag: Befreiung oder Ermäßigung',
traeger: 'ARD ZDF Deutschlandradio Beitragsservice',
rechtsgrundlage: '§ 4 RBStV',
kurz:
`Befreiung vom Beitrag von ${euro(RUNDFUNKBEITRAG_CENT)} monatlich, ersatzweise ` +
`Ermäßigung auf ${euro(RUNDFUNKBEITRAG_ERMAESSIGT_CENT)}.`,
jaehrlichNeuBeantragen: false,
betragCent: RUNDFUNKBEITRAG_CENT,
betragHinweis:
'Die Befreiung gilt so lange, wie der zugrundeliegende Bescheid gilt — nach dessen ' +
'Ablauf ist ein neuer Nachweis einzureichen. Ein Erinnerungstermin lohnt.',
voraussetzung:
'Befreiung nach § 4 Abs. 1 RBStV unter anderem für Empfänger von Hilfe zur Pflege nach ' +
'dem 7. Kapitel SGB XII (Nr. 6) und von Grundsicherung nach dem 4. Kapitel. Wer keinen ' +
'Befreiungsgrund hat, aber das Merkzeichen RF im Schwerbehindertenausweis trägt, ' +
'bekommt die Ermäßigung auf ein Drittel (§ 4 Abs. 2 RBStV). Im Heim gilt: Ein Zimmer ' +
'in einer Einrichtung ist keine beitragspflichtige Wohnung, wenn die Voraussetzungen ' +
'des § 3 Abs. 2 RBStV vorliegen — dann besteht ohnehin keine Beitragspflicht.',
benoetigt: [
'Kopie des Bewilligungsbescheids (Hilfe zur Pflege oder Grundsicherung)',
'oder Schwerbehindertenausweis mit Merkzeichen RF',
'Beitragsnummer, falls vorhanden',
],
reihenfolge: 50,
},
{
katalogSchluessel: 'schwerbehindertenausweis',
bezeichnung: 'Schwerbehindertenausweis',
traeger: 'Zentrum Bayern Familie und Soziales (ZBFS), Region Oberfranken',
rechtsgrundlage: '§ 152 SGB IX',
kurz:
'Stellt Grad der Behinderung und Merkzeichen fest — Türöffner für Steuerfreibetrag, ' +
'Rundfunkermäßigung und Freifahrt.',
jaehrlichNeuBeantragen: false,
voraussetzung:
'Antrag beim ZBFS, Feststellung anhand ärztlicher Unterlagen. Bei COPD kommt es auf ' +
'die Lungenfunktion an; ein Sauerstoffbedarf begründet regelmäßig einen hohen GdB und ' +
'kann die Merkzeichen G, aG oder B eröffnen. Ein Verschlimmerungsantrag ist jederzeit ' +
'möglich, wenn sich der Zustand verschlechtert hat.',
benoetigt: [
'Antragsformular des ZBFS',
'Namen und Anschriften der behandelnden Ärzte',
'Arztbriefe, Klinikberichte, Lungenfunktionsbefunde',
'Pflegegradgutachten des Medizinischen Dienstes',
],
reihenfolge: 60,
},
{
katalogSchluessel: 'wohngeld',
bezeichnung: 'Wohngeld',
traeger: 'Wohngeldstelle der Stadt bzw. des Landkreises',
rechtsgrundlage: 'Wohngeldgesetz (WoGG)',
kurz: 'Zuschuss zu den Kosten einer Mietwohnung.',
jaehrlichNeuBeantragen: true,
antragsschluss: 'Bewilligungszeitraum in der Regel zwölf Monate',
voraussetzung:
'Für den Heimplatz selbst kommt Wohngeld praktisch nicht in Betracht — die Kosten der ' +
'Einrichtung sind kein Mietzins im Sinne des WoGG, und wer Grundsicherung oder Hilfe ' +
'zum Lebensunterhalt bezieht, ist nach § 7 WoGG ohnehin ausgeschlossen. Relevant ' +
'bleibt Wohngeld nur, solange die frühere Wohnung noch besteht und gehalten wird. ' +
'Prüfen — und wenn es nicht passt, hier ausdrücklich auf "nicht zutreffend" setzen, ' +
'damit die Frage nicht wiederkehrt.',
benoetigt: [
'Mietvertrag der früheren Wohnung',
'Nachweis über Miete und Nebenkosten',
'Einkommensnachweise',
],
reihenfolge: 70,
},
{
katalogSchluessel: 'telefon-sozialtarif',
bezeichnung: 'Telefon: Sozialtarif',
traeger: 'Telekommunikationsanbieter (z.B. Deutsche Telekom)',
rechtsgrundlage: 'Kein gesetzlicher Anspruch — freiwillige Leistung des Anbieters',
kurz: 'Monatliche Gutschrift auf die Telefonrechnung.',
jaehrlichNeuBeantragen: false,
betragHinweis:
'Die Höhe legt der Anbieter fest und ändert sie ohne Vorankündigung. Nachfragen lohnt ' +
'trotzdem, der Aufwand ist ein Anruf.',
voraussetzung:
'Die früher gesetzlich geregelte Gebührenbefreiung gibt es seit der Privatisierung ' +
'nicht mehr. Geblieben ist der freiwillige Sozialtarif einzelner Anbieter, meist für ' +
'Menschen mit den Merkzeichen RF, Bl oder Gl. Im Heim oft gegenstandslos, weil der ' +
'Telefonanschluss über die Einrichtung läuft — dann in der Heimrechnung nachsehen.',
benoetigt: ['Schwerbehindertenausweis mit Merkzeichen', 'Kundennummer des Anschlusses'],
reihenfolge: 80,
},
] as const
export function katalogleistung(schluessel: string): Katalogleistung | undefined {
return LEISTUNGSKATALOG.find((l) => l.katalogSchluessel === schluessel)
}
export const LEISTUNG_STATUS: readonly LeistungStatus[] = [
'NICHT_GEPRUEFT',
'PRUEFUNG_LAEUFT',
'BEANTRAGT',
'BEWILLIGT',
'LAEUFT',
'ABGELEHNT',
'NICHT_ZUTREFFEND',
] as const
export const LEISTUNG_STATUS_BESCHRIFTUNG: Record<LeistungStatus, string> = {
NICHT_GEPRUEFT: 'Nicht geprüft',
PRUEFUNG_LAEUFT: 'Prüfung läuft',
BEANTRAGT: 'Beantragt',
BEWILLIGT: 'Bewilligt',
LAEUFT: 'Läuft',
ABGELEHNT: 'Abgelehnt',
NICHT_ZUTREFFEND: 'Nicht zutreffend',
}
export const LEISTUNG_STATUS_VARIANTE: Record<
LeistungStatus,
'rot' | 'orange' | 'gelb' | 'gruen' | 'secondary'
> = {
NICHT_GEPRUEFT: 'rot',
PRUEFUNG_LAEUFT: 'orange',
BEANTRAGT: 'gelb',
BEWILLIGT: 'gruen',
LAEUFT: 'gruen',
ABGELEHNT: 'secondary',
NICHT_ZUTREFFEND: 'secondary',
}
/**
* Welche Katalogleistungen sind noch nicht angelegt? Vergleich über den
* stabilen Schlüssel, nicht über die Bezeichnung.
*/
export function nochNichtAngelegt(vorhandeneSchluessel: readonly (string | null)[]): Katalogleistung[] {
const vorhanden = new Set(vorhandeneSchluessel.filter((s): s is string => Boolean(s)))
return LEISTUNGSKATALOG.filter((l) => !vorhanden.has(l.katalogSchluessel))
}
+194
View File
@@ -0,0 +1,194 @@
import type { NachforderungStatus } from '@prisma/client'
import { heuteAlsTag, tagesdatum, tageZwischen } from '@/lib/fristen/datum'
/**
* Der Nachforderungs-Tracker.
*
* Herzstück des Moduls: Jede vom Bezirk angeforderte Unterlage ist eine
* Zeile mit eigenem Status. Solange auch nur eine Zeile offen ist, ruht
* das Verfahren - und ohne Übersicht merkt man erst durch die
* Erinnerung des Trägers, welche fehlt.
*/
export const NACHFORDERUNG_STATUS: readonly NachforderungStatus[] = [
'OFFEN',
'IN_BESCHAFFUNG',
'UEBERSANDT',
'ANERKANNT',
'NICHT_VORHANDEN',
] as const
export const NACHFORDERUNG_BESCHRIFTUNG: Record<NachforderungStatus, string> = {
OFFEN: 'Offen',
IN_BESCHAFFUNG: 'In Beschaffung',
UEBERSANDT: 'Übersandt',
ANERKANNT: 'Anerkannt',
NICHT_VORHANDEN: 'Nicht vorhanden',
}
export const NACHFORDERUNG_ERKLAERUNG: Record<NachforderungStatus, string> = {
OFFEN: 'Angefordert, aber noch nichts veranlasst.',
IN_BESCHAFFUNG:
'Unterwegs — Anforderung ist raus, das Dokument liegt noch nicht vor. Typisch bei ' +
'Rückkaufswerten und Grundbuchauszügen.',
UEBERSANDT: 'An den Träger geschickt. Die Eingangsbestätigung steht noch aus.',
ANERKANNT: 'Der Träger hat die Unterlage als ausreichend bestätigt. Diese Zeile ist erledigt.',
NICHT_VORHANDEN:
'Es gibt die Unterlage nicht. Die Begründung dafür geht mit ins Unterlagenpaket — ' +
'eine unbeantwortete Zeile wirkt sonst wie fehlende Mitwirkung (§ 66 SGB I).',
}
export const NACHFORDERUNG_VARIANTE: Record<
NachforderungStatus,
'rot' | 'orange' | 'gelb' | 'gruen' | 'secondary'
> = {
OFFEN: 'rot',
IN_BESCHAFFUNG: 'orange',
UEBERSANDT: 'gelb',
ANERKANNT: 'gruen',
NICHT_VORHANDEN: 'secondary',
}
/**
* Status, die als "geliefert" zählen.
*
* NICHT_VORHANDEN zählt bewusst mit: Auch eine begründete Fehlanzeige ist
* eine Antwort an den Träger, und ohne sie bliebe der Balken für immer
* unvollständig. Damit die Zeile dann nicht leer beim Bezirk landet,
* verlangt die Eingabeprüfung in diesem Fall eine Begründung.
*/
export const GELIEFERTE_STATUS: readonly NachforderungStatus[] = [
'UEBERSANDT',
'ANERKANNT',
'NICHT_VORHANDEN',
] as const
/** Status, bei denen der Träger noch auf uns wartet. */
export const OFFENE_STATUS: readonly NachforderungStatus[] = ['OFFEN', 'IN_BESCHAFFUNG'] as const
export function istGeliefert(status: NachforderungStatus): boolean {
return GELIEFERTE_STATUS.includes(status)
}
export type TrackerZeile = {
id: string
bezeichnung: string
status: NachforderungStatus
fristBis: Date | null
}
export type TrackerFortschritt = {
gesamt: number
geliefert: number
offen: number
/** 0..100, für Anzeigezwecke gerundet. */
prozent: number
vollstaendig: boolean
/** Offene Zeilen, deren Frist bereits verstrichen ist. */
ueberfaellig: TrackerZeile[]
/** Offene Zeilen mit Frist in den nächsten sieben Tagen. */
dieseWoche: TrackerZeile[]
/** Nächste anstehende Frist über alle offenen Zeilen. */
naechsteFrist: Date | null
/** Für die Beschriftung des Balkens. */
beschriftung: string
}
/**
* Rechnet den Fortschritt aus. Rein, ohne Datenbank - deshalb testbar.
*/
export function trackerFortschritt(
zeilen: readonly TrackerZeile[],
heute: Date = heuteAlsTag(),
): TrackerFortschritt {
const stichtag = tagesdatum(heute)
const gesamt = zeilen.length
const geliefert = zeilen.filter((z) => istGeliefert(z.status)).length
const offeneZeilen = zeilen.filter((z) => !istGeliefert(z.status))
const mitFrist = offeneZeilen.filter(
(z): z is TrackerZeile & { fristBis: Date } => z.fristBis !== null,
)
const ueberfaellig = mitFrist.filter((z) => tageZwischen(stichtag, z.fristBis) < 0)
const dieseWoche = mitFrist.filter((z) => {
const tage = tageZwischen(stichtag, z.fristBis)
return tage >= 0 && tage <= 7
})
const naechsteFrist = mitFrist
.map((z) => tagesdatum(z.fristBis))
.sort((a, b) => a.getTime() - b.getTime())[0]
const prozent = gesamt > 0 ? Math.round((geliefert / gesamt) * 100) : 0
return {
gesamt,
geliefert,
offen: offeneZeilen.length,
prozent,
vollstaendig: gesamt > 0 && geliefert === gesamt,
ueberfaellig,
dieseWoche,
naechsteFrist: naechsteFrist ?? null,
beschriftung:
gesamt === 0
? 'Noch keine Unterlagen erfasst'
: `${geliefert} von ${gesamt} Unterlagen geliefert`,
}
}
/**
* Ampel für eine einzelne Nachforderungsfrist. Der Text steht immer neben
* der Farbe - Farbe allein ist keine Information.
*/
export function fristAmpel(
fristBis: Date | null,
status: NachforderungStatus,
heute: Date = heuteAlsTag(),
): { variante: 'rot' | 'orange' | 'gelb' | 'gruen' | 'secondary'; text: string } {
if (istGeliefert(status)) {
return { variante: 'gruen', text: NACHFORDERUNG_BESCHRIFTUNG[status] }
}
if (!fristBis) return { variante: 'secondary', text: 'ohne Frist' }
const tage = tageZwischen(tagesdatum(heute), tagesdatum(fristBis))
if (tage < 0) return { variante: 'rot', text: `${Math.abs(tage)} Tage überfällig` }
if (tage === 0) return { variante: 'rot', text: 'heute fällig' }
if (tage <= 7) return { variante: 'orange', text: `in ${tage} Tagen fällig` }
if (tage <= 30) return { variante: 'gelb', text: `in ${tage} Tagen fällig` }
return { variante: 'gruen', text: `in ${tage} Tagen fällig` }
}
/**
* Text für das Unterlagenpaket an den Träger: Was liegt bei, was nicht und
* warum nicht. Der Träger soll nicht raten müssen - und die Fehlanzeige
* ist die Mitwirkung, die § 60 SGB I verlangt.
*/
export function begleitschreibenZeilen(
zeilen: readonly {
bezeichnung: string
status: NachforderungStatus
begruendungNichtVorhanden: string | null
}[],
): { beiliegend: string[]; fehlanzeige: string[]; nochOffen: string[] } {
const beiliegend: string[] = []
const fehlanzeige: string[] = []
const nochOffen: string[] = []
for (const zeile of zeilen) {
if (zeile.status === 'NICHT_VORHANDEN') {
fehlanzeige.push(
`${zeile.bezeichnung}: ${
zeile.begruendungNichtVorhanden?.trim() || 'Begründung fehlt noch — bitte ergänzen.'
}`,
)
} else if (istGeliefert(zeile.status)) {
beiliegend.push(zeile.bezeichnung)
} else {
nochOffen.push(zeile.bezeichnung)
}
}
return { beiliegend, fehlanzeige, nochOffen }
}
+296
View File
@@ -0,0 +1,296 @@
import { alsIsoTag, heuteAlsTag, plusJahre, plusMonate, plusTage, tageZwischen, tagesdatum } from '@/lib/fristen/datum'
import { aufWerktagSchieben } from '@/lib/sozialhilfe/feiertage'
import {
WIDERSPRUCH_VORWARNUNG_TAGE,
WIDERSPRUCHSFRIST_MONATE_OHNE_BELEHRUNG,
WIDERSPRUCHSFRIST_MONATE_REGEL,
ZUGANGSVERMUTUNG_TAGE,
} from '@/lib/sozialhilfe/konstanten'
/**
* Berechnung der Widerspruchsfrist gegen einen Bescheid des Trägers.
*
* Der Rechenweg wird nicht nur ausgerechnet, sondern als Liste von
* Schritten mitgeliefert. Wer eine Frist verpasst, soll nachvollziehen
* können, wie die Anwendung darauf gekommen ist - und wo sie sich geirrt
* haben könnte. Die Anwendung rechnet; entscheiden tun der Träger, die
* Widerspruchsstelle und im Streitfall das Sozialgericht.
*
* Grundlagen:
* - § 84 Abs. 1 Satz 1 SGG: Der Widerspruch ist binnen eines Monats,
* nachdem der Verwaltungsakt bekanntgegeben worden ist, zu erheben.
* - § 66 Abs. 2 Satz 1 SGG: Fehlt die Rechtsbehelfsbelehrung oder ist sie
* unrichtig erteilt, läuft die Frist ein Jahr seit Bekanntgabe.
* - Fristbeginn und -ende: § 64 SGG, für das Verwaltungsverfahren
* § 26 SGB X, beide über §§ 187 ff. BGB.
* - § 187 Abs. 1 BGB: Der Tag der Bekanntgabe zählt nicht mit.
* - § 188 Abs. 2 BGB: Die Monatsfrist endet mit Ablauf des Tages, der
* durch seine Zahl dem Tag der Bekanntgabe entspricht.
* - § 188 Abs. 3 BGB: Fehlt dieser Tag im letzten Monat, endet die Frist
* am letzten Tag des Monats (31.01. + 1 Monat = 28./29.02.).
* - § 64 Abs. 3 SGG / § 26 Abs. 3 SGB X / § 193 BGB: Fällt das Ende auf
* Samstag, Sonntag oder gesetzlichen Feiertag, endet die Frist mit
* Ablauf des nächstfolgenden Werktags.
*
* ACHTUNG, Unterschied zu den Betreuerfristen: Dort verschiebt die
* Anwendung bewusst nicht (siehe src/lib/fristen/datum.ts). Hier verschiebt
* sie, weil die Verschiebung Teil der Rechtslage ist.
*/
export type Belehrung = 'ORDNUNGSGEMAESS' | 'FEHLT_ODER_FEHLERHAFT'
export const BELEHRUNG_BESCHRIFTUNG: Record<Belehrung, string> = {
ORDNUNGSGEMAESS: 'Rechtsbehelfsbelehrung vorhanden und richtig',
FEHLT_ODER_FEHLERHAFT: 'Rechtsbehelfsbelehrung fehlt oder ist fehlerhaft',
}
export const BELEHRUNG_ERKLAERUNG: Record<Belehrung, string> = {
ORDNUNGSGEMAESS:
'Regelfall: ein Monat ab Bekanntgabe (§ 84 Abs. 1 SGG). Die Belehrung muss Behörde, ' +
'Sitz, Form und Frist des Rechtsbehelfs richtig benennen.',
FEHLT_ODER_FEHLERHAFT:
'Dann ein Jahr ab Bekanntgabe (§ 66 Abs. 2 SGG). Das ist eine bewusste Einschätzung — ' +
'ob eine Belehrung wirklich fehlerhaft ist, entscheidet im Streitfall das Sozialgericht. ' +
'Verlass dich nie auf die Jahresfrist, wenn die Monatsfrist noch läuft.',
}
/** Monatszahl zur gewählten Belehrungslage. */
export function fristMonateZurBelehrung(belehrung: Belehrung): number {
return belehrung === 'FEHLT_ODER_FEHLERHAFT'
? WIDERSPRUCHSFRIST_MONATE_OHNE_BELEHRUNG
: WIDERSPRUCHSFRIST_MONATE_REGEL
}
export function belehrungZuMonaten(monate: number): Belehrung {
return monate >= WIDERSPRUCHSFRIST_MONATE_OHNE_BELEHRUNG
? 'FEHLT_ODER_FEHLERHAFT'
: 'ORDNUNGSGEMAESS'
}
export type Rechenschritt = {
titel: string
text: string
rechtsgrundlage: string
}
export type RechtsbehelfEingabe = {
/** Maßgeblich für den Fristbeginn: der Tag der Bekanntgabe. */
zugegangenAm: Date | null | undefined
/** Datum auf dem Bescheid - nur Rückfallebene für die Zugangsvermutung. */
bescheidVom?: Date | null
/** 1 (Regelfall) oder 12 (fehlende/fehlerhafte Belehrung). */
fristMonate?: number
/** Für Tests und für die Anzeige "noch x Tage". */
heute?: Date
}
export type RechtsbehelfErgebnis = {
/** Konnte gerechnet werden? Ohne Zugangsdatum: nein. */
berechenbar: boolean
/** Der Tag der Bekanntgabe, von dem aus gerechnet wurde. */
bekanntgabeAm: Date | null
/** Fristende vor der Werktagsverschiebung - nur für den Rechenweg. */
rechnerischesEnde: Date | null
/** Das maßgebliche Fristende. */
widerspruchMoeglichBis: Date | null
verschoben: boolean
fristMonate: number
belehrung: Belehrung
/** Negativ, wenn die Frist bereits abgelaufen ist. */
tageVerbleibend: number | null
abgelaufen: boolean
/** Läuft heute ab - der gefährlichste Tag. */
laeuftHeuteAb: boolean
schritte: Rechenschritt[]
hinweise: string[]
}
/**
* Zugangsvermutung bei Übermittlung durch die Post im Inland:
* bekanntgegeben am dritten Tag nach der Aufgabe zur Post
* (§ 37 Abs. 2 Satz 1 SGB X). Fällt dieser Tag auf Samstag, Sonntag oder
* einen gesetzlichen Feiertag, endet die Frist mit Ablauf des
* nächstfolgenden Werktags (§ 37 Abs. 2 Satz 3 SGB X).
*
* Das ist ausdrücklich nur ein VORSCHLAG für das Zugangsdatum. Maßgeblich
* ist, wann der Brief tatsächlich im Briefkasten lag - und im Zweifel
* trägt die Behörde die Beweislast (§ 37 Abs. 2 Satz 4 SGB X). Wer den
* Umschlag mit dem Poststempel aufhebt und das Eingangsdatum notiert,
* spart sich später eine Auseinandersetzung.
*/
export function zugangsvermutung(bescheidVom: Date): {
datum: Date
hinweis: string
} {
const roh = plusTage(tagesdatum(bescheidVom), ZUGANGSVERMUTUNG_TAGE)
const geschoben = aufWerktagSchieben(roh)
return {
datum: geschoben.datum,
hinweis:
`Vorschlag nach der Zugangsvermutung: dritter Tag nach Aufgabe zur Post ` +
`(§ 37 Abs. 2 SGB X). Das ist nur eine Annahme — trag das tatsächliche ` +
`Eingangsdatum ein und hebe den Briefumschlag auf.`,
}
}
/** Kern der Fristberechnung. Rein, ohne Datenbank, ohne Uhrzeit. */
export function rechtsbehelfsfristBerechnen(eingabe: RechtsbehelfEingabe): RechtsbehelfErgebnis {
const fristMonate = eingabe.fristMonate ?? WIDERSPRUCHSFRIST_MONATE_REGEL
const belehrung = belehrungZuMonaten(fristMonate)
const heute = tagesdatum(eingabe.heute ?? heuteAlsTag())
if (!eingabe.zugegangenAm) {
const hinweise = [
'Ohne Zugangsdatum lässt sich die Frist nicht berechnen. Maßgeblich ist der Tag der ' +
'Bekanntgabe, nicht das Datum auf dem Bescheid — die beiden liegen fast immer ' +
'mehrere Tage auseinander.',
]
if (eingabe.bescheidVom) {
hinweise.push(zugangsvermutung(eingabe.bescheidVom).hinweis)
}
return {
berechenbar: false,
bekanntgabeAm: null,
rechnerischesEnde: null,
widerspruchMoeglichBis: null,
verschoben: false,
fristMonate,
belehrung,
tageVerbleibend: null,
abgelaufen: false,
laeuftHeuteAb: false,
schritte: [],
hinweise,
}
}
const bekanntgabe = tagesdatum(eingabe.zugegangenAm)
// Schritt 1: Fristbeginn. Der Tag der Bekanntgabe zählt nicht mit; die
// Frist beginnt mit dem Ablauf dieses Tages, läuft also ab dem Folgetag.
const erstesLaufendesDatum = plusTage(bekanntgabe, 1)
// Schritt 2: Fristende rechnerisch. plusMonate/plusJahre rechnen
// kalendarisch und kappen auf den letzten Tag des Zielmonats - genau die
// Regel des § 188 Abs. 3 BGB.
const rechnerischesEnde =
fristMonate === 12 ? plusJahre(bekanntgabe, 1) : plusMonate(bekanntgabe, fristMonate)
// Schritt 3: Werktagsverschiebung.
const geschoben = aufWerktagSchieben(rechnerischesEnde)
const tageVerbleibend = tageZwischen(heute, geschoben.datum)
const schritte: Rechenschritt[] = [
{
titel: 'Fristbeginn',
text:
`Bekanntgabe am ${alsIsoTag(bekanntgabe)}. Dieser Tag zählt nicht mit, die Frist ` +
`läuft ab dem ${alsIsoTag(erstesLaufendesDatum)}.`,
rechtsgrundlage: '§ 187 Abs. 1 BGB',
},
{
titel: 'Fristlänge',
text:
fristMonate === 12
? 'Ein Jahr, weil die Rechtsbehelfsbelehrung als fehlend oder fehlerhaft eingestuft wurde.'
: `${fristMonate} Monat ab Bekanntgabe.`,
rechtsgrundlage: fristMonate === 12 ? '§ 66 Abs. 2 SGG' : '§ 84 Abs. 1 SGG',
},
{
titel: 'Rechnerisches Fristende',
text:
`${alsIsoTag(rechnerischesEnde)} — der Tag, der durch seine Zahl dem Tag der ` +
`Bekanntgabe entspricht. Fehlt dieser Tag im Zielmonat, ist es dessen letzter Tag.`,
rechtsgrundlage: '§ 188 Abs. 2 und 3 BGB',
},
]
if (geschoben.verschoben) {
schritte.push({
titel: 'Verschiebung auf den nächsten Werktag',
text: `${geschoben.grund} Die Frist endet deshalb erst am ${alsIsoTag(geschoben.datum)}.`,
rechtsgrundlage: '§ 64 Abs. 3 SGG, § 26 Abs. 3 SGB X',
})
} else {
schritte.push({
titel: 'Werktagsprüfung',
text: `${alsIsoTag(geschoben.datum)} ist ein Werktag — es wird nichts verschoben.`,
rechtsgrundlage: '§ 64 Abs. 3 SGG',
})
}
const hinweise = [...geschoben.hinweise]
hinweise.push(
'Der Widerspruch muss bis zum Ablauf dieses Tages bei der Behörde eingegangen sein — ' +
'die Aufgabe zur Post genügt nicht. Wer knapp dran ist, gibt persönlich ab und lässt ' +
'sich den Eingang bestätigen, oder nutzt Fax bzw. das Postfach mit Sendebericht.',
)
if (fristMonate === 12) {
hinweise.push(BELEHRUNG_ERKLAERUNG.FEHLT_ODER_FEHLERHAFT)
}
return {
berechenbar: true,
bekanntgabeAm: bekanntgabe,
rechnerischesEnde,
widerspruchMoeglichBis: geschoben.datum,
verschoben: geschoben.verschoben,
fristMonate,
belehrung,
tageVerbleibend,
abgelaufen: tageVerbleibend < 0,
laeuftHeuteAb: tageVerbleibend === 0,
schritte,
hinweise,
}
}
/**
* Vorschlag für den Frist-Datensatz "Widerspruch möglich bis".
* Wird von der Server Action in die Tabelle Frist geschrieben, mit
* typ BEHOERDLICH.
*/
export type Fristvorschlag = {
titel: string
beschreibung: string
datum: Date
rechtsgrundlage: string
vorwarnungTage: number[]
}
export function widerspruchsfristVorschlag(
bezeichnung: string,
ergebnis: RechtsbehelfErgebnis,
): Fristvorschlag | null {
if (!ergebnis.berechenbar || !ergebnis.widerspruchMoeglichBis) return null
return {
titel: `Widerspruch möglich bis — ${bezeichnung}`,
beschreibung:
ergebnis.schritte.map((s) => `${s.titel}: ${s.text} (${s.rechtsgrundlage})`).join('\n') +
'\n\nBerechnet vom Betreuungs-Cockpit. Keine Rechtsberatung — im Zweifel beim Bezirk ' +
'oder bei einem Anwalt nachfragen.',
datum: ergebnis.widerspruchMoeglichBis,
rechtsgrundlage: ergebnis.fristMonate === 12 ? '§ 66 Abs. 2 SGG' : '§ 84 Abs. 1 SGG',
vorwarnungTage: WIDERSPRUCH_VORWARNUNG_TAGE,
}
}
/**
* Ampel für die Anzeige. Farbe ist nie die einzige Information - der
* zurückgegebene Text steht immer daneben.
*/
export function fristAmpel(
ergebnis: RechtsbehelfErgebnis,
): { variante: 'rot' | 'orange' | 'gelb' | 'gruen' | 'secondary'; text: string } {
if (!ergebnis.berechenbar) {
return { variante: 'secondary', text: 'Zugangsdatum fehlt' }
}
const tage = ergebnis.tageVerbleibend ?? 0
if (tage < 0) return { variante: 'rot', text: `Frist seit ${Math.abs(tage)} Tagen abgelaufen` }
if (tage === 0) return { variante: 'rot', text: 'Frist läuft heute ab' }
if (tage <= 7) return { variante: 'orange', text: `noch ${tage} Tage` }
if (tage <= 30) return { variante: 'gelb', text: `noch ${tage} Tage` }
return { variante: 'gruen', text: `noch ${tage} Tage` }
}
+320
View File
@@ -0,0 +1,320 @@
import type { SozialhilfeBewertung, VermoegensArt, VertragArt } from '@prisma/client'
import {
SCHENKUNG_RUECKFORDERUNG_JAHRE,
VERMOEGENSFREIBETRAG_CENT,
VERMOEGENSFREIBETRAG_JE_UNTERHALTENE_PERSON_CENT,
} from '@/lib/sozialhilfe/konstanten'
/**
* Argumentationshilfen zum Schonvermögen - ausdrücklich KEINE Automatik.
*
* Diese Datei ordnet nichts ein. Sie liefert Textbausteine, mit denen sich
* die eigene Einschätzung begründen lässt, und benennt die Fundstelle
* dazu. Ob ein Posten geschützt ist, entscheidet der Träger; bleibt man
* uneins, entscheidet das Sozialgericht. Jeder Baustein sagt das auch.
*
* Warum überhaupt Textbausteine? Weil das Begründungsfeld sonst leer
* bleibt. Eine Bewertung ohne Begründung ist im Widerspruchsverfahren
* wertlos - und ein halbes Jahr später weiß man selbst nicht mehr, warum
* man den Posten für geschützt hielt.
*/
export const BEWERTUNG_BESCHRIFTUNG: Record<SozialhilfeBewertung, string> = {
UNGEPRUEFT: 'Ungeprüft',
VERWERTBAR: 'Verwertbar',
GESCHUETZT: 'Geschützt',
STRITTIG: 'Strittig',
}
export const BEWERTUNG_ERKLAERUNG: Record<SozialhilfeBewertung, string> = {
UNGEPRUEFT: 'Noch nicht angesehen. Vor dem Antrag sollte hier nichts mehr stehen.',
VERWERTBAR:
'Nach eigener Einschätzung einzusetzen — der Träger wird die Verwertung erwarten, ' +
'bevor er zahlt (§ 90 Abs. 1 SGB XII).',
GESCHUETZT:
'Nach eigener Einschätzung Schonvermögen. Die Begründung gehört daneben, sonst hilft ' +
'die Einordnung im Verfahren nicht weiter.',
STRITTIG:
'Die eigene Einschätzung weicht von der des Trägers ab oder die Rechtslage ist offen. ' +
'Hier lohnt der Blick in den Bescheid und gegebenenfalls der Widerspruch.',
}
export const BEWERTUNG_VARIANTE: Record<
SozialhilfeBewertung,
'rot' | 'orange' | 'gelb' | 'gruen' | 'secondary'
> = {
UNGEPRUEFT: 'secondary',
VERWERTBAR: 'rot',
GESCHUETZT: 'gruen',
STRITTIG: 'orange',
}
export const ALLE_BEWERTUNGEN: readonly SozialhilfeBewertung[] = [
'UNGEPRUEFT',
'VERWERTBAR',
'GESCHUETZT',
'STRITTIG',
] as const
/** Der Satz, der unter jedem Textbaustein steht. Ohne Ausnahme. */
export const VORBEHALT =
'Diese Einordnung ist meine Einschätzung als Betreuer und keine Rechtsauskunft. ' +
'Über die Anrechnung entscheidet der Sozialhilfeträger, im Streitfall das Sozialgericht.'
export type Argumentationshilfe = {
schluessel: string
titel: string
rechtsgrundlage: string
/** Ein Satz: worum geht es? */
kurz: string
/** Was man wissen muss, bevor man den Baustein benutzt. */
worauf_achten: string
/** Fertiger Text zum Übernehmen ins Begründungsfeld. */
textbaustein: string
/** Wozu die Einordnung tendiert - nur ein Vorschlag, nie gesetzt. */
vorschlag: SozialhilfeBewertung
/** Für welche Vermögensarten wird die Hilfe angeboten? Leer = alle. */
vermoegensarten: readonly VermoegensArt[]
/** Für welche Vertragsarten? Leer = keine besondere Zuordnung. */
vertragsarten: readonly VertragArt[]
}
function euro(cent: bigint): string {
return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(
Number(cent) / 100,
)
}
export const ARGUMENTATIONSHILFEN: readonly Argumentationshilfe[] = [
{
schluessel: 'vermoegensfreibetrag',
titel: 'Vermögensfreibetrag (kleinere Barbeträge)',
rechtsgrundlage:
'§ 90 Abs. 2 Nr. 9 SGB XII i.V.m. § 1 der Verordnung zur Durchführung des § 90 Abs. 2 Nr. 9 SGB XII',
kurz: `Ein Schonbetrag von ${euro(VERMOEGENSFREIBETRAG_CENT)} bleibt in jedem Fall anrechnungsfrei.`,
worauf_achten:
`Der Freibetrag gilt für die leistungsberechtigte Person; für jede Person, die sie ` +
`überwiegend unterhält, kommen ${euro(VERMOEGENSFREIBETRAG_JE_UNTERHALTENE_PERSON_CENT)} ` +
`hinzu. Er wird auf das Gesamtvermögen angewandt, nicht auf jeden Posten einzeln — ` +
`wer ihn dreimal ansetzt, rechnet falsch.`,
textbaustein:
`Der Posten liegt innerhalb des Vermögensfreibetrags nach § 90 Abs. 2 Nr. 9 SGB XII ` +
`in Verbindung mit § 1 der zugehörigen Durchführungsverordnung ` +
`(${euro(VERMOEGENSFREIBETRAG_CENT)}). Er ist deshalb nicht einzusetzen.\n\n${VORBEHALT}`,
vorschlag: 'GESCHUETZT',
vermoegensarten: ['KONTO', 'FORDERUNG', 'SONSTIGES'],
vertragsarten: [],
},
{
schluessel: 'hausgrundstueck-selbst-bewohnt',
titel: 'Angemessenes selbst bewohntes Hausgrundstück',
rechtsgrundlage: '§ 90 Abs. 2 Nr. 8 SGB XII',
kurz:
'Ein angemessenes Hausgrundstück, das die leistungsberechtigte Person selbst bewohnt, ' +
'ist geschützt.',
worauf_achten:
'Der wunde Punkt bei dauerhaftem Heimaufenthalt: "Selbst bewohnt" setzt voraus, dass ' +
'eine Rückkehr noch ernsthaft in Betracht kommt. Steht das Haus dauerhaft leer, sieht ' +
'der Träger den Schutz regelmäßig als entfallen an. Dann geht es nicht mehr um Nr. 8, ' +
'sondern um die Härte nach § 90 Abs. 3 SGB XII und um die Leistung als Darlehen nach ' +
'§ 91 SGB XII. Die Angemessenheit richtet sich nach Personenzahl, Wohnfläche, ' +
'Grundstücksgröße, Zuschnitt und Wert.',
textbaustein:
'Das Hausgrundstück ist nach § 90 Abs. 2 Nr. 8 SGB XII geschützt. Es wird von der ' +
'leistungsberechtigten Person bewohnt bzw. eine Rückkehr ist nicht ausgeschlossen; ' +
'nach Wohnfläche, Grundstücksgröße und Wert ist es angemessen. Ergänzend wird auf die ' +
'Härteregelung des § 90 Abs. 3 SGB XII hingewiesen.\n\n' +
'[Hier ergänzen: Wohnfläche, Grundstücksgröße, Zahl der Bewohner, Verkehrswert, ' +
'Belastungen aus Abteilung II und III des Grundbuchs.]\n\n' +
VORBEHALT,
vorschlag: 'GESCHUETZT',
vermoegensarten: ['IMMOBILIE'],
vertragsarten: [],
},
{
schluessel: 'darlehen-91',
titel: 'Verwertung nur als Darlehen (Härte bei Immobilien)',
rechtsgrundlage: '§ 91 SGB XII',
kurz:
'Wäre der sofortige Verbrauch oder Verkauf eine Härte, ist die Leistung als Darlehen ' +
'zu erbringen — gesichert durch eine Grundschuld.',
worauf_achten:
'Das ist der übliche Weg, wenn das Haus nicht kurzfristig verkäuflich ist. Der Träger ' +
'zahlt laufend, lässt sich aber eine Sicherung ins Grundbuch eintragen und fordert ' +
'nach dem Verkauf oder im Erbfall zurück. Die Eintragung einer Grundschuld auf dem ' +
'Grundstück der betreuten Person ist eine Verfügung, die das Betreuungsgericht ' +
'genehmigen muss (§ 1850 BGB) — erst das Gericht fragen, dann unterschreiben.',
textbaustein:
'Eine sofortige Verwertung des Grundstücks würde eine Härte im Sinne des § 90 Abs. 3 ' +
'SGB XII bedeuten. Es wird beantragt, die Leistung nach § 91 SGB XII als Darlehen zu ' +
'erbringen. Die Bereitschaft zur dinglichen Sicherung besteht, vorbehaltlich der ' +
'Genehmigung des Betreuungsgerichts.\n\n' +
'[Hier ergänzen: warum ist kurzfristige Verwertung nicht möglich oder zumutbar — ' +
'Marktlage, Zuschnitt, Wohnrecht Dritter, laufende Verkaufsbemühungen.]\n\n' +
VORBEHALT,
vorschlag: 'STRITTIG',
vermoegensarten: ['IMMOBILIE'],
vertragsarten: [],
},
{
schluessel: 'bestattungsvorsorge',
titel: 'Bestattungsvorsorge',
rechtsgrundlage: '§ 90 Abs. 3 SGB XII (Härte), ständige Rechtsprechung des BSG',
kurz:
'Eine angemessene Bestattungsvorsorge muss nicht aufgelöst werden — die Vorsorge für ' +
'die eigene Bestattung gilt als schutzwürdiges Anliegen.',
worauf_achten:
'Zwei Dinge entscheiden: die Angemessenheit der Höhe (Orientierung sind die Kosten ' +
'einer ortsüblichen, würdigen Bestattung samt Grabstelle und Grabpflege) und die ' +
'Zweckbindung. Am stärksten ist die Position, wenn der Vertrag an ein ' +
'Bestattungsunternehmen abgetreten oder als Treuhandvertrag geführt wird, das Geld ' +
'also gar nicht mehr frei verfügbar ist. Eine bloße Sterbegeldversicherung ohne ' +
'Zweckbindung wird häufiger angegriffen.',
textbaustein:
'Die Bestattungsvorsorge ist zweckgebunden und dient der Vorsorge für die eigene ' +
'Bestattung. Ihre Verwertung wäre eine Härte im Sinne des § 90 Abs. 3 SGB XII; die ' +
'Rechtsprechung erkennt eine angemessene Bestattungsvorsorge als schutzwürdig an. ' +
'Die Höhe hält sich im Rahmen einer ortsüblichen, würdigen Bestattung.\n\n' +
'[Hier ergänzen: Vertragsnummer, Rückkaufswert, ob eine Abtretung an ein ' +
'Bestattungsunternehmen besteht, ortsübliche Bestattungskosten.]\n\n' +
VORBEHALT,
vorschlag: 'GESCHUETZT',
vermoegensarten: ['VERSICHERUNG'],
vertragsarten: ['BESTATTUNGSVORSORGE', 'STERBEGELDVERSICHERUNG', 'STERBEKASSE'],
},
{
schluessel: 'haertefall',
titel: 'Härtefall allgemein',
rechtsgrundlage: '§ 90 Abs. 3 SGB XII',
kurz:
'Vermögen darf nicht eingesetzt werden, soweit das für die betroffene Person eine ' +
'Härte bedeuten würde.',
worauf_achten:
'Die Härte muss konkret begründet werden — der bloße Verweis auf die Vorschrift ' +
'reicht nicht. Tragfähige Argumente sind: Verwertung nur weit unter Wert möglich, ' +
'die Verwertung würde die weitere Lebensführung erheblich erschweren, oder das ' +
'Vermögen dient der Alterssicherung einer Person, die es sich mühsam erspart hat. ' +
'Bei Menschen mit Behinderung oder Pflegebedürftigen nennt das Gesetz die Härte ' +
'ausdrücklich (§ 90 Abs. 3 Satz 2 SGB XII).',
textbaustein:
'Der Einsatz dieses Vermögens würde für die leistungsberechtigte Person eine Härte im ' +
'Sinne des § 90 Abs. 3 SGB XII bedeuten.\n\n' +
'[Hier ergänzen: worin genau die Härte liegt — Verwertung nur weit unter Wert, ' +
'erhebliche Erschwerung der Lebensführung, Zweckbindung, Pflegebedürftigkeit.]\n\n' +
VORBEHALT,
vorschlag: 'STRITTIG',
vermoegensarten: [],
vertragsarten: [],
},
{
schluessel: 'rueckkaufswert-lebensversicherung',
titel: 'Lebens- oder Rentenversicherung: Rückkaufswert',
rechtsgrundlage: '§ 90 Abs. 1 SGB XII',
kurz:
'Verwertbar ist der heutige Rückkaufswert, nicht die Versicherungssumme und nicht die ' +
'Summe der eingezahlten Beiträge.',
worauf_achten:
'Zwei Gegenargumente sind einen Versuch wert: ein erheblicher Verlust bei vorzeitiger ' +
'Kündigung (Härte nach § 90 Abs. 3 SGB XII) und die Umwandlung in eine ' +
'beitragsfreie Versicherung statt einer Kündigung. Achtung beim Verwertungsverbot ' +
'nach § 165 Abs. 3 VVG: Es schützt vor der Kündigung durch Gläubiger, nicht vor der ' +
'sozialhilferechtlichen Anrechnung. Die Kündigung einer Versicherung der betreuten ' +
'Person kann genehmigungspflichtig sein — vorher beim Gericht nachfragen.',
textbaustein:
'Der Rückkaufswert zum Stichtag beträgt [Betrag] Euro laut Mitteilung der Gesellschaft ' +
'vom [Datum]. Er liegt erheblich unter der Summe der eingezahlten Beiträge; eine ' +
'Kündigung wäre mit einem Verlust von [Betrag] Euro verbunden. Darin liegt eine Härte ' +
'im Sinne des § 90 Abs. 3 SGB XII. Hilfsweise wird die Umwandlung in eine ' +
'beitragsfreie Versicherung angeboten.\n\n' + VORBEHALT,
vorschlag: 'STRITTIG',
vermoegensarten: ['VERSICHERUNG'],
vertragsarten: ['LEBENSVERSICHERUNG', 'RENTENVERSICHERUNG_PRIVAT'],
},
{
schluessel: 'gefoerderte-altersvorsorge',
titel: 'Staatlich gefördertes Altersvorsorgevermögen',
rechtsgrundlage: '§ 90 Abs. 2 Nr. 2 SGB XII',
kurz:
'Nach § 10a oder Abschnitt XI EStG gefördertes Altersvorsorgevermögen (Riester) samt ' +
'Erträgen bleibt anrechnungsfrei.',
worauf_achten:
'Der Schutz reicht nur so weit, wie das Vermögen tatsächlich gefördert wurde und im ' +
'Vertrag verbleibt. Wer es sich auszahlen lässt, verliert ihn. Betriebliche ' +
'Altersvorsorge fällt nicht unter diese Nummer.',
textbaustein:
'Es handelt sich um nach § 10a bzw. Abschnitt XI EStG gefördertes ' +
'Altersvorsorgevermögen einschließlich der Erträge. Es ist nach § 90 Abs. 2 Nr. 2 ' +
'SGB XII nicht einzusetzen. Die Förderzulagen sind der beigefügten Bescheinigung des ' +
'Anbieters zu entnehmen.\n\n' + VORBEHALT,
vorschlag: 'GESCHUETZT',
vermoegensarten: ['VERSICHERUNG', 'KONTO'],
vertragsarten: ['RENTENVERSICHERUNG_PRIVAT'],
},
{
schluessel: 'hausrat',
titel: 'Angemessener Hausrat',
rechtsgrundlage: '§ 90 Abs. 2 Nr. 4 SGB XII',
kurz: 'Angemessener Hausrat ist geschützt — auch bei einem Umzug ins Heim.',
worauf_achten:
'Bei der Auflösung der früheren Wohnung wird das praktisch: Der Träger verlangt keine ' +
'Verwertung des üblichen Hausrats. Anders bei einzelnen Stücken von erheblichem Wert ' +
'— Schmuck, Kunst, Sammlungen. Familien- und Erbstücke können daneben nach ' +
'§ 90 Abs. 2 Nr. 6 SGB XII geschützt sein, wenn ihre Veräußerung eine besondere ' +
'Härte wäre.',
textbaustein:
'Es handelt sich um angemessenen Hausrat im Sinne des § 90 Abs. 2 Nr. 4 SGB XII. ' +
'Angemessenheit richtet sich nach den bisherigen Lebensverhältnissen; Gegenstände von ' +
'erheblichem Verkehrswert sind nicht enthalten.\n\n' + VORBEHALT,
vorschlag: 'GESCHUETZT',
vermoegensarten: ['WERTGEGENSTAND', 'SONSTIGES'],
vertragsarten: [],
},
{
schluessel: 'schenkungsrueckforderung',
titel: 'Frühere Übertragung / Rückforderung wegen Verarmung',
rechtsgrundlage: '§ 528 BGB, § 529 Abs. 1 BGB, § 93 SGB XII',
kurz:
`Der Träger prüft Schenkungen der letzten ${SCHENKUNG_RUECKFORDERUNG_JAHRE} Jahre und ` +
`leitet einen Rückforderungsanspruch auf sich über.`,
worauf_achten:
`Die Zehnjahresfrist läuft ab der Leistung des geschenkten Gegenstands — bei ` +
`Grundstücken ab der Eintragung im Grundbuch, nicht ab dem Notartermin. Sie ist ` +
`ausgeschlossen, wenn beim Eintritt der Bedürftigkeit zehn Jahre verstrichen sind ` +
`(§ 529 Abs. 1 BGB). Gegenleistungen wie Wohnrecht, Nießbrauch oder übernommene ` +
`Pflege mindern den Schenkungsanteil erheblich — sie gehören beziffert in die ` +
`Begründung. Der Anspruch richtet sich gegen die Beschenkten und ist auf den Bedarf ` +
`begrenzt; er endet mit dem Tod der schenkenden Person.`,
textbaustein:
`Die Übertragung erfolgte mit notariellem Vertrag vom [Datum], Eintragung im Grundbuch ` +
`am [Datum]. Der Vertrag enthält folgende Gegenleistungen: [Wohnrecht / Nießbrauch / ` +
`Pflegeverpflichtung / Gleichstellungsgeld], die den unentgeltlichen Anteil mindern. ` +
`Seit der Leistung des Gegenstands sind [Anzahl] Jahre verstrichen.\n\n` +
`Eine Rückforderung nach § 528 BGB wird geprüft; die Bewertung obliegt dem Träger.\n\n` +
VORBEHALT,
vorschlag: 'STRITTIG',
vermoegensarten: ['IMMOBILIE', 'FORDERUNG'],
vertragsarten: [],
},
] as const
export function hilfeZuSchluessel(schluessel: string): Argumentationshilfe | undefined {
return ARGUMENTATIONSHILFEN.find((h) => h.schluessel === schluessel)
}
/**
* Welche Hilfen passen zu einem Vermögensposten? Hilfen ohne Zuordnung
* (z.B. der Härtefall) passen immer.
*/
export function hilfenFuerVermoegensart(art: VermoegensArt): Argumentationshilfe[] {
return ARGUMENTATIONSHILFEN.filter(
(h) => h.vermoegensarten.length === 0 || h.vermoegensarten.includes(art),
)
}
export function hilfenFuerVertragsart(art: VertragArt): Argumentationshilfe[] {
return ARGUMENTATIONSHILFEN.filter(
(h) =>
(h.vertragsarten.length === 0 && h.vermoegensarten.length === 0) ||
h.vertragsarten.includes(art),
)
}
+148
View File
@@ -0,0 +1,148 @@
import type { SozialhilfeBewertung } from '@prisma/client'
import {
VERMOEGENSFREIBETRAG_CENT,
VERMOEGENSFREIBETRAG_JE_UNTERHALTENE_PERSON_CENT,
} from '@/lib/sozialhilfe/konstanten'
import { ALLE_BEWERTUNGEN } from '@/lib/sozialhilfe/schonvermoegen'
/**
* Zusammenzählen der Vermögensbewertung.
*
* Vermögensposten und Verträge werden gemeinsam betrachtet - für den
* Träger ist es eine Vermögensmasse, auch wenn die Anwendung sie in zwei
* Tabellen führt. Beträge bleiben durchgehend BigInt in Cent.
*/
export type Bewertungsposten = {
id: string
bezeichnung: string
/** Aus welcher Tabelle stammt der Posten? */
herkunft: 'VERMOEGEN' | 'VERTRAG'
/** Menschenlesbare Art, z.B. "Lebensversicherung". */
artBeschriftung: string
bewertung: SozialhilfeBewertung
/** Aktueller Wert bzw. Rückkaufswert. null = noch nicht ermittelt. */
wertCent: bigint | null
/** Stichtag des Werts, für die Beurteilung der Aktualität. */
wertStichtag: Date | null
begruendung: string | null
/** Ziel für den Bearbeiten-Link. */
href: string
}
export type BewertungsSumme = {
anzahl: number
summeCent: bigint
/** Posten ohne hinterlegten Wert - die Summe ist dann unvollständig. */
ohneWert: number
}
export type Vermoegensuebersicht = {
jeBewertung: Record<SozialhilfeBewertung, BewertungsSumme>
gesamtCent: bigint
anzahlGesamt: number
/** Posten ohne Wertangabe über alle Bewertungen. */
ohneWertGesamt: number
/** Posten, bei denen die Begründung fehlt, obwohl sie gebraucht wird. */
ohneBegruendung: Bewertungsposten[]
/** Noch gar nicht angesehene Posten. */
ungeprueft: Bewertungsposten[]
}
const leereSumme = (): BewertungsSumme => ({ anzahl: 0, summeCent: 0n, ohneWert: 0 })
export function uebersichtBilden(posten: readonly Bewertungsposten[]): Vermoegensuebersicht {
const jeBewertung = {
UNGEPRUEFT: leereSumme(),
VERWERTBAR: leereSumme(),
GESCHUETZT: leereSumme(),
STRITTIG: leereSumme(),
} satisfies Record<SozialhilfeBewertung, BewertungsSumme>
let gesamtCent = 0n
let ohneWertGesamt = 0
const ohneBegruendung: Bewertungsposten[] = []
const ungeprueft: Bewertungsposten[] = []
for (const p of posten) {
const eintrag = jeBewertung[p.bewertung]
eintrag.anzahl += 1
if (p.wertCent === null) {
eintrag.ohneWert += 1
ohneWertGesamt += 1
} else {
eintrag.summeCent += p.wertCent
gesamtCent += p.wertCent
}
if (p.bewertung === 'UNGEPRUEFT') {
ungeprueft.push(p)
} else if (!p.begruendung || p.begruendung.trim() === '') {
// Verwertbar, geschützt oder strittig ohne Begründung: im
// Widerspruchsverfahren wertlos.
ohneBegruendung.push(p)
}
}
return {
jeBewertung,
gesamtCent,
anzahlGesamt: posten.length,
ohneWertGesamt,
ohneBegruendung,
ungeprueft,
}
}
/** Filter für die Übersichtsseite. */
export function nachBewertungFiltern(
posten: readonly Bewertungsposten[],
bewertung: SozialhilfeBewertung | 'ALLE',
): Bewertungsposten[] {
if (bewertung === 'ALLE') return [...posten]
return posten.filter((p) => p.bewertung === bewertung)
}
export function istBewertung(wert: string): wert is SozialhilfeBewertung {
return (ALLE_BEWERTUNGEN as readonly string[]).includes(wert)
}
export type Freibetragsvergleich = {
freibetragCent: bigint
verwertbarCent: bigint
/** Was über dem Freibetrag liegt und damit einzusetzen wäre. */
ueberFreibetragCent: bigint
ausgeschoepft: boolean
hinweis: string
}
/**
* Stellt das als verwertbar eingeschätzte Vermögen dem Freibetrag
* gegenüber. Auch das ist nur eine Rechnung, keine Entscheidung: Was
* verwertbar ist, hat der Träger noch nicht bestätigt.
*/
export function freibetragVergleichen(
verwertbarCent: bigint,
unterhalteneNahestehende = 0,
): Freibetragsvergleich {
const freibetragCent =
VERMOEGENSFREIBETRAG_CENT +
VERMOEGENSFREIBETRAG_JE_UNTERHALTENE_PERSON_CENT * BigInt(unterhalteneNahestehende)
const ueber = verwertbarCent > freibetragCent ? verwertbarCent - freibetragCent : 0n
return {
freibetragCent,
verwertbarCent,
ueberFreibetragCent: ueber,
ausgeschoepft: ueber > 0n,
hinweis:
ueber > 0n
? 'Das als verwertbar eingeschätzte Vermögen übersteigt den Freibetrag. Der Träger ' +
'wird den übersteigenden Teil einsetzen wollen, bevor er zahlt (§ 90 Abs. 1 SGB XII). ' +
'Prüfe, ob für einzelne Posten eine Härte nach § 90 Abs. 3 SGB XII in Betracht kommt.'
: 'Das als verwertbar eingeschätzte Vermögen bleibt im Rahmen des Freibetrags nach ' +
'§ 90 Abs. 2 Nr. 9 SGB XII. Das ist die eigene Einschätzung — der Träger prüft ' +
'eigenständig.',
}
}
+141
View File
@@ -0,0 +1,141 @@
import 'server-only'
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'
import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
import { dirname, join, resolve, sep } from 'node:path'
import { env } from '@/lib/env'
import {
dateiRoutePfad,
schluesselErfordern,
zugriffTokenErzeugen,
TOKEN_STANDARD_SEKUNDEN,
} from '@/lib/speicher/schluessel'
/**
* Treiber "datei": Ablage in einem Verzeichnis auf dem Server.
*
* Standard in der Entwicklung und fuer kleine Installationen, bei denen
* kein Objektspeicher gewuenscht ist - die Dateien liegen dann auf der
* verschluesselten Platte des eigenen Servers und verlassen sie nie.
*
* Zusaetzlich wird jede Datei anwendungsseitig mit AES-256-GCM
* verschluesselt. Grund ist derselbe wie bei der Feldverschluesselung: ein
* kopiertes Backup oder ein versehentlich falsch gerechtetes Verzeichnis
* gibt sonst Bescheide, Arztbriefe und Kontoauszuege im Klartext preis.
* Der Schluessel liegt in der Umgebung, nicht neben den Daten.
*
* Umschlag (binaer):
* "BCD1" | Schluesselversion (uint32 BE) | IV (12) | AuthTag (16) | Geheimtext
*/
const MAGIE = Buffer.from('BCD1', 'ascii')
const IV_LAENGE = 12
const TAG_LAENGE = 16
const KOPF_LAENGE = MAGIE.length + 4 + IV_LAENGE + TAG_LAENGE
function wurzel(): string {
return resolve(process.cwd(), env.dateiSpeicherPfad)
}
/**
* Baut den Dateipfad - und prueft danach noch einmal, dass er wirklich
* unterhalb des Ablageverzeichnisses liegt. Der Schluessel ist zwar bereits
* streng geprueft, aber diese zweite Sperre kostet nichts und faengt auch
* Symlink-Tricks und Windows-Eigenheiten ab.
*/
function pfadFuer(schluessel: string): string {
const geprueft = schluesselErfordern(schluessel)
const basis = wurzel()
const ziel = resolve(basis, ...geprueft.split('/'))
if (ziel !== basis && !ziel.startsWith(basis + sep)) {
throw new Error('Der Ablagepfad läge außerhalb des Dokumentenverzeichnisses.')
}
return ziel
}
function verschluesselnBinaer(inhalt: Buffer): Buffer {
const version = env.aktiveSchluesselversion
const schluessel = env.verschluesselungsschluessel.get(version)
if (!schluessel) throw new Error(`Verschlüsselungsschlüssel Version ${version} fehlt.`)
const iv = randomBytes(IV_LAENGE)
const cipher = createCipheriv('aes-256-gcm', schluessel, iv, { authTagLength: TAG_LAENGE })
cipher.setAAD(Buffer.from(`bcd:${version}`, 'utf8'))
const geheim = Buffer.concat([cipher.update(inhalt), cipher.final()])
const versionBytes = Buffer.alloc(4)
versionBytes.writeUInt32BE(version, 0)
return Buffer.concat([MAGIE, versionBytes, iv, cipher.getAuthTag(), geheim])
}
function entschluesselnBinaer(roh: Buffer): Buffer {
// Dateien ohne Umschlag (von Hand hineingelegt, aus einer aelteren
// Installation uebernommen) werden unveraendert durchgereicht.
if (roh.length < KOPF_LAENGE || !roh.subarray(0, MAGIE.length).equals(MAGIE)) return roh
const version = roh.readUInt32BE(MAGIE.length)
const schluessel = env.verschluesselungsschluessel.get(version)
if (!schluessel) {
throw new Error(
`Zum Entschlüsseln dieser Datei fehlt ENC_KEY_V${version}. Alte Schlüssel dürfen beim ` +
`Rotieren nicht aus der Umgebung entfernt werden.`,
)
}
const ivVon = MAGIE.length + 4
const iv = roh.subarray(ivVon, ivVon + IV_LAENGE)
const tag = roh.subarray(ivVon + IV_LAENGE, ivVon + IV_LAENGE + TAG_LAENGE)
const geheim = roh.subarray(KOPF_LAENGE)
const decipher = createDecipheriv('aes-256-gcm', schluessel, iv, { authTagLength: TAG_LAENGE })
decipher.setAAD(Buffer.from(`bcd:${version}`, 'utf8'))
decipher.setAuthTag(tag)
return Buffer.concat([decipher.update(geheim), decipher.final()])
}
export async function schreiben(schluessel: string, inhalt: Buffer): Promise<void> {
const pfad = pfadFuer(schluessel)
await mkdir(dirname(pfad), { recursive: true })
await writeFile(pfad, verschluesselnBinaer(inhalt), { mode: 0o600 })
}
export async function lesen(schluessel: string): Promise<Buffer> {
const roh = await readFile(pfadFuer(schluessel))
return entschluesselnBinaer(roh)
}
export async function entfernen(schluessel: string): Promise<void> {
await rm(pfadFuer(schluessel), { force: true })
}
export async function existiert(schluessel: string): Promise<boolean> {
try {
const angaben = await stat(pfadFuer(schluessel))
return angaben.isFile()
} catch {
return false
}
}
/**
* Zeigt auf die eigene, ebenfalls signierte Route. Diese Route prueft
* zusaetzlich die Berechtigung des angemeldeten Benutzers und schreibt
* einen Audit-Eintrag - ein Link allein oeffnet also nichts.
*/
export async function signierteUrl(
schluessel: string,
sekunden: number = TOKEN_STANDARD_SEKUNDEN,
): Promise<string> {
return dateiRoutePfad(zugriffTokenErzeugen(schluessel, sekunden))
}
/** Nur fuer Wartungsskripte: das Ablageverzeichnis. */
export function ablageverzeichnis(): string {
return wurzel()
}
/** Sorgt dafuer, dass das Verzeichnis existiert (Start, Tests, Skripte). */
export async function verzeichnisAnlegen(): Promise<void> {
await mkdir(join(wurzel(), 'betreute'), { recursive: true })
}
+172
View File
@@ -0,0 +1,172 @@
/**
* Dateityp-Erkennung an den Magic Bytes.
*
* Warum nicht am gemeldeten MIME-Typ oder an der Endung? Weil beides vom
* Browser kommt und damit vom Angreifer bestimmt wird. Eine Datei, die
* "rechnung.pdf" heisst und `application/pdf` meldet, kann eine HTML-Seite
* mit Skript sein. Liefert der Server sie spaeter aus, laeuft das Skript im
* Kontext dieser Anwendung - und diese Anwendung sieht Gesundheits- und
* Finanzdaten einer Person, die sich nicht wehren kann.
*
* Deshalb: Der Inhalt entscheidet. Gemeldeter Typ und Endung sind Indizien,
* mehr nicht. Bewusst ohne zusaetzliches Paket - die Signaturen der sechs
* erlaubten Formate sind kurz und aendern sich nicht.
*
* Rein und ohne Seiteneffekte, damit es sich testen laesst.
*/
export type ErlaubterMimeTyp =
| 'application/pdf'
| 'image/jpeg'
| 'image/png'
| 'image/webp'
| 'image/tiff'
| 'image/heic'
export type Dateityp = {
mimeTyp: ErlaubterMimeTyp
/** Endung ohne Punkt, kleingeschrieben. Wird an den Schluessel gehaengt. */
endung: string
bezeichnung: string
/** Kann Tesseract das direkt lesen, oder muss vorher gewandelt werden? */
istBild: boolean
}
export const ERLAUBTE_TYPEN: readonly Dateityp[] = [
{ mimeTyp: 'application/pdf', endung: 'pdf', bezeichnung: 'PDF', istBild: false },
{ mimeTyp: 'image/jpeg', endung: 'jpg', bezeichnung: 'JPEG-Bild', istBild: true },
{ mimeTyp: 'image/png', endung: 'png', bezeichnung: 'PNG-Bild', istBild: true },
{ mimeTyp: 'image/webp', endung: 'webp', bezeichnung: 'WEBP-Bild', istBild: true },
{ mimeTyp: 'image/tiff', endung: 'tif', bezeichnung: 'TIFF-Bild', istBild: true },
{ mimeTyp: 'image/heic', endung: 'heic', bezeichnung: 'HEIC-Foto', istBild: true },
]
/** Fuer Oberflaechentexte und das accept-Attribut im Datei-Auswahlfeld. */
export const ERLAUBTE_ENDUNGEN = ERLAUBTE_TYPEN.map((t) => `.${t.endung}`).concat(['.jpeg', '.tiff'])
export const ACCEPT_ATTRIBUT = [
'application/pdf',
'image/jpeg',
'image/png',
'image/webp',
'image/tiff',
'image/heic',
...ERLAUBTE_ENDUNGEN,
].join(',')
function typ(mimeTyp: ErlaubterMimeTyp): Dateityp {
const treffer = ERLAUBTE_TYPEN.find((t) => t.mimeTyp === mimeTyp)
if (!treffer) throw new Error(`Unbekannter Typ ${mimeTyp}`)
return treffer
}
function beginntMit(inhalt: Buffer, bytes: readonly number[], versatz = 0): boolean {
if (inhalt.length < versatz + bytes.length) return false
for (let i = 0; i < bytes.length; i++) {
if (inhalt[versatz + i] !== bytes[i]) return false
}
return true
}
function textAn(inhalt: Buffer, von: number, bis: number): string {
if (inhalt.length < bis) return ''
return inhalt.subarray(von, bis).toString('latin1')
}
/**
* HEIC/HEIF steckt in einem ISO-BMFF-Container: vier Byte Boxlaenge, dann
* "ftyp", dann die Marke. Nur die Bildmarken sind erlaubt - "mp42" waere
* ein Video und hat hier nichts verloren.
*/
const HEIC_MARKEN = new Set([
'heic',
'heix',
'heim',
'heis',
'hevc',
'hevx',
'hevm',
'hevs',
'mif1',
'msf1',
])
/**
* Erkennt den Typ am Inhalt. Gibt null zurueck, wenn es keiner der sechs
* erlaubten Typen ist - im Zweifel abgelehnt, nie "wird schon passen".
*/
export function typErkennen(inhalt: Buffer): Dateityp | null {
if (inhalt.length < 12) return null
// PDF: "%PDF-" muss ganz am Anfang stehen. Toleranz waere hier gefaehrlich:
// Dateien, die erst nach ein paar Bytes mit %PDF beginnen, sind der
// klassische Polyglot-Trick (HTML davor, PDF dahinter).
if (beginntMit(inhalt, [0x25, 0x50, 0x44, 0x46, 0x2d])) return typ('application/pdf')
// JPEG: FF D8 FF
if (beginntMit(inhalt, [0xff, 0xd8, 0xff])) return typ('image/jpeg')
// PNG: 89 "PNG" CR LF 1A LF
if (beginntMit(inhalt, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return typ('image/png')
// WEBP: "RIFF" ....(Groesse).... "WEBP"
if (textAn(inhalt, 0, 4) === 'RIFF' && textAn(inhalt, 8, 12) === 'WEBP') return typ('image/webp')
// TIFF: "II*\0" (little endian) oder "MM\0*" (big endian)
if (beginntMit(inhalt, [0x49, 0x49, 0x2a, 0x00])) return typ('image/tiff')
if (beginntMit(inhalt, [0x4d, 0x4d, 0x00, 0x2a])) return typ('image/tiff')
// HEIC: ftyp-Box mit Bildmarke
if (textAn(inhalt, 4, 8) === 'ftyp' && HEIC_MARKEN.has(textAn(inhalt, 8, 12))) {
return typ('image/heic')
}
return null
}
/**
* Beschreibt, was stattdessen erkannt wurde - damit die Fehlermeldung nicht
* bei "Dateityp nicht erlaubt" stehen bleibt, sondern sagt, was los ist.
* Nur fuer Meldungen, nie als Grundlage einer Entscheidung.
*/
export function inhaltBeschreiben(inhalt: Buffer): string {
const anfang = inhalt.subarray(0, 512).toString('latin1').trimStart().toLowerCase()
if (anfang.startsWith('<!doctype html') || anfang.startsWith('<html') || anfang.includes('<script'))
return 'eine HTML-Seite'
if (anfang.startsWith('<?xml') && anfang.includes('<svg')) return 'eine SVG-Grafik'
if (anfang.startsWith('<svg')) return 'eine SVG-Grafik'
if (anfang.startsWith('<?xml')) return 'eine XML-Datei'
if (anfang.startsWith('{') || anfang.startsWith('[')) return 'eine JSON-Datei'
if (beginntMit(inhalt, [0x50, 0x4b, 0x03, 0x04])) return 'ein ZIP-Archiv (auch DOCX/XLSX/ODT)'
if (beginntMit(inhalt, [0xd0, 0xcf, 0x11, 0xe0])) return 'ein altes Office-Dokument (DOC/XLS)'
if (beginntMit(inhalt, [0x4d, 0x5a])) return 'ein Windows-Programm'
if (beginntMit(inhalt, [0x7f, 0x45, 0x4c, 0x46])) return 'ein Linux-Programm'
if (beginntMit(inhalt, [0x1f, 0x8b])) return 'ein GZIP-Archiv'
if (beginntMit(inhalt, [0x47, 0x49, 0x46, 0x38])) return 'ein GIF-Bild'
if (inhalt.length === 0) return 'eine leere Datei'
return 'ein unbekanntes Format'
}
/**
* Passt die vom Browser gemeldete Endung zum erkannten Inhalt? Nur ein
* Hinweis fuer die Oberflaeche ("die Datei heisst .pdf, ist aber ein Foto"),
* kein Ablehnungsgrund - abgelehnt wird nur, was inhaltlich nicht passt.
*/
export function endungPasst(dateiname: string, erkannt: Dateityp): boolean {
const punkt = dateiname.lastIndexOf('.')
if (punkt < 0) return false
const endung = dateiname.slice(punkt + 1).toLowerCase()
if (endung === erkannt.endung) return true
if (erkannt.endung === 'jpg' && endung === 'jpeg') return true
if (erkannt.endung === 'tif' && endung === 'tiff') return true
return false
}
/** Menschenlesbare Groesse fuer Meldungen und Listen. */
export function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
+203
View File
@@ -0,0 +1,203 @@
import 'server-only'
import { createHash } from 'node:crypto'
import { env } from '@/lib/env'
import {
ERLAUBTE_TYPEN,
formatBytes,
inhaltBeschreiben,
typErkennen,
type Dateityp,
} from '@/lib/speicher/dateityp'
import {
betreuteIdAusSchluessel,
schluesselErfordern,
schluesselErzeugen,
schluesselGueltig,
TOKEN_STANDARD_SEKUNDEN,
type Bereich,
} from '@/lib/speicher/schluessel'
import * as dateiTreiber from '@/lib/speicher/datei'
/**
* Dateiablage - die Fassade, hinter der die beiden Treiber stecken.
*
* Vertrag: docs/SCHNITTSTELLEN.md, Abschnitt 1. Andere Module (Berichte,
* Exporte, Belegmappen) rufen ausschliesslich diese Funktionen auf.
*
* Die drei Zusagen, auf die sich alles verlaesst:
* - Der Schluessel wird erzeugt, nie vom Nutzer bestimmt.
* - Der Typ wird am Inhalt geprueft, nicht am gemeldeten MIME-Typ.
* - Jede Auslieferung ist ein Anhang, niemals HTML.
*/
export type Ablageziel = {
/** Schluessel im Speicher, z.B. "betreute/<id>/dokumente/<uuid>.pdf" */
schluessel: string
groesseBytes: number
/** SHA-256 hex - Nachweis der Unveraendertheit gegenueber Gericht. */
pruefsumme: string
}
/** Zusatzangaben, die der Aufrufer nach dem Ablegen meist braucht. */
export type AblageErgebnis = Ablageziel & {
/** Der tatsaechlich erkannte Typ - nicht der gemeldete. */
typ: Dateityp
/** Bereinigter Originalname, nur zur Anzeige. */
dateiname: string
}
export class DateiAbgelehnt extends Error {
readonly grund: 'zu_gross' | 'leer' | 'typ'
constructor(grund: 'zu_gross' | 'leer' | 'typ', nachricht: string) {
super(nachricht)
this.name = 'DateiAbgelehnt'
this.grund = grund
}
}
function treiber() {
return env.speicherTreiber
}
/**
* Der Anzeigename. Er landet nur in der Datenbank, nie im Pfad - trotzdem
* werden Pfadanteile und Steuerzeichen entfernt, damit er sich gefahrlos
* in einem Content-Disposition-Kopf und in der Oberflaeche darstellen laesst.
*/
export function dateinamenBereinigen(roh: string): string {
const ohnePfad = roh.split(/[\\/]/).pop() ?? roh
const sauber = ohnePfad
// eslint-disable-next-line no-control-regex
.replace(/[\u0000-\u001f\u007f"\\]/g, '')
.replace(/\s+/g, ' ')
.trim()
return sauber.slice(0, 180) || 'dokument'
}
/** SHA-256 hex ueber den Klartextinhalt - der Nachweis fuer das Gericht. */
export function pruefsummeBilden(inhalt: Buffer): string {
return createHash('sha256').update(inhalt).digest('hex')
}
/**
* Prueft Groesse und Typ, ohne etwas abzulegen. Wird auch von der
* Sammel-Upload-Vorschau benutzt, damit der Nutzer die Ablehnung sieht,
* bevor zehn Fotos hochgeladen sind.
*/
export function dateiPruefen(inhalt: Buffer, dateiname: string): Dateityp {
if (inhalt.length === 0) {
throw new DateiAbgelehnt('leer', `„${dateiname}“ ist leer und wurde nicht übernommen.`)
}
if (inhalt.length > env.uploadMaxBytes) {
throw new DateiAbgelehnt(
'zu_gross',
`„${dateiname}“ ist ${formatBytes(inhalt.length)} groß. Erlaubt sind höchstens ` +
`${formatBytes(env.uploadMaxBytes)}. Fotografiere das Blatt mit weniger Auflösung ` +
`oder lade das PDF seitenweise hoch.`,
)
}
const erkannt = typErkennen(inhalt)
if (!erkannt) {
throw new DateiAbgelehnt(
'typ',
`„${dateiname}“ ist ${inhaltBeschreiben(inhalt)}, kein Dokument. Erlaubt sind ` +
`${ERLAUBTE_TYPEN.map((t) => t.bezeichnung).join(', ')}.`,
)
}
return erkannt
}
/**
* Legt eine Datei ab. Der Schluessel wird erzeugt, nie vom Nutzer bestimmt.
*/
export async function ablegen(
betreuteId: string,
bereich: Bereich,
dateiname: string,
inhalt: Buffer,
mimeTyp: string,
): Promise<Ablageziel> {
return ablegenMitTyp(betreuteId, bereich, dateiname, inhalt, mimeTyp)
}
/** Wie ablegen(), gibt aber zusaetzlich den erkannten Typ zurueck. */
export async function ablegenMitTyp(
betreuteId: string,
bereich: Bereich,
dateiname: string,
inhalt: Buffer,
_gemeldeterMimeTyp: string,
): Promise<AblageErgebnis> {
// Der gemeldete MIME-Typ wird bewusst ignoriert: er kommt vom Browser.
const typ = dateiPruefen(inhalt, dateiname)
const schluessel = schluesselErzeugen(betreuteId, bereich, typ.endung)
if (treiber() === 's3') {
const s3 = await import('@/lib/speicher/s3')
await s3.schreiben(schluessel, inhalt, typ.mimeTyp)
} else {
await dateiTreiber.schreiben(schluessel, inhalt)
}
return {
schluessel,
groesseBytes: inhalt.length,
pruefsumme: pruefsummeBilden(inhalt),
typ,
dateiname: dateinamenBereinigen(dateiname),
}
}
/** Holt eine Datei zurueck in den Speicher des Servers. */
export async function lesen(schluessel: string): Promise<Buffer> {
schluesselErfordern(schluessel)
if (treiber() === 's3') {
const s3 = await import('@/lib/speicher/s3')
return s3.lesen(schluessel)
}
return dateiTreiber.lesen(schluessel)
}
/**
* Kurzlebige, signierte URL. Gueltigkeit in Sekunden, Standard 300.
* Beim Dateitreiber zeigt sie auf eine eigene, ebenfalls signierte Route.
*/
export async function signierteUrl(
schluessel: string,
sekunden: number = TOKEN_STANDARD_SEKUNDEN,
): Promise<string> {
schluesselErfordern(schluessel)
if (treiber() === 's3') {
const s3 = await import('@/lib/speicher/s3')
return s3.signierteUrl(schluessel, sekunden)
}
return dateiTreiber.signierteUrl(schluessel, sekunden)
}
export async function entfernen(schluessel: string): Promise<void> {
schluesselErfordern(schluessel)
if (treiber() === 's3') {
const s3 = await import('@/lib/speicher/s3')
return s3.entfernen(schluessel)
}
return dateiTreiber.entfernen(schluessel)
}
export async function existiert(schluessel: string): Promise<boolean> {
if (!schluesselGueltig(schluessel)) return false
if (treiber() === 's3') {
const s3 = await import('@/lib/speicher/s3')
return s3.existiert(schluessel)
}
return dateiTreiber.existiert(schluessel)
}
export {
betreuteIdAusSchluessel,
schluesselGueltig,
schluesselErzeugen,
schluesselErfordern,
type Bereich,
}
export { ERLAUBTE_TYPEN, typErkennen, formatBytes, type Dateityp } from '@/lib/speicher/dateityp'
+104
View File
@@ -0,0 +1,104 @@
import 'server-only'
import {
DeleteObjectCommand,
GetObjectCommand,
HeadObjectCommand,
PutObjectCommand,
S3Client,
} from '@aws-sdk/client-s3'
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
import { env } from '@/lib/env'
import { schluesselErfordern, TOKEN_STANDARD_SEKUNDEN } from '@/lib/speicher/schluessel'
/**
* Treiber "s3": S3-kompatibler Objektspeicher, betrieben in der EU.
*
* Voraussetzung fuer den Einsatz ist ein Auftragsverarbeitungsvertrag nach
* Art. 28 DSGVO mit dem Anbieter - hier liegen Gesundheits- und
* Finanzdaten. Ohne diesen Vertrag bleibt es beim Dateitreiber.
*
* Ausgeliefert wird nur ueber kurzlebige presigned URLs. Sie tragen
* Content-Disposition: attachment und einen festen Content-Type, damit der
* Objektspeicher eine hochgeladene Datei niemals als HTML im Browser
* rendern kann.
*/
let klient: S3Client | null = null
function s3(): S3Client {
if (klient) return klient
const k = env.s3
klient = new S3Client({
region: k.region,
endpoint: k.endpoint,
// Viele S3-kompatible Anbieter (MinIO, Hetzner) brauchen Pfad-Stil.
forcePathStyle: true,
credentials: { accessKeyId: k.accessKeyId, secretAccessKey: k.secretAccessKey },
})
return klient
}
function eimer(): string {
return env.s3.bucket
}
export async function schreiben(
schluessel: string,
inhalt: Buffer,
mimeTyp: string,
): Promise<void> {
const geprueft = schluesselErfordern(schluessel)
await s3().send(
new PutObjectCommand({
Bucket: eimer(),
Key: geprueft,
Body: inhalt,
ContentType: mimeTyp,
// Auch beim direkten Abruf niemals im Browser darstellen.
ContentDisposition: 'attachment',
// Serverseitige Verschluesselung, wo der Anbieter sie unterstuetzt.
ServerSideEncryption: 'AES256',
}),
)
}
export async function lesen(schluessel: string): Promise<Buffer> {
const geprueft = schluesselErfordern(schluessel)
const antwort = await s3().send(new GetObjectCommand({ Bucket: eimer(), Key: geprueft }))
if (!antwort.Body) throw new Error(`Objekt ${geprueft} ist leer.`)
const bytes = await antwort.Body.transformToByteArray()
return Buffer.from(bytes)
}
export async function entfernen(schluessel: string): Promise<void> {
const geprueft = schluesselErfordern(schluessel)
await s3().send(new DeleteObjectCommand({ Bucket: eimer(), Key: geprueft }))
}
export async function existiert(schluessel: string): Promise<boolean> {
const geprueft = schluesselErfordern(schluessel)
try {
await s3().send(new HeadObjectCommand({ Bucket: eimer(), Key: geprueft }))
return true
} catch {
return false
}
}
export async function signierteUrl(
schluessel: string,
sekunden: number = TOKEN_STANDARD_SEKUNDEN,
): Promise<string> {
const geprueft = schluesselErfordern(schluessel)
return getSignedUrl(
s3(),
new GetObjectCommand({
Bucket: eimer(),
Key: geprueft,
// Die Antwort des Objektspeichers wird hier festgenagelt: Anhang,
// niemals HTML, egal was beim Hochladen gemeldet wurde.
ResponseContentDisposition: 'attachment',
}),
{ expiresIn: Math.min(Math.max(Math.floor(sekunden), 1), 60 * 60) },
)
}
+204
View File
@@ -0,0 +1,204 @@
import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto'
/**
* Schluessel im Objektspeicher und kurzlebige Zugriffs-Token.
*
* Zwei Regeln, die hier hart durchgesetzt werden:
*
* 1. Der Schluessel wird **erzeugt**, nie vom Nutzer bestimmt. Ein
* hochgeladener Dateiname landet niemals im Pfad - sonst genuegt
* "../../../etc/passwd" oder "..%2f", um aus dem Ablageverzeichnis
* auszubrechen. Der Originalname wird in der Datenbank gemerkt, nicht
* im Dateisystem.
*
* 2. Das zweite Segment ist immer die betreuteId. Daraus liest jede
* Leseoperation, gegen wen sie den Zugriff pruefen muss.
*
* Bewusst ohne "server-only": die reinen Funktionen werden getestet.
*/
export const BEREICHE = ['dokumente', 'berichte', 'exporte', 'vorschau'] as const
export type Bereich = (typeof BEREICHE)[number]
/** cuid/uuid-Format: nur harmlose Zeichen, kein Punkt, kein Schraegstrich. */
const ID_MUSTER = /^[A-Za-z0-9_-]{1,64}$/
const UUID_MUSTER = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
const ENDUNG_MUSTER = /^[a-z0-9]{1,5}$/
export class UngueltigerSchluessel extends Error {
constructor(nachricht: string) {
super(nachricht)
this.name = 'UngueltigerSchluessel'
}
}
/**
* Erzeugt einen frischen Schluessel:
* betreute/<betreuteId>/<bereich>/<uuid>.<endung>
*
* Die Endung kommt aus der Magic-Byte-Erkennung, nicht aus dem Dateinamen.
*/
export function schluesselErzeugen(betreuteId: string, bereich: Bereich, endung: string): string {
if (!ID_MUSTER.test(betreuteId)) {
throw new UngueltigerSchluessel(
'Die Kennung der betreuten Person enthält Zeichen, die im Ablagepfad nichts zu suchen haben.',
)
}
if (!BEREICHE.includes(bereich)) {
throw new UngueltigerSchluessel(`Unbekannter Ablagebereich "${bereich}".`)
}
const sauber = endung.toLowerCase().replace(/[^a-z0-9]/g, '')
if (!ENDUNG_MUSTER.test(sauber)) {
throw new UngueltigerSchluessel(`Unbrauchbare Dateiendung "${endung}".`)
}
return `betreute/${betreuteId}/${bereich}/${randomUUID()}.${sauber}`
}
const SCHLUESSEL_MUSTER = new RegExp(
`^betreute/[A-Za-z0-9_-]{1,64}/(${BEREICHE.join('|')})/` +
`[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\\.[a-z0-9]{1,5}$`,
)
/**
* Ist das ein Schluessel, den diese Anwendung selbst erzeugt hat?
*
* Wird vor jedem Dateizugriff aufgerufen. Alles, was nicht exakt dem
* erzeugten Muster entspricht - auch "betreute/x/dokumente/../../geheim" -
* faellt hier durch, bevor irgendein Pfad zusammengebaut wird.
*/
export function schluesselGueltig(schluessel: unknown): schluessel is string {
if (typeof schluessel !== 'string') return false
if (schluessel.length > 200) return false
// Doppelte Sicherung gegen kodierte Punkte und Backslashes unter Windows.
if (schluessel.includes('..') || schluessel.includes('\\') || schluessel.includes('%')) return false
if (schluessel.includes('\0')) return false
return SCHLUESSEL_MUSTER.test(schluessel)
}
export function schluesselErfordern(schluessel: unknown): string {
if (!schluesselGueltig(schluessel)) {
throw new UngueltigerSchluessel('Dieser Ablageschlüssel stammt nicht aus dieser Anwendung.')
}
return schluessel
}
/** Die betreuteId aus dem Schluessel - Grundlage jeder Zugriffspruefung. */
export function betreuteIdAusSchluessel(schluessel: string): string | null {
if (!schluesselGueltig(schluessel)) return null
return schluessel.split('/')[1] ?? null
}
export function bereichAusSchluessel(schluessel: string): Bereich | null {
if (!schluesselGueltig(schluessel)) return null
const teil = schluessel.split('/')[2]
return BEREICHE.includes(teil as Bereich) ? (teil as Bereich) : null
}
export function endungAusSchluessel(schluessel: string): string | null {
if (!schluesselGueltig(schluessel)) return null
const punkt = schluessel.lastIndexOf('.')
return punkt < 0 ? null : schluessel.slice(punkt + 1)
}
/** Nur fuer Tests und Migrationen: baut einen Schluessel mit fester UUID. */
export function schluesselBauen(
betreuteId: string,
bereich: Bereich,
uuid: string,
endung: string,
): string {
if (!UUID_MUSTER.test(uuid)) throw new UngueltigerSchluessel('Keine gültige UUID.')
const roh = `betreute/${betreuteId}/${bereich}/${uuid}.${endung.toLowerCase()}`
return schluesselErfordern(roh)
}
// -----------------------------------------------------------------------------
// Kurzlebige Zugriffs-Token fuer den Dateitreiber
// -----------------------------------------------------------------------------
/**
* Beim S3-Treiber signiert der Objektspeicher die URL selbst. Beim
* Dateitreiber gibt es keinen solchen Dienst - also signieren wir selbst:
*
* <base64url(schluessel)>.<ablauf in Sekunden>.<HMAC-SHA256>
*
* Der HMAC laeuft ueber "schluessel.ablauf" mit AUTH_SECRET. Ohne das
* Geheimnis laesst sich weder ein fremder Schluessel unterschieben noch der
* Ablauf verlaengern.
*
* Das Token ersetzt die Zugriffspruefung NICHT. Die Route prueft
* zusaetzlich, dass der angemeldete Benutzer auf die betreuteId im
* Schluessel Zugriff hat - ein geteiltes Token darf keine Tuer sein.
*/
export const TOKEN_STANDARD_SEKUNDEN = 300
const TOKEN_MAX_SEKUNDEN = 60 * 60
function geheimnis(): string {
const wert = process.env.AUTH_SECRET
if (!wert || wert.trim() === '') {
throw new Error(
'AUTH_SECRET fehlt. Ohne dieses Geheimnis lassen sich keine Dateilinks signieren - ' +
'erzeuge es mit "npm run schluessel".',
)
}
return wert
}
function signieren(schluessel: string, ablauf: number): string {
return createHmac('sha256', geheimnis()).update(`${schluessel}.${ablauf}`).digest('base64url')
}
export function zugriffTokenErzeugen(
schluessel: string,
sekunden: number = TOKEN_STANDARD_SEKUNDEN,
jetzt: Date = new Date(),
): string {
schluesselErfordern(schluessel)
const dauer = Math.min(Math.max(Math.floor(sekunden), 1), TOKEN_MAX_SEKUNDEN)
const ablauf = Math.floor(jetzt.getTime() / 1000) + dauer
const kodiert = Buffer.from(schluessel, 'utf8').toString('base64url')
return `${kodiert}.${ablauf}.${signieren(schluessel, ablauf)}`
}
export type TokenPruefung =
| { gueltig: true; schluessel: string; ablauf: Date }
| { gueltig: false; grund: 'format' | 'signatur' | 'abgelaufen' }
export function zugriffTokenPruefen(token: unknown, jetzt: Date = new Date()): TokenPruefung {
if (typeof token !== 'string' || token.length > 500) return { gueltig: false, grund: 'format' }
const teile = token.split('.')
if (teile.length !== 3) return { gueltig: false, grund: 'format' }
const [kodiert, ablaufText, signatur] = teile as [string, string, string]
const ablauf = Number.parseInt(ablaufText, 10)
if (!/^\d{1,12}$/.test(ablaufText) || Number.isNaN(ablauf)) {
return { gueltig: false, grund: 'format' }
}
let schluessel: string
try {
schluessel = Buffer.from(kodiert, 'base64url').toString('utf8')
} catch {
return { gueltig: false, grund: 'format' }
}
if (!schluesselGueltig(schluessel)) return { gueltig: false, grund: 'format' }
// Erst die Signatur, dann der Ablauf: sonst verraet die Antwortzeit,
// ob ein Schluessel ueberhaupt existiert.
const erwartet = Buffer.from(signieren(schluessel, ablauf), 'utf8')
const vorhanden = Buffer.from(signatur, 'utf8')
if (erwartet.length !== vorhanden.length) return { gueltig: false, grund: 'signatur' }
if (!timingSafeEqual(erwartet, vorhanden)) return { gueltig: false, grund: 'signatur' }
if (ablauf * 1000 <= jetzt.getTime()) return { gueltig: false, grund: 'abgelaufen' }
return { gueltig: true, schluessel, ablauf: new Date(ablauf * 1000) }
}
/** Der Pfad, auf den signierteUrl() beim Dateitreiber zeigt. */
export function dateiRoutePfad(token: string): string {
return `/api/dokumente/datei/${encodeURIComponent(token)}`
}
+100
View File
@@ -0,0 +1,100 @@
import { addDays, addMonths, addYears, isAfter, isBefore } from 'date-fns'
/**
* Das Betreuungsjahr.
*
* Es laeuft ab dem Tag der Bestellung, nicht ab dem 1. Januar. Wer am
* 14. Februar 2026 bestellt wurde, hat sein erstes Betreuungsjahr bis zum
* 13. Februar 2027. Daran haengen:
*
* - der Jahresbericht nach § 1863 Abs. 3 BGB, faellig zum Ende des Jahres,
* - die Vermoegensuebersicht bzw. Rechnungslegung (§§ 1859, 1865 BGB),
* - die Aufwandspauschale nach § 1878 BGB, die binnen sechs Monaten nach
* Ablauf des Betreuungsjahres geltend gemacht werden muss. Diese Frist
* ist eine Ausschlussfrist: laeuft sie ab, ist der Anspruch weg. Kein
* Nachsehen, keine Wiedereinsetzung.
*
* Diese Datei ist bewusst frei von Datenbankzugriffen, damit sie sich
* ohne Aufwand testen laesst.
*/
export type Betreuungsjahr = {
/** Laufende Nummer, beginnend bei 1. */
nummer: number
beginn: Date
/** Letzter Tag des Jahres (Beginn + 1 Jahr - 1 Tag). */
ende: Date
/** Letzter Tag, an dem die Aufwandspauschale beantragt werden kann. */
pauschaleBis: Date
}
/**
* Liefert das Betreuungsjahr, in dem ein Stichtag liegt - standardmaessig
* heute. Gibt null zurueck, wenn kein Bestellungsdatum vorliegt oder der
* Stichtag davor liegt.
*/
export function betreuungsjahr(
bestellungAm: Date | null | undefined,
stichtag: Date = new Date(),
): Betreuungsjahr | null {
if (!bestellungAm) return null
if (isBefore(stichtag, bestellungAm)) return null
let nummer = 1
let beginn = new Date(bestellungAm)
// Schrittweise vorwaerts statt ueber eine Differenzrechnung: so stimmen
// Schaltjahre und der 29. Februar von selbst.
for (;;) {
const naechsterBeginn = addYears(beginn, 1)
if (isBefore(stichtag, naechsterBeginn)) break
beginn = naechsterBeginn
nummer += 1
// Schutz vor einem Datumsfehler in den Stammdaten.
if (nummer > 200) return null
}
const ende = addDays(addYears(beginn, 1), -1)
return {
nummer,
beginn,
ende,
// Sechs Monate ab dem Ende des Betreuungsjahres.
pauschaleBis: addMonths(ende, 6),
}
}
/** Das n-te Betreuungsjahr, unabhaengig vom heutigen Datum. */
export function betreuungsjahrNummer(
bestellungAm: Date | null | undefined,
nummer: number,
): Betreuungsjahr | null {
if (!bestellungAm || nummer < 1) return null
const beginn = addYears(new Date(bestellungAm), nummer - 1)
const ende = addDays(addYears(beginn, 1), -1)
return { nummer, beginn, ende, pauschaleBis: addMonths(ende, 6) }
}
/** Alle bisher abgelaufenen Betreuungsjahre - Grundlage der Berichtswahl. */
export function abgelaufeneBetreuungsjahre(
bestellungAm: Date | null | undefined,
stichtag: Date = new Date(),
): Betreuungsjahr[] {
if (!bestellungAm) return []
const jahre: Betreuungsjahr[] = []
let nummer = 1
for (;;) {
const jahr = betreuungsjahrNummer(bestellungAm, nummer)
if (!jahr || isAfter(jahr.ende, stichtag)) break
jahre.push(jahr)
nummer += 1
if (nummer > 200) break
}
return jahre
}
+398
View File
@@ -0,0 +1,398 @@
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()
.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()
.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
.union([z.literal('on'), z.literal('true'), z.literal('1'), z.literal(''), z.undefined()])
.transform((wert) => wert === 'on' || wert === 'true' || wert === '1')
// -----------------------------------------------------------------------------
// 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<typeof kontoSchema>
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<typeof saldostandSchema>
// -----------------------------------------------------------------------------
// 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),
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<typeof buchungSchema>
/** 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<typeof buchungsfilterSchema>
// -----------------------------------------------------------------------------
// 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<typeof vermoegenspostenSchema>
/**
* 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<typeof wertstandSchema>
// -----------------------------------------------------------------------------
// 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<string, string> {
const objekt: Record<string, string> = {}
for (const [schluessel, wert] of daten.entries()) {
if (typeof wert === 'string') objekt[schluessel] = wert
}
return objekt
}
/** Zod-Fehler in die Form, die <Feld fehler={...}> erwartet. */
export function fehlerAuflisten(fehler: z.ZodError): Record<string, string> {
const felder: Record<string, string> = {}
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)
}
+152
View File
@@ -0,0 +1,152 @@
import { z } from 'zod'
import { ausIsoTag } from '@/lib/fristen/datum'
import { rruleGueltig } from '@/lib/fristen/wiederholung'
/**
* Eingabepruefung fuer Fristen.
*
* Die Pruefung laeuft immer serverseitig; das Formular im Browser ist nur
* Bequemlichkeit. Alle Meldungen sagen, was zu tun ist - nicht, welche
* Regel verletzt wurde.
*/
export const FRIST_TYPEN = ['GESETZLICH', 'BEHOERDLICH', 'SELBST_GESETZT', 'WIEDERVORLAGE'] as const
export const FRIST_STATUS = ['OFFEN', 'ERLEDIGT', 'VERSCHOBEN', 'ENTFALLEN'] as const
export type FristTypWert = (typeof FRIST_TYPEN)[number]
export type FristStatusWert = (typeof FRIST_STATUS)[number]
export const FRIST_TYP_BESCHRIFTUNG: Record<FristTypWert, string> = {
GESETZLICH: 'Gesetzliche Frist',
BEHOERDLICH: 'Von Gericht oder Behörde gesetzt',
SELBST_GESETZT: 'Selbst gesetzt',
WIEDERVORLAGE: 'Wiedervorlage',
}
export const FRIST_TYP_ERKLAERUNG: Record<FristTypWert, string> = {
GESETZLICH: 'Steht so im Gesetz — läuft sie ab, hat das Folgen.',
BEHOERDLICH: 'Aus einem Bescheid oder einer Verfügung, z. B. eine Widerspruchsfrist.',
SELBST_GESETZT: 'Eigene Planung, kein Zwang von außen.',
WIEDERVORLAGE: 'Nur eine Erinnerung, noch einmal hinzuschauen.',
}
export const FRIST_STATUS_BESCHRIFTUNG: Record<FristStatusWert, string> = {
OFFEN: 'Offen',
ERLEDIGT: 'Erledigt',
VERSCHOBEN: 'Verschoben',
ENTFALLEN: 'Entfallen',
}
/** Was das Formular an Vorwarnstufen anbietet. */
export const VORWARNUNG_STUFEN = [90, 60, 30, 14, 7, 3, 1] as const
const pflichtDatum = z
.string()
.trim()
.min(1, 'Bitte ein Datum angeben.')
.transform((wert, ctx) => {
const datum = ausIsoTag(wert)
if (!datum) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Bitte ein gültiges Datum wählen.' })
return z.NEVER
}
return datum
})
const optionalesDatum = z
.string()
.trim()
.transform((wert, ctx) => {
if (wert === '') return null
const datum = ausIsoTag(wert)
if (!datum) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Bitte ein gültiges Datum wählen.' })
return z.NEVER
}
return datum
})
const optionalerText = (max: number) =>
z
.string()
.trim()
.max(max, `Bitte höchstens ${max} Zeichen.`)
.transform((wert) => (wert === '' ? null : wert))
const wiederholung = z
.string()
.trim()
.max(300, 'Die Wiederholungsregel ist zu lang.')
.transform((wert) => (wert === '' ? null : wert.toUpperCase()))
.refine((wert) => wert === null || rruleGueltig(wert), {
message:
'Diese Wiederholungsregel versteht der Kalender nicht. ' +
'Beispiel für jährlich: FREQ=YEARLY;INTERVAL=1',
})
export const FristSchema = z.object({
titel: z
.string()
.trim()
.min(3, 'Bitte gib der Frist einen Titel, an dem du sie wiedererkennst.')
.max(200, 'Bitte höchstens 200 Zeichen.'),
beschreibung: optionalerText(4000),
datum: pflichtDatum,
typ: z.enum(FRIST_TYPEN, {
errorMap: () => ({ message: 'Bitte eine Art der Frist wählen.' }),
}),
rechtsgrundlage: optionalerText(120),
vorwarnungTage: z
.array(z.coerce.number().int().min(0).max(400))
.transform((werte) => [...new Set(werte.filter((w) => w > 0))].sort((a, b) => b - a))
.default([60, 30, 7, 1]),
wiederholungRrule: wiederholung.nullable().default(null),
ausschlussfrist: z.coerce.boolean().default(false),
vorgangId: optionalerText(40),
})
export type FristEingabe = z.infer<typeof FristSchema>
export const FristVerschiebenSchema = z.object({
datum: pflichtDatum,
grund: optionalerText(500),
})
export const FristErledigenSchema = z.object({
erledigtAm: optionalesDatum,
notiz: optionalerText(500),
})
/**
* Liest ein FormData-Objekt in die Struktur, die das Schema erwartet.
* Kontrollkaestchen liefern "on" oder gar nichts, Mehrfachauswahlen
* kommen als wiederholter Schluessel.
*/
export function fristFormularLesen(formular: FormData): Record<string, unknown> {
const vorwarnung = formular.getAll('vorwarnungTage').map(String).filter(Boolean)
// "Eigene Regel" schlaegt die Auswahlliste - so laesst sich auch etwas
// eintragen, das in der Liste nicht steht.
const eigeneRegel = String(formular.get('wiederholungEigene') ?? '').trim()
const ausListe = String(formular.get('wiederholungRrule') ?? '').trim()
return {
titel: formular.get('titel') ?? '',
beschreibung: formular.get('beschreibung') ?? '',
datum: formular.get('datum') ?? '',
typ: formular.get('typ') ?? 'SELBST_GESETZT',
rechtsgrundlage: formular.get('rechtsgrundlage') ?? '',
vorwarnungTage: vorwarnung.length > 0 ? vorwarnung : [60, 30, 7, 1],
wiederholungRrule: eigeneRegel !== '' ? eigeneRegel : ausListe,
ausschlussfrist: formular.get('ausschlussfrist') === 'on',
vorgangId: formular.get('vorgangId') ?? '',
}
}
/** Rueckgabeform aller Formularaktionen, passend zu useActionState. */
export type FristZustand = {
fehler?: string
hinweis?: string
felder?: Partial<Record<keyof FristEingabe, string>>
werte?: Record<string, string | string[]>
}
+284
View File
@@ -0,0 +1,284 @@
import { z } from 'zod'
import type { KontaktArt, KontaktRolle } from '@prisma/client'
/**
* Eingabepruefung fuer Kontakte und Kontaktprotokolle.
*
* Die Pruefung laeuft immer serverseitig; das Formular im Browser ist nur
* Bequemlichkeit. Meldungen sagen, was zu tun ist - nicht, was falsch war.
*
* Diese Datei kommt ohne "server-only" aus: die Beschriftungen der Rollen
* und Kontaktarten werden auch im Browser gebraucht.
*/
// -----------------------------------------------------------------------------
// Bausteine
// -----------------------------------------------------------------------------
/** Leere Zeichenketten aus einem Formular werden zu null, nicht zu "". */
const optionalerText = (max = 200) =>
z
.string()
.trim()
.max(max, `Bitte höchstens ${max} Zeichen.`)
.transform((wert) => (wert === '' ? null : wert))
.nullable()
.default(null)
const optionaleEmail = z
.string()
.trim()
.max(200, 'Bitte höchstens 200 Zeichen.')
.transform((wert) => (wert === '' ? null : wert.toLowerCase()))
.nullable()
.default(null)
.refine((wert) => wert === null || /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(wert), {
message: 'Bitte eine gültige E-Mail-Adresse angeben – oder das Feld leer lassen.',
})
/**
* Ein Tag aus einem HTML-Datumsfeld ("2026-08-22").
*
* Gespeichert wird 12:00 UTC, nicht Mitternacht: Ein Kontaktprotokoll ist
* fachlich ein Tag, und um die Mittagszeit herum kippt keine Zeitzone der
* Welt den Eintrag auf den Vor- oder Folgetag.
*/
const TAG_MUSTER = /^\d{4}-\d{2}-\d{2}$/
const pflichtTag = z
.string()
.trim()
.min(1, 'Bitte ein Datum angeben.')
.refine((wert) => TAG_MUSTER.test(wert), {
message: 'Bitte ein Datum im Format TT.MM.JJJJ wählen.',
})
.transform((wert) => new Date(`${wert}T12:00:00.000Z`))
.refine((datum) => !Number.isNaN(datum.getTime()), {
message: 'Dieses Datum gibt es nicht.',
})
const optionaleId = z
.string()
.trim()
.max(40)
.transform((wert) => (wert === '' ? null : wert))
.nullable()
.default(null)
// -----------------------------------------------------------------------------
// Kontaktrollen
// -----------------------------------------------------------------------------
/**
* Reihenfolge im Verzeichnis: wer im Alltag am haeufigsten gebraucht wird,
* steht oben. Gericht und Bezirk zuerst - dort entscheidet sich das meiste.
*/
export const KONTAKT_ROLLEN = [
'RECHTSPFLEGER',
'RICHTER',
'BETREUUNGSBEHOERDE',
'SACHBEARBEITER_BEZIRK',
'SACHBEARBEITER_SOZIALAMT',
'HEIMLEITUNG',
'PFLEGEDIENSTLEITUNG',
'SOZIALDIENST_HEIM',
'HAUSARZT',
'FACHARZT',
'KRANKENKASSE',
'PFLEGEKASSE',
'RENTENVERSICHERUNG',
'NOTAR',
'RECHTSANWALT',
'STEUERBERATER',
'BANK',
'VERSICHERUNG',
'VERSICHERUNGSMAKLER',
'MAKLER',
'HANDWERKER',
'ANGEHOERIGER',
'SONSTIGES',
] as const satisfies readonly KontaktRolle[]
export const KONTAKT_ROLLE_BESCHRIFTUNG: Record<KontaktRolle, string> = {
RECHTSPFLEGER: 'Rechtspfleger',
RICHTER: 'Richter',
BETREUUNGSBEHOERDE: 'Betreuungsbehörde',
SACHBEARBEITER_BEZIRK: 'Sachbearbeiter Bezirk',
SACHBEARBEITER_SOZIALAMT: 'Sachbearbeiter Sozialamt',
HEIMLEITUNG: 'Heimleitung',
PFLEGEDIENSTLEITUNG: 'Pflegedienstleitung',
SOZIALDIENST_HEIM: 'Sozialdienst im Heim',
HAUSARZT: 'Hausarzt',
FACHARZT: 'Facharzt',
KRANKENKASSE: 'Krankenkasse',
PFLEGEKASSE: 'Pflegekasse',
RENTENVERSICHERUNG: 'Rentenversicherung',
NOTAR: 'Notar',
MAKLER: 'Makler',
VERSICHERUNGSMAKLER: 'Versicherungsmakler',
VERSICHERUNG: 'Versicherung',
STEUERBERATER: 'Steuerberater',
RECHTSANWALT: 'Rechtsanwalt',
BANK: 'Bank',
HANDWERKER: 'Handwerker',
ANGEHOERIGER: 'Angehöriger',
SONSTIGES: 'Sonstiges',
}
// -----------------------------------------------------------------------------
// Kontaktarten
// -----------------------------------------------------------------------------
export const KONTAKT_ARTEN = [
'BESUCH',
'TELEFONAT',
'EMAIL',
'BRIEF',
'VIDEOCALL',
'FAX',
'VORSPRACHE',
] as const satisfies readonly KontaktArt[]
export const KONTAKT_ART_BESCHRIFTUNG: Record<KontaktArt, string> = {
BESUCH: 'Besuch',
TELEFONAT: 'Telefonat',
EMAIL: 'E-Mail',
BRIEF: 'Brief',
VIDEOCALL: 'Videocall',
FAX: 'Fax',
VORSPRACHE: 'Vorsprache',
}
/** Die fünf Arten, die in der Schnellerfassung als grosse Knopfreihe stehen. */
export const KONTAKT_ARTEN_SCHNELLWAHL = [
'BESUCH',
'TELEFONAT',
'EMAIL',
'BRIEF',
'VIDEOCALL',
] as const satisfies readonly KontaktArt[]
/** Dauer-Schnellwahl in Minuten. Ein freies Feld gibt es zusaetzlich. */
export const DAUER_SCHNELLWAHL = [15, 30, 45, 60] as const
// -----------------------------------------------------------------------------
// Kontakt
// -----------------------------------------------------------------------------
export const KontaktSchema = z.object({
name: z
.string()
.trim()
.min(2, 'Bitte den Namen angeben – mindestens zwei Zeichen.')
.max(150, 'Bitte höchstens 150 Zeichen.'),
organisation: optionalerText(150),
rolle: z.enum(KONTAKT_ROLLEN, {
errorMap: () => ({ message: 'Bitte eine Rolle aus der Liste wählen.' }),
}),
telefon: optionalerText(50),
mobil: optionalerText(50),
fax: optionalerText(50),
email: optionaleEmail,
strasse: optionalerText(150),
plz: optionalerText(10),
ort: optionalerText(100),
aktenzeichen: optionalerText(80),
notizen: optionalerText(4000),
})
export type KontaktEingabe = z.infer<typeof KontaktSchema>
/** Diese Felder wandern verschluesselt in die Datenbank. */
export const KONTAKT_GEHEIME_FELDER = ['notizen'] as const
export function kontaktFormularLesen(formular: FormData): Record<string, unknown> {
const roh: Record<string, unknown> = {}
for (const feld of [
'name',
'organisation',
'rolle',
'telefon',
'mobil',
'fax',
'email',
'strasse',
'plz',
'ort',
'aktenzeichen',
'notizen',
]) {
const wert = formular.get(feld)
if (wert !== null) roh[feld] = wert
}
return roh
}
// -----------------------------------------------------------------------------
// Kontaktprotokoll
// -----------------------------------------------------------------------------
/** Laenger als ein voller Tag ist kein einzelner Kontakt. */
const DAUER_MAX_MINUTEN = 1440
export const KontaktprotokollSchema = z.object({
datum: pflichtTag,
art: z.enum(KONTAKT_ARTEN, {
errorMap: () => ({ message: 'Bitte auswählen, wie der Kontakt stattgefunden hat.' }),
}),
dauerMinuten: z
.union([z.literal(''), z.coerce.number()])
.transform((wert) => (wert === '' ? null : wert))
.nullable()
.default(null)
.refine((wert) => wert === null || (Number.isInteger(wert) && wert > 0), {
message: 'Bitte die Dauer in ganzen Minuten angeben.',
})
.refine((wert) => wert === null || wert <= DAUER_MAX_MINUTEN, {
message: 'Mehr als 24 Stunden sind kein einzelner Kontakt – bitte aufteilen.',
}),
gespraechspartner: optionalerText(150),
mitBetreuter: z.coerce.boolean().default(false),
kontaktId: optionaleId,
vorgangId: optionaleId,
inhalt: optionalerText(8000),
wuensche: optionalerText(4000),
folgeaufgaben: optionalerText(4000),
})
export type KontaktprotokollEingabe = z.infer<typeof KontaktprotokollSchema>
/** Diese Felder wandern verschluesselt in die Datenbank. */
export const PROTOKOLL_GEHEIME_FELDER = ['inhalt', 'wuensche', 'folgeaufgaben'] as const
export function protokollFormularLesen(formular: FormData): Record<string, unknown> {
const roh: Record<string, unknown> = {}
for (const feld of [
'datum',
'art',
'dauerMinuten',
'gespraechspartner',
'kontaktId',
'vorgangId',
'inhalt',
'wuensche',
'folgeaufgaben',
]) {
const wert = formular.get(feld)
if (wert !== null) roh[feld] = wert
}
// Ein Kontrollkaestchen liefert "on" oder gar nichts.
roh.mitBetreuter = formular.get('mitBetreuter') === 'on'
return roh
}
/**
* Ein gespeicherter Zeitstempel zurueck in den Wert eines HTML-Datumsfelds.
* Gegenstueck zu pflichtTag - beide rechnen in UTC, damit das Bearbeiten
* eines Protokolls es nicht auf den Vortag schiebt.
*/
export function tagFuerFormular(datum: Date): string {
const jahr = datum.getUTCFullYear()
const monat = String(datum.getUTCMonth() + 1).padStart(2, '0')
const tag = String(datum.getUTCDate()).padStart(2, '0')
return `${jahr}-${monat}-${tag}`
}
+237
View File
@@ -0,0 +1,237 @@
import { z } from 'zod'
import type { VorgangKategorie, VorgangStatus } from '@prisma/client'
import { GENEHMIGUNGSKETTE, SEITENAUSGANG } from '@/lib/vorgaenge/genehmigungskette'
import { VORLAGEN_SCHLUESSEL } from '@/lib/vorgaenge/vorlagen'
/**
* Eingabeprüfung für Vorgänge, Aufgaben und Genehmigungen.
*
* Wie überall gilt: Die Prüfung läuft serverseitig, das Formular im
* Browser ist nur Bequemlichkeit. Meldungen sagen, was zu tun ist.
*/
/** Leere Formularfelder sollen zu null werden, nicht zu "". */
const optionalerText = (max = 200) =>
z
.string()
.trim()
.max(max, `Bitte höchstens ${max} Zeichen.`)
.transform((wert) => (wert === '' ? null : wert))
.nullable()
const optionalesDatum = z
.string()
.trim()
.transform((wert) => (wert === '' ? null : wert))
.nullable()
.refine((wert) => wert === null || /^\d{4}-\d{2}-\d{2}$/.test(wert), {
message: 'Bitte ein Datum im Format TT.MM.JJJJ wählen.',
})
// Mitternacht UTC: Faelligkeiten sind @db.Date, ohne Uhrzeit. Sonst
// verschiebt die Zeitzone einen Termin um einen Tag.
.transform((wert) => (wert === null ? null : new Date(`${wert}T00:00:00.000Z`)))
export const VORGANG_KATEGORIEN = [
'GERICHT',
'SOZIALHILFE',
'IMMOBILIE',
'VERSICHERUNG',
'HEIM_PFLEGE',
'VERTRAEGE',
'STEUERN',
'SONSTIGES',
] as const satisfies readonly VorgangKategorie[]
export const VORGANG_STATUS = [
'OFFEN',
'IN_ARBEIT',
'WARTET_AUF_DRITTE',
'WARTET_AUF_GENEHMIGUNG',
'ERLEDIGT',
] as const satisfies readonly VorgangStatus[]
export const KATEGORIE_BESCHRIFTUNG: Record<VorgangKategorie, string> = {
GERICHT: 'Betreuungsgericht',
SOZIALHILFE: 'Sozialhilfe',
IMMOBILIE: 'Immobilie',
VERSICHERUNG: 'Versicherung',
HEIM_PFLEGE: 'Heim & Pflege',
VERTRAEGE: 'Verträge',
STEUERN: 'Steuern',
SONSTIGES: 'Sonstiges',
}
export const PRIORITAET_BESCHRIFTUNG: Record<number, string> = {
1: 'Hoch',
2: 'Normal',
3: 'Niedrig',
}
// -----------------------------------------------------------------------------
// Vorgang
// -----------------------------------------------------------------------------
export const VorgangSchema = z.object({
titel: z
.string()
.trim()
.min(3, 'Bitte gib dem Vorgang einen Titel, an dem du ihn wiedererkennst.')
.max(200, 'Bitte höchstens 200 Zeichen.'),
kategorie: z.enum(VORGANG_KATEGORIEN, {
errorMap: () => ({ message: 'Bitte eine Kategorie wählen.' }),
}),
status: z.enum(VORGANG_STATUS).default('OFFEN'),
prioritaet: z.coerce
.number()
.int()
.min(1, 'Die Priorität liegt zwischen 1 (hoch) und 3 (niedrig).')
.max(3, 'Die Priorität liegt zwischen 1 (hoch) und 3 (niedrig).')
.default(2),
beschreibung: optionalerText(4000),
gegenstelle: optionalerText(150),
aktenzeichen: optionalerText(80),
verantwortlich: optionalerText(120),
naechsterSchritt: optionalerText(300),
faelligAm: optionalesDatum,
letzterKontaktAm: optionalesDatum,
genehmigungspflichtig: z.coerce.boolean().default(false),
})
export type VorgangEingabe = z.infer<typeof VorgangSchema>
/**
* Anlegen aus einer Vorlage. Der Schlüssel muss eine bekannte Vorlage sein
* oder fehlen (leerer Vorgang) - erfundene Schlüssel würden sonst still
* einen Vorgang ohne Aufgaben erzeugen.
*/
export const VorgangAnlegenSchema = VorgangSchema.extend({
vorlageSchluessel: z
.string()
.trim()
.transform((wert) => (wert === '' ? null : wert))
.nullable()
.refine((wert) => wert === null || VORLAGEN_SCHLUESSEL.includes(wert), {
message: 'Diese Vorlage gibt es nicht.',
}),
})
export type VorgangAnlegenEingabe = z.infer<typeof VorgangAnlegenSchema>
export const VorgangStatusSchema = z.object({
vorgangId: z.string().min(1),
status: z.enum(VORGANG_STATUS, {
errorMap: () => ({ message: 'Diesen Status gibt es nicht.' }),
}),
})
// -----------------------------------------------------------------------------
// Aufgaben
// -----------------------------------------------------------------------------
export const AufgabeSchema = z.object({
vorgangId: z.string().min(1),
titel: z
.string()
.trim()
.min(3, 'Bitte beschreibe den Handgriff in ein paar Worten.')
.max(200, 'Bitte höchstens 200 Zeichen.'),
beschreibung: optionalerText(2000),
faelligAm: optionalesDatum,
})
export const AufgabeAbhakenSchema = z.object({
aufgabeId: z.string().min(1),
erledigt: z.coerce.boolean(),
})
// -----------------------------------------------------------------------------
// Genehmigungen
// -----------------------------------------------------------------------------
export const GENEHMIGUNG_STATUS_WERTE = [...GENEHMIGUNGSKETTE, SEITENAUSGANG] as [
string,
...string[],
]
export const GenehmigungSchema = z.object({
vorgangId: z.string().min(1),
titel: z
.string()
.trim()
.min(3, 'Bitte benenne das Geschäft, das genehmigt werden soll.')
.max(200, 'Bitte höchstens 200 Zeichen.'),
rechtsgrundlage: z
.string()
.trim()
.min(3, 'Bitte die Vorschrift angeben, z. B. „§ 1850 BGB“.')
.max(120, 'Bitte höchstens 120 Zeichen.'),
notizen: optionalerText(4000),
mitteilungAm: optionalesDatum,
antragAm: optionalesDatum,
anhoerungAm: optionalesDatum,
beschlussAm: optionalesDatum,
rechtskraftAm: optionalesDatum,
vollzugAm: optionalesDatum,
})
export type GenehmigungEingabe = z.infer<typeof GenehmigungSchema>
export const GenehmigungStatusSchema = z.object({
genehmigungId: z.string().min(1),
status: z.enum(GENEHMIGUNG_STATUS_WERTE, {
errorMap: () => ({ message: 'Diesen Status gibt es in der Genehmigungskette nicht.' }),
}),
/** Datum der neuen Stufe, sonst wird das heutige gesetzt. */
datum: optionalesDatum,
})
export const GenehmigungBeschlussSchema = z.object({
genehmigungId: z.string().min(1),
/** Leerer Wert löst die Verknüpfung wieder. */
dokumentId: z
.string()
.trim()
.transform((wert) => (wert === '' ? null : wert))
.nullable(),
})
// -----------------------------------------------------------------------------
/** Gemeinsamer Rückgabetyp aller Formular-Aktionen (useActionState). */
export type Formularzustand = {
fehler?: string
felder?: Record<string, string>
werte?: Record<string, string>
/** Gesetzt, wenn alles geklappt hat - die Oberfläche kann es melden. */
erfolg?: string
}
/**
* Liest ein FormData-Objekt in die Struktur, die die Schemata erwarten.
* Kontrollkästchen liefern "on" oder gar nichts.
*/
export function formularLesen(
formular: FormData,
kontrollkaestchen: string[] = [],
): Record<string, unknown> {
const roh: Record<string, unknown> = {}
for (const [name, wert] of formular.entries()) {
if (kontrollkaestchen.includes(name)) continue
roh[name] = wert
}
for (const name of kontrollkaestchen) {
roh[name] = formular.get(name) === 'on' || formular.get(name) === 'true'
}
return roh
}
/** Zod-Fehler in die flache Feldkarte des Formularzustands übersetzen. */
export function felderAusFehler(fehler: z.ZodError): Record<string, string> {
const flach = fehler.flatten().fieldErrors
const felder: Record<string, string> = {}
for (const [name, meldungen] of Object.entries(flach)) {
const erste = meldungen?.[0]
if (erste) felder[name] = erste
}
return felder
}
+89
View File
@@ -0,0 +1,89 @@
import { formatDatum } from '@/lib/utils'
/**
* Gemeinsame Bausteine der Anschreiben an Versicherer.
*
* Der erzeugte Text ist Freitext zum Kopieren und Bearbeiten - bewusst
* kein fertiges PDF. Jeder Fall hat eine Besonderheit, die in den Brief
* gehoert, und ein Formulartext, den man nicht mehr aendern kann, wird
* frueher oder falsch abgeschickt. Die PDF-Erzeugung kommt in Etappe 7.
*/
export type Briefkopf = {
/** Ich als Betreuer. */
betreuerName: string
betreuerStrasse?: string | null
betreuerPlz?: string | null
betreuerOrt?: string | null
/** Die betreute Person. */
betreuteName: string
betreuteGeburtsdatum: Date | null
betreuteAnschrift?: string | null
/** Betreuungsgericht und Aktenzeichen - Nachweis der Bestellung. */
betreuungsgericht?: string | null
aktenzeichenGericht?: string | null
bestellungAm?: Date | null
}
export type Empfaenger = {
gesellschaft: string | null
anschrift?: string | null
}
export function anschriftBlock(empfaenger: Empfaenger): string {
const zeilen = [empfaenger.gesellschaft ?? '[Name der Gesellschaft]']
if (empfaenger.anschrift) {
zeilen.push(...empfaenger.anschrift.split('\n').map((z) => z.trim()).filter(Boolean))
} else {
zeilen.push('[Straße und Hausnummer]', '[PLZ und Ort]')
}
return zeilen.join('\n')
}
export function absenderBlock(kopf: Briefkopf): string {
const zeilen = [kopf.betreuerName]
if (kopf.betreuerStrasse) zeilen.push(kopf.betreuerStrasse)
const ort = [kopf.betreuerPlz, kopf.betreuerOrt].filter(Boolean).join(' ')
if (ort) zeilen.push(ort)
return zeilen.join('\n')
}
/**
* Der Absatz, der in jedem Schreiben stehen muss: Wer bin ich, und woraus
* folgt meine Vertretungsmacht? Ohne ihn kommt die Antwort "aus
* datenschutzrechtlichen Gruenden koennen wir Ihnen keine Auskunft
* erteilen" - und dann ist ein Monat vergangen.
*/
export function bestellungsAbsatz(kopf: Briefkopf): string {
const gericht = kopf.betreuungsgericht ?? '[Betreuungsgericht]'
const az = kopf.aktenzeichenGericht ?? '[Aktenzeichen]'
const seit = kopf.bestellungAm
? `mit Beschluss vom ${formatDatum(kopf.bestellungAm)}`
: '[Datum des Bestellungsbeschlusses ergänzen]'
return (
`ich bin ${seit} vom ${gericht} (Az. ${az}) zum rechtlichen Betreuer ` +
`für ${kopf.betreuteName} bestellt; der Aufgabenkreis umfasst die ` +
`Vermögenssorge. Eine Kopie der Bestellungsurkunde füge ich bei. ` +
`Ich handele damit als gesetzlicher Vertreter (§ 1823 BGB).`
)
}
export function betreffZeile(
gegenstand: string,
vertragsnummer: string | null,
betreuteName: string,
geburtsdatum: Date | null,
): string {
const nummer = vertragsnummer ? `Vertragsnummer ${vertragsnummer}` : 'Vertragsnummer [bitte ergänzen]'
const geboren = geburtsdatum ? `, geboren am ${formatDatum(geburtsdatum)}` : ''
return `Betreff: ${gegenstand}\n${nummer}\nVersicherungsnehmerin: ${betreuteName}${geboren}`
}
export function datumsZeile(ort: string | null | undefined, datum: Date): string {
return `${ort ? `${ort}, den ` : ''}${formatDatum(datum)}`
}
export function grussformel(betreuerName: string): string {
return `Mit freundlichen Grüßen\n\n\n${betreuerName}\nRechtlicher Betreuer`
}
+128
View File
@@ -0,0 +1,128 @@
import type { SozialhilfeBewertung, VertragArt, VertragStatus, ZahlRhythmus } from '@prisma/client'
/**
* Beschriftungen und Einordnungen fuer Versicherungen und Vertraege.
*/
export const VERTRAG_ART_BESCHRIFTUNG: Record<VertragArt, string> = {
LEBENSVERSICHERUNG: 'Lebensversicherung',
STERBEGELDVERSICHERUNG: 'Sterbegeldversicherung',
BESTATTUNGSVORSORGE: 'Bestattungsvorsorgevertrag',
RENTENVERSICHERUNG_PRIVAT: 'Private Rentenversicherung',
UNFALLVERSICHERUNG: 'Unfallversicherung',
HAFTPFLICHT: 'Privathaftpflicht',
HAUSRAT: 'Hausratversicherung',
WOHNGEBAEUDE: 'Wohngebäudeversicherung',
KRANKENZUSATZ: 'Krankenzusatzversicherung',
PFLEGEZUSATZ: 'Pflegezusatzversicherung',
RECHTSSCHUTZ: 'Rechtsschutzversicherung',
STERBEKASSE: 'Sterbekasse',
STROM: 'Stromvertrag',
GAS: 'Gasvertrag',
WASSER: 'Wasserversorgung',
TELEFON_INTERNET: 'Telefon und Internet',
RUNDFUNKBEITRAG: 'Rundfunkbeitrag',
MITGLIEDSCHAFT: 'Mitgliedschaft',
ABONNEMENT: 'Abonnement',
SONSTIGES: 'Sonstiges',
}
export const VERTRAG_STATUS_BESCHRIFTUNG: Record<VertragStatus, string> = {
AKTIV: 'aktiv',
BEITRAGSFREI: 'beitragsfrei gestellt',
RUHEND: 'ruhend',
GEKUENDIGT: 'gekündigt',
ABGETRETEN: 'abgetreten',
VERWERTET: 'verwertet',
ABGELAUFEN: 'abgelaufen',
}
export const RHYTHMUS_BESCHRIFTUNG: Record<ZahlRhythmus, string> = {
MONATLICH: 'monatlich',
VIERTELJAEHRLICH: 'vierteljährlich',
HALBJAEHRLICH: 'halbjährlich',
JAEHRLICH: 'jährlich',
EINMALIG: 'einmalig',
}
/** Wie oft im Jahr wird gezahlt? Fuer den Jahresbeitrag in der Uebersicht. */
export const RHYTHMUS_JAHRESFAKTOR: Record<ZahlRhythmus, number> = {
MONATLICH: 12,
VIERTELJAEHRLICH: 4,
HALBJAEHRLICH: 2,
JAEHRLICH: 1,
EINMALIG: 0,
}
export function jahresbeitragCent(
beitragCent: bigint | null | undefined,
rhythmus: ZahlRhythmus | null | undefined,
): bigint | null {
if (beitragCent === null || beitragCent === undefined || !rhythmus) return null
const faktor = RHYTHMUS_JAHRESFAKTOR[rhythmus]
if (faktor === 0) return null
return beitragCent * BigInt(faktor)
}
export const BEWERTUNG_BESCHRIFTUNG: Record<SozialhilfeBewertung, string> = {
UNGEPRUEFT: 'ungeprüft',
VERWERTBAR: 'verwertbar',
GESCHUETZT: 'geschützt',
STRITTIG: 'strittig',
}
export const BEWERTUNG_ERKLAERUNG: Record<SozialhilfeBewertung, string> = {
UNGEPRUEFT: 'Noch nicht eingeordnet. Vor dem nächsten Schreiben an den Bezirk nachholen.',
VERWERTBAR:
'Der Vertrag lässt sich zu Geld machen und ist vor der Sozialhilfe einzusetzen. ' +
'Das heißt nicht, dass gekündigt werden muss — erst rechnen, dann handeln.',
GESCHUETZT:
'Aus meiner Sicht Schonvermögen, etwa eine angemessene Bestattungsvorsorge ' +
'(§ 90 Abs. 3 SGB XII) oder ein Vertrag unterhalb der Vermögensfreigrenze. ' +
'Die Begründung gehört daneben.',
STRITTIG: 'Der Träger sieht es anders als ich. Schriftwechsel dokumentieren.',
}
/**
* Vertragsarten, bei denen eine Verwertung genehmigungspflichtig sein
* kann. Das ist bewusst eine Pruefliste, keine Entscheidung: Ob § 1850 BGB
* greift, haengt am Einzelfall und beantwortet der Rechtspfleger, nicht
* diese Anwendung.
*/
export const GENEHMIGUNG_PRUEFEN_BEI: VertragArt[] = [
'LEBENSVERSICHERUNG',
'RENTENVERSICHERUNG_PRIVAT',
'STERBEGELDVERSICHERUNG',
'BESTATTUNGSVORSORGE',
'STERBEKASSE',
]
/**
* Vertragsarten, die einen Rueckkaufswert haben koennen. Nur bei diesen
* ergibt die Anforderung beim Versicherer Sinn.
*/
export const MIT_RUECKKAUFSWERT: VertragArt[] = [
'LEBENSVERSICHERUNG',
'RENTENVERSICHERUNG_PRIVAT',
'STERBEGELDVERSICHERUNG',
'BESTATTUNGSVORSORGE',
'STERBEKASSE',
]
export function kannRueckkaufswertHaben(art: VertragArt): boolean {
return MIT_RUECKKAUFSWERT.includes(art)
}
export function genehmigungPruefen(art: VertragArt): boolean {
return GENEHMIGUNG_PRUEFEN_BEI.includes(art)
}
/** Wertquellen fuer Rueckkaufswerte - Pflichtangabe an jedem Wertstand. */
export const WERTQUELLEN_VERTRAG = [
'Rückkaufswertmitteilung der Gesellschaft',
'Jahresstandmitteilung',
'telefonische Auskunft',
'eigene Schätzung',
] as const
export const RECHTSGRUNDLAGE_GENEHMIGUNG = '§ 1850 BGB'
+207
View File
@@ -0,0 +1,207 @@
import { alsIsoTag, heuteAlsTag, plusJahre, plusMonate, plusTage, tageZwischen, tagesdatum } from '@/lib/fristen/datum'
/**
* Kuendigungsfrist-Rechner.
*
* Die eigentliche Falle bei Versicherungsvertraegen ist nicht der
* Kuendigungstermin, sondern der Tag, an dem der Brief spaetestens raus
* muss. Eine Kuendigung wird mit dem ZUGANG beim Versicherer wirksam, nicht
* mit dem Absenden - wer am letzten Tag der Frist einwirft, hat die Frist
* verpasst und zahlt ein weiteres Jahr.
*
* Deshalb rechnet diese Datei drei Daten aus:
*
* 1. den naechstmoeglichen Kuendigungstermin (zu wann kann gekuendigt werden),
* 2. den Tag, an dem die Kuendigung spaetestens zugehen muss,
* 3. den spaetesten Absendetag mit Postlaufpuffer.
*
* Reine Logik, keine Datenbank.
*/
/**
* Puffer zwischen Absenden und Zugang. Drei Kalendertage sind knapp, aber
* bei einem Einwurf-Einschreiben realistisch. Wer es genauer braucht,
* schickt zusaetzlich ein Fax oder eine E-Mail - beides zaehlt fuer den
* Zugang und ist am selben Tag da.
*/
export const POSTLAUF_PUFFER_TAGE = 3
/** Ab wann wird gewarnt, dass der Absendetag naeher rueckt. */
export const KUENDIGUNG_VORWARNUNG_TAGE = 30
/**
* Verlaengerung, wenn im Vertrag keine Laufzeit hinterlegt ist. Ein Jahr
* ist bei Versicherungen der Regelfall (§ 11 Abs. 1 VVG).
*/
export const STANDARD_LAUFZEIT_JAHRE = 1
export type KuendigungEingabe = {
/** Laufzeitbeginn laut Vertrag. */
beginnAm: Date | null
/** Fest vereinbartes Vertragsende, falls bekannt. */
endeAm?: Date | null
/** Vertragsdauer in Jahren; fehlt sie, wird mit einem Jahr gerechnet. */
laufzeitJahre?: number | null
/** Kuendigungsfrist in Monaten zum Ende der Laufzeit. */
kuendigungsfristMonate: number | null
/** Verlaengert sich der Vertrag stillschweigend? Regelfall: ja. */
verlaengertSichAutomatisch?: boolean
/** Bezugstag; im Test gesetzt, sonst heute. */
heute?: Date
}
export type Kuendigungsplan = {
/** Laesst sich ueberhaupt etwas berechnen? */
berechenbar: boolean
/** Zu diesem Tag kann als Naechstes gekuendigt werden. */
naechsterTermin: Date | null
/** Bis zu diesem Tag muss die Kuendigung beim Vertragspartner sein. */
zugangSpaetestensAm: Date | null
/** Bis zu diesem Tag muss der Brief raus (Zugang minus Postlauf). */
spaetesterAbsendetag: Date | null
/** Tage von heute bis zum Absendetag. Negativ heisst: schon vorbei. */
tageBisAbsendetag: number | null
/** Weniger als 30 Tage bis zum Absendetag - jetzt handeln. */
vorwarnung: boolean
/** Der Absendetag ist verstrichen und der Vertrag laeuft aus. */
verpasst: boolean
/** Erklaerungen und Annahmen, die in die Rechnung eingegangen sind. */
hinweise: string[]
}
const LEERER_PLAN: Kuendigungsplan = {
berechenbar: false,
naechsterTermin: null,
zugangSpaetestensAm: null,
spaetesterAbsendetag: null,
tageBisAbsendetag: null,
vorwarnung: false,
verpasst: false,
hinweise: [],
}
/**
* Berechnet den naechstmoeglichen Kuendigungstermin.
*
* Vorgehen: Vom ersten Ablauftermin an wird geprueft, ob der zugehoerige
* Absendetag noch in der Zukunft liegt. Ist er verstrichen, wird um eine
* Laufzeit weitergerueckt. Der erste Termin, dessen Brief noch rechtzeitig
* rausgeht, ist der naechstmoegliche.
*/
export function kuendigungsplan(eingabe: KuendigungEingabe): Kuendigungsplan {
const heute = tagesdatum(eingabe.heute ?? heuteAlsTag())
const hinweise: string[] = []
const frist = eingabe.kuendigungsfristMonate
if (frist === null || frist === undefined) {
hinweise.push(
'Es ist keine Kündigungsfrist hinterlegt. Es wird ohne Frist gerechnet — ' +
'bitte im Vertrag nachsehen und nachtragen.',
)
}
const fristMonate = frist ?? 0
const laufzeit =
eingabe.laufzeitJahre && eingabe.laufzeitJahre > 0
? eingabe.laufzeitJahre
: STANDARD_LAUFZEIT_JAHRE
if (!eingabe.laufzeitJahre || eingabe.laufzeitJahre <= 0) {
hinweise.push(
`Im Vertrag steht keine Laufzeit. Gerechnet wird mit einer Verlängerung um ` +
`${STANDARD_LAUFZEIT_JAHRE} Jahr — bei Versicherungen der Regelfall.`,
)
}
const verlaengert = eingabe.verlaengertSichAutomatisch ?? true
// Erster Ablauftermin: das vereinbarte Ende, sonst Beginn plus Laufzeit.
let termin: Date | null = null
if (eingabe.endeAm) {
termin = tagesdatum(eingabe.endeAm)
} else if (eingabe.beginnAm) {
termin = plusJahre(tagesdatum(eingabe.beginnAm), laufzeit)
}
if (!termin) {
return {
...LEERER_PLAN,
hinweise: [
'Ohne Laufzeitbeginn oder Vertragsende lässt sich kein Kündigungstermin ' +
'berechnen. Bitte mindestens den Vertragsbeginn eintragen.',
],
}
}
// Bis zu 200 Verlaengerungen durchgehen - deckt auch einen Vertrag von
// 1962 ab, ohne bei fehlerhaften Daten endlos zu laufen.
let absendetag = absendetagZu(termin, fristMonate)
let durchlaeufe = 0
while (absendetag.getTime() < heute.getTime() && verlaengert && durchlaeufe < 200) {
termin = plusJahre(termin, laufzeit)
absendetag = absendetagZu(termin, fristMonate)
durchlaeufe += 1
}
const zugang = plusMonate(termin, -fristMonate)
const tageBis = tageZwischen(heute, absendetag)
const verpasst = absendetag.getTime() < heute.getTime()
if (verpasst) {
hinweise.push(
`Der Absendetag für den Termin ${alsIsoTag(termin)} ist verstrichen. ` +
`Dieser Vertrag verlängert sich nicht automatisch — eine Kündigung zu diesem ` +
`Termin ist nicht mehr möglich.`,
)
} else if (durchlaeufe > 0) {
hinweise.push(
`Der Vertrag hat sich bereits ${durchlaeufe}-mal verlängert. Angezeigt ist der ` +
`nächste Termin, zu dem noch rechtzeitig gekündigt werden kann.`,
)
}
hinweise.push(
`Eine Kündigung wird mit dem Zugang wirksam, nicht mit dem Absenden. Deshalb ` +
`sind zwischen Absendetag und Zugang ${POSTLAUF_PUFFER_TAGE} Tage Postlauf ` +
`eingerechnet. Am sichersten: Einschreiben mit Rückschein oder zusätzlich ` +
`ein Fax mit Sendeprotokoll.`,
)
return {
berechenbar: true,
naechsterTermin: termin,
zugangSpaetestensAm: zugang,
spaetesterAbsendetag: absendetag,
tageBisAbsendetag: tageBis,
vorwarnung: !verpasst && tageBis <= KUENDIGUNG_VORWARNUNG_TAGE,
verpasst,
hinweise,
}
}
/** Der Absendetag zu einem Termin: Termin minus Frist minus Postlauf. */
export function absendetagZu(termin: Date, fristMonate: number): Date {
return plusTage(plusMonate(tagesdatum(termin), -fristMonate), -POSTLAUF_PUFFER_TAGE)
}
/**
* Kurzfassung fuer die Uebersichtstabelle: eine Ampelstufe je Vertrag.
* Farbe ist nie die einzige Information - der Text steht daneben.
*/
export type Kuendigungsampel = {
variante: 'rot' | 'orange' | 'gelb' | 'gruen'
text: string
}
export function kuendigungsampel(plan: Kuendigungsplan): Kuendigungsampel | null {
if (!plan.berechenbar || plan.tageBisAbsendetag === null) return null
if (plan.verpasst) {
return { variante: 'rot', text: 'Absendetag verstrichen' }
}
const tage = plan.tageBisAbsendetag
if (tage <= 7) return { variante: 'rot', text: `Brief muss in ${tage} Tagen raus` }
if (tage <= KUENDIGUNG_VORWARNUNG_TAGE) {
return { variante: 'orange', text: `Brief muss in ${tage} Tagen raus` }
}
if (tage <= 60) return { variante: 'gelb', text: `Absendetag in ${tage} Tagen` }
return { variante: 'gruen', text: `Absendetag in ${tage} Tagen` }
}
+242
View File
@@ -0,0 +1,242 @@
import type { VertragArt } from '@prisma/client'
import { formatDatum } from '@/lib/utils'
import { alsIsoTag, heuteAlsTag } from '@/lib/fristen/datum'
import {
absenderBlock,
anschriftBlock,
bestellungsAbsatz,
betreffZeile,
datumsZeile,
grussformel,
type Briefkopf,
type Empfaenger,
} from '@/lib/vertraege/anschreiben'
import {
genehmigungPruefen,
kannRueckkaufswertHaben,
RECHTSGRUNDLAGE_GENEHMIGUNG,
VERTRAG_ART_BESCHRIFTUNG,
} from '@/lib/vertraege/konstanten'
import {
kuendigungsplan,
type Kuendigungsplan,
type KuendigungEingabe,
} from '@/lib/vertraege/kuendigung'
/**
* Kuendigungs-Assistent.
*
* Er tut absichtlich drei Dinge NICHT: Er kuendigt nicht, er verschickt
* nichts, und er entscheidet nicht, ob eine Genehmigung noetig ist.
*
* Der Grund fuer das Letzte ist wichtig genug, um ihn hier hinzuschreiben:
* Ob die Verwertung einer Lebensversicherung nach § 1850 BGB
* genehmigungspflichtig ist, haengt am Einzelfall - an der Vertragsart, am
* Wert, an der Frage, ob es sich um eine Verfuegung ueber eine Forderung
* handelt, und an der Handhabung des jeweiligen Betreuungsgerichts. Eine
* Automatik, die "nicht genehmigungspflichtig" ausgibt und daneben liegt,
* macht das Rechtsgeschaeft schwebend unwirksam und den Betreuer
* haftbar. Also: Der Assistent stellt die Frage, laut und sichtbar, und
* verlangt eine bewusste Antwort - die Rueckfrage beim Rechtspfleger.
*/
export type KuendigungsassistentEingabe = {
kopf: Briefkopf
empfaenger: Empfaenger
vertragsbezeichnung: string
vertragsnummer: string | null
art: VertragArt
laufzeit: KuendigungEingabe
/** Soll zum naechstmoeglichen Termin gekuendigt werden oder zu einem anderen? */
kuendigungZum?: Date | null
heute?: Date
}
export type Genehmigungsfrage = {
/** Muss die Frage gestellt werden? */
stellen: boolean
frage: string
rechtsgrundlage: string
hinweis: string
}
export type Versandnachweis = {
/** Ohne Nachweis wird die Kuendigung nicht als erledigt gefuehrt. */
verlangt: true
moeglichkeiten: string[]
hinweis: string
}
export type Kuendigungspaket = {
plan: Kuendigungsplan
/** Der Termin, zu dem gekuendigt wird. */
terminZum: Date | null
schreiben: string
genehmigung: Genehmigungsfrage
versandnachweis: Versandnachweis
/** Alles, was vor dem Absenden geklaert sein muss. */
warnungen: string[]
}
export function kuendigungsassistent(eingabe: KuendigungsassistentEingabe): Kuendigungspaket {
const heute = eingabe.heute ?? heuteAlsTag()
const plan = kuendigungsplan({ ...eingabe.laufzeit, heute })
const terminZum = eingabe.kuendigungZum ?? plan.naechsterTermin
const warnungen: string[] = []
if (!plan.berechenbar) {
warnungen.push(
'Der Kündigungstermin lässt sich nicht berechnen. Trag Vertragsbeginn und ' +
'Kündigungsfrist nach, bevor du das Schreiben abschickst — sonst kündigst ' +
'du ins Blaue.',
)
}
if (plan.verpasst) {
warnungen.push(
'Der Absendetag für den errechneten Termin ist verstrichen. Schick die ' +
'Kündigung trotzdem los, aber rechne mit einer Bestätigung zum übernächsten ' +
'Termin — und prüfe, ob ein Sonderkündigungsrecht greift.',
)
}
if (plan.vorwarnung && !plan.verpasst) {
warnungen.push(
`Der Brief muss bis zum ${formatDatum(plan.spaetesterAbsendetag)} raus. ` +
`Das sind nur noch ${plan.tageBisAbsendetag} Tage.`,
)
}
if (kannRueckkaufswertHaben(eingabe.art)) {
warnungen.push(
'Vor der Kündigung den Rückkaufswert UND die beitragsfreie ' +
'Versicherungssumme vergleichen. Eine Beitragsfreistellung erhält den ' +
'Vertrag und ist bei Sterbegeld- und Bestattungsvorsorgeverträgen häufig ' +
'die bessere Lösung als der Rückkauf — gekündigtes Geld kommt nicht zurück.',
)
}
if (eingabe.art === 'BESTATTUNGSVORSORGE' || eingabe.art === 'STERBEKASSE') {
warnungen.push(
'Eine angemessene Bestattungsvorsorge ist regelmäßig Schonvermögen ' +
'(§ 90 Abs. 3 SGB XII). Kündige sie nicht vorschnell, nur weil der Träger ' +
'sie in einer Aufstellung führt — erst die Angemessenheit klären.',
)
}
if (!eingabe.vertragsnummer) {
warnungen.push(
'Die Vertragsnummer fehlt. Ohne sie ordnet die Gesellschaft das Schreiben ' +
'nicht zu, und die Frist läuft weiter.',
)
}
return {
plan,
terminZum,
schreiben: kuendigungsschreiben(eingabe, terminZum, heute),
genehmigung: genehmigungsfrage(eingabe.art),
versandnachweis: VERSANDNACHWEIS,
warnungen,
}
}
/**
* Die Frage nach der Genehmigung. Sie wird gestellt, nicht beantwortet.
*/
export function genehmigungsfrage(art: VertragArt): Genehmigungsfrage {
const bezeichnung = VERTRAG_ART_BESCHRIFTUNG[art]
if (!genehmigungPruefen(art)) {
return {
stellen: false,
frage: `Ist die Kündigung dieses Vertrages (${bezeichnung}) genehmigungspflichtig?`,
rechtsgrundlage: RECHTSGRUNDLAGE_GENEHMIGUNG,
hinweis:
'Bei laufenden Verbrauchs- und Sachversicherungsverträgen ist eine ' +
'Genehmigung in der Regel nicht erforderlich. Bist du unsicher, kostet ' +
'die Rückfrage beim Rechtspfleger fünf Minuten.',
}
}
return {
stellen: true,
frage:
`Hast du beim Rechtspfleger geklärt, ob die Verwertung dieses Vertrages ` +
`(${bezeichnung}) genehmigungspflichtig ist?`,
rechtsgrundlage: RECHTSGRUNDLAGE_GENEHMIGUNG,
hinweis:
'Die Kündigung einer Lebens- oder Rentenversicherung ist eine Verfügung ' +
'über eine Forderung und kann nach § 1850 BGB der Genehmigung des ' +
'Betreuungsgerichts bedürfen. Ob sie es im konkreten Fall tut, hängt am ' +
'Einzelfall — diese Anwendung entscheidet das ausdrücklich nicht. ' +
'Die Genehmigung muss VOR dem Rechtsgeschäft vorliegen (§ 1858 BGB); ' +
'wer erst kündigt und dann fragt, hat ein schwebend unwirksames Geschäft ' +
'und im schlimmsten Fall ein Haftungsproblem. Ein kurzer Anruf beim ' +
'zuständigen Rechtspfleger, notiert im Kontaktprotokoll, klärt das.',
}
}
export const VERSANDNACHWEIS: Versandnachweis = {
verlangt: true,
moeglichkeiten: [
'Einschreiben mit Rückschein (Rückschein einscannen)',
'Einwurf-Einschreiben (Einlieferungsbeleg und Sendungsstatus)',
'Fax mit Sendeprotokoll („OK“, mit Datum und Uhrzeit)',
'Bestätigung der Gesellschaft über den Eingang der Kündigung',
],
hinweis:
'Ohne Nachweis über den Zugang ist die Kündigung im Streitfall nicht ' +
'bewiesen — und beweisen muss sie derjenige, der gekündigt hat. Lade den ' +
'Nachweis als Dokument hoch und verknüpfe ihn mit dem Vertrag, bevor du ' +
'die Kündigung hier als erledigt einträgst.',
}
function kuendigungsschreiben(
eingabe: KuendigungsassistentEingabe,
terminZum: Date | null,
heute: Date,
): string {
const kopf = eingabe.kopf
const zum = terminZum
? `zum nächstmöglichen Termin, nach meiner Berechnung zum ${formatDatum(terminZum)},`
: 'zum nächstmöglichen Termin'
return [
absenderBlock(kopf),
'',
anschriftBlock(eingabe.empfaenger),
'',
datumsZeile(kopf.betreuerOrt, heute),
'',
betreffZeile(
'Kündigung des Vertrages',
eingabe.vertragsnummer,
kopf.betreuteName,
kopf.betreuteGeburtsdatum,
),
'',
'Sehr geehrte Damen und Herren,',
'',
bestellungsAbsatz(kopf),
'',
`hiermit kündige ich den oben genannten Vertrag ` +
`(${eingabe.vertragsbezeichnung}) ${zum} ` +
`hilfsweise zum nächstmöglichen Zeitpunkt.`,
'',
'Ich bitte um schriftliche Bestätigung der Kündigung unter Angabe des ' +
'Beendigungszeitpunktes sowie um Mitteilung, welcher Betrag zur Auszahlung ' +
'kommt und wann die Auszahlung erfolgt.',
'',
'Die Auszahlung bitte ich ausschließlich auf ein Konto der Versicherungsnehmerin ' +
'vorzunehmen; die Bankverbindung teile ich Ihnen auf Anforderung mit. ' +
'Zahlungen auf ein Konto des Betreuers sind ausgeschlossen.',
'',
'Sollten dem Vertrag Abtretungen, Verpfändungen oder unwiderrufliche ' +
'Bezugsrechte entgegenstehen, bitte ich um sofortige Mitteilung, bevor Sie ' +
'die Kündigung ausführen.',
'',
grussformel(kopf.betreuerName),
'',
'Anlage: Kopie der Bestellungsurkunde',
'',
`— Entwurf erstellt am ${alsIsoTag(heute)}. Bitte vor dem Absenden prüfen und ` +
`an den Einzelfall anpassen. —`,
].join('\n')
}
+116
View File
@@ -0,0 +1,116 @@
import { formatDatum } from '@/lib/utils'
import { heuteAlsTag, plusTage, tageZwischen } from '@/lib/fristen/datum'
import {
absenderBlock,
anschriftBlock,
bestellungsAbsatz,
betreffZeile,
datumsZeile,
grussformel,
type Briefkopf,
type Empfaenger,
} from '@/lib/vertraege/anschreiben'
/**
* Anforderung des Rueckkaufswerts beim Versicherer.
*
* Warum das ein eigener Knopf ist: Der Bezirk verlangt fuer die Hilfe zur
* Pflege zu jedem Versicherungsvertrag den Rueckkaufswert zu einem
* Stichtag - und zwar schriftlich von der Gesellschaft, nicht aus der
* Jahresmitteilung geschaetzt. Die Antwort dauert regelmaessig drei bis
* sechs Wochen. Wer die Anfrage vergisst, haelt am Ende das ganze
* Verfahren auf.
*
* Deshalb: Brief erzeugen, Datum der Anfrage festhalten, Wiedervorlage in
* vier Wochen. Die Wiedervorlage legt die Server Action als Frist an.
*/
export const RUECKKAUFSWERT_WIEDERVORLAGE_TAGE = 28
export function wiedervorlageAm(angefragtAm: Date = heuteAlsTag()): Date {
return plusTage(angefragtAm, RUECKKAUFSWERT_WIEDERVORLAGE_TAGE)
}
export type RueckkaufswertAnfrage = {
kopf: Briefkopf
empfaenger: Empfaenger
vertragsbezeichnung: string
vertragsnummer: string | null
/** Zu welchem Tag soll der Wert mitgeteilt werden? */
stichtag: Date
/** Datum des Schreibens. */
heute?: Date
}
/**
* Erzeugt das Anschreiben als Freitext.
*
* Abgefragt wird bewusst mehr als nur der Rueckkaufswert: Die
* beitragsfreie Versicherungssumme ist die Alternative zur Kuendigung -
* der Vertrag bleibt bestehen, es wird nur nichts mehr eingezahlt. Bei
* einer Sterbegeldversicherung ist das haeufig die bessere Loesung als der
* Rueckkauf, und ohne die Zahl laesst sich das nicht vergleichen.
*/
export function rueckkaufswertAnschreiben(anfrage: RueckkaufswertAnfrage): string {
const heute = anfrage.heute ?? heuteAlsTag()
const kopf = anfrage.kopf
return [
absenderBlock(kopf),
'',
anschriftBlock(anfrage.empfaenger),
'',
datumsZeile(kopf.betreuerOrt, heute),
'',
betreffZeile(
'Auskunft über Rückkaufswert und beitragsfreie Versicherungssumme',
anfrage.vertragsnummer,
kopf.betreuteName,
kopf.betreuteGeburtsdatum,
),
'',
'Sehr geehrte Damen und Herren,',
'',
bestellungsAbsatz(kopf),
'',
`für ein laufendes Verfahren auf Hilfe zur Pflege beim überörtlichen ` +
`Sozialhilfeträger benötige ich zu dem oben genannten Vertrag ` +
`(${anfrage.vertragsbezeichnung}) folgende Auskünfte, jeweils zum Stichtag ` +
`${formatDatum(anfrage.stichtag)}:`,
'',
'1. den Rückkaufswert einschließlich bereits zugeteilter Überschussanteile,',
'2. die beitragsfreie Versicherungssumme bei sofortiger Beitragsfreistellung,',
'3. die Höhe der laufenden Beiträge und den Zahlungsrhythmus,',
'4. Beginn, vereinbartes Ende und die Kündigungsfrist des Vertrages,',
'5. ob der Vertrag abgetreten, verpfändet oder mit einem unwiderruflichen ' +
'Bezugsrecht belegt ist,',
'6. ob eine Verwertungsausschlussvereinbarung besteht.',
'',
`Ich bitte um schriftliche Mitteilung an die oben genannte Anschrift. Da im ` +
`Verfahren beim Sozialhilfeträger eine Frist läuft, wäre ich für eine Antwort ` +
`bis zum ${formatDatum(wiedervorlageAm(heute))} dankbar.`,
'',
'Für Rückfragen stehe ich gerne zur Verfügung.',
'',
grussformel(kopf.betreuerName),
'',
'Anlage: Kopie der Bestellungsurkunde',
].join('\n')
}
/**
* Wie lange ist die letzte Anfrage her? Grundlage fuer den Hinweis
* "Erinnerung faellig".
*/
export function anfrageStatus(
angefragtAm: Date | null,
heute: Date = heuteAlsTag(),
): { offen: boolean; erinnerungFaellig: boolean; tageSeitAnfrage: number | null } {
if (!angefragtAm) return { offen: false, erinnerungFaellig: false, tageSeitAnfrage: null }
const tage = tageZwischen(angefragtAm, heute)
return {
offen: true,
erinnerungFaellig: tage >= RUECKKAUFSWERT_WIEDERVORLAGE_TAGE,
tageSeitAnfrage: tage,
}
}
+393
View File
@@ -0,0 +1,393 @@
import type { GenehmigungStatus } from '@prisma/client'
/**
* Die Ablaufkette für genehmigungspflichtige Vorgänge.
*
* Warum eine feste Kette? Weil die Reihenfolge im Betreuungsrecht keine
* Geschmacksfrage ist. Die Genehmigung des Betreuungsgerichts muss VOR dem
* Rechtsgeschäft vorliegen (§ 1858 BGB); ein ohne Genehmigung geschlossener
* Vertrag ist schwebend unwirksam, und der Betreuer haftet persönlich, wenn
* daraus ein Schaden entsteht. Eine Oberfläche, in der man den Vollzug
* einfach anklicken kann, wäre also nicht nur unordentlich, sondern
* gefährlich.
*
* Deshalb steht die Regel hier in der Fachlogik und wird zusätzlich in der
* Server Action geprüft - nicht nur im UI. Das UI kann man umgehen.
*/
export const GENEHMIGUNGSKETTE = [
'GEPLANT',
'MITGETEILT',
'BEANTRAGT',
'ANGEHOERT',
'ERTEILT',
'RECHTSKRAEFTIG',
'VOLLZOGEN',
] as const satisfies readonly GenehmigungStatus[]
export type Kettenstatus = (typeof GENEHMIGUNGSKETTE)[number]
/** Seitenausgang: Das Gericht hat die Genehmigung versagt. */
export const SEITENAUSGANG: GenehmigungStatus = 'VERSAGT'
/**
* Der stehende Warnhinweis auf jeder genehmigungspflichtigen Ansicht.
* Ein Satz, der über allem steht, was hier passiert.
*/
export const GENEHMIGUNG_WARNUNG =
'Die Genehmigung muss vor dem Rechtsgeschäft vorliegen (§ 1858 BGB). ' +
'Bitte mit dem Rechtspfleger abstimmen.'
// -----------------------------------------------------------------------------
// Beschriftungen und Stufen
// -----------------------------------------------------------------------------
export type Stufe = {
status: GenehmigungStatus
beschriftung: string
erklaerung: string
/** Feld im Datensatz, in dem das Datum dieser Stufe steht. */
datumsfeld:
| 'mitteilungAm'
| 'antragAm'
| 'anhoerungAm'
| 'beschlussAm'
| 'rechtskraftAm'
| 'vollzugAm'
| null
}
export const GENEHMIGUNG_STUFEN: Stufe[] = [
{
status: 'GEPLANT',
beschriftung: 'Geplant',
erklaerung:
'Der Genehmigungsbedarf ist erkannt, beim Gericht ist noch nichts eingegangen. ' +
'Bis hierher darf noch nichts unterschrieben werden.',
datumsfeld: null,
},
{
status: 'MITGETEILT',
beschriftung: 'Dem Gericht mitgeteilt',
erklaerung:
'Die Absicht ist dem Betreuungsgericht angezeigt. Bei der Aufgabe von Wohnraum ist ' +
'diese Mitteilung eigenständig vorgeschrieben (§ 1833 Abs. 1 BGB) und geht der ' +
'Kündigung voraus.',
datumsfeld: 'mitteilungAm',
},
{
status: 'BEANTRAGT',
beschriftung: 'Genehmigung beantragt',
erklaerung:
'Der Antrag liegt beim Gericht, mit Vertragsentwurf und Begründung. Ab hier läuft ' +
'das Verfahren beim Rechtspfleger.',
datumsfeld: 'antragAm',
},
{
status: 'ANGEHOERT',
beschriftung: 'Anhörung erfolgt',
erklaerung:
'Die betreute Person und gegebenenfalls ein Verfahrenspfleger wurden angehört. ' +
'Ohne Anhörung ergeht kein Beschluss.',
datumsfeld: 'anhoerungAm',
},
{
status: 'ERTEILT',
beschriftung: 'Beschluss erteilt',
erklaerung:
'Das Gericht hat die Genehmigung erteilt. Der Beschluss ist damit noch nicht ' +
'rechtskräftig - die Beschwerdefrist läuft erst.',
datumsfeld: 'beschlussAm',
},
{
status: 'RECHTSKRAEFTIG',
beschriftung: 'Rechtskräftig',
erklaerung:
'Die Beschwerdefrist ist abgelaufen. Jetzt eine Ausfertigung mit Rechtskraftvermerk ' +
'anfordern - der Vertragspartner braucht genau dieses Papier (§ 1856 BGB).',
datumsfeld: 'rechtskraftAm',
},
{
status: 'VOLLZOGEN',
beschriftung: 'Vollzogen',
erklaerung:
'Das Rechtsgeschäft ist ausgeführt. Erst möglich, wenn der Beschluss als Dokument ' +
'hinterlegt ist - ohne Beleg gibt es gegenüber Gericht und Miterben keinen Nachweis.',
datumsfeld: 'vollzugAm',
},
]
export const GENEHMIGUNG_STATUS_BESCHRIFTUNG: Record<GenehmigungStatus, string> = {
GEPLANT: 'Geplant',
MITGETEILT: 'Dem Gericht mitgeteilt',
BEANTRAGT: 'Genehmigung beantragt',
ANGEHOERT: 'Anhörung erfolgt',
ERTEILT: 'Beschluss erteilt',
VERSAGT: 'Versagt',
RECHTSKRAEFTIG: 'Rechtskräftig',
VOLLZOGEN: 'Vollzogen',
}
/** Position in der Kette, -1 für den Seitenausgang VERSAGT. */
export function stufeVon(status: GenehmigungStatus): number {
return (GENEHMIGUNGSKETTE as readonly GenehmigungStatus[]).indexOf(status)
}
export function istKettenstatus(status: GenehmigungStatus): status is Kettenstatus {
return stufeVon(status) >= 0
}
/** Der reguläre nächste Schritt, oder null am Ende der Kette. */
export function naechsterStatus(status: GenehmigungStatus): GenehmigungStatus | null {
const stufe = stufeVon(status)
if (stufe < 0) return null
return GENEHMIGUNGSKETTE[stufe + 1] ?? null
}
/** Das Datumsfeld, das beim Erreichen eines Status gesetzt wird. */
export function datumsfeldFuer(status: GenehmigungStatus): Stufe['datumsfeld'] {
return GENEHMIGUNG_STUFEN.find((s) => s.status === status)?.datumsfeld ?? null
}
// -----------------------------------------------------------------------------
// Die Regel
// -----------------------------------------------------------------------------
/** Das Nötigste, was zur Prüfung eines Statuswechsels bekannt sein muss. */
export type PruefbareGenehmigung = {
status: GenehmigungStatus
beschlussDokumentId?: string | null
}
export type Wechselpruefung = {
erlaubt: boolean
/** Warum nicht - im Klartext, direkt anzeigbar. */
grund?: string
}
/**
* Darf die Genehmigung in diesen Status wechseln?
*
* Die Regeln:
*
* 1. Vorwärts immer nur eine Stufe. Wer von BEANTRAGT direkt auf VOLLZOGEN
* springt, überspringt Anhörung, Beschluss und Rechtskraft - genau der
* Fehler, den diese Kette verhindern soll.
* 2. Eine Stufe zurück ist als Korrektur erlaubt (verklickt, Beschluss
* doch noch nicht da).
* 3. VERSAGT ist der Seitenausgang: erreichbar, solange beim Gericht etwas
* anhängig und noch nichts rechtskräftig ist. Von dort führt der Weg
* zurück auf GEPLANT - ein geänderter Vertrag kann neu vorgelegt werden.
* 4. VOLLZOGEN ist endgültig. Ein ausgeführtes Rechtsgeschäft lässt sich
* nicht per Auswahlfeld zurücknehmen.
* 5. **VOLLZOGEN nur mit hinterlegtem Beschlussdokument.** Das ist die
* Kernregel: Der Vollzug ohne Nachweis der Genehmigung ist genau der
* Fall, in dem der Betreuer nach § 1858 BGB persönlich haftet. Diese
* Prüfung steht hier UND in der Server Action.
*/
export function darfStatusWechseln(
genehmigung: PruefbareGenehmigung,
neuerStatus: GenehmigungStatus,
): Wechselpruefung {
const alt = genehmigung.status
if (alt === neuerStatus) {
return {
erlaubt: false,
grund: `Die Genehmigung steht bereits auf „${GENEHMIGUNG_STATUS_BESCHRIFTUNG[alt]}“.`,
}
}
if (alt === 'VOLLZOGEN') {
return {
erlaubt: false,
grund:
'Ein vollzogenes Rechtsgeschäft lässt sich hier nicht zurücknehmen. ' +
'Wenn etwas nicht stimmt, gehört das ins Protokoll und ans Gericht.',
}
}
// Regel 5 zuerst: Sie gilt unabhaengig davon, aus welcher Stufe der
// Wechsel kommt, und ist die Regel, an der es am haeufigsten scheitert.
if (neuerStatus === 'VOLLZOGEN' && !genehmigung.beschlussDokumentId) {
return {
erlaubt: false,
grund:
'Ohne hinterlegten Genehmigungsbeschluss kann der Vollzug nicht eingetragen werden. ' +
'Bitte zuerst den Beschluss als Dokument hochladen und mit dieser Genehmigung ' +
'verknüpfen. ' +
GENEHMIGUNG_WARNUNG,
}
}
if (neuerStatus === 'VERSAGT') {
const versagbar: GenehmigungStatus[] = ['MITGETEILT', 'BEANTRAGT', 'ANGEHOERT', 'ERTEILT']
if (!versagbar.includes(alt)) {
return {
erlaubt: false,
grund:
'Versagen kann das Gericht nur, solange etwas anhängig und noch nichts ' +
'rechtskräftig ist.',
}
}
return { erlaubt: true }
}
if (alt === 'VERSAGT') {
if (neuerStatus !== 'GEPLANT') {
return {
erlaubt: false,
grund:
'Nach einer Versagung fängt das Verfahren wieder bei „Geplant“ an. ' +
'Ein geänderter Vertragsentwurf kann dem Gericht erneut vorgelegt werden.',
}
}
return { erlaubt: true }
}
const stufeAlt = stufeVon(alt)
const stufeNeu = stufeVon(neuerStatus)
if (stufeAlt < 0 || stufeNeu < 0) {
return { erlaubt: false, grund: 'Dieser Statuswechsel ist nicht vorgesehen.' }
}
const abstand = stufeNeu - stufeAlt
if (abstand === 1) return { erlaubt: true }
if (abstand === -1) return { erlaubt: true } // Korrektur
if (abstand > 1) {
const uebersprungen = GENEHMIGUNGSKETTE.slice(stufeAlt + 1, stufeNeu)
.map((s) => GENEHMIGUNG_STATUS_BESCHRIFTUNG[s])
.join(', ')
return {
erlaubt: false,
grund:
`Dieser Schritt überspringt: ${uebersprungen}. Die Kette wird der Reihe nach ` +
`durchlaufen. ` +
GENEHMIGUNG_WARNUNG,
}
}
return {
erlaubt: false,
grund:
'Es lässt sich immer nur eine Stufe zurückgehen. Für größere Korrekturen bitte den ' +
'Verlauf im Vorgang festhalten.',
}
}
/** Alle Status, in die von hier aus gewechselt werden darf - für das Auswahlfeld. */
export function moeglicheStatusWechsel(genehmigung: PruefbareGenehmigung): GenehmigungStatus[] {
const alle: GenehmigungStatus[] = [...GENEHMIGUNGSKETTE, SEITENAUSGANG]
return alle.filter((s) => darfStatusWechseln(genehmigung, s).erlaubt)
}
// -----------------------------------------------------------------------------
// Rechtsgrundlagen
// -----------------------------------------------------------------------------
export type Rechtsgrundlage = {
/** Genau so, wie es im Feld Genehmigung.rechtsgrundlage steht. */
wert: string
kurz: string
/** Was steckt dahinter - in normalen Worten. */
erklaerung: string
beispiele: string[]
}
/**
* Die wählbaren Rechtsgrundlagen mit Klartext-Erklärung.
*
* Das Feld in der Datenbank ist bewusst Freitext, damit auch seltene
* Tatbestände erfasst werden können. Diese Liste ist die Auswahlhilfe für
* den Alltag - jede Zeile bekommt in der Oberfläche einen
* <Rechtshinweis>, weil hier Paragraphen stehen.
*
* Die Zuordnung eines konkreten Geschäfts zu einer Vorschrift ist im
* Zweifel Sache des Rechtspflegers, nicht dieser Liste.
*/
export const RECHTSGRUNDLAGEN: Rechtsgrundlage[] = [
{
wert: '§ 1850 BGB',
kurz: 'Grundstücksgeschäfte',
erklaerung:
'Alles, was ein Grundstück betrifft: Verkauf, Kauf, Belastung mit Grundschuld oder ' +
'Hypothek, Erbbaurecht - und schon die Verpflichtung dazu. Der Vertrag wird erst ' +
'wirksam, wenn die rechtskräftige Genehmigung dem Vertragspartner vorgelegt wird.',
beispiele: [
'Verkauf des Hausgrundstücks',
'Grundschuld für ein Darlehen nach § 91 SGB XII',
'Bestellung eines Wohnrechts',
],
},
{
wert: '§ 1833 BGB',
kurz: 'Aufgabe von Wohnraum',
erklaerung:
'Die Aufgabe der Wohnung der betreuten Person ist dem Gericht mitzuteilen und ' +
'genehmigungsbedürftig. Die Mitteilung geht der Kündigung voraus - nicht umgekehrt. ' +
'Diese Vorschrift gilt auch für befreite Betreuer.',
beispiele: [
'Kündigung der Mietwohnung nach dem Heimeinzug',
'Vermietung der bisher selbst bewohnten Wohnung',
'Auflösung des Haushalts',
],
},
{
wert: '§ 1854 BGB',
kurz: 'Erbschaftsangelegenheiten und weitere Geschäfte',
erklaerung:
'Sammelvorschrift für Geschäfte, die für die betreute Person besonders folgenreich ' +
'sind - allen voran das Erbrecht. Wer eine Erbschaft ausschlägt, gibt sie endgültig ' +
'auf; das prüft das Gericht mit.',
beispiele: [
'Ausschlagung einer Erbschaft',
'Erbauseinandersetzung mit Miterben',
'Verzicht auf einen Pflichtteil',
],
},
{
wert: '§ 1851 BGB',
kurz: 'Kreditaufnahme und Wertpapiergeschäfte',
erklaerung:
'Geld auf den Kredit der betreuten Person aufnehmen und Geschäfte mit Wertpapieren. ' +
'Beides kann Vermögen schnell und dauerhaft mindern - deshalb schaut das Gericht ' +
'darauf.',
beispiele: [
'Aufnahme eines Darlehens',
'Verkauf eines Wertpapierdepots',
'Übernahme einer Bürgschaft',
],
},
{
wert: '§ 1849 BGB',
kurz: 'Verfügungen über angelegtes Geld und Forderungen',
erklaerung:
'Betrifft Guthaben, die mit Sperrvermerk angelegt sind, und Forderungen der ' +
'betreuten Person: Abheben über den Freibetrag hinaus, Abtreten, Erlassen oder ' +
'Kündigen einer Forderung.',
beispiele: [
'Auflösung eines gesperrten Sparguthabens',
'Verzicht auf eine Forderung gegen einen Angehörigen',
'Kündigung einer festverzinslichen Anlage über dem Freibetrag',
],
},
{
wert: '§ 1859 BGB',
kurz: 'Befreite Betreuung - wann die Genehmigung entfällt',
erklaerung:
'Wer als Ehegatte oder als Verwandter in gerader Linie betreut, ist von einem Teil ' +
'der Genehmigungserfordernisse und von den Anlagevorschriften befreit. Nicht befreit ' +
'ist man von der Genehmigung für Grundstücksgeschäfte (§ 1850 BGB) und von der ' +
'Aufgabe von Wohnraum (§ 1833 BGB). Der Umfang der Befreiung steht im ' +
'Bestellungsbeschluss - dort nachlesen, nicht raten.',
beispiele: [
'Prüfung, ob für dieses Geschäft überhaupt eine Genehmigung nötig ist',
'Rückfrage beim Rechtspfleger zum Umfang der Befreiung',
],
},
]
export function rechtsgrundlageFinden(wert: string): Rechtsgrundlage | undefined {
return RECHTSGRUNDLAGEN.find((r) => r.wert === wert)
}
+145
View File
@@ -0,0 +1,145 @@
import { differenceInCalendarDays } from 'date-fns'
import { formatDatum } from '@/lib/utils'
/**
* Sachstandsanfrage.
*
* Der häufigste Grund, warum ein Vorgang liegen bleibt, ist nicht Faulheit,
* sondern Warten: Der Antrag ist raus, und dann passiert monatelang nichts.
* Nachfassen hilft - aber nur, wenn man dabei sagt, worauf man sich bezieht.
* Deshalb erzeugt diese Funktion einen fertigen Text mit Aktenzeichen,
* Gegenstelle und dem Datum des letzten Kontakts.
*
* Bewusst Freitext zum Kopieren und kein PDF: Ich schreibe die meisten
* dieser Nachrichten per E-Mail oder aus einem Behördenportal heraus. Das
* PDF-Anschreiben kommt in Etappe 7 dazu.
*
* Rein und ohne Datenbank, damit der Wortlaut im Test prüfbar bleibt.
*/
export type SachstandsanfrageEingabe = {
vorgangTitel: string
/** Behörde oder Stelle, die am Zug ist. */
gegenstelle: string | null
/** Aktenzeichen, unter dem die Gegenstelle den Vorgang führt. */
aktenzeichen: string | null
/** Wann wurde zuletzt geschrieben oder gesprochen? */
letzterKontaktAm: Date | null
betreute: {
vorname: string
nachname: string
geburtsdatum: Date | null
}
betreuer: {
name: string
/** Aktenzeichen des Betreuungsgerichts, belegt die Vertretungsmacht. */
aktenzeichenGericht?: string | null
betreuungsgericht?: string | null
}
/** Ansprechpartner bei der Gegenstelle, wenn bekannt. */
ansprechpartner?: string | null
/** Für den Test überschreibbar. */
heute?: Date
}
export type Sachstandsanfrage = {
betreff: string
/** Vollständiger Text zum Kopieren, inklusive Betreffzeile. */
text: string
/** Tage seit dem letzten Kontakt, null wenn keiner erfasst ist. */
tageSeitKontakt: number | null
}
export function sachstandsanfrageErzeugen(
eingabe: SachstandsanfrageEingabe,
): Sachstandsanfrage {
const heute = eingabe.heute ?? new Date()
const name = `${eingabe.betreute.vorname} ${eingabe.betreute.nachname}`.trim()
const tageSeitKontakt = eingabe.letzterKontaktAm
? differenceInCalendarDays(heute, eingabe.letzterKontaktAm)
: null
const betreff =
[
`Sachstandsanfrage: ${eingabe.vorgangTitel}`,
`${name}${eingabe.betreute.geburtsdatum ? `, geb. ${formatDatum(eingabe.betreute.geburtsdatum)}` : ''}`,
eingabe.aktenzeichen ? `Az. ${eingabe.aktenzeichen}` : null,
]
.filter(Boolean)
.join(' · ')
const anrede = eingabe.ansprechpartner
? `Sehr geehrte(r) ${eingabe.ansprechpartner},`
: 'Sehr geehrte Damen und Herren,'
const zeilen: string[] = []
if (eingabe.gegenstelle) zeilen.push(eingabe.gegenstelle, '')
zeilen.push(formatDatum(heute), '')
zeilen.push(`Betreff: ${betreff}`, '')
zeilen.push(anrede, '')
// Kern: Wer schreibt, in welcher Eigenschaft, worum ging es zuletzt.
const eigenschaft = eingabe.betreuer.aktenzeichenGericht
? `als rechtlicher Betreuer von ${name} (Betreuungsgericht${
eingabe.betreuer.betreuungsgericht ? ` ${eingabe.betreuer.betreuungsgericht}` : ''
}, Az. ${eingabe.betreuer.aktenzeichenGericht})`
: `als rechtlicher Betreuer von ${name}`
zeilen.push(
`ich wende mich ${eigenschaft} in der oben genannten Angelegenheit ` +
`„${eingabe.vorgangTitel}“ an Sie.`,
)
zeilen.push('')
if (eingabe.letzterKontaktAm && tageSeitKontakt !== null) {
zeilen.push(
`Zuletzt standen wir am ${formatDatum(eingabe.letzterKontaktAm)} in dieser Sache in ` +
`Kontakt; das ist inzwischen ${tageSeitKontakt} Tage her. Eine Rückmeldung ist ` +
`seitdem hier nicht eingegangen.`,
)
} else {
zeilen.push(
'Eine Rückmeldung in dieser Sache ist hier bisher nicht eingegangen.',
)
}
zeilen.push('')
zeilen.push(
'Ich bitte Sie um eine kurze Mitteilung zum Sachstand: ob die Unterlagen vollständig ' +
'vorliegen, wer die Angelegenheit bearbeitet und bis wann mit einer Entscheidung zu ' +
'rechnen ist.',
)
zeilen.push('')
zeilen.push(
'Sollten Unterlagen fehlen, reiche ich sie kurzfristig nach - bitte teilen Sie mir in ' +
'diesem Fall mit, worum es genau geht.',
)
zeilen.push('')
zeilen.push('Vielen Dank für Ihre Mühe.', '')
zeilen.push('Mit freundlichen Grüßen', '', eingabe.betreuer.name, 'Rechtlicher Betreuer')
return {
betreff,
text: zeilen.join('\n'),
tageSeitKontakt,
}
}
/**
* Ab wann ist Nachfassen angebracht?
*
* Sechs Wochen sind die Erfahrungsgröße: kürzer wirkt drängelnd, länger
* verschenkt Zeit. Die harte Grenze steht woanders - nach sechs Monaten
* ohne Bescheid ist die Untätigkeitsklage nach § 88 SGG möglich.
*/
export const NACHFASSEN_AB_TAGEN = 42
export function nachfassenFaellig(
letzterKontaktAm: Date | null,
heute: Date = new Date(),
): boolean {
if (!letzterKontaktAm) return true
return differenceInCalendarDays(heute, letzterKontaktAm) >= NACHFASSEN_AB_TAGEN
}
+785
View File
@@ -0,0 +1,785 @@
import type { VorgangKategorie } from '@prisma/client'
import { addDays } from 'date-fns'
/**
* Vorgangs-Vorlagen.
*
* Warum Vorlagen? Die typischen Vorgänge einer rechtlichen Betreuung sind
* immer dieselben Handgriffe in immer derselben Reihenfolge - und die
* Reihenfolge ist der eigentliche Wert. Wer die Wohnung kündigt, bevor die
* Genehmigung da ist, hat ein Problem, das sich nicht mehr reparieren
* lässt. Deshalb steht in jeder Vorlage nicht nur, WAS zu tun ist, sondern
* auch, in welcher Reihenfolge - und die genehmigungspflichtigen Schritte
* stehen vor dem Rechtsgeschäft, nicht danach.
*
* Die Vorlage ist eine Starthilfe, kein Zwang: Aufgaben lassen sich nach
* dem Anlegen ergänzen, streichen und umdatieren.
*
* Der Vertrag dieser Datei steht in docs/SCHNITTSTELLEN.md, Abschnitt 6.
*/
export type Vorlagenaufgabe = {
titel: string
beschreibung?: string
/** Tage ab Anlegen des Vorgangs, bis die Aufgabe fällig ist. */
tageBisFaellig?: number
}
export type Vorlagenfrist = {
titel: string
tageBisFaellig: number
rechtsgrundlage?: string
}
export type Vorgangsvorlage = {
schluessel: string
titel: string
kategorie: VorgangKategorie
beschreibung: string
genehmigungspflichtig: boolean
rechtsgrundlage?: string
/** Vorbefüllte Aufgabenliste, in der Reihenfolge der Abarbeitung. */
aufgaben: Vorlagenaufgabe[]
/** Fristen, die mit angelegt werden. */
fristen?: Vorlagenfrist[]
}
export const VORLAGEN: Vorgangsvorlage[] = [
// ---------------------------------------------------------------------------
{
schluessel: 'heimeinzug',
titel: 'Einzug ins Pflegeheim',
kategorie: 'HEIM_PFLEGE',
beschreibung:
'Alles, was nach dem Einzug in eine vollstationäre Einrichtung zu erledigen ist: ' +
'Vertrag prüfen, Meldungen absetzen, Versicherungen und laufende Kosten anpassen.',
genehmigungspflichtig: false,
aufgaben: [
{
titel: 'Heimvertrag prüfen und gegenzeichnen',
beschreibung:
'Entgeltbestandteile (Pflege, Unterkunft/Verpflegung, Investitionskosten, ' +
'Ausbildungsumlage) einzeln aufschlüsseln lassen, Kündigungsfristen und die ' +
'Regelung zum Sterbefall nachlesen. Erst danach unterschreiben.',
tageBisFaellig: 7,
},
{
titel: 'Ummeldung beim Einwohnermeldeamt',
beschreibung:
'Das Heim ist die neue Hauptwohnung. Die Meldefrist beträgt zwei Wochen ab Einzug.',
tageBisFaellig: 14,
},
{
titel: 'Krankenkasse und Pflegekasse über den Einzug informieren',
beschreibung:
'Die Pflegekasse zahlt den Leistungsbetrag nach § 43 SGB XI erst ab Meldung an die ' +
'Einrichtung. Bestätigung der Kasse zu den Akten nehmen.',
tageBisFaellig: 14,
},
{
titel: 'Rentenversicherung: neue Anschrift und Zahlweg melden',
beschreibung:
'Anschrift und - falls sich das Konto ändert - die Bankverbindung melden. ' +
'Betreuerausweis in Kopie beilegen.',
tageBisFaellig: 21,
},
{
titel: 'Nachsendeauftrag bei der Post einrichten',
beschreibung:
'Läuft in der Regel 6 oder 12 Monate. Als Empfänger die eigene Anschrift wählen, ' +
'nicht das Heim - Post gehört in die Akte.',
tageBisFaellig: 21,
},
{
titel: 'Hausrat- und Haftpflichtversicherung anpassen',
beschreibung:
'Der Hausrat im Heimzimmer ist meist über die Einrichtung nicht gedeckt. ' +
'Hausratsumme an den tatsächlichen Bestand anpassen, Privathaftpflicht behalten - ' +
'sie ist im Heim wichtiger, nicht unwichtiger.',
tageBisFaellig: 30,
},
{
titel: 'Rundfunkbeitrag: Befreiung oder Ummeldung beantragen',
beschreibung:
'Bei Bezug von Hilfe zur Pflege oder Grundsicherung ist eine Befreiung möglich; ' +
'sonst wird die alte Wohnung abgemeldet und das Heimzimmer gilt in der Regel ' +
'nicht als eigene Wohnung.',
tageBisFaellig: 30,
},
{
titel: 'Verwahrgeldkonto im Heim einrichten und Höchstbetrag festlegen',
beschreibung:
'Barmittel für Friseur, Fußpflege und Einkäufe. Betrag klein halten und die ' +
'Abrechnung des Heims monatlich anfordern - das Verwahrgeld gehört in die ' +
'Rechnungslegung (§ 1865 BGB).',
tageBisFaellig: 30,
},
],
fristen: [
{
titel: 'Heimabrechnung und Verwahrgeldkonto zum ersten Mal prüfen',
tageBisFaellig: 60,
},
],
},
// ---------------------------------------------------------------------------
{
schluessel: 'antrag-hilfe-zur-pflege',
titel: 'Antrag auf Hilfe zur Pflege',
kategorie: 'SOZIALHILFE',
beschreibung:
'Antrag auf Hilfe zur Pflege in einer stationären Einrichtung beim überörtlichen ' +
'Sozialhilfeträger (in Oberfranken: der Bezirk). Reicht die Rente nicht für das ' +
'Heimentgelt, springt die Sozialhilfe ein - aber erst ab Kenntnis des Trägers.',
genehmigungspflichtig: false,
rechtsgrundlage: '§§ 61 ff. SGB XII',
aufgaben: [
{
titel: 'Antrag beim Bezirk stellen - formlos, aber sofort',
beschreibung:
'Sozialhilfe setzt ab Kenntnis des Trägers ein (§ 18 SGB XII). Deshalb zuerst ' +
'formlos schriftlich beantragen und den Eingang dokumentieren; das Formular kann ' +
'nachgereicht werden. Jeder Tag Verzögerung ist verlorenes Geld.',
tageBisFaellig: 3,
},
{
titel: 'Standardunterlagen zusammenstellen',
beschreibung:
'Betreuerausweis, Rentenbescheide, Kontoauszüge der letzten drei Monate aller ' +
'Konten, Pflegegradbescheid, Heimvertrag und aktuelle Heimrechnung, Nachweise zu ' +
'Versicherungen mit Rückkaufswert, Grundbuchauszug zum Hausgrundstück, ' +
'Schwerbehindertenausweis.',
tageBisFaellig: 14,
},
{
titel: 'Eingang des Antrags schriftlich bestätigen lassen',
beschreibung:
'Eingangsbestätigung mit Datum anfordern - sie ist der Beleg für den Beginn der ' +
'Leistung und für die Bearbeitungsfristen nach § 88 SGG.',
tageBisFaellig: 21,
},
{
titel: 'Sachbearbeiter und Aktenzeichen notieren',
beschreibung:
'Name, Durchwahl, E-Mail und Aktenzeichen im Vorgang und als Kontakt anlegen. ' +
'Alles Weitere läuft über diese eine Person.',
tageBisFaellig: 21,
},
{
titel: 'Nachforderungen abarbeiten',
beschreibung:
'Nachforderungen kommen fast immer. Jede einzeln abhaken und den Versand ' +
'dokumentieren - fehlende Mitwirkung kann zur Versagung führen (§ 66 SGB I).',
tageBisFaellig: 45,
},
{
titel: 'Bescheid prüfen: Zeitraum, Einkommenseinsatz, Barbetrag',
beschreibung:
'Prüfen: ab wann wird geleistet, welches Einkommen wird angerechnet, ist der ' +
'Barbetrag zur persönlichen Verfügung (§ 27b Abs. 2 SGB XII) enthalten, ist das ' +
'Hausgrundstück als Schonvermögen oder als einzusetzendes Vermögen bewertet? ' +
'Bei Zweifeln: Widerspruch innerhalb eines Monats.',
tageBisFaellig: 90,
},
],
fristen: [
{
titel: 'Sachstand beim Bezirk erfragen, wenn kein Bescheid vorliegt',
tageBisFaellig: 42,
},
{
titel: 'Untätigkeitsklage prüfen (6 Monate ohne Bescheid)',
tageBisFaellig: 183,
rechtsgrundlage: '§ 88 SGG',
},
],
},
// ---------------------------------------------------------------------------
{
schluessel: 'wohnungsaufloesung',
titel: 'Wohnung auflösen und kündigen',
kategorie: 'IMMOBILIE',
beschreibung:
'Die bisherige Wohnung wird aufgegeben. Achtung: Die Aufgabe von Wohnraum ist dem ' +
'Betreuungsgericht mitzuteilen und genehmigungsbedürftig - die Mitteilung geht VOR ' +
'der Kündigung raus, nicht danach.',
genehmigungspflichtig: true,
rechtsgrundlage: '§ 1833 BGB',
aufgaben: [
{
titel: 'Mitteilung an das Betreuungsgericht - vor jeder Kündigung',
beschreibung:
'Die Absicht, den Wohnraum aufzugeben, ist dem Gericht unverzüglich mitzuteilen ' +
'(§ 1833 Abs. 1 BGB). Mitteilen heißt: schriftlich, mit Begründung, warum eine ' +
'Rückkehr nicht mehr in Betracht kommt (ärztliche Einschätzung beilegen).',
tageBisFaellig: 7,
},
{
titel: 'Wunsch der betreuten Person erfragen und dokumentieren',
beschreibung:
'Die Wohnung ist für viele der letzte Bezug zum früheren Leben. Der geäußerte ' +
'oder mutmaßliche Wille ist nach § 1821 BGB maßgeblich und gehört ins ' +
'Kontaktprotokoll - das Gericht fragt danach.',
tageBisFaellig: 7,
},
{
titel: 'Genehmigung des Gerichts abwarten',
beschreibung:
'Erst mit rechtskräftiger Genehmigung wird gekündigt. Ohne sie ist die ' +
'Kündigung schwebend unwirksam und der Betreuer haftet persönlich.',
tageBisFaellig: 45,
},
{
titel: 'Wohnung vollständig fotografieren',
beschreibung:
'Raum für Raum, auch Keller, Dachboden und Zählerschränke. Die Bilder sind ' +
'später der einzige Beleg gegenüber Gericht und möglichen Miterben, in welchem ' +
'Zustand die Wohnung war und was darin stand.',
tageBisFaellig: 50,
},
{
titel: 'Inventarverzeichnis aufnehmen',
beschreibung:
'Liste aller Gegenstände mit geschätztem Wert. Sie ist Teil des ' +
'Vermögensverzeichnisses (§ 1835 BGB) und Grundlage für die Frage, was verkauft, ' +
'verschenkt oder entsorgt wird.',
tageBisFaellig: 55,
},
{
titel: 'Wertgegenstände, Papiere und Erinnerungsstücke sichern',
beschreibung:
'Schmuck, Bargeld, Sparbücher, Urkunden, Versicherungspolicen, Fotoalben. ' +
'Getrennt verwahren und im Vermögensverzeichnis führen. Erinnerungsstücke ins ' +
'Heimzimmer mitnehmen, soweit dort Platz ist.',
tageBisFaellig: 55,
},
{
titel: 'Mietverhältnis kündigen',
beschreibung:
'Erst jetzt, mit Genehmigung in der Hand. Kündigungsschreiben mit Kopie des ' +
'Betreuerausweises und der Genehmigung, Zugang nachweisbar (Einschreiben).',
tageBisFaellig: 60,
},
{
titel: 'Wohnungsübergabe mit Protokoll',
beschreibung:
'Übergabeprotokoll von beiden Seiten unterschreiben, Mängel und Schlüsselzahl ' +
'festhalten. Ohne Protokoll gibt es später Streit um die Kaution.',
tageBisFaellig: 90,
},
{
titel: 'Zählerstände ablesen und Versorger abmelden',
beschreibung:
'Strom, Gas, Wasser, Heizung - Stände mit Foto festhalten und mit Datum an die ' +
'Versorger melden. Danach Verträge kündigen und Endabrechnung anfordern.',
tageBisFaellig: 90,
},
{
titel: 'Nachsendeauftrag einrichten und Kaution zurückfordern',
beschreibung:
'Kaution nebst Zinsen anfordern; der Vermieter darf sie nur für berechtigte ' +
'Ansprüche zurückbehalten, üblicherweise bis zur Betriebskostenabrechnung.',
tageBisFaellig: 100,
},
],
fristen: [
{
titel: 'Betriebskostenabrechnung und Kautionsrückzahlung nachfassen',
tageBisFaellig: 180,
},
],
},
// ---------------------------------------------------------------------------
{
schluessel: 'hausverkauf',
titel: 'Hausgrundstück verkaufen',
kategorie: 'IMMOBILIE',
beschreibung:
'Verkauf eines Grundstücks aus dem Vermögen der betreuten Person. Genehmigungs' +
'pflichtig; die Genehmigung muss dem Vertragspartner in Ausfertigung mit ' +
'Rechtskraftvermerk vorgelegt werden, sonst wird der Vertrag nicht wirksam.',
genehmigungspflichtig: true,
rechtsgrundlage: '§ 1850 Nr. 1 BGB',
aufgaben: [
{
titel: 'Verkehrswert ermitteln lassen',
beschreibung:
'Gutachten eines öffentlich bestellten Sachverständigen oder Auskunft des ' +
'Gutachterausschusses. Das Gericht genehmigt keinen Verkauf ohne belastbaren ' +
'Wert - und ein Verkauf unter Wert ist eine Haftungsfalle.',
tageBisFaellig: 30,
},
{
titel: 'Aktuellen Grundbuchauszug und Flurkarte besorgen',
beschreibung:
'Abteilung II und III prüfen: Wohnrechte, Nießbrauch, Grundschulden, ' +
'Sanierungsvermerke. Alles davon beeinflusst den Preis und die Abwicklung.',
tageBisFaellig: 30,
},
{
titel: 'Maklerauftrag vergeben oder freihändig vermarkten',
beschreibung:
'Auftrag zeitlich begrenzen, Provision und Kündigungsmöglichkeit schriftlich ' +
'festhalten. Angebote dokumentieren - das Gericht will sehen, dass der Markt ' +
'befragt wurde.',
tageBisFaellig: 60,
},
{
titel: 'Notar beauftragen und Kaufvertragsentwurf erstellen lassen',
beschreibung:
'Der Notar kennt die betreuungsrechtliche Abwicklung. Im Entwurf sollte stehen, ' +
'dass der Vertrag erst mit rechtskräftiger betreuungsgerichtlicher Genehmigung ' +
'wirksam wird und wer die Kosten trägt, falls sie versagt wird.',
tageBisFaellig: 90,
},
{
titel: 'Kaufvertragsentwurf beim Betreuungsgericht einreichen',
beschreibung:
'Mit Wertgutachten, Grundbuchauszug und einer kurzen Begründung, warum der ' +
'Verkauf dem Wohl und dem Willen der betreuten Person entspricht.',
tageBisFaellig: 95,
},
{
titel: 'Genehmigungsbeschluss abwarten und Rechtskraft prüfen',
beschreibung:
'Der Beschluss wird erst mit Bekanntgabe an den Betreuer und Ablauf der ' +
'Beschwerdefrist wirksam. Ausfertigung mit Rechtskraftvermerk beim Gericht ' +
'anfordern - der Notar braucht genau dieses Papier.',
tageBisFaellig: 130,
},
{
titel: 'Beurkundung beim Notar',
beschreibung: 'Termin erst nach Vorliegen der Genehmigung wahrnehmen.',
tageBisFaellig: 140,
},
{
titel: 'Genehmigung mit Rechtskraftvermerk dem Käufer vorlegen',
beschreibung:
'Erst mit Vorlage wird der Vertrag endgültig wirksam (§ 1856 BGB). Der Notar ' +
'übernimmt das üblicherweise - kontrollieren und zu den Akten nehmen.',
tageBisFaellig: 145,
},
{
titel: 'Kaufpreiseingang überwachen und im Kassenbuch erfassen',
beschreibung:
'Der Erlös ist vermögenswirksam, keine laufende Einnahme. Auf ein Konto der ' +
'betreuten Person, niemals auf ein eigenes. Sofort dem Sozialhilfeträger melden - ' +
'er ändert die Berechnung.',
tageBisFaellig: 170,
},
{
titel: 'Löschung der Belastungen und Eigentumsumschreibung nachverfolgen',
beschreibung:
'Löschungsbewilligungen der Gläubiger, Löschung der Auflassungsvormerkung, ' +
'Eintragung des neuen Eigentümers. Neuen Grundbuchauszug zur Akte nehmen.',
tageBisFaellig: 210,
},
],
fristen: [
{
titel: 'Vermögensverzeichnis nach Verkauf fortschreiben',
tageBisFaellig: 180,
rechtsgrundlage: '§ 1835 BGB',
},
],
},
// ---------------------------------------------------------------------------
{
schluessel: 'darlehen-91-sgb-xii',
titel: 'Sozialhilfe als Darlehen gegen Grundschuld',
kategorie: 'SOZIALHILFE',
beschreibung:
'Wenn das Hausgrundstück nicht sofort verwertet werden kann oder die Verwertung eine ' +
'Härte wäre, leistet der Sozialhilfeträger darlehensweise und sichert sich im ' +
'Grundbuch ab. Die Bestellung der Grundschuld ist ein genehmigungspflichtiges ' +
'Grundstücksgeschäft.',
genehmigungspflichtig: true,
rechtsgrundlage: '§ 1850 BGB',
aufgaben: [
{
titel: 'Darlehensweise Leistung nach § 91 SGB XII beantragen',
beschreibung:
'Begründen, warum die sofortige Verwertung nicht möglich oder eine Härte ist: ' +
'kein Käufer zum Verkehrswert, laufendes Verfahren, Wohnrecht Dritter, ' +
'Rückkehroption. Alternative zum Verkauf unter Druck.',
tageBisFaellig: 14,
},
{
titel: 'Härtegesichtspunkte und Schonvermögen darlegen',
beschreibung:
'Prüfen, ob das Hausgrundstück ganz oder teilweise Schonvermögen nach ' +
'§ 90 Abs. 2 Nr. 8 und Abs. 3 SGB XII ist. Fällt es darunter, ist überhaupt kein ' +
'Darlehen nötig.',
tageBisFaellig: 21,
},
{
titel: 'Darlehensvertrag und Sicherungsabrede prüfen',
beschreibung:
'Verzinsung, Rangstelle, Fälligkeit und Kündigungsgründe genau lesen. ' +
'Unverzinsliche Darlehen sind der Regelfall - eine Verzinsung nicht ' +
'kommentarlos hinnehmen.',
tageBisFaellig: 35,
},
{
titel: 'Genehmigung des Betreuungsgerichts einholen',
beschreibung:
'Die Bestellung einer Grundschuld belastet ein Grundstück und ist deshalb ' +
'genehmigungspflichtig (§ 1850 BGB). Vertragsentwurf und Sicherungsabrede ' +
'beilegen.',
tageBisFaellig: 50,
},
{
titel: 'Grundschuld beim Notar bestellen und eintragen lassen',
beschreibung:
'Erst nach rechtskräftiger Genehmigung. Auf die Rangstelle achten - eine ' +
'nachrangige Eintragung ist für den Träger üblich und für die betreute Person ' +
'günstiger.',
tageBisFaellig: 80,
},
{
titel: 'Eintragung im Grundbuch prüfen und Vermögensverzeichnis anpassen',
beschreibung:
'Neuer Grundbuchauszug zur Akte. Das Darlehen ist eine Verbindlichkeit und ' +
'gehört als solche in die Vermögensübersicht.',
tageBisFaellig: 110,
},
],
},
// ---------------------------------------------------------------------------
{
schluessel: 'versicherungsverwertung',
titel: 'Lebens- oder Rentenversicherung verwerten',
kategorie: 'VERSICHERUNG',
beschreibung:
'Der Sozialhilfeträger verlangt regelmäßig, kapitalbildende Versicherungen zu ' +
'kündigen. Bevor gekündigt wird, gehört geprüft, ob die Versicherung überhaupt ' +
'einzusetzendes Vermögen ist - eine Kündigung lässt sich nicht rückgängig machen.',
genehmigungspflichtig: false,
rechtsgrundlage: '§ 90 SGB XII',
aufgaben: [
{
titel: 'Aktuellen Rückkaufswert und Vertragsdaten anfordern',
beschreibung:
'Vom Versicherer schriftlich: Rückkaufswert zum Stichtag, Überschussanteile, ' +
'Ablaufleistung, Bezugsberechtigung, Verwertungsausschluss. Der Rückkaufswert ' +
'gehört ohnehin jährlich ins Vermögensverzeichnis.',
tageBisFaellig: 14,
},
{
titel: 'Prüfen, ob Schonvermögen vorliegt',
beschreibung:
'Bestattungsvorsorge, angemessene Alterssicherung und Verträge mit vereinbartem ' +
'Verwertungsausschluss bleiben geschützt; ebenso greift der Schonbetrag nach ' +
'§ 90 Abs. 2 Nr. 9 SGB XII. Ergebnis schriftlich festhalten.',
tageBisFaellig: 21,
},
{
titel: 'Härte nach § 90 Abs. 3 SGB XII prüfen',
beschreibung:
'Ist der Rückkaufswert deutlich niedriger als die eingezahlten Beiträge, kann die ' +
'Verwertung eine Härte sein. Differenz ausrechnen und dem Träger vorrechnen - ' +
'das ist das stärkste Argument gegen eine Kündigung.',
tageBisFaellig: 28,
},
{
titel: 'Alternativen zur Kündigung prüfen',
beschreibung:
'Beitragsfreistellung, Beleihung oder Umwandlung in eine Bestattungsvorsorge sind ' +
'oft günstiger als der Rückkauf. Mit dem Sachbearbeiter besprechen, bevor ' +
'gekündigt wird.',
tageBisFaellig: 35,
},
{
titel: 'Genehmigungsfrage klären und beim Gericht abstimmen',
beschreibung:
'Bei einer schlichten Kündigung liegt in der Regel kein Genehmigungstatbestand ' +
'vor. Geht es aber um Wertpapiere oder eine Beleihung, greift § 1851 BGB. Im ' +
'Zweifel den Rechtspfleger fragen und die Antwort im Vorgang notieren.',
tageBisFaellig: 42,
},
{
titel: 'Kündigung erklären und Auszahlung überwachen',
beschreibung:
'Kündigung schriftlich mit Betreuerausweis, Auszahlung nur auf ein Konto der ' +
'betreuten Person. Eingang als vermögenswirksame Buchung erfassen und dem ' +
'Sozialhilfeträger melden.',
tageBisFaellig: 60,
},
],
},
// ---------------------------------------------------------------------------
{
schluessel: 'pflegegrad-hoeherstufung',
titel: 'Höherstufung des Pflegegrads',
kategorie: 'HEIM_PFLEGE',
beschreibung:
'Antrag auf Höherstufung, wenn der Pflegebedarf gestiegen ist. Bei einer ' +
'fortschreitenden COPD ist das der Regelfall, nicht die Ausnahme.',
genehmigungspflichtig: false,
rechtsgrundlage: '§ 18 SGB XI',
aufgaben: [
{
titel: 'Pflegetagebuch über mindestens zwei Wochen führen lassen',
beschreibung:
'Zusammen mit der Pflegedienstleitung: Hilfebedarf je Modul, Atemnot, ' +
'Sauerstoffbedarf, nächtliche Unruhe, Sturzereignisse. Ohne dieses Tagebuch ist ' +
'die Begutachtung ein Blindflug.',
tageBisFaellig: 21,
},
{
titel: 'Antrag auf Höherstufung bei der Pflegekasse stellen',
beschreibung:
'Formlos genügt. Änderung des Zustands kurz beschreiben und auf das ' +
'Pflegetagebuch verweisen.',
tageBisFaellig: 25,
},
{
titel: 'Begutachtung durch den Medizinischen Dienst vorbereiten',
beschreibung:
'Termin so legen, dass Pflegekraft und Betreuer dabei sind. Aktuelle ' +
'Arztberichte, Medikamentenplan und das Pflegetagebuch bereitlegen. Die ' +
'schlechten Tage schildern, nicht die guten.',
tageBisFaellig: 45,
},
{
titel: 'Gutachten anfordern und lesen',
beschreibung:
'Das Gutachten wird auf Verlangen übersandt. Punktvergabe je Modul mit der ' +
'eigenen Wahrnehmung abgleichen - hier stehen die Ansatzpunkte für einen ' +
'Widerspruch.',
tageBisFaellig: 70,
},
{
titel: 'Bescheid prüfen und ggf. Widerspruch einlegen',
beschreibung:
'Widerspruchsfrist: ein Monat ab Bekanntgabe (§ 84 SGG). Fristwahrend einlegen, ' +
'Begründung nach Erhalt des Gutachtens nachreichen.',
tageBisFaellig: 80,
},
{
titel: 'Heim, Sozialhilfeträger und Kassen über den neuen Grad informieren',
beschreibung:
'Der Pflegegrad ändert Heimentgelt und Leistungsbetrag - und damit die ' +
'Berechnung der Hilfe zur Pflege.',
tageBisFaellig: 95,
},
],
fristen: [
{
titel: 'Frist zur Bearbeitung durch die Pflegekasse endet (5 Wochen)',
tageBisFaellig: 35,
rechtsgrundlage: '§ 18 Abs. 3b SGB XI',
},
],
},
// ---------------------------------------------------------------------------
{
schluessel: 'steuererklaerung',
titel: 'Einkommensteuererklärung',
kategorie: 'STEUERN',
beschreibung:
'Steuererklärung für die betreute Person. Auch wer nur Rente bezieht, ist häufig ' +
'erklärungspflichtig - und bekommt wegen der außergewöhnlichen Belastungen durch ' +
'Heim und Pflege oft etwas zurück.',
genehmigungspflichtig: false,
aufgaben: [
{
titel: 'Prüfen, ob eine Erklärungspflicht besteht',
beschreibung:
'Rentenbezugsmitteilung, weitere Einkünfte, Aufforderung des Finanzamts. Auch ' +
'ohne Pflicht lohnt die Abgabe fast immer, wenn Heimkosten selbst getragen wurden.',
tageBisFaellig: 30,
},
{
titel: 'Unterlagen sammeln',
beschreibung:
'Rentenbezugsmitteilungen, Steuerbescheinigungen der Banken, Heimrechnungen und ' +
'Eigenanteil, Zuzahlungen, Medikamente, Fahrtkosten, Spenden, Grad der ' +
'Behinderung.',
tageBisFaellig: 60,
},
{
titel: 'Heimkosten als außergewöhnliche Belastung ansetzen',
beschreibung:
'Der selbst getragene Heimanteil ist abzüglich Haushaltsersparnis absetzbar ' +
'(§ 33 EStG); der Behinderten-Pauschbetrag nach § 33b EStG ist eine Alternative. ' +
'Beides gegeneinander rechnen.',
tageBisFaellig: 75,
},
{
titel: 'Erklärung erstellen und einreichen',
beschreibung:
'Als Betreuer unterschreiben, Betreuerausweis in Kopie beifügen. Abgabefrist ' +
'für nicht beratene Fälle: 31. Juli des Folgejahres.',
tageBisFaellig: 100,
},
{
titel: 'Bescheid prüfen',
beschreibung:
'Einspruchsfrist: ein Monat ab Bekanntgabe (§ 355 AO). Angesetzte Belastungen ' +
'mit der eigenen Aufstellung abgleichen.',
tageBisFaellig: 160,
},
{
titel: 'Erstattung oder Nachzahlung verbuchen und melden',
beschreibung:
'Erstattungen sind Einnahmen und dem Sozialhilfeträger zu melden.',
tageBisFaellig: 175,
},
],
fristen: [
{
titel: 'Abgabefrist Einkommensteuererklärung',
tageBisFaellig: 120,
rechtsgrundlage: '§ 149 AO',
},
],
},
// ---------------------------------------------------------------------------
{
schluessel: 'widerspruch-bescheid',
titel: 'Widerspruch gegen einen Bescheid',
kategorie: 'SOZIALHILFE',
beschreibung:
'Widerspruch gegen einen Bescheid des Sozialhilfeträgers, der Kranken- oder ' +
'Pflegekasse. Die Frist beträgt einen Monat ab Bekanntgabe - sie ist die härteste ' +
'Frist in diesem ganzen Programm. Zuerst einlegen, dann begründen.',
genehmigungspflichtig: false,
rechtsgrundlage: '§ 84 SGG',
aufgaben: [
{
titel: 'Widerspruch fristwahrend einlegen, Begründung nachreichen',
beschreibung:
'Ein Satz genügt: "Gegen den Bescheid vom … lege ich Widerspruch ein. Die ' +
'Begründung reiche ich nach." Sofort raus, Zugang nachweisbar. Die Monatsfrist ' +
'nach § 84 SGG läuft ab Bekanntgabe; fehlt die Rechtsbehelfsbelehrung, sind es ' +
'ein Jahr - darauf sollte man sich aber nie verlassen.',
tageBisFaellig: 7,
},
{
titel: 'Bekanntgabedatum feststellen und dokumentieren',
beschreibung:
'Maßgeblich ist nicht das Datum auf dem Bescheid: Bei Zusendung mit einfachem ' +
'Brief gilt er am dritten Tag nach Aufgabe zur Post als bekanntgegeben ' +
'(§ 37 Abs. 2 SGB X). Briefumschlag aufheben.',
tageBisFaellig: 3,
},
{
titel: 'Akteneinsicht beantragen',
beschreibung:
'Nach § 25 SGB X. Erst die Akte zeigt, worauf der Träger seine Berechnung ' +
'stützt und welche Unterlagen er hat.',
tageBisFaellig: 14,
},
{
titel: 'Begründung schreiben und einreichen',
beschreibung:
'Punkt für Punkt am Bescheid entlang: welche Rechengröße ist falsch, welche ' +
'Vorschrift wurde übersehen, welche Unterlage fehlte. Belege beifügen.',
tageBisFaellig: 35,
},
{
titel: 'Aufschiebende Wirkung bzw. einstweiligen Rechtsschutz prüfen',
beschreibung:
'Bei Aufhebung oder Erstattung entfällt die aufschiebende Wirkung teilweise ' +
'(§ 86a SGG). Droht ein Zahlungsausfall, ist ein Eilantrag beim Sozialgericht ' +
'zu erwägen.',
tageBisFaellig: 40,
},
{
titel: 'Widerspruchsbescheid prüfen und Klagefrist notieren',
beschreibung:
'Gegen den Widerspruchsbescheid ist binnen eines Monats Klage beim Sozialgericht ' +
'möglich (§ 87 SGG) - gerichtskostenfrei.',
tageBisFaellig: 120,
},
],
fristen: [
{
titel: 'Widerspruchsfrist läuft ab (ein Monat ab Bekanntgabe)',
tageBisFaellig: 30,
rechtsgrundlage: '§ 84 SGG',
},
],
},
]
/** Sucht eine Vorlage. Gibt undefined zurück, wenn es sie nicht gibt. */
export function vorlageFinden(schluessel: string): Vorgangsvorlage | undefined {
return VORLAGEN.find((v) => v.schluessel === schluessel)
}
export const VORLAGEN_SCHLUESSEL = VORLAGEN.map((v) => v.schluessel)
/**
* Rechnet die relativen Angaben einer Vorlage in echte Termine um.
*
* Bewusst rein und ohne Datenbank: so lässt sich im Test prüfen, dass eine
* Vorlage die erwarteten Aufgaben in der erwarteten Reihenfolge erzeugt.
* Datumsrechnung über date-fns, nie über setDate von Hand.
*/
export type ErzeugteAufgabe = {
titel: string
beschreibung: string | null
faelligAm: Date | null
reihenfolge: number
}
export type ErzeugteFrist = {
titel: string
datum: Date
rechtsgrundlage: string | null
}
export type VorlagenErgebnis = {
vorlage: Vorgangsvorlage
aufgaben: ErzeugteAufgabe[]
fristen: ErzeugteFrist[]
}
/**
* Der Tagesanteil eines Datums, auf Mitternacht UTC normiert.
*
* Fristen und Fälligkeiten sind in der Datenbank @db.Date - ohne diese
* Normierung verschiebt die Zeitzone eine Frist um einen Tag.
*/
export function alsTag(datum: Date): Date {
return new Date(
Date.UTC(datum.getUTCFullYear(), datum.getUTCMonth(), datum.getUTCDate(), 0, 0, 0, 0),
)
}
export function vorlageAnwenden(schluessel: string, start: Date = new Date()): VorlagenErgebnis {
const vorlage = vorlageFinden(schluessel)
if (!vorlage) {
throw new Error(`Unbekannte Vorgangs-Vorlage: ${schluessel}`)
}
const basis = alsTag(start)
return {
vorlage,
aufgaben: vorlage.aufgaben.map((aufgabe, index) => ({
titel: aufgabe.titel,
beschreibung: aufgabe.beschreibung ?? null,
faelligAm:
aufgabe.tageBisFaellig === undefined ? null : alsTag(addDays(basis, aufgabe.tageBisFaellig)),
// Die Reihenfolge ist der fachliche Kern der Vorlage - sie wird
// festgeschrieben, damit spaeteres Sortieren sie nicht zerstoert.
reihenfolge: index,
})),
fristen: (vorlage.fristen ?? []).map((frist) => ({
titel: frist.titel,
datum: alsTag(addDays(basis, frist.tageBisFaellig)),
rechtsgrundlage: frist.rechtsgrundlage ?? null,
})),
}
}
+331
View File
@@ -0,0 +1,331 @@
import type { BuchungRichtung, GenehmigungStatus, KontaktArt, VorgangStatus } from '@prisma/client'
import { GENEHMIGUNG_STATUS_BESCHRIFTUNG, GENEHMIGUNG_STUFEN } from '@/lib/vorgaenge/genehmigungskette'
/**
* Zeitstrahl einer Fallakte.
*
* Ein Vorgang zerfällt in der Datenbank in fünf Tabellen: Dokumente,
* Kontaktprotokolle, Fristen, Buchungen, Genehmigungsstufen. Für die Frage
* "was ist in dieser Sache eigentlich passiert?" ist genau diese Aufteilung
* unbrauchbar - man will eine Spalte, von unten nach oben gelesen.
*
* Und man braucht sie nicht nur zum Nachlesen: Wenn das Gericht oder der
* Bezirk fragt, wann etwas geschickt wurde, ist diese Liste die Antwort.
*
* Die Funktion ist bewusst rein: sie bekommt fertig geladene Daten und gibt
* eine sortierte Liste zurück. Kein Datenbankzugriff, damit sie im Test
* ohne Datenbank prüfbar bleibt.
*/
export type Zeitstrahlart =
| 'ANGELEGT'
| 'DOKUMENT'
| 'KONTAKT'
| 'FRIST'
| 'BUCHUNG'
| 'GENEHMIGUNG'
| 'STATUS'
| 'ERLEDIGT'
export type Zeitstrahleintrag = {
/** Eindeutig über alle Quellen hinweg: "<art>:<id>[:<zusatz>]". */
id: string
art: Zeitstrahlart
datum: Date
titel: string
detail: string | null
/** Ziel zum Weiterklicken, wenn es eine eigene Seite gibt. */
href: string | null
/** Cent als Zahl - der Zeitstrahl wandert zu Client Components. */
betragCent: number | null
}
// -----------------------------------------------------------------------------
// Eingangsdaten
// -----------------------------------------------------------------------------
export type ZeitstrahlDokument = {
id: string
titel: string
eingangAm: Date | null
versandAm: Date | null
erstelltAm: Date
absender: string | null
empfaenger: string | null
}
export type ZeitstrahlKontaktprotokoll = {
id: string
datum: Date
art: KontaktArt
gespraechspartner: string | null
mitBetreuter: boolean
/** Bereits entschlüsselter Inhalt, gekürzt. */
inhalt: string | null
}
export type ZeitstrahlFrist = {
id: string
titel: string
datum: Date
erledigtAm: Date | null
ausschlussfrist: boolean
rechtsgrundlage: string | null
}
export type ZeitstrahlBuchung = {
id: string
datum: Date
betragCent: number
richtung: BuchungRichtung
gegenstelle: string | null
/** Bereits entschlüsselter Verwendungszweck. */
verwendungszweck: string | null
}
export type ZeitstrahlGenehmigung = {
id: string
titel: string
rechtsgrundlage: string
status: GenehmigungStatus
mitteilungAm: Date | null
antragAm: Date | null
anhoerungAm: Date | null
beschlussAm: Date | null
rechtskraftAm: Date | null
vollzugAm: Date | null
}
/** Ein aus dem Audit-Log gelesener Statuswechsel des Vorgangs. */
export type ZeitstrahlStatuswechsel = {
id: string
zeitpunkt: Date
von: VorgangStatus | null
nach: VorgangStatus
wer: string | null
}
export type ZeitstrahlQuellen = {
vorgang: {
titel: string
erstelltAm: Date
erledigtAm: Date | null
}
dokumente?: ZeitstrahlDokument[]
kontaktprotokolle?: ZeitstrahlKontaktprotokoll[]
fristen?: ZeitstrahlFrist[]
buchungen?: ZeitstrahlBuchung[]
genehmigungen?: ZeitstrahlGenehmigung[]
statuswechsel?: ZeitstrahlStatuswechsel[]
}
export const VORGANG_STATUS_BESCHRIFTUNG: Record<VorgangStatus, string> = {
OFFEN: 'Offen',
IN_ARBEIT: 'In Arbeit',
WARTET_AUF_DRITTE: 'Wartet auf andere',
WARTET_AUF_GENEHMIGUNG: 'Wartet auf Genehmigung',
ERLEDIGT: 'Erledigt',
}
export const KONTAKTART_BESCHRIFTUNG: Record<KontaktArt, string> = {
BESUCH: 'Besuch',
TELEFONAT: 'Telefonat',
EMAIL: 'E-Mail',
BRIEF: 'Brief',
VIDEOCALL: 'Videogespräch',
FAX: 'Fax',
VORSPRACHE: 'Vorsprache',
}
// -----------------------------------------------------------------------------
function kuerzen(text: string | null, laenge = 160): string | null {
if (!text) return null
const sauber = text.replace(/\s+/g, ' ').trim()
if (sauber === '') return null
return sauber.length <= laenge ? sauber : `${sauber.slice(0, laenge - 1)}…`
}
/**
* Führt alle Quellen zu einer Liste zusammen.
*
* Sortierung: neueste zuerst. Bei gleichem Zeitpunkt entscheidet die
* Reihenfolge in RANG - sonst springen Einträge bei jedem Aufruf, was beim
* Vergleich zweier Ausdrucke unnötig verwirrt.
*/
const RANG: Record<Zeitstrahlart, number> = {
ERLEDIGT: 0,
STATUS: 1,
GENEHMIGUNG: 2,
DOKUMENT: 3,
KONTAKT: 4,
BUCHUNG: 5,
FRIST: 6,
ANGELEGT: 7,
}
export function zeitstrahlBauen(
quellen: ZeitstrahlQuellen,
optionen: { reihenfolge?: 'neueste-zuerst' | 'aelteste-zuerst' } = {},
): Zeitstrahleintrag[] {
const eintraege: Zeitstrahleintrag[] = []
// --- Anlage und Erledigung des Vorgangs selbst ----------------------------
eintraege.push({
id: 'ANGELEGT:vorgang',
art: 'ANGELEGT',
datum: quellen.vorgang.erstelltAm,
titel: 'Vorgang angelegt',
detail: quellen.vorgang.titel,
href: null,
betragCent: null,
})
if (quellen.vorgang.erledigtAm) {
eintraege.push({
id: 'ERLEDIGT:vorgang',
art: 'ERLEDIGT',
datum: quellen.vorgang.erledigtAm,
titel: 'Vorgang erledigt',
detail: null,
href: null,
betragCent: null,
})
}
// --- Dokumente ------------------------------------------------------------
for (const dokument of quellen.dokumente ?? []) {
// Eingang schlaegt Versand schlaegt Anlage: massgeblich ist, wann das
// Papier tatsaechlich bewegt wurde - daran haengen Rechtsbehelfsfristen.
const datum = dokument.eingangAm ?? dokument.versandAm ?? dokument.erstelltAm
const richtung = dokument.eingangAm
? `Eingang${dokument.absender ? ` von ${dokument.absender}` : ''}`
: dokument.versandAm
? `Versand${dokument.empfaenger ? ` an ${dokument.empfaenger}` : ''}`
: 'Abgelegt'
eintraege.push({
id: `DOKUMENT:${dokument.id}`,
art: 'DOKUMENT',
datum,
titel: dokument.titel,
detail: richtung,
href: `/dokumente/${dokument.id}`,
betragCent: null,
})
}
// --- Kontaktprotokolle ----------------------------------------------------
for (const protokoll of quellen.kontaktprotokolle ?? []) {
const art = KONTAKTART_BESCHRIFTUNG[protokoll.art]
const partner = protokoll.gespraechspartner ? ` mit ${protokoll.gespraechspartner}` : ''
eintraege.push({
id: `KONTAKT:${protokoll.id}`,
art: 'KONTAKT',
datum: protokoll.datum,
titel: `${art}${partner}`,
detail:
kuerzen(protokoll.inhalt) ??
(protokoll.mitBetreuter ? 'Persönlicher Kontakt zur betreuten Person' : null),
href: `/kontakte/protokoll/${protokoll.id}`,
betragCent: null,
})
}
// --- Fristen --------------------------------------------------------------
for (const frist of quellen.fristen ?? []) {
eintraege.push({
id: `FRIST:${frist.id}`,
art: 'FRIST',
datum: frist.datum,
titel: frist.ausschlussfrist ? `Ausschlussfrist: ${frist.titel}` : `Frist: ${frist.titel}`,
detail: [frist.rechtsgrundlage, frist.erledigtAm ? 'erledigt' : null]
.filter(Boolean)
.join(' · ') || null,
href: `/fristen/${frist.id}`,
betragCent: null,
})
}
// --- Buchungen ------------------------------------------------------------
for (const buchung of quellen.buchungen ?? []) {
const vorzeichen = buchung.richtung === 'AUSGABE' ? -1 : 1
const bezeichnung =
buchung.richtung === 'AUSGABE'
? 'Ausgabe'
: buchung.richtung === 'EINNAHME'
? 'Einnahme'
: 'Umbuchung'
eintraege.push({
id: `BUCHUNG:${buchung.id}`,
art: 'BUCHUNG',
datum: buchung.datum,
titel: bezeichnung,
detail:
[buchung.gegenstelle, kuerzen(buchung.verwendungszweck, 80)].filter(Boolean).join(' · ') ||
null,
href: `/finanzen/buchungen/${buchung.id}`,
betragCent: vorzeichen * Math.abs(buchung.betragCent),
})
}
// --- Genehmigungsstufen ---------------------------------------------------
// Jede erreichte Stufe hat ihr eigenes Datumsfeld; daraus entsteht die
// Chronik des Genehmigungsverfahrens ganz ohne zusaetzliche Tabelle.
for (const genehmigung of quellen.genehmigungen ?? []) {
for (const stufe of GENEHMIGUNG_STUFEN) {
if (!stufe.datumsfeld) continue
const datum = genehmigung[stufe.datumsfeld]
if (!datum) continue
eintraege.push({
id: `GENEHMIGUNG:${genehmigung.id}:${stufe.status}`,
art: 'GENEHMIGUNG',
datum,
titel: `Genehmigung: ${stufe.beschriftung}`,
detail: `${genehmigung.titel} · ${genehmigung.rechtsgrundlage}`,
href: null,
betragCent: null,
})
}
// Die Versagung hat kein eigenes Datumsfeld - sie wird am Beschluss-
// datum gezeigt, weil das Gericht mit dem Beschluss versagt.
if (genehmigung.status === 'VERSAGT' && genehmigung.beschlussAm) {
eintraege.push({
id: `GENEHMIGUNG:${genehmigung.id}:VERSAGT`,
art: 'GENEHMIGUNG',
datum: genehmigung.beschlussAm,
titel: `Genehmigung: ${GENEHMIGUNG_STATUS_BESCHRIFTUNG.VERSAGT}`,
detail: `${genehmigung.titel} · ${genehmigung.rechtsgrundlage}`,
href: null,
betragCent: null,
})
}
}
// --- Statuswechsel aus dem Audit-Log -------------------------------------
for (const wechsel of quellen.statuswechsel ?? []) {
const von = wechsel.von ? VORGANG_STATUS_BESCHRIFTUNG[wechsel.von] : null
eintraege.push({
id: `STATUS:${wechsel.id}`,
art: 'STATUS',
datum: wechsel.zeitpunkt,
titel: von
? `Status: ${von} → ${VORGANG_STATUS_BESCHRIFTUNG[wechsel.nach]}`
: `Status: ${VORGANG_STATUS_BESCHRIFTUNG[wechsel.nach]}`,
detail: wechsel.wer,
href: null,
betragCent: null,
})
}
const richtung = optionen.reihenfolge === 'aelteste-zuerst' ? 1 : -1
return eintraege.sort((a, b) => {
const differenz = a.datum.getTime() - b.datum.getTime()
if (differenz !== 0) return differenz * richtung
const rang = RANG[a.art] - RANG[b.art]
if (rang !== 0) return rang
return a.id.localeCompare(b.id, 'de')
})
}