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
@@ -0,0 +1,19 @@
-- Umbau 0.36: Bestandsaufnahme vor der Zeitachse, Assistent als Herzstueck.
--
-- (1) `FinancialElement.baseData` haelt den Bestand bei PLANBEGINN und die Ausgangs-Annahmen.
-- Bisher lagen diese Werte in `ElementPhaseValue` der ersten Phase. Das war schon immer
-- schief -- ein Startwert ist nicht "phase-1-spezifisch", sondern der Stand am Anfang --
-- und es machte den ersten Schritt des Assistenten unmoeglich: Eine Bestandsaufnahme
-- braucht noch keine Lebensphasen. Zugleich ist es die Wurzel der Feld-Vererbung.
--
-- (2) `Scenario.planningHorizonYears` fuehrt den Horizont in JAHREN statt als Endalter je
-- Person. Eine Zahl statt zweier, die bei einem Paar auseinanderlaufen koennten; die
-- Endalter werden abgeleitet. Ersetzt `Person.planningHorizonAge` aus 0.35.
--
-- (3) `Scenario.assistantProgress` haelt die sieben Haken des FPT-Assistenten.
--
-- Bewusst OHNE Datenmigration (Plaene wurden vorgaengig geloescht).
ALTER TABLE "FinancialElement" ADD COLUMN "baseData" JSONB;
ALTER TABLE "Scenario" ADD COLUMN "planningHorizonYears" INTEGER;
ALTER TABLE "Scenario" ADD COLUMN "assistantProgress" JSONB;
ALTER TABLE "Person" DROP COLUMN IF EXISTS "planningHorizonAge";
+18 -4
View File
@@ -76,11 +76,9 @@ model Person {
scenarioId String scenarioId String
scenario Scenario @relation(fields: [scenarioId], references: [id], onDelete: Cascade) scenario Scenario @relation(fields: [scenarioId], references: [id], onDelete: Cascade)
role PersonRole role PersonRole
// Alter, in dem die Person die Erwerbstaetigkeit aufgibt. Wird bei der Plan-Anlage nicht
// mehr abgefragt (Default 65) -- die Pensionsplanung im Assistenten legt es fest.
retirementAge Int retirementAge Int
// Bis zu welchem Alter gerechnet wird. Bisher ergab sich das Planende stillschweigend aus
// der Summe der Phasendauern -- zwei Szenarien konnten dadurch unbemerkt verschieden weit
// rechnen und waren nicht vergleichbar.
planningHorizonAge Int?
@@unique([scenarioId, role]) @@unique([scenarioId, role])
} }
@@ -224,6 +222,14 @@ model Scenario {
inflationRateDefault Float inflationRateDefault Float
initialCash Float @default(0) initialCash Float @default(0)
// Wie viele Jahre die Planung umfasst. Bewusst in JAHREN und nicht als Endalter je Person:
// eine Zahl statt zweier, die bei einem Paar auseinanderlaufen koennten. Die Endalter
// werden daraus abgeleitet. NULL = wie bisher aus der Summe der Phasendauern.
planningHorizonYears Int?
// Fortschritt des FPT-Assistenten (sieben Schritte, vom Benutzer abgehakt).
assistantProgress Json?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -309,6 +315,14 @@ model FinancialElement {
// er jede Verschiebung der Zeitachse. Siehe src/lib/retirement-decision.ts. // er jede Verschiebung der Zeitachse. Siehe src/lib/retirement-decision.ts.
retirementDecision Json? retirementDecision Json?
// Stammdaten des Elements: der Bestand bei PLANBEGINN und die Ausgangs-Annahmen (Rendite,
// Wertsteigerung, Zins). Zwei Gruende dafuer, dass das nicht in Phase 1 liegt:
// 1. Ein Startwert ist nicht "phase-1-spezifisch", sondern schlicht der Stand am Anfang.
// 2. Elemente lassen sich damit erfassen, BEVOR es Lebensphasen gibt -- der erste Schritt
// des Assistenten ist eine Bestandsaufnahme und braucht noch keine Zeitachse.
// Zugleich die Wurzel der Feld-Vererbung: Phase 1 erbt von hier.
baseData Json?
phaseValues ElementPhaseValue[] phaseValues ElementPhaseValue[]
transitionValues ElementTransitionValue[] transitionValues ElementTransitionValue[]
} }
@@ -33,12 +33,10 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
// kopiert. Szenario-eigen sind nur Inflation, Cash-Anfangswert und Pensionsalter. // kopiert. Szenario-eigen sind nur Inflation, Cash-Anfangswert und Pensionsalter.
inflationRateDefault: source.inflationRateDefault, inflationRateDefault: source.inflationRateDefault,
initialCash: source.initialCash, initialCash: source.initialCash,
planningHorizonYears: source.planningHorizonYears,
assistantProgress: source.assistantProgress ?? undefined,
persons: { persons: {
create: source.persons.map((p) => ({ create: source.persons.map((p) => ({ role: p.role, retirementAge: p.retirementAge })),
role: p.role,
retirementAge: p.retirementAge,
planningHorizonAge: p.planningHorizonAge,
})),
}, },
}, },
}); });
@@ -71,6 +69,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
// Ohne das waere die Kopie eines Szenarios genau fuer den Zweck unbrauchbar, fuer // Ohne das waere die Kopie eines Szenarios genau fuer den Zweck unbrauchbar, fuer
// den man sie am haeufigsten anlegt: ein anderes Pensionierungs-Szenario. // den man sie am haeufigsten anlegt: ein anderes Pensionierungs-Szenario.
retirementDecision: el.retirementDecision ?? undefined, retirementDecision: el.retirementDecision ?? undefined,
baseData: el.baseData ?? undefined,
sourceElementId: el.id, sourceElementId: el.id,
}, },
}); });
@@ -5,18 +5,17 @@ import { getOwnedScenario, toPlanInput } from "@/lib/queries";
import { getCurrentUserId } from "@/lib/session"; import { getCurrentUserId } from "@/lib/session";
import { touchScenario } from "@/lib/versioning-db"; import { touchScenario } from "@/lib/versioning-db";
import { planHorizonChange } from "@/lib/retirement"; import { planHorizonChange } from "@/lib/retirement";
import { MAX_PLANNING_HORIZON_AGE, MIN_PLANNING_HORIZON_AGE } from "@/lib/constants"; import { MAX_PLANNING_HORIZON_YEARS, MIN_PLANNING_HORIZON_YEARS } from "@/lib/constants";
// Planungshorizont setzen: bis zu welchem Alter gerechnet wird. // Planungshorizont setzen: wie viele JAHRE die Planung umfasst.
// //
// Wie beim Pensionsalter gilt: Zahl stellen, Struktur folgt. Die LETZTE Lebensphase wird so // Wie beim Pensionsalter gilt: Zahl stellen, Struktur folgt. Die LETZTE Lebensphase wird so
// verlängert oder gekürzt, dass der Plan genau bis zum Horizont läuft. Vorher ergab sich das // verlängert oder gekürzt, dass der Plan genau bis zum Horizont läuft. Gibt es noch keine
// Planende stillschweigend aus der Summe der Phasendauern -- zwei Szenarien konnten dadurch // Phasen, wird nur die Zahl gespeichert -- sie ist dann die Grundlage, auf der der Assistent
// unbemerkt verschieden weit rechnen und waren nicht vergleichbar. // die Zeitachse aufspannt.
const bodySchema = z.object({ const bodySchema = z.object({
role: z.enum(["PERSON_A", "PERSON_B"]), horizonYears: z.number().int().min(MIN_PLANNING_HORIZON_YEARS).max(MAX_PLANNING_HORIZON_YEARS),
horizonAge: z.number().int().min(MIN_PLANNING_HORIZON_AGE).max(MAX_PLANNING_HORIZON_AGE),
}); });
export async function POST(request: NextRequest, { params }: { params: Promise<{ scenarioId: string }> }) { export async function POST(request: NextRequest, { params }: { params: Promise<{ scenarioId: string }> }) {
@@ -29,27 +28,19 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
const parsed = bodySchema.safeParse(await request.json().catch(() => ({}))); const parsed = bodySchema.safeParse(await request.json().catch(() => ({})));
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 }); if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
const { role, horizonAge } = parsed.data; const { horizonYears } = parsed.data;
const planInput = toPlanInput(scenario); const planInput = toPlanInput(scenario);
const person = planInput.persons.find((p) => p.role === role); const change = planInput.phases.length > 0 ? planHorizonChange(planInput, horizonYears) : null;
if (!person) return NextResponse.json({ error: "Diese Person gibt es in diesem Szenario nicht." }, { status: 400 }); if (change?.blocked) return NextResponse.json({ error: change.blocked }, { status: 400 });
if (horizonAge <= person.retirementAge) {
return NextResponse.json(
{ error: "Der Planungshorizont muss nach der Pensionierung liegen." },
{ status: 400 }
);
}
const change = planHorizonChange(planInput, horizonAge, role);
if (!change) return NextResponse.json({ error: "Es gibt keine Lebensphase, die sich anpassen liesse." }, { status: 400 });
if (change.blocked) return NextResponse.json({ error: change.blocked }, { status: 400 });
await prisma.$transaction([ await prisma.$transaction([
prisma.person.update({ where: { id: person.id }, data: { planningHorizonAge: horizonAge } }), prisma.scenario.update({ where: { id: scenarioId }, data: { planningHorizonYears: horizonYears } }),
prisma.phase.update({ where: { id: change.lastPhaseId }, data: { durationYears: change.newDuration } }), ...(change
? [prisma.phase.update({ where: { id: change.lastPhaseId }, data: { durationYears: change.newDuration } })]
: []),
]); ]);
await touchScenario(scenario.id, userId); await touchScenario(scenario.id, userId);
return NextResponse.json({ ok: true, lastPhaseDuration: change.newDuration }); return NextResponse.json({ ok: true, lastPhaseDuration: change?.newDuration ?? null });
} }
+132
View File
@@ -0,0 +1,132 @@
// Der FPT-Assistent: sieben Schritte von der leeren Matrix zum fertigen Plan.
//
// Er ersetzt die frueheren "Naechsten Schritte". Der Unterschied ist nicht kosmetisch: Die
// alte Karte leitete AB, was zu tun waere; hier fuehrt jeder Schritt sein eigenes Werkzeug
// mit sich. Der Nutzer haelt den Fortschritt selbst fest (Haken) -- das Tool masst sich nicht
// an zu wissen, wann jemand mit einem Schritt fertig IST.
//
// Damit der Assistent trotzdem nichts Falsches behauptet, steht neben jedem Haken der
// ABGELEITETE Stand ("0 Lebensphasen"). Ein abgehakter Schritt ohne Substanz faellt so auf,
// ohne dass das Tool den Haken verweigert.
import { z } from "zod";
import type { PlanInput } from "@/lib/types";
export const ASSISTANT_STEP_COUNT = 7;
export type AssistantProgress = boolean[];
export const assistantProgressSchema = z.array(z.boolean()).length(ASSISTANT_STEP_COUNT);
export function emptyProgress(): AssistantProgress {
return Array.from({ length: ASSISTANT_STEP_COUNT }, () => false);
}
export function normalizeProgress(raw: unknown): AssistantProgress {
const parsed = assistantProgressSchema.safeParse(raw);
return parsed.success ? parsed.data : emptyProgress();
}
export interface AssistantStep {
index: number;
title: string;
short: string;
// Ein Satz, der sagt, worum es geht -- steht in der Liste unter dem Titel.
lead: string;
// Hat dieser Schritt ein Werkzeug, oder ist er reine Information?
guided: boolean;
}
export const ASSISTANT_STEPS: AssistantStep[] = [
{
index: 0,
title: "Bestandsaufnahme",
short: "Was du hast",
lead: "Alle Konten, Guthaben, Immobilien und Schulden erfassen mit ihrem heutigen Stand.",
guided: true,
},
{
index: 1,
title: "Eckdaten und Pensionsplanung",
short: "Wann was passiert",
lead: "Wie weit du planst und wann du aufhörst zu arbeiten und deine Renten beziehst.",
guided: true,
},
{
index: 2,
title: "Lebensphasen",
short: "Die Zeitachse",
lead: "Deine Planung in Abschnitte teilen. Die Fixpunkte aus Schritt 2 sind gesetzt.",
guided: true,
},
{
index: 3,
title: "Erwerbsjahre planen",
short: "Sparen bis zur Pension",
lead: "Wohin dein Sparbetrag fliesst und was an den Übergängen dazwischen passiert.",
guided: true,
},
{
index: 4,
title: "Pensionierung planen",
short: "Der Übergang",
lead: "Rente oder Kapital, wohin das Geld fliesst und wie es in den Jahren danach weitergeht.",
guided: true,
},
{
index: 5,
title: "Ruhestand planen",
short: "Die Jahre danach",
lead: "Wovon du lebst, wenn kein Lohn mehr kommt Bezüge aus dem Vermögen und die restlichen Übergänge.",
guided: true,
},
{
index: 6,
title: "Feinschliff",
short: "Was noch geht",
lead: "Was du jetzt noch verfeinern kannst und wo du es findest.",
guided: false,
},
];
// --- Abgeleiteter Stand je Schritt -----------------------------------------------------------
//
// Bewusst NICHT zum Erzwingen des Hakens, sondern als Gegenprobe daneben. Wer "Lebensphasen"
// abhakt, ohne eine anzulegen, sieht "0 Lebensphasen" -- das reicht.
export function stepStatus(plan: PlanInput, index: number): string {
const phases = plan.phases.length;
const elements = plan.elements.length;
switch (index) {
case 0:
return elements === 0
? "noch nichts erfasst"
: `${elements} ${elements === 1 ? "Element" : "Elemente"}`;
case 1: {
const geplant = plan.elements.filter(
(e) => e.category === "AHV" && e.retirementDecision?.confirmed === true
).length;
const horizon = plan.planningHorizonYears;
if (!horizon) return "Planungshorizont fehlt";
return geplant === plan.persons.length
? `${horizon} Jahre · Pensionsplanung steht`
: `${horizon} Jahre · Pensionsplanung offen`;
}
case 2:
return phases === 0 ? "0 Lebensphasen" : `${phases} ${phases === 1 ? "Lebensphase" : "Lebensphasen"}`;
default:
return "";
}
}
// Ein Schritt lässt sich erst sinnvoll öffnen, wenn seine Voraussetzung erfüllt ist. Der Haken
// bleibt trotzdem jederzeit setzbar -- gesperrt wird nur das Werkzeug, nicht die Selbstauskunft.
export function stepBlockedReason(plan: PlanInput, index: number): string | null {
const hasHorizon = typeof plan.planningHorizonYears === "number" && plan.planningHorizonYears > 0;
if (index === 2 && !hasHorizon) {
return "Lege zuerst den Planungshorizont und die Pensionsplanung fest daraus ergeben sich die Fixpunkte der Zeitachse.";
}
if (index >= 3 && plan.phases.length === 0) {
return "Lege zuerst deine Lebensphasen an ohne sie gibt es keine Übergänge zu entscheiden.";
}
return null;
}
+96 -41
View File
@@ -12,9 +12,12 @@ import {
DEFAULT_PROPERTY_GAINS_TAX_RATE, DEFAULT_PROPERTY_GAINS_TAX_RATE,
PILLAR_3A_MAX_WITHDRAWAL_AGE, PILLAR_3A_MAX_WITHDRAWAL_AGE,
PILLAR_3A_MIN_WITHDRAWAL_AGE, PILLAR_3A_MIN_WITHDRAWAL_AGE,
PK_MAX_RETIREMENT_AGE,
PK_MIN_RETIREMENT_AGE,
} from "@/lib/constants"; } from "@/lib/constants";
import { num } from "@/lib/elements"; import { num } from "@/lib/elements";
import { actualsForYear, rebaseFlow, type ResolvedActuals } from "@/lib/actuals"; import { actualsForYear, rebaseFlow, type ResolvedActuals } from "@/lib/actuals";
import { planFixpoints, type Fixpoint, type FixpointPerson } from "@/lib/phaseplan";
import { import {
ahvDrawLabel, ahvDrawLabel,
ahvFactor, ahvFactor,
@@ -23,7 +26,7 @@ import {
withRetirementDefaults, withRetirementDefaults,
type RetirementDecision, type RetirementDecision,
} from "@/lib/retirement-decision"; } from "@/lib/retirement-decision";
import type { ElementCategory } from "@/lib/elements"; import type { ElementCategory, PhaseData } from "@/lib/elements";
import type { ElementInput, PersonRole, PlanInput } from "@/lib/types"; import type { ElementInput, PersonRole, PlanInput } from "@/lib/types";
export type PhaseType = "ERWERB" | "PENSION" | "MIXED"; export type PhaseType = "ERWERB" | "PENSION" | "MIXED";
@@ -317,16 +320,49 @@ export function ahvYearsBeforePlan(ageAtPlanStart: number): number {
return Math.max(0, ageAtPlanStart - AHV_CONTRIBUTION_START_AGE); return Math.max(0, ageAtPlanStart - AHV_CONTRIBUTION_START_AGE);
} }
// Maximale Dauer einer neuen Phase bis zum nächsten Pensionsereignis (null = unbegrenzt). // Alle Fixpunkte eines Plans als Personen-Sicht für `phaseplan`. Bündelt an EINER Stelle,
// welche Ereignisse eine Phasengrenze erzwingen -- Erwerbsende plus die drei Bezugsbeginne.
export function fixpointPersonsOf(plan: PlanInput): FixpointPerson[] {
return plan.persons.map((p) => {
const own = (cat: ElementCategory) =>
plan.elements.filter((e) => e.category === cat && e.ownerRole === p.role);
const ahvEl = own("AHV")[0];
const pkEl = own("PENSION_FUND")[0];
const rdAhv = ahvEl ? withRetirementDefaults("AHV", p.retirementAge, ahvEl.retirementDecision) : null;
const rdPk = pkEl ? withRetirementDefaults("PENSION_FUND", p.retirementAge, pkEl.retirementDecision) : null;
return {
role: p.role,
name: p.name,
age: p.age,
retirementAge: p.retirementAge,
ahvStartAge: rdAhv ? Math.round(ahvStartAge(rdAhv)) : undefined,
pkWithdrawalAge: rdPk?.pkWithdrawalAge,
pillar3aAges: own("PILLAR_3A")
.map((e) => withRetirementDefaults("PILLAR_3A", p.retirementAge, e.retirementDecision).withdrawalAge)
.filter((x): x is number => typeof x === "number"),
};
});
}
// Maximale Dauer einer neuen Phase bis zum nächsten FIXPUNKT (null = unbegrenzt).
//
// Bis 0.35 zählte nur das Erwerbsende. Seit auch die Bezugsbeginne von AHV, PK und 3a eigene
// Zeitpunkte haben, muss die Kappung sie mitzählen: Fiele ein Bezug mitten in eine Phase,
// würde er auf die nächste Grenze rutschen -- unter Umständen Jahre später.
export function maxPhaseDuration( export function maxPhaseDuration(
persons: { role: PersonRole; age: number; retirementAge: number }[], persons: { role: PersonRole; age: number; retirementAge: number }[],
yearsBefore: number yearsBefore: number,
extraFixpoints: Fixpoint[] = []
): number | null { ): number | null {
const caps: number[] = []; const caps: number[] = [];
for (const p of persons) { for (const p of persons) {
const startAge = p.age + yearsBefore; const startAge = p.age + yearsBefore;
if (startAge < p.retirementAge) caps.push(p.retirementAge - startAge); if (startAge < p.retirementAge) caps.push(p.retirementAge - startAge);
} }
for (const f of extraFixpoints) {
const d = f.year - yearsBefore;
if (d > 0) caps.push(d);
}
return caps.length > 0 ? Math.min(...caps) : null; return caps.length > 0 ? Math.min(...caps) : null;
} }
@@ -390,6 +426,11 @@ function sourceLabelOf(
return `${e.name} (${who})`; return `${e.name} (${who})`;
} }
// Alter innerhalb eines gesetzlichen Fensters halten.
function clampAge(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, Math.round(v)));
}
function personByRole<T extends { role: PersonRole }>(persons: T[], role: string): T | null { function personByRole<T extends { role: PersonRole }>(persons: T[], role: string): T | null {
return persons.find((p) => p.role === role) ?? null; return persons.find((p) => p.role === role) ?? null;
} }
@@ -514,7 +555,9 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
const capsFromWorking = personInfos const capsFromWorking = personInfos
.filter((p) => p.working) .filter((p) => p.working)
.map((p) => retirementAge.get(p.personId)! - p.startAge) .map((p) => retirementAge.get(p.personId)! - p.startAge)
.filter((d) => d > 0); .filter((d) => d > 0)
// Dazu jeder noch bevorstehende Bezugsbeginn: Auch er erzwingt eine Phasengrenze.
.concat(planFixpoints(fixpointPersonsOf(plan)).map((f) => f.year - yearsBefore).filter((d) => d > 0));
const maxDurationYears = capsFromWorking.length > 0 ? Math.min(...capsFromWorking) : null; const maxDurationYears = capsFromWorking.length > 0 ? Math.min(...capsFromWorking) : null;
const workingByPerson = new Map(personInfos.map((p) => [p.personId, p.working])); const workingByPerson = new Map(personInfos.map((p) => [p.personId, p.working]));
@@ -618,7 +661,12 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
for (const e of orderedElements) { for (const e of orderedElements) {
const carry = carries.get(e.id)!; const carry = carries.get(e.id)!;
const pd = e.phaseValues[phase.id] ?? {}; // Stammdaten (Bestand bei Planbeginn, Ausgangs-Annahmen) und Phasenwerte. In der ERSTEN
// Phase gilt der Phasenwert, wo einer erfasst ist, sonst die Stammdaten -- damit ist
// Phase 1 nichts Besonderes mehr, sondern erbt schlicht von der Wurzel.
const bd = e.baseData ?? {};
const raw = e.phaseValues[phase.id] ?? {};
const pd: PhaseData = isFirstPhase ? { ...bd, ...raw } : raw;
const owner = e.ownerRole && e.ownerRole !== "HOUSEHOLD" ? personByRole(persons, e.ownerRole) : null; const owner = e.ownerRole && e.ownerRole !== "HOUSEHOLD" ? personByRole(persons, e.ownerRole) : null;
const ownerWorking = owner ? workingByPerson.get(owner.id) ?? false : anyWorking; const ownerWorking = owner ? workingByPerson.get(owner.id) ?? false : anyWorking;
@@ -628,7 +676,14 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
// verwendete Wert wird mitgeführt, damit die Kette über mehrere Phasen trägt. // verwendete Wert wird mitgeführt, damit die Kette über mehrere Phasen trägt.
const inherited = (key: string, fallback = 0): number => { const inherited = (key: string, fallback = 0): number => {
const own = (pd as Record<string, unknown>)[key]; const own = (pd as Record<string, unknown>)[key];
const v = typeof own === "number" ? own : carry.rates[key] ?? fallback; // Reihenfolge: eigener Wert -> aus der Vorphase geerbt -> Stammdaten -> Fallback.
// Die Stammdaten sind damit die WURZEL der Kette; vor 0.36 hatte Phase 1 nichts, von
// dem sie hätte erben können, und fiel auf 0.
const root = (bd as Record<string, unknown>)[key];
const v =
typeof own === "number"
? own
: carry.rates[key] ?? (typeof root === "number" ? root : fallback);
carry.rates[key] = v; carry.rates[key] = v;
return v; return v;
}; };
@@ -1541,24 +1596,30 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
// und innerhalb einer Phase kennt das Modell kein Einzelereignis -- gezogen wird deshalb // und innerhalb einer Phase kennt das Modell kein Einzelereignis -- gezogen wird deshalb
// an der ERSTEN Grenze bei oder nach dem Wunschalter. Liegt der Wunsch hinter dem // an der ERSTEN Grenze bei oder nach dem Wunschalter. Liegt der Wunsch hinter dem
// Planende, greift die letzte Grenze, damit das Guthaben nicht unbezogen liegen bleibt. // Planende, greift die letzte Grenze, damit das Guthaben nicht unbezogen liegen bleibt.
const drawsPillar3aHere = ( // Zieht dieses Vorsorge-Guthaben an DIESER Phasengrenze? Gilt gleichermassen für die
// Pensionskasse und die Säule 3a: Beide haben seit 0.36 ein eigenes Bezugsalter, und
// innerhalb einer Phase kennt das Modell kein Einzelereignis. Gezogen wird an der ERSTEN
// Grenze bei oder nach dem Wunschalter; liegt der Wunsch hinter dem Planende, greift die
// letzte Grenze, damit kein Guthaben unbezogen liegen bleibt.
const drawsHere = (
el: ElementInput, el: ElementInput,
ownerPerson: { id: string; age: number } | null, ownerPerson: { id: string; age: number } | null,
ageAtBoundary: number ageAtBoundary: number
): boolean => { ): boolean => {
// 3a ist personengebunden -- ohne Besitzer gibt es kein Bezugsalter und keinen Bezug. // Beide Kategorien sind personengebunden -- ohne Besitzer gibt es kein Bezugsalter.
if (!ownerPerson) return false; if (!ownerPerson) return false;
const rd = withRetirementDefaults( const ra = retirementAge.get(ownerPerson.id) ?? AHV_REFERENCE_AGE;
"PILLAR_3A", const rd = withRetirementDefaults(el.category, ra, el.retirementDecision);
retirementAge.get(ownerPerson.id) ?? AHV_REFERENCE_AGE, const wish =
el.retirementDecision el.category === "PENSION_FUND"
); ? clampAge(num(rd.pkWithdrawalAge, ra), PK_MIN_RETIREMENT_AGE, PK_MAX_RETIREMENT_AGE)
const wish = Math.max( : clampAge(
num(rd.withdrawalAge, ageAtBoundary),
PILLAR_3A_MIN_WITHDRAWAL_AGE, PILLAR_3A_MIN_WITHDRAWAL_AGE,
Math.min(PILLAR_3A_MAX_WITHDRAWAL_AGE, Math.round(num(rd.withdrawalAge, ageAtBoundary))) PILLAR_3A_MAX_WITHDRAWAL_AGE
); );
const prevBoundaryAge = ownerPerson.age + yearsBefore; const prevBoundaryAge = ownerPerson.age + yearsBefore;
if (prevBoundaryAge >= wish) return false; // in einer früheren Phase bereits gezogen if (prevBoundaryAge >= wish) return false; // an einer früheren Grenze bereits gezogen
return ageAtBoundary >= wish || !nextPhase; return ageAtBoundary >= wish || !nextPhase;
}; };
// Echte Vermögensänderungen an dieser Grenze (für die Brücke der Folgephase). // Echte Vermögensänderungen an dieser Grenze (für die Brücke der Folgephase).
@@ -1597,11 +1658,10 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
const ec = ecById.get(e.id)!; const ec = ecById.get(e.id)!;
const td = e.transitionValues[phase.id] ?? {}; const td = e.transitionValues[phase.id] ?? {};
const owner = e.ownerRole && e.ownerRole !== "HOUSEHOLD" ? personByRole(persons, e.ownerRole) : null; const owner = e.ownerRole && e.ownerRole !== "HOUSEHOLD" ? personByRole(persons, e.ownerRole) : null;
const ownerRetiresNext =
!!owner && !!nextPhase && workingByPerson.get(owner.id) === true &&
retiresInPhase(owner.id, persons, retirementAge, yearsBefore + duration);
// Pensionsalter des Besitzers und sein Alter AN dieser Phasengrenze -- Bezugspunkt für // Pensionsalter des Besitzers und sein Alter AN dieser Phasengrenze -- Bezugspunkt für
// die Vorgaben des Pensionierungs-Entscheids und für das 3a-Bezugsalter. // die Vorgaben des Pensionierungs-Entscheids und für die Bezugsalter von PK und 3a.
// Ob ein Guthaben HIER gezogen wird, entscheidet seit 0.36 `drawsHere` anhand des
// jeweiligen Bezugsalters, nicht mehr das Erwerbsende.
const ownerRetirementAge = owner ? retirementAge.get(owner.id) ?? AHV_REFERENCE_AGE : AHV_REFERENCE_AGE; const ownerRetirementAge = owner ? retirementAge.get(owner.id) ?? AHV_REFERENCE_AGE : AHV_REFERENCE_AGE;
const ownerAgeAtBoundary = owner ? owner.age + yearsBefore + duration : 0; const ownerAgeAtBoundary = owner ? owner.age + yearsBefore + duration : 0;
@@ -1628,7 +1688,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
switch (e.category) { switch (e.category) {
case "PENSION_FUND": { case "PENSION_FUND": {
if (ownerRetiresNext) { if (drawsHere(e, owner, ownerAgeAtBoundary)) {
// EINE Quote statt PENSION/CAPITAL/COMBI plus Frankenbetrag: 0 % = volle Rente, // EINE Quote statt PENSION/CAPITAL/COMBI plus Frankenbetrag: 0 % = volle Rente,
// 100 % = volles Kapital, alles dazwischen ist die Kombination. Als Quote, weil // 100 % = volles Kapital, alles dazwischen ist die Kombination. Als Quote, weil
// sich das Guthaben mit dem Pensionsalter ändert -- ein fixer Betrag würde beim // sich das Guthaben mit dem Pensionsalter ändert -- ein fixer Betrag würde beim
@@ -1665,7 +1725,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
// Übergang: Ein 3a-Konto lässt sich nur GANZ auflösen, und alle Bezüge desselben // Übergang: Ein 3a-Konto lässt sich nur GANZ auflösen, und alle Bezüge desselben
// Jahres werden steuerlich zusammengezählt -- gestaffelt wird deshalb über Konten // Jahres werden steuerlich zusammengezählt -- gestaffelt wird deshalb über Konten
// und Jahre. Gezogen wird an der ersten Phasengrenze bei oder nach dem Wunschalter. // und Jahre. Gezogen wird an der ersten Phasengrenze bei oder nach dem Wunschalter.
if (drawsPillar3aHere(e, owner, ownerAgeAtBoundary)) { if (drawsHere(e, owner, ownerAgeAtBoundary)) {
const rd = withRetirementDefaults("PILLAR_3A", ownerRetirementAge, e.retirementDecision); const rd = withRetirementDefaults("PILLAR_3A", ownerRetirementAge, e.retirementDecision);
const net = Math.round(ec.endValue * (1 - num(rd.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100)); const net = Math.round(ec.endValue * (1 - num(rd.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100));
txInflow += net; txInflow += net;
@@ -1746,17 +1806,22 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
if (e.category === "PENSION_FUND" || e.category === "PILLAR_3A") { if (e.category === "PENSION_FUND" || e.category === "PILLAR_3A") {
anchor = e.category === "PENSION_FUND" ? "491-pension_fund" : "492-pillar_3a"; anchor = e.category === "PENSION_FUND" ? "491-pension_fund" : "492-pillar_3a";
const mode = ownerRetiresNext ? td.payoutMode ?? "PENSION" : td.withdrawalMode ?? "NONE"; // Der Bezugs-Entscheid liegt seit 0.34 am Element; der ZEITPUNKT hat seit 0.36 ein
title = `Übergang «${ec.name}» ${ownerRetiresNext ? "Pensionierung" : "Vorbezug"}`; // eigenes Alter und muss nicht mehr mit dem Erwerbsende zusammenfallen.
if (ownerRetiresNext && e.category === "PENSION_FUND") { const drawnHere = drawsHere(e, owner, ownerAgeAtBoundary);
steps.push(st("Gewählte Bezugsart", 0, undefined, mode === "CAPITAL" ? "Kapitalbezug" : mode === "COMBI" ? "Kombination" : "Rente", undefined, "")); const rd = withRetirementDefaults(e.category, ownerRetirementAge, e.retirementDecision);
if (mode === "PENSION" || mode === "COMBI") { title = `Übergang «${ec.name}» ${drawnHere ? "Bezug" : "Vorbezug"}`;
steps.push(st("Umwandlungssatz", num(td.conversionRate, DEFAULT_PK_CONVERSION_RATE), undefined, undefined, undefined, "%")); if (drawnHere && e.category === "PENSION_FUND") {
const share = Math.max(0, Math.min(100, num(rd.capitalSharePct)));
steps.push(st("Anteil Kapitalbezug", share, undefined, undefined, "0 % = volle Rente, 100 % = volles Kapital.", "%"));
if (share < 100) {
steps.push(st("Umwandlungssatz", num(rd.conversionRate, DEFAULT_PK_CONVERSION_RATE), undefined, undefined, undefined, "%"));
steps.push(st("Jährliche Rente", carry.pkPensionAnnual, "verrentetes Kapital × Umwandlungssatz", undefined, "Das verrentete Kapital verlässt die Vermögensbilanz und erscheint fortan als Renteneinkommen.")); steps.push(st("Jährliche Rente", carry.pkPensionAnnual, "verrentetes Kapital × Umwandlungssatz", undefined, "Das verrentete Kapital verlässt die Vermögensbilanz und erscheint fortan als Renteneinkommen."));
} }
} }
if (taxHere > 0) { if (taxHere > 0) {
steps.push(st("Kapitalbezugssteuer", num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE), undefined, undefined, "Pauschalsatz die tatsächliche Steuer ist kantonal und progressiv.", "%")); const satz = drawnHere ? num(rd.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) : num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE);
steps.push(st("Kapitalbezugssteuer", satz, undefined, undefined, "Pauschalsatz die tatsächliche Steuer ist kantonal und progressiv.", "%"));
steps.push(st("Steuerbetrag", -taxHere)); steps.push(st("Steuerbetrag", -taxHere));
} }
if (inflowHere !== 0) steps.push(st("Netto ins Cash", inflowHere, "Bruttobezug Kapitalbezugssteuer")); if (inflowHere !== 0) steps.push(st("Netto ins Cash", inflowHere, "Bruttobezug Kapitalbezugssteuer"));
@@ -1924,7 +1989,8 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
personId: p.id, personId: p.id,
role: p.role, role: p.role,
retirementAge: retirementAge.get(p.id) ?? AHV_REFERENCE_AGE, retirementAge: retirementAge.get(p.id) ?? AHV_REFERENCE_AGE,
planningHorizonAge: p.planningHorizonAge ?? null, // Alter am Planende -- abgeleitet aus dem Horizont, nicht erfasst.
planningHorizonAge: typeof plan.planningHorizonYears === "number" ? p.age + plan.planningHorizonYears : null,
ahvAnnual: Math.round(ahvFinalByPerson.get(p.id) ?? 0), ahvAnnual: Math.round(ahvFinalByPerson.get(p.id) ?? 0),
ahvFromAge: Math.round(ahvStartAge(rd)), ahvFromAge: Math.round(ahvStartAge(rd)),
ahvDraw: ahvDrawLabel(rd), ahvDraw: ahvDrawLabel(rd),
@@ -2027,15 +2093,4 @@ function buildCareer(
}; };
} }
function retiresInPhase(
personId: string,
persons: { id: string; role: PersonRole; age: number }[],
retirementAge: Map<string, number>,
yearsBeforeNext: number
): boolean {
const p = persons.find((x) => x.id === personId);
if (!p) return false;
return p.age + yearsBeforeNext >= retirementAge.get(personId)!;
}
// Der CSV-Export liegt seit 0.31 in lib/csv.ts (vollstaendige Matrix statt Phasen-Summary). // Der CSV-Export liegt seit 0.31 in lib/csv.ts (vollstaendige Matrix statt Phasen-Summary).
+5 -3
View File
@@ -72,9 +72,11 @@ export const PK_BUYIN_BLOCKING_YEARS = 3;
// Summe der Phasendauern -- zwei Szenarien konnten dadurch unbemerkt verschieden weit rechnen // Summe der Phasendauern -- zwei Szenarien konnten dadurch unbemerkt verschieden weit rechnen
// und waren nicht vergleichbar. Der Default liegt bewusst ueber der Lebenserwartung: Eine zu // und waren nicht vergleichbar. Der Default liegt bewusst ueber der Lebenserwartung: Eine zu
// kurze Planung sieht tragfaehig aus, obwohl das Geld nur nicht lange genug reichen muss. // kurze Planung sieht tragfaehig aus, obwohl das Geld nur nicht lange genug reichen muss.
export const DEFAULT_PLANNING_HORIZON_AGE = 90; // Gefuehrt wird die Laufzeit in JAHREN und nicht als Endalter je Person: eine Zahl statt
export const MIN_PLANNING_HORIZON_AGE = 70; // zweier, die bei einem Paar auseinanderlaufen koennten. Die Endalter sind die Ableitung.
export const MAX_PLANNING_HORIZON_AGE = 110; export const DEFAULT_PLANNING_HORIZON_YEARS = 35;
export const MIN_PLANNING_HORIZON_YEARS = 1;
export const MAX_PLANNING_HORIZON_YEARS = 70;
// Umrechnung Netto- -> Bruttolohn für die AHV. Das Tool erfasst das Einkommen NETTO (so // Umrechnung Netto- -> Bruttolohn für die AHV. Das Tool erfasst das Einkommen NETTO (so
// denkt der Nutzer, und so stimmt der Cash-Fluss), die AHV bemisst sich aber am BRUTTOlohn. // denkt der Nutzer, und so stimmt der Cash-Fluss), die AHV bemisst sich aber am BRUTTOlohn.
+4 -2
View File
@@ -110,7 +110,8 @@ export function computeScenarioDiff(scenario: PlanInput, base: PlanInput | null)
if ( if (
el.name !== src.name || el.name !== src.name ||
el.ownerRole !== src.ownerRole || el.ownerRole !== src.ownerRole ||
!sameData(el.retirementDecision ?? {}, src.retirementDecision ?? {}) !sameData(el.retirementDecision ?? {}, src.retirementDecision ?? {}) ||
!sameData(el.baseData ?? {}, src.baseData ?? {})
) { ) {
d.elementRow.set(el.id, "changed"); d.elementRow.set(el.id, "changed");
} }
@@ -139,10 +140,11 @@ export function computeScenarioDiff(scenario: PlanInput, base: PlanInput | null)
// --- Profil und Cash-Anfangswert --- // --- Profil und Cash-Anfangswert ---
d.cashInitialChanged = Math.round(scenario.initialCash) !== Math.round(base.initialCash); d.cashInitialChanged = Math.round(scenario.initialCash) !== Math.round(base.initialCash);
const personKey = (p: PlanInput["persons"][number]) => const personKey = (p: PlanInput["persons"][number]) =>
`${p.role}|${p.name ?? ""}|${p.age}|${p.retirementAge}|${p.planningHorizonAge ?? ""}`; `${p.role}|${p.name ?? ""}|${p.age}|${p.retirementAge}`;
d.profileChanged = d.profileChanged =
scenario.householdType !== base.householdType || scenario.householdType !== base.householdType ||
scenario.inflationRateDefault !== base.inflationRateDefault || scenario.inflationRateDefault !== base.inflationRateDefault ||
(scenario.planningHorizonYears ?? null) !== (base.planningHorizonYears ?? null) ||
scenario.persons.map(personKey).sort().join(";") !== base.persons.map(personKey).sort().join(";"); scenario.persons.map(personKey).sort().join(";") !== base.persons.map(personKey).sort().join(";");
d.total = d.total =
+10 -4
View File
@@ -68,17 +68,23 @@ describe("Datenbank-Migrationen", () => {
expect(scenCols).not.toContain("startYear"); expect(scenCols).not.toContain("startYear");
expect(scenCols).not.toContain("userId"); // Eigentümer hängt am Plan expect(scenCols).not.toContain("userId"); // Eigentümer hängt am Plan
// Person trägt Pensionsalter und Planungshorizont -- Name und Alter beschreiben den // Person trägt nur noch das Pensionsalter -- Name und Alter beschreiben den Haushalt und
// Haushalt und liegen am Plan. // liegen am Plan, der Planungshorizont liegt seit 0.36 als JAHRESZAHL am Szenario.
const personCols = await cols("Person"); const personCols = await cols("Person");
expect(personCols).toContain("retirementAge"); expect(personCols).toContain("retirementAge");
expect(personCols).toContain("planningHorizonAge"); expect(personCols).not.toContain("planningHorizonAge");
expect(personCols).not.toContain("name"); expect(personCols).not.toContain("name");
expect(personCols).not.toContain("age"); expect(personCols).not.toContain("age");
const scenCols2 = await cols("Scenario");
expect(scenCols2).toContain("planningHorizonYears");
expect(scenCols2).toContain("assistantProgress");
// Der Pensionierungs-Entscheid hängt am ELEMENT und bewusst an keiner Phase (0.34) -- // Der Pensionierungs-Entscheid hängt am ELEMENT und bewusst an keiner Phase (0.34) --
// nur so überlebt er eine Verschiebung der Zeitachse. // nur so überlebt er eine Verschiebung der Zeitachse. Die Stammdaten (0.36) ebenso: Sie
// beschreiben den Bestand bei Planbeginn und existieren, bevor es Phasen gibt.
expect(await cols("FinancialElement")).toContain("retirementDecision"); expect(await cols("FinancialElement")).toContain("retirementDecision");
expect(await cols("FinancialElement")).toContain("baseData");
// Er muss NULL zulassen: Ohne erfassten Entscheid gelten die Vorgaben. // Er muss NULL zulassen: Ohne erfassten Entscheid gelten die Vorgaben.
const rd = await db.query<{ is_nullable: string }>( const rd = await db.query<{ is_nullable: string }>(
+72 -6
View File
@@ -1,10 +1,15 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { planSegments, defaultOpenDuration, planDurationChange } from "@/lib/phaseplan"; import { planFixpoints, planSegments, defaultOpenDuration, planDurationChange } from "@/lib/phaseplan";
// Seit 0.36 tragen Abschnitte zusaetzlich `endedBy` (Beschriftung). Die Struktur-Tests
// projizieren deshalb auf die drei Kernfelder.
const shape = (segs: ReturnType<typeof planSegments>) =>
segs.map((s) => ({ type: s.type, fromYear: s.fromYear, fixedYears: s.fixedYears }));
describe("planSegments", () => { describe("planSegments", () => {
it("Einzelperson: genau zwei Teile (Erwerb fix, Pension offen)", () => { it("Einzelperson: genau zwei Teile (Erwerb fix, Pension offen)", () => {
const segs = planSegments([{ age: 45, retirementAge: 65 }]); const segs = planSegments([{ age: 45, retirementAge: 65 }]);
expect(segs).toEqual([ expect(shape(segs)).toEqual([
{ type: "ERWERB", fromYear: 0, fixedYears: 20 }, { type: "ERWERB", fromYear: 0, fixedYears: 20 },
{ type: "PENSION", fromYear: 20, fixedYears: null }, { type: "PENSION", fromYear: 20, fixedYears: null },
]); ]);
@@ -25,7 +30,7 @@ describe("planSegments", () => {
{ age: 45, retirementAge: 65 }, // Ret in 20 J. { age: 45, retirementAge: 65 }, // Ret in 20 J.
{ age: 50, retirementAge: 62 }, // Ret in 12 J. { age: 50, retirementAge: 62 }, // Ret in 12 J.
]); ]);
expect(segs).toEqual([ expect(shape(segs)).toEqual([
{ type: "ERWERB", fromYear: 0, fixedYears: 12 }, { type: "ERWERB", fromYear: 0, fixedYears: 12 },
{ type: "MIXED", fromYear: 12, fixedYears: 8 }, { type: "MIXED", fromYear: 12, fixedYears: 8 },
{ type: "PENSION", fromYear: 20, fixedYears: null }, { type: "PENSION", fromYear: 20, fixedYears: null },
@@ -37,7 +42,7 @@ describe("planSegments", () => {
{ age: 67, retirementAge: 65 }, // bereits pensioniert -> Ret in 0 J. { age: 67, retirementAge: 65 }, // bereits pensioniert -> Ret in 0 J.
{ age: 60, retirementAge: 65 }, // Ret in 5 J. { age: 60, retirementAge: 65 }, // Ret in 5 J.
]); ]);
expect(segs).toEqual([ expect(shape(segs)).toEqual([
{ type: "MIXED", fromYear: 0, fixedYears: 5 }, { type: "MIXED", fromYear: 0, fixedYears: 5 },
{ type: "PENSION", fromYear: 5, fixedYears: null }, { type: "PENSION", fromYear: 5, fixedYears: null },
]); ]);
@@ -48,11 +53,11 @@ describe("planSegments", () => {
{ age: 70, retirementAge: 65 }, { age: 70, retirementAge: 65 },
{ age: 68, retirementAge: 64 }, { age: 68, retirementAge: 64 },
]); ]);
expect(segs).toEqual([{ type: "PENSION", fromYear: 0, fixedYears: null }]); expect(shape(segs)).toEqual([{ type: "PENSION", fromYear: 0, fixedYears: null }]);
}); });
it("Einzelperson bereits pensioniert: nur Pension", () => { it("Einzelperson bereits pensioniert: nur Pension", () => {
expect(planSegments([{ age: 66, retirementAge: 65 }])).toEqual([ expect(shape(planSegments([{ age: 66, retirementAge: 65 }]))).toEqual([
{ type: "PENSION", fromYear: 0, fixedYears: null }, { type: "PENSION", fromYear: 0, fixedYears: null },
]); ]);
}); });
@@ -127,3 +132,64 @@ describe("planDurationChange", () => {
expect(after).toBe(before); expect(after).toBe(before);
}); });
}); });
describe("planFixpoints (0.36)", () => {
// Bis 0.35 gab es genau einen Fixpunkt pro Person: das Erwerbsende. Seit die Bezugsbeginne
// von AHV, PK und 3a eigene Zeitpunkte haben, erzwingt jeder davon eine Phasengrenze --
// sonst fiele ein Bezug mitten in eine Phase und rutschte auf die nächste Grenze.
const person = {
role: "PERSON_A",
name: "Anna",
age: 60,
retirementAge: 62,
ahvStartAge: 63,
pkWithdrawalAge: 62,
pillar3aAges: [64, 65],
};
it("sammelt jeden Bezugsbeginn als eigenen Zeitpunkt", () => {
const f = planFixpoints([person]);
// Erwerbsende 62 und PK 62 fallen auf dasselbe Jahr -> zwei Eintraege, EIN Jahr.
expect(f.map((x) => x.year)).toEqual([2, 2, 3, 4, 5]);
expect(new Set(f.map((x) => x.kind))).toEqual(
new Set(["RETIREMENT", "PENSION_FUND", "AHV", "PILLAR_3A"])
);
});
it("erzeugt je distinktem Jahr genau eine Abschnittsgrenze", () => {
const segs = planSegments([person]);
// Grenzen bei 2, 3, 4, 5 -> fuenf Abschnitte (der letzte offen).
expect(segs.map((x) => x.fromYear)).toEqual([0, 2, 3, 4, 5]);
expect(segs[segs.length - 1].fixedYears).toBeNull();
});
it("beschriftet jede Grenze mit den Ereignissen, die sie ausloesen", () => {
const segs = planSegments([person]);
// Der erste Abschnitt endet mit Erwerbsende UND PK-Bezug -- beide im selben Jahr.
expect(segs[0].endedBy).toHaveLength(2);
expect(segs[0].endedBy.every((f) => f.label.includes("Anna"))).toBe(true);
});
it("ignoriert Ereignisse, die bei Planbeginn schon vorbei sind", () => {
const f = planFixpoints([{ role: "PERSON_A", age: 67, retirementAge: 65, ahvStartAge: 65 }]);
expect(f).toHaveLength(0);
});
it("faellt ohne Bezugsalter auf das alte Verhalten zurueck", () => {
const segs = planSegments([{ role: "PERSON_A", age: 45, retirementAge: 65 }]);
expect(shape(segs)).toEqual([
{ type: "ERWERB", fromYear: 0, fixedYears: 20 },
{ type: "PENSION", fromYear: 20, fixedYears: null },
]);
});
});
describe("defaultOpenDuration mit Planungshorizont", () => {
it("leitet die Dauer aus dem Horizont ab, wenn er erfasst ist", () => {
expect(defaultOpenDuration([{ age: 45 }], 20, 35)).toBe(15);
});
it("faellt ohne Horizont auf die Schaetzung bis Alter 90 zurueck", () => {
expect(defaultOpenDuration([{ age: 45 }], 20)).toBe(25);
});
});
+88 -14
View File
@@ -11,40 +11,114 @@
export type SegmentType = "ERWERB" | "MIXED" | "PENSION"; 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 { export interface PlanSegment {
type: SegmentType; type: SegmentType;
fromYear: number; // Jahre ab Planbeginn (Beginn des Abschnitts) fromYear: number; // Jahre ab Planbeginn (Beginn des Abschnitts)
// Feste Länge in Jahren (durch den nächsten Pensionierungspunkt bestimmt) ODER null für den // Feste Länge in Jahren (durch den nächsten Fixpunkt bestimmt) ODER null für den letzten,
// letzten, OFFENEN Abschnitt (Pension bis Lebensende -- Dauer frei wählbar). // OFFENEN Abschnitt (Dauer frei wählbar bzw. durch den Planungshorizont bestimmt).
fixedYears: number | null; 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, export interface FixpointPerson {
// falls bei Planbeginn bereits pensioniert). Die distinkten dieser Zeitpunkte sind die // Optional, damit Aufrufer, die nur Abschnitte brauchen (Zeitachse), nichts erfinden muessen.
// Abschnittsgrenzen; der Abschnitt nach dem letzten Zeitpunkt ist offen. role?: string;
export function planSegments(persons: { age: number; retirementAge: number }[]): PlanSegment[] { name?: string | null;
if (persons.length === 0) return [{ type: "PENSION", fromYear: 0, fixedYears: 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 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 years = [...new Set(fixpoints.map((f) => f.year))].sort((a, b) => a - b);
const boundaries = [0, ...events]; const boundaries = [0, ...years];
const segments: PlanSegment[] = []; const segments: PlanSegment[] = [];
for (let i = 0; i < boundaries.length; i++) { for (let i = 0; i < boundaries.length; i++) {
const from = boundaries[i]; const from = boundaries[i];
const next = i + 1 < boundaries.length ? boundaries[i + 1] : null; const next = i + 1 < boundaries.length ? boundaries[i + 1] : null;
// Eine Person arbeitet in einem Abschnitt, der bei Jahr `from` beginnt, genau dann, wenn // 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 working = retYears.filter((r) => r > from).length;
const type: SegmentType = working === persons.length ? "ERWERB" : working > 0 ? "MIXED" : "PENSION"; 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; return segments;
} }
// Vorgeschlagene Dauer des offenen Pensions-Abschnitts: bis das älteste Haushaltsmitglied // Vorgeschlagene Dauer des offenen Abschnitts. Mit erfasstem Planungshorizont ergibt sie sich
// ungefähr 90 ist, mindestens aber 5 Jahre. // daraus; ohne ihn bis etwa Alter 90, mindestens aber 5 Jahre.
export function defaultOpenDuration(persons: { age: number }[], fromYear: number): number { 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; const oldestNow = persons.length > 0 ? Math.max(...persons.map((p) => p.age)) : 65;
return Math.max(5, 90 - (oldestNow + fromYear)); return Math.max(5, 90 - (oldestNow + fromYear));
} }
+4 -1
View File
@@ -2,6 +2,7 @@ import { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { cashTransitionSchema, phaseDataSchema, transitionDataSchema } from "@/lib/elements"; import { cashTransitionSchema, phaseDataSchema, transitionDataSchema } from "@/lib/elements";
import { retirementDecisionSchema } from "@/lib/retirement-decision"; import { retirementDecisionSchema } from "@/lib/retirement-decision";
import { normalizeProgress } from "@/lib/assistant";
import type { CashTransitionData, PhaseData, TransitionData } from "@/lib/elements"; import type { CashTransitionData, PhaseData, TransitionData } from "@/lib/elements";
import type { RetirementDecision } from "@/lib/retirement-decision"; import type { RetirementDecision } from "@/lib/retirement-decision";
import type { PlanInput } from "@/lib/types"; import type { PlanInput } from "@/lib/types";
@@ -50,6 +51,8 @@ export function toPlanInput(plan: PlanWithRelations): PlanInput {
inflationRateDefault: plan.inflationRateDefault, inflationRateDefault: plan.inflationRateDefault,
initialCash: plan.initialCash, initialCash: plan.initialCash,
startYear: plan.plan.startYear, startYear: plan.plan.startYear,
planningHorizonYears: plan.planningHorizonYears,
assistantProgress: normalizeProgress(plan.assistantProgress),
// Name und Alter vom Plan, Pensionsalter vom Szenario. Fehlt zu einer Rolle die // Name und Alter vom Plan, Pensionsalter vom Szenario. Fehlt zu einer Rolle die
// Plan-Person, greift ein Notbehelf -- die Berechnung darf daran nicht scheitern. // Plan-Person, greift ein Notbehelf -- die Berechnung darf daran nicht scheitern.
persons: plan.persons.map((p) => { persons: plan.persons.map((p) => {
@@ -60,7 +63,6 @@ export function toPlanInput(plan: PlanWithRelations): PlanInput {
name: hh?.name ?? null, name: hh?.name ?? null,
age: hh?.age ?? 0, age: hh?.age ?? 0,
retirementAge: p.retirementAge, retirementAge: p.retirementAge,
planningHorizonAge: p.planningHorizonAge,
}; };
}), }),
phases: plan.phases.map((phase) => ({ phases: plan.phases.map((phase) => ({
@@ -85,6 +87,7 @@ export function toPlanInput(plan: PlanWithRelations): PlanInput {
phaseValues, phaseValues,
transitionValues, transitionValues,
retirementDecision: parseRetirementDecision(e.retirementDecision), retirementDecision: parseRetirementDecision(e.retirementDecision),
baseData: parsePhaseData(e.baseData),
sourceElementId: e.sourceElementId, sourceElementId: e.sourceElementId,
}; };
}), }),
+2 -8
View File
@@ -55,14 +55,7 @@ function plan(opts: {
inflationRateDefault: 0, inflationRateDefault: 0,
initialCash: 0, initialCash: 0,
persons: [ persons: [
{ { id: "A", role: "PERSON_A", name: null, age: opts.age, retirementAge: opts.retirementAge },
id: "A",
role: "PERSON_A",
name: null,
age: opts.age,
retirementAge: opts.retirementAge,
planningHorizonAge: opts.horizon ?? null,
},
], ],
phases: opts.phases.map((p, i) => ({ phases: opts.phases.map((p, i) => ({
id: p.id, id: p.id,
@@ -71,6 +64,7 @@ function plan(opts: {
durationYears: p.durationYears, durationYears: p.durationYears,
cashTransition: p.cashTransition ?? {}, cashTransition: p.cashTransition ?? {},
})), })),
planningHorizonYears: opts.horizon ?? null,
elements: opts.elements, elements: opts.elements,
}; };
} }
+10
View File
@@ -30,6 +30,8 @@ import {
DEFAULT_PK_CONVERSION_RATE, DEFAULT_PK_CONVERSION_RATE,
PILLAR_3A_MAX_WITHDRAWAL_AGE, PILLAR_3A_MAX_WITHDRAWAL_AGE,
PILLAR_3A_MIN_WITHDRAWAL_AGE, PILLAR_3A_MIN_WITHDRAWAL_AGE,
PK_MAX_RETIREMENT_AGE,
PK_MIN_RETIREMENT_AGE,
} from "@/lib/constants"; } from "@/lib/constants";
import type { ElementCategory } from "@/lib/elements"; import type { ElementCategory } from "@/lib/elements";
@@ -58,6 +60,11 @@ export interface RetirementDecision {
// Guthaben mit dem Pensionsalter aendert -- ein fixer Betrag wuerde still falsch. // Guthaben mit dem Pensionsalter aendert -- ein fixer Betrag wuerde still falsch.
capitalSharePct?: number; capitalSharePct?: number;
conversionRate?: number; conversionRate?: number;
// Alter, in dem das PK-Guthaben bezogen bzw. verrentet wird. Bis 0.35 war das starr an das
// Erwerbsende gekoppelt -- wer mit 62 aufhoert, aber die PK erst mit 65 beziehen will,
// konnte das nicht abbilden. Reglementarisch fruehestens 58, Aufschub bis 70 nur bei
// fortgesetzter Erwerbstaetigkeit.
pkWithdrawalAge?: number;
// Reines Hinweis-Flag: Ein Kapitalbezug innerhalb von drei Jahren nach einem Einkauf laesst // Reines Hinweis-Flag: Ein Kapitalbezug innerhalb von drei Jahren nach einem Einkauf laesst
// den Steuerabzug nachtraeglich entfallen (Art. 79b Abs. 3 BVG). Das Tool kennt keine // den Steuerabzug nachtraeglich entfallen (Art. 79b Abs. 3 BVG). Das Tool kennt keine
// Einkaeufe und kann das nicht selbst pruefen -- deshalb die Frage statt einer Automatik. // Einkaeufe und kann das nicht selbst pruefen -- deshalb die Frage statt einer Automatik.
@@ -91,6 +98,7 @@ export const retirementDecisionSchema = z
capitalSharePct: pct.optional(), capitalSharePct: pct.optional(),
conversionRate: z.number().min(0).max(20).optional(), conversionRate: z.number().min(0).max(20).optional(),
pkWithdrawalAge: z.number().int().min(PK_MIN_RETIREMENT_AGE).max(PK_MAX_RETIREMENT_AGE).optional(),
recentBuyIn: z.boolean().optional(), recentBuyIn: z.boolean().optional(),
withdrawalAge: z withdrawalAge: z
@@ -130,6 +138,8 @@ export function withRetirementDefaults(
d.capitalSharePct ??= 0; d.capitalSharePct ??= 0;
d.conversionRate ??= DEFAULT_PK_CONVERSION_RATE; d.conversionRate ??= DEFAULT_PK_CONVERSION_RATE;
d.capitalTaxRate ??= DEFAULT_CAPITAL_TAX_RATE; d.capitalTaxRate ??= DEFAULT_CAPITAL_TAX_RATE;
// Vorgabe: mit dem Erwerbsende, im gesetzlichen Fenster gehalten.
d.pkWithdrawalAge ??= clamp(retirementAge, PK_MIN_RETIREMENT_AGE, PK_MAX_RETIREMENT_AGE);
} else if (category === "PILLAR_3A") { } else if (category === "PILLAR_3A") {
// Im Pensionierungsjahr, aber nie ausserhalb des gesetzlichen Fensters: Wer mit 58 // Im Pensionierungsjahr, aber nie ausserhalb des gesetzlichen Fensters: Wer mit 58
// aufhoert, kann die 3a trotzdem erst mit 60 beziehen. // aufhoert, kann die 3a trotzdem erst mit 60 beziehen.
+21 -22
View File
@@ -49,18 +49,24 @@ export function sortedPhases(plan: PlanInput) {
// --- Planungshorizont ----------------------------------------------------------------------- // --- Planungshorizont -----------------------------------------------------------------------
// //
// Bis 0.33 ergab sich das Planende stillschweigend als Summe der Phasendauern. Zwei Szenarien // Bis 0.35 ergab sich das Planende stillschweigend als Summe der Phasendauern. Zwei Szenarien
// konnten dadurch unbemerkt verschieden weit rechnen -- und waren dann nicht vergleichbar, // konnten dadurch unbemerkt verschieden weit rechnen -- und waren dann nicht vergleichbar,
// obwohl genau das ihr Zweck ist. Neu ist der Horizont eine erfasste Zahl, und die LETZTE // obwohl genau das ihr Zweck ist.
// Phase folgt ihr (dieselbe Richtung wie beim Pensionsalter: Zahl stellen, Struktur folgt).
// //
// Referenzperson ist die aelteste: Sie erreicht ihren Horizont zuerst, und der Plan muss so // Seit 0.36 ist der Horizont eine Zahl in JAHREN am Szenario. Vorher stand er als Endalter je
// lange laufen, bis die LETZTE Person ihren erreicht hat. // PERSON -- bei einem Paar zwei Zahlen, die auseinanderlaufen konnten und aus denen sich das
// Planende erst per Maximum ergab. Eine Laufzeit ist eine Eigenschaft der PLANUNG, nicht der
// Person; die Endalter sind die Ableitung davon, nicht umgekehrt.
export function planEndYear(plan: PlanInput): number | null { export function planEndYear(plan: PlanInput): number | null {
const years = plan.persons const y = plan.planningHorizonYears;
.filter((p) => typeof p.planningHorizonAge === "number") return typeof y === "number" && y > 0 ? y : null;
.map((p) => (p.planningHorizonAge as number) - p.age); }
return years.length > 0 ? Math.max(...years) : null;
// Alter, das eine Person am Ende der Planung erreicht. Read-only-Anzeige neben dem Horizont.
export function endAgeOf(plan: PlanInput, role: PersonRole): number | null {
const years = planEndYear(plan);
const person = plan.persons.find((p) => p.role === role);
return years !== null && person ? person.age + years : null;
} }
export interface HorizonChange { export interface HorizonChange {
@@ -70,23 +76,15 @@ export interface HorizonChange {
blocked: string | null; blocked: string | null;
} }
// Was muesste an der letzten Phase geschehen, damit der Plan bis zum Horizont laeuft? // Was muesste an der letzten Phase geschehen, damit der Plan genau bis zum Horizont laeuft?
export function planHorizonChange(plan: PlanInput, horizonAge: number, role: PersonRole): HorizonChange | null { // Dieselbe Richtung wie beim Pensionsalter: Zahl stellen, Struktur folgt.
export function planHorizonChange(plan: PlanInput, horizonYears: number): HorizonChange | null {
const phases = sortedPhases(plan); const phases = sortedPhases(plan);
const last = phases[phases.length - 1]; const last = phases[phases.length - 1];
if (!last) return null; if (!last) return null;
const person = plan.persons.find((p) => p.role === role);
if (!person) return null;
// Zielgesamtdauer aus SICHT DIESER Person; die uebrigen Horizonte bleiben unberuehrt und
// koennen laenger sein -- deshalb das Maximum ueber alle.
const wish = Math.max(
horizonAge - person.age,
...plan.persons.filter((p) => p.role !== role && typeof p.planningHorizonAge === "number")
.map((p) => (p.planningHorizonAge as number) - p.age)
);
const before = phases.slice(0, -1).reduce((s, p) => s + p.durationYears, 0); const before = phases.slice(0, -1).reduce((s, p) => s + p.durationYears, 0);
const newDuration = wish - before; const newDuration = horizonYears - before;
const change: HorizonChange = { const change: HorizonChange = {
lastPhaseId: last.id, lastPhaseId: last.id,
oldDuration: last.durationYears, oldDuration: last.durationYears,
@@ -94,7 +92,8 @@ export function planHorizonChange(plan: PlanInput, horizonAge: number, role: Per
blocked: null, blocked: null,
}; };
if (newDuration < 1) { if (newDuration < 1) {
change.blocked = `Der Horizont liegt vor dem Ende der zweitletzten Lebensphase. Die letzte Phase muss mindestens ein Jahr dauern kürze zuerst eine frühere Phase.`; change.blocked =
"Der Horizont liegt vor dem Ende der zweitletzten Lebensphase. Die letzte Phase muss mindestens ein Jahr dauern kürze zuerst eine frühere Phase oder verlängere den Horizont.";
} }
return change; return change;
} }
+9 -3
View File
@@ -5,6 +5,7 @@
import type { CashTransitionData, ElementCategory, OwnerRole, PhaseData, TransitionData } from "@/lib/elements"; import type { CashTransitionData, ElementCategory, OwnerRole, PhaseData, TransitionData } from "@/lib/elements";
import type { RetirementDecision } from "@/lib/retirement-decision"; import type { RetirementDecision } from "@/lib/retirement-decision";
import type { AssistantProgress } from "@/lib/assistant";
export type HouseholdType = "SINGLE" | "COUPLE"; export type HouseholdType = "SINGLE" | "COUPLE";
export type PersonRole = "PERSON_A" | "PERSON_B"; export type PersonRole = "PERSON_A" | "PERSON_B";
@@ -15,9 +16,6 @@ export interface PersonInput {
name: string | null; name: string | null;
age: number; age: number;
retirementAge: number; retirementAge: number;
// Bis zu welchem Alter gerechnet wird. Fehlt der Wert, ergibt sich das Planende wie bisher
// aus der Summe der Phasendauern -- deshalb optional und nicht mit Default belegt.
planningHorizonAge?: number | null;
} }
export interface PhaseInput { export interface PhaseInput {
@@ -43,6 +41,9 @@ export interface ElementInput {
// Pensionierungs-Entscheid (nur AHV, PENSION_FUND, PILLAR_3A). Bewusst OHNE Phasenbezug -- // Pensionierungs-Entscheid (nur AHV, PENSION_FUND, PILLAR_3A). Bewusst OHNE Phasenbezug --
// er gilt für die Pensionierung des Besitzers, wo immer die gerade liegt. // er gilt für die Pensionierung des Besitzers, wo immer die gerade liegt.
retirementDecision?: RetirementDecision | null; retirementDecision?: RetirementDecision | null;
// Stammdaten: Bestand bei Planbeginn und Ausgangs-Annahmen. Existieren, BEVOR es Phasen
// gibt, und sind zugleich die Wurzel der Feld-Vererbung (Phase 1 erbt von hier).
baseData?: PhaseData | null;
// Gegenstück im Eltern-Szenario (Diff-Grundlage); null im Basisszenario. // Gegenstück im Eltern-Szenario (Diff-Grundlage); null im Basisszenario.
sourceElementId?: string | null; sourceElementId?: string | null;
} }
@@ -82,6 +83,11 @@ export interface PlanInput {
// Kalenderjahr des Planbeginns (Jahr 1) -- nur für die Darstellung, nicht für die // Kalenderjahr des Planbeginns (Jahr 1) -- nur für die Darstellung, nicht für die
// Berechnung. Optional, damit Berechnungs-Fixtures es nicht setzen müssen. // Berechnung. Optional, damit Berechnungs-Fixtures es nicht setzen müssen.
startYear?: number | null; startYear?: number | null;
// Laufzeit der Planung in JAHREN. Die Endalter je Person werden daraus abgeleitet. Fehlt
// der Wert, ergibt sich das Planende wie bisher aus der Summe der Phasendauern.
planningHorizonYears?: number | null;
// Fortschritt des FPT-Assistenten (sieben Schritte).
assistantProgress?: AssistantProgress | null;
persons: PersonInput[]; persons: PersonInput[];
phases: PhaseInput[]; phases: PhaseInput[];
elements: ElementInput[]; elements: ElementInput[];
+5 -8
View File
@@ -181,14 +181,8 @@ export async function restoreVersion(
for (const p of snap.persons) { for (const p of snap.persons) {
await tx.person.upsert({ await tx.person.upsert({
where: { scenarioId_role: { scenarioId, role: p.role } }, where: { scenarioId_role: { scenarioId, role: p.role } },
create: { create: { id: p.id, scenarioId, role: p.role, retirementAge: p.retirementAge },
id: p.id, update: { retirementAge: p.retirementAge },
scenarioId,
role: p.role,
retirementAge: p.retirementAge,
planningHorizonAge: p.planningHorizonAge ?? null,
},
update: { retirementAge: p.retirementAge, planningHorizonAge: p.planningHorizonAge ?? null },
}); });
} }
if (plan.deletePersonRoles.length > 0) { if (plan.deletePersonRoles.length > 0) {
@@ -236,6 +230,9 @@ export async function restoreVersion(
// Der Pensionierungs-Entscheid gehoert zum Inhalt des Szenarios -- ohne ihn wuerde // Der Pensionierungs-Entscheid gehoert zum Inhalt des Szenarios -- ohne ihn wuerde
// eine Wiederherstellung die Bezugsentscheide still auf die Vorgaben zuruecksetzen. // eine Wiederherstellung die Bezugsentscheide still auf die Vorgaben zuruecksetzen.
retirementDecision: (el.retirementDecision ?? undefined) as Prisma.InputJsonValue | undefined, retirementDecision: (el.retirementDecision ?? undefined) as Prisma.InputJsonValue | undefined,
// Stammdaten gehoeren zum Inhalt -- ohne sie verloere eine Wiederherstellung alle
// Bestaende bei Planbeginn.
baseData: (el.baseData ?? undefined) as Prisma.InputJsonValue | undefined,
sourceElementId: el.sourceElementId ?? null, sourceElementId: el.sourceElementId ?? null,
}; };
}; };