Das Pensionsalter laesst sich neu veraendern: Nicht das Alter wird gesetzt, sondern die Phasengrenze verschoben -- Vorphase laenger, Folgephase kuerzer, Gesamtdauer gleich. Faellt eine Phase dabei weg, werden die beiden Uebergaenge nach Bestaetigung zusammengelegt. - neues reines Modul lib/retirement.ts + POST /api/scenarios/<id>/retirement - Pensionsalter als Tornado-Treiber und Live-Simulations-Regler - AHV-Referenzalter 65: Rente ab 65 unabhaengig vom Pensionsalter; vor 65 Beitrag als Nichterwerbstaetige(r) (neues Feld ahvContribution). AHV wird dafuer jahresweise statt phasenweise gerechnet. - Punkt B: personenzugeordnetes Einkommen faellt bei Pensionierung auf 0 - Punkt A: Wiederkehr-Parameter werden live aus der Vorphase geerbt, sichtbar als "Aus Vorphase uebernehmen" - Punkt C: Kapitalzufluss am Pensions-Uebergang per Quote auf Amortisation, Anlage und Cash verteilbar - Fix: carry.flowBasis wurde vor der Jahresschleife berechnet, effektive Werte kamen deshalb nie in der Folgephase an SPEZIFIKATION 0.26 (neue Kapitel 3.12, 4.4.7, 4.16). 221 -> 261 Tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -79,33 +79,28 @@ export async function POST(
|
||||
return NextResponse.json({ phase: { id: phase.id } }, { status: 201 });
|
||||
}
|
||||
|
||||
// Vorbelegung für eine neue Phase: nur die editierbaren Felder werden übernommen. Start-
|
||||
// bzw. Restwerte (PK/3a/Vermögen/Hypothek/Schuld) werden in der Berechnung live aus der
|
||||
// Vorphase fortgeschrieben und deshalb hier NICHT als Snapshot gespeichert.
|
||||
// Vorbelegung für eine neue Phase.
|
||||
//
|
||||
// Seit Roadmap Nr. 44 (Punkt A) werden die WIEDERKEHR-Parameter (Raten, Beiträge, Amortisation,
|
||||
// Wertsteigerung, Zinssatz) NICHT mehr als Kopie gespeichert, sondern bewusst leer gelassen:
|
||||
// Die Berechnung übernimmt sie live aus der Vorphase, und im UI steht dafür sichtbar
|
||||
// «Aus Vorphase übernehmen» -- angehakt. Der Unterschied zeigt sich, sobald man die Vorphase
|
||||
// später ändert: Eine Kopie bliebe stehen, die Vererbung zieht mit.
|
||||
//
|
||||
// Start- bzw. Restwerte (PK/3a/Vermögen/Hypothek/Schuld) werden ohnehin live fortgeschrieben.
|
||||
function buildCarryData(category: string, prev: PhaseData): PhaseData {
|
||||
switch (category) {
|
||||
case "INCOME":
|
||||
case "EXPENSE":
|
||||
// Basis wird live indexiert fortgeschrieben; nur der Teuerungsausgleich wird übernommen.
|
||||
return prev.teuerungsausgleich != null ? { teuerungsausgleich: prev.teuerungsausgleich } : {};
|
||||
case "AHV":
|
||||
// Ausfalljahre sind KEIN Wiederkehr-Parameter: Sie gelten für genau diese Phase, und
|
||||
// ein geerbter Wert würde eine Lücke erfinden, die es nicht gibt.
|
||||
return { gapYears: 0 };
|
||||
case "PENSION_FUND":
|
||||
case "PILLAR_3A":
|
||||
case "OTHER_ASSET":
|
||||
return { annualContribution: num(prev.annualContribution), expectedReturn: num(prev.expectedReturn) };
|
||||
case "REAL_ESTATE":
|
||||
// purchasePrice + amortization bleiben; Resthypothek und Verkehrswert werden live
|
||||
// fortgeschrieben. Zinssatz, Zins-Behandlung und Wertsteigerung gelten weiter.
|
||||
// Der Kaufpreis ist eine Tatsache, keine Annahme (Basis der Grundstückgewinnsteuer);
|
||||
// die Zins-Behandlung ist ein Schalter ohne Zahlenwert -- beide werden weiterhin kopiert.
|
||||
return {
|
||||
purchasePrice: num(prev.purchasePrice),
|
||||
amortization: num(prev.amortization),
|
||||
interestRate: num(prev.interestRate),
|
||||
interestHandling: prev.interestHandling ?? "INCLUDED",
|
||||
valueGrowth: num(prev.valueGrowth),
|
||||
};
|
||||
case "OTHER_DEBT":
|
||||
return { annualRepayment: num(prev.annualRepayment) };
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getOwnedScenario, toPlanInput } from "@/lib/queries";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
import { touchScenario } from "@/lib/versioning-db";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { retirementBoundaries, shiftRetirement } from "@/lib/retirement";
|
||||
|
||||
// Pensionsalter anpassen (Roadmap Nr. 44).
|
||||
//
|
||||
// Das ist bewusst KEIN einfaches PATCH auf `retirementAge`: Eine Pensionierung liegt immer
|
||||
// auf einer Phasengrenze, also verschiebt jede Änderung zwei Phasendauern gleichzeitig --
|
||||
// und kann eine Phase ganz entfallen lassen. Die Entscheidung darüber trifft `lib/retirement`
|
||||
// (rein und getestet), hier wird sie nur noch geschrieben.
|
||||
|
||||
const bodySchema = z.object({
|
||||
role: z.enum(["PERSON_A", "PERSON_B"]),
|
||||
delta: z.number().int().min(-80).max(80),
|
||||
// Muss der Aufrufer ausdrücklich setzen, wenn dabei eine Lebensphase wegfällt.
|
||||
confirmMerge: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ scenarioId: string }> }) {
|
||||
const userId = await getCurrentUserId();
|
||||
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||
const { scenarioId } = await params;
|
||||
|
||||
const scenario = await getOwnedScenario(scenarioId, userId);
|
||||
if (!scenario) return NextResponse.json({ error: "Szenario nicht gefunden." }, { status: 404 });
|
||||
|
||||
const parsed = bodySchema.safeParse(await request.json().catch(() => ({})));
|
||||
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
|
||||
const { role, delta, confirmMerge } = parsed.data;
|
||||
|
||||
const planInput = toPlanInput(scenario);
|
||||
const boundary = retirementBoundaries(planInput).find((b) => b.role === role);
|
||||
if (!boundary) return NextResponse.json({ error: "Diese Person gibt es in diesem Szenario nicht." }, { status: 400 });
|
||||
if (boundary.blocked) return NextResponse.json({ error: boundary.blocked }, { status: 400 });
|
||||
|
||||
if (delta < (boundary.mergeDeltaDown ?? boundary.minDelta) || delta > (boundary.mergeDeltaUp ?? boundary.maxDelta)) {
|
||||
return NextResponse.json(
|
||||
{ error: "So weit lässt sich das Pensionsalter hier nicht verschieben. Passe zuerst die Lebensphasen an." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const result = shiftRetirement(planInput, role, delta);
|
||||
if (!result) return NextResponse.json({ error: "Die Verschiebung ist hier nicht möglich." }, { status: 400 });
|
||||
|
||||
// Eine Zusammenlegung verändert den Plan inhaltlich (zwei Übergänge werden einer) und muss
|
||||
// deshalb vom Aufrufer bestätigt sein. 409 = "verstanden, aber so nicht ohne Zustimmung".
|
||||
if (result.removedPhaseId && !confirmMerge) {
|
||||
const removed = planInput.phases.find((p) => p.id === result.removedPhaseId);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Bestätigung erforderlich.",
|
||||
needsMergeConfirmation: true,
|
||||
removedPhaseId: result.removedPhaseId,
|
||||
removedPhaseName: removed?.name ?? "",
|
||||
mergedIntoPhaseId: result.mergedIntoPhaseId,
|
||||
},
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.person.updateMany({
|
||||
where: { scenarioId: scenario.id, role },
|
||||
data: { retirementAge: boundary.retirementAge + delta },
|
||||
});
|
||||
|
||||
if (result.removedPhaseId) {
|
||||
// Die Übergangsdaten der entfallenden Phase wurden in den Vorgänger gezogen; hier wird
|
||||
// nur noch der zusammengeführte Stand geschrieben, bevor die Phase verschwindet.
|
||||
if (result.mergedIntoPhaseId) {
|
||||
const target = result.plan.phases.find((p) => p.id === result.mergedIntoPhaseId);
|
||||
if (target) {
|
||||
await tx.phase.update({
|
||||
where: { id: target.id },
|
||||
data: { cashTransition: (target.cashTransition ?? {}) as Prisma.InputJsonValue },
|
||||
});
|
||||
}
|
||||
for (const e of result.plan.elements) {
|
||||
const td = e.transitionValues?.[result.mergedIntoPhaseId];
|
||||
if (!td) continue;
|
||||
await tx.elementTransitionValue.upsert({
|
||||
where: { elementId_fromPhaseId: { elementId: e.id, fromPhaseId: result.mergedIntoPhaseId } },
|
||||
create: { elementId: e.id, fromPhaseId: result.mergedIntoPhaseId, data: td as Prisma.InputJsonValue },
|
||||
update: { data: td as Prisma.InputJsonValue },
|
||||
});
|
||||
}
|
||||
}
|
||||
// Löscht die Phase samt ihrer Phasen- und Übergangswerte (Kaskade im Schema).
|
||||
await tx.phase.delete({ where: { id: result.removedPhaseId } });
|
||||
}
|
||||
|
||||
for (const p of result.plan.phases) {
|
||||
await tx.phase.update({
|
||||
where: { id: p.id },
|
||||
data: { durationYears: p.durationYears, sequenceNumber: p.sequenceNumber },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await touchScenario(scenario.id, userId);
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
removedPhaseId: result.removedPhaseId,
|
||||
retirementAge: boundary.retirementAge + delta,
|
||||
});
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState } from "react";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { FieldLabel, MoneyField, NumberField, SelectField, TextField } from "@/components/FormField";
|
||||
import { FieldLabel, InheritableField, MoneyField, NumberField, SelectField, TextField } from "@/components/FormField";
|
||||
import { formatChf } from "@/lib/format";
|
||||
import { api } from "@/lib/api-client";
|
||||
// Die reinen Uebergangs-Regeln liegen in lib/transitions.ts (auch serverseitig nutzbar) und
|
||||
@@ -24,8 +24,9 @@ import {
|
||||
type RateScope,
|
||||
} from "@/lib/ratefields";
|
||||
import { ahvAnnualPension, ahvMdje, type AhvCareer } from "@/lib/calculations";
|
||||
import { CATEGORY_LABELS, num } from "@/lib/elements";
|
||||
import { CATEGORY_LABELS, num, type InheritableKey } from "@/lib/elements";
|
||||
import {
|
||||
AHV_REFERENCE_AGE,
|
||||
DEFAULT_CAPITAL_TAX_RATE,
|
||||
DEFAULT_PK_CONVERSION_RATE,
|
||||
DEFAULT_PROPERTY_GAINS_TAX_RATE,
|
||||
@@ -59,6 +60,12 @@ export interface CellContext {
|
||||
laterPhaseCount: number;
|
||||
// AHV-Beitragskarriere des Element-Besitzers (für die Prüfung am Pensions-Übergang).
|
||||
ahvCareer: AhvCareer | null;
|
||||
// Werte, die in dieser Phase gelten würden, wenn das jeweilige Feld leer bleibt
|
||||
// (Roadmap Nr. 44, Punkt A). Leer in der ersten Phase.
|
||||
inheritedValues: Record<string, number>;
|
||||
// Alter des Element-Besitzers im ersten Jahr dieser Phase. Entscheidet bei der AHV, ob
|
||||
// das Referenzalter in diese Phase fällt (Kap. 4.4.6).
|
||||
ownerAgeStart: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -244,10 +251,18 @@ export function CashTransitionFields({
|
||||
ct,
|
||||
setC,
|
||||
deflatorEnd,
|
||||
// Roadmap Nr. 44, Punkt C: Nur am Pensions-Übergang gefragt -- dort kommt das Kapital
|
||||
// (PK, 3a, Immobilienverkauf) auf einmal herein und will verwendet werden.
|
||||
showCapitalUse = false,
|
||||
capitalInflow = 0,
|
||||
investTargets = [],
|
||||
}: {
|
||||
ct: CashTransitionData;
|
||||
setC: (patch: Partial<CashTransitionData>) => void;
|
||||
deflatorEnd: number; // Bestands-Deflator an der Phasengrenze
|
||||
showCapitalUse?: boolean;
|
||||
capitalInflow?: number;
|
||||
investTargets?: { id: string; name: string }[];
|
||||
}) {
|
||||
const mode = ct.mode ?? "NONE";
|
||||
const showIn = mode === "INFLOW" || mode === "BOTH";
|
||||
@@ -347,6 +362,75 @@ export function CashTransitionFields({
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{showCapitalUse && <CapitalUseFields ct={ct} setC={setC} inflow={capitalInflow} targets={investTargets} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Verwendung des Kapitalzuflusses am Pensions-Übergang (Roadmap Nr. 44, Punkt C).
|
||||
//
|
||||
// In PROZENT und nicht in Franken: Verschiebt man das Pensionsalter, ändert sich das bezogene
|
||||
// Kapital. Ein Frankenbetrag müsste dann von Hand nachgezogen werden -- und würde bis dahin
|
||||
// still eine falsche Aufteilung rechnen. Eine Quote skaliert mit.
|
||||
function CapitalUseFields({
|
||||
ct,
|
||||
setC,
|
||||
inflow,
|
||||
targets,
|
||||
}: {
|
||||
ct: CashTransitionData;
|
||||
setC: (patch: Partial<CashTransitionData>) => void;
|
||||
inflow: number;
|
||||
targets: { id: string; name: string }[];
|
||||
}) {
|
||||
const amort = Math.max(0, Math.min(100, num(ct.capitalUseAmortizationPct)));
|
||||
const invest = Math.max(0, Math.min(100 - amort, num(ct.capitalUseInvestPct)));
|
||||
const cash = Math.max(0, 100 - amort - invest);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="sm:col-span-2 rounded-lg bg-surface-2 px-3 py-2 text-xs text-muted">
|
||||
<strong className="text-fg">Verwendung des Kapitalzuflusses</strong> (PK-Kapital, Säule 3a,
|
||||
Verkaufserlös). Bei diesem Übergang fliessen{" "}
|
||||
<strong className="text-fg">{formatChf(inflow)}</strong> netto herein. Erfasst wird die
|
||||
Aufteilung in Prozent – so bleibt sie richtig, wenn du das Pensionsalter verschiebst und
|
||||
sich der Betrag dadurch ändert. Was nicht zugeteilt ist, bleibt Cash.
|
||||
</div>
|
||||
<NumberField
|
||||
label="… in die Hypothek (Amortisation, %)"
|
||||
help="Reduziert die Resthypothek der Immobilie einmalig. Am Restsaldo gekappt – ist die Hypothek kleiner, bleibt der Rest Cash."
|
||||
step={5}
|
||||
min={0}
|
||||
max={100}
|
||||
value={amort}
|
||||
onChange={(v) => setC({ capitalUseAmortizationPct: Math.max(0, Math.min(100, v)) })}
|
||||
/>
|
||||
<NumberField
|
||||
label="… in die Anlage (%)"
|
||||
help="Fliesst als Zusatzinvestition in das gewählte Vermögens-Element und wächst dort mit dessen Rendite weiter."
|
||||
step={5}
|
||||
min={0}
|
||||
max={100 - amort}
|
||||
value={invest}
|
||||
onChange={(v) => setC({ capitalUseInvestPct: Math.max(0, Math.min(100 - amort, v)) })}
|
||||
/>
|
||||
{invest > 0 && targets.length > 0 && (
|
||||
<div className="sm:col-span-2">
|
||||
<SelectField
|
||||
label="Ziel der Anlage-Quote"
|
||||
help="Welches Vermögens-Element wird aufgestockt?"
|
||||
value={ct.capitalUseTargetElementId ?? targets[0].id}
|
||||
onChange={(v: string) => setC({ capitalUseTargetElementId: v })}
|
||||
options={targets.map((t) => ({ value: t.id, label: t.name }))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<DerivedField
|
||||
label={`… bleibt Cash (${cash} %)`}
|
||||
value={Math.round((inflow * cash) / 100)}
|
||||
help="Der nicht zugeteilte Rest. Wird automatisch berechnet."
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -365,6 +449,35 @@ export function ElementPhaseFields({
|
||||
setP: (patch: Partial<PhaseData>) => void;
|
||||
}) {
|
||||
const carried = context.carried;
|
||||
|
||||
// Punkt A (Roadmap Nr. 44): Wiederkehr-Parameter werden aus der Vorphase übernommen,
|
||||
// solange das Feld leer bleibt. `inh` baut die Hülle mit dem sichtbaren Häkchen.
|
||||
const inh = (
|
||||
key: InheritableKey,
|
||||
label: string,
|
||||
render: (value: number, set: (v: number) => void) => React.ReactNode,
|
||||
format: (v: number) => string,
|
||||
help?: string
|
||||
) => {
|
||||
const own = pd[key];
|
||||
const fallback = context.inheritedValues[key] ?? 0;
|
||||
const isInheriting = carried && typeof own !== "number";
|
||||
return (
|
||||
<InheritableField
|
||||
label={label}
|
||||
help={help}
|
||||
canInherit={carried}
|
||||
isInheriting={isInheriting}
|
||||
inheritedDisplay={format(fallback)}
|
||||
onToggle={(inherit) => setP({ [key]: inherit ? undefined : fallback } as Partial<PhaseData>)}
|
||||
>
|
||||
{render(typeof own === "number" ? own : fallback, (v) => setP({ [key]: v } as Partial<PhaseData>))}
|
||||
</InheritableField>
|
||||
);
|
||||
};
|
||||
const asPct = (v: number) => `${v} %`;
|
||||
const asChf = (v: number) => formatChf(v);
|
||||
|
||||
switch (element.category) {
|
||||
case "INCOME":
|
||||
case "EXPENSE": {
|
||||
@@ -372,7 +485,6 @@ export function ElementPhaseFields({
|
||||
// Basiswert (erstes Jahr). Ab Phase 2 mit dem fortgeschriebenen Wert der Vorphase
|
||||
// vorbelegt, aber bewusst änderbar (Teilzeit, Beförderung, Jobwechsel …).
|
||||
const baseValue = typeof pd.amount === "number" ? pd.amount : carried ? context.derivedStart : 0;
|
||||
const rate = num(pd.teuerungsausgleich, 0);
|
||||
const d = context.deflatorStart || 1;
|
||||
// Info-Gegenwert im ersten Jahr: Einkommen -> real; Ausgaben -> nominal.
|
||||
const otherValue = isIncome ? Math.round(baseValue / d) : Math.round(baseValue * d);
|
||||
@@ -394,21 +506,36 @@ export function ElementPhaseFields({
|
||||
value={otherValue}
|
||||
help="Nur zur Info, wird automatisch berechnet."
|
||||
/>
|
||||
<NumberField
|
||||
label={isIncome ? "Nominale Lohnerhöhung (%/Jahr)" : "Reale Mehrausgaben (%/Jahr)"}
|
||||
help={
|
||||
isIncome
|
||||
? "Geschätzte jährliche Lohnerhöhung. 0% = Lohn bleibt nominal gleich und verliert real an Kaufkraft."
|
||||
: "Zusätzliche reale Mehrausgaben pro Jahr (z. B. weil man sich mehr gönnt). Die Inflation kommt separat automatisch dazu. 0% = gleicher Lebensstandard."
|
||||
}
|
||||
step={0.1}
|
||||
value={rate}
|
||||
onChange={(v) => setP({ teuerungsausgleich: v })}
|
||||
/>
|
||||
{inh(
|
||||
"teuerungsausgleich",
|
||||
isIncome ? "Nominale Lohnerhöhung (%/Jahr)" : "Reale Mehrausgaben (%/Jahr)",
|
||||
(v, set) => (
|
||||
<NumberField
|
||||
label={isIncome ? "Nominale Lohnerhöhung (%/Jahr)" : "Reale Mehrausgaben (%/Jahr)"}
|
||||
step={0.1}
|
||||
value={v}
|
||||
onChange={set}
|
||||
/>
|
||||
),
|
||||
asPct,
|
||||
isIncome
|
||||
? "Geschätzte jährliche Lohnerhöhung. 0% = Lohn bleibt nominal gleich und verliert real an Kaufkraft."
|
||||
: "Zusätzliche reale Mehrausgaben pro Jahr (z. B. weil man sich mehr gönnt). Die Inflation kommt separat automatisch dazu. 0% = gleicher Lebensstandard."
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
case "AHV":
|
||||
case "AHV": {
|
||||
// Seit Roadmap Nr. 44 beginnt die AHV-Rente IMMER mit dem Referenzalter -- unabhängig
|
||||
// davon, wann die Person aufhört zu arbeiten (Kap. 4.4.6). Daraus folgen zwei Fälle,
|
||||
// die es vorher nicht gab und die hier sichtbar gemacht werden müssen:
|
||||
// * Pensionierung VOR 65 -> beitragspflichtig als Nichterwerbstätige(r) bis 65.
|
||||
// * Erwerbstätig ÜBER 65 -> die Rente fliesst trotzdem ab 65.
|
||||
const ageStart = context.ownerAgeStart;
|
||||
const ageEnd = ageStart + context.durationYears - 1;
|
||||
const reachesReference = ageEnd >= AHV_REFERENCE_AGE;
|
||||
const startsBeforeReference = ageStart < AHV_REFERENCE_AGE;
|
||||
|
||||
if (!context.ownerWorking) {
|
||||
// Sonderfall: bei Planbeginn bereits pensioniert -> es gibt keinen Pensions-Übergang,
|
||||
// an dem die Karriere geprüft werden könnte. Dann hier erfassen (nur erste Phase).
|
||||
@@ -442,23 +569,61 @@ export function ElementPhaseFields({
|
||||
);
|
||||
}
|
||||
return (
|
||||
<p className="col-span-2 text-sm text-muted">
|
||||
Die AHV-Rente wird aus der beim Pensions-Übergang geprüften Beitragskarriere berechnet
|
||||
(massgebendes Durchschnittseinkommen und Ausfalljahre). Bei Ehepaaren greift die
|
||||
Plafonierung auf 150% der Maximalrente.
|
||||
</p>
|
||||
<>
|
||||
<p className="col-span-2 text-sm text-muted">
|
||||
Die AHV-Rente wird aus der beim Pensions-Übergang geprüften Beitragskarriere berechnet
|
||||
(massgebendes Durchschnittseinkommen und Ausfalljahre). Bei Ehepaaren greift die
|
||||
Plafonierung auf 150% der Maximalrente. Sie fliesst ab Alter {AHV_REFERENCE_AGE}
|
||||
{reachesReference && startsBeforeReference
|
||||
? ` – also erst im Verlauf dieser Lebensphase (Alter ${ageStart} bis ${ageEnd}).`
|
||||
: "."}
|
||||
</p>
|
||||
{startsBeforeReference && (
|
||||
<>
|
||||
<p className="col-span-2 rounded-lg border border-attention bg-attention-soft px-3 py-2 text-xs text-attention-soft-fg">
|
||||
<strong>Frühpensionierung:</strong> Wer vor Alter {AHV_REFERENCE_AGE} aufhört zu
|
||||
arbeiten, bleibt bis dahin AHV-beitragspflichtig – als Nichterwerbstätige(r). Der
|
||||
Beitrag ist eine laufende Ausgabe und fällt mit {AHV_REFERENCE_AGE} weg, wenn die
|
||||
Rente einsetzt.
|
||||
</p>
|
||||
<MoneyField
|
||||
label="AHV-Beitrag als Nichterwerbstätige(r) (CHF/Jahr)"
|
||||
help={
|
||||
"Der Beitrag bemisst sich am Vermögen und am Renteneinkommen, nicht am Lohn – die Bandbreite ist deshalb enorm: " +
|
||||
"vom Mindestbeitrag von rund 530 CHF pro Jahr bis zum Höchstbeitrag von rund 26'500 CHF pro Jahr. " +
|
||||
"Faustregel: Bis etwa 300'000 CHF Vermögen liegt man nahe am Minimum; ab rund 8.7 Mio. am Maximum. " +
|
||||
"Verheiratete zahlen den Beitrag je einzeln, wenn beide nicht erwerbstätig sind; ist der Partner erwerbstätig " +
|
||||
"und zahlt genug, entfällt er. Die AHV-Ausgleichskasse rechnet den Betrag auf Anfrage aus."
|
||||
}
|
||||
value={num(pd.ahvContribution)}
|
||||
onChange={(v) => setP({ ahvContribution: v })}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<NumberField
|
||||
label="Geplante Ausfalljahre"
|
||||
help="Jahre ohne AHV-Beiträge in dieser Phase. Jedes Ausfalljahr kürzt die spätere Rente um 1/44."
|
||||
value={num(pd.gapYears)}
|
||||
min={0}
|
||||
max={context.durationYears}
|
||||
onChange={(v) => setP({ gapYears: Math.max(0, Math.min(context.durationYears, Math.round(v))) })}
|
||||
/>
|
||||
<>
|
||||
<NumberField
|
||||
label="Geplante Ausfalljahre"
|
||||
help={`Jahre ohne AHV-Beiträge in dieser Phase. Jedes Ausfalljahr kürzt die spätere Rente um 1/44. Nur Jahre VOR dem Referenzalter ${AHV_REFERENCE_AGE} zählen.`}
|
||||
value={num(pd.gapYears)}
|
||||
min={0}
|
||||
max={context.durationYears}
|
||||
onChange={(v) => setP({ gapYears: Math.max(0, Math.min(context.durationYears, Math.round(v))) })}
|
||||
/>
|
||||
{reachesReference && (
|
||||
<p className="col-span-2 rounded-lg border border-attention bg-attention-soft px-3 py-2 text-xs text-attention-soft-fg">
|
||||
<strong>Weiterarbeiten über {AHV_REFERENCE_AGE} hinaus:</strong> Die AHV-Rente
|
||||
beginnt trotzdem mit {AHV_REFERENCE_AGE} und fliesst neben dem Lohn. Ein Aufschub
|
||||
der Rente wird nicht abgebildet. Erfasse deshalb nur die Ausfalljahre bis zum
|
||||
Referenzalter.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
case "PENSION_FUND":
|
||||
if (!context.ownerWorking) {
|
||||
return (
|
||||
@@ -484,13 +649,19 @@ export function ElementPhaseFields({
|
||||
) : (
|
||||
<MoneyField label="Aktueller PK-Wert (CHF)" value={num(pd.currentValue)} onChange={(v) => setP({ currentValue: v })} />
|
||||
)}
|
||||
<MoneyField
|
||||
label="Jährliche Einzahlung (CHF)"
|
||||
help="Arbeitnehmer- und Arbeitgeberbeiträge. Fliesst NICHT in die Sparquote ein (bereits in den Ausgaben berücksichtigt)."
|
||||
value={num(pd.annualContribution)}
|
||||
onChange={(v) => setP({ annualContribution: v })}
|
||||
/>
|
||||
<NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={num(pd.expectedReturn)} onChange={(v) => setP({ expectedReturn: v })} />
|
||||
{inh(
|
||||
"annualContribution",
|
||||
"Jährliche Einzahlung (CHF)",
|
||||
(v, set) => <MoneyField label="Jährliche Einzahlung (CHF)" value={v} onChange={set} />,
|
||||
asChf,
|
||||
"Arbeitnehmer- und Arbeitgeberbeiträge. Fliesst NICHT in die Sparquote ein (bereits in den Ausgaben berücksichtigt)."
|
||||
)}
|
||||
{inh(
|
||||
"expectedReturn",
|
||||
"Erwartete Rendite (%/Jahr)",
|
||||
(v, set) => <NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={v} onChange={set} />,
|
||||
asPct
|
||||
)}
|
||||
</>
|
||||
);
|
||||
case "PILLAR_3A":
|
||||
@@ -513,14 +684,21 @@ export function ElementPhaseFields({
|
||||
) : (
|
||||
<MoneyField label="Aktueller 3a-Wert (CHF)" value={num(pd.currentValue)} onChange={(v) => setP({ currentValue: v })} />
|
||||
)}
|
||||
<MoneyField
|
||||
label="Jährliche Einzahlung (CHF)"
|
||||
help={`Maximal CHF ${PILLAR_3A_MAX_ANNUAL.toLocaleString("de-CH")} (2026, mit PK) und höchstens die Sparquote. Wird von der Sparquote abgezogen.`}
|
||||
value={num(pd.annualContribution)}
|
||||
max={PILLAR_3A_MAX_ANNUAL}
|
||||
onChange={(v) => setP({ annualContribution: v })}
|
||||
/>
|
||||
<NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={num(pd.expectedReturn)} onChange={(v) => setP({ expectedReturn: v })} />
|
||||
{inh(
|
||||
"annualContribution",
|
||||
"Jährliche Einzahlung (CHF)",
|
||||
(v, set) => (
|
||||
<MoneyField label="Jährliche Einzahlung (CHF)" value={v} max={PILLAR_3A_MAX_ANNUAL} onChange={set} />
|
||||
),
|
||||
asChf,
|
||||
`Maximal CHF ${PILLAR_3A_MAX_ANNUAL.toLocaleString("de-CH")} (2026, mit PK) und höchstens die Sparquote. Wird von der Sparquote abgezogen.`
|
||||
)}
|
||||
{inh(
|
||||
"expectedReturn",
|
||||
"Erwartete Rendite (%/Jahr)",
|
||||
(v, set) => <NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={v} onChange={set} />,
|
||||
asPct
|
||||
)}
|
||||
</>
|
||||
);
|
||||
case "REAL_ESTATE": {
|
||||
@@ -548,19 +726,20 @@ export function ElementPhaseFields({
|
||||
<MoneyField label="Hypothek (CHF)" value={num(pd.mortgage)} onChange={(v) => setP({ mortgage: v })} />
|
||||
</>
|
||||
)}
|
||||
<MoneyField
|
||||
label="Amortisation (CHF/Jahr)"
|
||||
help="Jährliche Reduktion der Hypothek. Zählt gegen die Sparquote und endet, sobald die Hypothek abbezahlt ist."
|
||||
value={num(pd.amortization)}
|
||||
onChange={(v) => setP({ amortization: v })}
|
||||
/>
|
||||
<NumberField
|
||||
label="Hypothekarzins (%/Jahr)"
|
||||
help="Zinssatz auf der Restschuld. Der Zinsbetrag sinkt automatisch mit der Amortisation."
|
||||
step={0.1}
|
||||
value={num(pd.interestRate)}
|
||||
onChange={(v) => setP({ interestRate: v })}
|
||||
/>
|
||||
{inh(
|
||||
"amortization",
|
||||
"Amortisation (CHF/Jahr)",
|
||||
(v, set) => <MoneyField label="Amortisation (CHF/Jahr)" value={v} onChange={set} />,
|
||||
asChf,
|
||||
"Jährliche Reduktion der Hypothek. Zählt gegen die Sparquote und endet, sobald die Hypothek abbezahlt ist."
|
||||
)}
|
||||
{inh(
|
||||
"interestRate",
|
||||
"Hypothekarzins (%/Jahr)",
|
||||
(v, set) => <NumberField label="Hypothekarzins (%/Jahr)" step={0.1} value={v} onChange={set} />,
|
||||
asPct,
|
||||
"Zinssatz auf der Restschuld. Der Zinsbetrag sinkt automatisch mit der Amortisation."
|
||||
)}
|
||||
<div>
|
||||
<FieldLabel
|
||||
label="Hypothekarzins-Betrag (Beginn → Ende)"
|
||||
@@ -570,13 +749,13 @@ export function ElementPhaseFields({
|
||||
{formatChf(zinsStart)} <span className="text-faint">→</span> {formatChf(zinsEnde)}
|
||||
</div>
|
||||
</div>
|
||||
<NumberField
|
||||
label="Geschätzte Wertsteigerung (%/Jahr)"
|
||||
help="Wirkt auf den Wert der LIEGENSCHAFT, nicht auf das Eigenkapital. 1% von 1 Mio sind 10'000 pro Jahr – bei 100'000 Eigenkapital also 10% darauf (Hebel)."
|
||||
step={0.1}
|
||||
value={num(pd.valueGrowth)}
|
||||
onChange={(v) => setP({ valueGrowth: v })}
|
||||
/>
|
||||
{inh(
|
||||
"valueGrowth",
|
||||
"Geschätzte Wertsteigerung (%/Jahr)",
|
||||
(v, set) => <NumberField label="Geschätzte Wertsteigerung (%/Jahr)" step={0.1} value={v} onChange={set} />,
|
||||
asPct,
|
||||
"Wirkt auf den Wert der LIEGENSCHAFT, nicht auf das Eigenkapital. 1% von 1 Mio sind 10'000 pro Jahr – bei 100'000 Eigenkapital also 10% darauf (Hebel)."
|
||||
)}
|
||||
<div className="sm:col-span-2">
|
||||
<SelectField
|
||||
label="Sind die Hypothekarzinsen bereits in den Ausgaben enthalten?"
|
||||
@@ -609,19 +788,26 @@ export function ElementPhaseFields({
|
||||
) : (
|
||||
<MoneyField label="Startwert (CHF)" value={num(pd.startValue)} onChange={(v) => setP({ startValue: v })} />
|
||||
)}
|
||||
<NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={num(pd.expectedReturn)} onChange={(v) => setP({ expectedReturn: v })} />
|
||||
<MoneyField
|
||||
label="Jährlicher Sparbeitrag (CHF)"
|
||||
help="Flacher Jahresbetrag, der ins Vermögen fliesst und vom Cash abgezogen wird (geplante Sparrate)."
|
||||
value={num(pd.annualContribution)}
|
||||
onChange={(v) => setP({ annualContribution: v })}
|
||||
/>
|
||||
<MoneyField
|
||||
label="Jährliche Bezugsrate (CHF)"
|
||||
help="Entnahme aus dem Vermögen (z. B. laufende Renten-Entnahme im Alter). Mindert das Vermögen und fliesst jährlich ins Cash (geplante Verzehrrate)."
|
||||
value={num(pd.annualWithdrawal)}
|
||||
onChange={(v) => setP({ annualWithdrawal: v })}
|
||||
/>
|
||||
{inh(
|
||||
"expectedReturn",
|
||||
"Erwartete Rendite (%/Jahr)",
|
||||
(v, set) => <NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={v} onChange={set} />,
|
||||
asPct
|
||||
)}
|
||||
{inh(
|
||||
"annualContribution",
|
||||
"Jährlicher Sparbeitrag (CHF)",
|
||||
(v, set) => <MoneyField label="Jährlicher Sparbeitrag (CHF)" value={v} onChange={set} />,
|
||||
asChf,
|
||||
"Flacher Jahresbetrag, der ins Vermögen fliesst und vom Cash abgezogen wird (geplante Sparrate)."
|
||||
)}
|
||||
{inh(
|
||||
"annualWithdrawal",
|
||||
"Jährliche Bezugsrate (CHF)",
|
||||
(v, set) => <MoneyField label="Jährliche Bezugsrate (CHF)" value={v} onChange={set} />,
|
||||
asChf,
|
||||
"Entnahme aus dem Vermögen (z. B. laufende Renten-Entnahme im Alter). Mindert das Vermögen und fliesst jährlich ins Cash (geplante Verzehrrate)."
|
||||
)}
|
||||
</>
|
||||
);
|
||||
case "OTHER_DEBT":
|
||||
@@ -632,13 +818,13 @@ export function ElementPhaseFields({
|
||||
) : (
|
||||
<MoneyField label="Restschuld (CHF)" value={num(pd.startValue)} onChange={(v) => setP({ startValue: v })} />
|
||||
)}
|
||||
<MoneyField
|
||||
label="Jährliche Tilgung (CHF)"
|
||||
help="Zählt gegen die Sparquote (max. die Sparquote)."
|
||||
value={num(pd.annualRepayment)}
|
||||
|
||||
onChange={(v) => setP({ annualRepayment: v })}
|
||||
/>
|
||||
{inh(
|
||||
"annualRepayment",
|
||||
"Jährliche Tilgung (CHF)",
|
||||
(v, set) => <MoneyField label="Jährliche Tilgung (CHF)" value={v} onChange={set} />,
|
||||
asChf,
|
||||
"Zählt gegen die Sparquote (max. die Sparquote)."
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -320,3 +320,54 @@ export function SelectField<T extends string>({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- "Aus Vorphase uebernehmen" (Roadmap Nr. 44, Punkt A) --------------------------------
|
||||
//
|
||||
// Wiederkehr-Parameter (Raten, Beitraege, Amortisation) gelten weiter, bis man sie bewusst
|
||||
// aendert. Bisher war das unsichtbar: Beim Anlegen einer Phase wurde der Wert KOPIERT, und
|
||||
// spaetere Aenderungen an der Vorphase kamen deshalb nie an. Jetzt entscheidet ein sichtbares
|
||||
// Haekchen: angehakt = Feld leer lassen und live erben; abgehakt = eigener Wert fuer diese
|
||||
// Phase. In der ersten Phase gibt es nichts zu erben -- dort erscheint das Haekchen nicht.
|
||||
export function InheritableField({
|
||||
label,
|
||||
help,
|
||||
canInherit,
|
||||
isInheriting,
|
||||
inheritedDisplay,
|
||||
onToggle,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
help?: string;
|
||||
canInherit: boolean;
|
||||
isInheriting: boolean;
|
||||
inheritedDisplay: string;
|
||||
onToggle: (inherit: boolean) => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
{isInheriting ? (
|
||||
<>
|
||||
<FieldLabel label={label} help={help} />
|
||||
<div className="w-full rounded-lg border border-dashed border-border bg-surface-2 px-2.5 py-1.5 text-sm text-muted">
|
||||
{inheritedDisplay} <span className="text-xs text-faint">(aus der Vorphase)</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
{canInherit && (
|
||||
<label className="mt-1 flex cursor-pointer items-center gap-1.5 text-xs text-muted">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isInheriting}
|
||||
onChange={(e) => onToggle(e.target.checked)}
|
||||
className="h-3.5 w-3.5 accent-accent"
|
||||
/>
|
||||
Aus Vorphase übernehmen
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { NumberField, SelectField, TextField } from "@/components/FormField";
|
||||
import { FieldLabel, NumberField, SelectField, TextField } from "@/components/FormField";
|
||||
import type { HouseholdType, PersonRole } from "@/lib/types";
|
||||
|
||||
export interface ProfileDraft {
|
||||
@@ -24,9 +24,14 @@ export function emptyProfileDraft(): ProfileDraft {
|
||||
export function PlanProfileFields({
|
||||
draft,
|
||||
onChange,
|
||||
// Sobald Lebensphasen bestehen, liegt jede Pensionierung auf einer Phasengrenze. Ein frei
|
||||
// änderbares Alter würde diese Grenze zerreissen -- deshalb übernimmt dort die eigene
|
||||
// Bedienung «Pensionsalter anpassen» (Roadmap Nr. 44), und das Feld ist nur noch Anzeige.
|
||||
lockRetirement = false,
|
||||
}: {
|
||||
draft: ProfileDraft;
|
||||
onChange: (next: ProfileDraft) => void;
|
||||
lockRetirement?: boolean;
|
||||
}) {
|
||||
function setType(type: HouseholdType) {
|
||||
if (type === "SINGLE") {
|
||||
@@ -89,14 +94,26 @@ export function PlanProfileFields({
|
||||
max={120}
|
||||
onChange={(v) => updatePerson(index, { age: Math.round(v) })}
|
||||
/>
|
||||
<NumberField
|
||||
label="Pensionierungsalter (nur dieses Szenario)"
|
||||
help="Steuert die Ableitung des Phasentyps (Erwerb/Pension). Als einzige Personenangabe szenario-eigen -- das ist der Kern jedes Früh-/Spätpensionierungs-Szenarios."
|
||||
value={person.retirementAge}
|
||||
min={30}
|
||||
max={100}
|
||||
onChange={(v) => updatePerson(index, { retirementAge: Math.round(v) })}
|
||||
/>
|
||||
{lockRetirement ? (
|
||||
<div>
|
||||
<FieldLabel
|
||||
label="Pensionierungsalter"
|
||||
help="Liegt immer auf einer Phasengrenze und wird deshalb unten unter «Pensionsalter anpassen» verschoben -- dabei wandern die angrenzenden Phasendauern mit."
|
||||
/>
|
||||
<div className="mt-1 rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-muted">
|
||||
{person.retirementAge} Jahre
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<NumberField
|
||||
label="Pensionierungsalter (nur dieses Szenario)"
|
||||
help="Steuert die Ableitung des Phasentyps (Erwerb/Pension). Als einzige Personenangabe szenario-eigen -- das ist der Kern jedes Früh-/Spätpensionierungs-Szenarios."
|
||||
value={person.retirementAge}
|
||||
min={30}
|
||||
max={100}
|
||||
onChange={(v) => updatePerson(index, { retirementAge: Math.round(v) })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
} from "@/components/ElementDetail";
|
||||
import { PhaseDetail } from "@/components/PhaseDetail";
|
||||
import { PlanProfileFields, type ProfileDraft } from "@/components/PlanProfileFields";
|
||||
import { RetirementAdjuster } from "@/components/RetirementAdjuster";
|
||||
import { MoneyField } from "@/components/FormField";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { formatChf } from "@/lib/format";
|
||||
@@ -58,6 +59,7 @@ import {
|
||||
CATEGORY_LABELS,
|
||||
CATEGORY_ORDER,
|
||||
PERSON_ONLY_CATEGORIES,
|
||||
inheritedPhaseValues,
|
||||
num,
|
||||
type CashTransitionData,
|
||||
type ElementCategory,
|
||||
@@ -256,6 +258,16 @@ export function PlanView({
|
||||
return computed.phases.length - phase.sequenceNumber;
|
||||
}
|
||||
|
||||
// Phasen in ihrer Reihenfolge -- Grundlage der Vererbung (Roadmap Nr. 44, Punkt A).
|
||||
const orderedPhaseIds = computed.phases.map((p) => p.id);
|
||||
|
||||
// Alter des Element-Besitzers im ersten Jahr einer Phase. Ohne Personenzuordnung gilt
|
||||
// Person A, weil die Zeitachse ohnehin an ihr hängt.
|
||||
function ownerAgeAtPhaseStart(phase: PhaseComputed, element: ElementInput): number {
|
||||
const role = element.ownerRole && element.ownerRole !== "HOUSEHOLD" ? element.ownerRole : "PERSON_A";
|
||||
return phase.persons.find((p) => p.role === role)?.startAge ?? 0;
|
||||
}
|
||||
|
||||
// Baut den Kontext für eine Phasenzelle.
|
||||
function buildPhaseContext(phase: PhaseComputed, element: ElementInput): CellContext {
|
||||
const ce = computedElement(phase.id, element.id);
|
||||
@@ -280,6 +292,8 @@ export function PlanView({
|
||||
deflatorStart: phase.cumulativeInflationStart,
|
||||
laterPhaseCount: laterPhaseCount(phase),
|
||||
ahvCareer: careerFor(element),
|
||||
inheritedValues: inheritedPhaseValues(element.phaseValues, orderedPhaseIds, phase.id),
|
||||
ownerAgeStart: ownerAgeAtPhaseStart(phase, element),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -302,6 +316,8 @@ export function PlanView({
|
||||
deflatorStart: fromPhase.cumulativeInflationStart,
|
||||
laterPhaseCount: laterPhaseCount(fromPhase),
|
||||
ahvCareer: careerFor(element),
|
||||
inheritedValues: {},
|
||||
ownerAgeStart: ownerAgeAtPhaseStart(fromPhase, element),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1468,6 +1484,9 @@ function AddElementDialog({
|
||||
deflatorStart: firstPhase.cumulativeInflationStart,
|
||||
laterPhaseCount: 0, // beim Anlegen bewusst kein Warnhinweis
|
||||
ahvCareer: null,
|
||||
// Beim Anlegen gibt es keine Vorphase, aus der etwas zu erben waere.
|
||||
inheritedValues: {},
|
||||
ownerAgeStart: firstPhase.persons.find((p) => p.role === (owner ?? "PERSON_A"))?.startAge ?? 0,
|
||||
};
|
||||
|
||||
async function create() {
|
||||
@@ -1615,6 +1634,14 @@ function AddPhaseDialog({
|
||||
);
|
||||
}
|
||||
|
||||
// Ziele für die Anlage-Quote der Kapitalverwendung (Roadmap Nr. 44, Punkt C): alle
|
||||
// Vermögens-Elemente, die in der Folgephase noch aktiv sind.
|
||||
function investTargetsOf(toPhase: PhaseComputed | undefined): { id: string; name: string }[] {
|
||||
return (toPhase?.elements ?? [])
|
||||
.filter((e) => e.category === "OTHER_ASSET" && e.status === "ACTIVE")
|
||||
.map((e) => ({ id: e.elementId, name: e.name }));
|
||||
}
|
||||
|
||||
// --- Panel: Szenario-Profil (Grundprofil bearbeiten) ---
|
||||
function ProfilePanel({ plan, onClose, onSaved }: { plan: PlanInput; onClose: () => void; onSaved: () => void }) {
|
||||
const [draft, setDraft] = useState<ProfileDraft>({
|
||||
@@ -1642,12 +1669,21 @@ function ProfilePanel({ plan, onClose, onSaved }: { plan: PlanInput; onClose: ()
|
||||
return (
|
||||
<InspectorShell title="Szenario-Profil" subtitle="Haushalt, Personen, Planstart und Inflation dieses Szenarios" onClose={onClose}>
|
||||
<div className="flex flex-col gap-3">
|
||||
<PlanProfileFields draft={draft} onChange={setDraft} />
|
||||
<PlanProfileFields draft={draft} onChange={setDraft} lockRetirement={plan.phases.length > 0} />
|
||||
{error && <p className="text-sm text-danger">{error}</p>}
|
||||
<div className="flex gap-2 pt-1">
|
||||
<Button disabled={saving} onClick={save}>{saving ? "…" : "Speichern"}</Button>
|
||||
<Button variant="secondary" onClick={onClose}>Abbrechen</Button>
|
||||
</div>
|
||||
|
||||
{plan.phases.length > 0 && (
|
||||
<div className="mt-3 border-t border-border pt-3">
|
||||
<h3 className="mb-2 text-sm font-semibold text-fg">Pensionsalter anpassen</h3>
|
||||
{/* Wirkt SOFORT (eigener Endpunkt), weil dabei Phasendauern verschoben werden --
|
||||
das lässt sich nicht sinnvoll mit dem Profil-Formular zusammen abspeichern. */}
|
||||
<RetirementAdjuster plan={plan} onSaved={onSaved} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</InspectorShell>
|
||||
);
|
||||
@@ -1723,6 +1759,9 @@ function TransitionReviewDialog({
|
||||
ct={ct}
|
||||
setC={(patch) => setCt((prev) => ({ ...prev, ...patch }))}
|
||||
deflatorEnd={fromPhase.cumulativeInflationEnd}
|
||||
showCapitalUse={(toPhase?.capitalInflow ?? 0) > 0}
|
||||
capitalInflow={toPhase?.capitalInflow ?? 0}
|
||||
investTargets={investTargetsOf(toPhase)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1814,6 +1853,9 @@ function CashTransitionPanel({
|
||||
ct={ct}
|
||||
setC={(patch) => setCt((prev) => ({ ...prev, ...patch }))}
|
||||
deflatorEnd={fromPhase.cumulativeInflationEnd}
|
||||
showCapitalUse={(toPhase?.capitalInflow ?? 0) > 0}
|
||||
capitalInflow={toPhase?.capitalInflow ?? 0}
|
||||
investTargets={investTargetsOf(toPhase)}
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="mt-3 text-sm text-danger">{error}</p>}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { limitsText, retirementBoundaries, shiftRetirement, sortedPhases } from "@/lib/retirement";
|
||||
import { Button, useConfirm } from "@/components/ui";
|
||||
import type { PlanInput } from "@/lib/types";
|
||||
|
||||
// Pensionsalter anpassen (Roadmap Nr. 44).
|
||||
//
|
||||
// Warum eine eigene Bedienung statt eines Zahlenfeldes im Profil: Eine Pensionierung liegt
|
||||
// immer auf einer Phasengrenze. Ein frei änderbares Alter würde diese Grenze zerreissen --
|
||||
// die Phasen blieben, wo sie sind, und der Phasentyp (Erwerb/Pension) käme mitten in einer
|
||||
// Phase ins Rutschen. Hier wird stattdessen die GRENZE verschoben: Die Phase davor wird
|
||||
// länger, die danach kürzer, die Gesamtdauer des Plans bleibt gleich.
|
||||
|
||||
function personLabel(plan: PlanInput, role: string): string {
|
||||
const p = plan.persons.find((x) => x.role === role);
|
||||
if (p?.name) return p.name;
|
||||
if (plan.householdType === "SINGLE") return "Du";
|
||||
return role === "PERSON_A" ? "Person A" : "Person B";
|
||||
}
|
||||
|
||||
export function RetirementAdjuster({ plan, onSaved }: { plan: PlanInput; onSaved: () => void }) {
|
||||
const confirm = useConfirm();
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const boundaries = useMemo(() => retirementBoundaries(plan), [plan]);
|
||||
|
||||
async function shift(role: "PERSON_A" | "PERSON_B", delta: number) {
|
||||
setError(null);
|
||||
const preview = shiftRetirement(plan, role, delta);
|
||||
if (!preview) {
|
||||
setError("So weit lässt sich das Pensionsalter hier nicht verschieben.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Zusammenlegung: Die entfallende Phase nimmt einen ganzen Übergang mit. Das muss VORHER
|
||||
// klar sein -- nachher liesse es sich nur durch erneutes Erfassen rückgängig machen.
|
||||
if (preview.removedPhaseId) {
|
||||
const removed = sortedPhases(plan).find((p) => p.id === preview.removedPhaseId);
|
||||
const target = sortedPhases(plan).find((p) => p.id === preview.mergedIntoPhaseId);
|
||||
const ok = await confirm({
|
||||
title: "Lebensphase fällt weg",
|
||||
message:
|
||||
`Bei dieser Verschiebung entfällt die Lebensphase «${removed?.name ?? ""}» vollständig. ` +
|
||||
(target
|
||||
? `Ihre Übergangs-Entscheide werden in den Übergang nach «${target.name}» gezogen: Bereits ` +
|
||||
`getroffene Entscheide bleiben, leere Felder werden von dort ergänzt, und einmalige ` +
|
||||
`Cash-Beträge werden addiert. `
|
||||
: "") +
|
||||
"Fortfahren?",
|
||||
confirmLabel: "Phase zusammenlegen",
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
}
|
||||
|
||||
setBusy(role);
|
||||
try {
|
||||
await api.post(`/api/scenarios/${plan.id}/retirement`, { role, delta, confirmMerge: true });
|
||||
onSaved();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Anpassung fehlgeschlagen.");
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{boundaries.map((b) => {
|
||||
const label = personLabel(plan, b.role);
|
||||
const canMergeDown = b.mergeDeltaDown !== null;
|
||||
const canMergeUp = b.mergeDeltaUp !== null;
|
||||
return (
|
||||
<div key={b.role} className="rounded-xl border border-border bg-surface-2 p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-faint">{label}</div>
|
||||
<div className="text-lg font-semibold text-fg">
|
||||
Pension mit {b.retirementAge}
|
||||
{b.blocked ? "" : <span className="ml-2 text-xs font-normal text-muted">Planjahr {b.planYear}</span>}
|
||||
</div>
|
||||
</div>
|
||||
{!b.blocked && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={busy !== null || (b.minDelta === 0 && !canMergeDown)}
|
||||
onClick={() => shift(b.role as "PERSON_A" | "PERSON_B", -1)}
|
||||
>
|
||||
−1 Jahr
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={busy !== null || (b.maxDelta === 0 && !canMergeUp)}
|
||||
onClick={() => shift(b.role as "PERSON_A" | "PERSON_B", 1)}
|
||||
>
|
||||
+1 Jahr
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Hilfebox: Was ist möglich -- und warum nicht mehr. Ohne diese Erklärung wirkt
|
||||
ein deaktivierter Knopf wie ein Fehler. */}
|
||||
<p className="mt-2 rounded-lg border border-attention bg-attention-soft px-3 py-2 text-xs text-attention-soft-fg">
|
||||
{limitsText(b, label)}
|
||||
</p>
|
||||
|
||||
{!b.blocked && (b.minDelta < -1 || b.maxDelta > 1) && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{[-5, -3, -2, 2, 3, 5]
|
||||
.filter((d) => d >= (b.mergeDeltaDown ?? b.minDelta) && d <= (b.mergeDeltaUp ?? b.maxDelta))
|
||||
.map((d) => (
|
||||
<button
|
||||
key={d}
|
||||
type="button"
|
||||
disabled={busy !== null}
|
||||
onClick={() => shift(b.role as "PERSON_A" | "PERSON_B", d)}
|
||||
className="rounded-full border border-border px-2 py-0.5 text-xs text-muted hover:border-accent hover:text-fg disabled:opacity-50"
|
||||
>
|
||||
{d > 0 ? "+" : ""}
|
||||
{d} → {b.retirementAge + d}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{error && <p className="text-sm text-danger">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -295,3 +295,50 @@ describe("rebaseFlow", () => {
|
||||
expect(rebaseFlow(1000, -100, 3)).toBe(1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Effektive Flusswerte wirken in die Folgephase (Roadmap Nr. 44)", () => {
|
||||
// Zwei Phasen a 5 Jahre, Lohn 100'000 ohne Teuerung. Im Planjahr 3 wird der Lohn
|
||||
// effektiv mit 200'000 erfasst -- das muss auch in Phase 2 ankommen, sonst faellt der
|
||||
// Wert am Phasenwechsel stillschweigend auf den geplanten zurueck.
|
||||
function lohnPlan(): PlanInput {
|
||||
return {
|
||||
id: "s",
|
||||
name: "T",
|
||||
householdType: "SINGLE",
|
||||
inflationRateDefault: 0,
|
||||
initialCash: 0,
|
||||
startYear: 2020,
|
||||
persons: [{ id: "A", role: "PERSON_A", name: null, age: 40, retirementAge: 65 }],
|
||||
phases: [
|
||||
{ id: "p1", sequenceNumber: 1, name: "E1", durationYears: 5, cashTransition: {} },
|
||||
{ id: "p2", sequenceNumber: 2, name: "E2", durationYears: 5, cashTransition: {} },
|
||||
],
|
||||
elements: [
|
||||
{
|
||||
id: "lohn",
|
||||
category: "INCOME",
|
||||
name: "Lohn",
|
||||
ownerRole: "HOUSEHOLD",
|
||||
orderIndex: 1,
|
||||
phaseValues: { p1: { amount: 100000, teuerungsausgleich: 0 }, p2: {} },
|
||||
transitionValues: {},
|
||||
sourceElementId: null,
|
||||
},
|
||||
],
|
||||
} as unknown as PlanInput;
|
||||
}
|
||||
|
||||
it("traegt den effektiven Wert ueber die Phasengrenze", () => {
|
||||
const p = lohnPlan();
|
||||
const actuals = resolveActuals([setAt(2022, { lohn: { value: 200000 } })], p, p.elements);
|
||||
const computed = computePlan(p, undefined, { actuals });
|
||||
expect(valueAt(computed, "lohn", 3)).toBe(200000);
|
||||
// Phase 2 beginnt im Planjahr 6 -- ohne den Fix stuende hier wieder 100'000.
|
||||
expect(valueAt(computed, "lohn", 6)).toBe(200000);
|
||||
});
|
||||
|
||||
it("bleibt ohne Ist-Werte auf der Planlinie", () => {
|
||||
const computed = computePlan(lohnPlan());
|
||||
expect(valueAt(computed, "lohn", 6)).toBe(100000);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -220,6 +220,38 @@ const konstellationen: { name: string; build: () => PlanInput }[] = [
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
// Roadmap Nr. 44, Punkt C: Das PK-Kapital wird nicht als Cash liegen gelassen, sondern
|
||||
// per Quote auf Hypothek und Anlagen verteilt. Beide Brücken müssen weiterhin aufgehen.
|
||||
name: "Kapitalverwendung am Pensions-Übergang (Quoten)",
|
||||
build: () =>
|
||||
plan({
|
||||
age: 60,
|
||||
retirementAge: 65,
|
||||
initialCash: 50000,
|
||||
phases: [
|
||||
{ id: "p1", durationYears: 5, cashTransition: { capitalUseAmortizationPct: 20, capitalUseInvestPct: 70 } },
|
||||
{ id: "p2", durationYears: 10 },
|
||||
],
|
||||
elements: [
|
||||
el("INCOME", "PERSON_A", { p1: { amount: 120000 } }),
|
||||
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 80000 }, p2: { amount: 80000 } }),
|
||||
el(
|
||||
"PENSION_FUND",
|
||||
"PERSON_A",
|
||||
{ p1: { currentValue: 600000, annualContribution: 20000, expectedReturn: 2 }, p2: {} },
|
||||
{ p1: { payoutMode: "CAPITAL", capitalTaxRate: 5 } }
|
||||
),
|
||||
el(
|
||||
"REAL_ESTATE",
|
||||
"HOUSEHOLD",
|
||||
{ p1: { purchasePrice: 900000, mortgage: 600000, amortization: 5000, valueGrowth: 1 }, p2: {} },
|
||||
{ p1: { decision: "HOLD" } }
|
||||
),
|
||||
el("OTHER_ASSET", "HOUSEHOLD", { p1: { startValue: 50000, expectedReturn: 4 }, p2: {} }),
|
||||
],
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
describe("Wasserfall-Brücken", () => {
|
||||
@@ -272,3 +304,44 @@ describe("Wasserfall-Brücken", () => {
|
||||
expect(verkauft.saleGainLoss).not.toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Kapitalverwendung am Pensions-Uebergang (Roadmap Nr. 44, Punkt C)", () => {
|
||||
const withQuoten = konstellationen.find((k) => k.name.startsWith("Kapitalverwendung"))!;
|
||||
|
||||
it("leitet den Zufluss gemaess Quote in Hypothek und Anlage statt ins Cash", () => {
|
||||
const mit = computePlan(withQuoten.build());
|
||||
const ohne = computePlan({
|
||||
...withQuoten.build(),
|
||||
phases: withQuoten.build().phases.map((p) => ({ ...p, cashTransition: {} })),
|
||||
});
|
||||
const p2Mit = mit.phases[1];
|
||||
const p2Ohne = ohne.phases[1];
|
||||
|
||||
// 90 % des Kapitals werden verwendet -> deutlich weniger Cash zu Beginn von Phase 2.
|
||||
expect(p2Mit.cashStart).toBeLessThan(p2Ohne.cashStart);
|
||||
// Die Hypothek ist tiefer, das Vermoegen dadurch hoeher.
|
||||
const immoMit = p2Mit.elements.find((e) => e.category === "REAL_ESTATE")!;
|
||||
const immoOhne = p2Ohne.elements.find((e) => e.category === "REAL_ESTATE")!;
|
||||
expect(immoMit.mortgageStart).toBeLessThan(immoOhne.mortgageStart);
|
||||
// Das Sonstige Vermoegen startet hoeher.
|
||||
const assetMit = p2Mit.elements.find((e) => e.category === "OTHER_ASSET")!;
|
||||
const assetOhne = p2Ohne.elements.find((e) => e.category === "OTHER_ASSET")!;
|
||||
expect(assetMit.startValue).toBeGreaterThan(assetOhne.startValue);
|
||||
});
|
||||
|
||||
it("skaliert mit dem Pensionsalter, statt einen Frankenbetrag stehen zu lassen", () => {
|
||||
// Genau der Grund fuer Quoten statt Betraege: Ein Jahr laenger arbeiten heisst mehr
|
||||
// Kapital -- und damit automatisch mehr Amortisation und mehr Anlage.
|
||||
const base = withQuoten.build();
|
||||
const laenger: PlanInput = {
|
||||
...base,
|
||||
persons: base.persons.map((p) => ({ ...p, retirementAge: 66 })),
|
||||
phases: base.phases.map((p, i) =>
|
||||
i === 0 ? { ...p, durationYears: 6 } : { ...p, durationYears: 9 }
|
||||
),
|
||||
};
|
||||
const a = computePlan(base).phases[1].elements.find((e) => e.category === "REAL_ESTATE")!;
|
||||
const b = computePlan(laenger).phases[1].elements.find((e) => e.category === "REAL_ESTATE")!;
|
||||
expect(b.mortgageStart).toBeLessThan(a.mortgageStart);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -733,3 +733,161 @@ describe("V5 Golden Tests", () => {
|
||||
expect(r.phases[1].cashStart).toBe(r.phases[0].cashEnd);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Roadmap Nr. 44: Referenzalter, Beitragspflicht, Einkommen bei Pensionierung ---
|
||||
|
||||
describe("AHV-Referenzalter (Kap. 4.4.6)", () => {
|
||||
// Person wird mit 60 pensioniert, die AHV-Rente beginnt trotzdem erst mit 65.
|
||||
function fruehPlan(beitrag: number) {
|
||||
return plan({
|
||||
age: 55,
|
||||
retirementAge: 60,
|
||||
inflation: 0,
|
||||
initialCash: 500000,
|
||||
phases: [
|
||||
{ id: "p1", durationYears: 5 }, // Alter 55-60, erwerbstaetig
|
||||
{ id: "p2", durationYears: 15 }, // Alter 60-75, pensioniert
|
||||
],
|
||||
elements: [
|
||||
el("INCOME", "PERSON_A", { p1: { amount: 100000, teuerungsausgleich: 0 }, p2: {} }),
|
||||
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 60000, teuerungsausgleich: 0 }, p2: {} }),
|
||||
el(
|
||||
"AHV",
|
||||
"PERSON_A",
|
||||
{ p1: {}, p2: { ahvContribution: beitrag } },
|
||||
{ p1: { reviewed: true, avgIncomeBefore: 100000, gapYearsBefore: 0 } }
|
||||
),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
const ahvYearly = (p: PlanInput, phaseIdx: number) =>
|
||||
computePlan(p).phases[phaseIdx].elements.find((e) => e.category === "AHV")!.yearly.map((y) => y.value);
|
||||
|
||||
it("zahlt vor 65 den Beitrag und ab 65 die Rente", () => {
|
||||
const werte = ahvYearly(fruehPlan(2000), 1);
|
||||
// Phase 2 laeuft von Alter 60 bis 75: 5 Jahre Beitrag (60-64), dann 10 Jahre Rente.
|
||||
expect(werte.slice(0, 5)).toEqual([-2000, -2000, -2000, -2000, -2000]);
|
||||
expect(new Set(werte.slice(5))).toEqual(new Set([32760]));
|
||||
});
|
||||
|
||||
it("belastet den Beitrag als Kosten -- ohne Beitrag bleibt mehr Vermoegen", () => {
|
||||
const mit = computePlan(fruehPlan(20000));
|
||||
const ohne = computePlan(fruehPlan(0));
|
||||
const endeMit = mit.phases[1].endWealthNominal;
|
||||
const endeOhne = ohne.phases[1].endWealthNominal;
|
||||
expect(endeOhne).toBeGreaterThan(endeMit);
|
||||
// 5 Beitragsjahre x 20'000 = 100'000 Unterschied (zzgl. entgangener Rendite darauf).
|
||||
expect(endeOhne - endeMit).toBeGreaterThanOrEqual(100000);
|
||||
});
|
||||
|
||||
it("laesst die Rente auch waehrend der Erwerbstaetigkeit ab 65 fliessen", () => {
|
||||
// Pension erst mit 70 -- die Rente muss trotzdem ab 65 kommen.
|
||||
const p = plan({
|
||||
age: 60,
|
||||
retirementAge: 70,
|
||||
inflation: 0,
|
||||
phases: [
|
||||
{ id: "p1", durationYears: 10 }, // Alter 60-70, erwerbstaetig (deckt 65 ab)
|
||||
{ id: "p2", durationYears: 5 },
|
||||
],
|
||||
elements: [
|
||||
el("INCOME", "PERSON_A", { p1: { amount: 100000, teuerungsausgleich: 0 }, p2: {} }),
|
||||
el("AHV", "PERSON_A", { p1: {}, p2: {} }, { p1: { reviewed: true, avgIncomeBefore: 100000 } }),
|
||||
],
|
||||
});
|
||||
const werte = ahvYearly(p, 0);
|
||||
// Jahre 1-5 (Alter 60-64): nichts, danach die Rente.
|
||||
expect(werte.slice(0, 5)).toEqual([0, 0, 0, 0, 0]);
|
||||
expect(werte[5]).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("Pensionierung mit genau 65 bleibt unveraendert", () => {
|
||||
const p = plan({
|
||||
age: 60,
|
||||
retirementAge: 65,
|
||||
inflation: 0,
|
||||
phases: [
|
||||
{ id: "p1", durationYears: 5 },
|
||||
{ id: "p2", durationYears: 10 },
|
||||
],
|
||||
elements: [
|
||||
el("INCOME", "PERSON_A", { p1: { amount: 100000, teuerungsausgleich: 0 }, p2: {} }),
|
||||
el("AHV", "PERSON_A", { p1: {}, p2: {} }, { p1: { reviewed: true, avgIncomeBefore: 100000 } }),
|
||||
],
|
||||
});
|
||||
const werte = ahvYearly(p, 1);
|
||||
expect(werte).toHaveLength(10);
|
||||
expect(new Set(werte)).toEqual(new Set([32760]));
|
||||
});
|
||||
});
|
||||
|
||||
describe("Einkommen bei Pensionierung (Roadmap Nr. 44, Punkt B)", () => {
|
||||
function lohnPlan(p2: PhaseData, owner: "PERSON_A" | "HOUSEHOLD") {
|
||||
return plan({
|
||||
age: 60,
|
||||
retirementAge: 65,
|
||||
inflation: 0,
|
||||
household: "COUPLE", // sonst zaehlt "Gemeinsam" zur Person A
|
||||
phases: [
|
||||
{ id: "p1", durationYears: 5 },
|
||||
{ id: "p2", durationYears: 5 },
|
||||
],
|
||||
elements: [el("INCOME", owner, { p1: { amount: 100000, teuerungsausgleich: 0 }, p2 })],
|
||||
});
|
||||
}
|
||||
const lohn = (p: PlanInput) => computePlan(p).phases[1].elements[0].startValue;
|
||||
|
||||
it("faellt ein personenbezogener Lohn ohne eigenen Wert auf 0", () => {
|
||||
expect(lohn(lohnPlan({}, "PERSON_A"))).toBe(0);
|
||||
});
|
||||
|
||||
it("bleibt bestehen, wenn ausdruecklich ein Betrag erfasst ist (Teilzeit)", () => {
|
||||
expect(lohn(lohnPlan({ amount: 30000, teuerungsausgleich: 0 }, "PERSON_A"))).toBe(30000);
|
||||
});
|
||||
|
||||
it("laesst gemeinsame Einkommen unberuehrt", () => {
|
||||
expect(lohn(lohnPlan({}, "HOUSEHOLD"))).toBe(100000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Wiederkehr-Parameter aus der Vorphase (Roadmap Nr. 44, Punkt A)", () => {
|
||||
function assetPlan(p2: PhaseData) {
|
||||
return plan({
|
||||
age: 40,
|
||||
retirementAge: 65,
|
||||
inflation: 0,
|
||||
phases: [
|
||||
{ id: "p1", durationYears: 10 },
|
||||
{ id: "p2", durationYears: 10 },
|
||||
],
|
||||
elements: [el("OTHER_ASSET", "HOUSEHOLD", { p1: { startValue: 100000, expectedReturn: 5 }, p2 })],
|
||||
});
|
||||
}
|
||||
const endwert = (p: PlanInput) => computePlan(p).phases[1].elements[0].endValue;
|
||||
|
||||
it("uebernimmt eine nicht erfasste Rendite aus der Vorphase", () => {
|
||||
// Ohne Vererbung waere Phase 2 zinslos -- der Endwert bliebe auf dem Stand von Phase 1.
|
||||
// Rundung je Phase -> auf den Franken genau vergleichen waere zu streng.
|
||||
expect(endwert(assetPlan({}))).toBeCloseTo(100000 * Math.pow(1.05, 20), -1);
|
||||
});
|
||||
|
||||
it("laesst einen ausdruecklich erfassten Wert gewinnen", () => {
|
||||
expect(endwert(assetPlan({ expectedReturn: 0 }))).toBe(Math.round(100000 * Math.pow(1.05, 10)));
|
||||
});
|
||||
|
||||
it("traegt die Vererbung ueber mehrere Phasen", () => {
|
||||
const p = plan({
|
||||
age: 40,
|
||||
retirementAge: 75,
|
||||
inflation: 0,
|
||||
phases: [
|
||||
{ id: "p1", durationYears: 10 },
|
||||
{ id: "p2", durationYears: 10 },
|
||||
{ id: "p3", durationYears: 10 },
|
||||
],
|
||||
elements: [el("OTHER_ASSET", "HOUSEHOLD", { p1: { startValue: 100000, expectedReturn: 5 }, p2: {}, p3: {} })],
|
||||
});
|
||||
expect(computePlan(p).phases[2].elements[0].endValue).toBeCloseTo(100000 * Math.pow(1.05, 30), -1);
|
||||
});
|
||||
});
|
||||
|
||||
+164
-33
@@ -4,6 +4,7 @@ import {
|
||||
AHV_FULL_CONTRIBUTION_YEARS,
|
||||
AHV_GROSS_FROM_NET_FACTOR,
|
||||
AHV_MAX_ANNUAL_SINGLE,
|
||||
AHV_REFERENCE_AGE,
|
||||
AHV_MIN_MONTHLY_FULL,
|
||||
AHV_PENSION_MONTHS,
|
||||
DEFAULT_CAPITAL_TAX_RATE,
|
||||
@@ -292,6 +293,9 @@ interface Carry {
|
||||
owed: number; // Schulden: Restschuld (positiv)
|
||||
pkPensionAnnual: number; // PK: jährliche Rente nach Verrentung
|
||||
flowBasis: number; // Einkommen/Ausgaben: indexierter Basiswert der nächsten Phase
|
||||
// Punkt A (Roadmap Nr. 44): zuletzt verwendete Wiederkehr-Parameter (Raten, Beiträge,
|
||||
// Amortisation). Fehlt der Wert in einer Phase, gilt der aus der Vorphase.
|
||||
rates: Record<string, number>;
|
||||
hasCarry: boolean;
|
||||
}
|
||||
|
||||
@@ -305,6 +309,7 @@ function emptyCarry(): Carry {
|
||||
owed: 0,
|
||||
pkPensionAnnual: 0,
|
||||
flowBasis: 0,
|
||||
rates: {},
|
||||
hasCarry: false,
|
||||
};
|
||||
}
|
||||
@@ -448,12 +453,17 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
||||
});
|
||||
}
|
||||
|
||||
// AHV-Renten der Pensionierten: Vollrente zum mdJE, gekürzt um die Ausfalljahre.
|
||||
// AHV-Renten: Vollrente zum mdJE, gekürzt um die Ausfalljahre.
|
||||
//
|
||||
// Seit Roadmap Nr. 44 wird die Rente für JEDE Person mit AHV-Element gerechnet, nicht nur
|
||||
// für bereits pensionierte: Sie fliesst ab dem REFERENZALTER -- auch wenn jemand darüber
|
||||
// hinaus arbeitet. Ob sie in einem Jahr tatsächlich fliesst, entscheidet die Jahres-
|
||||
// schleife anhand des Alters (Kap. 4.4.6).
|
||||
const ahvUncapped = new Map<string, number>();
|
||||
for (const e of plan.elements) {
|
||||
if (e.category !== "AHV" || !e.ownerRole) continue;
|
||||
const owner = personByRole(persons, e.ownerRole);
|
||||
if (!owner || workingByPerson.get(owner.id)) continue;
|
||||
if (!owner) continue;
|
||||
const before = ahvBeforeByPerson.get(owner.id) ?? { avg: 0, gap: 0 };
|
||||
const career = buildCareer(owner, ahvIncomeAccum, ahvYearsAccum, gapYearsByPerson);
|
||||
const mdJE = ahvMdje(career, before.avg, before.gap);
|
||||
@@ -472,9 +482,19 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
||||
const ecById = new Map<string, ElementPhaseComputed>();
|
||||
// Reales Durchschnittseinkommen dieser Phase je Person (für die AHV-Karriere).
|
||||
const phaseRealIncomeByPerson = new Map<string, number>();
|
||||
const incomes: { basis: number; idx: number; ec: ElementPhaseComputed }[] = [];
|
||||
const expenses: { basis: number; idx: number; ec: ElementPhaseComputed }[] = [];
|
||||
let renteTotal = 0; // AHV + PK-Renten (nominal fix)
|
||||
const incomes: { basis: number; idx: number; ec: ElementPhaseComputed; carry: Carry }[] = [];
|
||||
const expenses: { basis: number; idx: number; ec: ElementPhaseComputed; carry: Carry }[] = [];
|
||||
let renteTotal = 0; // PK-Renten (nominal fix ueber die ganze Phase)
|
||||
// AHV je Person: Rente und Beitrag stehen fest, WANN sie greifen entscheidet das Alter
|
||||
// im jeweiligen Jahr -- deshalb eine eigene Liste statt eines Phasenbetrags.
|
||||
const ahvItems: {
|
||||
ownerId: string;
|
||||
ownerStartAge: number;
|
||||
rente: number;
|
||||
beitrag: number;
|
||||
working: boolean;
|
||||
ec: ElementPhaseComputed;
|
||||
}[] = [];
|
||||
// `isPk` für die Vermögens-Brücke: PK-Beiträge verlassen das Cash NICHT (sie sind im
|
||||
// Nettolohn bereits abgezogen), erhöhen aber das Vermögen -- sie sind also ein echter
|
||||
// Zugang und keine Umbuchung.
|
||||
@@ -512,6 +532,17 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
||||
const owner = e.ownerRole && e.ownerRole !== "HOUSEHOLD" ? personByRole(persons, e.ownerRole) : null;
|
||||
const ownerWorking = owner ? workingByPerson.get(owner.id) ?? false : anyWorking;
|
||||
|
||||
// Punkt A (Roadmap Nr. 44): Ein nicht erfasster Wiederkehr-Parameter wird aus der
|
||||
// Vorphase ÜBERNOMMEN, statt stillschweigend auf 0 zu fallen. Im UI ist das das
|
||||
// angehakte «Aus Vorphase übernehmen»; ein eigener Wert hakt es ab. Der jeweils
|
||||
// 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;
|
||||
carry.rates[key] = v;
|
||||
return v;
|
||||
};
|
||||
|
||||
const ec: ElementPhaseComputed = {
|
||||
elementId: e.id,
|
||||
category: e.category,
|
||||
@@ -549,14 +580,24 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
||||
// REAL (heutige Kaufkraft; reale Mehrausgaben) -- die Inflation kommt separat dazu.
|
||||
// Rate-Default = 0 %. Basiswert ab Phase 2 = fortgeschriebener Wert der Vorphase
|
||||
// (nominal für Einkommen, real für Ausgaben), ausser bewusst geändert (pd.amount).
|
||||
const idx = num(pd.teuerungsausgleich, 0);
|
||||
const idx = inherited("teuerungsausgleich");
|
||||
ec.baseValue = carry.hasCarry ? Math.round(carry.flowBasis) : Math.round(num(pd.amount));
|
||||
const basis = !carry.hasCarry
|
||||
let basis = !carry.hasCarry
|
||||
? Math.round(num(pd.amount))
|
||||
: typeof pd.amount === "number"
|
||||
? Math.round(pd.amount)
|
||||
: ec.baseValue;
|
||||
(e.category === "INCOME" ? incomes : expenses).push({ basis, idx, ec });
|
||||
// Ist die besitzende Person pensioniert, fällt ihr Erwerbseinkommen weg -- sonst liefe
|
||||
// der Lohn stillschweigend in die Pension weiter (Kap. 4.4.7). Ein ausdrücklich
|
||||
// erfasster Betrag gewinnt, damit ein Teilzeitpensum oder eine Erwerbsersatz-Zahlung
|
||||
// modellierbar bleibt. Gemeinsame Einkommen (Mieterträge o. Ä.) sind NICHT betroffen,
|
||||
// weil sie nicht an der Erwerbstätigkeit einer Person hängen.
|
||||
if (e.category === "INCOME" && owner && !ownerWorking && typeof pd.amount !== "number") {
|
||||
basis = 0;
|
||||
ec.baseValue = 0;
|
||||
ec.note = "Wegen Pensionierung auf 0 gesetzt. Für ein Teilzeitpensum trage hier einen Betrag ein.";
|
||||
}
|
||||
(e.category === "INCOME" ? incomes : expenses).push({ basis, idx, ec, carry });
|
||||
|
||||
// AHV: reales Erwerbseinkommen der Person mitführen. Nur Einkommen, die einer
|
||||
// Person zugeordnet sind -- bei einem Einzelplan zählt "Gemeinsam" zur Person A.
|
||||
@@ -576,20 +617,41 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
||||
}
|
||||
}
|
||||
|
||||
// Basiswert der Folgephase fortschreiben (nominal für Einkommen, real für Ausgaben).
|
||||
carry.flowBasis = basis * Math.pow(1 + idx / 100, duration);
|
||||
// Die Fortschreibung in die Folgephase passiert NACH der Jahresschleife: Dort kann ein
|
||||
// effektiver Wert `basis` noch neu setzen (`rebaseFlow`), und genau der soll weiter-
|
||||
// getragen werden -- nicht der ursprünglich geplante.
|
||||
break;
|
||||
}
|
||||
case "AHV": {
|
||||
if (owner && !ownerWorking) {
|
||||
// Die AHV wird JAHRESWEISE gerechnet (Kap. 4.4.6): Bis zum Referenzalter zahlt eine
|
||||
// frühpensionierte Person Beiträge, ab dem Referenzalter fliesst die Rente -- beides
|
||||
// kann INNERHALB derselben Phase kippen, deshalb nicht über `renteTotal`.
|
||||
if (owner) {
|
||||
const rente = ahvFinal.get(owner.id) ?? 0;
|
||||
renteTotal += rente;
|
||||
ec.startValue = rente;
|
||||
ec.endValue = rente;
|
||||
ec.summary = `Rente ${fmt(rente)}`;
|
||||
} else {
|
||||
const gap = Math.max(0, Math.round(num(pd.gapYears)));
|
||||
ec.summary = gap > 0 ? `${gap} Ausfalljahre` : "Keine Ausfalljahre";
|
||||
const beitrag = Math.round(num(pd.ahvContribution));
|
||||
const ageStart = owner.age + yearsBefore; // Alter im ersten Jahr der Phase
|
||||
const ageEnd = ageStart + duration - 1; // Alter im letzten Jahr der Phase
|
||||
ahvItems.push({ ownerId: owner.id, ownerStartAge: ageStart, rente, beitrag, working: ownerWorking, ec });
|
||||
|
||||
const reachesRef = ageEnd >= AHV_REFERENCE_AGE;
|
||||
const startsRetired = ageStart >= AHV_REFERENCE_AGE;
|
||||
ec.startValue = startsRetired ? rente : 0;
|
||||
ec.endValue = reachesRef ? rente : 0;
|
||||
|
||||
if (startsRetired) {
|
||||
ec.summary = `Rente ${fmt(rente)}`;
|
||||
} else if (reachesRef && !ownerWorking) {
|
||||
ec.summary = `Beitrag ${fmt(beitrag)} → Rente ${fmt(rente)}`;
|
||||
ec.note = `Rente ab Alter ${AHV_REFERENCE_AGE}; bis dahin Beitrag als Nichterwerbstätige(r).`;
|
||||
} else if (reachesRef) {
|
||||
ec.summary = `Rente ab ${AHV_REFERENCE_AGE} ${fmt(rente)}`;
|
||||
} else if (!ownerWorking) {
|
||||
ec.summary = `Beitrag ${fmt(beitrag)}`;
|
||||
ec.note = `Frühpensioniert: beitragspflichtig bis Alter ${AHV_REFERENCE_AGE}.`;
|
||||
} else {
|
||||
const gap = Math.max(0, Math.round(num(pd.gapYears)));
|
||||
ec.summary = gap > 0 ? `${gap} Ausfalljahre` : "Keine Ausfalljahre";
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -606,12 +668,12 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
||||
const base = carry.hasCarry ? carry.value : Math.round(num(pd.currentValue));
|
||||
const topUp = carry.hasCarry ? Math.round(num(pd.additionalInvestment)) : 0;
|
||||
const start = base + topUp;
|
||||
const rate = Math.round(num(pd.annualContribution)); // PK-Beitrag zählt NICHT zur Quote
|
||||
const rate = Math.round(inherited("annualContribution")); // PK-Beitrag zählt NICHT zur Quote
|
||||
if (!isFirstPhase) investmentsFromCash += topUp;
|
||||
ec.baseValue = base;
|
||||
ec.startValue = start;
|
||||
wealthStart += start;
|
||||
assets.push({ value: start, rate, r: num(pd.expectedReturn), withdrawal: 0, isPk: true, ec });
|
||||
assets.push({ value: start, rate, r: inherited("expectedReturn"), withdrawal: 0, isPk: true, ec });
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -623,13 +685,13 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
||||
const base = carry.hasCarry ? carry.value : Math.round(num(pd.currentValue));
|
||||
const topUp = carry.hasCarry ? Math.round(num(pd.additionalInvestment)) : 0;
|
||||
const start = base + topUp;
|
||||
const rate = Math.round(num(pd.annualContribution));
|
||||
const rate = Math.round(inherited("annualContribution"));
|
||||
fixedRatesTotal += rate;
|
||||
if (!isFirstPhase) investmentsFromCash += topUp;
|
||||
ec.baseValue = base;
|
||||
ec.startValue = start;
|
||||
wealthStart += start;
|
||||
assets.push({ value: start, rate, r: num(pd.expectedReturn), withdrawal: 0, isPk: false, ec });
|
||||
assets.push({ value: start, rate, r: inherited("expectedReturn"), withdrawal: 0, isPk: false, ec });
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -637,15 +699,15 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
||||
const base = carry.hasCarry ? carry.value : Math.round(num(pd.startValue));
|
||||
const topUp = carry.hasCarry ? Math.round(num(pd.additionalInvestment)) : 0;
|
||||
const start = base + topUp;
|
||||
const rate = Math.round(num(pd.annualContribution));
|
||||
const withdrawal = Math.round(num(pd.annualWithdrawal));
|
||||
const rate = Math.round(inherited("annualContribution"));
|
||||
const withdrawal = Math.round(inherited("annualWithdrawal"));
|
||||
fixedRatesTotal += rate;
|
||||
plannedWithdrawTotal += withdrawal;
|
||||
if (!isFirstPhase) investmentsFromCash += topUp;
|
||||
ec.baseValue = base;
|
||||
ec.startValue = start;
|
||||
wealthStart += start;
|
||||
assets.push({ value: start, rate, r: num(pd.expectedReturn), withdrawal, isPk: false, ec });
|
||||
assets.push({ value: start, rate, r: inherited("expectedReturn"), withdrawal, isPk: false, ec });
|
||||
break;
|
||||
}
|
||||
case "REAL_ESTATE": {
|
||||
@@ -654,7 +716,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
||||
const purchase = carry.hasCarry ? carry.propertyPurchase : Math.round(num(pd.purchasePrice));
|
||||
const valueStart = carry.hasCarry ? carry.propertyValue : Math.round(num(pd.purchasePrice));
|
||||
const mortgageStart = carry.hasCarry ? carry.mortgage : Math.round(num(pd.mortgage));
|
||||
const amort = Math.round(num(pd.amortization));
|
||||
const amort = Math.round(inherited("amortization"));
|
||||
const equity = valueStart - mortgageStart;
|
||||
if (!carry.hasCarry && !isFirstPhase) investmentsFromCash += Math.max(0, equity);
|
||||
ec.baseValue = equity;
|
||||
@@ -666,8 +728,8 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
||||
purchase,
|
||||
mortgage: mortgageStart,
|
||||
amort,
|
||||
growth: num(pd.valueGrowth),
|
||||
interestRate: num(pd.interestRate),
|
||||
growth: inherited("valueGrowth"),
|
||||
interestRate: inherited("interestRate"),
|
||||
// Default INCLUDED: bestehende Pläne haben die Zinsen in den Ausgaben -> nicht
|
||||
// nochmals abziehen. Nur bei bewusstem "ADD" rechnet das Tool sie dazu.
|
||||
addInterest: pd.interestHandling === "ADD",
|
||||
@@ -677,7 +739,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
||||
}
|
||||
case "OTHER_DEBT": {
|
||||
const owedStart = carry.hasCarry ? carry.owed : Math.round(num(pd.startValue));
|
||||
const repay = Math.round(num(pd.annualRepayment));
|
||||
const repay = Math.round(inherited("annualRepayment"));
|
||||
ec.baseValue = -owedStart;
|
||||
ec.startValue = -owedStart;
|
||||
wealthStart += -owedStart;
|
||||
@@ -742,8 +804,20 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
||||
interestNominal += re.mortgage * (re.interestRate / 100);
|
||||
}
|
||||
|
||||
const expenseNominal = expenseRealBase * inflFactor + interestNominal;
|
||||
const expenseReal = expenseRealBase + interestNominal / (inflFactor || 1);
|
||||
// AHV jahresweise: Die Rente fliesst ab dem Referenzalter -- auch wenn die Person noch
|
||||
// arbeitet. Vorher zahlt eine bereits pensionierte Person Beiträge, die wie eine
|
||||
// Ausgabe auf die Quote schlagen (Kap. 4.4.6).
|
||||
let ahvIncome = 0;
|
||||
let ahvCost = 0;
|
||||
for (const a of ahvItems) {
|
||||
const ageThisYear = a.ownerStartAge + t - 1; // Alter zu Jahresbeginn
|
||||
if (ageThisYear >= AHV_REFERENCE_AGE) ahvIncome += a.rente;
|
||||
else if (!a.working) ahvCost += a.beitrag;
|
||||
}
|
||||
incomeFlow += ahvIncome;
|
||||
|
||||
const expenseNominal = expenseRealBase * inflFactor + interestNominal + ahvCost;
|
||||
const expenseReal = expenseRealBase + (interestNominal + ahvCost) / (inflFactor || 1);
|
||||
const quote = incomeFlow - expenseNominal;
|
||||
|
||||
// Der Vermögenswert wird erst nach Verzinsung/Cash-Fortschreibung bekannt und weiter
|
||||
@@ -899,9 +973,16 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
||||
});
|
||||
}
|
||||
for (const d of debts) d.ec.yearly.push({ year: yr, age, value: -Math.round(d.owed) });
|
||||
// Renten (AHV, verrentete PK) laufen nominal fix durch die Phase.
|
||||
// AHV jahresweise: vor dem Referenzalter der Beitrag als Nichterwerbstätige(r) (negativ,
|
||||
// also als Belastung sichtbar), ab dem Referenzalter die Rente.
|
||||
for (const a of ahvItems) {
|
||||
const ageThisYear = a.ownerStartAge + t - 1; // Alter zu Jahresbeginn
|
||||
const value = ageThisYear >= AHV_REFERENCE_AGE ? a.rente : a.working ? 0 : -a.beitrag;
|
||||
if (a.ec.yearly.length < t) a.ec.yearly.push({ year: yr, age, value });
|
||||
}
|
||||
// Verrentete PK läuft nominal fix durch die Phase.
|
||||
for (const ec of ecById.values()) {
|
||||
if ((ec.category === "AHV" || ec.category === "PENSION_FUND") && ec.startValue > 0 && ec.yearly.length < t) {
|
||||
if (ec.category === "PENSION_FUND" && ec.startValue > 0 && ec.yearly.length < t) {
|
||||
ec.yearly.push({ year: yr, age, value: ec.startValue });
|
||||
}
|
||||
}
|
||||
@@ -932,6 +1013,11 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
||||
exp.ec.endValue = Math.round(exp.basis * Math.pow(1 + exp.idx / 100, duration - 1) * flowDeflatorEnd);
|
||||
exp.ec.summary = fmt(exp.ec.startValue);
|
||||
}
|
||||
// Basiswert der Folgephase fortschreiben (nominal für Einkommen, real für Ausgaben). Erst
|
||||
// hier, weil `basis` in der Jahresschleife durch effektive Werte neu gesetzt worden sein kann.
|
||||
for (const f of [...incomes, ...expenses]) {
|
||||
f.carry.flowBasis = f.basis * Math.pow(1 + f.idx / 100, duration);
|
||||
}
|
||||
for (const a of assets) {
|
||||
a.ec.endValue = Math.round(a.value);
|
||||
a.ec.summary = fmt(a.ec.endValue);
|
||||
@@ -1571,6 +1657,51 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
|
||||
carry.hasCarry = true;
|
||||
}
|
||||
|
||||
// --- Punkt C (Roadmap Nr. 44): Verwendung des Kapitalzuflusses -----------------------
|
||||
//
|
||||
// Bei der Pensionierung fliesst oft ein grosser Betrag auf einmal (PK-Kapital, 3a,
|
||||
// Immobilienverkauf). Ihn vollständig als Cash liegen zu lassen ist selten die Absicht.
|
||||
// Die Verwendung wird deshalb als QUOTE erfasst: Verschiebt man das Pensionsalter, ändert
|
||||
// sich der Betrag -- die Aufteilung skaliert mit, statt still falsch zu werden.
|
||||
//
|
||||
// Mechanisch nichts Neues: Die Amortisations-Quote wirkt wie eine Sonderamortisation, die
|
||||
// Anlage-Quote wie eine Zusatzinvestition. Beide sind schon heute Cash-Abflüsse an der
|
||||
// Grenze und laufen damit korrekt durch beide Brücken.
|
||||
if (txInflow > 0) {
|
||||
const ct = phase.cashTransition ?? {};
|
||||
const amortPct = Math.max(0, Math.min(100, num(ct.capitalUseAmortizationPct)));
|
||||
const investPct = Math.max(0, Math.min(100 - amortPct, num(ct.capitalUseInvestPct)));
|
||||
if (amortPct > 0 || investPct > 0) {
|
||||
let amortBudget = Math.round((txInflow * amortPct) / 100);
|
||||
for (const e of orderedElements) {
|
||||
if (amortBudget <= 0) break;
|
||||
if (e.category !== "REAL_ESTATE") continue;
|
||||
const c = carries.get(e.id)!;
|
||||
if (c.status !== "ACTIVE" || c.mortgage <= 0) continue;
|
||||
const pay = Math.min(amortBudget, c.mortgage);
|
||||
c.mortgage -= pay;
|
||||
amortBudget -= pay;
|
||||
txImmediateRepay += pay;
|
||||
}
|
||||
|
||||
const investBudget = Math.round((txInflow * investPct) / 100);
|
||||
if (investBudget > 0) {
|
||||
const target =
|
||||
orderedElements.find(
|
||||
(e) =>
|
||||
e.id === ct.capitalUseTargetElementId &&
|
||||
e.category === "OTHER_ASSET" &&
|
||||
carries.get(e.id)!.status === "ACTIVE"
|
||||
) ??
|
||||
orderedElements.find((e) => e.category === "OTHER_ASSET" && carries.get(e.id)!.status === "ACTIVE");
|
||||
if (target) {
|
||||
carries.get(target.id)!.value += investBudget;
|
||||
txImmediateRepay += investBudget;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cashCarryIn = cashEnd + txInflow + txOneOffInflow - txImmediateRepay - txOneOffOutflow;
|
||||
incomingInflow = txInflow;
|
||||
incomingImmediateRepay = txImmediateRepay;
|
||||
|
||||
@@ -14,6 +14,11 @@ export const AHV_PENSION_MONTHS = 13;
|
||||
// Beitragspflicht ab dem 1. Januar nach dem 20. Geburtstag -- faktisch ab Alter 21.
|
||||
export const AHV_CONTRIBUTION_START_AGE = 21;
|
||||
|
||||
// Referenzalter: Ab hier fliesst die AHV-Rente -- UNABHAENGIG vom gewaehlten Pensionsalter.
|
||||
// Wer frueher aufhoert, bleibt bis dahin beitragspflichtig (als Nichterwerbstaetige(r)); wer
|
||||
// laenger arbeitet, erhaelt die Rente trotzdem ab diesem Alter (siehe SPEZIFIKATION 4.4.6).
|
||||
export const AHV_REFERENCE_AGE = 65;
|
||||
|
||||
// Maximale einfache AHV-Altersrente pro Jahr, inkl. 13. Rente: 2 x R0 x 13 = 32'760.
|
||||
// Abgeleitet, damit eine Anpassung von R0 nicht an zwei Stellen nachgezogen werden muss.
|
||||
export const AHV_MAX_ANNUAL_SINGLE = 2 * AHV_MIN_MONTHLY_FULL * AHV_PENSION_MONTHS;
|
||||
@@ -132,6 +137,17 @@ export const SYSTEM_PARAMETERS: SystemParameter[] = [
|
||||
validFrom: "1.1.2026",
|
||||
group: "AHV",
|
||||
},
|
||||
{
|
||||
key: "AHV_REFERENCE_AGE",
|
||||
label: "Referenzalter (Rentenbeginn)",
|
||||
value: AHV_REFERENCE_AGE,
|
||||
unit: "Jahre",
|
||||
meaning:
|
||||
"Ab diesem Alter fliesst die AHV-Rente – unabhängig vom gewählten Pensionsalter. Wer früher aufhört, bleibt bis dahin beitragspflichtig; wer länger arbeitet, erhält die Rente trotzdem ab hier. Ein Aufschub der Rente ist nicht modelliert.",
|
||||
source: "AHVG (Referenzalter 65 nach AHV 21)",
|
||||
validFrom: "1.1.2026",
|
||||
group: "AHV",
|
||||
},
|
||||
{
|
||||
key: "AHV_COUPLE_CAP_FACTOR",
|
||||
label: "Ehepaar-Plafonierung",
|
||||
|
||||
@@ -58,6 +58,9 @@ export interface PhaseData {
|
||||
teuerungsausgleich?: number;
|
||||
// AHV
|
||||
gapYears?: number;
|
||||
// Nur AHV, nur in Phasen NACH einer Fruehpensionierung: jaehrlicher Beitrag als
|
||||
// Nichterwerbstaetige(r) bis zum Referenzalter. Faellt als Kosten an (Kap. 4.4.6).
|
||||
ahvContribution?: number;
|
||||
// AHV, nur wenn die Person bei Planbeginn BEREITS pensioniert ist (dann gibt es keinen
|
||||
// Pensions-Übergang, an dem die Karriere geprüft werden könnte): Beitragskarriere.
|
||||
// avgIncomeBefore ist REAL (heutige Kaufkraft).
|
||||
@@ -135,6 +138,14 @@ export interface CashTransitionData {
|
||||
// Einmalige Kosten (z. B. Poolbau): REAL erfasst (heutige Kaufkraft).
|
||||
outflowLabel?: string;
|
||||
outflowAmount?: number;
|
||||
// Verwendung des Kapitalzuflusses am Pensions-Übergang (Roadmap Nr. 44, Punkt C).
|
||||
// Bewusst in PROZENT und nicht in Franken: Verschiebt man das Pensionsalter, ändert sich
|
||||
// das bezogene Kapital -- eine Franken-Angabe müsste man dann von Hand nachziehen, eine
|
||||
// Quote skaliert mit. Der nicht zugeteilte Rest bleibt Cash.
|
||||
capitalUseAmortizationPct?: number;
|
||||
capitalUseInvestPct?: number;
|
||||
// Ziel der Anlage-Quote; ohne Angabe das erste aktive «Sonstiges Vermögen».
|
||||
capitalUseTargetElementId?: string;
|
||||
}
|
||||
|
||||
// --- Zod-Schemas (nachsichtig: unbekannte Felder werden verworfen) ---
|
||||
@@ -149,6 +160,9 @@ export const cashTransitionSchema = z
|
||||
inflowTaxRate: z.number().min(0).max(100).optional(),
|
||||
outflowLabel: z.string().max(120).optional(),
|
||||
outflowAmount: nonNeg.optional(),
|
||||
capitalUseAmortizationPct: z.number().min(0).max(100).optional(),
|
||||
capitalUseInvestPct: z.number().min(0).max(100).optional(),
|
||||
capitalUseTargetElementId: z.string().max(60).optional(),
|
||||
})
|
||||
.strip();
|
||||
|
||||
@@ -157,6 +171,7 @@ export const phaseDataSchema = z
|
||||
amount: nonNeg.optional(),
|
||||
teuerungsausgleich: z.number().min(-20).max(50).optional(),
|
||||
gapYears: z.number().int().min(0).optional(),
|
||||
ahvContribution: nonNeg.optional(),
|
||||
avgIncomeBefore: nonNeg.optional(),
|
||||
gapYearsBefore: z.number().int().min(0).max(50).optional(),
|
||||
currentValue: nonNeg.optional(),
|
||||
@@ -198,3 +213,41 @@ export const transitionDataSchema = z
|
||||
export function num(value: number | undefined | null, fallback = 0): number {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
// --- Vererbung der Wiederkehr-Parameter (Roadmap Nr. 44, Punkt A) -----------------------
|
||||
//
|
||||
// Raten, Beitraege, Amortisation, Wertsteigerung und Zinssatz gelten weiter, bis man sie
|
||||
// bewusst aendert: Fehlt der Wert in einer Phase, gilt der aus der Vorphase. Genau diese
|
||||
// Regel wendet auch `computePlan` an -- hier steht sie fuer das UI, damit die Anzeige
|
||||
// "Aus Vorphase uebernehmen: <Wert>" nicht raten muss.
|
||||
export const INHERITABLE_KEYS = [
|
||||
"teuerungsausgleich",
|
||||
"expectedReturn",
|
||||
"annualContribution",
|
||||
"annualWithdrawal",
|
||||
"amortization",
|
||||
"valueGrowth",
|
||||
"interestRate",
|
||||
"annualRepayment",
|
||||
] as const;
|
||||
|
||||
export type InheritableKey = (typeof INHERITABLE_KEYS)[number];
|
||||
|
||||
// Wert, der in `phaseId` gelten wuerde, wenn das Feld dort leer bleibt. `orderedPhaseIds`
|
||||
// muss in Phasenreihenfolge vorliegen.
|
||||
export function inheritedPhaseValues(
|
||||
phaseValues: Record<string, PhaseData>,
|
||||
orderedPhaseIds: string[],
|
||||
phaseId: string
|
||||
): Record<string, number> {
|
||||
const out: Record<string, number> = {};
|
||||
for (const id of orderedPhaseIds) {
|
||||
if (id === phaseId) break;
|
||||
const pd = phaseValues[id] ?? {};
|
||||
for (const key of INHERITABLE_KEYS) {
|
||||
const v = pd[key];
|
||||
if (typeof v === "number") out[key] = v;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
+17
-3
@@ -13,7 +13,14 @@
|
||||
// keine Ladeanzeige. Der Engpass ist das Neuzeichnen der Grafik, nicht die Mathematik.
|
||||
|
||||
import { computePlan } from "@/lib/calculations";
|
||||
import { applyDriver, applyElementDriver, driverById, DRIVERS, tunableElements } from "@/lib/sensitivity";
|
||||
import {
|
||||
applyDriver,
|
||||
applyElementDriver,
|
||||
driverById,
|
||||
DRIVERS,
|
||||
retirementDriverRange,
|
||||
tunableElements,
|
||||
} from "@/lib/sensitivity";
|
||||
import type { DriverId, DriverUnit } from "@/lib/sensitivity";
|
||||
import type { PlanComputed } from "@/lib/calculations";
|
||||
import type { PlanInput } from "@/lib/types";
|
||||
@@ -51,6 +58,9 @@ const RANGES: Record<DriverId, { min: number; max: number; step: number; neutral
|
||||
expenses: { min: -20, max: 30, step: 1, neutral: 0 },
|
||||
income: { min: -30, max: 20, step: 1, neutral: 0 },
|
||||
lifespan: { min: -5, max: 10, step: 1, neutral: 0 },
|
||||
// Der wirkliche Spielraum hängt an den Phasendauern und wird in `buildSliders` gesetzt.
|
||||
retirementA: { min: -3, max: 3, step: 1, neutral: 0 },
|
||||
retirementB: { min: -3, max: 3, step: 1, neutral: 0 },
|
||||
};
|
||||
|
||||
// Bereich für die einzeln aufgeschlüsselten Element-Renditen: derselbe wie der Sammelregler,
|
||||
@@ -69,14 +79,18 @@ export function buildSliders(plan: PlanInput, expandReturns: boolean): SliderDef
|
||||
// Aufgeschlüsselt übernehmen die Element-Regler die Rolle der Sammelregler.
|
||||
if (expandReturns && (def.id === "returns" || def.id === "propertyGrowth")) continue;
|
||||
const r = RANGES[def.id];
|
||||
// Beim Pensionsalter darf der Regler nur so weit gehen, wie die angrenzenden Phasen es
|
||||
// zulassen -- sonst würde er stumm an seiner Grenze kleben (Roadmap Nr. 44).
|
||||
const hard =
|
||||
def.id === "retirementA" || def.id === "retirementB" ? retirementDriverRange(plan, def.id) : null;
|
||||
out.push({
|
||||
key: `d:${def.id}`,
|
||||
ref: { kind: "driver", id: def.id },
|
||||
label: def.shortLabel,
|
||||
unit: def.unit,
|
||||
help: def.help,
|
||||
min: r.min,
|
||||
max: r.max,
|
||||
min: hard ? Math.max(r.min, hard.min) : r.min,
|
||||
max: hard ? Math.min(r.max, hard.max) : r.max,
|
||||
step: r.step,
|
||||
// Die Inflation ist der einzige absolute Treiber: neutral ist der Wert aus dem Plan.
|
||||
neutral: def.id === "inflation" ? plan.inflationRateDefault : 0,
|
||||
|
||||
@@ -106,7 +106,9 @@ export function buildRateWrites(
|
||||
if (scope === "THIS" || fields.length === 0) return [];
|
||||
|
||||
const patch: Partial<PhaseData> = {};
|
||||
for (const f of fields) patch[f] = draft[f] ?? 0;
|
||||
// Bewusst OHNE `?? 0`: Ein leeres Feld bedeutet seit Roadmap Nr. 44 «aus der Vorphase
|
||||
// übernehmen». Ein 0 an dieser Stelle würde daraus eine harte Null machen.
|
||||
for (const f of fields) patch[f] = draft[f];
|
||||
|
||||
return targetPhases(phases, currentPhaseId, scope)
|
||||
.filter((p) => p.id !== currentPhaseId)
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { retirementBoundaries, shiftRetirement, sortedPhases } from "@/lib/retirement";
|
||||
import type { PlanInput } from "@/lib/types";
|
||||
|
||||
// Genau das Beispiel aus der Anforderung:
|
||||
// Person A: Start 20, Pension 65 -> Planjahr 45
|
||||
// Person B: Start 30, Pension 65 -> Planjahr 35
|
||||
// Phase 1 Erwerb 20 J. (Planjahr 0-20)
|
||||
// Phase 2 Erwerb 15 J. (Planjahr 20-35) <- B wird hier pensioniert (Grenze 35)
|
||||
// Phase 3 Misch 10 J. (Planjahr 35-45) <- A wird hier pensioniert (Grenze 45)
|
||||
// Phase 4 Pension 15 J. (Planjahr 45-60)
|
||||
function plan(): PlanInput {
|
||||
return {
|
||||
id: "s", name: "T", householdType: "COUPLE", inflationRateDefault: 1.5, initialCash: 0, startYear: 2026,
|
||||
persons: [
|
||||
{ id: "A", role: "PERSON_A", name: "Anna", age: 20, retirementAge: 65 },
|
||||
{ id: "B", role: "PERSON_B", name: "Beat", age: 30, retirementAge: 65 },
|
||||
],
|
||||
phases: [
|
||||
{ id: "p1", sequenceNumber: 1, name: "Erwerb 1", durationYears: 20, cashTransition: {} },
|
||||
{ id: "p2", sequenceNumber: 2, name: "Erwerb 2", durationYears: 15, cashTransition: {} },
|
||||
{ id: "p3", sequenceNumber: 3, name: "Misch", durationYears: 10, cashTransition: {} },
|
||||
{ id: "p4", sequenceNumber: 4, name: "Pension", durationYears: 15, cashTransition: {} },
|
||||
],
|
||||
elements: [],
|
||||
} as unknown as PlanInput;
|
||||
}
|
||||
|
||||
const durations = (p: PlanInput) => sortedPhases(p).map((x) => x.durationYears);
|
||||
const boundaryOf = (p: PlanInput, role: "PERSON_A" | "PERSON_B") =>
|
||||
retirementBoundaries(p).find((b) => b.role === role)!;
|
||||
|
||||
describe("retirementBoundaries", () => {
|
||||
it("findet die Grenze jeder Person", () => {
|
||||
const a = boundaryOf(plan(), "PERSON_A");
|
||||
const b = boundaryOf(plan(), "PERSON_B");
|
||||
// A wird am Ende von Phase 3 (Index 2) pensioniert, B am Ende von Phase 2 (Index 1).
|
||||
expect(a.planYear).toBe(45);
|
||||
expect(a.phaseIndex).toBe(2);
|
||||
expect(b.planYear).toBe(35);
|
||||
expect(b.phaseIndex).toBe(1);
|
||||
expect(a.blocked).toBeNull();
|
||||
});
|
||||
|
||||
it("nennt die Spielräume aus den angrenzenden Phasen", () => {
|
||||
const a = boundaryOf(plan(), "PERSON_A");
|
||||
// Phase 3 (10 J.) darf auf 1 schrumpfen -> 9 Jahre früher;
|
||||
// Phase 4 (15 J.) darf auf 1 schrumpfen -> 14 Jahre später.
|
||||
expect(a.minDelta).toBe(-9);
|
||||
expect(a.maxDelta).toBe(14);
|
||||
// Ein Jahr weiter faellt die jeweilige Phase weg.
|
||||
expect(a.mergeDeltaDown).toBe(-10);
|
||||
expect(a.mergeDeltaUp).toBe(15);
|
||||
});
|
||||
|
||||
it("sperrt, wenn die Person bei Planbeginn schon pensioniert ist", () => {
|
||||
const p = plan();
|
||||
p.persons[0].retirementAge = 20; // = Startalter
|
||||
expect(boundaryOf(p, "PERSON_A").blocked).toContain("bereits pensioniert");
|
||||
});
|
||||
|
||||
it("sperrt, wenn beide Personen dieselbe Grenze teilen", () => {
|
||||
// Nach einer Zusammenlegung ist genau das der Fall -- eine Trennung bräuchte eine neue Phase.
|
||||
const p = plan();
|
||||
p.persons[0].retirementAge = 55; // A -> Planjahr 35, wie B
|
||||
expect(boundaryOf(p, "PERSON_A").blocked).toContain("selben Zeitpunkt");
|
||||
});
|
||||
|
||||
it("sperrt, wenn die Pensionierung nicht auf einer Grenze liegt", () => {
|
||||
const p = plan();
|
||||
p.persons[0].retirementAge = 62; // Planjahr 42 -- mitten in Phase 3
|
||||
expect(boundaryOf(p, "PERSON_A").blocked).toContain("nicht auf einer Phasengrenze");
|
||||
});
|
||||
});
|
||||
|
||||
describe("shiftRetirement", () => {
|
||||
it("senkt das Pensionsalter von A um 4 Jahre (Beispiel aus der Anforderung)", () => {
|
||||
const r = shiftRetirement(plan(), "PERSON_A", -4)!;
|
||||
// Phase 3 wird von 10 auf 6 gekürzt, Phase 4 wächst von 15 auf 19.
|
||||
expect(durations(r.plan)).toEqual([20, 15, 6, 19]);
|
||||
expect(r.plan.persons.find((p) => p.role === "PERSON_A")!.retirementAge).toBe(61);
|
||||
// Die Gesamtdauer des Plans bleibt gleich.
|
||||
expect(durations(r.plan).reduce((a, b) => a + b, 0)).toBe(60);
|
||||
expect(r.removedPhaseId).toBeNull();
|
||||
});
|
||||
|
||||
it("erhöht das Pensionsalter von A um 4 Jahre", () => {
|
||||
const r = shiftRetirement(plan(), "PERSON_A", 4)!;
|
||||
expect(durations(r.plan)).toEqual([20, 15, 14, 11]);
|
||||
expect(r.plan.persons.find((p) => p.role === "PERSON_A")!.retirementAge).toBe(69);
|
||||
});
|
||||
|
||||
it("verschiebt bei Person B die Grenze zwischen Phase 2 und 3", () => {
|
||||
const r = shiftRetirement(plan(), "PERSON_B", -3)!;
|
||||
expect(durations(r.plan)).toEqual([20, 12, 13, 15]);
|
||||
expect(r.plan.persons.find((p) => p.role === "PERSON_B")!.retirementAge).toBe(62);
|
||||
// Das Pensionsalter von A bleibt unberührt.
|
||||
expect(r.plan.persons.find((p) => p.role === "PERSON_A")!.retirementAge).toBe(65);
|
||||
});
|
||||
|
||||
it("lässt die Mischphase verschwinden, wenn beide gleichzeitig pensioniert werden", () => {
|
||||
// Spezialfall 2: A genau 10 Jahre früher -> A und B werden zeitgleich pensioniert.
|
||||
const r = shiftRetirement(plan(), "PERSON_A", -10)!;
|
||||
expect(r.removedPhaseId).toBe("p3");
|
||||
expect(durations(r.plan)).toEqual([20, 15, 25]);
|
||||
// Sequenznummern bleiben lückenlos.
|
||||
expect(sortedPhases(r.plan).map((p) => p.sequenceNumber)).toEqual([1, 2, 3]);
|
||||
expect(durations(r.plan).reduce((a, b) => a + b, 0)).toBe(60);
|
||||
});
|
||||
|
||||
it("lässt auch die FOLGEphase verschwinden, wenn die Grenze ans Planende rückt", () => {
|
||||
const r = shiftRetirement(plan(), "PERSON_A", 15)!;
|
||||
expect(r.removedPhaseId).toBe("p4");
|
||||
expect(durations(r.plan)).toEqual([20, 15, 25]);
|
||||
});
|
||||
|
||||
it("verweigert alles jenseits der Grenzen", () => {
|
||||
// Eine Phase darf nicht negativ werden.
|
||||
expect(shiftRetirement(plan(), "PERSON_A", -11)).toBeNull();
|
||||
expect(shiftRetirement(plan(), "PERSON_A", 16)).toBeNull();
|
||||
});
|
||||
|
||||
it("verweigert die Verschiebung bei geteilter Grenze", () => {
|
||||
const p = plan();
|
||||
p.persons[0].retirementAge = 55;
|
||||
expect(shiftRetirement(p, "PERSON_A", -1)).toBeNull();
|
||||
});
|
||||
|
||||
it("lässt den Plan bei delta 0 unverändert", () => {
|
||||
const p = plan();
|
||||
expect(shiftRetirement(p, "PERSON_A", 0)!.plan).toBe(p);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Zusammenlegung der Uebergaenge", () => {
|
||||
// Plan wie oben, aber mit Entscheiden an beiden betroffenen Grenzen.
|
||||
function planMitEntscheiden(): PlanInput {
|
||||
const p = plan();
|
||||
p.phases[1].cashTransition = { mode: "INFLOW", inflowLabel: "Erbschaft", inflowAmount: 100000, inflowTaxRate: 10 };
|
||||
p.phases[2].cashTransition = { mode: "BOTH", inflowLabel: "Bonus", inflowAmount: 100000, inflowTaxRate: 30, outflowLabel: "Umbau", outflowAmount: 50000 };
|
||||
p.elements = [
|
||||
{
|
||||
id: "pk", category: "PENSION_FUND", name: "PK", ownerRole: "PERSON_A", orderIndex: 1,
|
||||
phaseValues: {},
|
||||
transitionValues: {
|
||||
p2: { withdrawalMode: "NONE" },
|
||||
p3: { payoutMode: "CAPITAL", capitalTaxRate: 5, conversionRate: 6 },
|
||||
},
|
||||
},
|
||||
] as unknown as PlanInput["elements"];
|
||||
return p;
|
||||
}
|
||||
|
||||
it("zieht Element-Entscheide in den ueberlebenden Uebergang", () => {
|
||||
const r = shiftRetirement(planMitEntscheiden(), "PERSON_A", -10)!;
|
||||
expect(r.removedPhaseId).toBe("p3");
|
||||
expect(r.mergedIntoPhaseId).toBe("p2");
|
||||
const tv = r.plan.elements[0].transitionValues;
|
||||
expect(tv.p3).toBeUndefined();
|
||||
// Der bestehende Entscheid bleibt, die leeren Felder werden aufgefuellt.
|
||||
expect(tv.p2).toEqual({ withdrawalMode: "NONE", payoutMode: "CAPITAL", capitalTaxRate: 5, conversionRate: 6 });
|
||||
});
|
||||
|
||||
it("addiert die einmaligen Cash-Betraege", () => {
|
||||
const r = shiftRetirement(planMitEntscheiden(), "PERSON_A", -10)!;
|
||||
const ct = sortedPhases(r.plan).find((x) => x.id === "p2")!.cashTransition;
|
||||
expect(ct.mode).toBe("BOTH");
|
||||
expect(ct.inflowAmount).toBe(200000);
|
||||
expect(ct.inflowLabel).toBe("Erbschaft + Bonus");
|
||||
// Betragsgewichteter Mischsatz: (10 % x 100'000 + 30 % x 100'000) / 200'000 = 20 %.
|
||||
expect(ct.inflowTaxRate).toBe(20);
|
||||
expect(ct.outflowAmount).toBe(50000);
|
||||
expect(ct.outflowLabel).toBe("Umbau");
|
||||
});
|
||||
|
||||
it("laesst die Uebergaenge unangetastet, wenn keine Phase entfaellt", () => {
|
||||
const r = shiftRetirement(planMitEntscheiden(), "PERSON_A", -4)!;
|
||||
expect(r.mergedIntoPhaseId).toBeNull();
|
||||
expect(r.plan.elements[0].transitionValues.p3).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,274 @@
|
||||
// Pensionsalter anpassen (Roadmap Nr. 44).
|
||||
//
|
||||
// Bis hierher war das Pensionsalter faktisch unantastbar: Es bestimmt, wo eine Phase endet
|
||||
// (`maxPhaseDuration` kappt jede Phasendauer beim nächsten Pensionierungsereignis), also
|
||||
// liegt JEDE Pensionierung zwangsläufig auf einer Phasengrenze. Genau das macht die
|
||||
// Anpassung überhaupt erst möglich: «Pensionsalter ändern» heisst «diese eine Grenze
|
||||
// verschieben» -- die Phase davor wird länger, die danach kürzer, die Gesamtdauer des Plans
|
||||
// bleibt gleich.
|
||||
//
|
||||
// Reines Modul ohne I/O: Es entscheidet nur, WAS geschehen soll. Das Schreiben übernimmt der
|
||||
// Aufrufer (Panel: API-Aufrufe; Tornado/Live-Simulation: reine Plan-Kopie).
|
||||
|
||||
import type { CashTransitionData, TransitionData } from "@/lib/elements";
|
||||
import type { PersonRole, PlanInput } from "@/lib/types";
|
||||
|
||||
export interface RetirementBoundary {
|
||||
role: PersonRole;
|
||||
personId: string;
|
||||
retirementAge: number;
|
||||
// Planjahr, in dem die Pensionierung liegt (1-basiert: Ende von Phase `phaseIndex`).
|
||||
planYear: number;
|
||||
phaseIndex: number; // Phase VOR der Grenze
|
||||
// Um so viele Jahre lässt sich das Pensionsalter senken bzw. erhöhen, ohne dass eine
|
||||
// angrenzende Phase unter 1 Jahr fällt.
|
||||
minDelta: number;
|
||||
maxDelta: number;
|
||||
// Bei genau diesen Werten verschwindet eine Phase (Zusammenlegung, siehe unten).
|
||||
mergeDeltaDown: number | null;
|
||||
mergeDeltaUp: number | null;
|
||||
// Warum ist keine Anpassung möglich?
|
||||
blocked: string | null;
|
||||
}
|
||||
|
||||
// Kumulierte Jahresgrenzen: Index i = Planjahr, an dem Phase i endet.
|
||||
function boundaries(plan: PlanInput): number[] {
|
||||
const sorted = [...plan.phases].sort((a, b) => a.sequenceNumber - b.sequenceNumber);
|
||||
const out: number[] = [];
|
||||
let acc = 0;
|
||||
for (const p of sorted) {
|
||||
acc += p.durationYears;
|
||||
out.push(acc);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function sortedPhases(plan: PlanInput) {
|
||||
return [...plan.phases].sort((a, b) => a.sequenceNumber - b.sequenceNumber);
|
||||
}
|
||||
|
||||
// Analysiert für JEDE Person, ob und wie weit sich ihr Pensionsalter verschieben lässt.
|
||||
export function retirementBoundaries(plan: PlanInput): RetirementBoundary[] {
|
||||
const phases = sortedPhases(plan);
|
||||
const bounds = boundaries(plan);
|
||||
const total = bounds[bounds.length - 1] ?? 0;
|
||||
|
||||
return plan.persons.map((p) => {
|
||||
const planYear = p.retirementAge - p.age;
|
||||
const base: RetirementBoundary = {
|
||||
role: p.role,
|
||||
personId: p.id,
|
||||
retirementAge: p.retirementAge,
|
||||
planYear,
|
||||
phaseIndex: -1,
|
||||
minDelta: 0,
|
||||
maxDelta: 0,
|
||||
mergeDeltaDown: null,
|
||||
mergeDeltaUp: null,
|
||||
blocked: null,
|
||||
};
|
||||
|
||||
if (planYear <= 0) {
|
||||
return { ...base, blocked: "Diese Person ist bei Planbeginn bereits pensioniert – es gibt keine Grenze zu verschieben." };
|
||||
}
|
||||
if (planYear >= total) {
|
||||
return { ...base, blocked: "Die Pensionierung liegt am oder nach dem Planende – dafür müsstest du zuerst eine Lebensphase anhängen." };
|
||||
}
|
||||
|
||||
const idx = bounds.indexOf(planYear);
|
||||
if (idx < 0 || idx >= phases.length - 1) {
|
||||
return {
|
||||
...base,
|
||||
blocked: "Die Pensionierung liegt nicht auf einer Phasengrenze. Passe zuerst die Lebensphasen an.",
|
||||
};
|
||||
}
|
||||
|
||||
// Teilen sich zwei Personen dieselbe Grenze, liesse sich eine davon nicht verschieben,
|
||||
// ohne die andere mitzunehmen -- dafür müsste eine Phase eingefügt werden.
|
||||
const shared = plan.persons.some((q) => q.id !== p.id && q.retirementAge - q.age === planYear);
|
||||
if (shared) {
|
||||
return {
|
||||
...base,
|
||||
phaseIndex: idx,
|
||||
blocked:
|
||||
"Beide Personen werden zum selben Zeitpunkt pensioniert. Um sie zu trennen, füge zuerst eine Lebensphase ein.",
|
||||
};
|
||||
}
|
||||
|
||||
const before = phases[idx].durationYears;
|
||||
const after = phases[idx + 1].durationYears;
|
||||
|
||||
return {
|
||||
...base,
|
||||
phaseIndex: idx,
|
||||
// Die Phase davor darf auf 1 schrumpfen, die danach ebenso.
|
||||
minDelta: -(before - 1),
|
||||
maxDelta: after - 1,
|
||||
// Ein Schritt weiter lässt die jeweilige Phase ganz verschwinden.
|
||||
mergeDeltaDown: -before,
|
||||
mergeDeltaUp: after,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export interface ShiftResult {
|
||||
plan: PlanInput;
|
||||
// Index der Phase, die dabei entfällt (Zusammenlegung) -- sonst null.
|
||||
removedPhaseIndex: number | null;
|
||||
removedPhaseId: string | null;
|
||||
// Phase, in deren Übergang die Entscheide der entfallenden Phase gezogen wurden.
|
||||
mergedIntoPhaseId: string | null;
|
||||
}
|
||||
|
||||
// Verschiebt die Pensionierungs-Grenze einer Person um `delta` Jahre.
|
||||
//
|
||||
// `delta > 0` = später in Pension: die Phase davor wird länger, die danach kürzer.
|
||||
// Fällt eine der beiden auf 0, verschwindet sie (Zusammenlegung) -- das muss der Aufrufer
|
||||
// vorher bestätigen lassen, weil dabei Übergangs-Entscheide zusammengeführt werden.
|
||||
export function shiftRetirement(plan: PlanInput, role: PersonRole, delta: number): ShiftResult | null {
|
||||
const info = retirementBoundaries(plan).find((b) => b.role === role);
|
||||
if (!info || info.blocked || info.phaseIndex < 0) return null;
|
||||
if (delta === 0) return { plan, removedPhaseIndex: null, removedPhaseId: null, mergedIntoPhaseId: null };
|
||||
|
||||
const allowedDown = info.mergeDeltaDown ?? info.minDelta;
|
||||
const allowedUp = info.mergeDeltaUp ?? info.maxDelta;
|
||||
if (delta < allowedDown || delta > allowedUp) return null;
|
||||
|
||||
const phases = sortedPhases(plan);
|
||||
const i = info.phaseIndex;
|
||||
const beforeNew = phases[i].durationYears + delta;
|
||||
const afterNew = phases[i + 1].durationYears - delta;
|
||||
if (beforeNew < 0 || afterNew < 0) return null;
|
||||
|
||||
const nextPhases = phases.map((p, k) =>
|
||||
k === i ? { ...p, durationYears: beforeNew } : k === i + 1 ? { ...p, durationYears: afterNew } : { ...p }
|
||||
);
|
||||
|
||||
// Genau eine der beiden kann 0 werden -- diese Phase entfällt.
|
||||
let removedIndex: number | null = null;
|
||||
if (beforeNew === 0) removedIndex = i;
|
||||
else if (afterNew === 0) removedIndex = i + 1;
|
||||
|
||||
const removedPhaseId = removedIndex === null ? null : nextPhases[removedIndex].id;
|
||||
// Die überlebende Grenze ist die des Vorgängers -- dorthin wandern die Entscheide.
|
||||
const mergedIntoPhaseId =
|
||||
removedIndex === null || removedIndex === 0 ? null : nextPhases[removedIndex - 1].id;
|
||||
|
||||
let kept = removedIndex === null ? nextPhases : nextPhases.filter((_, k) => k !== removedIndex);
|
||||
let elements = plan.elements;
|
||||
|
||||
if (removedPhaseId && mergedIntoPhaseId) {
|
||||
kept = kept.map((p) =>
|
||||
p.id === mergedIntoPhaseId
|
||||
? {
|
||||
...p,
|
||||
cashTransition: mergeCashTransition(
|
||||
p.cashTransition ?? {},
|
||||
nextPhases[removedIndex!].cashTransition ?? {}
|
||||
),
|
||||
}
|
||||
: p
|
||||
);
|
||||
elements = plan.elements.map((e) => {
|
||||
const removedTd = e.transitionValues?.[removedPhaseId];
|
||||
if (!removedTd) return e;
|
||||
const rest = { ...e.transitionValues };
|
||||
delete rest[removedPhaseId];
|
||||
return {
|
||||
...e,
|
||||
transitionValues: {
|
||||
...rest,
|
||||
[mergedIntoPhaseId]: mergeTransition(e.transitionValues[mergedIntoPhaseId] ?? {}, removedTd),
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
plan: {
|
||||
...plan,
|
||||
persons: plan.persons.map((p) => (p.role === role ? { ...p, retirementAge: p.retirementAge + delta } : p)),
|
||||
// Sequenznummern lückenlos neu vergeben, damit die Kette intakt bleibt.
|
||||
phases: kept.map((p, k) => ({ ...p, sequenceNumber: k + 1 })),
|
||||
elements,
|
||||
},
|
||||
removedPhaseIndex: removedIndex,
|
||||
removedPhaseId,
|
||||
mergedIntoPhaseId,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Zusammenlegung zweier Übergänge ----------------------------------------------------
|
||||
//
|
||||
// Fällt eine Phase weg, verschwindet auch einer der beiden Übergänge -- die dort getroffenen
|
||||
// Entscheide dürfen aber nicht stillschweigend untergehen. Die überlebende Grenze ist immer
|
||||
// die des VORGÄNGERS der entfallenden Phase; deren Daten werden in ihn hineingezogen.
|
||||
//
|
||||
// Regel (mit dem Nutzer abgestimmt):
|
||||
// * Element-Entscheide: Was am überlebenden Übergang schon entschieden ist, bleibt. Nur
|
||||
// leere Felder werden aus dem entfallenden Übergang aufgefüllt -- ein bewusster
|
||||
// Entscheid soll nie von einem anderen überschrieben werden.
|
||||
// * Einmalige Cash-Beträge: werden ADDIERT. Beide Ereignisse finden ja weiterhin statt,
|
||||
// nur zum selben Zeitpunkt.
|
||||
|
||||
// Feldweises Auffüllen: `keep` gewinnt, `removed` füllt die Lücken.
|
||||
export function mergeTransition(keep: TransitionData, removed: TransitionData): TransitionData {
|
||||
const out: TransitionData = { ...keep };
|
||||
for (const [k, v] of Object.entries(removed) as [keyof TransitionData, unknown][]) {
|
||||
if (v === undefined || v === null) continue;
|
||||
if (out[k] === undefined || out[k] === null) (out as Record<string, unknown>)[k] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function mergeCashTransition(keep: CashTransitionData, removed: CashTransitionData): CashTransitionData {
|
||||
// "NONE" bedeutet: bewusst nichts. Beträge daraus zählen nicht mit.
|
||||
const active = (c: CashTransitionData) => c.mode && c.mode !== "NONE";
|
||||
const inflow = (c: CashTransitionData) =>
|
||||
active(c) && (c.mode === "INFLOW" || c.mode === "BOTH") ? c.inflowAmount ?? 0 : 0;
|
||||
const outflow = (c: CashTransitionData) =>
|
||||
active(c) && (c.mode === "OUTFLOW" || c.mode === "BOTH") ? c.outflowAmount ?? 0 : 0;
|
||||
|
||||
const inA = inflow(keep);
|
||||
const inB = inflow(removed);
|
||||
const outA = outflow(keep);
|
||||
const outB = outflow(removed);
|
||||
const inSum = inA + inB;
|
||||
const outSum = outA + outB;
|
||||
|
||||
const join = (a: string | undefined, b: string | undefined) =>
|
||||
[a, b].filter((x) => x && x.trim()).join(" + ") || undefined;
|
||||
|
||||
return {
|
||||
mode: inSum > 0 && outSum > 0 ? "BOTH" : inSum > 0 ? "INFLOW" : outSum > 0 ? "OUTFLOW" : "NONE",
|
||||
inflowLabel: inSum > 0 ? join(inA > 0 ? keep.inflowLabel : undefined, inB > 0 ? removed.inflowLabel : undefined) : undefined,
|
||||
inflowAmount: inSum > 0 ? inSum : undefined,
|
||||
// Zwei Zuflüsse mit verschiedenen Steuersätzen ergeben zusammen einen betragsgewichteten
|
||||
// Mischsatz -- nur so bleibt der Netto-Zufluss derselbe wie vor der Zusammenlegung.
|
||||
inflowTaxRate:
|
||||
inSum > 0
|
||||
? Math.round((((keep.inflowTaxRate ?? 0) * inA + (removed.inflowTaxRate ?? 0) * inB) / inSum) * 100) / 100
|
||||
: undefined,
|
||||
outflowLabel: outSum > 0 ? join(outA > 0 ? keep.outflowLabel : undefined, outB > 0 ? removed.outflowLabel : undefined) : undefined,
|
||||
outflowAmount: outSum > 0 ? outSum : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// Erklärtext für die Hilfebox: was ist möglich, und warum nicht mehr.
|
||||
export function limitsText(b: RetirementBoundary, personLabel: string): string {
|
||||
if (b.blocked) return b.blocked;
|
||||
const down = Math.abs(b.minDelta);
|
||||
const up = b.maxDelta;
|
||||
const parts = [
|
||||
`Das Pensionsalter von ${personLabel} lässt sich um maximal ${down} Jahr${down === 1 ? "" : "e"} senken und ` +
|
||||
`${up} Jahr${up === 1 ? "" : "e"} erhöhen, weil die angrenzenden Lebensphasen mindestens 1 Jahr dauern müssen.`,
|
||||
];
|
||||
if (b.mergeDeltaDown !== null || b.mergeDeltaUp !== null) {
|
||||
parts.push(
|
||||
"Ein Jahr darüber hinaus fällt die angrenzende Lebensphase ganz weg – das ist möglich, wird aber vorher " +
|
||||
"bestätigt, weil dabei zwei Übergänge zusammengelegt werden."
|
||||
);
|
||||
}
|
||||
parts.push("Brauchst du mehr Spielraum, passe zuerst die Dauer der Lebensphasen an.");
|
||||
return parts.join(" ");
|
||||
}
|
||||
@@ -210,3 +210,41 @@ describe("Sensitivität: Tornado", () => {
|
||||
expect(real).toBe(Math.round(nominal / last.cumulativeInflationEnd));
|
||||
});
|
||||
});
|
||||
|
||||
describe("Treiber Pensionsalter (Roadmap Nr. 44)", () => {
|
||||
it("erscheint nur fuer vorhandene Personen mit Spielraum", () => {
|
||||
const p = basePlan();
|
||||
expect(driverById("retirementA").applies(p)).toBe(true);
|
||||
expect(driverById("retirementB").applies(p)).toBe(false); // Einzelhaushalt
|
||||
});
|
||||
|
||||
it("verschiebt die Phasengrenze, ohne die Gesamtdauer zu aendern", () => {
|
||||
const shifted = applyDriver(basePlan(), "retirementA", 3);
|
||||
expect(shifted.phases.map((x) => x.durationYears)).toEqual([23, 17]);
|
||||
expect(shifted.persons[0].retirementAge).toBe(68);
|
||||
});
|
||||
|
||||
it("kuerzt auf den moeglichen Spielraum statt gar nichts zu tun", () => {
|
||||
// Phase 2 hat 20 Jahre -> maximal +19; eine Eingabe von +50 landet dort.
|
||||
const shifted = applyDriver(basePlan(), "retirementA", 50);
|
||||
expect(shifted.phases.map((x) => x.durationYears)).toEqual([39, 1]);
|
||||
});
|
||||
|
||||
it("laenger arbeiten bringt mehr Endvermoegen", () => {
|
||||
// Der Lohn muss der PERSON gehoeren -- nur dann faellt er bei der Pensionierung weg
|
||||
// (Roadmap Nr. 44, Punkt B) und die Verschiebung der Grenze wirkt ueberhaupt.
|
||||
const p = basePlan();
|
||||
p.elements[0].ownerRole = "PERSON_A";
|
||||
const base = planMetric(p, "real");
|
||||
const spaeter = planMetric(applyDriver(p, "retirementA", 3), "real");
|
||||
const frueher = planMetric(applyDriver(p, "retirementA", -3), "real");
|
||||
expect(spaeter).toBeGreaterThan(base);
|
||||
expect(frueher).toBeLessThan(base);
|
||||
});
|
||||
|
||||
it("liefert im Tornado einen Balken mit Spannweite", () => {
|
||||
const t = computeTornado(basePlan(), "real", [{ id: "retirementA", low: -3, high: 3 }]);
|
||||
expect(t.bars[0].swing).toBeGreaterThan(0);
|
||||
expect(t.bars[0].note).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
+67
-2
@@ -17,8 +17,9 @@
|
||||
import { computePlan } from "@/lib/calculations";
|
||||
import { num } from "@/lib/elements";
|
||||
import type { ElementCategory, PhaseData } from "@/lib/elements";
|
||||
import type { ElementInput, PlanInput } from "@/lib/types";
|
||||
import type { ElementInput, PersonRole, PlanInput } from "@/lib/types";
|
||||
import type { ResolvedActuals } from "@/lib/actuals";
|
||||
import { retirementBoundaries, shiftRetirement } from "@/lib/retirement";
|
||||
|
||||
export type DriverId =
|
||||
| "inflation"
|
||||
@@ -27,7 +28,9 @@ export type DriverId =
|
||||
| "income"
|
||||
| "salaryGrowth"
|
||||
| "lifespan"
|
||||
| "propertyGrowth";
|
||||
| "propertyGrowth"
|
||||
| "retirementA"
|
||||
| "retirementB";
|
||||
|
||||
// Die Einheit bestimmt, WAS der eingegebene Wert bedeutet -- das ist je Treiber verschieden
|
||||
// und lässt sich nicht vereinheitlichen, ohne fachlich falsch zu werden:
|
||||
@@ -53,6 +56,24 @@ function hasCategory(plan: PlanInput, categories: ElementCategory[]): boolean {
|
||||
return plan.elements.some((e) => categories.includes(e.category));
|
||||
}
|
||||
|
||||
// Der Pensionsalter-Regler erscheint nur, wenn sich die Grenze überhaupt verschieben lässt --
|
||||
// ein Regler ohne Spielraum wäre schlimmer als keiner (siehe `retirementBoundaries`).
|
||||
function retirementDriverApplies(plan: PlanInput, role: PersonRole): boolean {
|
||||
const b = retirementBoundaries(plan).find((x) => x.role === role);
|
||||
return !!b && !b.blocked && (b.minDelta !== 0 || b.maxDelta !== 0);
|
||||
}
|
||||
|
||||
// Erlaubter Bereich des Reglers: bis zur Zusammenlegung, aber nicht darüber hinaus. Der
|
||||
// Tornado/die Live-Simulation legen bewusst NICHT zusammen -- das würde Übergangs-Entscheide
|
||||
// zusammenführen, also den Plan inhaltlich verändern, und dafür ist eine Was-wäre-wenn-
|
||||
// Betrachtung der falsche Ort. Deshalb endet der Spielraum bei minDelta/maxDelta.
|
||||
export function retirementDriverRange(plan: PlanInput, id: "retirementA" | "retirementB"): { min: number; max: number } {
|
||||
const role: PersonRole = id === "retirementA" ? "PERSON_A" : "PERSON_B";
|
||||
const b = retirementBoundaries(plan).find((x) => x.role === role);
|
||||
if (!b || b.blocked) return { min: 0, max: 0 };
|
||||
return { min: b.minDelta, max: b.maxDelta };
|
||||
}
|
||||
|
||||
export const DRIVERS: DriverDef[] = [
|
||||
{
|
||||
id: "expenses",
|
||||
@@ -108,6 +129,27 @@ export const DRIVERS: DriverDef[] = [
|
||||
"Verschiebung der jährlichen nominalen Lohnerhöhung in Prozentpunkten. Sinnvolle Bandbreite: −1 bis +1 Prozentpunkt. Wirkt nur über die verbleibenden Erwerbsjahre und ist deshalb meist ein schwacher Hebel.",
|
||||
applies: (p) => hasCategory(p, ["INCOME"]),
|
||||
},
|
||||
// Pensionsalter (Roadmap Nr. 44). Verschiebt die Phasengrenze, an der die Person in
|
||||
// Pension geht -- die Phase davor wird länger, die danach kürzer, die Gesamtdauer bleibt.
|
||||
// Der Hebel ist doppelt (länger Einkommen UND kürzer Verzehr) und deshalb meist gross.
|
||||
{
|
||||
id: "retirementA",
|
||||
label: "Pensionsalter Person A",
|
||||
shortLabel: "Pensionsalter A",
|
||||
unit: "delta_years",
|
||||
help:
|
||||
"Verschiebt das Pensionsalter der Person A. Sinnvolle Bandbreite: −3 bis +3 Jahre. Der Spielraum endet dort, wo eine angrenzende Lebensphase verschwinden würde – darüber hinaus eingegebene Jahre werden auf das Mögliche gekürzt.",
|
||||
applies: (p) => retirementDriverApplies(p, "PERSON_A"),
|
||||
},
|
||||
{
|
||||
id: "retirementB",
|
||||
label: "Pensionsalter Person B",
|
||||
shortLabel: "Pensionsalter B",
|
||||
unit: "delta_years",
|
||||
help:
|
||||
"Verschiebt das Pensionsalter der Person B. Sinnvolle Bandbreite: −3 bis +3 Jahre. Der Spielraum endet dort, wo eine angrenzende Lebensphase verschwinden würde – darüber hinaus eingegebene Jahre werden auf das Mögliche gekürzt.",
|
||||
applies: (p) => retirementDriverApplies(p, "PERSON_B"),
|
||||
},
|
||||
{
|
||||
id: "propertyGrowth",
|
||||
label: "Wertsteigerung der Immobilie",
|
||||
@@ -191,6 +233,18 @@ export function applyDriver(plan: PlanInput, id: DriverId, value: number): PlanI
|
||||
teuerungsausgleich: num(pd.teuerungsausgleich, 0) + value,
|
||||
}));
|
||||
|
||||
case "retirementA":
|
||||
case "retirementB": {
|
||||
// Auf den möglichen Spielraum kürzen statt den Plan unverändert zu lassen: Eine
|
||||
// eingegebene Bandbreite von ±5 Jahren soll auch dann etwas zeigen, wenn nur ±2
|
||||
// möglich sind. Wie weit tatsächlich verschoben wurde, steht in der Ergebnistabelle.
|
||||
const range = retirementDriverRange(plan, id);
|
||||
const delta = Math.max(range.min, Math.min(range.max, Math.round(value)));
|
||||
if (delta === 0) return plan;
|
||||
const role: PersonRole = id === "retirementA" ? "PERSON_A" : "PERSON_B";
|
||||
return shiftRetirement(plan, role, delta)?.plan ?? plan;
|
||||
}
|
||||
|
||||
case "lifespan": {
|
||||
// Verlängert/verkürzt die LETZTE Phase. Bewusst nicht das Pensionsalter: das lässt
|
||||
// sich ohne Mitverschieben der Phasengrenzen nicht sinnvoll variieren (siehe 9.18).
|
||||
@@ -289,6 +343,17 @@ export function ineffectiveReason(plan: PlanInput, id: DriverId): string {
|
||||
return "Wirkungslos, weil die Immobilie vor Planende verkauft wird: Der Verkaufserlös ergibt sich aus dem erfassten Verkaufspreis, nicht aus dem modellierten Verkehrswert. Die aufgelaufene Wertsteigerung wird beim Verkauf verworfen.";
|
||||
}
|
||||
}
|
||||
if (id === "retirementA" || id === "retirementB") {
|
||||
const range = retirementDriverRange(plan, id);
|
||||
if (range.min === 0 && range.max === 0) {
|
||||
const role: PersonRole = id === "retirementA" ? "PERSON_A" : "PERSON_B";
|
||||
const b = retirementBoundaries(plan).find((x) => x.role === role);
|
||||
return (
|
||||
b?.blocked ??
|
||||
"Die angrenzenden Lebensphasen dauern bereits nur ein Jahr – es bleibt kein Spielraum. Passe zuerst die Phasendauern an."
|
||||
);
|
||||
}
|
||||
}
|
||||
return "Dieser Parameter bewegt das Endvermögen in diesem Plan nicht.";
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user