Fundament: Element-Stammdaten, Fixpunkte, Horizont in Jahren, PK-Bezugsalter

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-26 22:06:04 +02:00
parent cc61a120ee
commit 2f762175d2
17 changed files with 515 additions and 146 deletions
+88 -14
View File
@@ -11,40 +11,114 @@
export type SegmentType = "ERWERB" | "MIXED" | "PENSION";
// Ein Zeitpunkt, an dem ZWINGEND eine neue Lebensphase beginnen muss.
//
// Bis 0.35 gab es genau einen pro Person: das Erwerbsende. Seit 0.36 sind es bis zu vier --
// jeder Beginn eines Renten- oder Kapitalbezugs ist einer. Der Grund ist derselbe wie beim
// Erwerbsende: Die Rechnung leitet Erwerbsstatus und Bezuege am PHASENBEGINN ab. Faellt ein
// Bezug mitten in eine Phase, waere der halbe Phasenertrag falsch.
export type FixpointKind = "RETIREMENT" | "AHV" | "PENSION_FUND" | "PILLAR_3A";
export interface Fixpoint {
// Jahre ab Planbeginn (1-basiert wie die Phasengrenzen: Jahr 5 = Ende des 5. Planjahres).
year: number;
age: number;
role: string;
kind: FixpointKind;
label: string;
}
export interface PlanSegment {
type: SegmentType;
fromYear: number; // Jahre ab Planbeginn (Beginn des Abschnitts)
// Feste Länge in Jahren (durch den nächsten Pensionierungspunkt bestimmt) ODER null für den
// letzten, OFFENEN Abschnitt (Pension bis Lebensende -- Dauer frei wählbar).
// Feste Länge in Jahren (durch den nächsten Fixpunkt bestimmt) ODER null für den letzten,
// OFFENEN Abschnitt (Dauer frei wählbar bzw. durch den Planungshorizont bestimmt).
fixedYears: number | null;
// Welche Ereignisse den Abschnitt beenden -- für die Beschriftung der Zeitachse.
endedBy: Fixpoint[];
}
// Ein Person-Erwerbsstatus wechselt genau einmal: im Jahr `retirementAge - age` (auf 0 gekappt,
// falls bei Planbeginn bereits pensioniert). Die distinkten dieser Zeitpunkte sind die
// Abschnittsgrenzen; der Abschnitt nach dem letzten Zeitpunkt ist offen.
export function planSegments(persons: { age: number; retirementAge: number }[]): PlanSegment[] {
if (persons.length === 0) return [{ type: "PENSION", fromYear: 0, fixedYears: null }];
export interface FixpointPerson {
// Optional, damit Aufrufer, die nur Abschnitte brauchen (Zeitachse), nichts erfinden muessen.
role?: string;
name?: string | null;
age: number;
retirementAge: number;
// Bezugsalter aus dem Pensionierungs-Entscheid. Fehlen sie, gilt das Erwerbsende.
ahvStartAge?: number;
pkWithdrawalAge?: number;
pillar3aAges?: number[];
}
const KIND_LABEL: Record<FixpointKind, string> = {
RETIREMENT: "Erwerbsende",
AHV: "AHV-Rente",
PENSION_FUND: "Pensionskasse",
PILLAR_3A: "Säule 3a",
};
// Alle Fixpunkte, sortiert und ohne Duplikate im selben Jahr (mehrere Ereignisse im gleichen
// Jahr brauchen nur EINE Phasengrenze -- sie wird dann mehrfach beschriftet).
export function planFixpoints(persons: FixpointPerson[]): Fixpoint[] {
const out: Fixpoint[] = [];
const add = (person: FixpointPerson, age: number | undefined, kind: FixpointKind) => {
if (typeof age !== "number") return;
const year = Math.round(age - person.age);
// Ereignisse vor oder bei Planbeginn sind keine Grenze -- sie sind bereits Geschichte.
if (year <= 0) return;
out.push({
year,
age: Math.round(age),
role: person.role ?? "PERSON_A",
kind,
label: `${KIND_LABEL[kind]}${person.name ? " " + person.name : ""} (${Math.round(age)})`,
});
};
for (const p of persons) {
add(p, p.retirementAge, "RETIREMENT");
add(p, p.ahvStartAge, "AHV");
add(p, p.pkWithdrawalAge, "PENSION_FUND");
for (const a of p.pillar3aAges ?? []) add(p, a, "PILLAR_3A");
}
return out.sort((a, b) => a.year - b.year || a.kind.localeCompare(b.kind));
}
// Die distinkten Fixpunkt-Jahre sind die Abschnittsgrenzen; der Abschnitt nach dem letzten
// ist offen.
export function planSegments(persons: FixpointPerson[]): PlanSegment[] {
if (persons.length === 0) return [{ type: "PENSION", fromYear: 0, fixedYears: null, endedBy: [] }];
const fixpoints = planFixpoints(persons);
const retYears = persons.map((p) => Math.max(0, p.retirementAge - p.age));
const events = [...new Set(retYears.filter((r) => r > 0))].sort((a, b) => a - b);
const boundaries = [0, ...events];
const years = [...new Set(fixpoints.map((f) => f.year))].sort((a, b) => a - b);
const boundaries = [0, ...years];
const segments: PlanSegment[] = [];
for (let i = 0; i < boundaries.length; i++) {
const from = boundaries[i];
const next = i + 1 < boundaries.length ? boundaries[i + 1] : null;
// Eine Person arbeitet in einem Abschnitt, der bei Jahr `from` beginnt, genau dann, wenn
// ihr Pensionierungszeitpunkt echt später liegt.
// ihr Erwerbsende echt später liegt.
const working = retYears.filter((r) => r > from).length;
const type: SegmentType = working === persons.length ? "ERWERB" : working > 0 ? "MIXED" : "PENSION";
segments.push({ type, fromYear: from, fixedYears: next === null ? null : next - from });
segments.push({
type,
fromYear: from,
fixedYears: next === null ? null : next - from,
endedBy: next === null ? [] : fixpoints.filter((f) => f.year === next),
});
}
return segments;
}
// Vorgeschlagene Dauer des offenen Pensions-Abschnitts: bis das älteste Haushaltsmitglied
// ungefähr 90 ist, mindestens aber 5 Jahre.
export function defaultOpenDuration(persons: { age: number }[], fromYear: number): number {
// Vorgeschlagene Dauer des offenen Abschnitts. Mit erfasstem Planungshorizont ergibt sie sich
// daraus; ohne ihn bis etwa Alter 90, mindestens aber 5 Jahre.
export function defaultOpenDuration(
persons: { age: number }[],
fromYear: number,
horizonYears?: number | null
): number {
if (typeof horizonYears === "number" && horizonYears > fromYear) return horizonYears - fromYear;
const oldestNow = persons.length > 0 ? Math.max(...persons.map((p) => p.age)) : 65;
return Math.max(5, 90 - (oldestNow + fromYear));
}