Files
FPT/src/lib/calculations.ts
T
admGitAICDS 0953880a80
Deploy App / deploy (push) Successful in 1m10s
Modul-Review 4: Matrix -- Kapitalverwendung, Phasendauer, Bedienung
Kapitalverwendung (Punkt C) am richtigen Ort:
- Prozent-Aufteilung des bezogenen Alterskapitals wandert vom Cash-Uebergang
  zum Bezugs-Entscheid der PK (nur bei Kapitalbezug) bzw. der Saeule 3a --
  mit zwei Guthaben liess sie sich vorher gar nicht getrennt beantworten
- Dialoge fuehren neu brutto -> Steuersatz -> netto -> Verteilung
- BUGFIX: Der zugeteilte Betrag erhoehte still den internen Bestand, deshalb
  zeigten Ziel-Element und "Kapital verteilen" eine 0. Er laeuft jetzt ueber
  Carry.capitalIn als Zusatzeinlage der Folgephase und ist ueberall sichtbar
- Saeule 3a ist am Pensions-Uebergang neu ein offener Entscheid

Phasendauer (gemeldeter Fehler):
- Die Folgephase gleicht eine geaenderte Dauer aus; Gesamtdauer bleibt gleich
- Vorher kappte das Tool nur die bearbeitete Phase -> Phase 2 ueberspannte
  danach die Pensionierung und die Invariante aus Punkt 44 kippte
- Rueckfrage vorher, Blockade wenn die Folgephase unter 1 Jahr fiele
- neue reine Funktion planDurationChange

Bedienung:
- Element-Zeile und Phasenkopf: Stift (umbenennen, beim Element inkl.
  Zuordnung), Papierkorb, Expand -- alle immer sichtbar
- PATCH /api/elements/<id> nimmt neu auch ownerRole
- Hilfetexte via Portal (wurden in scrollenden Dialogen abgeschnitten)
- Verteil-Dialoge: Zuordnung je Zeile, nach vom/ins Cash gruppiert,
  Vorbelegung mit dem EFFEKTIVEN Wert inkl. Vererbung (zeigte vorher 0)
- Matrix: gleiche Spaltenbreiten + horizontales Scrollen, "Alle auf-/
  zuklappen", Kategorie-Summe in der zugeklappten Zeile
- Phasen-Detailansicht nutzt die neue Aufteilungs-Grafik
- Uebersicht: "Leer starten" auch im leeren Zustand

Nebenbei: dritte verstuemmelte Hex-Farbe (#7c3aed) repariert, Phasen-Panel
nutzt den eigenen Bestaetigungs-Dialog statt window.confirm; mehrere veraltete
Referenzen und die buildCarryData-Tabelle in der Spez nachgezogen.

SPEZIFIKATION 0.33. 278 -> 288 Tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 23:09:03 +02:00

1852 lines
92 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,
} from "@/lib/constants";
import { num } from "@/lib/elements";
import { actualsForYear, rebaseFlow, type ResolvedActuals } from "@/lib/actuals";
import type { ElementCategory, TransitionData } from "@/lib/elements";
import type { 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;
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;
}
export interface PlanComputed {
phases: PhaseComputed[];
yearly: YearPoint[];
nachlass: number;
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);
}
// Maximale Dauer einer neuen Phase bis zum nächsten Pensionsereignis (null = unbegrenzt).
export function maxPhaseDuration(
persons: { role: PersonRole; age: number; retirementAge: number }[],
yearsBefore: number
): number | null {
const caps: number[] = [];
for (const p of persons) {
const startAge = p.age + yearsBefore;
if (startAge < p.retirementAge) caps.push(p.retirementAge - startAge);
}
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;
// 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,
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.
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 -- aus der Prüfung am Pensions-Übergang bzw. (für bereits
// Pensionierte) aus der Phasenzelle der ersten Phase.
const ahvBeforeByPerson = new Map<string, { avg: number; gap: number }>();
const carries = new Map<string, Carry>();
for (const e of plan.elements) carries.set(e.id, emptyCarry());
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);
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);
}
// Bereits bei Planbeginn pensioniert: es gibt keinen Pensions-Übergang, an dem die
// Beitragskarriere geprüft werden könnte -- die Werte liegen dann in der Phasenzelle.
for (const e of plan.elements) {
if (e.category !== "AHV" || !e.ownerRole) continue;
const owner = personByRole(persons, e.ownerRole);
if (!owner || workingByPerson.get(owner.id)) continue;
if (ahvBeforeByPerson.has(owner.id)) continue; // aus dem Übergang bereits gesetzt
const pd = e.phaseValues[phase.id] ?? {};
ahvBeforeByPerson.set(owner.id, {
avg: num(pd.avgIncomeBefore),
gap: Math.max(0, Math.round(num(pd.gapYearsBefore))),
});
}
// 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: 0, gap: 0 };
const career = buildCareer(owner, ahvIncomeAccum, ahvYearsAccum, gapYearsByPerson);
const mdJE = ahvMdje(career, before.avg, 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)));
}
// --- 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;
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)!;
const pd = e.phaseValues[phase.id] ?? {};
const owner = e.ownerRole && e.ownerRole !== "HOUSEHOLD" ? personByRole(persons, e.ownerRole) : null;
const ownerWorking = owner ? workingByPerson.get(owner.id) ?? false : anyWorking;
// Punkt A (Roadmap Nr. 44): Ein nicht erfasster Wiederkehr-Parameter wird aus der
// Vorphase ÜBERNOMMEN, statt stillschweigend auf 0 zu fallen. Im UI ist das das
// angehakte «Aus Vorphase übernehmen»; ein eigener Wert hakt es ab. Der jeweils
// verwendete Wert wird mitgeführt, damit die Kette über mehrere Phasen trägt.
const inherited = (key: string, fallback = 0): number => {
const own = (pd as Record<string, unknown>)[key];
const v = typeof own === "number" ? own : carry.rates[key] ?? fallback;
carry.rates[key] = v;
return v;
};
const ec: ElementPhaseComputed = {
elementId: e.id,
category: e.category,
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 ageStart = owner.age + yearsBefore; // Alter im ersten Jahr der Phase
const ageEnd = ageStart + duration - 1; // Alter im letzten Jahr der Phase
ahvItems.push({ ownerId: owner.id, ownerStartAge: ageStart, rente, beitrag, working: ownerWorking, ec });
const reachesRef = ageEnd >= AHV_REFERENCE_AGE;
const startsRetired = ageStart >= AHV_REFERENCE_AGE;
ec.startValue = startsRetired ? rente : 0;
ec.endValue = reachesRef ? rente : 0;
if (startsRetired) {
ec.summary = `Rente ${fmt(rente)}`;
} else if (reachesRef && !ownerWorking) {
ec.summary = `Beitrag ${fmt(beitrag)} → Rente ${fmt(rente)}`;
ec.note = `Rente ab Alter ${AHV_REFERENCE_AGE}; bis dahin Beitrag als Nichterwerbstätige(r).`;
} else if (reachesRef) {
ec.summary = `Rente ab ${AHV_REFERENCE_AGE} ${fmt(rente)}`;
} else if (!ownerWorking) {
ec.summary = `Beitrag ${fmt(beitrag)}`;
ec.note = `Frühpensioniert: beitragspflichtig bis Alter ${AHV_REFERENCE_AGE}.`;
} else {
const gap = Math.max(0, Math.round(num(pd.gapYears)));
ec.summary = gap > 0 ? `${gap} Ausfalljahre` : "Keine Ausfalljahre";
}
}
break;
}
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)}`;
} else if (!ownerWorking) {
ec.note = "Vollständig bezogen";
ec.summary = "Bezogen";
} 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";
} 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;
carry.capitalIn = 0; // verbraucht -- sonst flösse er in jeder Folgephase erneut
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 Referenzalter -- auch wenn die Person noch
// arbeitet. Vorher zahlt eine bereits pensionierte Person Beiträge, die wie eine
// Ausgabe auf die Quote schlagen (Kap. 4.4.6).
let ahvIncome = 0;
let ahvCost = 0;
for (const a of ahvItems) {
const ageThisYear = a.ownerStartAge + t - 1; // Alter zu Jahresbeginn
if (ageThisYear >= AHV_REFERENCE_AGE) ahvIncome += a.rente;
else if (!a.working) ahvCost += a.beitrag;
}
incomeFlow += ahvIncome;
const expenseNominal = expenseRealBase * inflFactor + interestNominal + ahvCost;
const expenseReal = expenseRealBase + (interestNominal + ahvCost) / (inflFactor || 1);
const quote = incomeFlow - expenseNominal;
// Der Vermögenswert wird erst nach Verzinsung/Cash-Fortschreibung bekannt und weiter
// 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 value = ageThisYear >= AHV_REFERENCE_AGE ? a.rente : a.working ? 0 : -a.beitrag;
if (a.ec.yearly.length < t) a.ec.yearly.push({ year: yr, age, value });
}
// Verrentete PK läuft nominal fix durch die Phase.
for (const ec of ecById.values()) {
if (ec.category === "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: 0, gap: 0 } : { avg: 0, gap: 0 };
const career = owner ? buildCareer(owner, ahvIncomeAccum, ahvYearsAccum, gapYearsByPerson) : null;
const mdJE = career ? ahvMdje(career, before.avg, 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: TransitionData }[] = [];
// 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;
const ownerRetiresNext =
!!owner && !!nextPhase && workingByPerson.get(owner.id) === true &&
retiresInPhase(owner.id, persons, retirementAge, yearsBefore + duration);
// Einkommen/Ausgaben: Basiswert wurde bereits im Element-Setup fortgeschrieben.
if (e.category === "INCOME" || e.category === "EXPENSE") {
carry.hasCarry = true;
continue;
}
// AHV: beim Pensions-Übergang die geprüften Karriere-Werte vor Planbeginn übernehmen.
if (e.category === "AHV") {
if (ownerRetiresNext && owner) {
const career = buildCareer(owner, ahvIncomeAccum, ahvYearsAccum, gapYearsByPerson);
ahvBeforeByPerson.set(owner.id, {
// Ohne erfassten Wert gilt der geplante Durchschnitt als Schätzung für die Jahre
// vor Planbeginn -- exakt der Wert, den der Prüf-Dialog vorbelegt. Ein Fallback auf
// 0 würde die Rente still und massiv zu tief rechnen. Beide Werte sind BRUTTO.
avg: num(td.avgIncomeBefore, career.plannedAvgGrossIncome),
gap: Math.max(0, Math.round(num(td.gapYearsBefore))),
});
}
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 (ownerRetiresNext) {
const value = ec.endValue;
const mode = td.payoutMode ?? "PENSION";
if (mode === "CAPITAL") {
const net = Math.round(value * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100));
txInflow += net;
txTax += value - net;
capitalUses.push({ net, td });
carry.value = 0;
carry.pkPensionAnnual = 0;
} else if (mode === "PENSION") {
carry.pkPensionAnnual = Math.round((value * num(td.conversionRate, DEFAULT_PK_CONVERSION_RATE)) / 100);
txPensionConversion += value;
carry.value = 0;
} else {
const capital = Math.min(value, Math.round(num(td.capitalAmount)));
const net = Math.round(capital * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100));
txInflow += net;
txTax += capital - net;
capitalUses.push({ net, td });
carry.pkPensionAnnual = Math.round(((value - capital) * num(td.conversionRate, DEFAULT_PK_CONVERSION_RATE)) / 100);
txPensionConversion += value - capital;
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": {
if (ownerRetiresNext) {
const net = Math.round(ec.endValue * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100));
txInflow += net;
txTax += ec.endValue - net;
capitalUses.push({ net, td });
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";
const mode = ownerRetiresNext ? td.payoutMode ?? "PENSION" : td.withdrawalMode ?? "NONE";
title = `Übergang «${ec.name}» ${ownerRetiresNext ? "Pensionierung" : "Vorbezug"}`;
if (ownerRetiresNext && e.category === "PENSION_FUND") {
steps.push(st("Gewählte Bezugsart", 0, undefined, mode === "CAPITAL" ? "Kapitalbezug" : mode === "COMBI" ? "Kombination" : "Rente", undefined, ""));
if (mode === "PENSION" || mode === "COMBI") {
steps.push(st("Umwandlungssatz", num(td.conversionRate, DEFAULT_PK_CONVERSION_RATE), undefined, undefined, undefined, "%"));
steps.push(st("Jährliche Rente", carry.pkPensionAnnual, "verrentetes Kapital × Umwandlungssatz", undefined, "Das verrentete Kapital verlässt die Vermögensbilanz und erscheint fortan als Renteneinkommen."));
}
}
if (taxHere > 0) {
steps.push(st("Kapitalbezugssteuer", num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE), undefined, undefined, "Pauschalsatz die tatsächliche Steuer ist kantonal und progressiv.", "%"));
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) carries.get(target.id)!.capitalIn += 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));
// --- 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: 0, gap: 0 };
const mdJE = ahvMdje(career, before.avg, 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(before.avg), undefined, undefined,
before.avg === 0 && career.yearsBeforePlan > 0 ? "Noch nicht erfasst am Pensions-Übergang 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, 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,
};
}
function retiresInPhase(
personId: string,
persons: { id: string; role: PersonRole; age: number }[],
retirementAge: Map<string, number>,
yearsBeforeNext: number
): boolean {
const p = persons.find((x) => x.id === personId);
if (!p) return false;
return p.age + yearsBeforeNext >= retirementAge.get(personId)!;
}
// Der CSV-Export liegt seit 0.31 in lib/csv.ts (vollstaendige Matrix statt Phasen-Summary).