feat: Fristen, Pflichtfristen aus dem Bestellungsdatum und ICS-Feed
Die gesetzlichen Fristen entstehen von selbst, sobald das Bestellungsdatum in den Stammdaten steht - Vermoegensverzeichnis und Anfangsbericht nach drei Monaten, Jahresbericht und Vermoegensuebersicht zum Ende jedes Betreuungsjahres, Aufwandspauschale, Landespflegegeld, Zuzahlungsbefreiung, Rueckkaufswerte. Zwei Punkte, an denen es sonst schiefgeht: Das Betreuungsjahr endet am Tag VOR dem Jahrestag (§ 188 Abs. 2 BGB), bei Bestellung am 29. Februar am 28. (§ 188 Abs. 3). Ohne diese Regel verrutscht die ganze Kette um einen Tag. Faellt eine Frist auf Samstag, Sonntag oder Feiertag, wird sie NICHT verschoben. § 193 BGB verschiebt zwar den Ablauf, aber als Betreuer will ich frueher fertig sein, nicht spaeter. Stattdessen steht ein Hinweis in der Beschreibung. Bayerische Feiertage inklusive, ueber die Osterformel berechnet. Pflichtfristen tragen bewusst keine RRULE: sonst entstuende beim Erledigen eine Dublette neben der ohnehin abgeleiteten Folgefrist. Beim erneuten Ableiten werden nur offene Fristen nachgezogen, Erledigtes bleibt unangetastet. Der ICS-Feed kommt ohne Token-Feld im Schema aus - die Signatur ist ein HMAC ueber betreuteId und userId. VALARM je Vorwarnstufe, Zeilenfaltung nach RFC 5545. 51 Tests. Etappe: 2 Phase: Alltag
This commit is contained in:
@@ -0,0 +1,86 @@
|
|||||||
|
# Schema-Wünsche aus dem Modul „Fristen & Kalender"
|
||||||
|
|
||||||
|
Das Modul ist vollständig mit dem bestehenden `model Frist` umgesetzt —
|
||||||
|
nichts davon blockiert. Die folgenden Punkte sind Verbesserungen, die
|
||||||
|
jeweils eine Behelfslösung ersetzen würden. `prisma/schema.prisma` wurde
|
||||||
|
nicht angefasst.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Getrennte Felder für Erledigungsnotiz, Verschiebungs- und Entfallgrund
|
||||||
|
|
||||||
|
**Behelf heute:** Beim Erledigen, Verschieben und Entfallenlassen hängen die
|
||||||
|
Server Actions den Grund als Textzeile an `beschreibung` an
|
||||||
|
(`"Verschoben vom 14.03.2026: Unterlagen fehlten noch."`).
|
||||||
|
|
||||||
|
**Warum das stört:** `beschreibung` ist der Text, der sagt, *was zu tun
|
||||||
|
ist* — er wandert unverändert in den ICS-Feed und in die Vorwarnungen. Mit
|
||||||
|
angehängter Historie wird er über die Jahre unlesbar, und die Ableitung der
|
||||||
|
Pflichtfristen überschreibt ihn bei offenen Fristen ohnehin wieder (dann
|
||||||
|
ist die Notiz weg). Ausserdem lässt sich nicht auswerten, wie oft eine
|
||||||
|
Frist verschoben wurde — genau die Zahl, die im Jahresbericht auffällt.
|
||||||
|
|
||||||
|
**Wunsch:**
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
/// Freitext beim Abhaken: wie wurde erledigt, was ging raus, an wen.
|
||||||
|
erledigtNotiz String?
|
||||||
|
/// Grund der letzten Verschiebung bzw. des Entfallens.
|
||||||
|
statusGrund String?
|
||||||
|
/// Wie oft wurde diese Frist schon verschoben? Fuer die Selbstkontrolle.
|
||||||
|
verschobenAnzahl Int @default(0)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Wer hat die Frist erledigt?
|
||||||
|
|
||||||
|
**Behelf heute:** Steht nur im Audit-Log (`AuditLog.userId`).
|
||||||
|
|
||||||
|
**Warum das stört:** Für die Anzeige („erledigt am 14.03. von Florian
|
||||||
|
Wilde") müsste die Liste für jede Frist im Audit-Log nachschlagen. Bei
|
||||||
|
mehreren Betreuern oder einem mitlesenden Geschwisterteil ist das die erste
|
||||||
|
Frage, die gestellt wird.
|
||||||
|
|
||||||
|
**Wunsch:** analog zu `Vorgang.angelegtVonId`
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
erledigtVonId String?
|
||||||
|
erledigtVon User? @relation("FristErledigtVon", fields: [erledigtVonId], references: [id], onDelete: SetNull)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. `vorgaengerFristId` ist `@unique` — die Kette ist einspurig
|
||||||
|
|
||||||
|
**Behelf heute:** Funktioniert für den Normalfall (eine erledigte Frist →
|
||||||
|
genau eine Nachfolgefrist).
|
||||||
|
|
||||||
|
**Warum das stört:** Beim Verschieben *und* bei der Wiederholung zeigt die
|
||||||
|
neue Frist auf dieselbe Vorgängerin. Wird eine verschobene Frist später
|
||||||
|
irrtümlich noch einmal verschoben, läuft das in eine Verletzung der
|
||||||
|
Eindeutigkeit statt in eine verständliche Meldung. Ein einfacher `@@index`
|
||||||
|
statt `@unique` würde reichen; die Kette bliebe über `vorgaengerFristId`
|
||||||
|
weiterhin rückwärts lesbar.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Kein Feld für den Kalender-Token — bewusst so
|
||||||
|
|
||||||
|
Zur Klarstellung, damit niemand später eines nachrüstet: Der abonnierbare
|
||||||
|
ICS-Feed kommt **ohne** Schemafeld aus. Die Adresse trägt eine Signatur
|
||||||
|
`base64url(<betreuteId>.<userId>.<HMAC-SHA256>)`, berechnet mit
|
||||||
|
`AUTH_SECRET` (siehe `src/lib/fristen/ics.ts`).
|
||||||
|
|
||||||
|
Vorteil: Es liegt kein dauerhaft gültiges Geheimnis in der Datenbank, das
|
||||||
|
bei einem Datenbank-Leck mitgelesen werden könnte. Die Route prüft bei
|
||||||
|
jedem Abruf zusätzlich, ob der Benutzer noch aktiv ist und noch Lesezugriff
|
||||||
|
auf das Modul `FRISTEN` hat — ein entzogener Zugang leert den Kalender
|
||||||
|
sofort.
|
||||||
|
|
||||||
|
Nachteil, der in Kauf genommen wird: Eine einzelne Adresse lässt sich nicht
|
||||||
|
gezielt widerrufen, nur durch Wechsel von `AUTH_SECRET` (was alle
|
||||||
|
Anmeldungen beendet). Falls das je gebraucht wird, wäre ein
|
||||||
|
`kalenderGeneration Int @default(0)` an `BetreuungsZugang` die kleinste
|
||||||
|
Lösung: Sie geht in den HMAC ein, und ein Hochzählen entwertet nur die
|
||||||
|
Adressen dieses einen Zugangs.
|
||||||
@@ -52,7 +52,7 @@ export function ErledigenKnopf({
|
|||||||
size={groesse}
|
size={groesse}
|
||||||
onClick={abhaken}
|
onClick={abhaken}
|
||||||
disabled={laeuft}
|
disabled={laeuft}
|
||||||
aria-label={`Frist „${titel}" als erledigt abhaken`}
|
aria-label={`Frist „${titel}“ als erledigt abhaken`}
|
||||||
>
|
>
|
||||||
{laeuft ? (
|
{laeuft ? (
|
||||||
<Loader2 className="animate-spin" aria-hidden="true" />
|
<Loader2 className="animate-spin" aria-hidden="true" />
|
||||||
|
|||||||
@@ -314,7 +314,10 @@ export function pflichtfristenAbleiten(stammdaten: Pflichtfristenstammdaten): Pf
|
|||||||
...kuenftige(jahresberichte, heute, anzahl),
|
...kuenftige(jahresberichte, heute, anzahl),
|
||||||
...kuenftige(uebersichten, heute, anzahl),
|
...kuenftige(uebersichten, heute, anzahl),
|
||||||
...kuenftige(rueckkaufswerte, heute, anzahl),
|
...kuenftige(rueckkaufswerte, heute, anzahl),
|
||||||
...kuenftige(pauschalen, heute, anzahl + 1),
|
// Die Pauschalen sind chronologisch sortiert - die noch laufende
|
||||||
|
// Ausschlussfrist des eben abgelaufenen Betreuungsjahres steht damit
|
||||||
|
// vorn und ueberlebt die Begrenzung auf jahreVoraus Termine.
|
||||||
|
...kuenftige(pauschalen, heute, anzahl),
|
||||||
)
|
)
|
||||||
|
|
||||||
// --- Wiederkehrende Fristen am Kalenderjahr ------------------------------
|
// --- Wiederkehrende Fristen am Kalenderjahr ------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,572 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { fristAmpel } from '@/lib/fristen/ampel'
|
||||||
|
import {
|
||||||
|
alsIsoTag,
|
||||||
|
ausIsoTag,
|
||||||
|
feiertagName,
|
||||||
|
istWochenende,
|
||||||
|
nichtWerktagHinweis,
|
||||||
|
plusMonate,
|
||||||
|
plusTage,
|
||||||
|
tag,
|
||||||
|
} from '@/lib/fristen/datum'
|
||||||
|
import {
|
||||||
|
alsIcsDatum,
|
||||||
|
icsErzeugen,
|
||||||
|
signaturErzeugen,
|
||||||
|
signaturPruefen,
|
||||||
|
textEscapen,
|
||||||
|
zeileFalten,
|
||||||
|
} from '@/lib/fristen/ics'
|
||||||
|
import {
|
||||||
|
betreuungsjahrBeginn,
|
||||||
|
betreuungsjahrEnde,
|
||||||
|
laufendesBetreuungsjahr,
|
||||||
|
pflichtfristenAbleiten,
|
||||||
|
type Pflichtfrist,
|
||||||
|
} from '@/lib/fristen/pflichtfristen'
|
||||||
|
import { naechsteFristAusErledigter, naechsterTermin, rruleGueltig } from '@/lib/fristen/wiederholung'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Diese Tests beschreiben, was fachlich gelten soll - nicht, wie es
|
||||||
|
* programmiert ist. Sie kommen ohne Datenbank aus: geprueft wird die reine
|
||||||
|
* Rechenlogik, an der im Betreuungsalltag Geld und Ansprueche haengen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const STAMMDATEN = {
|
||||||
|
befreiterBetreuer: true,
|
||||||
|
ehrenamtlich: true,
|
||||||
|
pflegegrad: 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
function schluessel(fristen: Pflichtfrist[]): string[] {
|
||||||
|
return fristen.map((f) => f.systemSchluessel)
|
||||||
|
}
|
||||||
|
|
||||||
|
function finde(fristen: Pflichtfrist[], gesucht: string): Pflichtfrist {
|
||||||
|
const treffer = fristen.find((f) => f.systemSchluessel === gesucht)
|
||||||
|
if (!treffer) throw new Error(`Frist "${gesucht}" wurde nicht abgeleitet.`)
|
||||||
|
return treffer
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('Betreuungsjahr', () => {
|
||||||
|
it('läuft ab dem Tag der Bestellung, nicht ab dem Kalenderjahr', () => {
|
||||||
|
const bestellung = tag(2025, 3, 15)
|
||||||
|
expect(alsIsoTag(betreuungsjahrBeginn(bestellung, 1))).toBe('2025-03-15')
|
||||||
|
expect(alsIsoTag(betreuungsjahrEnde(bestellung, 1))).toBe('2026-03-14')
|
||||||
|
expect(alsIsoTag(betreuungsjahrBeginn(bestellung, 2))).toBe('2026-03-15')
|
||||||
|
expect(alsIsoTag(betreuungsjahrEnde(bestellung, 2))).toBe('2027-03-14')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reicht über den Jahreswechsel, ohne auf den 31.12. zu springen', () => {
|
||||||
|
const bestellung = tag(2025, 11, 20)
|
||||||
|
expect(alsIsoTag(betreuungsjahrEnde(bestellung, 1))).toBe('2026-11-19')
|
||||||
|
expect(alsIsoTag(betreuungsjahrBeginn(bestellung, 3))).toBe('2027-11-20')
|
||||||
|
expect(alsIsoTag(betreuungsjahrEnde(bestellung, 3))).toBe('2028-11-19')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('endet bei Bestellung am 29. Februar im Folgejahr am 28. Februar', () => {
|
||||||
|
// § 188 Abs. 3 BGB: Fehlt der massgebende Tag im letzten Monat, endet
|
||||||
|
// die Frist mit dem letzten Tag dieses Monats.
|
||||||
|
const bestellung = tag(2024, 2, 29)
|
||||||
|
expect(alsIsoTag(betreuungsjahrEnde(bestellung, 1))).toBe('2025-02-28')
|
||||||
|
expect(alsIsoTag(betreuungsjahrBeginn(bestellung, 2))).toBe('2025-03-01')
|
||||||
|
expect(alsIsoTag(betreuungsjahrEnde(bestellung, 2))).toBe('2026-02-28')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lässt kein Datum zwischen zwei Betreuungsjahren durchfallen', () => {
|
||||||
|
const bestellung = tag(2024, 2, 29)
|
||||||
|
for (let n = 1; n <= 8; n++) {
|
||||||
|
const ende = betreuungsjahrEnde(bestellung, n)
|
||||||
|
const naechsterBeginn = betreuungsjahrBeginn(bestellung, n + 1)
|
||||||
|
expect(alsIsoTag(naechsterBeginn)).toBe(alsIsoTag(plusTage(ende, 1)))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('weiß, in welchem Betreuungsjahr ein Tag liegt', () => {
|
||||||
|
const bestellung = tag(2025, 3, 15)
|
||||||
|
expect(laufendesBetreuungsjahr(bestellung, tag(2025, 3, 15))).toBe(1)
|
||||||
|
expect(laufendesBetreuungsjahr(bestellung, tag(2026, 3, 14))).toBe(1)
|
||||||
|
expect(laufendesBetreuungsjahr(bestellung, tag(2026, 3, 15))).toBe(2)
|
||||||
|
expect(laufendesBetreuungsjahr(bestellung, tag(2030, 1, 1))).toBe(5)
|
||||||
|
// Vor der Bestellung gibt es noch kein abgelaufenes Jahr.
|
||||||
|
expect(laufendesBetreuungsjahr(bestellung, tag(2024, 1, 1))).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('Ausschlussfrist der Aufwandspauschale', () => {
|
||||||
|
const bestellung = tag(2025, 3, 15)
|
||||||
|
|
||||||
|
it('verfällt sechs Monate nach Ablauf des Betreuungsjahres', () => {
|
||||||
|
// Betreuungsjahr 1: 15.03.2025 bis 14.03.2026. Sechs Monate nach dessen
|
||||||
|
// Ablauf ist der 14.09.2026 - § 1878 Abs. 2 BGB.
|
||||||
|
const fristen = pflichtfristenAbleiten({
|
||||||
|
...STAMMDATEN,
|
||||||
|
bestellungAm: bestellung,
|
||||||
|
heute: tag(2025, 6, 1),
|
||||||
|
})
|
||||||
|
const pauschale = finde(fristen, 'aufwandspauschale-2026')
|
||||||
|
expect(alsIsoTag(pauschale.datum)).toBe('2026-09-14')
|
||||||
|
expect(pauschale.ausschlussfrist).toBe(true)
|
||||||
|
expect(pauschale.rechtsgrundlage).toBe('§ 1878 BGB')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('warnt früher vor als eine gewöhnliche Frist', () => {
|
||||||
|
const fristen = pflichtfristenAbleiten({
|
||||||
|
...STAMMDATEN,
|
||||||
|
bestellungAm: bestellung,
|
||||||
|
heute: tag(2025, 6, 1),
|
||||||
|
})
|
||||||
|
expect(finde(fristen, 'aufwandspauschale-2026').vorwarnungTage).toEqual([90, 60, 30, 14, 7, 1])
|
||||||
|
expect(finde(fristen, 'jahresbericht-2026').vorwarnungTage).toEqual([60, 30, 7, 1])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('bleibt für das eben abgelaufene Betreuungsjahr stehen, solange sie läuft', () => {
|
||||||
|
// Am 01.06.2026 ist das erste Betreuungsjahr vorbei, die Pauschale
|
||||||
|
// dafür aber noch bis 14.09.2026 zu holen. Genau dieses Geld darf die
|
||||||
|
// Anwendung nicht verschweigen.
|
||||||
|
const fristen = pflichtfristenAbleiten({
|
||||||
|
...STAMMDATEN,
|
||||||
|
bestellungAm: bestellung,
|
||||||
|
heute: tag(2026, 6, 1),
|
||||||
|
})
|
||||||
|
expect(schluessel(fristen)).toContain('aufwandspauschale-2026')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('verschwindet, sobald sie endgültig verfallen ist', () => {
|
||||||
|
const fristen = pflichtfristenAbleiten({
|
||||||
|
...STAMMDATEN,
|
||||||
|
bestellungAm: bestellung,
|
||||||
|
heute: tag(2026, 9, 15),
|
||||||
|
})
|
||||||
|
expect(schluessel(fristen)).not.toContain('aufwandspauschale-2026')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('entsteht gar nicht, wenn die Betreuung berufsmäßig geführt wird', () => {
|
||||||
|
const fristen = pflichtfristenAbleiten({
|
||||||
|
...STAMMDATEN,
|
||||||
|
ehrenamtlich: false,
|
||||||
|
bestellungAm: bestellung,
|
||||||
|
heute: tag(2025, 6, 1),
|
||||||
|
})
|
||||||
|
expect(schluessel(fristen).filter((s) => s.startsWith('aufwandspauschale'))).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('Ableitung der Pflichtfristen', () => {
|
||||||
|
const bestellung = tag(2025, 3, 15)
|
||||||
|
const heute = tag(2025, 6, 1)
|
||||||
|
|
||||||
|
it('bringt Vermögensverzeichnis und Anfangsbericht drei Monate nach der Bestellung', () => {
|
||||||
|
const fristen = pflichtfristenAbleiten({ ...STAMMDATEN, bestellungAm: bestellung, heute })
|
||||||
|
expect(alsIsoTag(finde(fristen, 'vermoegensverzeichnis').datum)).toBe('2025-06-15')
|
||||||
|
expect(alsIsoTag(finde(fristen, 'anfangsbericht').datum)).toBe('2025-06-15')
|
||||||
|
expect(finde(fristen, 'anfangsbericht').rechtsgrundlage).toBe('§ 1863 Abs. 1 BGB')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('liefert bei gleichen Stammdaten zweimal genau dasselbe (Idempotenz)', () => {
|
||||||
|
const eingabe = { ...STAMMDATEN, bestellungAm: bestellung, heute }
|
||||||
|
const ersteLauf = pflichtfristenAbleiten(eingabe)
|
||||||
|
const zweiterLauf = pflichtfristenAbleiten(eingabe)
|
||||||
|
|
||||||
|
expect(schluessel(zweiterLauf)).toEqual(schluessel(ersteLauf))
|
||||||
|
expect(zweiterLauf.map((f) => alsIsoTag(f.datum))).toEqual(
|
||||||
|
ersteLauf.map((f) => alsIsoTag(f.datum)),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('vergibt jeden Systemschlüssel nur einmal — sonst gäbe es Dubletten', () => {
|
||||||
|
const fristen = pflichtfristenAbleiten({ ...STAMMDATEN, bestellungAm: bestellung, heute })
|
||||||
|
expect(new Set(schluessel(fristen)).size).toBe(fristen.length)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('erzeugt standardmäßig drei Jahre im Voraus', () => {
|
||||||
|
const fristen = pflichtfristenAbleiten({ ...STAMMDATEN, bestellungAm: bestellung, heute })
|
||||||
|
expect(schluessel(fristen).filter((s) => s.startsWith('jahresbericht-'))).toHaveLength(3)
|
||||||
|
expect(schluessel(fristen).filter((s) => s.startsWith('landespflegegeld-'))).toHaveLength(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('richtet sich nach dem Parameter jahreVoraus', () => {
|
||||||
|
const fristen = pflichtfristenAbleiten({
|
||||||
|
...STAMMDATEN,
|
||||||
|
bestellungAm: bestellung,
|
||||||
|
heute,
|
||||||
|
jahreVoraus: 1,
|
||||||
|
})
|
||||||
|
expect(schluessel(fristen).filter((s) => s.startsWith('jahresbericht-'))).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leitet ohne Bestellungsdatum gar nichts ab', () => {
|
||||||
|
expect(pflichtfristenAbleiten({ ...STAMMDATEN, bestellungAm: null, heute })).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lässt Landespflegegeld unter Pflegegrad 2 weg', () => {
|
||||||
|
const mitPflegegrad = pflichtfristenAbleiten({
|
||||||
|
...STAMMDATEN,
|
||||||
|
pflegegrad: 2,
|
||||||
|
bestellungAm: bestellung,
|
||||||
|
heute,
|
||||||
|
})
|
||||||
|
const ohne = pflichtfristenAbleiten({
|
||||||
|
...STAMMDATEN,
|
||||||
|
pflegegrad: 1,
|
||||||
|
bestellungAm: bestellung,
|
||||||
|
heute,
|
||||||
|
})
|
||||||
|
expect(schluessel(mitPflegegrad).some((s) => s.startsWith('landespflegegeld-'))).toBe(true)
|
||||||
|
expect(schluessel(ohne).some((s) => s.startsWith('landespflegegeld-'))).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('nennt beim befreiten Betreuer die Vermögensübersicht, sonst die Rechnungslegung', () => {
|
||||||
|
const befreit = pflichtfristenAbleiten({ ...STAMMDATEN, bestellungAm: bestellung, heute })
|
||||||
|
const voll = pflichtfristenAbleiten({
|
||||||
|
...STAMMDATEN,
|
||||||
|
befreiterBetreuer: false,
|
||||||
|
bestellungAm: bestellung,
|
||||||
|
heute,
|
||||||
|
})
|
||||||
|
expect(finde(befreit, 'vermoegensuebersicht-2026').rechtsgrundlage).toBe('§ 1859 Abs. 1 BGB')
|
||||||
|
expect(finde(voll, 'vermoegensuebersicht-2026').rechtsgrundlage).toBe('§ 1865 BGB')
|
||||||
|
// Der Schluessel bleibt gleich, damit ein Wechsel der Einstufung keine
|
||||||
|
// zweite Frist neben der bestehenden erzeugt.
|
||||||
|
expect(finde(voll, 'vermoegensuebersicht-2026').titel).toContain('Rechnungslegung')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('weist auf Stichtage hin, die auf ein Wochenende fallen — ohne sie zu verschieben', () => {
|
||||||
|
// Der 14.09.2025 ist ein Sonntag; Bestellung am 14.06.2025 legt den
|
||||||
|
// Anfangsbericht genau dorthin.
|
||||||
|
const fristen = pflichtfristenAbleiten({
|
||||||
|
...STAMMDATEN,
|
||||||
|
bestellungAm: tag(2025, 6, 14),
|
||||||
|
heute: tag(2025, 6, 20),
|
||||||
|
})
|
||||||
|
const bericht = finde(fristen, 'anfangsbericht')
|
||||||
|
expect(alsIsoTag(bericht.datum)).toBe('2025-09-14')
|
||||||
|
expect(istWochenende(bericht.datum)).toBe(true)
|
||||||
|
expect(bericht.beschreibung).toContain('Sonntag')
|
||||||
|
expect(bericht.beschreibung).toContain('§ 193 BGB')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ist chronologisch sortiert', () => {
|
||||||
|
const fristen = pflichtfristenAbleiten({ ...STAMMDATEN, bestellungAm: bestellung, heute })
|
||||||
|
const daten = fristen.map((f) => f.datum.getTime())
|
||||||
|
expect([...daten].sort((a, b) => a - b)).toEqual(daten)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('Wiederkehrende Fristen', () => {
|
||||||
|
it('findet den nächsten Jahrestermin', () => {
|
||||||
|
const naechster = naechsterTermin('FREQ=YEARLY;INTERVAL=1', tag(2026, 3, 14))
|
||||||
|
expect(alsIsoTag(naechster!)).toBe('2027-03-14')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('gibt nie den Tag zurück, der gerade erledigt wurde', () => {
|
||||||
|
const naechster = naechsterTermin('FREQ=MONTHLY;INTERVAL=1', tag(2026, 1, 31))
|
||||||
|
expect(alsIsoTag(naechster!)).not.toBe('2026-01-31')
|
||||||
|
// Der Februar hat keinen 31. - die naechste Wiederholung ist der 31.03.
|
||||||
|
expect(alsIsoTag(naechster!)).toBe('2026-03-31')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rechnet vierteljährlich in Dreimonatsschritten', () => {
|
||||||
|
expect(alsIsoTag(naechsterTermin('FREQ=MONTHLY;INTERVAL=3', tag(2026, 2, 10))!)).toBe(
|
||||||
|
'2026-05-10',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('endet, wenn die Regel erschöpft ist', () => {
|
||||||
|
expect(naechsterTermin('FREQ=YEARLY;COUNT=1', tag(2026, 3, 14))).toBeNull()
|
||||||
|
expect(naechsterTermin('FREQ=MONTHLY;UNTIL=20260201T000000Z', tag(2026, 1, 15))).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('erkennt unbrauchbare Regeln, statt sie stillschweigend zu schlucken', () => {
|
||||||
|
expect(rruleGueltig('FREQ=YEARLY;INTERVAL=1')).toBe(true)
|
||||||
|
expect(rruleGueltig('jeden Montag')).toBe(false)
|
||||||
|
expect(naechsterTermin('Unsinn', tag(2026, 1, 1))).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('verkettet die Nachfolgefrist mit der erledigten', () => {
|
||||||
|
const nachfolger = naechsteFristAusErledigter({
|
||||||
|
id: 'frist-1',
|
||||||
|
betreuteId: 'betreute-1',
|
||||||
|
vorgangId: 'vorgang-9',
|
||||||
|
titel: 'Kontoauszüge holen',
|
||||||
|
beschreibung: 'Bei der Sparkasse abholen.',
|
||||||
|
datum: tag(2026, 1, 15),
|
||||||
|
typ: 'WIEDERVORLAGE',
|
||||||
|
rechtsgrundlage: null,
|
||||||
|
vorwarnungTage: [7, 1],
|
||||||
|
wiederholungRrule: 'FREQ=MONTHLY;INTERVAL=1',
|
||||||
|
ausschlussfrist: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(nachfolger).not.toBeNull()
|
||||||
|
expect(nachfolger!.vorgaengerFristId).toBe('frist-1')
|
||||||
|
expect(alsIsoTag(nachfolger!.datum)).toBe('2026-02-15')
|
||||||
|
expect(nachfolger!.titel).toBe('Kontoauszüge holen')
|
||||||
|
expect(nachfolger!.vorgangId).toBe('vorgang-9')
|
||||||
|
expect(nachfolger!.wiederholungRrule).toBe('FREQ=MONTHLY;INTERVAL=1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rechnet ab dem Solltermin, nicht ab dem Tag der Erledigung', () => {
|
||||||
|
// Wer drei Tage zu spaet abhakt, soll nicht dauerhaft nachrutschen.
|
||||||
|
const nachfolger = naechsteFristAusErledigter({
|
||||||
|
id: 'frist-2',
|
||||||
|
betreuteId: 'betreute-1',
|
||||||
|
titel: 'Heimkosten prüfen',
|
||||||
|
datum: tag(2026, 1, 1),
|
||||||
|
typ: 'WIEDERVORLAGE',
|
||||||
|
vorwarnungTage: [7],
|
||||||
|
wiederholungRrule: 'FREQ=MONTHLY;INTERVAL=1',
|
||||||
|
ausschlussfrist: false,
|
||||||
|
})
|
||||||
|
expect(alsIsoTag(nachfolger!.datum)).toBe('2026-02-01')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('legt ohne Wiederholungsregel keine Nachfolgefrist an', () => {
|
||||||
|
const nachfolger = naechsteFristAusErledigter({
|
||||||
|
id: 'frist-3',
|
||||||
|
betreuteId: 'betreute-1',
|
||||||
|
titel: 'Einmalige Sache',
|
||||||
|
datum: tag(2026, 1, 1),
|
||||||
|
typ: 'SELBST_GESETZT',
|
||||||
|
vorwarnungTage: [],
|
||||||
|
wiederholungRrule: null,
|
||||||
|
ausschlussfrist: false,
|
||||||
|
})
|
||||||
|
expect(nachfolger).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('Fristenampel', () => {
|
||||||
|
const heute = tag(2026, 5, 20)
|
||||||
|
|
||||||
|
it('färbt Überfälliges rot und sagt seit wann', () => {
|
||||||
|
const gestern = fristAmpel(tag(2026, 5, 19), 'OFFEN', heute)
|
||||||
|
expect(gestern.stufe).toBe('ueberfaellig')
|
||||||
|
expect(gestern.variante).toBe('rot')
|
||||||
|
expect(gestern.text).toBe('Überfällig seit gestern')
|
||||||
|
|
||||||
|
const vorheriger = fristAmpel(tag(2026, 5, 10), 'OFFEN', heute)
|
||||||
|
expect(vorheriger.text).toBe('Überfällig seit 10 Tagen')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('zählt heute und morgen zur laufenden Woche', () => {
|
||||||
|
expect(fristAmpel(heute, 'OFFEN', heute)).toMatchObject({
|
||||||
|
stufe: 'diese_woche',
|
||||||
|
variante: 'orange',
|
||||||
|
text: 'Heute fällig',
|
||||||
|
})
|
||||||
|
expect(fristAmpel(tag(2026, 5, 21), 'OFFEN', heute)).toMatchObject({
|
||||||
|
stufe: 'diese_woche',
|
||||||
|
text: 'Morgen fällig',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('trennt „diese Woche“ von „dieser Monat“ genau nach sieben Tagen', () => {
|
||||||
|
const siebter = fristAmpel(tag(2026, 5, 27), 'OFFEN', heute)
|
||||||
|
const achter = fristAmpel(tag(2026, 5, 28), 'OFFEN', heute)
|
||||||
|
|
||||||
|
expect(siebter.tage).toBe(7)
|
||||||
|
expect(siebter.stufe).toBe('diese_woche')
|
||||||
|
expect(siebter.variante).toBe('orange')
|
||||||
|
|
||||||
|
expect(achter.tage).toBe(8)
|
||||||
|
expect(achter.stufe).toBe('dieser_monat')
|
||||||
|
expect(achter.variante).toBe('gelb')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('schaltet nach 31 Tagen auf „später“', () => {
|
||||||
|
expect(fristAmpel(tag(2026, 6, 20), 'OFFEN', heute).stufe).toBe('dieser_monat')
|
||||||
|
expect(fristAmpel(tag(2026, 6, 21), 'OFFEN', heute).stufe).toBe('spaeter')
|
||||||
|
expect(fristAmpel(tag(2026, 6, 21), 'OFFEN', heute).variante).toBe('gruen')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('nimmt Abgeschlossenes aus der Ampel heraus', () => {
|
||||||
|
for (const status of ['ERLEDIGT', 'VERSCHOBEN', 'ENTFALLEN'] as const) {
|
||||||
|
const ampel = fristAmpel(tag(2026, 1, 1), status, heute)
|
||||||
|
expect(ampel.stufe).toBe('erledigt')
|
||||||
|
expect(ampel.variante).toBe('secondary')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('liefert zu jeder Stufe einen Text — Farbe allein ist keine Information', () => {
|
||||||
|
const tage = [-30, -1, 0, 1, 5, 7, 8, 20, 31, 32, 400]
|
||||||
|
for (const versatz of tage) {
|
||||||
|
const ampel = fristAmpel(plusTage(heute, versatz), 'OFFEN', heute)
|
||||||
|
expect(ampel.text.length).toBeGreaterThan(3)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('Tagesdatum und Feiertage', () => {
|
||||||
|
it('rechnet Monatsschritte kalendarisch, nicht in 30 Tagen', () => {
|
||||||
|
expect(alsIsoTag(plusMonate(tag(2026, 1, 31), 1))).toBe('2026-02-28')
|
||||||
|
expect(alsIsoTag(plusMonate(tag(2024, 1, 31), 1))).toBe('2024-02-29')
|
||||||
|
expect(alsIsoTag(plusMonate(tag(2026, 3, 15), 6))).toBe('2026-09-15')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('liest und schreibt ISO-Tagesdaten verlustfrei', () => {
|
||||||
|
expect(alsIsoTag(ausIsoTag('2026-09-14')!)).toBe('2026-09-14')
|
||||||
|
expect(ausIsoTag('2026-02-31')).toBeNull()
|
||||||
|
expect(ausIsoTag('14.09.2026')).toBeNull()
|
||||||
|
expect(ausIsoTag('')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('kennt die beweglichen bayerischen Feiertage', () => {
|
||||||
|
// Ostersonntag 2026 ist der 05.04.
|
||||||
|
expect(feiertagName(tag(2026, 4, 3))).toBe('Karfreitag')
|
||||||
|
expect(feiertagName(tag(2026, 4, 6))).toBe('Ostermontag')
|
||||||
|
expect(feiertagName(tag(2026, 5, 14))).toBe('Christi Himmelfahrt')
|
||||||
|
expect(feiertagName(tag(2026, 6, 4))).toBe('Fronleichnam')
|
||||||
|
expect(feiertagName(tag(2026, 1, 6))).toBe('Heilige Drei Könige')
|
||||||
|
expect(feiertagName(tag(2026, 7, 7))).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('warnt vor arbeitsfreien Stichtagen, ohne das Datum zu ändern', () => {
|
||||||
|
const hinweis = nichtWerktagHinweis(tag(2026, 4, 3))
|
||||||
|
expect(hinweis).toContain('Karfreitag')
|
||||||
|
expect(hinweis).toContain('§ 193 BGB')
|
||||||
|
expect(nichtWerktagHinweis(tag(2026, 7, 7))).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('ICS-Kalender', () => {
|
||||||
|
const frist = {
|
||||||
|
id: 'abc123',
|
||||||
|
titel: 'Jahresbericht 2026 an das Betreuungsgericht',
|
||||||
|
beschreibung: 'Bericht über das Betreuungsjahr; mit Vermögensübersicht, Belegen.',
|
||||||
|
datum: tag(2026, 3, 14),
|
||||||
|
rechtsgrundlage: '§ 1863 Abs. 3 BGB',
|
||||||
|
typ: 'GESETZLICH',
|
||||||
|
ausschlussfrist: false,
|
||||||
|
vorwarnungTage: [60, 30, 7, 1],
|
||||||
|
}
|
||||||
|
|
||||||
|
const kalender = () =>
|
||||||
|
icsErzeugen({
|
||||||
|
kalendername: 'Betreuung EM — Fristen',
|
||||||
|
fristen: [frist],
|
||||||
|
jetzt: new Date(Date.UTC(2026, 1, 1, 10, 15, 0)),
|
||||||
|
})
|
||||||
|
|
||||||
|
it('faltet keine Zeile über 75 Oktette hinaus', () => {
|
||||||
|
const lang =
|
||||||
|
'SUMMARY:Vermögensübersicht 2027 einreichen — Kontostände, Rückkaufswerte, ' +
|
||||||
|
'Verbindlichkeiten, jeweils mit Nachweis für das Betreuungsgericht'
|
||||||
|
const gefaltet = zeileFalten(lang)
|
||||||
|
|
||||||
|
for (const zeile of gefaltet.split('\r\n')) {
|
||||||
|
expect(Buffer.byteLength(zeile, 'utf8')).toBeLessThanOrEqual(75)
|
||||||
|
}
|
||||||
|
// Jede Folgezeile beginnt mit genau einem Leerzeichen.
|
||||||
|
for (const zeile of gefaltet.split('\r\n').slice(1)) {
|
||||||
|
expect(zeile.startsWith(' ')).toBe(true)
|
||||||
|
}
|
||||||
|
// Entfaltet muss wieder das Original herauskommen.
|
||||||
|
expect(gefaltet.split('\r\n ').join('')).toBe(lang)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('zerschneidet keine Umlaute beim Falten', () => {
|
||||||
|
const zeile = `DESCRIPTION:${'ä'.repeat(120)}`
|
||||||
|
const gefaltet = zeileFalten(zeile)
|
||||||
|
expect(gefaltet).not.toContain('�')
|
||||||
|
expect(gefaltet.split('\r\n ').join('')).toBe(zeile)
|
||||||
|
for (const teil of gefaltet.split('\r\n')) {
|
||||||
|
expect(Buffer.byteLength(teil, 'utf8')).toBeLessThanOrEqual(75)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lässt kurze Zeilen unangetastet', () => {
|
||||||
|
expect(zeileFalten('VERSION:2.0')).toBe('VERSION:2.0')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('maskiert Sonderzeichen nach RFC 5545', () => {
|
||||||
|
expect(textEscapen('a;b,c\\d')).toBe('a\\;b\\,c\\\\d')
|
||||||
|
expect(textEscapen('Zeile 1\nZeile 2')).toBe('Zeile 1\\nZeile 2')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('baut ein gültiges Gerüst mit CRLF-Zeilenenden', () => {
|
||||||
|
const ics = kalender()
|
||||||
|
expect(ics.startsWith('BEGIN:VCALENDAR\r\n')).toBe(true)
|
||||||
|
expect(ics.endsWith('END:VCALENDAR\r\n')).toBe(true)
|
||||||
|
expect(ics).toContain('VERSION:2.0')
|
||||||
|
// Kein nacktes Zeilenende ohne Wagenruecklauf.
|
||||||
|
expect(/[^\r]\n/.test(ics)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('schreibt ganztägige Termine mit Ende am Folgetag', () => {
|
||||||
|
const ics = kalender()
|
||||||
|
expect(ics).toContain('DTSTART;VALUE=DATE:20260314')
|
||||||
|
expect(ics).toContain('DTEND;VALUE=DATE:20260315')
|
||||||
|
expect(alsIcsDatum(tag(2026, 3, 14))).toBe('20260314')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('macht aus jeder Vorwarnstufe einen Wecker', () => {
|
||||||
|
const ics = kalender()
|
||||||
|
for (const tage of [60, 30, 7, 1]) {
|
||||||
|
expect(ics).toContain(`TRIGGER;RELATED=START:-P${tage}D`)
|
||||||
|
}
|
||||||
|
expect(ics).not.toContain('-P0D')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('markiert Ausschlussfristen schon im Titel', () => {
|
||||||
|
const ics = icsErzeugen({
|
||||||
|
kalendername: 'Test',
|
||||||
|
fristen: [{ ...frist, ausschlussfrist: true }],
|
||||||
|
jetzt: new Date(Date.UTC(2026, 1, 1)),
|
||||||
|
})
|
||||||
|
expect(ics).toContain('Ausschlussfrist!')
|
||||||
|
expect(ics).toContain('PRIORITY:1')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('Signatur des Kalender-Abonnements', () => {
|
||||||
|
it('lässt sich erzeugen und wieder auflösen', () => {
|
||||||
|
const signatur = signaturErzeugen('betreute-1', 'user-1')
|
||||||
|
expect(signaturPruefen(signatur)).toEqual({ betreuteId: 'betreute-1', userId: 'user-1' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('enthält die Kennungen nicht im Klartext in der Adresse', () => {
|
||||||
|
expect(signaturErzeugen('betreute-1', 'user-1')).not.toContain('betreute-1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('weist eine veränderte Signatur ab', () => {
|
||||||
|
const echt = signaturErzeugen('betreute-1', 'user-1')
|
||||||
|
const gefaelscht = Buffer.from('betreute-2.user-1.irgendwas', 'utf8').toString('base64url')
|
||||||
|
|
||||||
|
expect(signaturPruefen(gefaelscht)).toBeNull()
|
||||||
|
expect(signaturPruefen(`${echt}xyz`)).toBeNull()
|
||||||
|
expect(signaturPruefen('kein-base64url-inhalt')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('gilt nur für genau dieses Paar aus Person und Benutzer', () => {
|
||||||
|
const fuerA = signaturErzeugen('betreute-1', 'user-1')
|
||||||
|
const fuerB = signaturErzeugen('betreute-1', 'user-2')
|
||||||
|
expect(fuerA).not.toBe(fuerB)
|
||||||
|
expect(signaturPruefen(fuerB)!.userId).toBe('user-2')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('verträgt die Endung .ics an der Adresse', () => {
|
||||||
|
const signatur = signaturErzeugen('betreute-1', 'user-1')
|
||||||
|
expect(signaturPruefen(`${signatur}.ics`)).toEqual({
|
||||||
|
betreuteId: 'betreute-1',
|
||||||
|
userId: 'user-1',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user