Fundament: Element-Stammdaten, Fixpunkte, Horizont in Jahren, PK-Bezugsalter
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
@@ -76,11 +76,9 @@ model Person {
|
||||
scenarioId String
|
||||
scenario Scenario @relation(fields: [scenarioId], references: [id], onDelete: Cascade)
|
||||
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
|
||||
// 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])
|
||||
}
|
||||
@@ -224,6 +222,14 @@ model Scenario {
|
||||
inflationRateDefault Float
|
||||
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())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -309,6 +315,14 @@ model FinancialElement {
|
||||
// er jede Verschiebung der Zeitachse. Siehe src/lib/retirement-decision.ts.
|
||||
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[]
|
||||
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.
|
||||
inflationRateDefault: source.inflationRateDefault,
|
||||
initialCash: source.initialCash,
|
||||
planningHorizonYears: source.planningHorizonYears,
|
||||
assistantProgress: source.assistantProgress ?? undefined,
|
||||
persons: {
|
||||
create: source.persons.map((p) => ({
|
||||
role: p.role,
|
||||
retirementAge: p.retirementAge,
|
||||
planningHorizonAge: p.planningHorizonAge,
|
||||
})),
|
||||
create: source.persons.map((p) => ({ role: p.role, retirementAge: p.retirementAge })),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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
|
||||
// den man sie am haeufigsten anlegt: ein anderes Pensionierungs-Szenario.
|
||||
retirementDecision: el.retirementDecision ?? undefined,
|
||||
baseData: el.baseData ?? undefined,
|
||||
sourceElementId: el.id,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -5,18 +5,17 @@ import { getOwnedScenario, toPlanInput } from "@/lib/queries";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
import { touchScenario } from "@/lib/versioning-db";
|
||||
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
|
||||
// verlängert oder gekürzt, dass der Plan genau bis zum Horizont läuft. Vorher ergab sich das
|
||||
// Planende stillschweigend aus der Summe der Phasendauern -- zwei Szenarien konnten dadurch
|
||||
// unbemerkt verschieden weit rechnen und waren nicht vergleichbar.
|
||||
// verlängert oder gekürzt, dass der Plan genau bis zum Horizont läuft. Gibt es noch keine
|
||||
// Phasen, wird nur die Zahl gespeichert -- sie ist dann die Grundlage, auf der der Assistent
|
||||
// die Zeitachse aufspannt.
|
||||
|
||||
const bodySchema = z.object({
|
||||
role: z.enum(["PERSON_A", "PERSON_B"]),
|
||||
horizonAge: z.number().int().min(MIN_PLANNING_HORIZON_AGE).max(MAX_PLANNING_HORIZON_AGE),
|
||||
horizonYears: z.number().int().min(MIN_PLANNING_HORIZON_YEARS).max(MAX_PLANNING_HORIZON_YEARS),
|
||||
});
|
||||
|
||||
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(() => ({})));
|
||||
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 person = planInput.persons.find((p) => p.role === role);
|
||||
if (!person) return NextResponse.json({ error: "Diese Person gibt es in diesem Szenario nicht." }, { 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 });
|
||||
const change = planInput.phases.length > 0 ? planHorizonChange(planInput, horizonYears) : null;
|
||||
if (change?.blocked) return NextResponse.json({ error: change.blocked }, { status: 400 });
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.person.update({ where: { id: person.id }, data: { planningHorizonAge: horizonAge } }),
|
||||
prisma.phase.update({ where: { id: change.lastPhaseId }, data: { durationYears: change.newDuration } }),
|
||||
prisma.scenario.update({ where: { id: scenarioId }, data: { planningHorizonYears: horizonYears } }),
|
||||
...(change
|
||||
? [prisma.phase.update({ where: { id: change.lastPhaseId }, data: { durationYears: change.newDuration } })]
|
||||
: []),
|
||||
]);
|
||||
|
||||
await touchScenario(scenario.id, userId);
|
||||
return NextResponse.json({ ok: true, lastPhaseDuration: change.newDuration });
|
||||
return NextResponse.json({ ok: true, lastPhaseDuration: change?.newDuration ?? null });
|
||||
}
|
||||
|
||||
@@ -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
@@ -12,9 +12,12 @@ import {
|
||||
DEFAULT_PROPERTY_GAINS_TAX_RATE,
|
||||
PILLAR_3A_MAX_WITHDRAWAL_AGE,
|
||||
PILLAR_3A_MIN_WITHDRAWAL_AGE,
|
||||
PK_MAX_RETIREMENT_AGE,
|
||||
PK_MIN_RETIREMENT_AGE,
|
||||
} from "@/lib/constants";
|
||||
import { num } from "@/lib/elements";
|
||||
import { actualsForYear, rebaseFlow, type ResolvedActuals } from "@/lib/actuals";
|
||||
import { planFixpoints, type Fixpoint, type FixpointPerson } from "@/lib/phaseplan";
|
||||
import {
|
||||
ahvDrawLabel,
|
||||
ahvFactor,
|
||||
@@ -23,7 +26,7 @@ import {
|
||||
withRetirementDefaults,
|
||||
type RetirementDecision,
|
||||
} 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";
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// 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(
|
||||
persons: { role: PersonRole; age: number; retirementAge: number }[],
|
||||
yearsBefore: number
|
||||
yearsBefore: number,
|
||||
extraFixpoints: Fixpoint[] = []
|
||||
): number | null {
|
||||
const caps: number[] = [];
|
||||
for (const p of persons) {
|
||||
const startAge = p.age + yearsBefore;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -390,6 +426,11 @@ function sourceLabelOf(
|
||||
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 {
|
||||
return persons.find((p) => p.role === role) ?? null;
|
||||
}
|
||||
@@ -514,7 +555,9 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
||||
const capsFromWorking = personInfos
|
||||
.filter((p) => p.working)
|
||||
.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 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) {
|
||||
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 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.
|
||||
const inherited = (key: string, fallback = 0): number => {
|
||||
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;
|
||||
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
|
||||
// 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.
|
||||
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,
|
||||
ownerPerson: { id: string; age: number } | null,
|
||||
ageAtBoundary: number
|
||||
): 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;
|
||||
const rd = withRetirementDefaults(
|
||||
"PILLAR_3A",
|
||||
retirementAge.get(ownerPerson.id) ?? AHV_REFERENCE_AGE,
|
||||
el.retirementDecision
|
||||
);
|
||||
const wish = Math.max(
|
||||
const ra = retirementAge.get(ownerPerson.id) ?? AHV_REFERENCE_AGE;
|
||||
const rd = withRetirementDefaults(el.category, ra, el.retirementDecision);
|
||||
const wish =
|
||||
el.category === "PENSION_FUND"
|
||||
? clampAge(num(rd.pkWithdrawalAge, ra), PK_MIN_RETIREMENT_AGE, PK_MAX_RETIREMENT_AGE)
|
||||
: clampAge(
|
||||
num(rd.withdrawalAge, ageAtBoundary),
|
||||
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;
|
||||
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;
|
||||
};
|
||||
// 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 td = e.transitionValues[phase.id] ?? {};
|
||||
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
|
||||
// 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 ownerAgeAtBoundary = owner ? owner.age + yearsBefore + duration : 0;
|
||||
|
||||
@@ -1628,7 +1688,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
||||
|
||||
switch (e.category) {
|
||||
case "PENSION_FUND": {
|
||||
if (ownerRetiresNext) {
|
||||
if (drawsHere(e, owner, ownerAgeAtBoundary)) {
|
||||
// EINE Quote statt PENSION/CAPITAL/COMBI plus Frankenbetrag: 0 % = volle Rente,
|
||||
// 100 % = volles Kapital, alles dazwischen ist die Kombination. Als Quote, weil
|
||||
// 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
|
||||
// Jahres werden steuerlich zusammengezählt -- gestaffelt wird deshalb über Konten
|
||||
// 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 net = Math.round(ec.endValue * (1 - num(rd.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100));
|
||||
txInflow += net;
|
||||
@@ -1746,17 +1806,22 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
||||
|
||||
if (e.category === "PENSION_FUND" || e.category === "PILLAR_3A") {
|
||||
anchor = e.category === "PENSION_FUND" ? "491-pension_fund" : "492-pillar_3a";
|
||||
const mode = ownerRetiresNext ? td.payoutMode ?? "PENSION" : td.withdrawalMode ?? "NONE";
|
||||
title = `Übergang «${ec.name}» – ${ownerRetiresNext ? "Pensionierung" : "Vorbezug"}`;
|
||||
if (ownerRetiresNext && e.category === "PENSION_FUND") {
|
||||
steps.push(st("Gewählte Bezugsart", 0, undefined, mode === "CAPITAL" ? "Kapitalbezug" : mode === "COMBI" ? "Kombination" : "Rente", undefined, ""));
|
||||
if (mode === "PENSION" || mode === "COMBI") {
|
||||
steps.push(st("Umwandlungssatz", num(td.conversionRate, DEFAULT_PK_CONVERSION_RATE), undefined, undefined, undefined, "%"));
|
||||
// Der Bezugs-Entscheid liegt seit 0.34 am Element; der ZEITPUNKT hat seit 0.36 ein
|
||||
// eigenes Alter und muss nicht mehr mit dem Erwerbsende zusammenfallen.
|
||||
const drawnHere = drawsHere(e, owner, ownerAgeAtBoundary);
|
||||
const rd = withRetirementDefaults(e.category, ownerRetirementAge, e.retirementDecision);
|
||||
title = `Übergang «${ec.name}» – ${drawnHere ? "Bezug" : "Vorbezug"}`;
|
||||
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."));
|
||||
}
|
||||
}
|
||||
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));
|
||||
}
|
||||
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,
|
||||
role: p.role,
|
||||
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),
|
||||
ahvFromAge: Math.round(ahvStartAge(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).
|
||||
|
||||
@@ -72,9 +72,11 @@ export const PK_BUYIN_BLOCKING_YEARS = 3;
|
||||
// Summe der Phasendauern -- zwei Szenarien konnten dadurch unbemerkt verschieden weit rechnen
|
||||
// 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.
|
||||
export const DEFAULT_PLANNING_HORIZON_AGE = 90;
|
||||
export const MIN_PLANNING_HORIZON_AGE = 70;
|
||||
export const MAX_PLANNING_HORIZON_AGE = 110;
|
||||
// Gefuehrt wird die Laufzeit in JAHREN und nicht als Endalter je Person: eine Zahl statt
|
||||
// zweier, die bei einem Paar auseinanderlaufen koennten. Die Endalter sind die Ableitung.
|
||||
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
|
||||
// denkt der Nutzer, und so stimmt der Cash-Fluss), die AHV bemisst sich aber am BRUTTOlohn.
|
||||
|
||||
+4
-2
@@ -110,7 +110,8 @@ export function computeScenarioDiff(scenario: PlanInput, base: PlanInput | null)
|
||||
if (
|
||||
el.name !== src.name ||
|
||||
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");
|
||||
}
|
||||
@@ -139,10 +140,11 @@ export function computeScenarioDiff(scenario: PlanInput, base: PlanInput | null)
|
||||
// --- Profil und Cash-Anfangswert ---
|
||||
d.cashInitialChanged = Math.round(scenario.initialCash) !== Math.round(base.initialCash);
|
||||
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 =
|
||||
scenario.householdType !== base.householdType ||
|
||||
scenario.inflationRateDefault !== base.inflationRateDefault ||
|
||||
(scenario.planningHorizonYears ?? null) !== (base.planningHorizonYears ?? null) ||
|
||||
scenario.persons.map(personKey).sort().join(";") !== base.persons.map(personKey).sort().join(";");
|
||||
|
||||
d.total =
|
||||
|
||||
@@ -68,17 +68,23 @@ describe("Datenbank-Migrationen", () => {
|
||||
expect(scenCols).not.toContain("startYear");
|
||||
expect(scenCols).not.toContain("userId"); // Eigentümer hängt am Plan
|
||||
|
||||
// Person trägt Pensionsalter und Planungshorizont -- Name und Alter beschreiben den
|
||||
// Haushalt und liegen am Plan.
|
||||
// Person trägt nur noch das Pensionsalter -- Name und Alter beschreiben den Haushalt und
|
||||
// liegen am Plan, der Planungshorizont liegt seit 0.36 als JAHRESZAHL am Szenario.
|
||||
const personCols = await cols("Person");
|
||||
expect(personCols).toContain("retirementAge");
|
||||
expect(personCols).toContain("planningHorizonAge");
|
||||
expect(personCols).not.toContain("planningHorizonAge");
|
||||
expect(personCols).not.toContain("name");
|
||||
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) --
|
||||
// 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("baseData");
|
||||
|
||||
// Er muss NULL zulassen: Ohne erfassten Entscheid gelten die Vorgaben.
|
||||
const rd = await db.query<{ is_nullable: string }>(
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
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", () => {
|
||||
it("Einzelperson: genau zwei Teile (Erwerb fix, Pension offen)", () => {
|
||||
const segs = planSegments([{ age: 45, retirementAge: 65 }]);
|
||||
expect(segs).toEqual([
|
||||
expect(shape(segs)).toEqual([
|
||||
{ type: "ERWERB", fromYear: 0, fixedYears: 20 },
|
||||
{ type: "PENSION", fromYear: 20, fixedYears: null },
|
||||
]);
|
||||
@@ -25,7 +30,7 @@ describe("planSegments", () => {
|
||||
{ age: 45, retirementAge: 65 }, // Ret in 20 J.
|
||||
{ age: 50, retirementAge: 62 }, // Ret in 12 J.
|
||||
]);
|
||||
expect(segs).toEqual([
|
||||
expect(shape(segs)).toEqual([
|
||||
{ type: "ERWERB", fromYear: 0, fixedYears: 12 },
|
||||
{ type: "MIXED", fromYear: 12, fixedYears: 8 },
|
||||
{ type: "PENSION", fromYear: 20, fixedYears: null },
|
||||
@@ -37,7 +42,7 @@ describe("planSegments", () => {
|
||||
{ age: 67, retirementAge: 65 }, // bereits pensioniert -> Ret in 0 J.
|
||||
{ age: 60, retirementAge: 65 }, // Ret in 5 J.
|
||||
]);
|
||||
expect(segs).toEqual([
|
||||
expect(shape(segs)).toEqual([
|
||||
{ type: "MIXED", fromYear: 0, fixedYears: 5 },
|
||||
{ type: "PENSION", fromYear: 5, fixedYears: null },
|
||||
]);
|
||||
@@ -48,11 +53,11 @@ describe("planSegments", () => {
|
||||
{ age: 70, retirementAge: 65 },
|
||||
{ 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", () => {
|
||||
expect(planSegments([{ age: 66, retirementAge: 65 }])).toEqual([
|
||||
expect(shape(planSegments([{ age: 66, retirementAge: 65 }]))).toEqual([
|
||||
{ type: "PENSION", fromYear: 0, fixedYears: null },
|
||||
]);
|
||||
});
|
||||
@@ -127,3 +132,64 @@ describe("planDurationChange", () => {
|
||||
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
@@ -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));
|
||||
}
|
||||
|
||||
+4
-1
@@ -2,6 +2,7 @@ import { Prisma } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { cashTransitionSchema, phaseDataSchema, transitionDataSchema } from "@/lib/elements";
|
||||
import { retirementDecisionSchema } from "@/lib/retirement-decision";
|
||||
import { normalizeProgress } from "@/lib/assistant";
|
||||
import type { CashTransitionData, PhaseData, TransitionData } from "@/lib/elements";
|
||||
import type { RetirementDecision } from "@/lib/retirement-decision";
|
||||
import type { PlanInput } from "@/lib/types";
|
||||
@@ -50,6 +51,8 @@ export function toPlanInput(plan: PlanWithRelations): PlanInput {
|
||||
inflationRateDefault: plan.inflationRateDefault,
|
||||
initialCash: plan.initialCash,
|
||||
startYear: plan.plan.startYear,
|
||||
planningHorizonYears: plan.planningHorizonYears,
|
||||
assistantProgress: normalizeProgress(plan.assistantProgress),
|
||||
// Name und Alter vom Plan, Pensionsalter vom Szenario. Fehlt zu einer Rolle die
|
||||
// Plan-Person, greift ein Notbehelf -- die Berechnung darf daran nicht scheitern.
|
||||
persons: plan.persons.map((p) => {
|
||||
@@ -60,7 +63,6 @@ export function toPlanInput(plan: PlanWithRelations): PlanInput {
|
||||
name: hh?.name ?? null,
|
||||
age: hh?.age ?? 0,
|
||||
retirementAge: p.retirementAge,
|
||||
planningHorizonAge: p.planningHorizonAge,
|
||||
};
|
||||
}),
|
||||
phases: plan.phases.map((phase) => ({
|
||||
@@ -85,6 +87,7 @@ export function toPlanInput(plan: PlanWithRelations): PlanInput {
|
||||
phaseValues,
|
||||
transitionValues,
|
||||
retirementDecision: parseRetirementDecision(e.retirementDecision),
|
||||
baseData: parsePhaseData(e.baseData),
|
||||
sourceElementId: e.sourceElementId,
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -55,14 +55,7 @@ function plan(opts: {
|
||||
inflationRateDefault: 0,
|
||||
initialCash: 0,
|
||||
persons: [
|
||||
{
|
||||
id: "A",
|
||||
role: "PERSON_A",
|
||||
name: null,
|
||||
age: opts.age,
|
||||
retirementAge: opts.retirementAge,
|
||||
planningHorizonAge: opts.horizon ?? null,
|
||||
},
|
||||
{ id: "A", role: "PERSON_A", name: null, age: opts.age, retirementAge: opts.retirementAge },
|
||||
],
|
||||
phases: opts.phases.map((p, i) => ({
|
||||
id: p.id,
|
||||
@@ -71,6 +64,7 @@ function plan(opts: {
|
||||
durationYears: p.durationYears,
|
||||
cashTransition: p.cashTransition ?? {},
|
||||
})),
|
||||
planningHorizonYears: opts.horizon ?? null,
|
||||
elements: opts.elements,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -30,6 +30,8 @@ import {
|
||||
DEFAULT_PK_CONVERSION_RATE,
|
||||
PILLAR_3A_MAX_WITHDRAWAL_AGE,
|
||||
PILLAR_3A_MIN_WITHDRAWAL_AGE,
|
||||
PK_MAX_RETIREMENT_AGE,
|
||||
PK_MIN_RETIREMENT_AGE,
|
||||
} from "@/lib/constants";
|
||||
import type { ElementCategory } from "@/lib/elements";
|
||||
|
||||
@@ -58,6 +60,11 @@ export interface RetirementDecision {
|
||||
// Guthaben mit dem Pensionsalter aendert -- ein fixer Betrag wuerde still falsch.
|
||||
capitalSharePct?: 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
|
||||
// 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.
|
||||
@@ -91,6 +98,7 @@ export const retirementDecisionSchema = z
|
||||
|
||||
capitalSharePct: pct.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(),
|
||||
|
||||
withdrawalAge: z
|
||||
@@ -130,6 +138,8 @@ export function withRetirementDefaults(
|
||||
d.capitalSharePct ??= 0;
|
||||
d.conversionRate ??= DEFAULT_PK_CONVERSION_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") {
|
||||
// Im Pensionierungsjahr, aber nie ausserhalb des gesetzlichen Fensters: Wer mit 58
|
||||
// aufhoert, kann die 3a trotzdem erst mit 60 beziehen.
|
||||
|
||||
+21
-22
@@ -49,18 +49,24 @@ export function sortedPhases(plan: PlanInput) {
|
||||
|
||||
// --- 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,
|
||||
// obwohl genau das ihr Zweck ist. Neu ist der Horizont eine erfasste Zahl, und die LETZTE
|
||||
// Phase folgt ihr (dieselbe Richtung wie beim Pensionsalter: Zahl stellen, Struktur folgt).
|
||||
// obwohl genau das ihr Zweck ist.
|
||||
//
|
||||
// Referenzperson ist die aelteste: Sie erreicht ihren Horizont zuerst, und der Plan muss so
|
||||
// lange laufen, bis die LETZTE Person ihren erreicht hat.
|
||||
// Seit 0.36 ist der Horizont eine Zahl in JAHREN am Szenario. Vorher stand er als Endalter je
|
||||
// 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 {
|
||||
const years = plan.persons
|
||||
.filter((p) => typeof p.planningHorizonAge === "number")
|
||||
.map((p) => (p.planningHorizonAge as number) - p.age);
|
||||
return years.length > 0 ? Math.max(...years) : null;
|
||||
const y = plan.planningHorizonYears;
|
||||
return typeof y === "number" && y > 0 ? y : 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 {
|
||||
@@ -70,23 +76,15 @@ export interface HorizonChange {
|
||||
blocked: string | null;
|
||||
}
|
||||
|
||||
// Was muesste an der letzten Phase geschehen, damit der Plan bis zum Horizont laeuft?
|
||||
export function planHorizonChange(plan: PlanInput, horizonAge: number, role: PersonRole): HorizonChange | null {
|
||||
// Was muesste an der letzten Phase geschehen, damit der Plan genau bis zum Horizont laeuft?
|
||||
// Dieselbe Richtung wie beim Pensionsalter: Zahl stellen, Struktur folgt.
|
||||
export function planHorizonChange(plan: PlanInput, horizonYears: number): HorizonChange | null {
|
||||
const phases = sortedPhases(plan);
|
||||
const last = phases[phases.length - 1];
|
||||
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 newDuration = wish - before;
|
||||
const newDuration = horizonYears - before;
|
||||
const change: HorizonChange = {
|
||||
lastPhaseId: last.id,
|
||||
oldDuration: last.durationYears,
|
||||
@@ -94,7 +92,8 @@ export function planHorizonChange(plan: PlanInput, horizonAge: number, role: Per
|
||||
blocked: null,
|
||||
};
|
||||
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;
|
||||
}
|
||||
|
||||
+9
-3
@@ -5,6 +5,7 @@
|
||||
|
||||
import type { CashTransitionData, ElementCategory, OwnerRole, PhaseData, TransitionData } from "@/lib/elements";
|
||||
import type { RetirementDecision } from "@/lib/retirement-decision";
|
||||
import type { AssistantProgress } from "@/lib/assistant";
|
||||
|
||||
export type HouseholdType = "SINGLE" | "COUPLE";
|
||||
export type PersonRole = "PERSON_A" | "PERSON_B";
|
||||
@@ -15,9 +16,6 @@ export interface PersonInput {
|
||||
name: string | null;
|
||||
age: 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 {
|
||||
@@ -43,6 +41,9 @@ export interface ElementInput {
|
||||
// Pensionierungs-Entscheid (nur AHV, PENSION_FUND, PILLAR_3A). Bewusst OHNE Phasenbezug --
|
||||
// er gilt für die Pensionierung des Besitzers, wo immer die gerade liegt.
|
||||
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.
|
||||
sourceElementId?: string | null;
|
||||
}
|
||||
@@ -82,6 +83,11 @@ export interface PlanInput {
|
||||
// Kalenderjahr des Planbeginns (Jahr 1) -- nur für die Darstellung, nicht für die
|
||||
// Berechnung. Optional, damit Berechnungs-Fixtures es nicht setzen müssen.
|
||||
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[];
|
||||
phases: PhaseInput[];
|
||||
elements: ElementInput[];
|
||||
|
||||
@@ -181,14 +181,8 @@ export async function restoreVersion(
|
||||
for (const p of snap.persons) {
|
||||
await tx.person.upsert({
|
||||
where: { scenarioId_role: { scenarioId, role: p.role } },
|
||||
create: {
|
||||
id: p.id,
|
||||
scenarioId,
|
||||
role: p.role,
|
||||
retirementAge: p.retirementAge,
|
||||
planningHorizonAge: p.planningHorizonAge ?? null,
|
||||
},
|
||||
update: { retirementAge: p.retirementAge, planningHorizonAge: p.planningHorizonAge ?? null },
|
||||
create: { id: p.id, scenarioId, role: p.role, retirementAge: p.retirementAge },
|
||||
update: { retirementAge: p.retirementAge },
|
||||
});
|
||||
}
|
||||
if (plan.deletePersonRoles.length > 0) {
|
||||
@@ -236,6 +230,9 @@ export async function restoreVersion(
|
||||
// Der Pensionierungs-Entscheid gehoert zum Inhalt des Szenarios -- ohne ihn wuerde
|
||||
// eine Wiederherstellung die Bezugsentscheide still auf die Vorgaben zuruecksetzen.
|
||||
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,
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user