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,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('')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user