diff --git a/prisma/migrations/20260726140000_base_data_and_assistant/migration.sql b/prisma/migrations/20260726140000_base_data_and_assistant/migration.sql new file mode 100644 index 0000000..b05c063 --- /dev/null +++ b/prisma/migrations/20260726140000_base_data_and_assistant/migration.sql @@ -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"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a70fe01..112420a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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[] } diff --git a/src/app/api/scenarios/[scenarioId]/copy/route.ts b/src/app/api/scenarios/[scenarioId]/copy/route.ts index 68fb327..7bb5882 100644 --- a/src/app/api/scenarios/[scenarioId]/copy/route.ts +++ b/src/app/api/scenarios/[scenarioId]/copy/route.ts @@ -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, }, }); diff --git a/src/app/api/scenarios/[scenarioId]/horizon/route.ts b/src/app/api/scenarios/[scenarioId]/horizon/route.ts index 2a01087..c389181 100644 --- a/src/app/api/scenarios/[scenarioId]/horizon/route.ts +++ b/src/app/api/scenarios/[scenarioId]/horizon/route.ts @@ -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 }); } diff --git a/src/lib/assistant.ts b/src/lib/assistant.ts new file mode 100644 index 0000000..ac3fa55 --- /dev/null +++ b/src/lib/assistant.ts @@ -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; +} diff --git a/src/lib/calculations.ts b/src/lib/calculations.ts index cb40d43..15862e8 100644 --- a/src/lib/calculations.ts +++ b/src/lib/calculations.ts @@ -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(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)[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)[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( - PILLAR_3A_MIN_WITHDRAWAL_AGE, - Math.min(PILLAR_3A_MAX_WITHDRAWAL_AGE, Math.round(num(rd.withdrawalAge, ageAtBoundary))) - ); + 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, + 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, - 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). diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 6376661..dd52b92 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -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. diff --git a/src/lib/diff.ts b/src/lib/diff.ts index 5a9ba09..1310f3e 100644 --- a/src/lib/diff.ts +++ b/src/lib/diff.ts @@ -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 = diff --git a/src/lib/migrations.test.ts b/src/lib/migrations.test.ts index c3e9515..61d6af6 100644 --- a/src/lib/migrations.test.ts +++ b/src/lib/migrations.test.ts @@ -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 }>( diff --git a/src/lib/phaseplan.test.ts b/src/lib/phaseplan.test.ts index e9916a7..2042b3f 100644 --- a/src/lib/phaseplan.test.ts +++ b/src/lib/phaseplan.test.ts @@ -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) => + 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); + }); +}); diff --git a/src/lib/phaseplan.ts b/src/lib/phaseplan.ts index d57fbb8..51697f7 100644 --- a/src/lib/phaseplan.ts +++ b/src/lib/phaseplan.ts @@ -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 = { + 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)); } diff --git a/src/lib/queries.ts b/src/lib/queries.ts index b226931..56fa06a 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -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, }; }), diff --git a/src/lib/retirement-decision.test.ts b/src/lib/retirement-decision.test.ts index 7d91892..72d1e39 100644 --- a/src/lib/retirement-decision.test.ts +++ b/src/lib/retirement-decision.test.ts @@ -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, }; } diff --git a/src/lib/retirement-decision.ts b/src/lib/retirement-decision.ts index dc3f264..bcfc0b2 100644 --- a/src/lib/retirement-decision.ts +++ b/src/lib/retirement-decision.ts @@ -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. diff --git a/src/lib/retirement.ts b/src/lib/retirement.ts index dc76711..73d458b 100644 --- a/src/lib/retirement.ts +++ b/src/lib/retirement.ts @@ -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; } diff --git a/src/lib/types.ts b/src/lib/types.ts index 0ea3f73..657d118 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -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[]; diff --git a/src/lib/versioning-db.ts b/src/lib/versioning-db.ts index 4c069b8..8a1394d 100644 --- a/src/lib/versioning-db.ts +++ b/src/lib/versioning-db.ts @@ -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, }; };