UI-Gesamtumbau: Du-Form, Inspector-Panel, Onboarding-Assistent, Tour, Palette
Deploy App / deploy (push) Successful in 1m1s

Rein an der Oberflaeche -- Berechnung, Datenmodell und API-Semantik unveraendert
(103 Tests unveraendert gruen).

Paket A (Fundament):
- durchgehend Du-Form und echte Umlaute in allen sichtbaren Texten,
  inkl. API-Fehlermeldungen (vorher Mix aus Sie/Du und ae/oe/ue)
- neue UI-Primitiven (ui.tsx): Button, Modal mit ESC/Fokus-Falle/Animation,
  Bestaetigungs-Dialog statt window.confirm, Toasts statt alert,
  Skeleton-Loader, EmptyState
- eigene Attention-Farbe (Amber) fuer offene Entscheide, getrennt vom Akzent
- Micro-Interactions mit prefers-reduced-motion-Fallback

Paket B (Onboarding, Roadmap Nr. 10):
- gefuehrter Plan-Assistent in 5 Schritten; Einkommen bewusst pro Person
  (raeumt die 9.9-AHV-Falle aus); reine Orchestrierung bestehender Endpunkte
- Beispielplan mit einem Klick; Uebergaenge absichtlich offen
- interaktive Tour ueber die Planansicht (localStorage, jederzeit neu startbar)
- abgeleitete "Naechste Schritte"-Karte (offene Entscheide, fehlende Elemente,
  fehlende Pensionsphase, Ruin -> Einflussfaktoren)

Paket C (Struktur):
- Inspector-Panel rechts statt Modals fuer alle Einzel-Bearbeitungen;
  Matrix bleibt sichtbar, Zellklick wechselt den Inhalt
- Phasenkopf auf vier Kern-Infos entschlackt (Rest in der 0.11-Detailansicht)
- Matrix mit eigenem Scrollbereich, Koepfe beidachsig fixiert
- Sidebar-Gruppen "Meine Plaene" / "Wissen"; "So rechnet FPT" statt
  SPEZIFIKATION; "Szenario-Profil" statt "Plan-Einstellungen"
- Aktions-Icons ohne Hover sichtbar (Touch)

Paket D (Extras):
- Sparklines je Element-Zeile aus den 0.11-Verlaufswerten
- Befehls-Palette (Ctrl/Cmd+K)
- Ruin-Banner verlinkt auf die Einflussfaktoren

Nebenbei: der ProfileMenu-Lint-Fehler und der Selection-Rest (9.17) sind
behoben -- npm run lint laeuft erstmals fehlerfrei.

SPEZIFIKATION auf 0.13: neue Kapitel 3.2.8, 3.7.6-3.7.9, 9.23, 9.24;
3.6.3 und 3.7.1 ueberarbeitet, 9.17 bereinigt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 07:40:42 +02:00
parent e1f74fca95
commit c2fb82b0de
60 changed files with 2915 additions and 1210 deletions
+1 -1
View File
@@ -19,7 +19,7 @@ export async function createSessionToken(userId: string): Promise<string> {
.sign(getSecretKey());
}
// Liefert die User-ID aus einem gueltigen Session-Token, sonst null.
// Liefert die User-ID aus einem gültigen Session-Token, sonst null.
export async function verifySessionToken(token: string): Promise<string | null> {
try {
const { payload } = await jwtVerify(token, getSecretKey());
+21 -21
View File
@@ -3,10 +3,10 @@ import { computePlan } from "@/lib/calculations";
import type { CashTransitionData, ElementCategory, PhaseData, TransitionData } from "@/lib/elements";
import type { PlanInput } from "@/lib/types";
// Die zentrale Invariante beider Wasserfall-Bruecken: `residual` ist die Differenz zwischen
// dem tatsaechlichen Endwert und der Summe der gezeichneten Schritte. Ist die Zerlegung
// vollstaendig und richtig, MUSS sie 0 sein -- ein Wasserfall, der nicht aufgeht, waere ein
// Fehler in der Berechnung und nicht bloss ein Darstellungsproblem. Deshalb ueber moeglichst
// Die zentrale Invariante beider Wasserfall-Brücken: `residual` ist die Differenz zwischen
// dem tatsächlichen Endwert und der Summe der gezeichneten Schritte. Ist die Zerlegung
// vollständig und richtig, MUSS sie 0 sein -- ein Wasserfall, der nicht aufgeht, wäre ein
// Fehler in der Berechnung und nicht bloss ein Darstellungsproblem. Deshalb über möglichst
// verschiedene Konstellationen festgenagelt statt an einem einzigen Beispiel.
let idc = 0;
@@ -100,7 +100,7 @@ const konstellationen: { name: string; build: () => PlanInput }[] = [
}),
},
{
name: "PK-Kapitalbezug statt Rente (Steuer am Uebergang)",
name: "PK-Kapitalbezug statt Rente (Steuer am Übergang)",
build: () =>
plan({
age: 62,
@@ -142,7 +142,7 @@ const konstellationen: { name: string; build: () => PlanInput }[] = [
}),
},
{
name: "Immobilie verkauft (Verkaufsdifferenz und Grundstueckgewinnsteuer)",
name: "Immobilie verkauft (Verkaufsdifferenz und Grundstückgewinnsteuer)",
build: () =>
plan({
age: 45,
@@ -164,7 +164,7 @@ const konstellationen: { name: string; build: () => PlanInput }[] = [
}),
},
{
name: "Einmalige Sonderein-/ausgaben am Uebergang",
name: "Einmalige Sonderein-/ausgaben am Übergang",
build: () =>
plan({
age: 50,
@@ -189,7 +189,7 @@ const konstellationen: { name: string; build: () => PlanInput }[] = [
}),
},
{
name: "Sofort-Tilgung und Sonderamortisation am Uebergang",
name: "Sofort-Tilgung und Sonderamortisation am Übergang",
build: () =>
plan({
age: 50,
@@ -222,15 +222,15 @@ const konstellationen: { name: string; build: () => PlanInput }[] = [
},
];
describe("Wasserfall-Bruecken", () => {
describe("Wasserfall-Brücken", () => {
for (const k of konstellationen) {
it(`${k.name}: beide Bruecken gehen ohne Restgroesse auf`, () => {
it(`${k.name}: beide Brücken gehen ohne Restgrösse auf`, () => {
const r = computePlan(k.build());
expect(r.phases.length).toBeGreaterThan(0);
for (const ph of r.phases) {
expect(ph.wealthBridge.residual, `Vermoegensbruecke ${ph.name}`).toBe(0);
expect(ph.cashBridge.residual, `Cash-Bruecke ${ph.name}`).toBe(0);
// Die Kontrollpunkte muessen den offiziellen Kennzahlen entsprechen.
expect(ph.wealthBridge.residual, `Vermögensbrücke ${ph.name}`).toBe(0);
expect(ph.cashBridge.residual, `Cash-Brücke ${ph.name}`).toBe(0);
// Die Kontrollpunkte müssen den offiziellen Kennzahlen entsprechen.
expect(ph.wealthBridge.startWealth).toBe(ph.startWealthNominal);
expect(ph.wealthBridge.endWealth).toBe(ph.endWealthNominal);
expect(ph.cashBridge.cashStart).toBe(ph.cashStart);
@@ -239,10 +239,10 @@ describe("Wasserfall-Bruecken", () => {
});
}
it("Umbuchungen erscheinen NICHT in der Vermoegensbruecke", () => {
// Sparraten und Amortisationen verschieben Geld vom Cash in einen Vermoegenswert, ohne
// das Vermoegen zu aendern. Sie duerfen deshalb nur in der Cash-Bruecke auftauchen; die
// Vermoegensaenderung erklaert sich allein aus Quote, Ertraegen und PK-Beitraegen.
it("Umbuchungen erscheinen NICHT in der Vermögensbrücke", () => {
// Sparraten und Amortisationen verschieben Geld vom Cash in einen Vermögenswert, ohne
// das Vermögen zu ändern. Sie dürfen deshalb nur in der Cash-Brücke auftauchen; die
// Vermögensänderung erklärt sich allein aus Quote, Erträgen und PK-Beiträgen.
const r = computePlan(konstellationen[0].build());
const ph = r.phases[0];
expect(ph.cashBridge.savingRates).toBeGreaterThan(0);
@@ -253,9 +253,9 @@ describe("Wasserfall-Bruecken", () => {
);
});
it("PK-Verrentung erscheint als Vermoegensabgang am Uebergang", () => {
// Das verrentete Kapital verlaesst die Bilanz und wird zum Rentenstrom -- in der
// Vermoegensbruecke ein echter Abgang, kein Umbuchungsposten.
it("PK-Verrentung erscheint als Vermögensabgang am Übergang", () => {
// Das verrentete Kapital verlässt die Bilanz und wird zum Rentenstrom -- in der
// Vermögensbrücke ein echter Abgang, kein Umbuchungsposten.
const r = computePlan(konstellationen[1].build());
const pension = r.phases[1];
expect(pension.wealthBridge.pensionConversion).toBeGreaterThan(0);
@@ -268,7 +268,7 @@ describe("Wasserfall-Bruecken", () => {
expect(gehalten.saleGainLoss).toBe(0);
expect(gehalten.transitionTax).toBe(0);
expect(verkauft.transitionTax).toBeGreaterThan(0);
// Verkaufspreis ueber dem modellierten Verkehrswert -> positive Differenz.
// Verkaufspreis über dem modellierten Verkehrswert -> positive Differenz.
expect(verkauft.saleGainLoss).not.toBe(0);
});
});
+37 -37
View File
@@ -45,8 +45,8 @@ function plan(opts: {
const within = (actual: number, expected: number, pct: number) => Math.abs(actual - expected) <= Math.abs(expected) * pct;
// Stuetzstellen aus der AMTLICHEN Tabelle 318.117.1 "Monatliche Vollrenten, Skala 44"
// (BSV, gueltig ab 1.1.2025/2026): mdJE -> monatliche Vollrente. Deckt Mindestrente,
// Stützstellen aus der AMTLICHEN Tabelle 318.117.1 "Monatliche Vollrenten, Skala 44"
// (BSV, gültig ab 1.1.2025/2026): mdJE -> monatliche Vollrente. Deckt Mindestrente,
// Formel 1, den Wendepunkt (45'360), Formel 2 und die Maximalrente ab.
const AHV_AMTLICHE_TABELLE: [number, number][] = [
[15120, 1260], // Mindestrente (12 x R0)
@@ -72,7 +72,7 @@ describe("AHV-Rentenformel (Skala 44)", () => {
it("kappt ausserhalb der Schwellen", () => {
expect(ahvMonthlyFullPension(0)).toBe(1260); // unter 12 x R0 -> Mindestrente
expect(ahvMonthlyFullPension(10000)).toBe(1260);
expect(ahvMonthlyFullPension(150000)).toBe(2520); // ueber 72 x R0 -> Maximalrente
expect(ahvMonthlyFullPension(150000)).toBe(2520); // über 72 x R0 -> Maximalrente
});
it("ist am Wendepunkt stetig (kein Sprung zwischen Formel 1 und 2)", () => {
@@ -82,12 +82,12 @@ describe("AHV-Rentenformel (Skala 44)", () => {
});
});
// V5-Modell: Einkommen = nominale Basis + nominale Lohnerhoehung; Ausgaben = REALE Basis +
// V5-Modell: Einkommen = nominale Basis + nominale Lohnerhöhung; Ausgaben = REALE Basis +
// reale Mehrausgaben, nominal = real x (plan-weite Inflation).
// Netto -> Brutto fuer die AHV (AHV_GROSS_FROM_NET_FACTOR).
// Netto -> Brutto für die AHV (AHV_GROSS_FROM_NET_FACTOR).
const BRUTTO = 1.12;
describe("Teilverkauf Sonstiges Vermoegen (Roadmap 42)", () => {
describe("Teilverkauf Sonstiges Vermögen (Roadmap 42)", () => {
function p42(decision: "HOLD" | "SELL" | "PARTIAL", amount?: number) {
return plan({
age: 40, retirementAge: 70, inflation: 0, initialCash: 0,
@@ -111,10 +111,10 @@ describe("Teilverkauf Sonstiges Vermoegen (Roadmap 42)", () => {
const r = computePlan(p42("PARTIAL", 30000));
expect(r.phases[1].capitalInflow).toBe(30000); // "zu investierendes Kapital" im Phasenkopf
expect(r.phases[1].cashStart).toBe(30000);
expect(asset(p42("PARTIAL", 30000), 1).startValue).toBe(70000); // Rest laeuft weiter
expect(asset(p42("PARTIAL", 30000), 1).startValue).toBe(70000); // Rest läuft weiter
});
it("Teilverkauf am Endwert gekappt; Vollverkauf und Halten unveraendert", () => {
it("Teilverkauf am Endwert gekappt; Vollverkauf und Halten unverändert", () => {
expect(computePlan(p42("PARTIAL", 999999)).phases[1].capitalInflow).toBe(100000);
expect(computePlan(p42("SELL")).phases[1].capitalInflow).toBe(100000);
expect(computePlan(p42("HOLD")).phases[1].capitalInflow).toBe(0);
@@ -203,7 +203,7 @@ describe("Immobilie: Zins und Wertsteigerung", () => {
expect(computePlan(mit).phases[0].cashEnd).toBe(500000 - 90000);
});
it("Zinsbetrag sinkt mit der Amortisation und zaehlt in die Quote", () => {
it("Zinsbetrag sinkt mit der Amortisation und zählt in die Quote", () => {
const p = immoPlan({
purchasePrice: 1000000, mortgage: 1000000, amortization: 10000, interestRate: 1, interestHandling: "ADD",
});
@@ -211,10 +211,10 @@ describe("Immobilie: Zins und Wertsteigerung", () => {
// Jahr 1: 1% von 1'000'000 = 10'000. Jahr 10: 1% von (1'000'000 - 9 x 10'000) = 9'100.
expect(ph.expenseStart).toBe(10000);
expect(ph.expenseEnd).toBe(9100);
expect(ph.quotaStart).toBe(-10000); // Zins schlaegt auf die Quote durch
expect(ph.quotaStart).toBe(-10000); // Zins schlägt auf die Quote durch
});
it("Verkauf: Grundstueckgewinnsteuer bemisst sich am urspruenglichen Kaufpreis", () => {
it("Verkauf: Grundstückgewinnsteuer bemisst sich am ursprünglichen Kaufpreis", () => {
const p = plan({
age: 40, retirementAge: 70, inflation: 0, initialCash: 0,
phases: [
@@ -231,13 +231,13 @@ describe("Immobilie: Zins und Wertsteigerung", () => {
});
const r = computePlan(p);
// Gewinn = 1'200'000 - 1'000'000 (Kaufpreis!) = 200'000 -> Steuer 40'000.
// Erloes = 1'200'000 - 800'000 Resthypothek - 40'000 = 360'000.
// Erlös = 1'200'000 - 800'000 Resthypothek - 40'000 = 360'000.
expect(r.phases[1].capitalInflow).toBe(360000);
});
});
describe("AHV einkommensabhaengig", () => {
// 60-jaehrig, Pension mit 65: 39 Beitragsjahre vor Planbeginn (ab 21), 5 im Plan.
describe("AHV einkommensabhängig", () => {
// 60-jährig, Pension mit 65: 39 Beitragsjahre vor Planbeginn (ab 21), 5 im Plan.
function ahvPlan(opts: {
age?: number;
income: number;
@@ -250,7 +250,7 @@ describe("AHV einkommensabhaengig", () => {
return plan({
age,
retirementAge: 65,
inflation: 0, // real = nominal, damit die Erwartungswerte von Hand pruefbar bleiben
inflation: 0, // real = nominal, damit die Erwartungswerte von Hand prüfbar bleiben
phases: [
{ id: "p1", durationYears: 65 - age },
{ id: "p2", durationYears: 10 },
@@ -282,7 +282,7 @@ describe("AHV einkommensabhaengig", () => {
});
it("mdJE unter der Schwelle -> abgestufte Rente (Formel 2)", () => {
// Plan-Einkommen ist NETTO -> fuer die AHV auf brutto hochgerechnet; das Feld
// Plan-Einkommen ist NETTO -> für die AHV auf brutto hochgerechnet; das Feld
// "vor Planbeginn" ist bereits brutto. 39 Jahre davor, 5 im Plan.
const p = ahvPlan({ income: 60000, avgIncomeBefore: 60000 });
const mdJE = (60000 * 39 + 60000 * BRUTTO * 5) / 44;
@@ -290,8 +290,8 @@ describe("AHV einkommensabhaengig", () => {
expect(renteIn(p, 1)).toBeLessThan(32760);
});
it("Nettolohn wird fuer die AHV auf den Bruttolohn hochgerechnet", () => {
// Ohne Vorgeschichte (Alter 21 bei Planbeginn) haengt das mdJE nur am Plan-Einkommen.
it("Nettolohn wird für die AHV auf den Bruttolohn hochgerechnet", () => {
// Ohne Vorgeschichte (Alter 21 bei Planbeginn) hängt das mdJE nur am Plan-Einkommen.
const p = plan({
age: 21,
retirementAge: 65,
@@ -323,7 +323,7 @@ describe("AHV einkommensabhaengig", () => {
expect(renteIn(p, 1)).toBe(Math.round(ahvMonthlyFullPension(mdJE) * 13));
});
it("Ausfalljahre kuerzen die Rente ueber die Skala 44", () => {
it("Ausfalljahre kürzen die Rente über die Skala 44", () => {
const ohne = ahvPlan({ income: 100000, avgIncomeBefore: 100000 });
const mit = ahvPlan({ income: 100000, avgIncomeBefore: 100000, gapYearsBefore: 4 });
expect(renteIn(mit, 1)).toBe(Math.round(32760 * (40 / 44))); // 4 Ausfalljahre = 4/44 weniger
@@ -336,7 +336,7 @@ describe("AHV einkommensabhaengig", () => {
expect(renteIn(p, 1)).toBe(Math.round(32760 * (42 / 44)));
});
it("ohne Pruefung gilt der geplante Durchschnitt auch fuer die Jahre vor Planbeginn", () => {
it("ohne Prüfung gilt der geplante Durchschnitt auch für die Jahre vor Planbeginn", () => {
// Kein avgIncomeBefore erfasst -> darf NICHT als 0 gerechnet werden (sonst mdJE ~45%).
const p = plan({
age: 60,
@@ -348,11 +348,11 @@ describe("AHV einkommensabhaengig", () => {
],
elements: [
el("INCOME", "PERSON_A", { p1: { amount: 80000, teuerungsausgleich: 0 }, p2: {} }),
el("AHV", "PERSON_A", { p1: {}, p2: {} }), // keine Uebergangsdaten
el("AHV", "PERSON_A", { p1: {}, p2: {} }), // keine Übergangsdaten
],
});
const rente = computePlan(p).phases[1].elements.find((e) => e.category === "AHV")!.startValue;
// mdJE = geplantes Brutto (80'000 x 1.12), nicht ~9'091 (das waere der Fallback auf 0).
// mdJE = geplantes Brutto (80'000 x 1.12), nicht ~9'091 (das wäre der Fallback auf 0).
expect(rente).toBe(Math.round(ahvMonthlyFullPension(80000 * BRUTTO) * 13));
});
@@ -463,7 +463,7 @@ describe("V5 Golden Tests", () => {
expect(ph.expenseEnd).toBe(Math.round(100000 * Math.pow(1.02, 4))); // 108'243
});
it("Einkommen nominal flach bei Lohnerhoehung 0%", () => {
it("Einkommen nominal flach bei Lohnerhöhung 0%", () => {
const p = plan({
age: 40,
retirementAge: 60,
@@ -475,7 +475,7 @@ describe("V5 Golden Tests", () => {
expect(ph.incomeEnd).toBe(80000);
});
it("Bezugsrate aus Sonstigem Vermoegen fliesst ins Cash (Verzehrrate)", () => {
it("Bezugsrate aus Sonstigem Vermögen fliesst ins Cash (Verzehrrate)", () => {
const p = plan({
age: 65,
retirementAge: 65,
@@ -506,11 +506,11 @@ describe("V5 Golden Tests", () => {
});
const r = computePlan(p);
const p2 = r.phases[1];
expect(p2.capitalInflow).toBe(10000); // Verkaufserloes aus dem Uebergang
expect(p2.capitalInflow).toBe(10000); // Verkaufserlös aus dem Übergang
expect(p2.capitalInvest).toBe(10000); // Zusatzinvestition
expect(p2.cashStart).toBe(0); // Startwert bereits nach Abzug der Investition
expect(p2.cashEnd).toBe(0);
// Startvermoegen Phase 2: Cash(0) + Vermoegen B(10'000) = 10'000 (kein Doppelzaehlen).
// Startvermögen Phase 2: Cash(0) + Vermögen B(10'000) = 10'000 (kein Doppelzählen).
expect(p2.startWealthNominal).toBe(10000);
});
@@ -597,7 +597,7 @@ describe("V5 Golden Tests", () => {
expect(pk.startValue).toBe(200000); // brutto 100'000 dem Kapital entnommen
});
it("Einmaliger Zufluss am Uebergang: nominal erfasst, Steuer abgezogen, direkt ins Cash", () => {
it("Einmaliger Zufluss am Übergang: nominal erfasst, Steuer abgezogen, direkt ins Cash", () => {
const p = plan({
age: 40,
retirementAge: 70,
@@ -610,13 +610,13 @@ describe("V5 Golden Tests", () => {
elements: [],
});
const r = computePlan(p);
expect(r.phases[1].oneOffInflow).toBe(90000); // 100'000 abzueglich 10% Steuer
expect(r.phases[1].oneOffInflow).toBe(90000); // 100'000 abzüglich 10% Steuer
expect(r.phases[1].oneOffInflowLabel).toBe("Erbschaft");
expect(r.phases[1].cashStart).toBe(91000); // 1'000 + 90'000
expect(r.phases[0].oneOffInflow).toBe(0); // Phase 1 hat keinen eingehenden Uebergang
expect(r.phases[0].oneOffInflow).toBe(0); // Phase 1 hat keinen eingehenden Übergang
});
it("Einmalige Kosten am Uebergang: real erfasst, mit Inflation aufgewertet", () => {
it("Einmalige Kosten am Übergang: real erfasst, mit Inflation aufgewertet", () => {
// Kosten 20'000 real, 2% Inflation, Grenze nach 10 Jahren -> 20'000 x 1.02^10 = 24'380.
const p = plan({
age: 40,
@@ -648,7 +648,7 @@ describe("V5 Golden Tests", () => {
const r1 = computePlan(beide);
expect(r1.phases[1].cashStart).toBe(30000); // +50'000 -20'000
// Betraege sind erfasst, aber der Entscheid lautet "1:1 uebernehmen" -> keine Wirkung.
// Beträge sind erfasst, aber der Entscheid lautet "1:1 übernehmen" -> keine Wirkung.
const keine = plan({
age: 40, retirementAge: 70, inflation: 0, initialCash: 0,
phases: [
@@ -660,7 +660,7 @@ describe("V5 Golden Tests", () => {
expect(computePlan(keine).phases[1].cashStart).toBe(0);
});
it("Einmalige Kosten koennen eine Liquiditaetsluecke ausloesen", () => {
it("Einmalige Kosten können eine Liquiditätslücke auslösen", () => {
const p = plan({
age: 40, retirementAge: 70, inflation: 0, initialCash: 10000,
phases: [
@@ -675,7 +675,7 @@ describe("V5 Golden Tests", () => {
expect(p2.incomplete).toBe(true);
});
it("Cash-Entscheid der LETZTEN Phase bleibt wirkungslos (kein Uebergang mehr)", () => {
it("Cash-Entscheid der LETZTEN Phase bleibt wirkungslos (kein Übergang mehr)", () => {
const p = plan({
age: 40, retirementAge: 70, inflation: 0, initialCash: 5000,
phases: [{ id: "p1", durationYears: 1, cashTransition: { mode: "INFLOW", inflowAmount: 999999 } }],
@@ -686,7 +686,7 @@ describe("V5 Golden Tests", () => {
expect(r.nachlass).toBe(5000);
});
it("Vermoegen wird pro JAHR mitgefuehrt (Grundlage des Verlaufs ueber alle Jahre)", () => {
it("Vermögen wird pro JAHR mitgeführt (Grundlage des Verlaufs über alle Jahre)", () => {
const p = plan({
age: 40, retirementAge: 70, inflation: 0, initialCash: 0,
phases: [{ id: "p1", durationYears: 3 }],
@@ -697,12 +697,12 @@ describe("V5 Golden Tests", () => {
expect(r.yearly[0].wealthNominal).toBe(110000);
expect(r.yearly[1].wealthNominal).toBe(121000);
expect(r.yearly[2].wealthNominal).toBe(133100);
// Ohne Inflation sind real und nominal identisch; Endjahr = Phasen-Endvermoegen.
// Ohne Inflation sind real und nominal identisch; Endjahr = Phasen-Endvermögen.
expect(r.yearly[2].wealthReal).toBe(133100);
expect(r.yearly[2].wealthNominal).toBe(r.phases[0].endWealthNominal);
});
it("Vermoegen real ist bei Inflation kaufkraftbereinigt", () => {
it("Vermögen real ist bei Inflation kaufkraftbereinigt", () => {
const p = plan({
age: 40, retirementAge: 70, inflation: 10, initialCash: 100000,
phases: [{ id: "p1", durationYears: 1 }],
@@ -713,7 +713,7 @@ describe("V5 Golden Tests", () => {
expect(y.wealthReal).toBe(Math.round(100000 / 1.1)); // 90'909
});
it("Fortschreibung: nominaler Einkommens-Basiswert Phase 1 -> Startwert Phase 2; Cash laeuft fort", () => {
it("Fortschreibung: nominaler Einkommens-Basiswert Phase 1 -> Startwert Phase 2; Cash läuft fort", () => {
const p = plan({
age: 40,
retirementAge: 70,
+120 -120
View File
@@ -27,13 +27,13 @@ export interface PersonPhaseInfo {
}
// --- Rechenweg-Protokoll (Roadmap Nr. 41) ------------------------------------------------
// Ein Schritt haelt die Formel UND die eingesetzten Zahlen fest. Entscheidend: Die Schritte
// entstehen INNERHALB der Berechnung, als Nebenprodukt der Rechnung, die ohnehin laeuft.
// Damit kann die angezeigte Herleitung nicht von der tatsaechlichen Rechnung abdriften --
// eine zweite Formel-Implementierung im UI waere die gefaehrlichste Variante ueberhaupt.
// 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 + Lohnerhoehung)^(t-1)"
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" | "";
@@ -47,15 +47,15 @@ export interface Trace {
}
export interface ComputeOptions {
// Standardmaessig aus: die Monte-Carlo-Simulation ruft computePlan zehntausendfach auf
// Standardmässig aus: die Monte-Carlo-Simulation ruft computePlan zehntausendfach auf
// und darf von der Protokollierung nichts merken.
explain?: boolean;
}
// Ein Datenpunkt pro Jahr JE ELEMENT -- Grundlage der Detailansicht (Roadmap Nr. 43).
// Nur die Phasengrenzen zu kennen reicht nicht: Eine Anlage mit 5 % ueber 20 Jahre waechst
// konvex, eine Gerade von Start zu Ende waere sichtbar falsch (gleiche Ueberlegung wie beim
// Vermoegensverlauf in v0.9).
// 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
@@ -81,47 +81,47 @@ export interface ElementPhaseComputed {
note: string | null;
yearly: ElementYearPoint[]; // Verlauf innerhalb dieser Phase
trace?: Trace; // Rechenweg der Phasenwerte (nur mit explain)
transitionTrace?: Trace; // Rechenweg des Uebergangs NACH dieser Phase (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 erhoehen im selben Zug
// einen Vermoegenswert bzw. senken eine Schuld; das Vermoegen bleibt unveraendert. Sie
// gehoeren deshalb in die Cash-Bruecke und ausdruecklich NICHT in die Vermoegens-Bruecke --
// dort als Abgang gezeichnet wuerden sie eine Vermoegensminderung suggerieren, die es nicht
// 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; // Endvermoegen der Vorphase (Phase 1: Startvermoegen)
// Uebergang IN diese Phase -- echte Vermoegensaenderungen an der Phasengrenze:
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 Grundstueckgewinnsteuer (Abgang)
pensionConversion: number; // in eine Rente umgewandeltes PK-Kapital (verlaesst die Bilanz)
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) ueber alle Phasenjahre
investmentReturn: number; // Rendite auf PK/3a/Sonstigem Vermoegen
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-Beitraege: erhoehen das Vermoegen, ohne Cash zu kosten
pensionFundContribution: number; // PK-Beiträge: erhöhen das Vermögen, ohne Cash zu kosten
endWealth: number; // = endWealthNominal
residual: number; // Rundungsdifferenz (Kontrollgroesse, sollte nahe 0 sein)
residual: number; // Rundungsdifferenz (Kontrollgrösse, sollte nahe 0 sein)
}
export interface CashBridge {
openingCash: number; // Cash-Endbestand der Vorphase (Phase 1: initialCash)
capitalInflow: number; // Verkaeufe + PK-/3a-Bezuege aus dem Uebergang
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 + Sparbeitraege (Abgang)
savingRates: number; // 3a + Sparbeiträge (Abgang)
debtRates: number; // Amortisationen + Tilgungen (Abgang)
withdrawals: number; // Bezugsraten aus Sonstigem Vermoegen (Zugang)
withdrawals: number; // Bezugsraten aus Sonstigem Vermögen (Zugang)
cashEnd: number;
residual: number;
}
@@ -143,15 +143,15 @@ export interface PhaseComputed {
quotaStart: number;
quotaEnd: number;
isConsumption: boolean;
// Geplante Sparrate im ERSTEN Phasenjahr: 3a + Sonstiges-Vermoegen-Sparbeitrag + Amort. +
// Geplante Sparrate im ERSTEN Phasenjahr: 3a + Sonstiges-Vermögen-Sparbeitrag + Amort. +
// Tilgung. Amortisation/Tilgung entfallen, sobald Hypothek/Schuld abbezahlt sind -- die Rate
// kann in spaeteren Phasenjahren also tiefer liegen.
// kann in späteren Phasenjahren also tiefer liegen.
plannedSaveRate: number;
plannedWithdrawRate: number; // geplante Verzehrrate: Bezugsraten aus Sonstigem Vermoegen
capitalInflow: number; // Kapitalzufluss: PK-/3a-Bezuege + Verkaeufe (aus dem Uebergang in diese Phase)
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 Uebergang in DIESE Phase (nominal, netto nach Steuer).
// Bewusst getrennt von capitalInflow/capitalInvest: eine Erbschaft ist kein Verkaufserloes,
// 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;
@@ -159,29 +159,29 @@ export interface PhaseComputed {
oneOffOutflowLabel: string | null;
cashStart: number;
cashEnd: number;
cashNegative: boolean; // Cash faellt in dieser Phase (irgendwann) unter 0 -> Liquiditaetsluecke
incomplete: boolean; // roter Status = Liquiditaetsluecke
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 fuer den Flow-Endwert (Jahr `duration`)
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 (ueber alle Phasen), fuer die Verlaufsgrafik.
// 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;
// Gesamtvermoegen am Jahresende (inkl. Cash, abzgl. Schulden) -- fuer den
// Vermoegensverlauf ueber ALLE Jahre statt nur ueber die Phasengrenzen.
// 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;
}
@@ -190,8 +190,8 @@ export interface PlanComputed {
phases: PhaseComputed[];
yearly: YearPoint[];
nachlass: number;
ruinAge: number | null; // Alter (Person A), in dem das Gesamtvermoegen (inkl. Cash) erstmals < 0 faellt
ahvCareer: AhvCareer[]; // Beitragskarriere je Person (fuer die AHV-Pruefung am Uebergang)
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)
}
@@ -203,7 +203,7 @@ export interface PlanComputed {
// 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;
// fuer eine Planung ist der stetige Wert naeher an der Wahrheit (Abweichung < 20/Monat).
// 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;
@@ -212,20 +212,20 @@ export function ahvMonthlyFullPension(mdJE: number): number {
return mdJE <= 36 * r0 ? r0 * (0.74 + 0.26 * x) : r0 * (1.04 + 0.16 * x);
}
// Beitragskarriere einer Person fuer die AHV -- akkumuliert ueber die Erwerbsphasen des Plans.
// 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 abzueglich Ausfalljahre)
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 ueber ALLE Beitragsjahre (vor Planbeginn + im Plan). REAL gerechnet, weil die
// 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.
@@ -236,7 +236,7 @@ export function ahvMdje(career: AhvCareer, avgGrossIncomeBefore: number, gapYear
return (avgGrossIncomeBefore * yearsBefore + career.plannedAvgGrossIncome * career.planYears) / totalYears;
}
// Jaehrliche AHV-Rente: Vollrente zum mdJE, mal 13 Zahlungen, gekuerzt um die Ausfalljahre
// 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(
@@ -251,7 +251,7 @@ export function ahvYearsBeforePlan(ageAtPlanStart: number): number {
return Math.max(0, ageAtPlanStart - AHV_CONTRIBUTION_START_AGE);
}
// Maximale Dauer einer neuen Phase bis zum naechsten Pensionsereignis (null = unbegrenzt).
// Maximale Dauer einer neuen Phase bis zum nächsten Pensionsereignis (null = unbegrenzt).
export function maxPhaseDuration(
persons: { role: PersonRole; age: number; retirementAge: number }[],
yearsBefore: number
@@ -266,13 +266,13 @@ export function maxPhaseDuration(
interface Carry {
status: ElementStatus;
value: number; // Aktiven-Saldo (PK/3a/Sonstiges Vermoegen) am Ende der Vorphase
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: urspruenglicher Kaufpreis (Basis der Grundstueckgewinnsteuer)
propertyPurchase: number; // Immobilie: ursprünglicher Kaufpreis (Basis der Grundstückgewinnsteuer)
owed: number; // Schulden: Restschuld (positiv)
pkPensionAnnual: number; // PK: jaehrliche Rente nach Verrentung
flowBasis: number; // Einkommen/Ausgaben: indexierter Basiswert der naechsten Phase
pkPensionAnnual: number; // PK: jährliche Rente nach Verrentung
flowBasis: number; // Einkommen/Ausgaben: indexierter Basiswert der nächsten Phase
hasCarry: boolean;
}
@@ -296,12 +296,12 @@ function fmt(v: number): string {
return sign + Math.abs(rounded).toString().replace(/\B(?=(\d{3})+(?!\d))/g, "'");
}
// Generisch, damit der Aufrufer den vollen Personen-Typ (inkl. age) behaelt.
// 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 fuer die Monte-Carlo-Simulation: liefert je Jahr eine Inflation und
// 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).
@@ -310,7 +310,7 @@ export interface PlanSample {
assetReturn: (elementId: string, year: number) => number;
}
// Kurzform fuer einen Rechenschritt. `pct` formatiert Prozentsaetze lesbar (2 statt 2.0000001).
// Kurzform für einen Rechenschritt. `pct` formatiert Prozentsätze lesbar (2 statt 2.0000001).
function st(
label: string,
result: number,
@@ -336,7 +336,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
// 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 frueheren geschlossenen Potenz-Ausdruecke.
// 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;
@@ -347,7 +347,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
// 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 Pruefung am Pensions-Uebergang bzw. (fuer bereits
// 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>();
@@ -358,18 +358,18 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
let yearsBefore = 0;
let cumulativeInflation = 1;
let cashCarryIn = Math.round(plan.initialCash || 0);
// Aus dem Uebergang der Vorphase in DIESE Phase fliessende Groessen (Kopf-Kennzahlen).
let incomingInflow = 0; // Brutto-Zufluss: Verkaeufe + PK-/3a-Bezuege
// 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 Vermoegensaenderungen am Uebergang IN die Folgephase (fuer die Vermoegens-Bruecke).
let incomingTax = 0; // Kapitalbezugs- + Grundstueckgewinnsteuer
// 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; // Endvermoegen der Vorphase (Startpunkt der Bruecke)
let previousEndWealth = 0; // Endvermögen der Vorphase (Startpunkt der Brücke)
let previousCashEnd = Math.round(plan.initialCash || 0);
let ruinAge: number | null = null;
@@ -402,7 +402,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
const maxDurationYears = capsFromWorking.length > 0 ? Math.min(...capsFromWorking) : null;
const workingByPerson = new Map(personInfos.map((p) => [p.personId, p.working]));
// Ausfalljahre kumulieren (nur waehrend der Erwerbstaetigkeit).
// 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;
@@ -413,13 +413,13 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
gapYearsByPerson.set(owner.id, (gapYearsByPerson.get(owner.id) ?? 0) + gy);
}
// Bereits bei Planbeginn pensioniert: es gibt keinen Pensions-Uebergang, an dem die
// Beitragskarriere geprueft werden koennte -- die Werte liegen dann in der Phasenzelle.
// 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 Uebergang bereits gesetzt
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),
@@ -427,7 +427,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
});
}
// AHV-Renten der Pensionierten: Vollrente zum mdJE, gekuerzt um die Ausfalljahre.
// AHV-Renten der Pensionierten: Vollrente zum mdJE, gekürzt um die Ausfalljahre.
const ahvUncapped = new Map<string, number>();
for (const e of plan.elements) {
if (e.category !== "AHV" || !e.ownerRole) continue;
@@ -446,16 +446,16 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
if (sum > cap && sum > 0) for (const [pid, v] of ahvUncapped) ahvFinal.set(pid, Math.round(v * (cap / sum)));
}
// --- Element-Laufzeitzustaende aufbauen ---
// --- 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 (fuer die AHV-Karriere).
// 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 }[] = [];
const expenses: { basis: number; idx: number; ec: ElementPhaseComputed }[] = [];
let renteTotal = 0; // AHV + PK-Renten (nominal fix)
// `isPk` fuer die Vermoegens-Bruecke: PK-Beitraege verlassen das Cash NICHT (sie sind im
// Nettolohn bereits abgezogen), erhoehen aber das Vermoegen -- sie sind also ein echter
// `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;
@@ -467,7 +467,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
}[] = [];
// 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 (waechst mit valueGrowth), `purchase` der urspruengliche Kaufpreis.
// Liegenschaft (wächst mit valueGrowth), `purchase` der ursprüngliche Kaufpreis.
const realEstates: {
value: number;
purchase: number;
@@ -479,8 +479,8 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
ec: ElementPhaseComputed;
}[] = [];
const debts: { owed: number; repay: number; ec: ElementPhaseComputed }[] = [];
let fixedRatesTotal = 0; // Sparraten mit konstantem Jahresbetrag: 3a + Sonstiges Vermoegen
let plannedWithdrawTotal = 0; // Bezugsraten (fliessen ins Cash): Sonstiges Vermoegen
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
@@ -524,10 +524,10 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
switch (e.category) {
case "INCOME":
case "EXPENSE": {
// Einkommen: Basis + Rate sind NOMINAL (Lohnerhoehung). Ausgaben: Basis + Rate sind
// 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 fuer Einkommen, real fuer Ausgaben), ausser bewusst geaendert (pd.amount).
// (nominal für Einkommen, real für Ausgaben), ausser bewusst geändert (pd.amount).
const idx = num(pd.teuerungsausgleich, 0);
ec.baseValue = carry.hasCarry ? Math.round(carry.flowBasis) : Math.round(num(pd.amount));
const basis = !carry.hasCarry
@@ -537,15 +537,15 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
: ec.baseValue;
(e.category === "INCOME" ? incomes : expenses).push({ basis, idx, ec });
// AHV: reales Erwerbseinkommen der Person mitfuehren. Nur Einkommen, die einer
// Person zugeordnet sind -- bei einem Einzelplan zaehlt "Gemeinsam" zur Person A.
// 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-Groesse auf Planungsbasis -- bewusst mit der
// festen Plan-Inflation, nicht der (evtl. gewuerfelten) Sample-Inflation.
// 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(
@@ -555,7 +555,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
}
}
// Basiswert der Folgephase fortschreiben (nominal fuer Einkommen, real fuer Ausgaben).
// Basiswert der Folgephase fortschreiben (nominal für Einkommen, real für Ausgaben).
carry.flowBasis = basis * Math.pow(1 + idx / 100, duration);
break;
}
@@ -579,13 +579,13 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
ec.endValue = carry.pkPensionAnnual;
ec.summary = `Rente ${fmt(carry.pkPensionAnnual)}`;
} else if (!ownerWorking) {
ec.note = "Vollstaendig bezogen";
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(num(pd.annualContribution)); // PK-Beitrag zaehlt NICHT zur Quote
const rate = Math.round(num(pd.annualContribution)); // PK-Beitrag zählt NICHT zur Quote
if (!isFirstPhase) investmentsFromCash += topUp;
ec.baseValue = base;
ec.startValue = start;
@@ -596,7 +596,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
}
case "PILLAR_3A": {
if (!ownerWorking) {
ec.note = "Vollstaendig bezogen";
ec.note = "Vollständig bezogen";
ec.summary = "Bezogen";
} else {
const base = carry.hasCarry ? carry.value : Math.round(num(pd.currentValue));
@@ -628,8 +628,8 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
break;
}
case "REAL_ESTATE": {
// Urspruenglicher Kaufpreis (Basis der Grundstueckgewinnsteuer) und Verkehrswert
// (waechst mit der Wertsteigerung) laufen getrennt.
// 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));
@@ -647,7 +647,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
amort,
growth: num(pd.valueGrowth),
interestRate: num(pd.interestRate),
// Default INCLUDED: bestehende Plaene haben die Zinsen in den Ausgaben -> nicht
// 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,
@@ -677,9 +677,9 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
ahvYearsAccum.set(p.id, (ahvYearsAccum.get(p.id) ?? 0) + contribYears);
}
// --- Jahr-fuer-Jahr: indexierte Flows, Cash-Ausgleich, Verzinsung, Ruin ---
// --- 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 Doppelzaehlen mit dem Vermoegen).
// 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;
@@ -689,8 +689,8 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
let expenseEnd = 0;
let quotaStart = 0;
let quotaEnd = 0;
let plannedSaveRate = 0; // Kopf-Kennzahl: die tatsaechliche Sparrate im ersten Phasenjahr
// Summanden der beiden Wasserfall-Bruecken (ueber alle Phasenjahre kumuliert).
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;
@@ -700,7 +700,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
let withdrawalsTotal = 0;
for (let t = 1; t <= duration; t++) {
// Einkommen: nominal (Basis x (1+Lohnerhoehung)^(t-1)) + Renten (nominal fix).
// 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.
@@ -709,7 +709,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
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. Zaehlt zu den Ausgaben (und damit in die Quote), sofern nicht bereits
// 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) {
@@ -721,8 +721,8 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
const expenseReal = expenseRealBase + interestNominal / (inflFactor || 1);
const quote = incomeFlow - expenseNominal;
// Der Vermoegenswert wird erst nach Verzinsung/Cash-Fortschreibung bekannt und weiter
// unten nachgetragen (gleiche Groesse, die auch die Ruin-Erkennung verwendet).
// 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,
@@ -745,11 +745,11 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
quotaEnd = quote;
}
// Vermoegen verzinsen + Sparbeitrag; Bezugsrate entnehmen (gekappt am Bestand) und ins Cash.
// 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 unveraendert lassen; die Rendite wird als Differenz abgeleitet, damit
// 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;
@@ -761,7 +761,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
}
// Amortisation/Tilgung: nur so lange und so viel, wie noch Restschuld besteht. Ist die
// Hypothek/Schuld abbezahlt, entfaellt die Rate -- sie belastet weder Cash noch Sparquote.
// 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);
@@ -771,7 +771,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
// 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 unveraendert
re.value *= 1 + g / 100; // Arithmetik unverändert
propertyAppreciationTotal += re.value - valueBefore;
}
for (const d of debts) {
@@ -790,7 +790,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
withdrawalsTotal += cashFromWithdraw;
// Verlaufspunkt je Element (Detailansicht). Bewusst NACH Verzinsung und Tilgung, also
// der Stand am Jahresende -- konsistent zum Gesamtvermoegen im YearPoint.
// 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) {
@@ -821,7 +821,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
}
}
// Gesamtvermoegen zum Jahresende t (fuer Ruin-Erkennung).
// 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;
@@ -831,7 +831,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
if (ruinAge === null && total < 0) ruinAge = personA.age + yearsBefore + t;
}
// Flow-Deflator fuer den Endwert (Jahr `duration`): eine Kaufkraft-Stufe weniger als der
// Flow-Deflator für den Endwert (Jahr `duration`): eine Kaufkraft-Stufe weniger als der
// Bestands-Deflator am Phasenende.
const flowDeflatorEnd = cumInfl[yearsBefore + duration - 1];
@@ -1007,7 +1007,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
};
}
// AHV-/PK-Renten: der Rechenweg der Rentenhoehe selbst liegt auf Plan-Ebene (mdJE).
// 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;
@@ -1232,19 +1232,19 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
previousEndWealth = endWealthNominal;
previousCashEnd = cashEnd;
// --- Uebergang: Carry aktualisieren, Cash der Folgephase bilden ---
// --- Übergang: Carry aktualisieren, Cash der Folgephase bilden ---
let txInflow = 0;
let txImmediateRepay = 0;
// Echte Vermoegensaenderungen an dieser Grenze (fuer die Bruecke der Folgephase).
// Verkaeufe, Bezuege und Tilgungen sind fuer sich Umbuchungen -- vermoegenswirksam sind
// nur die Steuer, die Verrentung (Kapital verlaesst die Bilanz) und die Differenz
// 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 Uebergang. Der Wechselkurs zwischen
// 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;
@@ -1281,14 +1281,14 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
continue;
}
// AHV: beim Pensions-Uebergang die geprueften Karriere-Werte vor Planbeginn uebernehmen.
// 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 Schaetzung fuer die Jahre
// vor Planbeginn -- exakt der Wert, den der Pruef-Dialog vorbelegt. Ein Fallback auf
// 0 wuerde die Rente still und massiv zu tief rechnen. Beide Werte sind BRUTTO.
// 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))),
});
@@ -1330,7 +1330,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
carry.value = 0;
}
} else {
// Vorbezug (z. B. Wohneigentum/Selbststaendigkeit): ebenfalls kapitalbezugssteuerpflichtig.
// 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;
@@ -1360,7 +1360,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
txInflow += ec.endValue;
carry.status = "SOLD";
} else if (td.decision === "PARTIAL") {
// Teilverkauf: Betrag fliesst ins Cash, der Rest bleibt aktiv und waechst weiter.
// 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;
@@ -1374,14 +1374,14 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
if (!re) break;
if (td.decision === "SELL") {
const salePrice = Math.round(num(td.salePrice));
// Grundstueckgewinnsteuer bemisst sich am urspruenglichen Kaufpreis, nicht am
// 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 Vermoegensgewinn bzw. -verlust an der Grenze.
// Differenz ist ein echter Vermögensgewinn bzw. -verlust an der Grenze.
txSaleGainLoss += Math.round(salePrice - re.value);
carry.status = "SOLD";
} else {
@@ -1409,7 +1409,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
break;
}
// --- Rechenweg des Uebergangs je Element ---
// --- Rechenweg des Übergangs je Element ---
if (explain && nextPhase) {
const inflowHere = txInflow - txStepsBefore;
const taxHere = txTax - taxBefore;
@@ -1492,8 +1492,8 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
incomingPensionConversion = txPensionConversion;
incomingSaleGainLoss = txSaleGainLoss;
// Rechenweg des Cash-Uebergangs (einmalige Sonderein-/ausgaben). Wird nachtraeglich an
// die Trace-Liste dieser Phase gehaengt -- `result` haelt die Referenz auf das Array.
// 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[] = [];
@@ -1541,7 +1541,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
specAnchor: "442-beitragskarriere-und-mdje",
steps: [
st("Beitragsjahre im Plan", career.planYears, "Summe (Phasendauer Ausfalljahre)", undefined, undefined, "Jahre"),
st("Durchschnittliches Bruttoeinkommen im Plan (real)", Math.round(career.plannedAvgGrossIncome), `Nettolohn × ${AHV_GROSS_FROM_NET_FACTOR}`, undefined,
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,
@@ -1568,7 +1568,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
return { phases: result, yearly, nachlass, ruinAge, ahvCareer, traces: explain ? planTraces : undefined };
}
// Durchschnittliches REALES Jahreseinkommen ueber eine Phase. Nominal waechst der Flow mit
// 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);
@@ -1616,8 +1616,8 @@ export function planToCsv(plan: PlanInput, computed: PlanComputed): string {
"Quote (Beginn)",
"Quote (Ende)",
"Cash (Ende)",
"Endvermoegen (nominal)",
"Endvermoegen (real)",
"Endvermögen (nominal)",
"Endvermögen (real)",
];
const rows = computed.phases.map((p) => [
p.name,
+26 -26
View File
@@ -1,9 +1,9 @@
// Konfigurierbare Systemparameter (Stand 2026). Aendern sich periodisch durch
// Anpassungen des Bundes -- deshalb hier zentral gefuehrt, nicht im Code verteilt.
// Konfigurierbare Systemparameter (Stand 2026). Ändern sich periodisch durch
// Anpassungen des Bundes -- deshalb hier zentral geführt, nicht im Code verteilt.
// R0 = Mindestbetrag der vollen monatlichen Altersrente (Skala 44). Alle Schwellen der
// Rentenformel sind Vielfache von R0 -- aendert sich R0, verschiebt sich die ganze Skala.
// Quelle: BSV/MAS "Berechnungsvorschriften der AHV/IV-Renten", gueltig ab 1.1.2026;
// Rentenformel sind Vielfache von R0 -- ändert sich R0, verschiebt sich die ganze Skala.
// Quelle: BSV/MAS "Berechnungsvorschriften der AHV/IV-Renten", gültig ab 1.1.2026;
// amtliche Tabelle 318.117.1 "Monatliche Vollrenten, Skala 44".
export const AHV_MIN_MONTHLY_FULL = 1260;
@@ -18,48 +18,48 @@ export const AHV_CONTRIBUTION_START_AGE = 21;
// Abgeleitet, damit eine Anpassung von R0 nicht an zwei Stellen nachgezogen werden muss.
export const AHV_MAX_ANNUAL_SINGLE = 2 * AHV_MIN_MONTHLY_FULL * AHV_PENSION_MONTHS;
// Umrechnung Netto- -> Bruttolohn fuer die AHV. Das Tool erfasst das Einkommen NETTO (so
// Umrechnung Netto- -> Bruttolohn für die AHV. Das Tool erfasst das Einkommen NETTO (so
// denkt der Nutzer, und so stimmt der Cash-Fluss), die AHV bemisst sich aber am BRUTTOlohn.
//
// Herleitung (Arbeitnehmer-Abzuege in % des Bruttolohns):
// AHV/IV/EO 5.30 % (10.6 % total, haelftig geteilt)
// ALV 1.10 % ( 2.2 % total, haelftig geteilt, bis 148'200)
// Herleitung (Arbeitnehmer-Abzüge in % des Bruttolohns):
// AHV/IV/EO 5.30 % (10.6 % total, hälftig geteilt)
// ALV 1.10 % ( 2.2 % total, hälftig geteilt, bis 148'200)
// NBU ~1.00 % (variiert 0.4-1.6 %, teils vom Arbeitgeber getragen)
// PK ~2-5 % (BVG-Altersgutschrift 7/10/15/18 % auf den KOORDINIERTEN Lohn
// [Brutto - 26'460, max. 90'720], Arbeitnehmer >= die Haelfte)
// [Brutto - 26'460, max. 90'720], Arbeitnehmer >= die Hälfte)
// -> total ~9-13 % je nach Alter und Lohn; Faktor 1/(1-q) = 1.10 bis 1.16, Mittel ~1.12.
//
// Ein fixer Faktor ist hier vertretbar, weil das mdJE selbst ein Durchschnitt ueber die ganze
// Beitragskarriere (44 Jahre) ist: der altersabhaengige PK-Satz mittelt sich dabei heraus.
// Die Restunschaerfe (~3 %) ist deutlich kleiner als der Fehler, den die Umrechnung behebt (~11 %).
// Ein fixer Faktor ist hier vertretbar, weil das mdJE selbst ein Durchschnitt über die ganze
// Beitragskarriere (44 Jahre) ist: der altersabhängige PK-Satz mittelt sich dabei heraus.
// Die Restunschärfe (~3 %) ist deutlich kleiner als der Fehler, den die Umrechnung behebt (~11 %).
//
// Quellen: BSV "Betraege gueltig ab 1.1.2026" (Koordinationsabzug 26'460, obere Limite 90'720,
// keine Aenderung gegenueber 2025); AHV-Merkblaetter 2.01 (AHV/IV/EO) und 2.08 (ALV).
// Quellen: BSV "Beträge gültig ab 1.1.2026" (Koordinationsabzug 26'460, obere Limite 90'720,
// keine Änderung gegenüber 2025); AHV-Merkblätter 2.01 (AHV/IV/EO) und 2.08 (ALV).
export const AHV_GROSS_FROM_NET_FACTOR = 1.12;
// Ehepaar-Plafonierung: die Summe beider Einzelrenten ist auf 150% der Einzel-
// Maximalrente begrenzt. Bei Ueberschreitung werden beide Renten proportional gekuerzt.
// Maximalrente begrenzt. Bei Überschreitung werden beide Renten proportional gekürzt.
export const AHV_COUPLE_CAP_FACTOR = 1.5;
// Volle Beitragsdauer fuer eine ungekuerzte AHV-Rente (Rentenskala 44). Pro fehlendes
// Beitragsjahr (Ausfalljahr) wird die Rente um 1/44 gekuerzt.
// Volle Beitragsdauer für eine ungekürzte AHV-Rente (Rentenskala 44). Pro fehlendes
// Beitragsjahr (Ausfalljahr) wird die Rente um 1/44 gekürzt.
export const AHV_FULL_CONTRIBUTION_YEARS = 44;
// Maximaler jaehrlicher Saeule-3a-Beitrag fuer PK-Versicherte (2026).
// Maximaler jährlicher Säule-3a-Beitrag für PK-Versicherte (2026).
export const PILLAR_3A_MAX_ANNUAL = 7258;
// Default-Annahmen fuer Uebergangs-Ereignisse (pro Ereignis editierbar). Werden SOWOHL als
// Default-Annahmen für Übergangs-Ereignisse (pro Ereignis editierbar). Werden SOWOHL als
// UI-Vorschlag als auch in der Berechnung als Fallback verwendet -- damit ein nicht
// angetupfter Wert nicht faelschlich als 0 gerechnet wird.
export const DEFAULT_PK_CONVERSION_RATE = 6; // % (jaehrliche Rente = Kapital x Satz)
// angetupfter Wert nicht fälschlich als 0 gerechnet wird.
export const DEFAULT_PK_CONVERSION_RATE = 6; // % (jährliche Rente = Kapital x Satz)
export const DEFAULT_CAPITAL_TAX_RATE = 8; // % Kapitalbezugssteuer (PK-/3a-Kapitalbezug)
export const DEFAULT_PROPERTY_GAINS_TAX_RATE = 20; // % Grundstueckgewinnsteuer
export const DEFAULT_PROPERTY_GAINS_TAX_RATE = 20; // % Grundstückgewinnsteuer
// --- Strukturierte Fassung fuer die Systemparameter-Ansicht (Roadmap Nr. 41) -------------
// --- Strukturierte Fassung für die Systemparameter-Ansicht (Roadmap Nr. 41) -------------
//
// Dieselben Werte wie oben, nur maschinenlesbar beschrieben: Damit kann die Oberflaeche
// Dieselben Werte wie oben, nur maschinenlesbar beschrieben: Damit kann die Oberfläche
// Wert, Bedeutung, Herleitung und Quelle anzeigen, OHNE dass die Zahlen ein zweites Mal
// gepflegt werden muessen -- die Eintraege referenzieren die Konstanten direkt und koennen
// gepflegt werden müssen -- die Einträge referenzieren die Konstanten direkt und können
// deshalb nicht von ihnen abdriften.
export type ParameterGroup = "AHV" | "Vorsorge" | "Steuern";
@@ -74,7 +74,7 @@ export interface SystemParameter {
source: string;
validFrom: string;
group: ParameterGroup;
editablePerEvent?: boolean; // Default-Satz, pro Ereignis ueberschreibbar
editablePerEvent?: boolean; // Default-Satz, pro Ereignis überschreibbar
}
export const SYSTEM_PARAMETERS: SystemParameter[] = [
+96
View File
@@ -0,0 +1,96 @@
// Beispielplan mit einem Klick (Teil von Roadmap Nr. 10). Erzeugt einen realistischen,
// vollständig ausgefuellten Plan über die BESTEHENDEN API-Endpunkte -- reine Orchestrierung,
// kein neuer Endpunkt, keine Änderung an der Berechnung.
//
// Didaktischer Entscheid: Die Übergangs-Entscheide bleiben absichtlich OFFEN. Der neue
// Nutzer sieht dadurch die Ampel ("N offen") in Aktion und lernt das wichtigste Konzept des
// Tools am Beispiel, statt es erklärt zu bekommen.
//
// WICHTIG zur Reihenfolge: erst Phase 1, dann die Elemente samt Phase-1-Werten, dann die
// Folgephase. Die Phasen-Route belegt beim Anlegen einer Phase die editierbaren Felder aus
// der Vorphase vor -- existieren die Elemente noch nicht, bleibt die Folgephase leer
// (Renditen/Beiträge wären dort 0).
import { api } from "@/lib/api-client";
import type { PhaseData } from "@/lib/elements";
async function addElement(
scenarioId: string,
firstPhaseId: string,
category: string,
name: string,
ownerRole: string,
values?: PhaseData
): Promise<void> {
const { element } = await api.post<{ element: { id: string } }>(
`/api/scenarios/${scenarioId}/elements`,
{ category, name, ownerRole }
);
if (values && Object.keys(values).length > 0) {
await api.put(`/api/elements/${element.id}/phase/${firstPhaseId}`, values);
}
}
// Liefert die Szenario-Id des angelegten Beispielplans.
export async function createDemoPlan(): Promise<string> {
const currentYear = new Date().getFullYear();
const { scenario } = await api.post<{ plan: { id: string }; scenario: { id: string } }>(
"/api/plans",
{
name: "Beispiel: Alex Muster",
householdType: "SINGLE",
inflationRateDefault: 1.5,
startYear: currentYear,
persons: [{ role: "PERSON_A", name: "Alex", age: 45, retirementAge: 65 }],
}
);
const sid = scenario.id;
await api.patch(`/api/scenarios/${sid}`, { initialCash: 40000 });
// Phase 1: Erwerbsphase bis zur Pension (die Route kappt die Dauer ohnehin am
// Pensionsereignis; 20 Jahre = 45 -> 65).
const p1 = await api.post<{ phase: { id: string } }>(`/api/scenarios/${sid}/phases`, {
name: "Erwerbsphase",
durationYears: 20,
});
const firstPhaseId = p1.phase.id;
await addElement(sid, firstPhaseId, "INCOME", "Lohn Alex", "PERSON_A", {
amount: 110000,
teuerungsausgleich: 1,
});
await addElement(sid, firstPhaseId, "EXPENSE", "Lebenshaltung", "HOUSEHOLD", {
amount: 78000,
teuerungsausgleich: 0,
});
await addElement(sid, firstPhaseId, "AHV", "AHV Alex", "PERSON_A", { gapYears: 0 });
await addElement(sid, firstPhaseId, "PENSION_FUND", "Pensionskasse", "PERSON_A", {
currentValue: 380000,
annualContribution: 14000,
expectedReturn: 1.5,
});
await addElement(sid, firstPhaseId, "PILLAR_3A", "Säule 3a", "PERSON_A", {
currentValue: 75000,
annualContribution: 7000,
expectedReturn: 2,
});
await addElement(sid, firstPhaseId, "OTHER_ASSET", "ETF-Depot", "HOUSEHOLD", {
startValue: 120000,
annualContribution: 12000,
expectedReturn: 5,
});
await addElement(sid, firstPhaseId, "REAL_ESTATE", "Eigenheim", "HOUSEHOLD", {
purchasePrice: 900000,
mortgage: 600000,
amortization: 12000,
interestRate: 1.5,
valueGrowth: 1,
});
// Pensionsphase 65 -> 90. Die Route belegt die editierbaren Felder aus Phase 1 vor.
await api.post(`/api/scenarios/${sid}/phases`, { name: "Pensionsphase", durationYears: 25 });
return sid;
}
+8 -8
View File
@@ -15,7 +15,7 @@ function base(): PlanInput {
};
}
// Unveraenderte Kopie.
// Unveränderte Kopie.
function copy(): PlanInput {
return {
id: "S2", name: "Variante", householdType: "SINGLE", inflationRateDefault: 1.5, initialCash: 1000,
@@ -40,16 +40,16 @@ describe("Szenario-Diff", () => {
expect(computeScenarioDiff(base(), null).total).toBe(0);
});
it("geaenderter Zellwert wird genau einer Zelle zugeordnet", () => {
it("geänderter Zellwert wird genau einer Zelle zugeordnet", () => {
const c = copy();
c.elements[0].phaseValues["c-ph1"] = { amount: 120000 };
const d = computeScenarioDiff(c, base());
expect(d.phaseCell.has("c-e1:c-ph1")).toBe(true);
expect(d.phaseCell.size).toBe(1);
expect(d.elementRow.size).toBe(0); // Zeile selbst unveraendert
expect(d.elementRow.size).toBe(0); // Zeile selbst unverändert
});
it("geaenderter Uebergangs-Entscheid markiert die Uebergangszelle", () => {
it("geänderter Übergangs-Entscheid markiert die Übergangszelle", () => {
const c = copy();
c.elements[1].transitionValues["c-ph1"] = { decision: "SELL" };
const d = computeScenarioDiff(c, base());
@@ -57,16 +57,16 @@ describe("Szenario-Diff", () => {
expect(d.phaseCell.size).toBe(0);
});
it("neues Element ist 'added', geloeschtes wird als entfernt gemeldet", () => {
it("neues Element ist 'added', gelöschtes wird als entfernt gemeldet", () => {
const c = copy();
c.elements.push({ id: "c-e3", category: "OTHER_DEBT", name: "Kredit", ownerRole: "HOUSEHOLD", orderIndex: 3, phaseValues: {}, transitionValues: {}, sourceElementId: null });
c.elements = c.elements.filter((e) => e.id !== "c-e1"); // Lohn geloescht
c.elements = c.elements.filter((e) => e.id !== "c-e1"); // Lohn gelöscht
const d = computeScenarioDiff(c, base());
expect(d.elementRow.get("c-e3")).toBe("added");
expect(d.removedElements.map((r) => r.name)).toEqual(["Lohn"]);
});
it("Phasenkopf: Dauer/Name geaendert bzw. Phase neu", () => {
it("Phasenkopf: Dauer/Name geändert bzw. Phase neu", () => {
const c = copy();
c.phases[0].durationYears = 15;
c.phases.push({ id: "c-ph2", sequenceNumber: 2, name: "Pension", durationYears: 25, cashTransition: {}, sourcePhaseId: null });
@@ -84,7 +84,7 @@ describe("Szenario-Diff", () => {
expect(d.cashInitialChanged).toBe(true);
});
it("Cash-Uebergang wird erkannt", () => {
it("Cash-Übergang wird erkannt", () => {
const c = copy();
c.phases[0].cashTransition = { mode: "INFLOW", inflowAmount: 100000 };
const d = computeScenarioDiff(c, base());
+10 -10
View File
@@ -1,10 +1,10 @@
// Abweichungs-Erkennung zwischen einem Szenario und seinem ELTERN-Szenario.
//
// Die Zuordnung laeuft ueber die Herkunfts-Verweise, die beim Kopieren gesetzt werden
// (Phase.sourcePhaseId, FinancialElement.sourceElementId). Ueber den Namen zu matchen waere
// fragil: Umbenennen wuerde die Verknuepfung brechen und gleichnamige Elemente kollidieren.
// Die Zuordnung läuft über die Herkunfts-Verweise, die beim Kopieren gesetzt werden
// (Phase.sourcePhaseId, FinancialElement.sourceElementId). Über den Namen zu matchen wäre
// fragil: Umbenennen würde die Verknüpfung brechen und gleichnamige Elemente kollidieren.
//
// Der Vergleich ist LIVE gegen den aktuellen Stand des Elternteils -- aendert man dort einen
// Der Vergleich ist LIVE gegen den aktuellen Stand des Elternteils -- ändert man dort einen
// Wert, verschiebt sich die Markierung im Kind entsprechend.
import { num } from "@/lib/elements";
@@ -18,15 +18,15 @@ export interface ScenarioDiff {
elementRow: Map<string, DiffKind>;
// `${elementId}:${phaseId}` -> Zelle weicht ab.
phaseCell: Set<string>;
// `${elementId}:${fromPhaseId}` -> Uebergangs-Zelle weicht ab.
// `${elementId}:${fromPhaseId}` -> Übergangs-Zelle weicht ab.
transitionCell: Set<string>;
// phaseId -> Phasenkopf weicht ab (Name/Dauer) bzw. Phase ist neu.
phaseHeader: Map<string, DiffKind>;
// phaseId -> Cash-Uebergang nach dieser Phase weicht ab.
// phaseId -> Cash-Übergang nach dieser Phase weicht ab.
cashTransitionCell: Set<string>;
cashInitialChanged: boolean;
profileChanged: boolean;
// Im Elternteil vorhandene, hier geloeschte Elemente (fuer die Geisterzeilen).
// Im Elternteil vorhandene, hier gelöschte Elemente (für die Geisterzeilen).
removedElements: { id: string; name: string; category: ElementInput["category"] }[];
removedPhaseCount: number;
total: number;
@@ -48,7 +48,7 @@ export function emptyDiff(): ScenarioDiff {
}
// Vergleicht zwei JSON-Payloads feldweise; fehlend und 0 gelten als gleich, damit ein
// nicht gesetztes Feld nicht faelschlich als Aenderung erscheint.
// nicht gesetztes Feld nicht fälschlich als Änderung erscheint.
function sameData(a: object = {}, b: object = {}): boolean {
const ra = a as Record<string, unknown>;
const rb = b as Record<string, unknown>;
@@ -72,7 +72,7 @@ export function computeScenarioDiff(scenario: PlanInput, base: PlanInput | null)
// --- Phasen ---
const basePhaseById = new Map(base.phases.map((p) => [p.id, p]));
const usedBasePhases = new Set<string>();
// Zuordnung eigene Phase -> Eltern-Phase (fuer die Zellen-Vergleiche).
// Zuordnung eigene Phase -> Eltern-Phase (für die Zellen-Vergleiche).
const phaseToBase = new Map<string, string>();
for (const ph of scenario.phases) {
@@ -108,7 +108,7 @@ export function computeScenarioDiff(scenario: PlanInput, base: PlanInput | null)
d.elementRow.set(el.id, "changed");
}
// Zellen je Phase / Uebergang ueber die Phasen-Zuordnung vergleichen.
// Zellen je Phase / Übergang über die Phasen-Zuordnung vergleichen.
for (const ph of scenario.phases) {
const basePhaseId = phaseToBase.get(ph.id);
const mine = el.phaseValues[ph.id];
+22 -22
View File
@@ -1,4 +1,4 @@
// Typ- und Validierungs-Layer fuer die finanziellen Elemente. Die kategorie- und
// Typ- und Validierungs-Layer für die finanziellen Elemente. Die kategorie- und
// kontextspezifischen Felder liegen in der DB als JSON; hier werden sie typisiert und
// (an der API-Grenze) mit Zod validiert.
@@ -19,7 +19,7 @@ export type OwnerRole = "PERSON_A" | "PERSON_B" | "HOUSEHOLD";
// Kategorien, deren Element zwingend genau einer Person zugeordnet ist.
export const PERSON_ONLY_CATEGORIES: ElementCategory[] = ["AHV", "PENSION_FUND", "PILLAR_3A"];
// Kategorien, die gemeinsam ODER pro Person erfasst werden koennen (inkl. Einkommen).
// Kategorien, die gemeinsam ODER pro Person erfasst werden können (inkl. Einkommen).
export const OWNER_OPTIONAL_CATEGORIES: ElementCategory[] = ["INCOME", "EXPENSE", "REAL_ESTATE", "OTHER_ASSET", "OTHER_DEBT"];
export const CATEGORY_LABELS: Record<ElementCategory, string> = {
@@ -27,9 +27,9 @@ export const CATEGORY_LABELS: Record<ElementCategory, string> = {
EXPENSE: "Ausgaben",
AHV: "AHV",
PENSION_FUND: "Pensionskasse",
PILLAR_3A: "Saeule 3a",
PILLAR_3A: "Säule 3a",
REAL_ESTATE: "Immobilie",
OTHER_ASSET: "Sonstiges Vermoegen",
OTHER_ASSET: "Sonstiges Vermögen",
OTHER_DEBT: "Sonstige Schulden",
};
@@ -52,14 +52,14 @@ export const CATEGORY_ORDER: ElementCategory[] = [
export interface PhaseData {
// INCOME / EXPENSE
amount?: number;
// INCOME / EXPENSE: jaehrlicher Teuerungsausgleich (%). Indexiert den Flow ueber die
// INCOME / EXPENSE: jährlicher Teuerungsausgleich (%). Indexiert den Flow über die
// Phasenjahre (Jahr t = Basis x (1+idx)^(t-1)). Eigenes Feld je Element (Einkommen und
// Ausgaben unabhaengig). Default = Phaseninflation.
// Ausgaben unabhängig). Default = Phaseninflation.
teuerungsausgleich?: number;
// AHV
gapYears?: number;
// AHV, nur wenn die Person bei Planbeginn BEREITS pensioniert ist (dann gibt es keinen
// Pensions-Uebergang, an dem die Karriere geprueft werden koennte): Beitragskarriere.
// Pensions-Übergang, an dem die Karriere geprüft werden könnte): Beitragskarriere.
// avgIncomeBefore ist REAL (heutige Kaufkraft).
avgIncomeBefore?: number;
gapYearsBefore?: number;
@@ -68,10 +68,10 @@ export interface PhaseData {
startValue?: number;
expectedReturn?: number;
annualContribution?: number;
// OTHER_ASSET: jaehrliche Bezugsrate (Entnahme). Mindert das Vermoegen und fliesst ins Cash.
// OTHER_ASSET: jährliche Bezugsrate (Entnahme). Mindert das Vermögen und fliesst ins Cash.
annualWithdrawal?: number;
// PENSION_FUND / PILLAR_3A / OTHER_ASSET (ab Phase 2): zusaetzliche Einlage aus dem
// verfuegbaren Kapital der Phase. Der Basis-Startwert wird aus der Vorphase fortgeschrieben.
// PENSION_FUND / PILLAR_3A / OTHER_ASSET (ab Phase 2): zusätzliche Einlage aus dem
// verfügbaren Kapital der Phase. Der Basis-Startwert wird aus der Vorphase fortgeschrieben.
additionalInvestment?: number;
// REAL_ESTATE
purchasePrice?: number;
@@ -79,10 +79,10 @@ export interface PhaseData {
amortization?: number;
// Hypothekarzins in % der Restschuld. Der Zinsbetrag sinkt dadurch mit der Amortisation.
interestRate?: number;
// Steuert die Doppelzaehlung: Sind die Zinsen im Ausgaben-Element bereits enthalten
// (INCLUDED, Default -- Verhalten bisheriger Plaene) oder soll das Tool sie dazurechnen (ADD)?
// Steuert die Doppelzählung: Sind die Zinsen im Ausgaben-Element bereits enthalten
// (INCLUDED, Default -- Verhalten bisheriger Pläne) oder soll das Tool sie dazurechnen (ADD)?
interestHandling?: "INCLUDED" | "ADD";
// Geschaetzte jaehrliche Wertveraenderung der LIEGENSCHAFT (nicht des Eigenkapitals).
// Geschätzte jährliche Wertveränderung der LIEGENSCHAFT (nicht des Eigenkapitals).
valueGrowth?: number;
// OTHER_DEBT
annualRepayment?: number;
@@ -92,36 +92,36 @@ export type TransitionDecision = "HOLD" | "SELL" | "PARTIAL";
export type PkPayoutMode = "CAPITAL" | "PENSION" | "COMBI";
export interface TransitionData {
// AHV (Pensions-Uebergang): Pruefung der Beitragskarriere. Das geplante Durchschnitts-
// AHV (Pensions-Übergang): Prüfung der Beitragskarriere. Das geplante Durchschnitts-
// einkommen kommt aus dem Plan; reicht der Plan nicht bis zum Beitragsbeginn (Alter 21)
// zurueck, ergaenzt der Benutzer die Jahre davor. avgIncomeBefore ist REAL.
// zurück, ergänzt der Benutzer die Jahre davor. avgIncomeBefore ist REAL.
reviewed?: boolean;
avgIncomeBefore?: number;
gapYearsBefore?: number;
// PENSION_FUND / PILLAR_3A (normaler Uebergang): expliziter Bezugs-Entscheid.
// PENSION_FUND / PILLAR_3A (normaler Übergang): expliziter Bezugs-Entscheid.
withdrawalMode?: "NONE" | "AMOUNT";
withdrawal?: number;
// PENSION_FUND (Pensions-Uebergang)
// PENSION_FUND (Pensions-Übergang)
payoutMode?: PkPayoutMode;
capitalAmount?: number;
conversionRate?: number;
// PENSION_FUND (Kapital) / PILLAR_3A (Pensions-Uebergang) / REAL_ESTATE
// PENSION_FUND (Kapital) / PILLAR_3A (Pensions-Übergang) / REAL_ESTATE
capitalTaxRate?: number;
saleTaxRate?: number;
// REAL_ESTATE / OTHER_ASSET
decision?: TransitionDecision;
salePrice?: number;
// OTHER_ASSET (Teilverkauf): Betrag, der am Uebergang ins Cash fliesst; der Rest bleibt aktiv.
// OTHER_ASSET (Teilverkauf): Betrag, der am Übergang ins Cash fliesst; der Rest bleibt aktiv.
partialSaleAmount?: number;
// REAL_ESTATE (Sonderamortisation): Einmaltilgung der Hypothek am Uebergang, aus dem Cash.
// REAL_ESTATE (Sonderamortisation): Einmaltilgung der Hypothek am Übergang, aus dem Cash.
extraAmortization?: number;
// OTHER_DEBT
immediateRepayment?: number;
}
// --- Cash-Uebergang: einmalige Sonderein-/ausgaben ---
// --- Cash-Übergang: einmalige Sonderein-/ausgaben ---
// Entscheid am UEBERGANG zwischen zwei Phasen, direkt auf dem Cash-Konto (Cash ist kein
// Element, der Entscheid haengt darum an der Von-Phase). Erfassungs-Konventionen analog zu
// Element, der Entscheid hängt darum an der Von-Phase). Erfassungs-Konventionen analog zu
// den laufenden Flows: Zufluss NOMINAL (wie Einkommen), Kosten REAL (wie Ausgaben).
export type CashTransitionMode = "NONE" | "INFLOW" | "OUTFLOW" | "BOTH";
+17 -17
View File
@@ -7,7 +7,7 @@ import type { PlanInput } from "@/lib/types";
// Reichhaltiger Plan: Erwerbsphase mit Einkommen, Ausgaben, PK, 3a, Immobilie (Hypothek,
// Amortisation, Wertsteigerung) und einer Schuld; danach Pensionsphase mit Verzehr.
// Am Uebergang: PK-Verrentung, 3a-Bezug mit Steuer, Sonderamortisation, Einmalposten.
// Am Übergang: PK-Verrentung, 3a-Bezug mit Steuer, Sonderamortisation, Einmalposten.
function richPlan(): PlanInput {
return {
id: "p",
@@ -93,8 +93,8 @@ describe("Verlaufswerte je Element", () => {
const etf = c.phases[0].elements.find((e) => e.elementId === "asset")!;
expect(etf.yearly[0].value).toBe(220000);
expect(etf.yearly[1].value).toBe(Math.round(220000 * 1.05 + 10000));
// Konvexitaet: die Jahreszuwaechse werden von Jahr zu Jahr GROESSER. Eine Gerade
// zwischen den Phasengrenzen haette konstante Zuwaechse -- genau der Fehler, den die
// Konvexität: die Jahreszuwächse werden von Jahr zu Jahr GROESSER. Eine Gerade
// zwischen den Phasengrenzen hätte konstante Zuwächse -- genau der Fehler, den die
// per-Jahr-Werte vermeiden.
const deltas = etf.yearly.slice(1).map((p, i) => p.value - etf.yearly[i].value);
for (let i = 1; i < deltas.length; i++) {
@@ -102,7 +102,7 @@ describe("Verlaufswerte je Element", () => {
}
});
it("Immobilie fuehrt Verkehrswert und Restschuld getrennt mit", () => {
it("Immobilie führt Verkehrswert und Restschuld getrennt mit", () => {
const c = computePlan(richPlan());
const re = c.phases[0].elements.find((e) => e.elementId === "re")!;
const y0 = re.yearly[0];
@@ -112,8 +112,8 @@ describe("Verlaufswerte je Element", () => {
});
});
describe("Wasserfall-Bruecken", () => {
it("Vermoegens-Bruecke geht auf (Restposten ~ 0)", () => {
describe("Wasserfall-Brücken", () => {
it("Vermögens-Brücke geht auf (Restposten ~ 0)", () => {
const c = computePlan(richPlan());
for (const ph of c.phases) {
// Rundungen auf ganze Franken je Summand -> ein paar Franken Toleranz.
@@ -123,7 +123,7 @@ describe("Wasserfall-Bruecken", () => {
}
});
it("Cash-Bruecke geht auf (Restposten ~ 0)", () => {
it("Cash-Brücke geht auf (Restposten ~ 0)", () => {
const c = computePlan(richPlan());
for (const ph of c.phases) {
expect(Math.abs(ph.cashBridge.residual)).toBeLessThanOrEqual(5);
@@ -132,7 +132,7 @@ describe("Wasserfall-Bruecken", () => {
}
});
it("Sparraten erscheinen in der CASH-, nicht in der Vermoegens-Bruecke", () => {
it("Sparraten erscheinen in der CASH-, nicht in der Vermögens-Brücke", () => {
// Reiner Umbuchungs-Plan: 12'000 Sparbeitrag pro Jahr, 0 % Rendite, keine Flows.
const plan: PlanInput = {
id: "p", name: "T", householdType: "SINGLE", inflationRateDefault: 0, initialCash: 100000,
@@ -149,14 +149,14 @@ describe("Wasserfall-Bruecken", () => {
// Cash: 5 x 12'000 fliessen ab.
expect(ph.cashBridge.savingRates).toBe(60000);
expect(ph.cashEnd).toBe(100000 - 60000);
// Vermoegen: unveraendert -- das Geld ist nur umgezogen, kein Ertrag, keine Quote.
// Vermögen: unverändert -- das Geld ist nur umgezogen, kein Ertrag, keine Quote.
expect(ph.wealthBridge.quotaTotal).toBe(0);
expect(ph.wealthBridge.investmentReturn).toBe(0);
expect(ph.startWealthNominal).toBe(100000);
expect(ph.endWealthNominal).toBe(100000);
});
it("PK-Beitraege sind ein Vermoegenszugang, ohne das Cash zu belasten", () => {
it("PK-Beiträge sind ein Vermögenszugang, ohne das Cash zu belasten", () => {
const plan: PlanInput = {
id: "p", name: "T", householdType: "SINGLE", inflationRateDefault: 0, initialCash: 0,
persons: [{ id: "A", role: "PERSON_A", name: null, age: 40, retirementAge: 70 }],
@@ -174,18 +174,18 @@ describe("Wasserfall-Bruecken", () => {
expect(ph.endWealthNominal).toBe(40000);
});
it("Verrentung und Kapitalbezugssteuer erscheinen in der Vermoegens-Bruecke der Folgephase", () => {
it("Verrentung und Kapitalbezugssteuer erscheinen in der Vermögens-Brücke der Folgephase", () => {
const c = computePlan(richPlan());
const p2 = c.phases[1];
expect(p2.wealthBridge.pensionConversion).toBeGreaterThan(0); // PK wurde verrentet
expect(p2.wealthBridge.transitionTax).toBeGreaterThan(0); // 8 % auf den 3a-Bezug
expect(p2.wealthBridge.oneOffInflow).toBe(90000); // 100'000 abzueglich 10 % Steuer
expect(p2.wealthBridge.oneOffInflow).toBe(90000); // 100'000 abzüglich 10 % Steuer
expect(p2.wealthBridge.oneOffOutflow).toBeGreaterThan(20000); // real 20'000, nominal aufgewertet
});
});
describe("Rechenweg (explain)", () => {
it("ist standardmaessig aus und aendert das Ergebnis nicht", () => {
it("ist standardmässig aus und ändert das Ergebnis nicht", () => {
const plain = computePlan(richPlan());
const explained = computePlan(richPlan(), undefined, { explain: true });
@@ -208,7 +208,7 @@ describe("Rechenweg (explain)", () => {
expect((c.phases[0].traces ?? []).length).toBeGreaterThan(0);
const withTrace = c.phases[0].elements.filter((e) => e.trace);
// Einkommen, Ausgaben, PK, 3a, Immobilie, Vermoegen, Schuld
// Einkommen, Ausgaben, PK, 3a, Immobilie, Vermögen, Schuld
expect(withTrace.length).toBeGreaterThanOrEqual(7);
const withTxTrace = c.phases[0].elements.filter((e) => e.transitionTrace);
@@ -224,9 +224,9 @@ describe("Rechenweg (explain)", () => {
expect(endStep.result).toBe(inc.endValue);
});
// Der Rechenweg verlinkt in die SPEZIFIKATION. Wird dort eine Ueberschrift umbenannt,
// zeigen die Verweise ins Leere -- und zwar still. Dieser Test faengt das ab.
it("alle Spezifikations-Verweise treffen eine existierende Ueberschrift", () => {
// Der Rechenweg verlinkt in die SPEZIFIKATION. Wird dort eine Überschrift umbenannt,
// zeigen die Verweise ins Leere -- und zwar still. Dieser Test fängt das ab.
it("alle Spezifikations-Verweise treffen eine existierende Überschrift", () => {
const md = readFileSync(path.join(process.cwd(), "SPEZIFIKATION.md"), "utf8");
// Slug-Regel von github-slugger (das rehype-slug verwendet) nachgebildet.
const slug = (s: string) =>
+2 -2
View File
@@ -1,5 +1,5 @@
// Einheitliches Format fuer Geldbetraege im ganzen Tool: Tausendertrennzeichen mit
// geradem Apostroph, keine Nachkommastellen (z. B. 1'780'000). Bewusst nicht ueber
// Einheitliches Format für Geldbeträge im ganzen Tool: Tausendertrennzeichen mit
// geradem Apostroph, keine Nachkommastellen (z. B. 1'780'000). Bewusst nicht über
// Intl/toLocaleString("de-CH"), da dessen Gruppentrennzeichen ( U+2019) nicht dem
// geraden Apostroph entspricht.
export function formatChf(value: number): string {
+11 -11
View File
@@ -1,6 +1,6 @@
// Spielt ALLE Migrationen gegen ein echtes PostgreSQL (PGlite, in-process) ein und prueft das
// Ergebnis. Faengt kaputte oder nicht-idempotente Migrations-SQL ab, bevor sie beim Deploy
// gegen die Live-Datenbank laufen -- lokal steht sonst keine Datenbank zur Verfuegung.
// Spielt ALLE Migrationen gegen ein echtes PostgreSQL (PGlite, in-process) ein und prüft das
// Ergebnis. Fängt kaputte oder nicht-idempotente Migrations-SQL ab, bevor sie beim Deploy
// gegen die Live-Datenbank laufen -- lokal steht sonst keine Datenbank zur Verfügung.
import { describe, it, expect } from "vitest";
import { PGlite } from "@electric-sql/pglite";
import { readFileSync, readdirSync } from "node:fs";
@@ -9,7 +9,7 @@ import path from "node:path";
const MIG = path.join(process.cwd(), "prisma", "migrations");
describe("Datenbank-Migrationen", () => {
it("laufen vollstaendig durch und ergeben das erwartete Schema", async () => {
it("laufen vollständig durch und ergeben das erwartete Schema", async () => {
const db = await PGlite.create();
const dirs = readdirSync(MIG).filter((d) => !d.endsWith(".toml")).sort();
expect(dirs.length).toBeGreaterThan(0);
@@ -24,7 +24,7 @@ describe("Datenbank-Migrationen", () => {
)
).rows.map((r) => r.table_name);
// V6-Struktur: Behaelter Plan + berechenbares Scenario.
// V6-Struktur: Behälter Plan + berechenbares Scenario.
for (const t of ["User", "Plan", "Scenario", "Person", "Phase", "FinancialElement"]) {
expect(tables, `Tabelle ${t} fehlt`).toContain(t);
}
@@ -37,27 +37,27 @@ describe("Datenbank-Migrationen", () => {
)
).rows.map((r) => r.column_name);
// Kind-Tabellen haengen am Szenario, nicht mehr am Plan.
// Kind-Tabellen hängen am Szenario, nicht mehr am Plan.
for (const t of ["Person", "Phase", "FinancialElement"]) {
const c = await cols(t);
expect(c, `${t}.scenarioId fehlt`).toContain("scenarioId");
expect(c, `${t}.planId haette entfernt werden muessen`).not.toContain("planId");
expect(c, `${t}.planId hätte entfernt werden müssen`).not.toContain("planId");
}
// Herkunfts-Verweise fuer den Diff.
// Herkunfts-Verweise für den Diff.
expect(await cols("Phase")).toContain("sourcePhaseId");
expect(await cols("FinancialElement")).toContain("sourceElementId");
// Der Plan traegt keine Finanzdaten mehr.
// Der Plan trägt keine Finanzdaten mehr.
const planCols = await cols("Plan");
expect(planCols).not.toContain("householdType");
expect(planCols).toContain("userId");
// Das Szenario traegt sie.
// Das Szenario trägt sie.
const scenCols = await cols("Scenario");
for (const c of ["planId", "isBase", "parentScenarioId", "householdType", "initialCash"]) {
expect(scenCols, `Scenario.${c} fehlt`).toContain(c);
}
expect(scenCols).not.toContain("userId"); // Eigentuemer haengt am Plan
expect(scenCols).not.toContain("userId"); // Eigentümer hängt am Plan
}, 60000);
});
+21 -21
View File
@@ -12,8 +12,8 @@ import {
} from "@/lib/montecarlo";
import type { PlanInput } from "@/lib/types";
// Plan: 40-jaehrig, 1 Phase 10 Jahre, ein Sonstiges Vermoegen 100'000 @ 5 %, 2 % Inflation.
// Deterministisch: 100'000 x 1.05^10 = 162'889 Endvermoegen, kein Ruin.
// Plan: 40-jährig, 1 Phase 10 Jahre, ein Sonstiges Vermögen 100'000 @ 5 %, 2 % Inflation.
// Deterministisch: 100'000 x 1.05^10 = 162'889 Endvermögen, kein Ruin.
function basePlan(): PlanInput {
return {
id: "p", name: "T", householdType: "SINGLE", inflationRateDefault: 2, initialCash: 0,
@@ -49,15 +49,15 @@ describe("Monte Carlo", () => {
const det = detEnd();
expect(r.finalWealthMedian).toBe(det);
expect(r.finalWealthP10).toBe(det);
expect(r.finalWealthP90).toBe(det); // Baender kollabieren auf die deterministische Linie
expect(r.finalWealthP90).toBe(det); // Bänder kollabieren auf die deterministische Linie
expect(r.ruinProbability).toBe(0);
expect(r.bands[r.bands.length - 1].p50).toBe(det);
});
it("Volatilitaet spreizt den Faecher (p90 > p10)", async () => {
it("Volatilität spreizt den Fächer (p90 > p10)", async () => {
const r = await runMonteCarlo(basePlan(), params({ elements: { asset: { mean: 5, sigma: 20, floor: -100 } } }));
expect(r.finalWealthP90).toBeGreaterThan(r.finalWealthP10);
// Median bleibt in der Naehe des deterministischen Werts (leicht darunter wegen Vol-Drag).
// Median bleibt in der Nähe des deterministischen Werts (leicht darunter wegen Vol-Drag).
expect(r.finalWealthMedian).toBeLessThan(r.finalWealthP90);
expect(r.finalWealthMedian).toBeGreaterThan(r.finalWealthP10);
});
@@ -79,8 +79,8 @@ describe("Monte Carlo", () => {
expect(a.ruinProbability).toBe(b.ruinProbability);
});
it("Boden 0 % (PK/3a): Rendite nie negativ -> Endvermoegen nie unter dem Startwert", async () => {
// Ohne Beitraege kann ein bei 0 % gebodetes Asset nur wachsen oder gleich bleiben.
it("Boden 0 % (PK/3a): Rendite nie negativ -> Endvermögen nie unter dem Startwert", async () => {
// Ohne Beiträge kann ein bei 0 % gebodetes Asset nur wachsen oder gleich bleiben.
const r = await runMonteCarlo(
basePlan(),
params({ elements: { asset: { mean: 0, sigma: 80, floor: 0 } } })
@@ -88,8 +88,8 @@ describe("Monte Carlo", () => {
expect(r.finalWealthP10).toBeGreaterThanOrEqual(100000);
});
it("Ruinwahrscheinlichkeit: sicherer Verzehr fuehrt immer in den Ruin", async () => {
// Rente 20k, Ausgaben 60k, kleines Vermoegen -> deterministisch Ruin, auch ohne Streuung.
it("Ruinwahrscheinlichkeit: sicherer Verzehr führt immer in den Ruin", async () => {
// Rente 20k, Ausgaben 60k, kleines Vermögen -> deterministisch Ruin, auch ohne Streuung.
const plan: PlanInput = {
id: "p", name: "T", householdType: "SINGLE", inflationRateDefault: 0, initialCash: 0,
persons: [{ id: "A", role: "PERSON_A", name: null, age: 65, retirementAge: 65 }],
@@ -104,7 +104,7 @@ describe("Monte Carlo", () => {
runs: 1000, inflationMean: 0, inflationSigma: 0,
elements: { asset: { mean: 0, sigma: 10, floor: -100 } }, target: 0, seed: 1,
});
expect(r.ruinProbability).toBe(1); // Verzehr uebersteigt Rente + Vermoegen in jedem Pfad
expect(r.ruinProbability).toBe(1); // Verzehr übersteigt Rente + Vermögen in jedem Pfad
});
it("Hilfsfunktionen: Default-Stufen und σ-Tabelle", () => {
@@ -118,7 +118,7 @@ describe("Monte Carlo", () => {
// --- Mehrere Szenarien im selben Lauf ----------------------------------------------------
// Kopie des Basisplans mit neuen Ids; das Element verweist per sourceElementId auf sein
// Gegenstueck -- genau wie es die Kopier-Route beim Anlegen eines Szenarios setzt.
// Gegenstück -- genau wie es die Kopier-Route beim Anlegen eines Szenarios setzt.
function copyPlan(source: PlanInput, suffix: string, expectedReturn: number): PlanInput {
return {
...source,
@@ -133,7 +133,7 @@ function copyPlan(source: PlanInput, suffix: string, expectedReturn: number): Pl
}
describe("Monte Carlo: mehrere Szenarien", () => {
it("loest die Herkunfts-Kette bis zum Ursprung auf", () => {
it("löst die Herkunfts-Kette bis zum Ursprung auf", () => {
const chain = new Map<string, string | null>([
["a", null],
["b", "a"],
@@ -141,13 +141,13 @@ describe("Monte Carlo: mehrere Szenarien", () => {
]);
expect(resolveRootElementId("c", chain)).toBe("a");
expect(resolveRootElementId("a", chain)).toBe("a");
// Verweis ins Leere (Vorlage geloescht): das Element ist selbst die Wurzel.
// Verweis ins Leere (Vorlage gelöscht): das Element ist selbst die Wurzel.
expect(resolveRootElementId("x", new Map([["x", "weg"]]))).toBe("x");
// Defekte Kette darf nicht zur Endlosschleife fuehren.
// Defekte Kette darf nicht zur Endlosschleife führen.
expect(resolveRootElementId("p", new Map([["p", "q"], ["q", "p"]]))).toBeDefined();
});
it("fasst dasselbe Element ueber Szenarien zu EINER Gruppe zusammen", () => {
it("fasst dasselbe Element über Szenarien zu EINER Gruppe zusammen", () => {
const base = basePlan();
const s2 = copyPlan(base, "s2", 6);
const groups = buildElementGroups(
@@ -160,13 +160,13 @@ describe("Monte Carlo: mehrere Szenarien", () => {
expect(groups[0].scenarioIds.sort()).toEqual(["base", "s2"]);
});
it("loest die Kette auch ueber ein NICHT ausgewaehltes Zwischen-Szenario auf", () => {
it("löst die Kette auch über ein NICHT ausgewähltes Zwischen-Szenario auf", () => {
const base = basePlan();
const s1 = copyPlan(base, "s1", 6);
const s2 = copyPlan(s1, "s2", 7); // zeigt auf s1, nicht auf base
const groups = buildElementGroups(
[{ id: "base", plan: base }, { id: "s1", plan: s1 }, { id: "s2", plan: s2 }],
["base", "s2"] // s1 ist nur zur Aufloesung geladen
["base", "s2"] // s1 ist nur zur Auflösung geladen
);
expect(groups).toHaveLength(1);
expect(groups[0].rootId).toBe("asset");
@@ -191,7 +191,7 @@ describe("Monte Carlo: mehrere Szenarien", () => {
expect(neu.scenarioIds).toEqual(["s2"]); // im Basisszenario ohne Wirkung
});
it("uebersetzt die Gruppen-Parameter auf die Element-Ids des jeweiligen Szenarios", () => {
it("übersetzt die Gruppen-Parameter auf die Element-Ids des jeweiligen Szenarios", () => {
const base = basePlan();
const s2 = copyPlan(base, "s2", 6);
const groups = buildElementGroups([{ id: "base", plan: base }, { id: "s2", plan: s2 }], ["base", "s2"]);
@@ -203,9 +203,9 @@ describe("Monte Carlo: mehrere Szenarien", () => {
it("gleicher Seed: nur die GEPLANTE Rendite unterscheidet sich -> identische Verteilung, andere Erfolgsquote", async () => {
// Der Kern-Anwendungsfall: dasselbe Portfolio, einmal pessimistisch (5 %) und einmal
// optimistisch (6 %) geplant. In der Simulation wird die geplante Rendite ersetzt, also
// sind beide Verlaeufe identisch -- der Unterschied liegt allein im Zielbetrag, der aus
// der jeweiligen Planung stammt. Das pessimistische Szenario haelt sein (tieferes)
// Versprechen oefter.
// sind beide Verläufe identisch -- der Unterschied liegt allein im Zielbetrag, der aus
// der jeweiligen Planung stammt. Das pessimistische Szenario hält sein (tieferes)
// Versprechen öfter.
const base = basePlan(); // expectedReturn 5
const optimistisch = copyPlan(base, "opt", 6);
+32 -32
View File
@@ -1,11 +1,11 @@
// Monte-Carlo-Simulation (Roadmap Nr. 19, Stufe A). Laeuft vollstaendig im Browser, weil
// computePlan eine reine Funktion ohne Server-Abhaengigkeiten ist.
// Monte-Carlo-Simulation (Roadmap Nr. 19, Stufe A). Läuft vollständig im Browser, weil
// computePlan eine reine Funktion ohne Server-Abhängigkeiten ist.
//
// Modell: Pro Jahr wird EIN gemeinsamer Marktschock gezogen (damit riskante Anlagen zusammen
// fallen, nicht gegeneinander). Jede Rendite = historischer Mittelwert + Standardabweichung x
// (Marktanteil x Marktschock + Eigenanteil x Eigenrauschen). Beide Schocks sind fettschwaenzig
// (standardisierte Student-t), damit Extremcrashs realistisch haeufig auftreten -- eine
// Normalverteilung wuerde sie stark unterschaetzen. Boeden: 0 % fuer PK/3a, -100 % sonst.
// (Marktanteil x Marktschock + Eigenanteil x Eigenrauschen). Beide Schocks sind fettschwänzig
// (standardisierte Student-t), damit Extremcrashs realistisch häufig auftreten -- eine
// Normalverteilung würde sie stark unterschätzen. Böden: 0 % für PK/3a, -100 % sonst.
import { computePlan, type PlanSample } from "@/lib/calculations";
import type { ElementCategory } from "@/lib/elements";
@@ -23,7 +23,7 @@ export const RETURN_VOLATILITY_LEVELS = {
} as const;
// Inflation ist in der Schweiz historisch stabil (Standardabweichung der letzten 20 Jahre
// ~1 %). Hoehere Stufen ergaeben Hyperinflations-Annahmen -- deshalb nur zwei Stufen.
// ~1 %). Höhere Stufen ergäben Hyperinflations-Annahmen -- deshalb nur zwei Stufen.
export const INFLATION_VOLATILITY_LEVELS = {
sehr_niedrig: 1,
niedrig: 2,
@@ -56,7 +56,7 @@ export function floorFor(category: ElementCategory): number {
export interface ElementMcParams {
mean: number; // historische Durchschnittsrendite (%/Jahr)
sigma: number; // Standardabweichung (%/Jahr)
floor: number; // 0 fuer PK/3a, -100 sonst
floor: number; // 0 für PK/3a, -100 sonst
}
export interface MonteCarloParams {
@@ -64,42 +64,42 @@ export interface MonteCarloParams {
inflationMean: number;
inflationSigma: number;
elements: Record<string, ElementMcParams>; // key = elementId
target: number; // Zielbetrag fuer die Erfolgswahrscheinlichkeit (nominal)
target: number; // Zielbetrag für die Erfolgswahrscheinlichkeit (nominal)
seed?: number;
}
export interface MonteCarloResult {
runs: number;
ruinProbability: number; // P(Vermoegen faellt vor Planende unter 0)
successProbability: number; // P(Endvermoegen >= Zielbetrag)
ruinProbability: number; // P(Vermögen fällt vor Planende unter 0)
successProbability: number; // P(Endvermögen >= Zielbetrag)
finalWealthP10: number;
finalWealthMedian: number;
finalWealthP90: number;
// Faecher ueber das Alter: je Alterspunkt der pessimistische/mittlere/optimistische Wert.
// Fächer über das Alter: je Alterspunkt der pessimistische/mittlere/optimistische Wert.
bands: { age: number; p10: number; p50: number; p90: number }[];
}
// --- Mehrere Szenarien im selben Lauf vergleichen -------------------------------------
//
// Ein "logisches" Element ueber Szenariogrenzen hinweg: Beim Kopieren eines Szenarios
// erhaelt jedes Element eine NEUE Id plus einen Verweis auf sein Gegenstueck in der Vorlage
// (sourceElementId -- dieselbe Kette, auf der auch der Diff beruht). Ueber diese Kette wird
// dieselbe Anlage in mehreren Szenarien wiedergefunden. Das ist die Voraussetzung dafuer,
// die historischen Annahmen nur EINMAL zu erfassen: Ein Vergleich ist nur dann aussagekraeftig,
// wenn alle Szenarien mit denselben Marktannahmen gewuerfelt werden -- sonst vergleicht man
// Ein "logisches" Element über Szenariogrenzen hinweg: Beim Kopieren eines Szenarios
// erhält jedes Element eine NEUE Id plus einen Verweis auf sein Gegenstück in der Vorlage
// (sourceElementId -- dieselbe Kette, auf der auch der Diff beruht). Über diese Kette wird
// dieselbe Anlage in mehreren Szenarien wiedergefunden. Das ist die Voraussetzung dafür,
// die historischen Annahmen nur EINMAL zu erfassen: Ein Vergleich ist nur dann aussagekräftig,
// wenn alle Szenarien mit denselben Marktannahmen gewürfelt werden -- sonst vergleicht man
// die Eingaben statt der Szenarien.
export interface ElementGroup {
rootId: string; // Id des Ursprungs-Elements (Anker der Parametereingabe)
name: string;
category: ElementCategory;
memberIds: string[]; // Element-Ids ueber alle ausgewaehlten Szenarien
memberIds: string[]; // Element-Ids über alle ausgewählten Szenarien
scenarioIds: string[]; // Szenarien, in denen dieses Element vorkommt
}
// Folgt sourceElementId bis zum Ursprung. Bricht ab, sobald der Verweis ins Leere zeigt
// (Vorlage geloescht oder Szenario nicht geladen) -- dann ist dieses Element selbst die
// (Vorlage gelöscht oder Szenario nicht geladen) -- dann ist dieses Element selbst die
// Wurzel und bildet eine eigene Gruppe. Der Zyklusschutz ist reine Vorsicht: die Verweise
// sind lose (kein FK), eine defekte Kette darf nicht zur Endlosschleife fuehren.
// sind lose (kein FK), eine defekte Kette darf nicht zur Endlosschleife führen.
export function resolveRootElementId(elementId: string, sourceById: Map<string, string | null>): string {
let current = elementId;
const seen = new Set<string>();
@@ -112,9 +112,9 @@ export function resolveRootElementId(elementId: string, sourceById: Map<string,
return current;
}
// `all` enthaelt ALLE Szenarien des Plans (auch nicht ausgewaehlte) -- nur so laesst sich die
// Kette ueber ein uebersprungenes Zwischen-Szenario hinweg aufloesen (Basis -> S1 -> S2, wenn
// nur Basis und S2 ausgewaehlt sind). Gruppen entstehen nur fuer die ausgewaehlten Szenarien.
// `all` enthält ALLE Szenarien des Plans (auch nicht ausgewählte) -- nur so lässt sich die
// Kette über ein übersprungenes Zwischen-Szenario hinweg auflösen (Basis -> S1 -> S2, wenn
// nur Basis und S2 ausgewählt sind). Gruppen entstehen nur für die ausgewählten Szenarien.
export function buildElementGroups(
all: { id: string; plan: PlanInput }[],
selectedIds: string[]
@@ -154,7 +154,7 @@ export function buildElementGroups(
return [...groups.values()];
}
// Uebersetzt die je Gruppe erfassten Parameter auf die Element-Ids EINES Szenarios.
// Übersetzt die je Gruppe erfassten Parameter auf die Element-Ids EINES Szenarios.
export function paramsForScenario(
plan: PlanInput,
groups: ElementGroup[],
@@ -186,9 +186,9 @@ export interface ScenarioMcResult extends MonteCarloResult {
target: number;
}
// Fuehrt dieselbe Simulation fuer mehrere Szenarien aus -- mit DEMSELBEN Seed. Ohne das
// waeren kleine Unterschiede blosses Rauschen (bei 1'000 Laeufen betraegt der Standardfehler
// der Erfolgswahrscheinlichkeit rund 1.5 Prozentpunkte, zwei identische Szenarien koennten
// Führt dieselbe Simulation für mehrere Szenarien aus -- mit DEMSELBEN Seed. Ohne das
// wären kleine Unterschiede blosses Rauschen (bei 1'000 Läufen beträgt der Standardfehler
// der Erfolgswahrscheinlichkeit rund 1.5 Prozentpunkte, zwei identische Szenarien könnten
// also 87 % und 90 % zeigen). Mit gemeinsamem Seed teilen strukturgleiche Szenarien dieselben
// Marktpfade, und die Unterschiede sind rein strukturell (Common Random Numbers).
export async function runMonteCarloMulti(
@@ -242,7 +242,7 @@ function normal(rng: () => number): number {
}
// Standardisierte Student-t mit nu Freiheitsgraden (Einheitsvarianz -> die eingegebene
// Standardabweichung bleibt die tatsaechliche). Fette Raender: nu = 5.
// Standardabweichung bleibt die tatsächliche). Fette Ränder: nu = 5.
const NU = 5;
function studentT(rng: () => number): number {
let chi2 = 0;
@@ -265,7 +265,7 @@ function percentile(sortedAsc: number[], p: number): number {
return sortedAsc[idx];
}
// Ein Alterspunkt je Phasenanfang plus das Planende (wie im Vermoegensverlauf-Chart).
// Ein Alterspunkt je Phasenanfang plus das Planende (wie im Vermögensverlauf-Chart).
function agePointsOf(plan: PlanInput): number[] {
const phases = [...plan.phases].sort((a, b) => a.sequenceNumber - b.sequenceNumber);
const startAge = plan.persons.find((p) => p.role === "PERSON_A")?.age ?? plan.persons[0]?.age ?? 0;
@@ -278,7 +278,7 @@ function agePointsOf(plan: PlanInput): number[] {
return points;
}
// Wert je Alterspunkt fuer EINEN Durchlauf (Start-/Endvermoegen der Phasen, nominal).
// Wert je Alterspunkt für EINEN Durchlauf (Start-/Endvermögen der Phasen, nominal).
function wealthTrajectory(computed: ReturnType<typeof computePlan>): number[] {
const phases = computed.phases;
if (phases.length === 0) return [];
@@ -306,7 +306,7 @@ export async function runMonteCarlo(
const elementIds = Object.keys(params.elements);
for (let run = 0; run < params.runs; run++) {
// Pro Jahr ein Marktschock; pro Element/Jahr eine Rendite (fette Raender, gebodet).
// Pro Jahr ein Marktschock; pro Element/Jahr eine Rendite (fette Ränder, gebodet).
const inflation: number[] = new Array(totalYears);
for (let y = 0; y < totalYears; y++) {
inflation[y] = params.inflationMean + params.inflationSigma * studentT(rng);
@@ -338,7 +338,7 @@ export async function runMonteCarlo(
finalWealth.push(fw);
if (fw >= params.target) successCount++;
// Alle ~500 Laeufe die Kontrolle abgeben, damit die Oberflaeche nicht einfriert.
// Alle ~500 Läufe die Kontrolle abgeben, damit die Oberfläche nicht einfriert.
if (run % 500 === 499) {
onProgress?.(run + 1, params.runs);
await new Promise((r) => setTimeout(r, 0));
+7 -7
View File
@@ -73,10 +73,10 @@ export function toPlanInput(plan: PlanWithRelations): PlanInput {
};
}
// --- Ownership-Abfragen. Der Eigentuemer haengt neu am PLAN; Szenario/Phase/Element
// erben ihn ueber die Kette Scenario -> Plan -> User. ---
// --- Ownership-Abfragen. Der Eigentümer hängt neu am PLAN; Szenario/Phase/Element
// erben ihn über die Kette Scenario -> Plan -> User. ---
// Laedt ein Szenario inkl. Profil + Phasen + Elemente, nur wenn es dem Benutzer gehoert.
// Lädt ein Szenario inkl. Profil + Phasen + Elemente, nur wenn es dem Benutzer gehört.
export async function getOwnedScenario(scenarioId: string, userId: string) {
return prisma.scenario.findFirst({
where: { id: scenarioId, plan: { userId } },
@@ -84,7 +84,7 @@ export async function getOwnedScenario(scenarioId: string, userId: string) {
});
}
// Wie oben, zusaetzlich mit den Kopfdaten (Plan, Basis-Flag, Elternteil).
// Wie oben, zusätzlich mit den Kopfdaten (Plan, Basis-Flag, Elternteil).
export async function getOwnedScenarioWithMeta(scenarioId: string, userId: string) {
return prisma.scenario.findFirst({
where: { id: scenarioId, plan: { userId } },
@@ -92,7 +92,7 @@ export async function getOwnedScenarioWithMeta(scenarioId: string, userId: strin
});
}
// Laedt einen Plan (Behaelter) inkl. Szenario-Kopfdaten.
// Lädt einen Plan (Behälter) inkl. Szenario-Kopfdaten.
export async function getOwnedPlan(planId: string, userId: string) {
return prisma.plan.findFirst({
where: { id: planId, userId },
@@ -100,14 +100,14 @@ export async function getOwnedPlan(planId: string, userId: string) {
});
}
// Laedt eine Phase (Basisdaten), aber nur wenn sie dem Benutzer gehoert.
// Lädt eine Phase (Basisdaten), aber nur wenn sie dem Benutzer gehört.
export async function getOwnedPhase(phaseId: string, userId: string) {
return prisma.phase.findFirst({
where: { id: phaseId, scenario: { plan: { userId } } },
});
}
// Laedt ein Element (Basisdaten), aber nur wenn es dem Benutzer gehoert.
// Lädt ein Element (Basisdaten), aber nur wenn es dem Benutzer gehört.
export async function getOwnedElement(elementId: string, userId: string) {
return prisma.financialElement.findFirst({
where: { id: elementId, scenario: { plan: { userId } } },
+19 -19
View File
@@ -9,8 +9,8 @@ import {
} from "@/lib/sensitivity";
import type { PlanInput } from "@/lib/types";
// Plan: 45-jaehrig, zwei Phasen (20 J. Erwerb + 20 J. Pension), Einkommen 100'000 netto,
// Ausgaben 70'000 real, ein Sonstiges Vermoegen 200'000 @ 4 %, Inflation 1.5 %.
// Plan: 45-jährig, zwei Phasen (20 J. Erwerb + 20 J. Pension), Einkommen 100'000 netto,
// Ausgaben 70'000 real, ein Sonstiges Vermögen 200'000 @ 4 %, Inflation 1.5 %.
function basePlan(): PlanInput {
return {
id: "p",
@@ -58,7 +58,7 @@ function basePlan(): PlanInput {
};
}
// Plan mit Immobilie -- wahlweise gehalten oder am Uebergang verkauft.
// Plan mit Immobilie -- wahlweise gehalten oder am Übergang verkauft.
function planMitImmobilie(verkaufen: boolean): PlanInput {
const p = basePlan();
return {
@@ -81,8 +81,8 @@ function planMitImmobilie(verkaufen: boolean): PlanInput {
};
}
describe("Sensitivitaet: applyDriver", () => {
it("laesst den Ausgangsplan unberuehrt (rein)", () => {
describe("Sensitivität: applyDriver", () => {
it("lässt den Ausgangsplan unberührt (rein)", () => {
const p = basePlan();
const snapshot = JSON.stringify(p);
applyDriver(p, "inflation", 3);
@@ -101,7 +101,7 @@ describe("Sensitivitaet: applyDriver", () => {
const asset = p.elements.find((e) => e.id === "asset")!;
expect(asset.phaseValues.p1.expectedReturn).toBe(6);
expect(asset.phaseValues.p2.expectedReturn).toBe(6);
// Einkommen/Ausgaben bleiben unberuehrt.
// Einkommen/Ausgaben bleiben unberührt.
expect(p.elements.find((e) => e.id === "inc")!.phaseValues.p1.amount).toBe(100000);
});
@@ -126,8 +126,8 @@ describe("Sensitivitaet: applyDriver", () => {
});
});
describe("Sensitivitaet: Treiber-Verfuegbarkeit", () => {
it("blendet Treiber aus, fuer die es keine passenden Elemente gibt", () => {
describe("Sensitivität: Treiber-Verfügbarkeit", () => {
it("blendet Treiber aus, für die es keine passenden Elemente gibt", () => {
const p = basePlan();
expect(driverById("propertyGrowth").applies(p)).toBe(false); // keine Immobilie
expect(driverById("returns").applies(p)).toBe(true);
@@ -143,8 +143,8 @@ describe("Sensitivitaet: Treiber-Verfuegbarkeit", () => {
});
});
describe("Sensitivitaet: Tornado", () => {
it("Basiswert entspricht dem unveraenderten Plan", () => {
describe("Sensitivität: Tornado", () => {
it("Basiswert entspricht dem unveränderten Plan", () => {
const p = basePlan();
const result = computeTornado(p, "real", [{ id: "inflation", low: 0.5, high: 3.5 }]);
const last = computePlan(p).phases.at(-1)!;
@@ -163,19 +163,19 @@ describe("Sensitivitaet: Tornado", () => {
// Ein Treiber ohne Bandbreite kann nichts bewegen und landet zuunterst.
expect(result.bars.at(-1)!.id).toBe("salaryGrowth");
expect(result.bars.at(-1)!.swing).toBe(0);
// Welcher Treiber oben steht, haengt vom konkreten Plan ab -- genau das ist die Aussage
// Welcher Treiber oben steht, hängt vom konkreten Plan ab -- genau das ist die Aussage
// des Tornados und deshalb bewusst nicht fix getestet.
});
it("kehrt die Richtung korrekt ab: hoehere Ausgaben -> tieferes Endvermoegen", () => {
it("kehrt die Richtung korrekt ab: höhere Ausgaben -> tieferes Endvermögen", () => {
const [bar] = computeTornado(basePlan(), "real", [{ id: "expenses", low: -15, high: 15 }]).bars;
expect(bar.lowResult).toBeGreaterThan(bar.highResult); // tiefe Ausgaben = mehr Vermoegen
expect(bar.lowResult).toBeGreaterThan(bar.highResult); // tiefe Ausgaben = mehr Vermögen
expect(bar.min).toBe(bar.highResult);
expect(bar.max).toBe(bar.lowResult);
expect(bar.swing).toBe(bar.max - bar.min);
});
it("hoehere Rendite -> hoeheres Endvermoegen", () => {
it("höhere Rendite -> höheres Endvermögen", () => {
const [bar] = computeTornado(basePlan(), "real", [{ id: "returns", low: -2, high: 2 }]).bars;
expect(bar.highResult).toBeGreaterThan(bar.lowResult);
});
@@ -183,13 +183,13 @@ describe("Sensitivitaet: Tornado", () => {
it("identische Bandbreite ergibt Spannweite 0", () => {
const [bar] = computeTornado(basePlan(), "real", [{ id: "inflation", low: 2, high: 2 }]).bars;
expect(bar.swing).toBe(0);
expect(bar.note).toBeTruthy(); // Nullbalken bekommt immer eine Erklaerung
expect(bar.note).toBeTruthy(); // Nullbalken bekommt immer eine Erklärung
});
it("verkaufte Immobilie: Wertsteigerung ist nachweislich wirkungslos und wird erklaert", () => {
// Beim Verkauf ist der Erloes `Verkaufspreis - Hypothek - Steuer` und haengt am ERFASSTEN
it("verkaufte Immobilie: Wertsteigerung ist nachweislich wirkungslos und wird erklärt", () => {
// Beim Verkauf ist der Erlös `Verkaufspreis - Hypothek - Steuer` und hängt am ERFASSTEN
// Preis, nicht am modellierten Verkehrswert. Die aufgelaufene Wertsteigerung wird damit
// verworfen -- der Treiber kann das Endvermoegen nicht mehr bewegen.
// verworfen -- der Treiber kann das Endvermögen nicht mehr bewegen.
const gehalten = computeTornado(planMitImmobilie(false), "real", [{ id: "propertyGrowth", low: 0.5, high: 2.5 }]);
const verkauft = computeTornado(planMitImmobilie(true), "real", [{ id: "propertyGrowth", low: 0.5, high: 2.5 }]);
@@ -206,7 +206,7 @@ describe("Sensitivitaet: Tornado", () => {
const real = planMetric(p, "real");
const nominal = planMetric(p, "nominal");
const last = computePlan(p).phases.at(-1)!;
expect(nominal).toBeGreaterThan(real); // 1.5 % Inflation ueber 40 Jahre
expect(nominal).toBeGreaterThan(real); // 1.5 % Inflation über 40 Jahre
expect(real).toBe(Math.round(nominal / last.cumulativeInflationEnd));
});
});
+31 -31
View File
@@ -1,17 +1,17 @@
// Sensitivitaetsanalyse / Tornado (Roadmap Nr. 20). Beantwortet nicht "wie viel Geld habe
// ich am Schluss", sondern "welche meiner Annahmen entscheidet ueberhaupt ueber das Ergebnis".
// Sensitivitätsanalyse / Tornado (Roadmap Nr. 20). Beantwortet nicht "wie viel Geld habe
// ich am Schluss", sondern "welche meiner Annahmen entscheidet überhaupt über das Ergebnis".
//
// Verfahren: One-at-a-time (OAT). Je Treiber wird EIN Parameter auf seinen tiefen und seinen
// hohen Wert gesetzt, alle uebrigen bleiben auf dem Planwert; die Differenz der beiden
// hohen Wert gesetzt, alle übrigen bleiben auf dem Planwert; die Differenz der beiden
// Ergebnisse ist die Spannweite. Nach Spannweite sortiert ergibt sich die Trichterform.
//
// Zwei bewusste Grenzen (im Dialog ausgewiesen, siehe SPEZIFIKATION 9.18):
// 1. Die Balkenlaenge haengt von den eingegebenen Bandbreiten ab -- deshalb gibt es hier
// 1. Die Balkenlänge hängt von den eingegebenen Bandbreiten ab -- deshalb gibt es hier
// KEINE Defaults, die Bandbreite ist je Treiber Pflichteingabe.
// 2. OAT sieht keine Wechselwirkungen (tiefe Rendite UND hohe Ausgaben treffen haerter als
// die Summe der Einzelbalken). Dafuer ist die Monte-Carlo-Simulation zustaendig.
// 2. OAT sieht keine Wechselwirkungen (tiefe Rendite UND hohe Ausgaben treffen härter als
// die Summe der Einzelbalken). Dafür ist die Monte-Carlo-Simulation zuständig.
//
// Laeuft wie die Monte-Carlo-Simulation vollstaendig im Browser: computePlan ist rein, und
// Läuft wie die Monte-Carlo-Simulation vollständig im Browser: computePlan ist rein, und
// ein Tornado braucht nur 2 Aufrufe je Treiber (Millisekunden).
import { computePlan } from "@/lib/calculations";
@@ -29,18 +29,18 @@ export type DriverId =
| "propertyGrowth";
// Die Einheit bestimmt, WAS der eingegebene Wert bedeutet -- das ist je Treiber verschieden
// und laesst sich nicht vereinheitlichen, ohne fachlich falsch zu werden:
// und lässt sich nicht vereinheitlichen, ohne fachlich falsch zu werden:
// abs_pct absoluter Prozentsatz (es gibt genau einen plan-weiten Wert)
// delta_pp Verschiebung in Prozentpunkten (die Elemente haben je eigene Saetze -- ein
// absoluter Wert wuerde die PK auf ETF-Rendite plaetten)
// rel_pct relative Abweichung in Prozent (die Elemente haben je eigene Betraege)
// delta_pp Verschiebung in Prozentpunkten (die Elemente haben je eigene Sätze -- ein
// absoluter Wert würde die PK auf ETF-Rendite plätten)
// rel_pct relative Abweichung in Prozent (die Elemente haben je eigene Beträge)
// delta_years Verschiebung in Jahren
export type DriverUnit = "abs_pct" | "delta_pp" | "rel_pct" | "delta_years";
export interface DriverDef {
id: DriverId;
label: string;
shortLabel: string; // Achsenbeschriftung im Tornado (kurz genug fuer die y-Achse)
shortLabel: string; // Achsenbeschriftung im Tornado (kurz genug für die y-Achse)
unit: DriverUnit;
help: string;
applies: (plan: PlanInput) => boolean;
@@ -118,7 +118,7 @@ export const DRIVERS: DriverDef[] = [
},
];
// Einheiten-Suffix fuer die Eingabefelder und die Ergebnistabelle.
// Einheiten-Suffix für die Eingabefelder und die Ergebnistabelle.
export const UNIT_SUFFIX: Record<DriverUnit, string> = {
abs_pct: "%",
delta_pp: "pp",
@@ -133,7 +133,7 @@ export function driverById(id: DriverId): DriverDef {
}
// --- Anwenden eines Treiber-Wertes auf einen Plan -------------------------------------
// Alle Transformationen sind rein: sie liefern eine Kopie und lassen das Original unberuehrt.
// Alle Transformationen sind rein: sie liefern eine Kopie und lassen das Original unberührt.
function mapPhaseData(element: ElementInput, f: (pd: PhaseData) => PhaseData): ElementInput {
const phaseValues: Record<string, PhaseData> = {};
@@ -141,7 +141,7 @@ function mapPhaseData(element: ElementInput, f: (pd: PhaseData) => PhaseData): E
return { ...element, phaseValues };
}
// Bildet die Elemente der gegebenen Kategorien ab; alle uebrigen bleiben unveraendert.
// Bildet die Elemente der gegebenen Kategorien ab; alle übrigen bleiben unverändert.
function mapElements(
plan: PlanInput,
categories: ElementCategory[],
@@ -159,7 +159,7 @@ export function applyDriver(plan: PlanInput, id: DriverId, value: number): PlanI
return { ...plan, inflationRateDefault: value };
case "returns":
// Verschiebung in Prozentpunkten auf die geplante Rendite. Nur dort, wo ueberhaupt ein
// Verschiebung in Prozentpunkten auf die geplante Rendite. Nur dort, wo überhaupt ein
// Werte-Datensatz existiert -- fehlt er, rechnet computePlan ohnehin mit 0 %.
return mapElements(plan, RETURN_CATEGORIES, (pd) => ({
...pd,
@@ -177,7 +177,7 @@ export function applyDriver(plan: PlanInput, id: DriverId, value: number): PlanI
// Relative Skalierung des Basisbetrags. Bewusst nur dort, wo `amount` gesetzt ist:
// ab Phase 2 ist der Wert in der Regel live vererbt (kein gespeicherter Betrag), und
// die Fortschreibung leitet ihn aus dem skalierten Basiswert ab -- dadurch wirkt die
// Skalierung automatisch ueber alle Folgephasen.
// Skalierung automatisch über alle Folgephasen.
const factor = 1 + value / 100;
return mapElements(plan, [id === "expenses" ? "EXPENSE" : "INCOME"], (pd) =>
typeof pd.amount === "number" ? { ...pd, amount: Math.max(0, pd.amount * factor) } : pd
@@ -191,7 +191,7 @@ export function applyDriver(plan: PlanInput, id: DriverId, value: number): PlanI
}));
case "lifespan": {
// Verlaengert/verkuerzt die LETZTE Phase. Bewusst nicht das Pensionsalter: das laesst
// Verlängert/verkürzt die LETZTE Phase. Bewusst nicht das Pensionsalter: das lässt
// sich ohne Mitverschieben der Phasengrenzen nicht sinnvoll variieren (siehe 9.18).
if (plan.phases.length === 0) return plan;
const lastSeq = Math.max(...plan.phases.map((p) => p.sequenceNumber));
@@ -221,13 +221,13 @@ export interface TornadoBar {
id: DriverId;
label: string;
shortLabel: string;
note?: string; // Erklaerung, wenn der Treiber das Ergebnis nachweislich nicht bewegt
note?: string; // Erklärung, wenn der Treiber das Ergebnis nachweislich nicht bewegt
unit: DriverUnit;
low: number; // eingegebene Bandbreite
high: number;
lowResult: number; // Zielgroesse beim tiefen Wert
highResult: number; // Zielgroesse beim hohen Wert
min: number; // fuer den Balken: kleinerer der beiden Ergebniswerte
lowResult: number; // Zielgrösse beim tiefen Wert
highResult: number; // Zielgrösse beim hohen Wert
min: number; // für den Balken: kleinerer der beiden Ergebniswerte
max: number;
swing: number; // max - min
}
@@ -237,13 +237,13 @@ export interface TornadoResult {
bars: TornadoBar[];
}
// Warum bewegt ein Treiber gar nichts? Ein stummer Nullbalken ohne Erklaerung ist die
// schlechteste Antwort -- der Nutzer haelt ihn fuer einen Fehler.
// Warum bewegt ein Treiber gar nichts? Ein stummer Nullbalken ohne Erklärung ist die
// schlechteste Antwort -- der Nutzer hält ihn für einen Fehler.
//
// Der wichtigste Fall ist die Immobilien-Wertsteigerung bei einem Verkauf: Der Verkaufserloes
// ist `Verkaufspreis - Hypothek - Grundstueckgewinnsteuer` und haengt damit am ERFASSTEN
// Der wichtigste Fall ist die Immobilien-Wertsteigerung bei einem Verkauf: Der Verkaufserlös
// ist `Verkaufspreis - Hypothek - Grundstückgewinnsteuer` und hängt damit am ERFASSTEN
// Verkaufspreis, nicht am modellierten Verkehrswert. Die aufgelaufene Wertsteigerung wird beim
// Verkauf also verworfen -- der Treiber kann das Endvermoegen nicht mehr beeinflussen.
// Verkauf also verworfen -- der Treiber kann das Endvermögen nicht mehr beeinflussen.
export function ineffectiveReason(plan: PlanInput, id: DriverId): string {
if (id === "propertyGrowth") {
const computed = computePlan(plan);
@@ -256,7 +256,7 @@ export function ineffectiveReason(plan: PlanInput, id: DriverId): string {
return "Dieser Parameter bewegt das Endvermögen in diesem Plan nicht.";
}
// Zielgroesse: Endvermoegen der letzten Phase, real (kaufkraftbereinigt) oder nominal.
// Zielgrösse: Endvermögen der letzten Phase, real (kaufkraftbereinigt) oder nominal.
export function planMetric(plan: PlanInput, metric: TornadoMetric): number {
const computed = computePlan(plan);
const last = computed.phases[computed.phases.length - 1];
@@ -275,8 +275,8 @@ export function computeTornado(
const def = driverById(input.id);
const lowResult = planMetric(applyDriver(plan, input.id, input.low), metric);
const highResult = planMetric(applyDriver(plan, input.id, input.high), metric);
// Die Richtung kann sich umkehren (tiefe Ausgaben -> hohes Vermoegen). Der Balken spannt
// deshalb ueber min..max; welche Eingabe zu welchem Ende gehoert, zeigt die Tabelle.
// Die Richtung kann sich umkehren (tiefe Ausgaben -> hohes Vermögen). Der Balken spannt
// deshalb über min..max; welche Eingabe zu welchem Ende gehört, zeigt die Tabelle.
const swing = Math.abs(highResult - lowResult);
return {
id: input.id,
@@ -294,7 +294,7 @@ export function computeTornado(
};
});
// Trichterform: groesste Spannweite zuoberst.
// Trichterform: grösste Spannweite zuoberst.
bars.sort((a, b) => b.swing - a.swing);
return { base, bars };
}
+2 -2
View File
@@ -2,8 +2,8 @@ import { cookies } from "next/headers";
import { SESSION_COOKIE_NAME, verifySessionToken } from "@/lib/auth";
// Liest die User-ID des eingeloggten Benutzers aus dem Session-Cookie.
// Fuer API-Routen (Node.js-Runtime); die Middleware schuetzt die Routen bereits,
// dies ist die zweite Verteidigungslinie und liefert die ID fuer Ownership-Checks.
// Für API-Routen (Node.js-Runtime); die Middleware schützt die Routen bereits,
// dies ist die zweite Verteidigungslinie und liefert die ID für Ownership-Checks.
export async function getCurrentUserId(): Promise<string | null> {
const cookieStore = await cookies();
const token = cookieStore.get(SESSION_COOKIE_NAME)?.value;
+2 -2
View File
@@ -1,5 +1,5 @@
// Theme-Verwaltung: drei waehlbare Schemata, persistiert in localStorage und als
// data-theme am <html> gesetzt. Ohne gespeicherte Wahl folgt die Oberflaeche der
// Theme-Verwaltung: drei wählbare Schemata, persistiert in localStorage und als
// data-theme am <html> gesetzt. Ohne gespeicherte Wahl folgt die Oberfläche der
// OS-Einstellung (siehe globals.css, prefers-color-scheme).
export type Theme = "light" | "dark" | "warm";
+11 -11
View File
@@ -1,5 +1,5 @@
// Domain-Typen fuer Berechnungslogik und API-Payloads. Entkoppelt von den generierten
// Prisma-Typen, damit die Berechnung unabhaengig testbar bleibt.
// Domain-Typen für Berechnungslogik und API-Payloads. Entkoppelt von den generierten
// Prisma-Typen, damit die Berechnung unabhängig testbar bleibt.
//
// V3-Rework: Das Grundprofil (Haushaltsform, Personen, Inflation) liegt neu direkt am Plan.
@@ -21,9 +21,9 @@ export interface PhaseInput {
sequenceNumber: number;
name: string;
durationYears: number;
// Cash-Entscheid beim Uebergang NACH dieser Phase (einmalige Sonderein-/ausgaben).
// Cash-Entscheid beim Übergang NACH dieser Phase (einmalige Sonderein-/ausgaben).
cashTransition: CashTransitionData;
// Gegenstueck im Eltern-Szenario (Diff-Grundlage); null im Basisszenario.
// Gegenstück im Eltern-Szenario (Diff-Grundlage); null im Basisszenario.
sourcePhaseId?: string | null;
}
@@ -33,14 +33,14 @@ export interface ElementInput {
name: string;
ownerRole: OwnerRole | null;
orderIndex: number;
// Werte je Phase (Key = phaseId) bzw. je Uebergang (Key = fromPhaseId).
// Werte je Phase (Key = phaseId) bzw. je Übergang (Key = fromPhaseId).
phaseValues: Record<string, PhaseData>;
transitionValues: Record<string, TransitionData>;
// Gegenstueck im Eltern-Szenario (Diff-Grundlage); null im Basisszenario.
// Gegenstück im Eltern-Szenario (Diff-Grundlage); null im Basisszenario.
sourceElementId?: string | null;
}
// Kopf-Daten eines Szenarios (fuer Baum und Auswahl in der Seitenleiste).
// Kopf-Daten eines Szenarios (für Baum und Auswahl in der Seitenleiste).
export interface ScenarioMeta {
id: string;
planId: string;
@@ -49,7 +49,7 @@ export interface ScenarioMeta {
parentScenarioId: string | null;
}
// Ein Plan ist der Behaelter; er traegt nur den Namen und seine Szenarien.
// Ein Plan ist der Behälter; er trägt nur den Namen und seine Szenarien.
export interface PlanListItem {
id: string;
name: string;
@@ -57,7 +57,7 @@ export interface PlanListItem {
scenarios: ScenarioMeta[];
}
// Die berechenbare Einheit (fachlich: ein SZENARIO). Sie ist selbsttragend und traegt ihr
// Die berechenbare Einheit (fachlich: ein SZENARIO). Sie ist selbsttragend und trägt ihr
// eigenes Grundprofil (Haushaltsform, Personen, Inflation, Cash) plus Phasen und Elemente.
// Der Name `PlanInput` ist historisch und bleibt, weil die ganze Berechnungsschicht darauf
// aufsetzt (computePlan, Monte Carlo, Tests).
@@ -67,8 +67,8 @@ export interface PlanInput {
householdType: HouseholdType;
inflationRateDefault: number;
initialCash: number; // Anfangswert des Cash-Kontos in der ersten Lebensphase
// Kalenderjahr des Planbeginns (Jahr 1) -- nur fuer die Darstellung, nicht fuer die
// Berechnung. Optional, damit Berechnungs-Fixtures es nicht setzen muessen.
// Kalenderjahr des Planbeginns (Jahr 1) -- nur für die Darstellung, nicht für die
// Berechnung. Optional, damit Berechnungs-Fixtures es nicht setzen müssen.
startYear?: number | null;
persons: PersonInput[];
phases: PhaseInput[];
+1 -1
View File
@@ -2,7 +2,7 @@ import bcrypt from "bcryptjs";
import { prisma } from "@/lib/db";
// Nur von API-Routen (Node.js-Runtime) verwendet -- niemals von middleware.ts
// importieren, da dort (Edge-Runtime) kein Datenbankzugriff moeglich ist.
// importieren, da dort (Edge-Runtime) kein Datenbankzugriff möglich ist.
const USERNAME_PATTERN = /^[a-zA-Z0-9._-]{3,32}$/;