feat: Stammdaten, Ersteinrichtung, Zweitfaktor und Protokollansicht
Damit ist Etappe 1 fachlich vollstaendig. Ersteinrichtung in drei Schritten, bewusst ausserhalb des Anwendungsrahmens: der setzt Benutzer, zweiten Faktor UND eine betreute Person voraus - beim allerersten Start gibt es nichts davon. Die Aktion fuer den ersten Zugang sperrt sich selbst, sobald ein Benutzer existiert. Zweiter Faktor: Das Geheimnis wird zuerst nur abgelegt, ohne totpAktiviertAm. Erst ein echter Code aus der App schaltet ihn scharf. Wer sich beim Abtippen vertut oder die falsche App nimmt, wuerde sich sonst dauerhaft aussperren. Erst danach gibt es die Wiederherstellungscodes - einmal angezeigt, danach nur noch als Hash. Betreuungsjahr (src/lib/stammdaten/betreuungsjahr.ts): laeuft ab dem Tag der Bestellung, nicht ab dem Kalenderjahr. Schrittweise vorwaerts gerechnet statt ueber eine Differenz, damit Schaltjahre und der 29. Februar von selbst stimmen. Daran haengen Jahresbericht, Rechnungslegung und die Ausschlussfrist der Aufwandspauschale. Aendert sich das Bestellungsdatum, werden die Pflichtfristen neu abgeleitet - ueber einen dynamischen Import, damit die Stammdaten auch dann speicherbar bleiben, wenn das Fristenmodul fehlt. Protokollansicht zeigt je Eintrag die geaenderten Felder als alt -> neu. Die Aenderungen liegen verschluesselt, weil dort Diagnosen und Kontostaende stehen koennen. Seed legt bewusst keine echten Personendaten an, nur den ersten Zugang. Mit SEED_DEMO=true kommt ein vollstaendiger Beispielfall dazu - Erika Musterfrau, COPD GOLD III, Pflegegrad 3, Heimeinzug Januar 2026, leerstehendes Haus, zwei Versicherungen, laufender Antrag beim Bezirk mit acht Nachforderungen. Nur ausserhalb der Produktion. Docker Compose ohne Reverse Proxy - der ist auf dem Zielserver vorhanden. Die Datenbank hat keine Portfreigabe und ist nur ueber das interne Netz erreichbar; die Anwendung horcht auf 127.0.0.1. Etappe: 1 Phase: Fundament
This commit is contained in:
@@ -0,0 +1,207 @@
|
||||
import { alsIsoTag, heuteAlsTag, plusJahre, plusMonate, plusTage, tageZwischen, tagesdatum } from '@/lib/fristen/datum'
|
||||
|
||||
/**
|
||||
* Kuendigungsfrist-Rechner.
|
||||
*
|
||||
* Die eigentliche Falle bei Versicherungsvertraegen ist nicht der
|
||||
* Kuendigungstermin, sondern der Tag, an dem der Brief spaetestens raus
|
||||
* muss. Eine Kuendigung wird mit dem ZUGANG beim Versicherer wirksam, nicht
|
||||
* mit dem Absenden - wer am letzten Tag der Frist einwirft, hat die Frist
|
||||
* verpasst und zahlt ein weiteres Jahr.
|
||||
*
|
||||
* Deshalb rechnet diese Datei drei Daten aus:
|
||||
*
|
||||
* 1. den naechstmoeglichen Kuendigungstermin (zu wann kann gekuendigt werden),
|
||||
* 2. den Tag, an dem die Kuendigung spaetestens zugehen muss,
|
||||
* 3. den spaetesten Absendetag mit Postlaufpuffer.
|
||||
*
|
||||
* Reine Logik, keine Datenbank.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Puffer zwischen Absenden und Zugang. Drei Kalendertage sind knapp, aber
|
||||
* bei einem Einwurf-Einschreiben realistisch. Wer es genauer braucht,
|
||||
* schickt zusaetzlich ein Fax oder eine E-Mail - beides zaehlt fuer den
|
||||
* Zugang und ist am selben Tag da.
|
||||
*/
|
||||
export const POSTLAUF_PUFFER_TAGE = 3
|
||||
|
||||
/** Ab wann wird gewarnt, dass der Absendetag naeher rueckt. */
|
||||
export const KUENDIGUNG_VORWARNUNG_TAGE = 30
|
||||
|
||||
/**
|
||||
* Verlaengerung, wenn im Vertrag keine Laufzeit hinterlegt ist. Ein Jahr
|
||||
* ist bei Versicherungen der Regelfall (§ 11 Abs. 1 VVG).
|
||||
*/
|
||||
export const STANDARD_LAUFZEIT_JAHRE = 1
|
||||
|
||||
export type KuendigungEingabe = {
|
||||
/** Laufzeitbeginn laut Vertrag. */
|
||||
beginnAm: Date | null
|
||||
/** Fest vereinbartes Vertragsende, falls bekannt. */
|
||||
endeAm?: Date | null
|
||||
/** Vertragsdauer in Jahren; fehlt sie, wird mit einem Jahr gerechnet. */
|
||||
laufzeitJahre?: number | null
|
||||
/** Kuendigungsfrist in Monaten zum Ende der Laufzeit. */
|
||||
kuendigungsfristMonate: number | null
|
||||
/** Verlaengert sich der Vertrag stillschweigend? Regelfall: ja. */
|
||||
verlaengertSichAutomatisch?: boolean
|
||||
/** Bezugstag; im Test gesetzt, sonst heute. */
|
||||
heute?: Date
|
||||
}
|
||||
|
||||
export type Kuendigungsplan = {
|
||||
/** Laesst sich ueberhaupt etwas berechnen? */
|
||||
berechenbar: boolean
|
||||
/** Zu diesem Tag kann als Naechstes gekuendigt werden. */
|
||||
naechsterTermin: Date | null
|
||||
/** Bis zu diesem Tag muss die Kuendigung beim Vertragspartner sein. */
|
||||
zugangSpaetestensAm: Date | null
|
||||
/** Bis zu diesem Tag muss der Brief raus (Zugang minus Postlauf). */
|
||||
spaetesterAbsendetag: Date | null
|
||||
/** Tage von heute bis zum Absendetag. Negativ heisst: schon vorbei. */
|
||||
tageBisAbsendetag: number | null
|
||||
/** Weniger als 30 Tage bis zum Absendetag - jetzt handeln. */
|
||||
vorwarnung: boolean
|
||||
/** Der Absendetag ist verstrichen und der Vertrag laeuft aus. */
|
||||
verpasst: boolean
|
||||
/** Erklaerungen und Annahmen, die in die Rechnung eingegangen sind. */
|
||||
hinweise: string[]
|
||||
}
|
||||
|
||||
const LEERER_PLAN: Kuendigungsplan = {
|
||||
berechenbar: false,
|
||||
naechsterTermin: null,
|
||||
zugangSpaetestensAm: null,
|
||||
spaetesterAbsendetag: null,
|
||||
tageBisAbsendetag: null,
|
||||
vorwarnung: false,
|
||||
verpasst: false,
|
||||
hinweise: [],
|
||||
}
|
||||
|
||||
/**
|
||||
* Berechnet den naechstmoeglichen Kuendigungstermin.
|
||||
*
|
||||
* Vorgehen: Vom ersten Ablauftermin an wird geprueft, ob der zugehoerige
|
||||
* Absendetag noch in der Zukunft liegt. Ist er verstrichen, wird um eine
|
||||
* Laufzeit weitergerueckt. Der erste Termin, dessen Brief noch rechtzeitig
|
||||
* rausgeht, ist der naechstmoegliche.
|
||||
*/
|
||||
export function kuendigungsplan(eingabe: KuendigungEingabe): Kuendigungsplan {
|
||||
const heute = tagesdatum(eingabe.heute ?? heuteAlsTag())
|
||||
const hinweise: string[] = []
|
||||
|
||||
const frist = eingabe.kuendigungsfristMonate
|
||||
if (frist === null || frist === undefined) {
|
||||
hinweise.push(
|
||||
'Es ist keine Kündigungsfrist hinterlegt. Es wird ohne Frist gerechnet — ' +
|
||||
'bitte im Vertrag nachsehen und nachtragen.',
|
||||
)
|
||||
}
|
||||
const fristMonate = frist ?? 0
|
||||
|
||||
const laufzeit =
|
||||
eingabe.laufzeitJahre && eingabe.laufzeitJahre > 0
|
||||
? eingabe.laufzeitJahre
|
||||
: STANDARD_LAUFZEIT_JAHRE
|
||||
if (!eingabe.laufzeitJahre || eingabe.laufzeitJahre <= 0) {
|
||||
hinweise.push(
|
||||
`Im Vertrag steht keine Laufzeit. Gerechnet wird mit einer Verlängerung um ` +
|
||||
`${STANDARD_LAUFZEIT_JAHRE} Jahr — bei Versicherungen der Regelfall.`,
|
||||
)
|
||||
}
|
||||
|
||||
const verlaengert = eingabe.verlaengertSichAutomatisch ?? true
|
||||
|
||||
// Erster Ablauftermin: das vereinbarte Ende, sonst Beginn plus Laufzeit.
|
||||
let termin: Date | null = null
|
||||
if (eingabe.endeAm) {
|
||||
termin = tagesdatum(eingabe.endeAm)
|
||||
} else if (eingabe.beginnAm) {
|
||||
termin = plusJahre(tagesdatum(eingabe.beginnAm), laufzeit)
|
||||
}
|
||||
|
||||
if (!termin) {
|
||||
return {
|
||||
...LEERER_PLAN,
|
||||
hinweise: [
|
||||
'Ohne Laufzeitbeginn oder Vertragsende lässt sich kein Kündigungstermin ' +
|
||||
'berechnen. Bitte mindestens den Vertragsbeginn eintragen.',
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
// Bis zu 200 Verlaengerungen durchgehen - deckt auch einen Vertrag von
|
||||
// 1962 ab, ohne bei fehlerhaften Daten endlos zu laufen.
|
||||
let absendetag = absendetagZu(termin, fristMonate)
|
||||
let durchlaeufe = 0
|
||||
while (absendetag.getTime() < heute.getTime() && verlaengert && durchlaeufe < 200) {
|
||||
termin = plusJahre(termin, laufzeit)
|
||||
absendetag = absendetagZu(termin, fristMonate)
|
||||
durchlaeufe += 1
|
||||
}
|
||||
|
||||
const zugang = plusMonate(termin, -fristMonate)
|
||||
const tageBis = tageZwischen(heute, absendetag)
|
||||
const verpasst = absendetag.getTime() < heute.getTime()
|
||||
|
||||
if (verpasst) {
|
||||
hinweise.push(
|
||||
`Der Absendetag für den Termin ${alsIsoTag(termin)} ist verstrichen. ` +
|
||||
`Dieser Vertrag verlängert sich nicht automatisch — eine Kündigung zu diesem ` +
|
||||
`Termin ist nicht mehr möglich.`,
|
||||
)
|
||||
} else if (durchlaeufe > 0) {
|
||||
hinweise.push(
|
||||
`Der Vertrag hat sich bereits ${durchlaeufe}-mal verlängert. Angezeigt ist der ` +
|
||||
`nächste Termin, zu dem noch rechtzeitig gekündigt werden kann.`,
|
||||
)
|
||||
}
|
||||
|
||||
hinweise.push(
|
||||
`Eine Kündigung wird mit dem Zugang wirksam, nicht mit dem Absenden. Deshalb ` +
|
||||
`sind zwischen Absendetag und Zugang ${POSTLAUF_PUFFER_TAGE} Tage Postlauf ` +
|
||||
`eingerechnet. Am sichersten: Einschreiben mit Rückschein oder zusätzlich ` +
|
||||
`ein Fax mit Sendeprotokoll.`,
|
||||
)
|
||||
|
||||
return {
|
||||
berechenbar: true,
|
||||
naechsterTermin: termin,
|
||||
zugangSpaetestensAm: zugang,
|
||||
spaetesterAbsendetag: absendetag,
|
||||
tageBisAbsendetag: tageBis,
|
||||
vorwarnung: !verpasst && tageBis <= KUENDIGUNG_VORWARNUNG_TAGE,
|
||||
verpasst,
|
||||
hinweise,
|
||||
}
|
||||
}
|
||||
|
||||
/** Der Absendetag zu einem Termin: Termin minus Frist minus Postlauf. */
|
||||
export function absendetagZu(termin: Date, fristMonate: number): Date {
|
||||
return plusTage(plusMonate(tagesdatum(termin), -fristMonate), -POSTLAUF_PUFFER_TAGE)
|
||||
}
|
||||
|
||||
/**
|
||||
* Kurzfassung fuer die Uebersichtstabelle: eine Ampelstufe je Vertrag.
|
||||
* Farbe ist nie die einzige Information - der Text steht daneben.
|
||||
*/
|
||||
export type Kuendigungsampel = {
|
||||
variante: 'rot' | 'orange' | 'gelb' | 'gruen'
|
||||
text: string
|
||||
}
|
||||
|
||||
export function kuendigungsampel(plan: Kuendigungsplan): Kuendigungsampel | null {
|
||||
if (!plan.berechenbar || plan.tageBisAbsendetag === null) return null
|
||||
if (plan.verpasst) {
|
||||
return { variante: 'rot', text: 'Absendetag verstrichen' }
|
||||
}
|
||||
const tage = plan.tageBisAbsendetag
|
||||
if (tage <= 7) return { variante: 'rot', text: `Brief muss in ${tage} Tagen raus` }
|
||||
if (tage <= KUENDIGUNG_VORWARNUNG_TAGE) {
|
||||
return { variante: 'orange', text: `Brief muss in ${tage} Tagen raus` }
|
||||
}
|
||||
if (tage <= 60) return { variante: 'gelb', text: `Absendetag in ${tage} Tagen` }
|
||||
return { variante: 'gruen', text: `Absendetag in ${tage} Tagen` }
|
||||
}
|
||||
Reference in New Issue
Block a user