AHV einkommensabhaengig + Warnhinweis bei Aenderungen in frueheren Phasen
Deploy App / deploy (push) Successful in 59s

Roadmap Nr. 3: Die AHV-Rente folgt neu der amtlichen Rentenformel (Skala 44) ueber
das massgebende durchschnittliche Jahreseinkommen statt pauschal der Maximalrente.

- Formel aus den amtlichen Randbedingungen hergeleitet und gegen die Tabelle
  318.117.1 verifiziert: 51/51 Zeilen exakt. Schwellen sind Vielfache von R0=1'260
  (12/36/72 x R0 = 15'120 / 45'360 / 90'720). Stuetzstellen als Golden Tests.
- Alles REAL gerechnet: die AHV wertet vergangene Einkommen auf UND indexiert die
  Schwellen -- real hebt sich das auf. Nominal wuerde die Rente systematisch zu hoch
  ausfallen (Beispiel: faelschlich Maximalrente, ~41'600 ueber 25 Rentenjahre).
- Pruefung der Beitragskarriere am Pensions-Uebergang; Zusatzfelder fuer die Jahre
  vor Planbeginn nur, wenn der Plan nicht bis Alter 21 zurueckreicht.
- Sonderfall "bei Planbeginn bereits pensioniert": Felder in der Phasenzelle.
- Ohne Pruefung gilt der geplante Durchschnitt (nicht 0) -- sonst waere die Rente
  still viel zu tief.
- 13. Altersrente: Jahresbetrag = Monatsrente x 13.

Roadmap Nr. 4: Warnhinweis in Phasenzellen und Phasen-Detail, wenn Folgephasen
existieren -- Werte schreiben sich fort und wirken bis ans Planende durch.

Verhaltensaenderung fuer bestehende Plaene: siehe SPEZIFIKATION 9.10.
Zwoelf Regressionstests (18 -> 30). Spezifikation auf v0.4.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 09:02:42 +02:00
parent 9f5bd754eb
commit e901d23970
8 changed files with 771 additions and 53 deletions
+150 -4
View File
@@ -2,9 +2,11 @@
import { useState } from "react";
import { Trash2 } from "lucide-react";
import { AlertTriangle } from "lucide-react";
import { FieldLabel, MoneyField, NumberField, SelectField, TextField } from "@/components/FormField";
import { formatChf } from "@/lib/format";
import { api } from "@/lib/api-client";
import { ahvAnnualPension, ahvMdje, type AhvCareer } from "@/lib/calculations";
import { CATEGORY_LABELS, num } from "@/lib/elements";
import {
DEFAULT_CAPITAL_TAX_RATE,
@@ -31,6 +33,10 @@ export interface CellContext {
carried: boolean; // Phase >= 2: Basiswert wird aus der Vorphase fortgeschrieben
derivedStart: number; // fortgeschriebener Basiswert (read-only Anzeige)
deflatorStart: number; // Kaufkraft-Deflator zu Phasenbeginn (real <-> nominal, erstes Jahr)
// Warnhinweis: Anzahl Phasen NACH dieser (Aenderungen schreiben sich dorthin fort).
laterPhaseCount: number;
// AHV-Beitragskarriere des Element-Besitzers (fuer die Pruefung am Pensions-Uebergang).
ahvCareer: AhvCareer | null;
}
interface Props {
@@ -42,6 +48,24 @@ interface Props {
onDeleteElement: () => void;
}
// Warnhinweis beim Bearbeiten einer Phase, der noch Phasen folgen. Seit V3 werden Werte live
// fortgeschrieben (Endwert = Startwert der Folgephase) -- eine Aenderung hier wirkt also bis
// ans Planende durch, was ohne Hinweis leicht uebersehen wird.
export function CarryWarning({ laterPhaseCount }: { laterPhaseCount: number }) {
if (laterPhaseCount <= 0) return null;
const phasen = laterPhaseCount === 1 ? "die nachfolgende Lebensphase" : `die ${laterPhaseCount} nachfolgenden Lebensphasen`;
return (
<div className="col-span-full flex items-start gap-2 rounded-lg border border-danger bg-danger-soft px-3 py-2 text-xs text-danger">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<span>
<strong>Achtung: Diese Lebensphase ist nicht die letzte.</strong> Werte werden fortgeschrieben
(Endwert = Startwert der Folgephase). Eine Aenderung hier wirkt sich auf {phasen} aus und
veraendert deren Startwerte, Kennzahlen sowie Endvermoegen, Cash-Verlauf und Ruinalter.
</span>
</div>
);
}
// Read-only Anzeige eines abgeleiteten (fortgeschriebenen) Wertes.
function DerivedField({ label, value, help }: { label: string; value: number; help?: string }) {
return (
@@ -111,9 +135,86 @@ function WithdrawalDecision({
// Vorbelegung expliziter Entscheide, damit ein blosses "Speichern" den sichtbaren Default
// (Halten / Kein Bezug / Rente) auch tatsaechlich persistiert.
// AHV-Pruefung am Pensions-Uebergang: geplantes Durchschnittseinkommen aus dem Plan plus --
// nur falls der Plan nicht bis zum Beitragsbeginn (Alter 21) zurueckreicht -- die Jahre davor.
export function AhvReviewFields({
career,
td,
setT,
}: {
career: AhvCareer;
td: TransitionData;
setT: (patch: Partial<TransitionData>) => void;
}) {
const brauchtVorgeschichte = career.yearsBeforePlan > 0;
// Vorbelegung mit dem geplanten Durchschnitt: besser als 0, wenn der Nutzer nichts weiss.
const avgBefore = typeof td.avgIncomeBefore === "number" ? td.avgIncomeBefore : Math.round(career.plannedAvgIncome);
const gapBefore = Math.max(0, Math.round(num(td.gapYearsBefore)));
const mdJE = ahvMdje(career, brauchtVorgeschichte ? avgBefore : 0, gapBefore);
const rente = ahvAnnualPension(mdJE, career.gapYearsInPlan + gapBefore);
return (
<>
<p className="col-span-full rounded-lg bg-surface-2 px-3 py-2 text-xs text-muted">
Die AHV-Rente haengt vom <strong className="text-fg">massgebenden durchschnittlichen Jahreseinkommen</strong>{" "}
ueber die ganze Beitragsdauer (ab Alter 21) ab. Alle Betraege sind REAL (heutige Kaufkraft)
die AHV wertet vergangene Einkommen auf und indexiert die Schwellen, was sich real weitgehend aufhebt.
</p>
<DerivedField
label="Geplantes Durchschnittseinkommen (aus dem Plan)"
value={Math.round(career.plannedAvgIncome)}
help={`Reales Mittel ueber ${career.planYears} Beitragsjahre der Erwerbsphasen dieses Plans. Nur Einkommen, die dieser Person zugeordnet sind.`}
/>
<DerivedField
label="Beitragsjahre im Plan"
value={career.planYears}
help="Dauer der Erwerbsphasen abzueglich der dort erfassten Ausfalljahre."
/>
{brauchtVorgeschichte && (
<>
<p className="col-span-full rounded-lg border border-dashed border-border px-3 py-2 text-xs text-muted">
Der Plan beginnt erst im Alter {career.yearsBeforePlan + 21}. Die {career.yearsBeforePlan} Beitragsjahre
davor kennt das Tool nicht bitte ergaenzen.
</p>
<MoneyField
label="Durchschnittseinkommen vor Planbeginn (real)"
help="In HEUTIGER Kaufkraft. Am besten aus der AHV-Rentenvorausberechnung uebernehmen dort ist die Aufwertung bereits enthalten. Die Rohbetraege im Kontoauszug sind historisch-nominal und daher zu tief."
value={avgBefore}
onChange={(v) => setT({ avgIncomeBefore: v })}
/>
<NumberField
label="Ausfalljahre vor Planbeginn"
help="Jahre ohne AHV-Beitraege zwischen Alter 21 und Planbeginn (z. B. Ausland, Studium). Jedes kuerzt die Rente um 1/44."
value={gapBefore}
min={0}
max={career.yearsBeforePlan}
onChange={(v) => setT({ gapYearsBefore: Math.max(0, Math.min(career.yearsBeforePlan, Math.round(v))) })}
/>
</>
)}
<DerivedField
label="Massgebendes durchschnittliches Jahreseinkommen"
value={Math.round(mdJE)}
help="Gewichtetes Mittel ueber alle Beitragsjahre (vor Planbeginn und im Plan). Ab 90'720 gibt es die Maximalrente."
/>
<DerivedField
label="Resultierende AHV-Rente pro Jahr"
value={rente}
help="Vollrente zum mdJE (Skala 44), inkl. 13. Altersrente, gekuerzt um die Ausfalljahre. Bei Ehepaaren zusaetzlich plafoniert."
/>
</>
);
}
export function withTransitionDefaults(category: ElementCategory, isRetirement: boolean, td: TransitionData): TransitionData {
const out = { ...td };
if (category === "REAL_ESTATE" || category === "OTHER_ASSET") {
if (category === "AHV") {
// Ein Speichern der Pruefung markiert sie als erledigt (gleiches Muster wie bei PK/3a).
if (isRetirement && out.reviewed === undefined) out.reviewed = true;
} else if (category === "REAL_ESTATE" || category === "OTHER_ASSET") {
if (out.decision === undefined) out.decision = "HOLD";
} else if (category === "PENSION_FUND") {
if (isRetirement) {
@@ -271,6 +372,9 @@ export function CashTransitionFields({
// "Beantwortet" = ein konkreter Entscheid liegt vor (kein offenes Fragezeichen).
export function isTransitionAnswered(category: ElementCategory, isRetirement: boolean, td: TransitionData): boolean {
switch (category) {
case "AHV":
// Nur bei der Pensionierung ist eine Pruefung der Beitragskarriere noetig.
return isRetirement ? td.reviewed === true : true;
case "REAL_ESTATE":
case "OTHER_ASSET":
return td.decision !== undefined;
@@ -342,10 +446,42 @@ export function ElementPhaseFields({
}
case "AHV":
if (!context.ownerWorking) {
// Sonderfall: bei Planbeginn bereits pensioniert -> es gibt keinen Pensions-Uebergang,
// an dem die Karriere geprueft werden koennte. Dann hier erfassen (nur erste Phase).
if (!carried && context.ahvCareer && context.ahvCareer.planYears === 0) {
return (
<>
<p className="col-span-2 rounded-lg bg-surface-2 px-3 py-2 text-xs text-muted">
Diese Person ist bei Planbeginn bereits pensioniert. Die AHV-Rente haengt vom
massgebenden durchschnittlichen Jahreseinkommen ueber die ganze Beitragsdauer ab
bitte hier erfassen (REAL, heutige Kaufkraft).
</p>
<MoneyField
label="Durchschnittseinkommen der Beitragsjahre (real)"
help="Am besten aus der AHV-Rentenvorausberechnung bzw. der Rentenverfuegung. Ab 90'720 gibt es die Maximalrente."
value={num(pd.avgIncomeBefore)}
onChange={(v) => setP({ avgIncomeBefore: v })}
/>
<NumberField
label="Ausfalljahre total"
help="Jahre ohne AHV-Beitraege. Jedes kuerzt die Rente um 1/44."
value={num(pd.gapYearsBefore)}
min={0}
onChange={(v) => setP({ gapYearsBefore: Math.max(0, Math.round(v)) })}
/>
<DerivedField
label="Resultierende AHV-Rente pro Jahr"
value={ahvAnnualPension(num(pd.avgIncomeBefore), num(pd.gapYearsBefore))}
help="Inkl. 13. Altersrente. Bei Ehepaaren zusaetzlich plafoniert."
/>
</>
);
}
return (
<p className="col-span-2 text-sm text-muted">
Die AHV-Rente wird automatisch aus den bisherigen Ausfalljahren berechnet (siehe Kennzahl in der
Matrix). Bei Ehepaaren greift die Plafonierung auf 150% der Maximalrente.
Die AHV-Rente wird aus der beim Pensions-Uebergang geprueften Beitragskarriere berechnet
(massgebendes Durchschnittseinkommen und Ausfalljahre). Bei Ehepaaren greift die
Plafonierung auf 150% der Maximalrente.
</p>
);
}
@@ -517,13 +653,21 @@ export function ElementTransitionFields({
switch (element.category) {
case "INCOME":
case "EXPENSE":
case "AHV":
return (
<p className="col-span-2 text-sm text-muted">
Fuer diese Kategorie gibt es im Uebergang keine Eingaben. Die Werte werden 1:1 in die naechste
Lebensphase uebernommen und koennen dort angepasst werden.
</p>
);
case "AHV":
if (context.isRetirementTransition && context.ahvCareer) {
return <AhvReviewFields career={context.ahvCareer} td={td} setT={setT} />;
}
return (
<p className="col-span-2 text-sm text-muted">
Die Beitragskarriere wird erst beim Uebergang in die Pensionierung geprueft.
</p>
);
case "PENSION_FUND":
if (context.isRetirementTransition) {
const mode = td.payoutMode ?? "PENSION";
@@ -689,6 +833,8 @@ export function ElementDetail({ element, context, phaseData, transitionData, onS
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{/* Punkt 4: Warnung, dass Aenderungen sich in die Folgephasen fortschreiben. */}
{!isTransition && <CarryWarning laterPhaseCount={context.laterPhaseCount} />}
{isTransition ? (
<ElementTransitionFields element={element} context={context} td={td} setT={setT} />
) : (
+5
View File
@@ -3,6 +3,7 @@
import { useState } from "react";
import { Trash2 } from "lucide-react";
import { NumberField, TextField } from "@/components/FormField";
import { CarryWarning } from "@/components/ElementDetail";
import { api } from "@/lib/api-client";
import type { PhaseInput } from "@/lib/types";
@@ -10,12 +11,14 @@ export function PhaseDetail({
phase,
maxDurationYears,
isLast,
laterPhaseCount,
onSaved,
onDeleted,
}: {
phase: PhaseInput;
maxDurationYears: number | null;
isLast: boolean;
laterPhaseCount: number;
onSaved: () => void;
onDeleted: () => void;
}) {
@@ -67,6 +70,8 @@ export function PhaseDetail({
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{/* Eine geaenderte Dauer verschiebt alle Folgephasen (Alter, Renten, Vermoegen). */}
<CarryWarning laterPhaseCount={laterPhaseCount} />
<TextField label="Bezeichnung" value={name} onChange={setName} />
<NumberField
label={`Dauer (Jahre)${cap != null ? ` · max. ${cap}` : ""}`}
+45 -18
View File
@@ -60,7 +60,10 @@ const CATEGORY_ICON: Record<ElementCategory, React.ReactNode> = {
OTHER_DEBT: <CreditCard className="h-4 w-4" />,
};
// Kategorien mit einem Uebergangs-Entscheid. AHV ist dabei ein Sonderfall: nur beim
// Pensions-Uebergang ist die Beitragskarriere zu pruefen (siehe transitionInactive).
const TRANSITION_CATEGORIES: ElementCategory[] = [
"AHV",
"PENSION_FUND",
"PILLAR_3A",
"REAL_ESTATE",
@@ -161,6 +164,18 @@ export function PlanView({
return !!before?.working && !!after && !after.working;
}
// Beitragskarriere des Element-Besitzers (nur fuer AHV relevant).
function careerFor(element: ElementInput) {
if (element.category !== "AHV" || !element.ownerRole || element.ownerRole === "HOUSEHOLD") return null;
const person = plan.persons.find((p) => p.role === element.ownerRole);
return computed.ahvCareer.find((c) => c.personId === person?.id) ?? null;
}
// Anzahl Phasen NACH dieser (fuer den Fortschreibungs-Warnhinweis).
function laterPhaseCount(phase: PhaseComputed): number {
return computed.phases.length - phase.sequenceNumber;
}
// Baut den Kontext fuer eine Phasenzelle.
function buildPhaseContext(phase: PhaseComputed, element: ElementInput): CellContext {
const ce = computedElement(phase.id, element.id);
@@ -179,6 +194,8 @@ export function PlanView({
carried: ce?.carried ?? false,
derivedStart: ce?.baseValue ?? 0,
deflatorStart: phase.cumulativeInflationStart,
laterPhaseCount: laterPhaseCount(phase),
ahvCareer: careerFor(element),
};
}
@@ -195,14 +212,20 @@ export function PlanView({
carried: ce?.carried ?? false,
derivedStart: 0,
deflatorStart: fromPhase.cumulativeInflationStart,
laterPhaseCount: laterPhaseCount(fromPhase),
ahvCareer: careerFor(element),
};
}
// Am Uebergang nichts (mehr) zu tun: verkauft/getilgt ODER PK/3a nach der Pensionierung
// (Besitzer ist zu Beginn der Von-Phase bereits pensioniert -> bereits bezogen/verrentet).
function transitionInactive(el: ElementInput, fromPhase: PhaseComputed): boolean {
// (Besitzer ist zu Beginn der Von-Phase bereits pensioniert -> bereits bezogen/verrentet)
// ODER AHV ausserhalb des Pensions-Uebergangs.
function transitionInactive(el: ElementInput, fromPhase: PhaseComputed, toPhase?: PhaseComputed): boolean {
const ce = computedElement(fromPhase.id, el.id);
if (ce && ce.status !== "ACTIVE") return true;
if (el.category === "AHV") {
return !(toPhase && isRetirementTransition(el, fromPhase, toPhase));
}
if (el.category === "PENSION_FUND" || el.category === "PILLAR_3A") {
if (el.ownerRole && el.ownerRole !== "HOUSEHOLD") {
const owner = fromPhase.persons.find((p) => p.role === el.ownerRole);
@@ -222,7 +245,7 @@ export function PlanView({
let n = isCashTransitionAnswered(cashTransitionFor(fromPhase.id)) ? 0 : 1;
for (const el of plan.elements) {
if (!TRANSITION_CATEGORIES.includes(el.category)) continue;
if (transitionInactive(el, fromPhase)) continue;
if (transitionInactive(el, fromPhase, toPhase)) continue;
const td = el.transitionValues[fromPhase.id] ?? {};
const retire = toPhase ? isRetirementTransition(el, fromPhase, toPhase) : false;
if (!isTransitionAnswered(el.category, retire, td)) n++;
@@ -232,18 +255,15 @@ export function PlanView({
// Ist der Uebergangs-Entscheid dieses Elements noch offen?
function transitionUnanswered(el: ElementInput, fromPhase: PhaseComputed, toPhase: PhaseComputed): boolean {
if (transitionInactive(el, fromPhase)) return false;
if (transitionInactive(el, fromPhase, toPhase)) return false;
const retire = isRetirementTransition(el, fromPhase, toPhase);
return !isTransitionAnswered(el.category, retire, el.transitionValues[fromPhase.id] ?? {});
}
function transitionElements(fromPhase: PhaseComputed): ElementInput[] {
function transitionElements(fromPhase: PhaseComputed, toPhase?: PhaseComputed): ElementInput[] {
return plan.elements
.filter((el) => TRANSITION_CATEGORIES.includes(el.category))
.filter((el) => {
const ce = computedElement(fromPhase.id, el.id);
return !ce || ce.status === "ACTIVE";
})
.filter((el) => !transitionInactive(el, fromPhase, toPhase))
.sort((a, b) => a.orderIndex - b.orderIndex);
}
@@ -461,7 +481,7 @@ export function PlanView({
</td>
);
}
const locked = TRANSITION_CATEGORIES.includes(el.category) && transitionInactive(el, col.fromPhase);
const locked = TRANSITION_CATEGORIES.includes(el.category) && transitionInactive(el, col.fromPhase, col.toPhase);
const canTransition = TRANSITION_CATEGORIES.includes(el.category) && !locked;
const open = canTransition && transitionUnanswered(el, col.fromPhase, col.toPhase);
return (
@@ -571,7 +591,7 @@ export function PlanView({
if (!fromPhase) return null;
const toIndex = computed.phases.findIndex((p) => p.id === fromPhase.id) + 1;
const toPhase = computed.phases[toIndex];
const els = transitionElements(fromPhase);
const els = transitionElements(fromPhase, toPhase);
return (
<TransitionReviewDialog
fromPhase={fromPhase}
@@ -660,6 +680,7 @@ export function PlanView({
phase={phaseInput}
maxDurationYears={phase.maxDurationYears}
isLast={isLast}
laterPhaseCount={laterPhaseCount(phase)}
onSaved={onChanged}
onDeleted={() => {
setSelected(null);
@@ -679,6 +700,8 @@ export function PlanView({
function transitionSummary(el: ElementInput, fromPhase: PhaseComputed, toPhase: PhaseComputed): string {
const td = el.transitionValues[fromPhase.id] ?? {};
switch (el.category) {
case "AHV":
return td.reviewed === true ? "Geprueft" : "?";
case "REAL_ESTATE":
case "OTHER_ASSET":
return td.decision === "SELL" ? "Verkauf" : td.decision === "HOLD" ? "Halten" : "?";
@@ -913,6 +936,8 @@ function AddElementDialog({
carried: false,
derivedStart: 0,
deflatorStart: firstPhase.cumulativeInflationStart,
laterPhaseCount: 0, // beim Anlegen bewusst kein Warnhinweis
ahvCareer: null,
};
async function create() {
@@ -1175,13 +1200,15 @@ function TransitionReviewDialog({
const ctx = buildContext(el);
const retire = isRetirement(el);
const hint =
(el.category === "PENSION_FUND" || el.category === "PILLAR_3A") && !retire
? "Hier könnten Sie optional Kapital beziehen."
: el.category === "PENSION_FUND" && retire
? "Pensionierung: Bezugsart wählen (Rente / Kapital / Kombination)."
: el.category === "PILLAR_3A" && retire
? "Wird bei Pensionierung vollständig bezogen."
: null;
el.category === "AHV" && retire
? "Pensionierung: Beitragskarriere prüfen die Rente hängt vom Durchschnittseinkommen ab."
: (el.category === "PENSION_FUND" || el.category === "PILLAR_3A") && !retire
? "Hier könnten Sie optional Kapital beziehen."
: el.category === "PENSION_FUND" && retire
? "Pensionierung: Bezugsart wählen (Rente / Kapital / Kombination)."
: el.category === "PILLAR_3A" && retire
? "Wird bei Pensionierung vollständig bezogen."
: null;
return (
<div key={el.id} className="rounded-xl border border-border bg-surface-2 p-3">
<div className="mb-2 flex items-center gap-2">