Files
Florian Wilde 5b54d7bc5f Ersteinrichtung schliessen: Geheimnis und eine Sperre statt zweier Schritte
Die Aktion ist die einzige Stelle der Anwendung, an der jemand ohne
Anmeldung schreiben darf. Zwei Dinge fehlten.

Erstens stand die Seite zwischen dem ersten Start und dem Anlegen des
Zugangs fuer jeden offen: Wer sie in diesem Fenster fand, wurde
Administrator einer Anwendung mit Gesundheits- und Finanzdaten. Das
Fenster ist kurz, aber Zufall ist keine Zugriffskontrolle. Jetzt muss
ERSTEINRICHTUNG_GEHEIMNIS aus der .env mit eingegeben werden; verglichen
wird ueber Hashes in gleichbleibender Zeit, und ein falscher Wert
antwortet mit derselben Mindestdauer wie das Anmeldeformular.

Fehlt der Wert, ist die Einrichtung gesperrt statt offen. Das ist
Absicht: Eine Installation, bei der die Zeile vergessen wurde, soll eine
Meldung zeigen und nicht einrichtbar sein.

Zweitens waren Pruefung und Anlegen zwei Schritte. Beide laufen jetzt in
einer Transaktion, und die Sperrzeile mit fester Id entscheidet - eine
zweite gleichzeitige Anfrage scheitert am Primaerschluessel, bevor ein
zweiter Administrator entsteht.

Die Seite liest die Sperrzeile mit: Lag der erste Zugang im Papierkorb,
stand Schritt 1 sonst wieder offen. Der Seed setzt sie ebenfalls.

Refs #38
2026-08-24 08:03:49 +02:00

860 lines
31 KiB
TypeScript

/**
* Startdaten.
*
* npm run db:seed
*
* Der Seed legt bewusst KEINE echten Personendaten an. Er richtet nur den
* ersten Zugang ein; alles Weitere fragt der Ersteinrichtungs-Assistent
* beim ersten Start ab. So landen keine Gesundheitsdaten im Repository,
* und der Seed bleibt auch dann brauchbar, wenn eine zweite Betreuung
* dazukommt.
*
* Zwei Schalter fuer die Entwicklung:
*
* SEED_DEMO=true legt einen vollstaendigen Beispielfall an
* (Erika Musterfrau). Nur ausserhalb der
* Produktion, und in der Oberflaeche als
* Demo gekennzeichnet.
* SEED_DEV_TOTP_SECRET=... aktiviert den zweiten Faktor mit einem
* bekannten Geheimnis, damit automatisierte
* Tests sich anmelden koennen. Wird in der
* Produktion ignoriert.
*
* Der Seed ist idempotent: mehrfaches Ausfuehren aendert nichts.
*/
import { PrismaClient, type BuchungKategorie, type Prisma } from '@prisma/client'
import { hash } from '@node-rs/argon2'
import { createCipheriv, randomBytes } from 'node:crypto'
const db = new PrismaClient()
// Muss zu ARGON2_OPTIONEN in src/lib/crypto/hash.ts passen.
// Ohne Angabe benutzt @node-rs/argon2 von sich aus Argon2id.
const ARGON2 = {
memoryCost: 19_456,
timeCost: 2,
parallelism: 1,
} as const
const istProduktion = process.env.NODE_ENV === 'production'
/**
* Verschluesselt wie src/lib/crypto/feld.ts. Bewusst hier noch einmal
* ausgeschrieben: der Seed laeuft ausserhalb von Next.js und kann das
* Modul mit seinem "server-only"-Import nicht laden.
*/
function verschluesseln(klartext: string | null, kontext: string): string | null {
if (!klartext) return null
const version = Number.parseInt(process.env.ENC_KEY_AKTIV ?? '1', 10)
const roh = process.env[`ENC_KEY_V${version}`]
if (!roh) throw new Error(`ENC_KEY_V${version} fehlt. Erzeuge ihn mit "npm run schluessel".`)
const schluessel = Buffer.from(roh, 'base64')
if (schluessel.length !== 32) throw new Error(`ENC_KEY_V${version} muss 32 Bytes lang sein.`)
const iv = randomBytes(12)
const cipher = createCipheriv('aes-256-gcm', schluessel, iv, { authTagLength: 16 })
cipher.setAAD(Buffer.from(`bc1:${version}:${kontext}`, 'utf8'))
const geheim = Buffer.concat([cipher.update(klartext, 'utf8'), cipher.final()])
return [
'bc1',
version,
iv.toString('base64url'),
cipher.getAuthTag().toString('base64url'),
geheim.toString('base64url'),
].join('.')
}
function datum(text: string): Date {
return new Date(`${text}T00:00:00.000Z`)
}
function cent(euro: number): bigint {
return BigInt(Math.round(euro * 100))
}
async function ersterZugang() {
const email = (process.env.SEED_ADMIN_EMAIL ?? '').trim().toLowerCase()
const passwort = process.env.SEED_ADMIN_PASSWORT
if (!email || !passwort) {
console.log(
'Kein SEED_ADMIN_EMAIL / SEED_ADMIN_PASSWORT gesetzt - der erste Zugang wird beim ' +
'Aufruf von /ersteinrichtung im Browser angelegt.',
)
return null
}
const vorhanden = await db.user.findUnique({ where: { email } })
if (vorhanden) {
console.log(`Zugang ${email} besteht bereits.`)
return vorhanden
}
// Der zweite Faktor bleibt normalerweise offen und wird beim ersten
// Anmelden eingerichtet - nur die Entwicklung darf ihn vorbelegen.
const devSecret = !istProduktion ? process.env.SEED_DEV_TOTP_SECRET : undefined
const benutzer = await db.user.create({
data: {
email,
name: process.env.SEED_ADMIN_NAME ?? 'Betreuer',
passwortHash: await hash(passwort, ARGON2),
rolle: 'ADMIN',
...(devSecret
? {
totpSecretVerschluesselt: verschluesseln(devSecret, 'totpSecretVerschluesselt'),
totpAktiviertAm: new Date(),
}
: {}),
},
})
// Die Ersteinrichtung ist damit erledigt. Ohne diese Zeile stuende die
// Einrichtungsseite nach dem Seed weiter offen.
await db.ersteinrichtung.upsert({
where: { id: 'einmalig' },
update: {},
create: { id: 'einmalig', userId: benutzer.id },
})
console.log(`Zugang ${email} angelegt.`)
if (devSecret) {
console.log(' Zweiter Faktor mit dem Entwicklungs-Geheimnis vorbelegt.')
} else {
console.log(' Der zweite Faktor wird beim ersten Anmelden eingerichtet.')
}
return benutzer
}
/**
* Vollstaendiger Beispielfall. Nur mit SEED_DEMO=true und nie in der
* Produktion - er soll zeigen, wie die Anwendung mit Inhalt aussieht.
*/
async function demofall(userId: string) {
const vorhanden = await db.betreute.findFirst({
where: { nachname: 'Musterfrau', vorname: 'Erika' },
})
if (vorhanden) {
console.log('Demofall besteht bereits.')
return
}
const betreute = await db.betreute.create({
data: {
anrede: 'Frau',
vorname: 'Erika',
nachname: 'Musterfrau',
geburtsname: 'Beispiel',
geburtsdatum: datum('1943-04-17'),
geburtsort: 'Bayreuth',
heimName: 'Seniorenzentrum St. Katharina',
heimStrasse: 'Kirchweg 12',
heimPlz: '95444',
heimOrt: 'Bayreuth',
heimZimmer: '218',
heimTelefon: '0921 123456-218',
heimEinzugAm: datum('2026-01-19'),
fruehereStrasse: 'Am Sonnenhang 4',
frueherePlz: '95463',
fruehererOrt: 'Bindlach',
krankenkasse: 'AOK Bayern',
krankenversichertennummerVerschluesselt: verschluesseln(
'A123456789',
'krankenversichertennummerVerschluesselt',
),
pflegekasse: 'AOK Pflegekasse Bayern',
pflegegrad: 3,
pflegegradSeit: datum('2025-11-01'),
pflegegradBescheidVom: datum('2025-10-22'),
schwerbehinderungGdB: 80,
schwerbehinderungMerkzeichen: 'G, B',
diagnosenVerschluesselt: verschluesseln(
[
'COPD GOLD III mit chronischer respiratorischer Insuffizienz (J44.09)',
'Langzeitsauerstofftherapie seit 03/2025, 2 l/min, 16 h/Tag',
'Arterielle Hypertonie (I10)',
'Osteoporose mit Wirbelkörperfraktur BWK 12 (M80.08)',
'Beginnende vaskuläre Demenz (F01.9)',
].join('\n'),
'diagnosenVerschluesselt',
),
ermittelterWilleVerschluesselt: verschluesseln(
'Möchte im Seniorenzentrum bleiben, dort ist eine frühere Nachbarin. ' +
'Will das Haus in Bindlach "nicht einfach hergeben", versteht aber, dass die ' +
'Pflegekosten gedeckt werden müssen. Wünscht keine künstliche Beatmung und keine ' +
'Verlegung in eine Klinik, wenn es zum Ende geht — so auch in der Patientenverfügung ' +
'vom 08.03.2019 festgehalten. Besuche bitte nachmittags, vormittags ist sie erschöpft.',
'ermittelterWilleVerschluesselt',
),
patientenverfuegungVorhanden: true,
vorsorgevollmachtVorhanden: false,
betreuungsgericht: 'Amtsgericht Bayreuth',
aktenzeichenGericht: 'XVII 412/25',
bestellungAm: datum('2026-02-14'),
aufgabenkreise: [
'VERMOEGENSSORGE',
'GESUNDHEITSSORGE',
'AUFENTHALTSBESTIMMUNG',
'WOHNUNGSANGELEGENHEITEN',
'BEHOERDENANGELEGENHEITEN',
],
befreiterBetreuer: true,
ehrenamtlich: true,
aktenzeichenBezirk: 'HzP 2026/04711',
aktenzeichenSozialamt: 'SA-4711-26',
rentenversicherungsnummerVerschluesselt: verschluesseln(
'17 170443 M 512',
'rentenversicherungsnummerVerschluesselt',
),
notizenVerschluesselt: verschluesseln(
'Dies ist ein Demofall zum Ausprobieren. Er lässt sich unter ' +
'Einstellungen jederzeit vollständig löschen.',
'notizenVerschluesselt',
),
zugaenge: { create: { userId, rolle: 'BETREUER' } },
},
})
// --- Kontakte -----------------------------------------------------------
await db.kontakt.createMany({
data: [
{
betreuteId: betreute.id,
name: 'Frau Hartmann',
organisation: 'Amtsgericht Bayreuth',
rolle: 'RECHTSPFLEGER',
telefon: '0921 504-0',
aktenzeichen: 'XVII 412/25',
},
{
betreuteId: betreute.id,
name: 'Herr Doblinger',
organisation: 'Bezirk Oberfranken, Sozialverwaltung',
rolle: 'SACHBEARBEITER_BEZIRK',
telefon: '0921 604-1234',
email: 'sozialverwaltung@bezirk-oberfranken.de',
aktenzeichen: 'HzP 2026/04711',
},
{
betreuteId: betreute.id,
name: 'Frau Kellner',
organisation: 'Seniorenzentrum St. Katharina',
rolle: 'SOZIALDIENST_HEIM',
telefon: '0921 123456-30',
},
{
betreuteId: betreute.id,
name: 'Dr. med. Reuther',
organisation: 'Hausarztpraxis Bindlach',
rolle: 'HAUSARZT',
telefon: '09208 4477',
},
],
})
// --- Konten und Vermoegen ----------------------------------------------
const giro = await db.konto.create({
data: {
betreuteId: betreute.id,
bezeichnung: 'Girokonto',
bank: 'Sparkasse Bayreuth',
art: 'GIRO',
ibanVerschluesselt: verschluesseln('DE02773501100123456789', 'ibanVerschluesselt'),
saldostaende: {
// Nur der Anfangsstand. Der spaetere wird weiter unten aus den
// Buchungen gerechnet - ein Demofall, der sich selbst
// widerspricht, waere schlimmer als einer ohne Buchungen.
create: [
{ stichtag: datum('2026-02-14'), betragCent: cent(4218.36), quelle: 'Kontoauszug 02/2026' },
],
},
},
})
await db.konto.create({
data: {
betreuteId: betreute.id,
bezeichnung: 'Verwahrgeld im Heim',
bank: 'Seniorenzentrum St. Katharina',
art: 'VERWAHRGELD_HEIM',
saldostaende: {
create: { stichtag: datum('2026-07-31'), betragCent: cent(184.2), quelle: 'Abrechnung Heim' },
},
},
})
const immobilie = await db.immobilie.create({
data: {
betreuteId: betreute.id,
bezeichnung: 'Einfamilienhaus Bindlach',
strasse: 'Am Sonnenhang 4',
plz: '95463',
ort: 'Bindlach',
grundbuchamt: 'Amtsgericht Bayreuth',
grundbuchBlatt: '3241',
gemarkung: 'Bindlach',
flurstueck: '812/3',
eigentumsanteil: '1/1',
wohnflaecheQm: 128,
grundstueckQm: 640,
baujahr: 1972,
bewohntStatus: 'leerstehend',
leerstandSeit: datum('2026-01-19'),
statusSozialhilfe: 'UNGEKLAERT',
statusBegruendungVerschluesselt: verschluesseln(
'Nicht mehr selbst bewohnt, seit dem Heimeinzug leerstehend. Schutz nach ' +
'§ 90 Abs. 2 Nr. 8 SGB XII greift daher voraussichtlich nicht. Zu prüfen: ' +
'Härtefall nach § 90 Abs. 3 SGB XII und Darlehen nach § 91 SGB XII statt ' +
'sofortiger Verwertung. Mit dem Bezirk noch nicht abschließend geklärt.',
'statusBegruendungVerschluesselt',
),
belastungen: {
create: {
art: 'Grundschuld',
abteilung: 'III',
laufendeNr: '2',
glaeubiger: 'Sparkasse Bayreuth',
betragCent: cent(30000),
bemerkung: 'Valutiert nach Auskunft der Bank nicht mehr, Löschungsbewilligung angefordert.',
},
},
},
})
const lebensversicherung = await db.vertrag.create({
data: {
betreuteId: betreute.id,
bezeichnung: 'Kapitallebensversicherung',
gesellschaft: 'Musterland Leben AG',
art: 'LEBENSVERSICHERUNG',
status: 'BEITRAGSFREI',
vertragsnummerVerschluesselt: verschluesseln('LV-8842119', 'vertragsnummerVerschluesselt'),
beginnAm: datum('1998-07-01'),
endeAm: datum('2028-07-01'),
bewertungSozialhilfe: 'STRITTIG',
bewertungBegruendungVerschluesselt: verschluesseln(
'Rückkaufswert liegt über dem Schonvermögen. Verwertung wäre grundsätzlich zumutbar, ' +
'aber der Vertrag läuft 2028 aus — Kündigung jetzt bedeutet spürbaren Verlust. ' +
'Härtefall nach § 90 Abs. 3 SGB XII geltend gemacht, Antwort des Bezirks steht aus.',
'bewertungBegruendungVerschluesselt',
),
rueckkaufswertAngefragtAm: datum('2026-03-02'),
},
})
await db.vertrag.create({
data: {
betreuteId: betreute.id,
bezeichnung: 'Sterbegeldversicherung',
gesellschaft: 'Nordheim Sterbekasse VVaG',
art: 'STERBEGELDVERSICHERUNG',
status: 'AKTIV',
vertragsnummerVerschluesselt: verschluesseln('SG-220417', 'vertragsnummerVerschluesselt'),
beitragCent: cent(18.4),
rhythmus: 'MONATLICH',
bewertungSozialhilfe: 'GESCHUETZT',
bewertungBegruendungVerschluesselt: verschluesseln(
'Angemessene Bestattungsvorsorge. Nach § 90 Abs. 3 SGB XII und ständiger ' +
'Rechtsprechung regelmäßig geschützt, weil sie der Würde der Bestattung dient.',
'bewertungBegruendungVerschluesselt',
),
},
})
await db.vermoegensposten.createMany({
data: [
{ betreuteId: betreute.id, bezeichnung: 'Girokonto Sparkasse', art: 'KONTO', kontoId: giro.id, bewertungSozialhilfe: 'VERWERTBAR' },
{ betreuteId: betreute.id, bezeichnung: 'Einfamilienhaus Bindlach', art: 'IMMOBILIE', immobilieId: immobilie.id, bewertungSozialhilfe: 'STRITTIG' },
{ betreuteId: betreute.id, bezeichnung: 'Kapitallebensversicherung', art: 'VERSICHERUNG', vertragId: lebensversicherung.id, bewertungSozialhilfe: 'STRITTIG' },
],
})
const posten = await db.vermoegensposten.findMany({ where: { betreuteId: betreute.id } })
for (const p of posten) {
const werte: Record<string, { wert: number; quelle: string }> = {
IMMOBILIE: { wert: 268000, quelle: 'Bodenrichtwert und Vergleichspreise, eigene Schätzung' },
VERSICHERUNG: { wert: 14820.55, quelle: 'Rückkaufswertmitteilung vom 26.03.2026' },
KONTO: { wert: 2967.11, quelle: 'Kontoauszug 07/2026' },
}
const w = werte[p.art]
if (w) {
await db.wertstand.create({
data: {
vermoegenspostenId: p.id,
stichtag: datum('2026-07-31'),
wertCent: cent(w.wert),
wertquelle: w.quelle,
},
})
}
}
// --- Sozialhilfeantrag mit Nachforderungen ------------------------------
const antrag = await db.sozialhilfeAntrag.create({
data: {
betreuteId: betreute.id,
traeger: 'Bezirk Oberfranken',
leistungsart: 'Hilfe zur Pflege nach dem 7. Kapitel SGB XII',
aktenzeichen: 'HzP 2026/04711',
sachbearbeiter: 'Herr Doblinger',
telefon: '0921 604-1234',
antragAm: datum('2026-02-26'),
eingangBeimTraegerAm: datum('2026-03-02'),
},
})
await db.nachforderung.createMany({
data: [
{ antragId: antrag.id, bezeichnung: 'Rentenbescheide aller Träger', status: 'ANERKANNT', angefordertAm: datum('2026-03-18'), uebersandtAm: datum('2026-03-26'), ausCheckliste: true, reihenfolge: 1 },
{ antragId: antrag.id, bezeichnung: 'Kontoauszüge der letzten 3 Monate, lückenlos', status: 'ANERKANNT', angefordertAm: datum('2026-03-18'), uebersandtAm: datum('2026-03-26'), ausCheckliste: true, reihenfolge: 2 },
{ antragId: antrag.id, bezeichnung: 'Pflegegradbescheid', status: 'ANERKANNT', angefordertAm: datum('2026-03-18'), uebersandtAm: datum('2026-03-26'), ausCheckliste: true, reihenfolge: 3 },
{ antragId: antrag.id, bezeichnung: 'Heimvertrag', status: 'UEBERSANDT', angefordertAm: datum('2026-03-18'), uebersandtAm: datum('2026-04-08'), ausCheckliste: true, reihenfolge: 4 },
{ antragId: antrag.id, bezeichnung: 'Aktueller Grundbuchauszug', status: 'UEBERSANDT', angefordertAm: datum('2026-04-21'), uebersandtAm: datum('2026-05-06'), ausCheckliste: true, reihenfolge: 5 },
{ antragId: antrag.id, bezeichnung: 'Rückkaufswert Kapitallebensversicherung zum Antragsdatum', status: 'IN_BESCHAFFUNG', angefordertAm: datum('2026-04-21'), fristBis: datum('2026-09-05'), erlaeuterung: 'Zweite Anfrage an die Gesellschaft am 12.08.2026 raus.', ausCheckliste: true, reihenfolge: 6 },
{ antragId: antrag.id, bezeichnung: 'Notarielle Verträge früherer Grundstücksübertragungen', status: 'OFFEN', angefordertAm: datum('2026-04-21'), fristBis: datum('2026-09-05'), erlaeuterung: 'Der Träger prüft eine Rückforderung wegen Verarmung (§ 528 BGB). Notariat Bayreuth angefragt.', ausCheckliste: true, reihenfolge: 7 },
{ antragId: antrag.id, bezeichnung: 'Erklärung zu Unterhaltspflichtigen', status: 'OFFEN', angefordertAm: datum('2026-07-30'), fristBis: datum('2026-08-31'), ausCheckliste: true, reihenfolge: 8 },
],
})
// --- Vorgaenge ----------------------------------------------------------
await db.vorgang.create({
data: {
betreuteId: betreute.id,
titel: 'Antrag Hilfe zur Pflege beim Bezirk Oberfranken',
kategorie: 'SOZIALHILFE',
status: 'WARTET_AUF_DRITTE',
gegenstelle: 'Bezirk Oberfranken',
aktenzeichen: 'HzP 2026/04711',
naechsterSchritt: 'Rückkaufswert und Übertragungsverträge nachreichen, dann Sachstand erfragen',
letzterKontaktAm: datum('2026-07-30'),
angelegtVonId: userId,
beschreibungVerschluesselt: verschluesseln(
'Antrag vom 26.02.2026. Der Bezirk hat in zwei Wellen nachgefordert. ' +
'Offen sind noch der Rückkaufswert der Lebensversicherung und die notariellen ' +
'Übertragungsverträge aus den 1990er-Jahren.',
'beschreibungVerschluesselt',
),
},
})
await db.vorgang.create({
data: {
betreuteId: betreute.id,
titel: 'Haus Bindlach: Verwertung oder Darlehen klären',
kategorie: 'IMMOBILIE',
status: 'IN_ARBEIT',
gegenstelle: 'Bezirk Oberfranken',
genehmigungspflichtig: true,
naechsterSchritt: 'Verkehrswert belastbar ermitteln lassen, dann Gespräch mit dem Bezirk',
angelegtVonId: userId,
beschreibungVerschluesselt: verschluesseln(
'Das Haus ist seit dem Heimeinzug leerstehend. Ein Verkauf wäre nach § 1850 BGB ' +
'genehmigungspflichtig. Vorrangig prüfen: Darlehen nach § 91 SGB XII gegen ' +
'Grundschuld, damit nicht unter Zeitdruck verkauft werden muss.',
'beschreibungVerschluesselt',
),
},
})
await db.vorgang.create({
data: {
betreuteId: betreute.id,
titel: 'Aufwandspauschale für das erste Betreuungsjahr beantragen',
kategorie: 'GERICHT',
status: 'OFFEN',
gegenstelle: 'Amtsgericht Bayreuth',
aktenzeichen: 'XVII 412/25',
naechsterSchritt: 'Antrag nach Ablauf des Betreuungsjahres am 13.02.2027 stellen',
angelegtVonId: userId,
},
})
// --- Kontaktprotokolle --------------------------------------------------
const besuche: { tag: string; dauer: number; inhalt: string; wunsch?: string }[] = [
{ tag: '2026-06-14', dauer: 75, inhalt: 'Besuch im Heim. Gemeinsam im Garten gesessen. Atmung an diesem Tag ruhig, Sauerstoff 2 l/min. Über das Haus gesprochen.', wunsch: 'Möchte das Haus nicht verkaufen, solange es nicht sein muss.' },
{ tag: '2026-07-05', dauer: 60, inhalt: 'Besuch. Erschöpft, blieb im Zimmer. Pflegedienstleitung berichtet von zwei kurzen Luftnotepisoden in der Vorwoche.' },
{ tag: '2026-07-22', dauer: 30, inhalt: 'Telefonat mit dem Sozialdienst wegen der Heimabrechnung Juli.' },
{ tag: '2026-08-09', dauer: 90, inhalt: 'Besuch mit Enkelin. Deutlich wacher als beim letzten Mal. Wünscht sich häufiger Besuch am Nachmittag.', wunsch: 'Besuche bitte nachmittags, vormittags ist sie zu müde.' },
]
for (const b of besuche) {
await db.kontaktprotokoll.create({
data: {
betreuteId: betreute.id,
datum: new Date(`${b.tag}T15:00:00.000Z`),
art: b.dauer === 30 ? 'TELEFONAT' : 'BESUCH',
dauerMinuten: b.dauer,
mitBetreuter: b.dauer !== 30,
inhaltVerschluesselt: verschluesseln(b.inhalt, 'inhaltVerschluesselt'),
wuenscheVerschluesselt: b.wunsch ? verschluesseln(b.wunsch, 'wuenscheVerschluesselt') : null,
},
})
}
// --- Gesundheitsverlauf -------------------------------------------------
await db.gesundheitsereignis.createMany({
data: [
{ betreuteId: betreute.id, datum: datum('2026-03-11'), art: 'EXAZERBATION', titel: 'Infektexazerbation, ambulant behandelt', behandler: 'Dr. Reuther', beschreibungVerschluesselt: verschluesseln('Antibiotikum und kurzzeitig Prednisolon. Keine Einweisung nötig.', 'beschreibungVerschluesselt') },
{ betreuteId: betreute.id, datum: datum('2026-05-02'), bisDatum: datum('2026-05-09'), art: 'KRANKENHAUS', titel: 'Stationär wegen Luftnot', einrichtung: 'Klinikum Bayreuth', abwesenheitstage: 7, beschreibungVerschluesselt: verschluesseln('Aufnahme über die Notaufnahme. Sauerstoffbedarf danach von 1,5 auf 2 l/min erhöht.', 'beschreibungVerschluesselt') },
{ betreuteId: betreute.id, datum: datum('2026-06-20'), art: 'HILFSMITTEL', titel: 'Mobiler Sauerstoffkonzentrator bewilligt', beschreibungVerschluesselt: verschluesseln('Ermöglicht Ausflüge aus dem Zimmer.', 'beschreibungVerschluesselt') },
{ betreuteId: betreute.id, datum: datum('2026-07-15'), art: 'IMPFUNG', titel: 'Pneumokokken-Impfung', behandler: 'Dr. Reuther' },
],
})
// --- Heimkosten ---------------------------------------------------------
for (const monat of [5, 6, 7]) {
await db.heimkostenmonat.create({
data: {
betreuteId: betreute.id,
jahr: 2026,
monat,
pflegeEigenanteilCent: cent(1489.0),
unterkunftVerpflegungCent: cent(878.4),
investitionskostenCent: cent(486.3),
ausbildungsumlageCent: cent(112.5),
pflegekasseCent: cent(1363.0),
leistungszuschlagProzent: 15,
leistungszuschlagCent: cent(223.35),
renteCent: cent(1284.62),
barbetragCent: cent(135.66),
bekleidungspauschaleCent: cent(27.13),
abwesenheitstage: monat === 5 ? 7 : 0,
},
})
}
// --- Kontobewegungen ----------------------------------------------------
// Zwoelf Monate auf dem Girokonto. Erzeugt aus einem Muster und nicht
// einzeln abgetippt: So bleibt der Fall aenderbar, und die Betraege
// widersprechen sich nicht.
//
// Die Gegenstellen sind absichtlich uneinheitlich geschrieben. Genau
// damit muss die Zusammenfassung wiederkehrender Zahlungen umgehen - auf
// einem Kontoauszug steht dieselbe Stelle auch mal in Grossbuchstaben.
type Buchungsvorlage = {
tag: number
betrag: number
richtung: 'EINNAHME' | 'AUSGABE'
kategorie: BuchungKategorie
gegenstellen: string[]
zweck: string
}
const MONATLICH: Buchungsvorlage[] = [
{
tag: 1,
betrag: 1284.17,
richtung: 'EINNAHME',
kategorie: 'RENTE',
gegenstellen: [
'Deutsche Rentenversicherung Bund',
'DRV BUND',
'Deutsche Rentenversich. Bund',
],
zweck: 'Altersrente',
},
{
tag: 1,
betrag: 96.4,
richtung: 'EINNAHME',
kategorie: 'RENTE',
gegenstellen: ['Zusatzversorgungskasse Bayern', 'ZVK Bayern'],
zweck: 'Betriebsrente',
},
{
tag: 3,
betrag: 1262,
richtung: 'EINNAHME',
kategorie: 'PFLEGEKASSE',
gegenstellen: ['Pflegekasse bei der AOK Bayern', 'AOK Bayern - Pflegekasse'],
zweck: 'Leistung vollstationaere Pflege, Pflegegrad 3',
},
{
tag: 5,
betrag: 2489,
richtung: 'AUSGABE',
kategorie: 'HEIMKOSTEN',
gegenstellen: [
'Seniorenzentrum St. Katharina',
'SENIORENZENTRUM ST KATHARINA',
'Seniorenzentrum St. Katharina gGmbH',
],
zweck: 'Heimentgelt',
},
{
tag: 5,
betrag: 135,
richtung: 'AUSGABE',
kategorie: 'BARBETRAG',
// Eigene Gegenstelle, nicht das Heim: Sonst faellt der Barbetrag
// mit dem Heimentgelt in eine Gruppe, und aus zwei sauberen
// monatlichen Zahlungen wird eine "schwankende".
gegenstellen: ['Barauszahlung Verwahrgeldkonto'],
zweck: 'Barbetrag zur persoenlichen Verfuegung',
},
{
tag: 15,
betrag: 18.4,
richtung: 'AUSGABE',
kategorie: 'VERSICHERUNG',
gegenstellen: ['Nordheim Sterbekasse VVaG', 'NORDHEIM STERBEKASSE', 'Nordheim Sterbekasse'],
zweck: 'Beitrag Sterbegeldversicherung SG-220417',
},
{
tag: 20,
betrag: 24.9,
richtung: 'AUSGABE',
kategorie: 'TELEFON_INTERNET',
gegenstellen: ['TeleCare Kommunikation GmbH', 'TELECARE KOMMUNIKATION'],
zweck: 'Telefon und Fernsehen im Zimmer',
},
{
tag: 28,
betrag: 4.9,
richtung: 'AUSGABE',
kategorie: 'BANKGEBUEHREN',
gegenstellen: ['Sparkasse Bayreuth'],
zweck: 'Kontofuehrung',
},
]
/** Zwoelf Monate bis einschliesslich Juli 2026. */
const MONATE = [
'2025-08',
'2025-09',
'2025-10',
'2025-11',
'2025-12',
'2026-01',
'2026-02',
'2026-03',
'2026-04',
'2026-05',
'2026-06',
'2026-07',
]
const buchungen: Prisma.BuchungCreateManyInput[] = []
MONATE.forEach((monat, lauf) => {
MONATLICH.forEach((vorlage, stelle) => {
// Die Sterbegeld-Lastschrift steigt im Mai 2026. Daran wird die
// Pruefung beim Zuordnen einer Dauerzahlung sichtbar: Der Betrag
// weicht vom hinterlegten Beitrag ab.
const betrag =
vorlage.kategorie === 'VERSICHERUNG' && monat >= '2026-05' ? 19.8 : vorlage.betrag
buchungen.push({
betreuteId: betreute.id,
kontoId: giro.id,
datum: datum(`${monat}-${String(vorlage.tag).padStart(2, '0')}`),
betragCent: cent(betrag),
richtung: vorlage.richtung,
kategorie: vorlage.kategorie,
gegenstelle: vorlage.gegenstellen[lauf % vorlage.gegenstellen.length],
verwendungszweckVerschluesselt: verschluesseln(
vorlage.zweck,
'verwendungszweckVerschluesselt',
),
// Wiederkehrende Zahlungen brauchen keinen Einzelbeleg - der
// Bescheid bzw. der Vertrag dahinter ist der Nachweis.
belegFehltBegruendung:
vorlage.richtung === 'EINNAHME'
? 'Laufender Bezug, Nachweis ist der Bescheid.'
: 'Wiederkehrende Zahlung aus laufendem Vertrag.',
// Die Stelle in der Vorlagenliste gehoert in die Referenz: Zwei
// Renten am Monatsersten haetten sonst dieselbe, und der
// Unique-Index gegen Doppelbuchungen schlaegt zu Recht zu.
importReferenz: `demo-${monat}-${stelle}-${vorlage.kategorie}`,
})
})
})
// Vierteljaehrliches und Einmaliges - damit nicht alles im Monatsraster
// liegt und die Belegpflicht etwas zu zeigen hat.
const EINZELN: {
datum: string
betrag: number
richtung: 'EINNAHME' | 'AUSGABE'
kategorie: BuchungKategorie
gegenstelle: string
zweck: string
begruendung?: string
}[] = [
{
datum: '2025-08-15',
betrag: 55.08,
richtung: 'AUSGABE',
kategorie: 'RUNDFUNKBEITRAG',
gegenstelle: 'ARD ZDF Deutschlandradio Beitragsservice',
zweck: 'Rundfunkbeitrag 3. Quartal 2025',
begruendung: 'Wiederkehrende Zahlung, Beitragsbescheid liegt vor.',
},
{
datum: '2025-11-15',
betrag: 55.08,
richtung: 'AUSGABE',
kategorie: 'RUNDFUNKBEITRAG',
gegenstelle: 'ARD ZDF Deutschlandradio Beitragsservice',
zweck: 'Rundfunkbeitrag 4. Quartal 2025',
begruendung: 'Wiederkehrende Zahlung, Beitragsbescheid liegt vor.',
},
{
datum: '2026-02-15',
betrag: 55.08,
richtung: 'AUSGABE',
kategorie: 'RUNDFUNKBEITRAG',
gegenstelle: 'ARD ZDF Deutschlandradio Beitragsservice',
zweck: 'Rundfunkbeitrag 1. Quartal 2026',
begruendung: 'Wiederkehrende Zahlung, Beitragsbescheid liegt vor.',
},
{
datum: '2026-05-15',
betrag: 55.08,
richtung: 'AUSGABE',
kategorie: 'RUNDFUNKBEITRAG',
gegenstelle: 'ARD ZDF Deutschlandradio Beitragsservice',
zweck: 'Rundfunkbeitrag 2. Quartal 2026',
begruendung: 'Wiederkehrende Zahlung, Beitragsbescheid liegt vor.',
},
{
datum: '2026-02-16',
betrag: 187.5,
richtung: 'AUSGABE',
kategorie: 'GRUNDSTEUER',
gegenstelle: 'Gemeinde Bindlach',
zweck: 'Grundsteuer B, 1. Rate 2026',
begruendung: 'Bescheid der Gemeinde liegt in der Akte.',
},
{
datum: '2026-05-16',
betrag: 187.5,
richtung: 'AUSGABE',
kategorie: 'GRUNDSTEUER',
gegenstelle: 'Gemeinde Bindlach',
zweck: 'Grundsteuer B, 2. Rate 2026',
begruendung: 'Bescheid der Gemeinde liegt in der Akte.',
},
{
datum: '2026-01-19',
betrag: 240,
richtung: 'AUSGABE',
kategorie: 'IMMOBILIE_BETRIEBSKOSTEN',
gegenstelle: 'Stadtwerke Bayreuth',
zweck: 'Abschlag Strom und Wasser, Haus Bindlach',
begruendung: 'Jahresabrechnung steht noch aus.',
},
{
datum: '2026-03-11',
betrag: 10,
richtung: 'AUSGABE',
kategorie: 'ZUZAHLUNG',
gegenstelle: 'Apotheke am Markt',
zweck: 'Zuzahlung Rezept',
begruendung: 'Quittung liegt in der Akte.',
},
// Der Fall fuer die Belegpflicht: keine Quittung und keine Begruendung.
// Genau so sieht eine Buchung aus, die noch Arbeit macht.
{
datum: '2026-04-08',
betrag: 89.95,
richtung: 'AUSGABE',
kategorie: 'BEKLEIDUNG',
gegenstelle: 'Modehaus Lindner',
zweck: 'Winterjacke und zwei Nachthemden',
},
{
datum: '2026-06-17',
betrag: 23,
richtung: 'AUSGABE',
kategorie: 'SONSTIGE_AUSGABE',
gegenstelle: 'Friseur im Haus',
zweck: 'Friseur',
begruendung: 'Bar aus dem Barbetrag bezahlt, keine Quittung ausgestellt.',
},
{
datum: '2026-07-02',
betrag: 62.4,
richtung: 'EINNAHME',
kategorie: 'ZINSEN',
gegenstelle: 'Sparkasse Bayreuth',
zweck: 'Zinsgutschrift',
begruendung: 'Der Kontoauszug ist der Nachweis.',
},
]
for (const eintrag of EINZELN) {
buchungen.push({
betreuteId: betreute.id,
kontoId: giro.id,
datum: datum(eintrag.datum),
betragCent: cent(eintrag.betrag),
richtung: eintrag.richtung,
kategorie: eintrag.kategorie,
gegenstelle: eintrag.gegenstelle,
verwendungszweckVerschluesselt: verschluesseln(
eintrag.zweck,
'verwendungszweckVerschluesselt',
),
belegFehltBegruendung: eintrag.begruendung ?? null,
importReferenz: `demo-${eintrag.datum}-${eintrag.kategorie}`,
})
}
await db.buchung.createMany({ data: buchungen })
// Der Kontostand zum 31.07.2026 wird gerechnet, nicht behauptet: Anfangs-
// stand plus alles, was seither ueber das Konto gelaufen ist. Sonst
// widerspraeche der Demofall sich selbst, sobald jemand nachrechnet.
const bewegungCent = buchungen
.filter((eintrag) => {
const tag = eintrag.datum as Date
return tag > datum('2026-02-14') && tag <= datum('2026-07-31')
})
.reduce((summe, eintrag) => {
const betrag = eintrag.betragCent as bigint
return eintrag.richtung === 'EINNAHME' ? summe + betrag : summe - betrag
}, 0n)
await db.saldostand.create({
data: {
kontoId: giro.id,
stichtag: datum('2026-07-31'),
betragCent: cent(4218.36) + bewegungCent,
quelle: 'Kontoauszug 07/2026',
},
})
console.log('Demofall "Erika Musterfrau" angelegt.')
}
async function main() {
const benutzer = await ersterZugang()
if (process.env.SEED_DEMO === 'true' && !istProduktion && benutzer) {
await demofall(benutzer.id)
} else if (process.env.SEED_DEMO === 'true' && istProduktion) {
console.log('SEED_DEMO wird in der Produktion nicht ausgefuehrt.')
}
console.log('\nFertig. Weiter geht es im Browser unter /ersteinrichtung.')
}
main()
.catch((fehler) => {
console.error('Seed fehlgeschlagen:', fehler)
process.exit(1)
})
.finally(() => db.$disconnect())