Files
FPT/src/lib/calculations.ts
T
admGitAICDS 6ff144d7e1
Deploy App / deploy (push) Successful in 1m57s
Assistent auf zwei Schritte, Pensionsalter fix 65, Horizont abgeleitet
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 12:18:56 +02:00

2097 lines
105 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
AHV_CONTRIBUTION_START_AGE,
AHV_COUPLE_CAP_FACTOR,
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,
DEFAULT_PK_CONVERSION_RATE,
DEFAULT_PROPERTY_GAINS_TAX_RATE,
PILLAR_3A_MAX_WITHDRAWAL_AGE,
PILLAR_3A_MIN_WITHDRAWAL_AGE,
PK_MAX_RETIREMENT_AGE,
PK_MIN_RETIREMENT_AGE,
} from "@/lib/constants";
import { num } from "@/lib/elements";
import { actualsForYear, rebaseFlow, type ResolvedActuals } from "@/lib/actuals";
import { planFixpoints, type Fixpoint, type FixpointPerson } from "@/lib/phaseplan";
import {
ahvDrawLabel,
ahvFactor,
ahvShiftMonths,
ahvStartAge,
withRetirementDefaults,
type RetirementDecision,
} from "@/lib/retirement-decision";
import type { ElementCategory, PhaseData } from "@/lib/elements";
import type { ElementInput, PersonRole, PlanInput } from "@/lib/types";
export type PhaseType = "ERWERB" | "PENSION" | "MIXED";
export type ElementStatus = "ACTIVE" | "SOLD" | "SETTLED";
export interface PersonPhaseInfo {
personId: string;
role: PersonRole;
startAge: number;
endAge: number;
working: boolean;
retiresAtStart: boolean;
}
// --- Rechenweg-Protokoll (Roadmap Nr. 41) ------------------------------------------------
// Ein Schritt hält die Formel UND die eingesetzten Zahlen fest. Entscheidend: Die Schritte
// entstehen INNERHALB der Berechnung, als Nebenprodukt der Rechnung, die ohnehin läuft.
// Damit kann die angezeigte Herleitung nicht von der tatsächlichen Rechnung abdriften --
// eine zweite Formel-Implementierung im UI wäre die gefährlichste Variante überhaupt.
export interface TraceStep {
label: string;
formula?: string; // abstrakt, z. B. "Basis x (1 + Lohnerhöhung)^(t-1)"
substituted?: string; // mit eingesetzten Zahlen, z. B. "100'000 x (1 + 2%)^4"
result: number;
unit?: "CHF" | "%" | "Jahre" | "";
note?: string; // Vereinfachung/Grenze, die an dieser Stelle gilt
}
export interface Trace {
title: string;
specAnchor?: string; // Sprungmarke in die SPEZIFIKATION (Kapitel 4)
steps: TraceStep[];
}
export interface ComputeOptions {
// Standardmässig aus: die Monte-Carlo-Simulation ruft computePlan zehntausendfach auf
// und darf von der Protokollierung nichts merken.
explain?: boolean;
// Effektive (Ist-)Werte, auf DIESES Szenario aufgelöst (siehe `actuals.ts`). Ohne sie
// rechnet die Funktion exakt wie bisher -- die Plan-Sicht bleibt unangetastet.
actuals?: ResolvedActuals[];
}
// Ein Datenpunkt pro Jahr JE ELEMENT -- Grundlage der Detailansicht (Roadmap Nr. 43).
// Nur die Phasengrenzen zu kennen reicht nicht: Eine Anlage mit 5 % über 20 Jahre wächst
// konvex, eine Gerade von Start zu Ende wäre sichtbar falsch (gleiche Überlegung wie beim
// Vermögensverlauf in v0.9).
export interface ElementYearPoint {
year: number; // 1-basiert ab Planbeginn
age: number; // Alter Person A am Jahresende
value: number; // Haupt-Kennzahl (Saldo, Eigenkapital, Flow, Rente)
propertyValue?: number; // nur REAL_ESTATE: Verkehrswert der Liegenschaft
mortgage?: number; // nur REAL_ESTATE: Restschuld
// Die in DIESEM Jahr wirksame Rate in Prozent -- erwartete Rendite (PK/3a/Vermögen),
// Wertsteigerung (Immobilie) bzw. jährliche Anpassung (Einkommen/Ausgaben). Rein für die
// Darstellung; die Rechnung benutzt denselben Wert, der hier nur mitgeführt wird.
// Innerhalb einer Phase konstant, an der Phasengrenze springt sie.
rate?: number;
}
export interface ElementPhaseComputed {
elementId: string;
category: ElementCategory;
name: string;
ownerRole: string | null;
status: ElementStatus;
locked: boolean;
carried: boolean; // Phase >= 2: Start-/Basiswert wird aus der Vorphase fortgeschrieben
baseValue: number; // fortgeschriebener Basiswert (read-only Anzeige ab Phase 2; ohne Zusatzeinlage)
mortgageStart: number; // nur REAL_ESTATE: Resthypothek zu Phasenbeginn (0 sonst)
mortgageEnd: number; // nur REAL_ESTATE: Resthypothek zu Phasenende (0 sonst)
startValue: number; // Wert/Flow zu Phasenbeginn (Aktiven +, Schulden -, Einkommen/Ausgaben = Flow Jahr 1)
endValue: number; // Wert/Flow am Phasenende (letztes Jahr)
summary: string;
note: string | null;
// Betrag, der aus einem Kapitalbezug (PK/3a) des vorigen Übergangs in dieses Element
// umgeleitet wurde (Punkt C). Nur bei OTHER_ASSET und nur ab Phase 2 > 0.
capitalFromTransfer?: number;
capitalFromTransferSources?: { name: string; amount: number }[];
// Nimmt dieses Element in DIESER Phase überhaupt noch Geld auf? PK und Säule 3a nicht mehr,
// sobald der Besitzer pensioniert ist -- die Rechnung ignoriert dort Beiträge und
// Zusatzeinlagen. Die Verteil-Dialoge dürfen solche Zeilen deshalb gar nicht erst anbieten
// (sonst tippt man Beträge ein, die wirkungslos bleiben). Fehlt das Feld: nimmt auf.
acceptsCapital?: boolean;
yearly: ElementYearPoint[]; // Verlauf innerhalb dieser Phase
trace?: Trace; // Rechenweg der Phasenwerte (nur mit explain)
transitionTrace?: Trace; // Rechenweg des Übergangs NACH dieser Phase (nur mit explain)
}
// --- Wasserfall-Zerlegungen (Roadmap Nr. 43) ---------------------------------------------
//
// WICHTIG -- der Unterschied zwischen den beiden: Sparraten, Amortisationen und
// Zusatzinvestitionen sind UMBUCHUNGEN. Sie verlassen das Cash und erhöhen im selben Zug
// einen Vermögenswert bzw. senken eine Schuld; das Vermögen bleibt unverändert. Sie
// gehören deshalb in die Cash-Brücke und ausdrücklich NICHT in die Vermögens-Brücke --
// dort als Abgang gezeichnet würden sie eine Vermögensminderung suggerieren, die es nicht
// gibt.
export interface WealthBridge {
openingWealth: number; // Endvermögen der Vorphase (Phase 1: Startvermögen)
// Übergang IN diese Phase -- echte Vermögensänderungen an der Phasengrenze:
oneOffInflow: number; // einmaliger Zufluss (netto nach Steuer)
oneOffOutflow: number; // einmalige Kosten
transitionTax: number; // Kapitalbezugs- und Grundstückgewinnsteuer (Abgang)
pensionConversion: number; // in eine Rente umgewandeltes PK-Kapital (verlässt die Bilanz)
saleGainLoss: number; // Verkaufspreis minus Verkehrswert bei einem Immobilienverkauf
startWealth: number; // Kontrollpunkt = startWealthNominal
// Innerhalb der Phase:
quotaTotal: number; // Summe (Einkommen - nominale Ausgaben) über alle Phasenjahre
investmentReturn: number; // Rendite auf PK/3a/Sonstigem Vermögen
propertyAppreciation: number; // Wertsteigerung der Liegenschaft
pensionFundContribution: number; // PK-Beiträge: erhöhen das Vermögen, ohne Cash zu kosten
// Sprung auf die erfassten Ist-Werte (nur in der Ist-Sicht, sonst 0). Bewusst als eigene
// Position: Die Differenz zwischen Plan und Wirklichkeit ist KEINE Rendite und darf nicht
// als solche erscheinen -- ohne diese Zeile ginge die Brücke im Ist-Jahr nicht auf.
actualsCorrection: number;
endWealth: number; // = endWealthNominal
residual: number; // Rundungsdifferenz (Kontrollgrösse, sollte nahe 0 sein)
}
export interface CashBridge {
openingCash: number; // Cash-Endbestand der Vorphase (Phase 1: initialCash)
capitalInflow: number; // Verkäufe + PK-/3a-Bezüge aus dem Übergang
oneOffInflow: number;
immediateRepay: number; // Sofort-Tilgungen und Sonderamortisation (Abgang)
oneOffOutflow: number;
investments: number; // Zusatz-/Neuinvestitionen am Phasenanfang (Abgang)
cashStart: number; // Kontrollpunkt
quotaTotal: number;
savingRates: number; // 3a + Sparbeiträge (Abgang)
debtRates: number; // Amortisationen + Tilgungen (Abgang)
withdrawals: number; // Bezugsraten aus Sonstigem Vermögen (Zugang)
actualsCorrection: number; // Sprung auf den erfassten Ist-Cashbestand (sonst 0)
cashEnd: number;
residual: number;
}
export interface PhaseComputed {
id: string;
name: string;
sequenceNumber: number;
durationYears: number;
type: PhaseType;
persons: PersonPhaseInfo[];
maxDurationYears: number | null;
// Einkommen/Ausgaben als indexierte Flows: Wert im ersten und im letzten Phasenjahr.
incomeStart: number;
incomeEnd: number;
expenseStart: number;
expenseEnd: number;
// Spar-/Verzehrquote zu Phasenbeginn (Jahr 1) und Phasenende (letztes Jahr).
quotaStart: number;
quotaEnd: number;
isConsumption: boolean;
// Geplante Sparrate im ERSTEN Phasenjahr: 3a + Sonstiges-Vermögen-Sparbeitrag + Amort. +
// Tilgung. Amortisation/Tilgung entfallen, sobald Hypothek/Schuld abbezahlt sind -- die Rate
// kann in späteren Phasenjahren also tiefer liegen.
plannedSaveRate: number;
plannedWithdrawRate: number; // geplante Verzehrrate: Bezugsraten aus Sonstigem Vermögen
capitalInflow: number; // Kapitalzufluss: PK-/3a-Bezüge + Verkäufe (aus dem Übergang in diese Phase)
capitalInvest: number; // Kapitalinvestitionen: Zusatz-/Neuinvestitionen + sofortige Tilgungen
// Einmalige Sonderein-/ausgaben aus dem Übergang in DIESE Phase (nominal, netto nach Steuer).
// Bewusst getrennt von capitalInflow/capitalInvest: eine Erbschaft ist kein Verkaufserlös,
// ein Poolbau keine Kapitalinvestition.
oneOffInflow: number;
oneOffInflowLabel: string | null;
oneOffOutflow: number;
oneOffOutflowLabel: string | null;
cashStart: number;
cashEnd: number;
cashNegative: boolean; // Cash fällt in dieser Phase (irgendwann) unter 0 -> Liquiditätslücke
incomplete: boolean; // roter Status = Liquiditätslücke
elements: ElementPhaseComputed[];
startWealthNominal: number; // inkl. Cash
endWealthNominal: number; // inkl. Cash
cumulativeInflationStart: number; // Kaufkraft-Deflator zu Phasenbeginn (Bestandswerte)
cumulativeInflationEnd: number; // Kaufkraft-Deflator am Phasenende (Bestandswerte)
flowDeflatorEnd: number; // Deflator für den Flow-Endwert (Jahr `duration`)
endWealthReal: number;
wealthBridge: WealthBridge;
cashBridge: CashBridge;
traces?: Trace[]; // Rechenwege der Phasen-Kennzahlen (nur mit explain)
}
// Ein Datenpunkt pro Jahr (über alle Phasen), für die Verlaufsgrafik.
export interface YearPoint {
year: number; // 1-basiert ab Planbeginn
age: number; // Alter Person A am Jahresende
income: number; // Einkommen inkl. Renten (nominal)
expenseNominal: number;
expenseReal: number;
// Gesamtvermögen am Jahresende (inkl. Cash, abzgl. Schulden) -- für den
// Vermögensverlauf über ALLE Jahre statt nur über die Phasengrenzen.
wealthNominal: number;
wealthReal: number;
// Cash-Bestand am Jahresende. Die Brücken führen Cash nur je PHASE (Anfang/Ende) -- für
// eine Aussage zu einem einzelnen Jahr (Vorbelegung der effektiven Werte, Kap. 3.9.2)
// reicht das nicht: In einer Phase über zehn Jahre ist der Phasen-Endwert etwas ganz
// anderes als der Stand in der Mitte.
cash: number;
}
// Kennzahlen des Pensionierungs-Bildschirms. Bewusst im Rechenkern und nicht im UI: Es sind
// die beiden Zahlen, an denen die ganze Planung haengt -- eine Nebenrechnung in der
// Komponente wuerde im PDF-Bericht anders ausfallen.
export interface RetirementPersonSummary {
personId: string;
role: PersonRole;
retirementAge: number;
planningHorizonAge: number | null;
ahvAnnual: number; // inkl. Kuerzung bei Vorbezug bzw. Zuschlag bei Aufschub
ahvFromAge: number; // individuelles Rentenalter, nicht zwingend das Referenzalter
ahvDraw: string; // lesbare Kurzfassung ("Referenzalter", "Vorbezug 2 J. (-13.6 %)")
pkPensionAnnual: number;
capitalAtRetirement: number; // PK-Kapital + Saeule 3a, NETTO nach Kapitalbezugssteuer
}
export interface RetirementOverview {
// Planjahr, ab dem NIEMAND mehr erwerbstaetig ist. null, solange jemand arbeitet.
firstRetirementYear: number | null;
pensionIncome: number | null;
expenses: number | null;
// Renteneinkommen minus Ausgaben im ersten voll pensionierten Jahr. Negativ = Luecke, die
// aus dem Vermoegen zu decken ist.
gapAnnual: number | null;
perPerson: RetirementPersonSummary[];
}
export interface PlanComputed {
phases: PhaseComputed[];
yearly: YearPoint[];
nachlass: number;
retirement: RetirementOverview;
ruinAge: number | null; // Alter (Person A), in dem das Gesamtvermögen (inkl. Cash) erstmals < 0 fällt
ahvCareer: AhvCareer[]; // Beitragskarriere je Person (für die AHV-Prüfung am Übergang)
traces?: Trace[]; // plan-weite Rechenwege: Deflatoren, AHV-Karriere, Ruinalter (nur mit explain)
}
// --- AHV-Rentenformel (Skala 44) ---------------------------------------------------------
// Amtliche Rentenformel des BSV. Alle Schwellen sind Vielfache von R0 (Mindestrente):
// mdJE <= 12 x R0 -> Mindestrente R0
// 12 x R0 < mdJE <= 36 x R0 -> Formel 1: R0 x (0.74 + 0.26 x mdJE/(12 R0))
// 36 x R0 < mdJE < 72 x R0 -> Formel 2: R0 x (1.04 + 0.16 x mdJE/(12 R0))
// mdJE >= 72 x R0 -> Maximalrente 2 x R0
// Verifiziert gegen die amtliche Tabelle 318.117.1 (51/51 Zeilen exakt, siehe Tests).
// Bewusst STETIG gerechnet: die amtliche Tabelle stuft das mdJE auf Vielfache von 1.2 x R0;
// für eine Planung ist der stetige Wert näher an der Wahrheit (Abweichung < 20/Monat).
export function ahvMonthlyFullPension(mdJE: number): number {
const r0 = AHV_MIN_MONTHLY_FULL;
if (mdJE <= 12 * r0) return r0;
if (mdJE >= 72 * r0) return 2 * r0;
const x = mdJE / (12 * r0);
return mdJE <= 36 * r0 ? r0 * (0.74 + 0.26 * x) : r0 * (1.04 + 0.16 * x);
}
// Beitragskarriere einer Person für die AHV -- akkumuliert über die Erwerbsphasen des Plans.
// ACHTUNG: Alle Einkommen sind BRUTTO. Das Tool erfasst netto (so stimmt der Cash-Fluss), die
// AHV bemisst sich aber am Bruttolohn -- die Umrechnung passiert beim Aufbau der Karriere.
export interface AhvCareer {
personId: string;
role: PersonRole;
plannedAvgGrossIncome: number; // reales BRUTTO-Durchschnittseinkommen der Beitragsjahre im Plan
planYears: number; // Beitragsjahre im Plan (Dauer abzüglich Ausfalljahre)
yearsBeforePlan: number; // Jahre zwischen Alter 21 und Planbeginn
gapYearsInPlan: number;
}
// Massgebendes durchschnittliches Jahreseinkommen: gewichteter Mittelwert der realen
// Einkommen über ALLE Beitragsjahre (vor Planbeginn + im Plan). REAL gerechnet, weil die
// AHV vergangene Einkommen aufwertet UND die Schwellen indexiert -- beides hebt sich in
// realer Betrachtung weitgehend auf.
// `avgGrossIncomeBefore` ist -- wie die Karriere -- ein BRUTTO-Wert.
export function ahvMdje(career: AhvCareer, avgGrossIncomeBefore: number, gapYearsBefore: number): number {
const yearsBefore = Math.max(0, career.yearsBeforePlan - Math.max(0, gapYearsBefore));
const totalYears = yearsBefore + career.planYears;
if (totalYears <= 0) return 0;
return (avgGrossIncomeBefore * yearsBefore + career.plannedAvgGrossIncome * career.planYears) / totalYears;
}
// Jährliche AHV-Rente: Vollrente zum mdJE, mal 13 Zahlungen, gekürzt um die Ausfalljahre
// (Rentenskala: pro fehlendes Beitragsjahr 1/44).
export function ahvAnnualPension(mdJE: number, totalGapYears: number): number {
const factor = Math.max(
0,
(AHV_FULL_CONTRIBUTION_YEARS - Math.max(0, totalGapYears)) / AHV_FULL_CONTRIBUTION_YEARS
);
return Math.round(ahvMonthlyFullPension(mdJE) * AHV_PENSION_MONTHS * factor);
}
// Jahre zwischen dem AHV-Beitragsbeginn (21) und dem Planbeginn.
export function ahvYearsBeforePlan(ageAtPlanStart: number): number {
return Math.max(0, ageAtPlanStart - AHV_CONTRIBUTION_START_AGE);
}
// Alle Fixpunkte eines Plans als Personen-Sicht für `phaseplan`. Bündelt an EINER Stelle,
// welche Ereignisse eine Phasengrenze erzwingen -- Erwerbsende plus die drei Bezugsbeginne.
export function fixpointPersonsOf(plan: PlanInput): FixpointPerson[] {
return plan.persons.map((p) => {
const own = (cat: ElementCategory) =>
plan.elements.filter((e) => e.category === cat && e.ownerRole === p.role);
const ahvEl = own("AHV")[0];
const pkEl = own("PENSION_FUND")[0];
const rdAhv = ahvEl ? withRetirementDefaults("AHV", p.retirementAge, ahvEl.retirementDecision) : null;
const rdPk = pkEl ? withRetirementDefaults("PENSION_FUND", p.retirementAge, pkEl.retirementDecision) : null;
return {
role: p.role,
name: p.name,
age: p.age,
retirementAge: p.retirementAge,
ahvStartAge: rdAhv ? Math.round(ahvStartAge(rdAhv)) : undefined,
pkWithdrawalAge: rdPk?.pkWithdrawalAge,
pillar3aAges: own("PILLAR_3A")
.map((e) => withRetirementDefaults("PILLAR_3A", p.retirementAge, e.retirementDecision).withdrawalAge)
.filter((x): x is number => typeof x === "number"),
};
});
}
// Maximale Dauer einer neuen Phase bis zum nächsten FIXPUNKT (null = unbegrenzt).
//
// Bis 0.35 zählte nur das Erwerbsende. Seit auch die Bezugsbeginne von AHV, PK und 3a eigene
// Zeitpunkte haben, muss die Kappung sie mitzählen: Fiele ein Bezug mitten in eine Phase,
// würde er auf die nächste Grenze rutschen -- unter Umständen Jahre später.
export function maxPhaseDuration(
persons: { role: PersonRole; age: number; retirementAge: number }[],
yearsBefore: number,
extraFixpoints: Fixpoint[] = []
): number | null {
const caps: number[] = [];
for (const p of persons) {
const startAge = p.age + yearsBefore;
if (startAge < p.retirementAge) caps.push(p.retirementAge - startAge);
}
for (const f of extraFixpoints) {
const d = f.year - yearsBefore;
if (d > 0) caps.push(d);
}
return caps.length > 0 ? Math.min(...caps) : null;
}
interface Carry {
status: ElementStatus;
value: number; // Aktiven-Saldo (PK/3a/Sonstiges Vermögen) am Ende der Vorphase
mortgage: number; // Immobilie: Resthypothek
propertyValue: number; // Immobilie: Verkehrswert am Ende der Vorphase (inkl. Wertsteigerung)
propertyPurchase: number; // Immobilie: ursprünglicher Kaufpreis (Basis der Grundstückgewinnsteuer)
owed: number; // Schulden: Restschuld (positiv)
pkPensionAnnual: number; // PK: jährliche Rente nach Verrentung
flowBasis: number; // Einkommen/Ausgaben: indexierter Basiswert der nächsten Phase
// Betrag, der am letzten Übergang aus einem Kapitalbezug (PK/3a) in dieses Element
// umgeleitet wurde (Roadmap Nr. 44, Punkt C). Wird in der Folgephase wie eine
// Zusatzeinlage behandelt -- dadurch erscheint er im Element, in der Cash-Brücke und im
// Verteil-Dialog, statt unsichtbar im Bestand zu verschwinden.
capitalIn: number;
// Woher der Betrag stammt (PK bzw. 3a, je Person) -- damit im UI nachvollziehbar bleibt,
// welcher Bezug wohin geflossen ist. Bei zwei Personen fliessen sonst mehrere Beträge
// ununterscheidbar in dasselbe Element.
capitalInSources: { name: string; amount: number }[];
// 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;
}
function emptyCarry(): Carry {
return {
status: "ACTIVE",
value: 0,
mortgage: 0,
propertyValue: 0,
propertyPurchase: 0,
owed: 0,
pkPensionAnnual: 0,
flowBasis: 0,
capitalIn: 0,
capitalInSources: [],
rates: {},
hasCarry: false,
};
}
function fmt(v: number): string {
const rounded = Math.round(v || 0);
const sign = rounded < 0 ? "-" : "";
return sign + Math.abs(rounded).toString().replace(/\B(?=(\d{3})+(?!\d))/g, "'");
}
// Generisch, damit der Aufrufer den vollen Personen-Typ (inkl. age) behält.
// Sprechende Herkunft eines Kapitalbezugs: Elementname plus Person. Ohne die Person sind zwei
// gleichnamige Guthaben (z. B. beide «Säule 3a») im UI nicht auseinanderzuhalten.
function sourceLabelOf(
e: { name: string; ownerRole: string | null },
persons: { role: PersonRole; name: string | null }[]
): string {
if (e.ownerRole !== "PERSON_A" && e.ownerRole !== "PERSON_B") return e.name;
const p = persons.find((x) => x.role === e.ownerRole);
const who = p?.name?.trim() || (e.ownerRole === "PERSON_A" ? "Person A" : "Person B");
return `${e.name} (${who})`;
}
// Alter innerhalb eines gesetzlichen Fensters halten.
function clampAge(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, Math.round(v)));
}
function personByRole<T extends { role: PersonRole }>(persons: T[], role: string): T | null {
return persons.find((p) => p.role === role) ?? null;
}
// Ein Zufalls-Szenario für die Monte-Carlo-Simulation: liefert je Jahr eine Inflation und
// je Element/Jahr eine Rendite. Ohne Sample rechnet computePlan rein deterministisch (die
// geplanten Annahmen), mit Sample einen einzelnen simulierten Pfad. Jahr ist 1-basiert
// (ab Planbeginn); der Inflations-Index ist 0-basiert (inflation[0] = Jahr 1).
export interface PlanSample {
inflation: number[];
assetReturn: (elementId: string, year: number) => number;
}
// Kurzform für einen Rechenschritt. `pct` formatiert Prozentsätze lesbar (2 statt 2.0000001).
function st(
label: string,
result: number,
formula?: string,
substituted?: string,
note?: string,
unit: TraceStep["unit"] = "CHF"
): TraceStep {
return { label, formula, substituted, result, unit, note };
}
function pct(v: number): string {
return `${Math.round(v * 1000) / 1000} %`;
}
export function computePlan(plan: PlanInput, sample?: PlanSample, options?: ComputeOptions): PlanComputed {
const explain = options?.explain === true;
// Ohne Ist-Werte verhält sich die Funktion exakt wie bisher (die Golden Tests belegen es).
const actuals = options?.actuals;
const phases = [...plan.phases].sort((a, b) => a.sequenceNumber - b.sequenceNumber);
const persons = plan.persons;
const personA = persons.find((p) => p.role === "PERSON_A") ?? persons[0];
const retirementAge = new Map<string, number>();
for (const p of persons) retirementAge.set(p.id, p.retirementAge);
// Kumulierter Inflations-Deflator je Jahr (cumInfl[0] = 1, cumInfl[k] = Kaufkraftfaktor nach
// k Jahren). Deterministisch identisch zur bisherigen (1+infl)^k-Formel; mit Sample variiert
// die Inflation pro Jahr. Ersetzt die früheren geschlossenen Potenz-Ausdrücke.
const totalYears = phases.reduce((s, p) => s + Math.max(1, p.durationYears), 0);
const inflationOfYear = (year: number) =>
sample ? sample.inflation[year - 1] ?? plan.inflationRateDefault : plan.inflationRateDefault;
const cumInfl: number[] = [1];
for (let y = 1; y <= totalYears; y++) cumInfl[y] = cumInfl[y - 1] * (1 + inflationOfYear(y) / 100);
const gapYearsByPerson = new Map<string, number>();
// AHV-Beitragskarriere je Person: reales Einkommen x Beitragsjahre, und Beitragsjahre.
const ahvIncomeAccum = new Map<string, number>();
const ahvYearsAccum = new Map<string, number>();
// Karriere VOR Planbeginn und der Bezugs-Entscheid, je Person. Beides steht seit 0.34 im
// Pensionierungs-Entscheid des AHV-Elements -- vorher lag es an ZWEI Orten (Übergangszelle
// bzw., für bei Planbeginn bereits Pensionierte, Phasenzelle der ersten Phase), was zwei
// Codepfade für dieselbe Frage bedeutete.
// avg === null heisst NICHT ERFASST -- dann gilt der geplante Durchschnitt als Schaetzung
// (siehe unten). Ein Fallback auf 0 wuerde die Rente still und massiv zu tief rechnen.
const ahvBeforeByPerson = new Map<string, { avg: number | null; gap: number }>();
const ahvDrawByPerson = new Map<string, RetirementDecision>();
for (const e of plan.elements) {
if (e.category !== "AHV" || !e.ownerRole) continue;
const owner = personByRole(persons, e.ownerRole);
if (!owner) continue;
const rd = withRetirementDefaults("AHV", retirementAge.get(owner.id) ?? AHV_REFERENCE_AGE, e.retirementDecision);
ahvDrawByPerson.set(owner.id, rd);
ahvBeforeByPerson.set(owner.id, {
avg: typeof rd.avgIncomeBefore === "number" ? rd.avgIncomeBefore : null,
gap: Math.max(0, Math.round(num(rd.gapYearsBefore))),
});
}
const carries = new Map<string, Carry>();
for (const e of plan.elements) carries.set(e.id, emptyCarry());
// Fuer die Pensionierungs-Uebersicht: die zuletzt gerechnete AHV-Rente je Person und das
// netto bezogene Alterskapital (PK + 3a). Beides faellt in der Phasen- bzw. Uebergangs-
// schleife ohnehin an -- gesammelt wird es hier, damit es am Ende zur Verfuegung steht.
const ahvFinalByPerson = new Map<string, number>();
const capitalNetByPerson = new Map<string, number>();
const result: PhaseComputed[] = [];
const yearly: YearPoint[] = [];
let yearsBefore = 0;
let cumulativeInflation = 1;
let cashCarryIn = Math.round(plan.initialCash || 0);
// Aus dem Übergang der Vorphase in DIESE Phase fliessende Grössen (Kopf-Kennzahlen).
let incomingInflow = 0; // Brutto-Zufluss: Verkäufe + PK-/3a-Bezüge
let incomingImmediateRepay = 0; // sofortige Schuldentilgungen (Abfluss)
let incomingOneOffInflow = 0; // einmaliger Sonderzufluss (netto nach Steuer)
let incomingOneOffInflowLabel: string | null = null;
let incomingOneOffOutflow = 0; // einmalige Sonderkosten (nominal)
let incomingOneOffOutflowLabel: string | null = null;
// Echte Vermögensänderungen am Übergang IN die Folgephase (für die Vermögens-Brücke).
let incomingTax = 0; // Kapitalbezugs- + Grundstückgewinnsteuer
let incomingPensionConversion = 0; // in eine Rente umgewandeltes PK-Kapital
let incomingSaleGainLoss = 0; // Verkaufspreis minus Verkehrswert
let previousEndWealth = 0; // Endvermögen der Vorphase (Startpunkt der Brücke)
let previousCashEnd = Math.round(plan.initialCash || 0);
let ruinAge: number | null = null;
for (let i = 0; i < phases.length; i++) {
const phase = phases[i];
const nextPhase = phases[i + 1];
const isFirstPhase = i === 0;
const duration = Math.max(1, phase.durationYears);
const cumInflStart = cumInfl[yearsBefore]; // Kaufkraft-Deflator zu Phasenbeginn
const personInfos: PersonPhaseInfo[] = persons.map((p) => {
const ra = retirementAge.get(p.id)!;
const startAge = p.age + yearsBefore;
return {
personId: p.id,
role: p.role,
startAge,
endAge: startAge + duration,
working: startAge < ra,
retiresAtStart: startAge === ra,
};
});
const anyWorking = personInfos.some((p) => p.working);
const anyRetired = personInfos.some((p) => !p.working);
const type: PhaseType = anyWorking && anyRetired ? "MIXED" : anyWorking ? "ERWERB" : "PENSION";
const capsFromWorking = personInfos
.filter((p) => p.working)
.map((p) => retirementAge.get(p.personId)! - p.startAge)
.filter((d) => d > 0)
// Dazu jeder noch bevorstehende Bezugsbeginn: Auch er erzwingt eine Phasengrenze.
.concat(planFixpoints(fixpointPersonsOf(plan)).map((f) => f.year - yearsBefore).filter((d) => d > 0));
const maxDurationYears = capsFromWorking.length > 0 ? Math.min(...capsFromWorking) : null;
const workingByPerson = new Map(personInfos.map((p) => [p.personId, p.working]));
// Ausfalljahre kumulieren (nur während der Erwerbstätigkeit).
const gapThisPhase = 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;
const gy = Math.max(0, Math.round(num(e.phaseValues[phase.id]?.gapYears)));
gapThisPhase.set(owner.id, (gapThisPhase.get(owner.id) ?? 0) + gy);
gapYearsByPerson.set(owner.id, (gapYearsByPerson.get(owner.id) ?? 0) + gy);
}
// 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) continue;
const before = ahvBeforeByPerson.get(owner.id) ?? { avg: null, gap: 0 };
const career = buildCareer(owner, ahvIncomeAccum, ahvYearsAccum, gapYearsByPerson);
// Ohne erfassten Wert gilt der geplante Durchschnitt als Schaetzung fuer die Jahre vor
// Planbeginn -- exakt der Wert, den das UI vorbelegt. Beide Groessen sind BRUTTO.
const avgBefore = before.avg ?? career.plannedAvgGrossIncome;
const mdJE = ahvMdje(career, avgBefore, before.gap);
const totalGap = (gapYearsByPerson.get(owner.id) ?? 0) + before.gap;
ahvUncapped.set(owner.id, ahvAnnualPension(mdJE, totalGap));
}
const ahvFinal = new Map(ahvUncapped);
if (plan.householdType === "COUPLE" && ahvUncapped.size === 2) {
const sum = [...ahvUncapped.values()].reduce((a, b) => a + b, 0);
const cap = AHV_MAX_ANNUAL_SINGLE * AHV_COUPLE_CAP_FACTOR;
if (sum > cap && sum > 0) for (const [pid, v] of ahvUncapped) ahvFinal.set(pid, Math.round(v * (cap / sum)));
}
// Vorbezug kürzt, Aufschub erhöht -- und zwar NACH dem Ehepaar-Plafond: Der Plafond gilt
// für die ordentlichen Renten, die individuelle Kürzung setzt darauf auf.
for (const [pid, v] of [...ahvFinal]) {
const f = ahvFactor(ahvDrawByPerson.get(pid) ?? {});
if (f !== 1) ahvFinal.set(pid, Math.round(v * f));
}
for (const [pid, v] of ahvFinal) ahvFinalByPerson.set(pid, v);
// --- Element-Laufzeitzustände aufbauen ---
const orderedElements = [...plan.elements].sort((a, b) => a.orderIndex - b.orderIndex);
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; 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;
// Alter, ab dem die Rente DIESER Person fliesst. Bis 0.33 war das fix das
// Referenzalter; mit Vorbezug/Aufschub ist es individuell. Für das Jahresraster
// gerundet -- die Monatsgenauigkeit steckt im Faktor, nicht im Auszahlungszeitpunkt.
renteFromAge: 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.
const assets: {
value: number;
rate: number;
r: number;
withdrawal: number;
isPk: boolean;
ec: ElementPhaseComputed;
}[] = [];
// mortgage/owed sind LAUFENDE Salden: sie werden in der Jahresschleife abgebaut und am
// Nullpunkt gestoppt (keine Rate mehr, sobald abbezahlt). `value` ist der Verkehrswert der
// Liegenschaft (wächst mit valueGrowth), `purchase` der ursprüngliche Kaufpreis.
const realEstates: {
value: number;
purchase: number;
mortgage: number;
amort: number;
growth: number;
interestRate: number;
addInterest: boolean;
ec: ElementPhaseComputed;
}[] = [];
const debts: { owed: number; repay: number; ec: ElementPhaseComputed }[] = [];
let fixedRatesTotal = 0; // Sparraten mit konstantem Jahresbetrag: 3a + Sonstiges Vermögen
let plannedWithdrawTotal = 0; // Bezugsraten (fliessen ins Cash): Sonstiges Vermögen
let investmentsFromCash = 0; // Neuinvestitionen/Aufstockungen (ab Phase 2, aus Cash)
let wealthStart = 0;
let wealthEnd = 0; // wird nach der Jahresschleife gefuellt
for (const e of orderedElements) {
const carry = carries.get(e.id)!;
// Stammdaten (Bestand bei Planbeginn, Ausgangs-Annahmen) und Phasenwerte. In der ERSTEN
// Phase gilt der Phasenwert, wo einer erfasst ist, sonst die Stammdaten -- damit ist
// Phase 1 nichts Besonderes mehr, sondern erbt schlicht von der Wurzel.
const bd = e.baseData ?? {};
const raw = e.phaseValues[phase.id] ?? {};
const pd: PhaseData = isFirstPhase ? { ...bd, ...raw } : raw;
const owner = e.ownerRole && e.ownerRole !== "HOUSEHOLD" ? personByRole(persons, e.ownerRole) : null;
const ownerWorking = owner ? workingByPerson.get(owner.id) ?? false : anyWorking;
// 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];
// Reihenfolge: eigener Wert -> aus der Vorphase geerbt -> Stammdaten -> Fallback.
// Die Stammdaten sind damit die WURZEL der Kette; vor 0.36 hatte Phase 1 nichts, von
// dem sie hätte erben können, und fiel auf 0.
const root = (bd as Record<string, unknown>)[key];
const v =
typeof own === "number"
? own
: carry.rates[key] ?? (typeof root === "number" ? root : fallback);
carry.rates[key] = v;
return v;
};
const ec: ElementPhaseComputed = {
elementId: e.id,
category: e.category,
name: e.name,
ownerRole: e.ownerRole,
status: carry.status,
locked: carry.status !== "ACTIVE",
carried: carry.hasCarry,
baseValue: 0,
mortgageStart: 0,
mortgageEnd: 0,
startValue: 0,
endValue: 0,
summary: "",
note: null,
yearly: [],
};
ecById.set(e.id, ec);
if (carry.status === "SOLD") {
ec.note = "Verkauft";
ec.summary = "Verkauft";
continue;
}
if (carry.status === "SETTLED" && e.category === "OTHER_DEBT") {
ec.note = "Getilgt";
ec.summary = "Getilgt";
continue;
}
switch (e.category) {
case "INCOME":
case "EXPENSE": {
// Einkommen: Basis + Rate sind NOMINAL (Lohnerhöhung). Ausgaben: Basis + Rate sind
// 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 = inherited("teuerungsausgleich");
ec.baseValue = carry.hasCarry ? Math.round(carry.flowBasis) : Math.round(num(pd.amount));
let basis = !carry.hasCarry
? Math.round(num(pd.amount))
: typeof pd.amount === "number"
? Math.round(pd.amount)
: ec.baseValue;
// 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.
// Das Feld ist NETTO erfasst; die AHV bemisst sich am Bruttolohn -> hochrechnen.
if (e.category === "INCOME" && ownerWorking) {
const attributed =
owner ?? (plan.householdType === "SINGLE" && e.ownerRole === "HOUSEHOLD" ? personA : null);
if (attributed) {
// Die AHV-Karriere ist eine Real-Grösse auf Planungsbasis -- bewusst mit der
// festen Plan-Inflation, nicht der (evtl. gewürfelten) Sample-Inflation.
const avgRealGross =
avgRealFlow(basis, idx, plan.inflationRateDefault, duration, cumInflStart) * AHV_GROSS_FROM_NET_FACTOR;
phaseRealIncomeByPerson.set(
attributed.id,
(phaseRealIncomeByPerson.get(attributed.id) ?? 0) + avgRealGross
);
}
}
// 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": {
// 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;
const beitrag = Math.round(num(pd.ahvContribution));
const rd = ahvDrawByPerson.get(owner.id) ?? {};
// Rentenbeginn individuell (Vorbezug/Aufschub), Beitragspflicht dagegen IMMER bis
// zum Referenzalter: Wer die Rente vorbezieht, ist damit nicht von den Beiträgen
// als Nichterwerbstätige(r) befreit -- die beiden Alter sind zu trennen.
const renteFromAge = Math.round(ahvStartAge(rd));
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,
renteFromAge,
rente,
beitrag,
working: ownerWorking,
ec,
});
const reachesRente = ageEnd >= renteFromAge;
const startsWithRente = ageStart >= renteFromAge;
const owesContribution = !ownerWorking && ageStart < AHV_REFERENCE_AGE;
ec.startValue = startsWithRente ? rente : 0;
ec.endValue = reachesRente ? rente : 0;
if (startsWithRente) {
ec.summary = `Rente ${fmt(rente)}`;
} else if (reachesRente && owesContribution) {
ec.summary = `Beitrag ${fmt(beitrag)} → Rente ${fmt(rente)}`;
ec.note = `Rente ab Alter ${renteFromAge}; bis Alter ${AHV_REFERENCE_AGE} Beitrag als Nichterwerbstätige(r).`;
} else if (reachesRente) {
ec.summary = `Rente ab ${renteFromAge} ${fmt(rente)}`;
} else if (owesContribution) {
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";
}
if (ahvShiftMonths(rd) !== 0) {
ec.note = [ec.note, `AHV: ${ahvDrawLabel(rd)}.`].filter(Boolean).join(" ");
}
}
break;
}
case "PENSION_FUND": {
if (!ownerWorking && carry.pkPensionAnnual > 0) {
renteTotal += carry.pkPensionAnnual;
ec.startValue = carry.pkPensionAnnual;
ec.endValue = carry.pkPensionAnnual;
ec.summary = `Rente ${fmt(carry.pkPensionAnnual)}`;
ec.acceptsCapital = false;
} else if (!ownerWorking) {
ec.note = "Vollständig bezogen";
ec.summary = "Bezogen";
ec.acceptsCapital = false;
} else {
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(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: inherited("expectedReturn"), withdrawal: 0, isPk: true, ec });
}
break;
}
case "PILLAR_3A": {
if (!ownerWorking) {
ec.note = "Vollständig bezogen";
ec.summary = "Bezogen";
ec.acceptsCapital = false;
} else {
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(inherited("annualContribution"));
fixedRatesTotal += rate;
if (!isFirstPhase) investmentsFromCash += topUp;
ec.baseValue = base;
ec.startValue = start;
wealthStart += start;
assets.push({ value: start, rate, r: inherited("expectedReturn"), withdrawal: 0, isPk: false, ec });
}
break;
}
case "OTHER_ASSET": {
const base = carry.hasCarry ? carry.value : Math.round(num(pd.startValue));
// Zusatzeinlage = manuell erfasst PLUS das am Übergang umgeleitete Alterskapital.
// Beides ist mechanisch dasselbe (Cash -> Vermögen), deshalb derselbe Weg.
const fromTransfer = carry.hasCarry ? carry.capitalIn : 0;
const topUp = (carry.hasCarry ? Math.round(num(pd.additionalInvestment)) : 0) + fromTransfer;
ec.capitalFromTransfer = fromTransfer;
ec.capitalFromTransferSources = carry.hasCarry ? carry.capitalInSources : [];
// Verbraucht -- sonst flösse derselbe Betrag in jeder Folgephase erneut.
carry.capitalIn = 0;
carry.capitalInSources = [];
const start = base + topUp;
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: inherited("expectedReturn"), withdrawal, isPk: false, ec });
break;
}
case "REAL_ESTATE": {
// Ursprünglicher Kaufpreis (Basis der Grundstückgewinnsteuer) und Verkehrswert
// (wächst mit der Wertsteigerung) laufen getrennt.
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(inherited("amortization"));
const equity = valueStart - mortgageStart;
if (!carry.hasCarry && !isFirstPhase) investmentsFromCash += Math.max(0, equity);
ec.baseValue = equity;
ec.mortgageStart = mortgageStart;
ec.startValue = equity;
wealthStart += equity;
realEstates.push({
value: valueStart,
purchase,
mortgage: mortgageStart,
amort,
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",
ec,
});
break;
}
case "OTHER_DEBT": {
const owedStart = carry.hasCarry ? carry.owed : Math.round(num(pd.startValue));
const repay = Math.round(inherited("annualRepayment"));
ec.baseValue = -owedStart;
ec.startValue = -owedStart;
wealthStart += -owedStart;
debts.push({ owed: owedStart, repay, ec });
break;
}
}
}
// AHV-Karriere fortschreiben: Beitragsjahre EINMAL je Person und Phase (nicht je
// Einkommens-Element), gewichtet mit dem realen Durchschnittseinkommen der Phase.
for (const p of persons) {
if (!workingByPerson.get(p.id)) continue;
const contribYears = Math.max(0, duration - (gapThisPhase.get(p.id) ?? 0));
if (contribYears <= 0) continue;
const avgReal = phaseRealIncomeByPerson.get(p.id) ?? 0;
ahvIncomeAccum.set(p.id, (ahvIncomeAccum.get(p.id) ?? 0) + avgReal * contribYears);
ahvYearsAccum.set(p.id, (ahvYearsAccum.get(p.id) ?? 0) + contribYears);
}
// --- Jahr-für-Jahr: indexierte Flows, Cash-Ausgleich, Verzinsung, Ruin ---
// Investitionen dieser Phase werden am Phasenanfang abgezogen -> der Cash-Startwert
// zeigt den Bestand NACH den Investitionen (kein Doppelzählen mit dem Vermögen).
let cash = cashCarryIn - (isFirstPhase ? 0 : investmentsFromCash);
const cashStart = cash;
let cashNegative = cash < 0;
let incomeStart = 0;
let incomeEnd = 0;
let expenseStart = 0;
let expenseEnd = 0;
let quotaStart = 0;
let quotaEnd = 0;
let plannedSaveRate = 0; // Kopf-Kennzahl: die tatsächliche Sparrate im ersten Phasenjahr
// Summanden der beiden Wasserfall-Brücken (über alle Phasenjahre kumuliert).
let quotaTotal = 0;
let investmentReturnTotal = 0;
let propertyAppreciationTotal = 0;
let pensionFundContributionTotal = 0;
let savingRatesTotal = 0;
let debtRatesTotal = 0;
let withdrawalsTotal = 0;
// Sprung auf die Ist-Werte. Ohne Ist-Daten bleiben beide 0 und die Brücken rechnen
// exakt wie bisher.
let actualsCorrectionTotal = 0;
let actualsCashCorrectionTotal = 0;
for (let t = 1; t <= duration; t++) {
// Einkommen: nominal (Basis x (1+Lohnerhöhung)^(t-1)) + Renten (nominal fix).
let incomeFlow = renteTotal;
for (const inc of incomes) incomeFlow += inc.basis * Math.pow(1 + inc.idx / 100, t - 1);
// Ausgaben: real (Basis x (1+reale Mehrausgabe)^(t-1)); nominal = real x kumul. Inflation.
const inflFactor = cumInfl[yearsBefore + t - 1];
let expenseRealBase = 0;
for (const exp of expenses) expenseRealBase += exp.basis * Math.pow(1 + exp.idx / 100, t - 1);
// Hypothekarzins: NOMINAL aus der Restschuld zu Jahresbeginn -- nicht mit der Inflation
// hochrechnen. Zählt zu den Ausgaben (und damit in die Quote), sofern nicht bereits
// im Ausgaben-Element enthalten.
let interestNominal = 0;
for (const re of realEstates) {
if (!re.addInterest) continue;
interestNominal += re.mortgage * (re.interestRate / 100);
}
// AHV jahresweise: Die Rente fliesst ab dem individuellen Rentenalter -- auch wenn die
// Person noch arbeitet. Unabhängig davon zahlt eine nicht (mehr) erwerbstätige Person
// bis zum REFERENZALTER Beiträge, die wie eine Ausgabe auf die Quote schlagen
// (Kap. 4.4.6). Beide Bedingungen können gleichzeitig gelten: Wer mit 62 aufhört und
// die Rente ab 63 vorbezieht, bezieht ab 63 und zahlt bis 65.
let ahvIncome = 0;
let ahvCost = 0;
for (const a of ahvItems) {
const ageThisYear = a.ownerStartAge + t - 1; // Alter zu Jahresbeginn
if (ageThisYear >= a.renteFromAge) ahvIncome += a.rente;
if (!a.working && ageThisYear < AHV_REFERENCE_AGE) 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
// unten nachgetragen (gleiche Grösse, die auch die Ruin-Erkennung verwendet).
const yearPoint: YearPoint = {
year: yearsBefore + t,
age: personA.age + yearsBefore + t,
income: Math.round(incomeFlow),
expenseNominal: Math.round(expenseNominal),
expenseReal: Math.round(expenseReal),
wealthNominal: 0,
wealthReal: 0,
cash: 0,
};
yearly.push(yearPoint);
if (t === 1) {
incomeStart = incomeFlow;
expenseStart = expenseNominal;
quotaStart = quote;
}
if (t === duration) {
incomeEnd = incomeFlow;
expenseEnd = expenseNominal;
quotaEnd = quote;
}
// Vermögen verzinsen + Sparbeitrag; Bezugsrate entnehmen (gekappt am Bestand) und ins Cash.
let cashFromWithdraw = 0;
for (const a of assets) {
const r = sample ? sample.assetReturn(a.ec.elementId, yearsBefore + t) : a.r;
// Arithmetik unverändert lassen; die Rendite wird als Differenz abgeleitet, damit
// die Protokollierung das Ergebnis nicht (per Gleitkomma-Umformung) verschiebt.
const grown = a.value * (1 + r / 100) + a.rate;
const growth = grown - a.rate - a.value;
const w = Math.min(a.withdrawal, Math.max(0, grown));
a.value = grown - w;
cashFromWithdraw += w;
investmentReturnTotal += growth;
if (a.isPk) pensionFundContributionTotal += a.rate;
}
// Amortisation/Tilgung: nur so lange und so viel, wie noch Restschuld besteht. Ist die
// Hypothek/Schuld abbezahlt, entfällt die Rate -- sie belastet weder Cash noch Sparquote.
let debtRates = 0;
for (const re of realEstates) {
const pay = Math.min(re.amort, re.mortgage);
re.mortgage -= pay;
debtRates += pay;
// Wertsteigerung wirkt auf die LIEGENSCHAFT, nicht auf das Eigenkapital -- das ist der
// Hebel: 1 % von 1 Mio sind 10'000, also 10 % eines Eigenkapitals von 100'000.
const g = sample ? sample.assetReturn(re.ec.elementId, yearsBefore + t) : re.growth;
const valueBefore = re.value;
re.value *= 1 + g / 100; // Arithmetik unverändert
propertyAppreciationTotal += re.value - valueBefore;
}
for (const d of debts) {
const pay = Math.min(d.repay, d.owed);
d.owed -= pay;
debtRates += pay;
}
if (t === 1) plannedSaveRate = fixedRatesTotal + debtRates;
cash += quote - fixedRatesTotal - debtRates + cashFromWithdraw;
// --- Effektive Werte einspielen (Roadmap Nr. 5) --------------------------------------
// Bewusst NACH Verzinsung, Tilgung und Cash-Fortschreibung: Der erfasste Wert ist der
// Stand AM ENDE des Ist-Jahres. Rechnet man 2022 mit 120 und wieder 2024 mit 140, so
// liegen dazwischen genau zwei Wachstumsjahre -- das entspricht der Erwartung.
//
// Die Differenz wird als eigene Grösse geführt und NICHT den Renditen zugeschlagen:
// Ein Rückstand gegenüber dem Plan ist keine negative Rendite, sondern eine Korrektur.
const act = actuals ? actualsForYear(actuals, yearsBefore + t) : null;
if (act) {
for (const a of assets) {
const v = act.byElementId[a.ec.elementId]?.value;
if (typeof v !== "number") continue; // Lücke -> Planlinie läuft weiter
actualsCorrectionTotal += v - a.value;
a.value = v;
}
for (const re of realEstates) {
const av = act.byElementId[re.ec.elementId];
if (typeof av?.value === "number") {
actualsCorrectionTotal += av.value - re.value;
re.value = av.value;
}
if (typeof av?.mortgage === "number") {
// Eine höhere Restschuld mindert das Vermögen -- Vorzeichen umgekehrt.
actualsCorrectionTotal -= av.mortgage - re.mortgage;
re.mortgage = av.mortgage;
}
}
for (const d of debts) {
const v = act.byElementId[d.ec.elementId]?.value;
if (typeof v !== "number") continue;
actualsCorrectionTotal -= v - d.owed;
d.owed = v;
}
// Flüsse: Der erfasste Betrag gilt für DIESES Jahr; die Basis wird so zurückgerechnet,
// dass die Reihe hier den Ist-Wert trifft und danach planmässig weiterwächst.
for (const inc of incomes) {
const v = act.byElementId[inc.ec.elementId]?.value;
if (typeof v === "number") inc.basis = rebaseFlow(v, inc.idx, t);
}
for (const exp of expenses) {
const v = act.byElementId[exp.ec.elementId]?.value;
// Ausgaben werden real geführt, erfasst wird der nominale Ist-Betrag.
if (typeof v === "number") exp.basis = rebaseFlow(v, exp.idx, t, inflFactor);
}
if (typeof act.cash === "number") {
actualsCashCorrectionTotal += act.cash - cash;
actualsCorrectionTotal += act.cash - cash;
cash = act.cash;
}
}
if (cash < 0) cashNegative = true;
quotaTotal += quote;
savingRatesTotal += fixedRatesTotal;
debtRatesTotal += debtRates;
withdrawalsTotal += cashFromWithdraw;
// Verlaufspunkt je Element (Detailansicht). Bewusst NACH Verzinsung und Tilgung, also
// der Stand am Jahresende -- konsistent zum Gesamtvermögen im YearPoint.
const age = personA.age + yearsBefore + t;
const yr = yearsBefore + t;
for (const inc of incomes) {
inc.ec.yearly.push({
year: yr,
age,
value: Math.round(inc.basis * Math.pow(1 + inc.idx / 100, t - 1)),
rate: inc.idx,
});
}
for (const exp of expenses) {
exp.ec.yearly.push({
year: yr,
age,
value: Math.round(exp.basis * Math.pow(1 + exp.idx / 100, t - 1) * inflFactor),
rate: exp.idx,
});
}
for (const a of assets) a.ec.yearly.push({ year: yr, age, value: Math.round(a.value), rate: a.r });
for (const re of realEstates) {
re.ec.yearly.push({
year: yr,
age,
value: Math.round(re.value - re.mortgage), // Eigenkapital
propertyValue: Math.round(re.value),
mortgage: Math.round(re.mortgage),
rate: re.growth,
});
}
for (const d of debts) d.ec.yearly.push({ year: yr, age, value: -Math.round(d.owed) });
// 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 rente = ageThisYear >= a.renteFromAge ? a.rente : 0;
const beitrag = !a.working && ageThisYear < AHV_REFERENCE_AGE ? a.beitrag : 0;
if (a.ec.yearly.length < t) a.ec.yearly.push({ year: yr, age, value: rente - beitrag });
}
// Verrentete PK läuft nominal fix durch die Phase.
for (const ec of ecById.values()) {
if (ec.category === "PENSION_FUND" && ec.startValue > 0 && ec.yearly.length < t) {
ec.yearly.push({ year: yr, age, value: ec.startValue });
}
}
// Gesamtvermögen zum Jahresende t (für Ruin-Erkennung).
let total = cash;
for (const a of assets) total += a.value;
for (const re of realEstates) total += re.value - re.mortgage;
for (const d of debts) total += -d.owed;
yearPoint.wealthNominal = Math.round(total);
yearPoint.wealthReal = Math.round(total / (cumInfl[yearsBefore + t] || 1));
yearPoint.cash = Math.round(cash);
if (ruinAge === null && total < 0) ruinAge = personA.age + yearsBefore + t;
}
// Flow-Deflator für den Endwert (Jahr `duration`): eine Kaufkraft-Stufe weniger als der
// Bestands-Deflator am Phasenende.
const flowDeflatorEnd = cumInfl[yearsBefore + duration - 1];
// Endwerte je Element setzen (Einkommen/Ausgaben nominal; Ausgaben-Nominal = real x Infl.).
for (const inc of incomes) {
inc.ec.startValue = Math.round(inc.basis);
inc.ec.endValue = Math.round(inc.basis * Math.pow(1 + inc.idx / 100, duration - 1));
inc.ec.summary = fmt(inc.ec.startValue);
}
for (const exp of expenses) {
exp.ec.startValue = Math.round(exp.basis * cumInflStart);
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);
wealthEnd += a.ec.endValue;
}
for (const re of realEstates) {
re.ec.endValue = Math.round(re.value - re.mortgage);
re.ec.mortgageEnd = Math.round(re.mortgage);
re.ec.summary = fmt(re.ec.endValue);
wealthEnd += re.ec.endValue;
}
for (const d of debts) {
d.ec.endValue = d.owed > 0 ? -d.owed : 0;
d.ec.summary = fmt(d.ec.endValue);
wealthEnd += d.ec.endValue;
if (d.owed === 0) d.ec.note = "Wird getilgt";
}
// --- Rechenweg je Element (Roadmap Nr. 41) ---
// Bewusst HIER, nach der Jahresschleife: erst jetzt stehen die Endwerte fest.
if (explain) {
for (const inc of incomes) {
inc.ec.trace = {
title: `Einkommen «${inc.ec.name}» in dieser Lebensphase`,
specAnchor: "461-income--expense",
steps: [
st(
inc.ec.carried ? "Basiswert (aus der Vorphase fortgeschrieben)" : "Basiswert (erfasst)",
Math.round(inc.basis),
undefined,
undefined,
inc.ec.carried
? "Der Endwert der Vorphase wird fortgeschrieben, solange kein abweichender Wert erfasst ist."
: "Erfasst wird das NETTO-Einkommen, nominal."
),
st("Nominale Lohnerhöhung", inc.idx, undefined, undefined, undefined, "%"),
st(
`Einkommen im letzten Phasenjahr (Jahr ${duration})`,
inc.ec.endValue,
"Basis × (1 + Lohnerhöhung)^(Dauer 1)",
`${fmt(inc.basis)} × (1 + ${pct(inc.idx)})^${duration - 1}`
),
st(
"Basiswert für die Folgephase",
Math.round(inc.basis * Math.pow(1 + inc.idx / 100, duration)),
"Basis × (1 + Lohnerhöhung)^Dauer",
`${fmt(inc.basis)} × (1 + ${pct(inc.idx)})^${duration}`,
"Ein Jahr weiter als der Endwert die Folgephase beginnt nach dem letzten Jahr dieser Phase."
),
],
};
}
for (const exp of expenses) {
const realEnd = exp.basis * Math.pow(1 + exp.idx / 100, duration - 1);
exp.ec.trace = {
title: `Ausgaben «${exp.ec.name}» in dieser Lebensphase`,
specAnchor: "461-income--expense",
steps: [
st(
exp.ec.carried ? "Basiswert real (fortgeschrieben)" : "Basiswert real (erfasst)",
Math.round(exp.basis),
undefined,
undefined,
"Ausgaben werden REAL erfasst, also in heutiger Kaufkraft."
),
st("Reale Mehrausgaben pro Jahr", exp.idx, undefined, undefined, "Zusätzlich zur Inflation.", "%"),
st(
"Nominal im ersten Phasenjahr",
exp.ec.startValue,
"Basis × kumulierte Inflation (Phasenbeginn)",
`${fmt(exp.basis)} × ${Math.round(cumInflStart * 10000) / 10000}`
),
st(
`Real im letzten Phasenjahr (Jahr ${duration})`,
Math.round(realEnd),
"Basis × (1 + reale Mehrausgaben)^(Dauer 1)",
`${fmt(exp.basis)} × (1 + ${pct(exp.idx)})^${duration - 1}`
),
st(
`Nominal im letzten Phasenjahr (Jahr ${duration})`,
exp.ec.endValue,
"real × Flow-Deflator am Phasenende",
`${fmt(realEnd)} × ${Math.round(flowDeflatorEnd * 10000) / 10000}`,
"Der Flow-Deflator liegt eine Kaufkraft-Stufe unter dem Bestands-Deflator: ein Flow fällt IM Jahr `Dauer` an, ein Bestand wird NACH diesem Jahr gemessen."
),
],
};
}
for (const a of assets) {
const isPk = a.isPk;
a.ec.trace = {
title: `${isPk ? "Pensionskasse" : "Kapitalanlage"} «${a.ec.name}» in dieser Lebensphase`,
specAnchor: isPk ? "463-pension_fund" : "466-other_asset",
steps: [
st(a.ec.carried ? "Startwert (fortgeschrieben)" : "Startwert (erfasst)", a.ec.baseValue),
...(a.ec.startValue !== a.ec.baseValue
? [st("Zusatzeinlage aus Kapital", a.ec.startValue - a.ec.baseValue, undefined, undefined, "Wird dem Cash entnommen.")]
: []),
st("Erwartete Rendite", a.r, undefined, undefined, undefined, "%"),
st(
"Jährliche Einzahlung",
a.rate,
undefined,
undefined,
isPk
? "Arbeitnehmer- und Arbeitgeberbeitrag. Belastet das Cash NICHT die Beiträge sind im Nettolohn bereits abgezogen."
: "Verlässt das Cash und zählt zur geplanten Sparrate."
),
...(a.withdrawal > 0
? [st("Jährliche Bezugsrate", a.withdrawal, undefined, undefined, "Entnahme aus dem Vermögen ins Cash, am Bestand gekappt.")]
: []),
st(
"Jahresschritt",
a.ec.endValue,
"Bestand × (1 + Rendite) + Einzahlung Bezugsrate",
`über ${duration} Jahre, beginnend bei ${fmt(a.ec.startValue)}`,
"Verzinsung ist nachschüssig: die Einzahlung des laufenden Jahres wird noch nicht mitverzinst."
),
st("Endwert der Phase", a.ec.endValue),
],
};
}
for (const re of realEstates) {
const interestStart = Math.round((re.ec.mortgageStart * re.interestRate) / 100);
const interestEnd = Math.round((re.ec.mortgageEnd * re.interestRate) / 100);
re.ec.trace = {
title: `Immobilie «${re.ec.name}» in dieser Lebensphase`,
specAnchor: "465-real_estate-immobilie",
steps: [
st("Verkehrswert zu Phasenbeginn", re.ec.startValue + re.ec.mortgageStart),
st("Resthypothek zu Phasenbeginn", re.ec.mortgageStart),
st("Eigenkapital zu Phasenbeginn", re.ec.startValue, "Verkehrswert Resthypothek", `${fmt(re.ec.startValue + re.ec.mortgageStart)} ${fmt(re.ec.mortgageStart)}`),
st("Ursprünglicher Kaufpreis", re.purchase, undefined, undefined, "Basis der Grundstückgewinnsteuer beim Verkauf läuft getrennt vom Verkehrswert."),
st("Wertsteigerung pro Jahr", re.growth, undefined, undefined, "Wirkt auf die LIEGENSCHAFT, nicht auf das Eigenkapital daher der Hebel.", "%"),
st("Amortisation pro Jahr", re.amort, undefined, undefined, "Am Restsaldo gekappt: ist die Hypothek getilgt, entfällt die Rate."),
st("Hypothekarzins", re.interestRate, undefined, undefined, re.addInterest ? "Wird zu den Ausgaben dazugerechnet." : "Gilt als bereits im Ausgaben-Element enthalten und wird NICHT zusätzlich abgezogen.", "%"),
st("Zinsbetrag im ersten Jahr", interestStart, "Restschuld × Zinssatz", `${fmt(re.ec.mortgageStart)} × ${pct(re.interestRate)}`),
st("Zinsbetrag im letzten Jahr", interestEnd, "Restschuld × Zinssatz", `${fmt(re.ec.mortgageEnd)} × ${pct(re.interestRate)}`, "Sinkt automatisch mit der Amortisation."),
st("Verkehrswert am Phasenende", re.ec.endValue + re.ec.mortgageEnd),
st("Resthypothek am Phasenende", re.ec.mortgageEnd),
st("Eigenkapital am Phasenende", re.ec.endValue, "Verkehrswert Resthypothek", `${fmt(re.ec.endValue + re.ec.mortgageEnd)} ${fmt(re.ec.mortgageEnd)}`),
],
};
}
for (const d of debts) {
d.ec.trace = {
title: `Schuld «${d.ec.name}» in dieser Lebensphase`,
specAnchor: "467-other_debt",
steps: [
st("Restschuld zu Phasenbeginn", -d.ec.startValue),
st("Jährliche Tilgung", d.repay, undefined, undefined, "Am Restsaldo gekappt im letzten Zahlungsjahr wird nur der Restbetrag fällig."),
st("Restschuld am Phasenende", d.owed, `min(Tilgung, Restschuld) über ${duration} Jahre`),
st("Beitrag zum Vermögen", d.ec.endValue, undefined, undefined, "Schulden gehen mit negativem Vorzeichen ins Vermögen ein."),
],
};
}
// AHV-/PK-Renten: der Rechenweg der Rentenhöhe selbst liegt auf Plan-Ebene (mdJE).
for (const e of orderedElements) {
const ec = ecById.get(e.id)!;
if (ec.trace || ec.startValue === 0) continue;
if (e.category === "AHV") {
const owner = e.ownerRole ? personByRole(persons, e.ownerRole) : null;
const gap = owner ? gapYearsByPerson.get(owner.id) ?? 0 : 0;
const before = owner ? ahvBeforeByPerson.get(owner.id) ?? { avg: null, gap: 0 } : { avg: null, gap: 0 };
const career = owner ? buildCareer(owner, ahvIncomeAccum, ahvYearsAccum, gapYearsByPerson) : null;
const avgBefore = before.avg ?? career?.plannedAvgGrossIncome ?? 0;
const mdJE = career ? ahvMdje(career, avgBefore, before.gap) : 0;
ec.trace = {
title: `AHV-Rente «${ec.name}»`,
specAnchor: "44-ahv-rente",
steps: [
st("Massgebendes durchschnittliches Jahreseinkommen (mdJE)", Math.round(mdJE), undefined, undefined, "Gewichtetes Mittel über alle Beitragsjahre, REAL gerechnet und auf den Bruttolohn hochgerechnet."),
st("Volle Monatsrente zum mdJE", Math.round(ahvMonthlyFullPension(mdJE)), "Rentenformel Skala 44", `mdJE ${fmt(mdJE)} → Skala 44`),
st("Ausfalljahre total", gap + before.gap, undefined, undefined, "Jedes Ausfalljahr kürzt die Rente um 1/44.", "Jahre"),
st(
"Rentenskala-Faktor",
Math.round(((AHV_FULL_CONTRIBUTION_YEARS - (gap + before.gap)) / AHV_FULL_CONTRIBUTION_YEARS) * 1000) / 10,
"(44 Ausfalljahre) / 44",
`(${AHV_FULL_CONTRIBUTION_YEARS} ${gap + before.gap}) / ${AHV_FULL_CONTRIBUTION_YEARS}`,
undefined,
"%"
),
st(
"Jahresrente",
ec.startValue,
"Monatsrente × 13 × Skalafaktor",
`${fmt(ahvMonthlyFullPension(mdJE))} × ${AHV_PENSION_MONTHS} × Faktor`,
plan.householdType === "COUPLE"
? "Bei Ehepaaren zusätzlich auf 150 % der Einzel-Maximalrente plafoniert. Die Rente wird danach nominal eingefroren und nicht indexiert."
: "Die Rente wird danach nominal eingefroren und nicht indexiert (bewusste Vereinfachung)."
),
],
};
} else if (e.category === "PENSION_FUND") {
ec.trace = {
title: `PK-Rente «${ec.name}»`,
specAnchor: "491-pension_fund",
steps: [
st("Jährliche Rente", ec.startValue, "verrentetes Kapital × Umwandlungssatz", undefined, "Der Umwandlungssatz wurde beim Pensions-Übergang gewählt. Die Rente ist danach nominal fix."),
],
};
}
}
}
const cashEnd = Math.round(cash);
const startWealthNominal = Math.round(wealthStart + cashStart);
const endWealthNominal = Math.round(wealthEnd + cashEnd);
cumulativeInflation = cumInfl[yearsBefore + duration];
// --- Rechenweg der Phasen-Kennzahlen ---
const phaseTraces: Trace[] = [];
if (explain) {
phaseTraces.push({
title: "Spar- bzw. Verzehrquote",
specAnchor: "47-die-jahresschleife",
steps: [
st("Einkommen im ersten Jahr (inkl. Renten)", Math.round(incomeStart)),
st("Ausgaben im ersten Jahr (nominal)", Math.round(expenseStart)),
st("Quote im ersten Jahr", Math.round(quotaStart), "Einkommen nominale Ausgaben", `${fmt(incomeStart)} ${fmt(expenseStart)}`,
quotaStart < 0 ? "Negativ das ist eine Verzehrphase: die laufenden Ausgaben übersteigen das Einkommen." : undefined),
st("Quote im letzten Jahr", Math.round(quotaEnd), "Einkommen nominale Ausgaben", `${fmt(incomeEnd)} ${fmt(expenseEnd)}`),
st("Summe über alle Phasenjahre", Math.round(quotaTotal), undefined, undefined, "Diese Summe ist der Beitrag der laufenden Flows zur Vermögensentwicklung."),
],
});
phaseTraces.push({
title: "Geplante Spar- und Verzehrrate",
specAnchor: "47-die-jahresschleife",
steps: [
st("Feste Sparraten (3a + Sparbeiträge)", fixedRatesTotal, undefined, undefined, "PK-Beiträge zählen NICHT dazu sie sind im Nettolohn bereits abgezogen."),
st("Amortisationen und Tilgungen im ersten Jahr", plannedSaveRate - fixedRatesTotal),
st("Geplante Sparrate (erstes Jahr)", plannedSaveRate, "Sparraten + Amortisationen + Tilgungen", `${fmt(fixedRatesTotal)} + ${fmt(plannedSaveRate - fixedRatesTotal)}`,
"Bewusst das erste Phasenjahr: läuft eine Schuld während der Phase aus, liegt die Rate später tiefer."),
st("Geplante Verzehrrate", plannedWithdrawTotal, undefined, undefined, "Summe der Bezugsraten aus Sonstigem Vermögen."),
],
});
phaseTraces.push({
title: "Cash-Fortschreibung",
specAnchor: "47-die-jahresschleife",
steps: [
st(isFirstPhase ? "Cash-Anfangswert" : "Cash-Endbestand der Vorphase", isFirstPhase ? Math.round(plan.initialCash || 0) : previousCashEnd),
...(incomingInflow ? [st("+ Kapitalzufluss aus dem Übergang", Math.round(incomingInflow), undefined, undefined, "Verkäufe und PK-/3a-Bezüge, netto nach Steuer.")] : []),
...(incomingOneOffInflow ? [st("+ Einmaliger Zufluss", Math.round(incomingOneOffInflow))] : []),
...(incomingImmediateRepay ? [st(" Sofort-Tilgung / Sonderamortisation", -Math.round(incomingImmediateRepay))] : []),
...(incomingOneOffOutflow ? [st(" Einmalige Kosten", -Math.round(incomingOneOffOutflow))] : []),
...(!isFirstPhase && investmentsFromCash ? [st(" Investitionen am Phasenanfang", -Math.round(investmentsFromCash), undefined, undefined, "Werden vorab abgezogen, damit sie nicht doppelt (im Cash UND im Vermögen) zählen.")] : []),
st("= Cash zu Phasenbeginn", Math.round(cashStart)),
st("+ Summe der Quoten", Math.round(quotaTotal)),
st(" Sparraten", -Math.round(savingRatesTotal)),
st(" Amortisationen und Tilgungen", -Math.round(debtRatesTotal)),
st("+ Bezugsraten", Math.round(withdrawalsTotal)),
st("= Cash am Phasenende", cashEnd, undefined, undefined,
cashNegative ? "Der Cash-Bestand fällt in dieser Phase unter 0 das ist eine Liquiditätslücke. Sie wird gemeldet, aber nicht automatisch ausgeglichen." : undefined),
],
});
phaseTraces.push({
title: "Vermögen zu Beginn und am Ende",
specAnchor: "48-endwerte-und-phasen-kennzahlen",
steps: [
st("Summe der Element-Startwerte", Math.round(wealthStart)),
st("+ Cash zu Phasenbeginn", Math.round(cashStart)),
st("= Vermögen zu Phasenbeginn", startWealthNominal),
st("Summe der Element-Endwerte", Math.round(wealthEnd)),
st("+ Cash am Phasenende", cashEnd),
st("= Vermögen am Phasenende (nominal)", endWealthNominal),
st("Bestands-Deflator am Phasenende", Math.round(cumulativeInflation * 10000) / 10000, undefined, undefined, undefined, ""),
st("= Vermögen am Phasenende (real)", Math.round(endWealthNominal / cumulativeInflation),
"nominal / kumulierte Inflation", `${fmt(endWealthNominal)} / ${Math.round(cumulativeInflation * 10000) / 10000}`,
"Real heisst: kaufkraftbereinigt auf den Planbeginn."),
],
});
phaseTraces.push({
title: "Phasentyp und Alter",
specAnchor: "43-personen-und-phasentyp",
steps: [
...personInfos.map((p) =>
st(
`${p.role === "PERSON_A" ? "Person A" : "Person B"}: Alter zu Phasenbeginn`,
p.startAge,
"aktuelles Alter + Jahre vor dieser Phase",
`${persons.find((x) => x.id === p.personId)?.age ?? 0} + ${yearsBefore}`,
p.working ? "erwerbstätig (Alter < Pensionsalter)" : "pensioniert",
"Jahre"
)
),
st("Phasentyp", 0, undefined, type === "ERWERB" ? "alle erwerbstätig" : type === "PENSION" ? "alle pensioniert" : "gemischt",
"Der Phasentyp wird nie gespeichert, sondern in jeder Berechnung neu abgeleitet.", ""),
],
});
}
result.push({
id: phase.id,
name: phase.name,
sequenceNumber: phase.sequenceNumber,
durationYears: duration,
type,
persons: personInfos,
maxDurationYears,
incomeStart: Math.round(incomeStart),
incomeEnd: Math.round(incomeEnd),
expenseStart: Math.round(expenseStart),
expenseEnd: Math.round(expenseEnd),
quotaStart: Math.round(quotaStart),
quotaEnd: Math.round(quotaEnd),
isConsumption: quotaStart < 0,
plannedSaveRate,
plannedWithdrawRate: plannedWithdrawTotal,
capitalInflow: Math.round(incomingInflow),
capitalInvest: Math.round(investmentsFromCash + incomingImmediateRepay),
oneOffInflow: Math.round(incomingOneOffInflow),
oneOffInflowLabel: incomingOneOffInflowLabel,
oneOffOutflow: Math.round(incomingOneOffOutflow),
oneOffOutflowLabel: incomingOneOffOutflowLabel,
cashStart: Math.round(cashStart),
cashEnd,
cashNegative,
incomplete: cashNegative,
elements: orderedElements.map((e) => ecById.get(e.id)!),
startWealthNominal,
endWealthNominal,
cumulativeInflationStart: cumInflStart,
cumulativeInflationEnd: cumulativeInflation,
flowDeflatorEnd,
endWealthReal: endWealthNominal / cumulativeInflation,
wealthBridge: {
openingWealth: isFirstPhase ? startWealthNominal : previousEndWealth,
oneOffInflow: Math.round(incomingOneOffInflow),
oneOffOutflow: Math.round(incomingOneOffOutflow),
transitionTax: Math.round(incomingTax),
pensionConversion: Math.round(incomingPensionConversion),
saleGainLoss: Math.round(incomingSaleGainLoss),
startWealth: startWealthNominal,
quotaTotal: Math.round(quotaTotal),
investmentReturn: Math.round(investmentReturnTotal),
propertyAppreciation: Math.round(propertyAppreciationTotal),
pensionFundContribution: Math.round(pensionFundContributionTotal),
actualsCorrection: Math.round(actualsCorrectionTotal),
endWealth: endWealthNominal,
residual:
endWealthNominal -
((isFirstPhase ? startWealthNominal : previousEndWealth) +
Math.round(incomingOneOffInflow) -
Math.round(incomingOneOffOutflow) -
Math.round(incomingTax) -
Math.round(incomingPensionConversion) +
Math.round(incomingSaleGainLoss) +
Math.round(quotaTotal) +
Math.round(investmentReturnTotal) +
Math.round(propertyAppreciationTotal) +
Math.round(pensionFundContributionTotal) +
Math.round(actualsCorrectionTotal)),
},
cashBridge: {
openingCash: isFirstPhase ? Math.round(plan.initialCash || 0) : previousCashEnd,
capitalInflow: Math.round(incomingInflow),
oneOffInflow: Math.round(incomingOneOffInflow),
immediateRepay: Math.round(incomingImmediateRepay),
oneOffOutflow: Math.round(incomingOneOffOutflow),
investments: isFirstPhase ? 0 : Math.round(investmentsFromCash),
cashStart: Math.round(cashStart),
quotaTotal: Math.round(quotaTotal),
savingRates: Math.round(savingRatesTotal),
debtRates: Math.round(debtRatesTotal),
withdrawals: Math.round(withdrawalsTotal),
actualsCorrection: Math.round(actualsCashCorrectionTotal),
cashEnd,
residual:
cashEnd -
(Math.round(cashStart) +
Math.round(quotaTotal) -
Math.round(savingRatesTotal) -
Math.round(debtRatesTotal) +
Math.round(withdrawalsTotal) +
Math.round(actualsCashCorrectionTotal)),
},
traces: explain ? phaseTraces : undefined,
});
previousEndWealth = endWealthNominal;
previousCashEnd = cashEnd;
// --- Übergang: Carry aktualisieren, Cash der Folgephase bilden ---
let txInflow = 0;
let txImmediateRepay = 0;
// Kapitalbezüge, deren Verwendung je Element geregelt ist (Punkt C). Gesammelt WÄHREND
// der Übergangs-Schleife, angewendet danach -- die Zielelemente werden erst dort bekannt.
const capitalUses: { net: number; td: RetirementDecision; sourceName: string }[] = [];
// Zieht dieses 3a-Konto an DIESER Phasengrenze? Ein Konto lässt sich nur ganz auflösen,
// und innerhalb einer Phase kennt das Modell kein Einzelereignis -- gezogen wird deshalb
// an der ERSTEN Grenze bei oder nach dem Wunschalter. Liegt der Wunsch hinter dem
// Planende, greift die letzte Grenze, damit das Guthaben nicht unbezogen liegen bleibt.
// Zieht dieses Vorsorge-Guthaben an DIESER Phasengrenze? Gilt gleichermassen für die
// Pensionskasse und die Säule 3a: Beide haben seit 0.36 ein eigenes Bezugsalter, und
// innerhalb einer Phase kennt das Modell kein Einzelereignis. Gezogen wird an der ERSTEN
// Grenze bei oder nach dem Wunschalter; liegt der Wunsch hinter dem Planende, greift die
// letzte Grenze, damit kein Guthaben unbezogen liegen bleibt.
const drawsHere = (
el: ElementInput,
ownerPerson: { id: string; age: number } | null,
ageAtBoundary: number
): boolean => {
// Beide Kategorien sind personengebunden -- ohne Besitzer gibt es kein Bezugsalter.
if (!ownerPerson) return false;
const ra = retirementAge.get(ownerPerson.id) ?? AHV_REFERENCE_AGE;
const rd = withRetirementDefaults(el.category, ra, el.retirementDecision);
const wish =
el.category === "PENSION_FUND"
? clampAge(num(rd.pkWithdrawalAge, ra), PK_MIN_RETIREMENT_AGE, PK_MAX_RETIREMENT_AGE)
: clampAge(
num(rd.withdrawalAge, ageAtBoundary),
PILLAR_3A_MIN_WITHDRAWAL_AGE,
PILLAR_3A_MAX_WITHDRAWAL_AGE
);
const prevBoundaryAge = ownerPerson.age + yearsBefore;
if (prevBoundaryAge >= wish) return false; // an einer früheren Grenze bereits gezogen
return ageAtBoundary >= wish || !nextPhase;
};
// Echte Vermögensänderungen an dieser Grenze (für die Brücke der Folgephase).
// Verkäufe, Bezüge und Tilgungen sind für sich Umbuchungen -- vermögenswirksam sind
// nur die Steuer, die Verrentung (Kapital verlässt die Bilanz) und die Differenz
// zwischen Verkaufspreis und Verkehrswert.
let txTax = 0;
let txPensionConversion = 0;
let txSaleGainLoss = 0;
// Einmalige Sonderein-/ausgaben auf dem Cash-Konto. Nur sinnvoll, wenn eine Folgephase
// existiert -- nach der letzten Phase gibt es keinen Übergang. Der Wechselkurs zwischen
// real und nominal ist an dieser Grenze `cumulativeInflation` (Bestands-Deflator am
// Phasenende), denn Cash ist ein Bestand.
let txOneOffInflow = 0;
let txOneOffOutflow = 0;
let txOneOffInflowLabel: string | null = null;
let txOneOffOutflowLabel: string | null = null;
if (nextPhase) {
const ct = phase.cashTransition ?? {};
const mode = ct.mode ?? "NONE";
if (mode === "INFLOW" || mode === "BOTH") {
// Zufluss ist NOMINAL erfasst (wie Einkommen); Steuer mindert den Netto-Zufluss.
const gross = Math.round(num(ct.inflowAmount));
txOneOffInflow = Math.round(gross * (1 - num(ct.inflowTaxRate, 0) / 100));
txOneOffInflowLabel = ct.inflowLabel?.trim() || null;
}
if (mode === "OUTFLOW" || mode === "BOTH") {
// Kosten sind REAL erfasst (wie Ausgaben) -> mit der kumulierten Inflation aufwerten.
txOneOffOutflow = Math.round(num(ct.outflowAmount) * cumulativeInflation);
txOneOffOutflowLabel = ct.outflowLabel?.trim() || null;
}
}
for (const e of orderedElements) {
const carry = carries.get(e.id)!;
const ec = ecById.get(e.id)!;
const td = e.transitionValues[phase.id] ?? {};
const owner = e.ownerRole && e.ownerRole !== "HOUSEHOLD" ? personByRole(persons, e.ownerRole) : null;
// Pensionsalter des Besitzers und sein Alter AN dieser Phasengrenze -- Bezugspunkt für
// die Vorgaben des Pensionierungs-Entscheids und für die Bezugsalter von PK und 3a.
// Ob ein Guthaben HIER gezogen wird, entscheidet seit 0.36 `drawsHere` anhand des
// jeweiligen Bezugsalters, nicht mehr das Erwerbsende.
const ownerRetirementAge = owner ? retirementAge.get(owner.id) ?? AHV_REFERENCE_AGE : AHV_REFERENCE_AGE;
const ownerAgeAtBoundary = owner ? owner.age + yearsBefore + duration : 0;
// Einkommen/Ausgaben: Basiswert wurde bereits im Element-Setup fortgeschrieben.
if (e.category === "INCOME" || e.category === "EXPENSE") {
carry.hasCarry = true;
continue;
}
// AHV: Der Bezugs-Entscheid hängt seit 0.34 am Element, nicht am Übergang -- hier ist
// deshalb nichts mehr zu tun.
if (e.category === "AHV") {
carry.hasCarry = true;
continue;
}
if (carry.status !== "ACTIVE") {
carry.hasCarry = true;
continue;
}
const txStepsBefore = txInflow;
const taxBefore = txTax;
switch (e.category) {
case "PENSION_FUND": {
if (drawsHere(e, owner, ownerAgeAtBoundary)) {
// EINE Quote statt PENSION/CAPITAL/COMBI plus Frankenbetrag: 0 % = volle Rente,
// 100 % = volles Kapital, alles dazwischen ist die Kombination. Als Quote, weil
// sich das Guthaben mit dem Pensionsalter ändert -- ein fixer Betrag würde beim
// Verschieben des Alters still ein anderes Verhältnis bedeuten.
const value = ec.endValue;
const rd = withRetirementDefaults("PENSION_FUND", ownerRetirementAge, e.retirementDecision);
const sharePct = Math.max(0, Math.min(100, num(rd.capitalSharePct)));
const capital = Math.round((value * sharePct) / 100);
const rest = value - capital;
if (capital > 0) {
const net = Math.round(capital * (1 - num(rd.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100));
txInflow += net;
txTax += capital - net;
capitalUses.push({ net, td: rd, sourceName: sourceLabelOf(e, persons) });
if (owner) capitalNetByPerson.set(owner.id, (capitalNetByPerson.get(owner.id) ?? 0) + net);
}
carry.pkPensionAnnual =
rest > 0 ? Math.round((rest * num(rd.conversionRate, DEFAULT_PK_CONVERSION_RATE)) / 100) : 0;
txPensionConversion += rest;
carry.value = 0;
} else {
// Vorbezug (z. B. Wohneigentum/Selbstständigkeit): ebenfalls kapitalbezugssteuerpflichtig.
// Das Kapital wird brutto entnommen, netto (nach Steuer) fliesst es ins Cash.
const withdrawal = Math.min(ec.endValue, Math.round(num(td.withdrawal)));
carry.value = ec.endValue - withdrawal;
const net = Math.round(withdrawal * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100));
txInflow += net;
txTax += withdrawal - net;
}
break;
}
case "PILLAR_3A": {
// Der Bezug hängt seit 0.34 am gewählten Alter, nicht mehr starr am Pensions-
// Übergang: Ein 3a-Konto lässt sich nur GANZ auflösen, und alle Bezüge desselben
// Jahres werden steuerlich zusammengezählt -- gestaffelt wird deshalb über Konten
// und Jahre. Gezogen wird an der ersten Phasengrenze bei oder nach dem Wunschalter.
if (drawsHere(e, owner, ownerAgeAtBoundary)) {
const rd = withRetirementDefaults("PILLAR_3A", ownerRetirementAge, e.retirementDecision);
const net = Math.round(ec.endValue * (1 - num(rd.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100));
txInflow += net;
txTax += ec.endValue - net;
capitalUses.push({ net, td: rd, sourceName: sourceLabelOf(e, persons) });
if (owner) capitalNetByPerson.set(owner.id, (capitalNetByPerson.get(owner.id) ?? 0) + net);
carry.value = 0;
} else {
const withdrawal = Math.min(ec.endValue, Math.round(num(td.withdrawal)));
carry.value = ec.endValue - withdrawal;
const net = Math.round(withdrawal * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100));
txInflow += net;
txTax += withdrawal - net;
}
break;
}
case "OTHER_ASSET": {
if (td.decision === "SELL") {
txInflow += ec.endValue;
carry.status = "SOLD";
} else if (td.decision === "PARTIAL") {
// Teilverkauf: Betrag fliesst ins Cash, der Rest bleibt aktiv und wächst weiter.
const part = Math.min(ec.endValue, Math.round(num(td.partialSaleAmount)));
txInflow += part;
carry.value = ec.endValue - part;
} else {
carry.value = ec.endValue;
}
break;
}
case "REAL_ESTATE": {
const re = realEstates.find((r) => r.ec.elementId === e.id);
if (!re) break;
if (td.decision === "SELL") {
const salePrice = Math.round(num(td.salePrice));
// Grundstückgewinnsteuer bemisst sich am ursprünglichen Kaufpreis, nicht am
// zwischenzeitlich gestiegenen Verkehrswert.
const gain = Math.max(0, salePrice - re.purchase);
const tax = gain * (num(td.saleTaxRate, DEFAULT_PROPERTY_GAINS_TAX_RATE) / 100);
txInflow += Math.round(salePrice - re.mortgage - tax);
txTax += Math.round(tax);
// Der Verkaufspreis kann vom fortgeschriebenen Verkehrswert abweichen -- diese
// Differenz ist ein echter Vermögensgewinn bzw. -verlust an der Grenze.
txSaleGainLoss += Math.round(salePrice - re.value);
carry.status = "SOLD";
} else {
// Sonderamortisation: Einmaltilgung der Hypothek aus dem Cash, am Restsaldo gekappt.
const extra = Math.min(re.mortgage, Math.round(num(td.extraAmortization)));
if (extra > 0) txImmediateRepay += extra;
carry.mortgage = re.mortgage - extra;
carry.propertyValue = re.value;
carry.propertyPurchase = re.purchase;
}
break;
}
case "OTHER_DEBT": {
const owedEnd = -ec.endValue;
carry.owed = owedEnd;
const immediate = Math.min(carry.owed, Math.round(num(td.immediateRepayment)));
if (immediate > 0) {
carry.owed = Math.max(0, carry.owed - immediate);
txImmediateRepay += immediate;
}
if (carry.owed === 0) carry.status = "SETTLED";
break;
}
default:
break;
}
// --- Rechenweg des Übergangs je Element ---
if (explain && nextPhase) {
const inflowHere = txInflow - txStepsBefore;
const taxHere = txTax - taxBefore;
const steps: TraceStep[] = [st("Wert am Ende der Vorphase", ec.endValue)];
let title = `Übergang «${ec.name}»`;
let anchor: string | undefined;
if (e.category === "PENSION_FUND" || e.category === "PILLAR_3A") {
anchor = e.category === "PENSION_FUND" ? "491-pension_fund" : "492-pillar_3a";
// Der Bezugs-Entscheid liegt seit 0.34 am Element; der ZEITPUNKT hat seit 0.36 ein
// eigenes Alter und muss nicht mehr mit dem Erwerbsende zusammenfallen.
const drawnHere = drawsHere(e, owner, ownerAgeAtBoundary);
const rd = withRetirementDefaults(e.category, ownerRetirementAge, e.retirementDecision);
title = `Übergang «${ec.name}» ${drawnHere ? "Bezug" : "Vorbezug"}`;
if (drawnHere && e.category === "PENSION_FUND") {
const share = Math.max(0, Math.min(100, num(rd.capitalSharePct)));
steps.push(st("Anteil Kapitalbezug", share, undefined, undefined, "0 % = volle Rente, 100 % = volles Kapital.", "%"));
if (share < 100) {
steps.push(st("Umwandlungssatz", num(rd.conversionRate, DEFAULT_PK_CONVERSION_RATE), undefined, undefined, undefined, "%"));
steps.push(st("Jährliche Rente", carry.pkPensionAnnual, "verrentetes Kapital × Umwandlungssatz", undefined, "Das verrentete Kapital verlässt die Vermögensbilanz und erscheint fortan als Renteneinkommen."));
}
}
if (taxHere > 0) {
const satz = drawnHere ? num(rd.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) : num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE);
steps.push(st("Kapitalbezugssteuer", satz, undefined, undefined, "Pauschalsatz die tatsächliche Steuer ist kantonal und progressiv.", "%"));
steps.push(st("Steuerbetrag", -taxHere));
}
if (inflowHere !== 0) steps.push(st("Netto ins Cash", inflowHere, "Bruttobezug Kapitalbezugssteuer"));
} else if (e.category === "REAL_ESTATE") {
anchor = "494-real_estate";
const re = realEstates.find((r) => r.ec.elementId === e.id);
if (td.decision === "SELL") {
const salePrice = Math.round(num(td.salePrice));
title = `Verkauf «${ec.name}»`;
steps.push(st("Verkaufspreis", salePrice));
steps.push(st("Ursprünglicher Kaufpreis", re?.purchase ?? 0, undefined, undefined, "Bemessungsgrundlage der Grundstückgewinnsteuer nicht der zwischenzeitliche Verkehrswert."));
steps.push(st("Steuerbarer Gewinn", Math.max(0, salePrice - (re?.purchase ?? 0)), "max(0, Verkaufspreis Kaufpreis)", `max(0, ${fmt(salePrice)} ${fmt(re?.purchase ?? 0)})`, "Ein Verlustverkauf erzeugt keine Steuer."));
steps.push(st("Grundstückgewinnsteuer", num(td.saleTaxRate, DEFAULT_PROPERTY_GAINS_TAX_RATE), undefined, undefined, "Die Haltedauer-Abstufung ist nicht modelliert.", "%"));
steps.push(st("Steuerbetrag", -taxHere));
steps.push(st("Hypothekenablösung", -(re?.mortgage ?? 0)));
steps.push(st("Netto ins Cash", inflowHere, "Verkaufspreis Hypothek Steuer", `${fmt(salePrice)} ${fmt(re?.mortgage ?? 0)} ${fmt(taxHere)}`));
} else {
const extra = Math.min(re?.mortgage ?? 0, Math.round(num(td.extraAmortization)));
steps.push(st("Entscheid", 0, undefined, "Halten", undefined, ""));
if (extra > 0) {
steps.push(st("Sonderamortisation", -extra, undefined, undefined, "Einmaltilgung aus dem Cash der Folgephase; senkt Restschuld und künftige Zinsen."));
steps.push(st("Resthypothek danach", carry.mortgage));
}
}
} else if (e.category === "OTHER_ASSET") {
anchor = "493-other_asset";
if (td.decision === "SELL") {
steps.push(st("Entscheid", 0, undefined, "Vollverkauf", undefined, ""));
steps.push(st("Erlös ins Cash", inflowHere, undefined, undefined, "Kein Steuerabzug private Kapitalgewinne sind in der Schweiz steuerfrei."));
} else if (td.decision === "PARTIAL") {
steps.push(st("Entscheid", 0, undefined, "Teilverkauf", undefined, ""));
steps.push(st("Betrag ins Cash", inflowHere, "min(Endwert, gewünschter Betrag)"));
steps.push(st("Rest bleibt investiert", carry.value, undefined, undefined, "Der verbleibende Teil wächst in der Folgephase weiter."));
} else {
steps.push(st("Entscheid", 0, undefined, "Halten", undefined, ""));
steps.push(st("Wert in der Folgephase", carry.value));
}
} else if (e.category === "OTHER_DEBT") {
anchor = "495-other_debt";
const immediate = Math.round(num(td.immediateRepayment));
steps.push(st("Restschuld", -ec.endValue));
if (immediate > 0) steps.push(st("Sofortige Tilgung", -Math.min(-ec.endValue, immediate), undefined, undefined, "Wird dem Cash der Folgephase entnommen."));
steps.push(st("Restschuld in der Folgephase", carry.owed, undefined, undefined, carry.owed === 0 ? "Vollständig getilgt." : undefined));
}
if (steps.length > 1) ec.transitionTrace = { title, specAnchor: anchor, steps };
}
carry.hasCarry = true;
}
// --- Punkt C (Roadmap Nr. 44): Verwendung des bezogenen Alterskapitals ---------------
//
// Bei der Pensionierung fliesst oft ein grosser Betrag auf einmal (PK-Kapital, Säule 3a).
// Ihn vollständig als Cash liegen zu lassen ist selten die Absicht. Die Verwendung wird
// als QUOTE erfasst: Verschiebt man das Pensionsalter, ändert sich der Betrag -- die
// Aufteilung skaliert mit, statt still falsch zu werden.
//
// Seit 0.33 hängt die Quote am jeweiligen VORSORGE-ELEMENT (PK bzw. 3a) statt am
// Cash-Übergang: Nur so lassen sich zwei Guthaben getrennt verwenden, und die Frage steht
// dort, wo der Bezugs-Entscheid fällt.
//
// Mechanisch nichts Neues: Die Amortisations-Quote wirkt wie eine Sonderamortisation, die
// Anlage-Quote wie eine Zusatzeinlage in der Folgephase. Beide sind Cash-Abflüsse und
// laufen damit korrekt durch beide Brücken.
for (const use of capitalUses) {
const amortPct = Math.max(0, Math.min(100, num(use.td.capitalUseAmortizationPct)));
const investPct = Math.max(0, Math.min(100 - amortPct, num(use.td.capitalUseInvestPct)));
if (use.net <= 0 || (amortPct === 0 && investPct === 0)) continue;
let amortBudget = Math.round((use.net * 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((use.net * investPct) / 100);
if (investBudget > 0) {
const target =
orderedElements.find(
(e) =>
e.id === use.td.capitalUseTargetElementId &&
e.category === "OTHER_ASSET" &&
carries.get(e.id)!.status === "ACTIVE"
) ??
orderedElements.find((e) => e.category === "OTHER_ASSET" && carries.get(e.id)!.status === "ACTIVE");
// Der Betrag wandert NICHT direkt in den Bestand, sondern über `capitalIn` in die
// Zusatzeinlage der Folgephase -- dadurch wird er im UI überall sichtbar.
if (target) {
const c = carries.get(target.id)!;
c.capitalIn += investBudget;
c.capitalInSources.push({ name: use.sourceName, amount: investBudget });
}
}
}
cashCarryIn = cashEnd + txInflow + txOneOffInflow - txImmediateRepay - txOneOffOutflow;
incomingInflow = txInflow;
incomingImmediateRepay = txImmediateRepay;
incomingOneOffInflow = txOneOffInflow;
incomingOneOffInflowLabel = txOneOffInflowLabel;
incomingOneOffOutflow = txOneOffOutflow;
incomingOneOffOutflowLabel = txOneOffOutflowLabel;
incomingTax = txTax;
incomingPensionConversion = txPensionConversion;
incomingSaleGainLoss = txSaleGainLoss;
// Rechenweg des Cash-Übergangs (einmalige Sonderein-/ausgaben). Wird nachträglich an
// die Trace-Liste dieser Phase gehängt -- `result` hält die Referenz auf das Array.
if (explain && nextPhase && (txOneOffInflow !== 0 || txOneOffOutflow !== 0)) {
const ct = phase.cashTransition ?? {};
const steps: TraceStep[] = [];
if (txOneOffInflow !== 0) {
const gross = Math.round(num(ct.inflowAmount));
steps.push(st(`Zufluss «${ct.inflowLabel?.trim() || "ohne Bezeichnung"}» brutto`, gross, undefined, undefined, "Nominal erfasst der Betrag, der zu diesem Zeitpunkt tatsächlich aufs Konto kommt."));
steps.push(st("Steuersatz", num(ct.inflowTaxRate, 0), undefined, undefined, "Default 0 % Erbschaften an direkte Nachkommen sind in den meisten Kantonen steuerfrei.", "%"));
steps.push(st("Netto ins Cash", txOneOffInflow, "brutto × (1 Steuersatz)", `${fmt(gross)} × (1 ${pct(num(ct.inflowTaxRate, 0))})`));
}
if (txOneOffOutflow !== 0) {
steps.push(st(`Kosten «${ct.outflowLabel?.trim() || "ohne Bezeichnung"}» real`, Math.round(num(ct.outflowAmount)), undefined, undefined, "Real erfasst, in heutiger Kaufkraft."));
steps.push(st("Bestands-Deflator an der Phasengrenze", Math.round(cumulativeInflation * 10000) / 10000, undefined, undefined, undefined, ""));
steps.push(st("Nominal vom Cash abgezogen", -txOneOffOutflow, "real × kumulierte Inflation", `${fmt(num(ct.outflowAmount))} × ${Math.round(cumulativeInflation * 10000) / 10000}`));
}
steps.push(st("Wirkung auf die Quote", 0, undefined, "keine", "Einmalposten sind keine laufenden Flows sie wirken ausschliesslich aufs Cash.", ""));
phaseTraces.push({ title: "Cash-Übergang: einmalige Sonderein-/ausgaben", specAnchor: "496-cash-einmalige-sonderein-ausgaben", steps });
}
yearsBefore += duration;
}
const nachlass = result.length > 0 ? result[result.length - 1].endWealthNominal : 0;
const ahvCareer = persons.map((p) => buildCareer(p, ahvIncomeAccum, ahvYearsAccum, gapYearsByPerson));
// --- Pensionierung: die Kennzahlen des gleichnamigen Bildschirms -------------------------
//
// Bewusst HIER und nicht im UI: Rentenlücke und Reichweite sind die beiden Zahlen, an denen
// die ganze Planung hängt. Eine Nebenrechnung in der Komponente hätte dieselbe Driftgefahr
// wie bei den Rechenwegen (Kap. 4.14.3) -- und würde im PDF-Bericht anders ausfallen.
//
// Die Rentenlücke ist KEINE neue Grösse: Sie ist die Verzehrquote im ersten Jahr, in dem
// niemand mehr arbeitet -- Renteneinkommen minus Ausgaben. Genau das rechnet die
// Jahresschleife ohnehin, es fehlte nur der Name dafür.
const retirement = ((): RetirementOverview => {
// Startjahr je Phase (1-basiert) -- PhaseComputed fuehrt nur die Dauer.
const startYearOf = new Map<string, number>();
let acc = 1;
for (const r of result) {
startYearOf.set(r.id, acc);
acc += r.durationYears;
}
const firstRetiredPhase = result.find((p) => p.persons.every((x) => !x.working));
const perPerson = persons.map((p) => {
// Die erste Phase, in der DIESE Person nicht mehr arbeitet -- dort stehen ihre Renten.
const ph = result.find((r) => r.persons.find((x) => x.role === p.role && !x.working));
const own = (cat: ElementCategory) =>
ph?.elements.find((e) => e.category === cat && e.ownerRole === p.role);
const rd = ahvDrawByPerson.get(p.id) ?? {};
return {
personId: p.id,
role: p.role,
retirementAge: retirementAge.get(p.id) ?? AHV_REFERENCE_AGE,
// Alter am Planende -- abgeleitet aus der Summe der Lebensphasen.
planningHorizonAge: totalYears > 0 ? p.age + totalYears : null,
ahvAnnual: Math.round(ahvFinalByPerson.get(p.id) ?? 0),
ahvFromAge: Math.round(ahvStartAge(rd)),
ahvDraw: ahvDrawLabel(rd),
pkPensionAnnual: Math.round(own("PENSION_FUND")?.startValue ?? 0),
// Einmalig verfügbares Alterskapital: netto, nach Kapitalbezugssteuer.
capitalAtRetirement: Math.round(capitalNetByPerson.get(p.id) ?? 0),
};
});
if (!firstRetiredPhase) {
return { firstRetirementYear: null, pensionIncome: null, expenses: null, gapAnnual: null, perPerson };
}
// Erstes Jahr dieser Phase: Einkommen und Ausgaben stehen im Jahrespunkt.
const firstYear = startYearOf.get(firstRetiredPhase.id) ?? 1;
const yp = yearly.find((y) => y.year === firstYear);
return {
firstRetirementYear: firstYear,
pensionIncome: yp ? Math.round(yp.income) : null,
expenses: yp ? Math.round(yp.expenseNominal) : null,
gapAnnual: yp ? Math.round(yp.income - yp.expenseNominal) : null,
perPerson,
};
})();
// --- Plan-weite Rechenwege ---
const planTraces: Trace[] = [];
if (explain) {
planTraces.push({
title: "Inflation und die drei Deflatoren",
specAnchor: "45-nominal-real-und-die-deflatoren",
steps: [
st("Angenommene Inflationsrate", plan.inflationRateDefault, undefined, undefined, "Gilt plan-weit für alle Lebensphasen; eine phasenspezifische Rate gibt es nicht.", "%"),
st("Planlaufzeit", totalYears, undefined, undefined, undefined, "Jahre"),
st("Kumulierte Inflation am Planende", Math.round(cumInfl[totalYears] * 10000) / 10000, "Produkt von (1 + Inflation) über alle Jahre", `(1 + ${pct(plan.inflationRateDefault)})^${totalYears}`, undefined, ""),
st("Kaufkraft eines heutigen Frankens am Planende", Math.round((1 / (cumInfl[totalYears] || 1)) * 10000) / 10000, "1 / kumulierte Inflation", undefined,
"Bestände am Phasenende, Bestände zu Phasenbeginn und Flow-Endwerte nutzen drei UNTERSCHIEDLICHE Deflatoren ein Flow fällt IM Jahr an, ein Bestand wird NACH dem Jahr gemessen.", ""),
],
});
for (const career of ahvCareer) {
const before = ahvBeforeByPerson.get(career.personId) ?? { avg: null, gap: 0 };
const avgBefore = before.avg ?? career.plannedAvgGrossIncome;
const mdJE = ahvMdje(career, avgBefore, before.gap);
planTraces.push({
title: `AHV-Beitragskarriere ${career.role === "PERSON_A" ? "Person A" : "Person B"}`,
specAnchor: "442-beitragskarriere-und-mdje",
steps: [
st("Beitragsjahre im Plan", career.planYears, "Summe (Phasendauer Ausfalljahre)", undefined, undefined, "Jahre"),
st("Durchschnittliches Bruttöinkommen im Plan (real)", Math.round(career.plannedAvgGrossIncome), `Nettolohn × ${AHV_GROSS_FROM_NET_FACTOR}`, undefined,
"Das Tool erfasst netto, die AHV bemisst sich am Brutto. Der Faktor 1.12 ist ein Karriere-Durchschnitt und keine exakte Einzelfall-Umrechnung."),
st("Beitragsjahre vor Planbeginn", Math.max(0, career.yearsBeforePlan - before.gap), "Alter bei Planbeginn 21 Ausfalljahre davor", undefined, undefined, "Jahre"),
st("Durchschnittseinkommen vor Planbeginn (real, brutto)", Math.round(avgBefore), undefined, undefined,
before.avg === null && career.yearsBeforePlan > 0
? "Nicht erfasst geschätzt aus dem geplanten Durchschnitt. Im Pensionierungs-Bildschirm zu prüfen."
: "Aus der AHV-Rentenvorausberechnung übernommen."),
st("Massgebendes durchschnittliches Jahreseinkommen", Math.round(mdJE),
"(Einkommen davor × Jahre davor + Einkommen im Plan × Jahre im Plan) / Total Jahre", undefined,
"REAL gerechnet: die echte AHV wertet vergangene Einkommen auf UND indexiert die Schwellen beides hebt sich real weitgehend auf."),
st("Ausfalljahre total", (gapYearsByPerson.get(career.personId) ?? 0) + before.gap, undefined, undefined, undefined, "Jahre"),
],
});
}
planTraces.push({
title: "Ruinalter",
specAnchor: "47-die-jahresschleife",
steps: [
st("Ruinalter", ruinAge ?? 0, undefined, ruinAge === null ? "kein Ruin das Gesamtvermögen bleibt über die ganze Planung positiv" : `Alter ${ruinAge}`,
"Bezieht sich auf das GESAMTvermögen inklusive Immobilien, nicht auf das Cash. Wird nur beim ersten Auftreten gesetzt.", ruinAge === null ? "" : "Jahre"),
st("Endvermögen (Nachlass, nominal)", nachlass, undefined, undefined, "Entspricht dem nominalen Endvermögen der letzten Lebensphase."),
],
});
}
return { phases: result, yearly, nachlass, retirement, ruinAge, ahvCareer, traces: explain ? planTraces : undefined };
}
// Durchschnittliches REALES Jahreseinkommen über eine Phase. Nominal wächst der Flow mit
// idx, real wird er mit der Inflation deflationiert -> geometrische Reihe mit q.
function avgRealFlow(basis: number, idx: number, infl: number, duration: number, cumInflStart: number): number {
const q = (1 + idx / 100) / (1 + infl / 100);
const sum = Math.abs(q - 1) < 1e-12 ? duration : (1 - Math.pow(q, duration)) / (1 - q);
return (basis / (cumInflStart || 1)) * (sum / duration);
}
function buildCareer(
owner: { id: string; role: PersonRole; age: number },
incomeAccum: Map<string, number>,
yearsAccum: Map<string, number>,
gapAccum: Map<string, number>
): AhvCareer {
const planYears = yearsAccum.get(owner.id) ?? 0;
const incomeSum = incomeAccum.get(owner.id) ?? 0; // bereits brutto (siehe Element-Setup)
return {
personId: owner.id,
role: owner.role,
plannedAvgGrossIncome: planYears > 0 ? incomeSum / planYears : 0,
planYears,
yearsBeforePlan: ahvYearsBeforePlan(owner.age),
gapYearsInPlan: gapAccum.get(owner.id) ?? 0,
};
}
// Der CSV-Export liegt seit 0.31 in lib/csv.ts (vollstaendige Matrix statt Phasen-Summary).