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; 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(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(); 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(); // AHV-Beitragskarriere je Person: reales Einkommen x Beitragsjahre, und Beitragsjahre. const ahvIncomeAccum = new Map(); const ahvYearsAccum = new Map(); // 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(); const carries = new Map(); 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(); 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(); 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(); // Reales Durchschnittseinkommen dieser Phase je Person (für die AHV-Karriere). const phaseRealIncomeByPerson = new Map(); 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)[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, yearsAccum: Map, gapAccum: Map ): 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, 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).