0953880a80
Deploy App / deploy (push) Successful in 1m10s
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>
1852 lines
92 KiB
TypeScript
1852 lines
92 KiB
TypeScript
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).
|