Monte Carlo ueber mehrere Szenarien eines Plans in einem Lauf: - Annahmen nur EINMAL je logischem Element (Zuordnung ueber sourceElementId, dieselbe Kette wie beim Diff) -- sonst vergleicht man die Eingaben statt der Szenarien - gemeinsamer Seed fuer alle Szenarien (Common Random Numbers), damit Unterschiede strukturell und nicht zufaellig sind - Zielbetrag bleibt szenario-eigen: die Erfolgswahrscheinlichkeit misst, wie oft ein Szenario sein EIGENES Versprechen haelt - Vergleichstabelle + Median-Linien; bei einem Szenario unveraenderter Faecher Sensitivitaetsanalyse (Roadmap Nr. 20), eigener Dialog "Einflussfaktoren": - neues reines Modul sensitivity.ts, One-at-a-time ueber 7 Treiber - Bandbreiten je Treiber pflichtig und ohne Default (die Balkenlaenge haengt direkt davon ab) - Pensionsalter bewusst ausgeschlossen: nicht variierbar ohne Mitverschieben der Phasengrenzen (Begruendung in 9.18) SPEZIFIKATION auf 1.0: neue Kapitel 3.6.6, 4.12.6, 4.13, 9.18, 9.19 sowie vier korrigierte Dokumentationsfehler (Kap. 1.2, 4.6.5, 5.2/5.3/5.5.2/8.1, Glossar). 20 Tests ergaenzt (60 -> 80). Keine DB-Aenderung. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> @
142 KiB
FPT – Financial Planning Tool
Funktionale und Technische Spezifikation
| Dokument | Funktionale und Technische Spezifikation FPT |
| Version | 1.0 |
| Datum | 2026-07-18 |
| Status | Lebendes Dokument |
| Codestand | Arbeitsstand nach d203e50 inkl. Szenario-Vergleich in der Monte-Carlo-Simulation und Sensitivitätsanalyse (Branch main) |
| Ersetzt | FDD_TDD_FPT.docx (v1–v5) im Ordner Info Dateien – diese sind ab Version 0.1 dieses Dokuments obsolet |
| Geltungsbereich | Gesamter Code im Verzeichnis FPT |
Änderungshistorie
| Version | Datum | Autor | Änderung |
|---|---|---|---|
| 1.0 | 2026-07-18 | Claude (Opus 4.8) | Szenario-Vergleich in der Monte-Carlo-Simulation und Sensitivitätsanalyse / Tornado (Roadmap Nr. 20). (1) Die MC-Simulation rechnet neu mehrere Szenarien desselben Plans in einem Lauf. Die historischen Annahmen werden dabei nur einmal je logischem Element erfasst – die Zuordnung über die Herkunfts-Kette sourceElementId, dieselbe Grundlage wie beim Diff (neue Funktionen resolveRootElementId, buildElementGroups, paramsForScenario, runMonteCarloMulti). Alle Szenarien laufen mit demselben Seed (Common Random Numbers), damit Unterschiede strukturell und nicht zufällig sind. Der Zielbetrag bleibt szenario-eigen (vorbelegt mit dem jeweils geplanten Endvermögen) – nur so misst die Erfolgswahrscheinlichkeit, wie oft ein Szenario sein eigenes Versprechen hält. Ergebnis als Vergleichstabelle plus Median-Linien je Szenario; bei einem einzelnen Szenario unverändert der bisherige Fächer. (2) Neuer Bereich Einflussfaktoren (eigener Button, eigener Dialog) mit einem Tornado-Chart nach dem One-at-a-time-Verfahren: neues reines Modul sensitivity.ts mit sieben Treibern, je Treiber an-/abwählbar und mit pflichtiger, frei definierbarer Bandbreite ohne Default. Das Pensionsalter ist bewusst nicht enthalten (Begründung: 9.18). Neue Kapitel 3.6.6, 4.12.6, 4.13, 9.18; 20 Tests ergänzt (60 → 80). Keine DB-Änderung, keine Verhaltensänderung bestehender Pläne. Ausserdem vier Dokumentationsfehler korrigiert: Kap. 1.2 nannte noch Monte-Carlo, Hypothekarzinsen und Immobilien-Wertsteigerung als nicht umgesetzt (seit 0.5/0.7 vorhanden); Kap. 4.6.5 endete mit einem widersprüchlichen Restsatz zur fehlenden Wertsteigerung; Kap. 5.2/5.3/5.5.2/8.1 waren bei Migrationszahl, Dateiliste, Komponentenliste und Testzahlen veraltet; der Glossar-Eintrag „Szenario" beschrieb noch das Modell vor V6. |
| 0.9 | 2026-07-18 | Claude (Opus 4.8) | UI-Umbau und Planstart. (1) Die Grafiken liegen neu im eigenen Bereich Grafiken (Dialog, Button oben) statt unter der Matrix. (2) Kennzahl Geschätzter Nachlass entfernt – sie war identisch mit dem nominalen Endvermögen. (3) Monte-Carlo-Button nach oben zu den Szenario-Aktionen verschoben. (4) Neues Profilfeld Planstart (Jahr) (Scenario.startYear, Migration; reine Anzeige, Berechnung bleibt in relativen Jahren) – erscheint auf der Zeitachse. (5) Zeitachse zeigt neu die Lebensphasen als Segmente (Breite = Dauer, Einfärbung nach Phasentyp, Jahresspanne). (6) Lebensphase bearbeiten neu als Popup statt Panel unter der Tabelle. (7) Vermögensverlauf über alle Jahre statt nur über die Phasengrenzen – dafür führt computePlan das Vermögen neu pro Jahr mit (YearPoint.wealthNominal/wealthReal). Zwei Tests ergänzt (58 → 60). |
| 0.8 | 2026-07-18 | Claude (Opus 4.8) | Szenario-Hierarchie (V6) – grösste Umstrukturierung bisher. Der Plan ist neu ein schlanker Behälter ohne Finanzdaten; die berechenbare Einheit ist das Szenario, das Grundprofil (inkl. Pensionsalter → Frühpensionierungs-Szenarien), Phasen und Elemente trägt. Jeder Plan erhält beim Anlegen automatisch ein Basisszenario; weitere Szenarien entstehen als vollständige Kopie eines beliebigen Szenarios und hängen als Baum darunter (Sidebar zeigt die Verschachtelung). Kopierte Phasen/Elemente tragen Herkunfts-Verweise (sourcePhaseId, sourceElementId) – darauf beruht die Abweichungs-Markierung: geändert = gelb, neu = grün, entfernt = graue Geisterzeile (eigene Theme-Tokens für alle drei Farbschemata). Charts vergleichen neu die Geschwister-Szenarien. Datenmodell: neue Tabelle Plan, bisheriger Plan → Scenario (IDs erhalten), planId → scenarioId in Person/Phase/FinancialElement. API neu unter /api/scenarios/*. Neue Kapitel 2.1, 3.2, 4.13; 9 Diff-Tests + Migrations-Test (48 → 58). Migration mit echtem Postgres (PGlite) verifiziert, inkl. verschachtelter Szenarien und Cascade. |
| 0.7 | 2026-07-17 | Claude (Opus 4.8) | Monte-Carlo-Simulation (Roadmap Nr. 19, Stufe A). Button in der Planansicht → Dialog mit Erklärung, Eingaben und Ergebnis. Statt einer festen Rendite/Inflation werden tausende Zufallspfade gerechnet; ausgewiesen werden Ruinwahrscheinlichkeit, Erfolgswahrscheinlichkeit (P(Endvermögen ≥ Zielbetrag)) und ein Fächer (10 %/Median/90 %) plus die deterministische Linie. Läuft komplett im Browser (computePlan ist rein). Modell: fettschwänzige Verteilung (Student-t, ν=5), gemeinsamer Marktschock (ρ=0.7), Böden 0 % für PK/3a und −100 % sonst. Pro renditetragendem Element und für die Inflation je: historischer Ø (Pflicht), Streuungsstufe, σ. Neue Datei montecarlo.ts + optionaler sample-Parameter in computePlan (Inflation neu als kumulatives Array; deterministisch identisch). Neues Kapitel 4.12; 7 MC-Tests + Refactor-Absicherung (41 → 48). Keine Verhaltensänderung, keine DB-Änderung. |
| 0.6 | 2026-07-17 | Claude (Opus 4.8) | Teilverkauf von Sonstigem Vermögen (Roadmap Nr. 42) und Sonderamortisation der Hypothek (Roadmap Nr. 15). OTHER_ASSET am Übergang neu: Halten / Verkaufen / Teilverkauf – ein Betrag fliesst ins Cash (erscheint als „Kapitalzufluss" im Phasenkopf), der Rest bleibt investiert. REAL_ESTATE im Halten-Fall neu mit Einmaltilgung aus dem Cash (analog zur Sofort-Tilgung bei Schulden; erscheint als „Kapitalinvestition"). Damit lässt sich die indirekte Amortisation via 3a mechanisch nachbilden. Die eigentliche Roadmap Nr. 15 (Steuerwirkung der indirekten Amortisation) bleibt mit dem Steuer-Bündel 13/14/23 zurückgestellt – siehe 9.14. Kapitel 4.9.3/4.9.4 ergänzt. Fünf Regressionstests (36 → 41). Keine Verhaltensänderung für bestehende Pläne. |
| 0.5 | 2026-07-17 | Claude (Opus 4.8) | Netto/Brutto geklärt (Roadmap Nr. 9, reduziert) und Immobilien-Modul erweitert (Roadmap Nr. 8). Einkommen ist neu explizit als Nettolohn definiert (Label und Hilfetext); für die AHV rechnet das Tool intern mit AHV_GROSS_FROM_NET_FACTOR = 1.12 auf den Bruttolohn hoch – die AHV bemisst sich am Brutto, die bisherige Netto-Basis unterschätzte die Rente um bis zu ~1'900/Jahr. Immobilie neu mit Hypothekarzins (% der Restschuld, sinkt mit der Amortisation, mit Doppelzählungs-Schalter) und Wertsteigerung (auf die Liegenschaft, nicht auf das Eigenkapital – Hebeleffekt). Grundstückgewinnsteuer bemisst sich neu explizit am ursprünglichen Kaufpreis. Keine Aufschlüsselung bei Einkommen oder Ausgaben, keine Steuerschätzung (Begründung: 9.14). Neues Kapitel 4.4.5; 4.6.5 und 4.9.4 überarbeitet; Abschnitt 9 um zwei Punkte ergänzt. Sechs Regressionstests (30 → 36). Verhaltensänderung: siehe 9.13. |
| 0.4 | 2026-07-17 | Claude (Opus 4.8) | AHV-Rente einkommensabhängig (Roadmap Nr. 3) und Fortschreibungs-Warnhinweis (Roadmap Nr. 4). Die AHV-Rente folgt neu der amtlichen Rentenformel (Skala 44) über das massgebende durchschnittliche Jahreseinkommen statt pauschal der Maximalrente; verifiziert gegen die amtliche Tabelle 318.117.1 (51/51 Zeilen). Prüfung der Beitragskarriere am Pensions-Übergang, mit Zusatzfeldern für die Jahre vor Planbeginn (ab Alter 21). Alles real gerechnet. Neue Konstanten AHV_MIN_MONTHLY_FULL, AHV_PENSION_MONTHS, AHV_CONTRIBUTION_START_AGE; AHV_MAX_ANNUAL_SINGLE neu abgeleitet. Warnhinweis in Phasenzellen und Phasen-Detail, wenn Folgephasen existieren. Neue Kapitel 3.5.6, 4.4; Abschnitt 9 um zwei Punkte ergänzt. Zwölf Regressionstests (18 → 30). Verhaltensänderung: siehe 9.10. |
| 0.3 | 2026-07-16 | Claude (Opus 4.8) | Einmalige Sonderein-/ausgaben umgesetzt (Roadmap Nr. 1). Der Cash-Übergang zwischen zwei Phasen ist neu ein eigener Entscheid: 1:1 übernehmen · einmaliger Zufluss · einmalige Kosten · beides. Beträge gehen direkt aufs Cash-Konto. Zufluss wird nominal erfasst (real angezeigt) mit optionalem Steuersatz (Default 0 %), Kosten werden real erfasst (nominal angezeigt); beide bleiben aus der Sparquote heraus. Der Entscheid startet unbeantwortet und zählt im „offen"-Badge mit. Neue Spalte Phase.cashTransition (JSON) + Migration, neue Route PUT /api/phases/<id>/cash-transition, eigene Kennzahlen im Phasenkopf. Fünf Regressionstests ergänzt (13 → 18). Neue Kapitel 3.5.5, 4.9.6; Abschnitt 9 um zwei Grenzen ergänzt. |
| 0.2 | 2026-07-16 | Claude (Opus 4.8) | Drei Fixes umgesetzt und dokumentiert: (1) Phase.inflationRate ersatzlos entfernt (DB-Migration, API, Typen, UI) – die Inflation liegt seit V5 plan-weit; das Feld war wirkungslos. (2) Amortisation und Tilgung stoppen neu, sobald Hypothek bzw. Schuld abbezahlt sind – sie belasten danach weder Cash noch Sparquote (Kap. 4.6.5, 4.6.7, 4.7, 4.8). (3) Kapitalbezugssteuer greift neu auch bei PK-/3a-Vorbezügen vor der Pensionierung, inkl. Steuerfeld und Netto-Vorschau im UI (Kap. 3.5.2, 4.9.1, 4.9.2). Kennzahl plannedSaveRate ist neu die Rate des ersten Phasenjahres. Drei Regressionstests ergänzt (10 → 13). Abschnitt 9 neu nummeriert (erledigte Punkte entfernt, „Sparraten werden nicht indexiert" ergänzt). |
| 0.1 | 2026-07-16 | Claude (Opus 4.8) | Erstfassung. Vollständige Neuerstellung aus dem Code (Stand f768e01). Ersetzt die bisherigen FDD/TDD-Dokumente v1–v5 vollständig. |
Pflegehinweis: Dieses Dokument ist ein lebendes Dokument. Bei jeder Aktualisierung wird (a) die Änderungshistorie um eine Zeile ergänzt, (b) die Versionsnummer im Kopf um 0.1 erhöht und (c) der referenzierte Codestand (Commit) aktualisiert.
Inhaltsverzeichnis
- Einleitung und Abgrenzung
- Fachliche Grundkonzepte
- Funktionale Spezifikation
- Berechnungsmodell
- Technische Spezifikation
- API-Referenz
- Betrieb und Deployment
- Qualitätssicherung
- Bekannte Einschränkungen und Modellentscheide
- Glossar
1. Einleitung und Abgrenzung
1.1 Zweck des Tools
Das FPT ist eine Webanwendung zur persönlichen Finanzplanung über Lebensabschnitte hinweg, ausgelegt auf Schweizer Verhältnisse (AHV, Pensionskasse, Säule 3a, Grundstückgewinnsteuer). Ein Benutzer bildet seine finanzielle Situation als Kette von Lebensphasen ab, hinterlegt finanzielle Elemente (Einkommen, Ausgaben, Vorsorge, Immobilien, Vermögen, Schulden) und trifft an den Übergängen zwischen den Phasen bewusste Entscheide (halten, verkaufen, Kapital beziehen, verrenten). Das Tool rechnet daraus Jahr für Jahr das Vermögen, den Cash-Bestand, Spar- bzw. Verzehrquoten sowie ein allfälliges Ruinalter fort.
1.2 Was das Tool nicht ist
Aus dem Code direkt ableitbare Abgrenzungen:
- Keine Steuerberechnung ausser den drei explizit modellierten Sätzen (Kapitalbezugssteuer, Grundstückgewinnsteuer, PK-Umwandlungssatz). Einkommens- und Vermögenssteuern sind nicht modelliert und müssen vom Benutzer in den Ausgaben erfasst werden (Begründung: 9.14).
- Keine Nebenkosten, kein Eigenmietwert, keine Mieteinnahmen bei Immobilien (siehe 9.3).
- Keine automatische Deckung von Liquiditätslücken. Negatives Cash wird gemeldet, aber nicht korrigiert (siehe 9.1).
- Keine Mehrbenutzer-Kollaboration. Pläne gehören genau einem Benutzer.
Hinweis zur Dokumenthistorie: Bis Version 0.9 stand hier zusätzlich „keine Monte-Carlo-Simulation", „keine Hypothekarzinsen" und „keine Wertentwicklung von Immobilien". Alle drei sind seit Version 0.5 bzw. 0.7 umgesetzt (4.4.5, 4.6.5, 4.12); die Abgrenzung war versehentlich stehen geblieben.
Die deterministische Rechnung bleibt der Kern des Tools; Stochastik kommt ausschliesslich in der Monte-Carlo-Simulation (4.12) und – als reine Was-wäre-wenn- Rechnung – in der Sensitivitätsanalyse (4.13) dazu. Beide verändern die gespeicherten Plandaten nicht.
1.3 Kernprinzip: Plan als selbsttragende Einheit
Seit dem V3-Rework (Migration 20260713150000_profile_to_plan_v3) trägt jeder Plan sein
eigenes Grundprofil: Haushaltsform, Personen (Alter, Pensionsalter, Name) und
Inflationsannahme. Es gibt keine übergeordnete Haushalts-Entität mehr. Ein Szenario ist
deshalb eine vollständige Deep-Copy und vom Ursprungsplan unabhängig veränderbar.
Referenz: prisma/schema.prisma Zeilen 8–10, src/lib/types.ts Zeilen 38–49.
2. Fachliche Grundkonzepte
2.1 Die Ebenen
User
└── Plan (Behälter: nur Name — KEINE Finanzdaten)
└── Scenario[] (die berechenbare Einheit)
│ Grundprofil: Haushaltsform, Personen, Inflation, Cash-Startwert
│ isBase = genau eines je Plan · parentScenarioId = Baum + Vergleichsbasis
├── Person[] (1 bei SINGLE, 2 bei COUPLE — je Szenario eigen!)
├── Phase[] (Kette 1..n; sourcePhaseId = Gegenstück in der Vorlage)
└── FinancialElement[] (szenario-weit; sourceElementId = Gegenstück in der Vorlage)
├── ElementPhaseValue[] (Werte je Phase, JSON)
└── ElementTransitionValue[] (Entscheide je Übergang, JSON)
Zwei Designprinzipien tragen dieses Modell:
(1) Ein finanzielles Element ist über alle Lebensphasen hinweg dieselbe Entität. Eine Pensionskasse „PK Arbeitgeber" existiert einmal pro Szenario; sie hat pro Phase einen Werte-Satz und pro Phasenübergang einen Entscheid-Satz. Dadurch bleibt die Identität eines Vermögensgegenstands über die Zeit erhalten – Voraussetzung für die Fortschreibung (Carry) und die Vermögensaufteilungs-Grafik.
(2) Das Grundprofil liegt am Szenario, nicht am Plan (V6). Nur so lassen sich die
wertvollsten Szenario-Fragen abbilden – allen voran ein abweichendes Pensionsalter
(„Was, wenn ich mit 62 statt 65 aufhöre?"), das in Person steckt. Wäre das Profil geteilt,
wären Frühpensionierungs-Szenarien unmöglich.
Der Plan ist damit ein reiner Behälter: Er bündelt Szenarien und trägt den Eigentümer
(userId). Ownership von Szenario/Phase/Element läuft über die Kette
Scenario → Plan → User.
Referenz: prisma/schema.prisma.
2.2 Die Matrix als Leitmetapher
Die Hauptansicht ist eine Tabelle:
- Zeilen = finanzielle Elemente, gruppiert nach Kategorie; zuoberst die systemseitige, read-only Zeile Cash.
- Spalten = abwechselnd Phasenspalten und Übergangsspalten (Phase 1 → Übergang → Phase 2 → Übergang → Phase 3 …). Der letzten Phase folgt keine Übergangsspalte.
- Zellen = anklickbar; Phasenzellen öffnen die Werte-Eingabe, Übergangszellen den Entscheid-Dialog.
Referenz: src/components/PlanView.tsx Zeilen 111–120 (Spaltenaufbau), 333–477 (Matrix).
2.3 Phasentyp – abgeleitet, nicht gespeichert
Der Typ einer Phase wird nie gespeichert, sondern in jeder Berechnung aus Alter und Pensionsalter der Personen abgeleitet:
| Bedingung (zu Phasenbeginn) | Typ |
|---|---|
alle Personen startAge < retirementAge |
ERWERB |
alle Personen startAge >= retirementAge |
PENSION |
| gemischt | MIXED |
Referenz: src/lib/calculations.ts Zeilen 158–172; Kommentar in prisma/schema.prisma Zeilen 94–95.
2.4 Cash als Ausgleichskonto
Cash ist kein vom Benutzer erfassbares Element, sondern das systemseitige Ausgleichskonto:
- Es startet mit
Plan.initialCash(Phase 1). - Es nimmt jährlich die Spar-/Verzehrquote (Einkommen − nominale Ausgaben) auf.
- Es finanziert die geplanten Sparraten (3a-Beiträge, Sparbeiträge, Amortisationen, Tilgungen).
- Es empfängt die Bezugsraten aus Sonstigem Vermögen.
- Es empfängt an Übergängen Kapitalzuflüsse (Verkäufe, PK-/3a-Bezüge) und finanziert Sofort-Tilgungen sowie Zusatzinvestitionen der Folgephase.
- Es nimmt an Übergängen einmalige Sonderein-/ausgaben auf (Erbschaft, Poolbau, Autokauf) – siehe 3.5.5.
- Es darf negativ werden – dies ist die Definition einer Liquiditätslücke und wird rot markiert, aber nicht automatisch korrigiert.
Cash ist die einzige Zeile der Matrix ohne FinancialElement-Datensatz. Zwei Zellen sind
dennoch bearbeitbar: die erste Phasenzelle (Cash-Anfangswert) und jede Übergangszelle
(einmalige Sonderein-/ausgaben).
Referenz: src/lib/calculations.ts (Jahresschleife und Übergang).
3. Funktionale Spezifikation
3.1 Authentifizierung und Benutzerkonto
3.1.1 Registrierung
- Öffentlich zugänglich (kein Einladungscode, keine Freischaltung).
- Benutzername: Muster
^[a-zA-Z0-9._-]{3,32}$– 3 bis 32 Zeichen, Buchstaben, Zahlen, Punkt, Unterstrich, Bindestrich. Muss plan-übergreifend eindeutig sein (User.username @unique). - Passwort: mindestens 6 Zeichen; wird mit bcrypt (Cost-Faktor 12) gehasht.
- Bei Erfolg wird sofort eine Session gesetzt (Login inbegriffen), HTTP 201.
- Bei belegtem Benutzernamen: HTTP 409 mit Meldung „Dieser Benutzername ist bereits vergeben."
Referenz: src/lib/users.ts Zeilen 6–22, src/app/api/auth/register/route.ts.
3.1.2 Anmeldung
- Benutzername + Passwort; Prüfung via
bcrypt.compare. - Fehlermeldung ist bewusst unspezifisch: „Benutzername oder Passwort falsch." (HTTP 401) – verrät nicht, ob der Benutzer existiert.
- Bei Erfolg: JWT (HS256, Payload
{ userId }, Gültigkeit 30 Tage) im HttpOnly-Cookiefpt_session(sameSite=lax,securenur in Produktion,maxAge30 Tage).
Referenz: src/app/api/auth/login/route.ts, src/lib/auth.ts.
3.1.3 Abmeldung
POST /api/auth/logout löscht das Cookie. Da die Session zustandslos ist (JWT), bleibt ein
bereits kopiertes Token bis zum Ablauf technisch gültig – es gibt keine serverseitige
Token-Sperrliste.
3.1.4 Passwortänderung
Im Profilmenü. Erfordert das aktuelle Passwort; das neue Passwort muss ≥ 6 Zeichen haben und wird im Dialog gegen eine Wiederholung geprüft (Client-seitig). Nach Erfolg erscheint 1.2 s lang „Passwort geändert.", dann schliesst der Dialog.
Referenz: src/app/api/auth/change-password/route.ts, src/components/ProfileMenu.tsx Zeilen 117–140.
3.1.5 Zugriffsschutz
Zweistufig:
- Middleware (
src/middleware.ts, Edge-Runtime): schützt alle Pfade ausser/login,/api/auth/login,/api/auth/register,/_next/*,/favicon*. Ohne gültiges Token → API-Aufrufe erhalten HTTP 401, Seitenaufrufe werden nach/login?next=<pfad>umgeleitet. - Ownership-Check in jeder API-Route:
getCurrentUserId()plus eine Abfrage, die den Datensatz nur zurückgibt, wenn er dem Benutzer gehört (getOwnedPlan,getOwnedPhase,getOwnedElementinsrc/lib/queries.ts). Ein fremder Datensatz führt zu HTTP 404 (nicht 403) – die Existenz wird nicht preisgegeben.
3.2 Plan-Verwaltung
3.2.1 Plan erstellen
Dialog mit Name plus dem vollständigen Grundprofil:
| Feld | Typ | Default | Wertebereich |
|---|---|---|---|
| Name des Plans | Text | „Basisplan" | 1–120 Zeichen |
| Haushaltsform | Auswahl | SINGLE |
SINGLE / COUPLE |
| Name je Person | Text (optional) | leer | ≤ 60 Zeichen |
| Aktuelles Alter | Zahl | 35 | 0–120 |
| Pensionierungsalter | Zahl | 65 | 30–100 |
| Erwartete Inflationsrate (%) | Zahl | 1.5 | −20 bis 50 |
Konsistenzregel: SINGLE erfordert genau eine Person, COUPLE genau zwei (Person A und B).
Verletzung → HTTP 400 mit Klartextmeldung.
Referenz: src/components/PlanProfileFields.tsx Zeilen 1012–1018, src/app/api/plans/route.ts
Zeilen 6–35.
Ein neu erstellter Plan hat keine Phasen und keine Elemente; initialCash ist 0.
3.2.2 Grundprofil ändern
Über „Einstellungen" in der Planansicht. Wichtig: Die Personen werden serverseitig in einer
Transaktion gelöscht und neu angelegt (deleteMany + create). Die Person-IDs ändern sich
dadurch. Da Elemente über ownerRole (nicht über personId) zugeordnet sind, bleibt die
Zuordnung erhalten.
Ein Wechsel von COUPLE auf SINGLE entfernt Person B. Elemente mit ownerRole = PERSON_B
bleiben in der Datenbank bestehen, finden aber keinen Owner mehr – siehe
9.2.
Referenz: src/app/api/plans/[planId]/route.ts Zeilen 55–70.
3.2.3 Cash-Anfangswert
Klick auf die erste Zelle der Cash-Zeile öffnet den Dialog „Cash-Anfangswert". Wertebereich 0 bis 1'000'000'000, wird auf ganze Franken gerundet.
Referenz: src/components/PlanView.tsx Zeilen 1130–1156, src/app/api/plans/[planId]/route.ts Zeile 36.
3.2.4 Plan löschen
Aus der Übersichtskachel oder der Planansicht, mit Browser-confirm(). Löscht per Datenbank-Cascade
Personen, Phasen, Elemente und alle Werte. Szenarien, die auf diesen Plan als parentPlanId
zeigen, werden nicht gelöscht – ihre parentPlanId wird auf NULL gesetzt
(onDelete: SetNull).
Referenz: prisma/schema.prisma Zeile 82.
3.2.5 Szenarien
Beim Anlegen eines Plans entsteht automatisch das Basisszenario (isBase = true,
Name „Basisszenario"). Der beim Anlegen erfasste Profilteil (Haushaltsform, Personen,
Inflation) landet dort, der Name am Plan.
Ein weiteres Szenario ist eine vollständige Kopie eines beliebigen bestehenden Szenarios (nicht nur des Basisszenarios). Kopiert werden Grundprofil, alle Personen, alle Phasen, alle Elemente sowie sämtliche Phasen- und Übergangswerte.
Gesetzt werden dabei:
parentScenarioId= das kopierte Szenario → ergibt den Baum in der Seitenleiste und die Vergleichsbasis für die Abweichungs-MarkierungsourcePhaseId/sourceElementIdje kopierter Phase bzw. Element → die Identität, über die der Diff „dieselbe Zelle" wiederfindet
Da jede Kopie wieder kopierbar ist, entstehen Sub-Szenarien beliebiger Tiefe; die Seitenleiste rückt sie entsprechend ein.
Löschen: Ein Szenario lässt sich löschen, das Basisszenario nicht (dafür den ganzen Plan löschen). Das Löschen eines Plans entfernt per Cascade alle seine Szenarien.
Referenz: src/app/api/scenarios/[scenarioId]/copy/route.ts.
3.2.6 Abweichungs-Markierung (Diff)
Jedes Szenario ausser dem Basisszenario wird live gegen sein Eltern-Szenario verglichen. Abweichende Stellen sind in der Matrix farblich markiert:
| Zustand | Darstellung |
|---|---|
| geändert | gelb hinterlegt (--diff / --diff-soft) |
| neu (in der Vorlage nicht vorhanden) | grün hinterlegt + Badge „neu" (--diff-added) |
| entfernt (in der Vorlage vorhanden, hier gelöscht) | graue Geisterzeile, durchgestrichen (--diff-removed) |
Markiert werden: Phasenzellen, Übergangszellen, Element-Zeilen (Name/Zuordnung), Phasenköpfe (Name/Dauer), der Cash-Anfangswert, Cash-Übergänge und das Grundprofil-Banner. Über der Matrix steht die Gesamtzahl der Abweichungen.
Die Farben sind semantische Tokens und für Hell, Dunkel und Warm getrennt abgestimmt – ein fester Gelbwert würde im Dunkelschema unbrauchbar aussehen.
Zwei bewusste Eigenschaften:
- Der Vergleich läuft gegen das direkte Eltern-Szenario, nicht immer gegen die Basis. Bei einem Sub-Szenario ist das genau „was habe ich gegenüber der Vorlage geändert".
- Der Diff ist live: Ändert man die Vorlage, verschiebt sich die Markierung im Kind rückwirkend (siehe 9.16).
Referenz: src/lib/diff.ts.
3.2.7 Planstart (Kalenderjahr)
Das Grundprofil enthält das Feld Planstart (Jahr) – das Kalenderjahr, in dem Jahr 1 der
Planung liegt (Scenario.startYear). Es dient ausschliesslich der Darstellung: Zeitachse
und Grafiken beschriften damit Jahre statt nur Alter. Die Berechnung rechnet unverändert in
relativen Jahren ab Planbeginn – startYear fliesst in keine Formel ein.
Beim Anlegen wird das laufende Jahr vorbelegt; bestehende Szenarien wurden per Migration darauf
gesetzt. Kalenderjahr eines Planjahrs: startYear + (Jahr − 1).
3.3 Lebensphasen
3.3.1 Phase anlegen
Neue Phasen werden immer am Ende der Kette angehängt (sequenceNumber = Anzahl + 1).
Automatische Dauer-Kappung: Die Dauer wird ans nächste Pensionsereignis gekappt. Formel
(maxPhaseDuration): für jede Person, die zu Phasenbeginn noch erwerbstätig ist, gilt
retirementAge − (age + yearsBefore); das Minimum dieser Werte ist die Obergrenze. Ist keine
Person mehr erwerbstätig, gibt es keine Obergrenze (null). Diese Kappung ist im Dialog
sichtbar („max. N") und wird serverseitig erzwungen.
Fachliche Begründung: Eine Phase darf keine Pensionierung überspannen, weil der Phasentyp und die AHV-/PK-Renten am Phasenbeginn ausgewertet werden.
Default-Dauer: die Kappung, sonst 10 Jahre. Default-Name: Phase 1 → „Erste Lebensphase"; sonst „Pensionsphase" wenn zu Phasenbeginn mindestens eine Person pensioniert ist, sonst „Erwerbsphase".
Vorbelegung der Elemente: Beim Anlegen einer Phase wird für jedes noch aktive Element
(nicht SOLD, nicht SETTLED) ein ElementPhaseValue mit den editierbaren Feldern der
Vorphase erzeugt (buildCarryData):
| Kategorie | Übernommene Felder |
|---|---|
INCOME, EXPENSE |
nur teuerungsausgleich (Basis wird live fortgeschrieben) |
AHV |
gapYears: 0 |
PENSION_FUND, PILLAR_3A, OTHER_ASSET |
annualContribution, expectedReturn |
REAL_ESTATE |
purchasePrice, amortization (Resthypothek wird live fortgeschrieben) |
OTHER_DEBT |
annualRepayment |
Bestände (PK-/3a-/Vermögenswert, Resthypothek, Restschuld) werden bewusst nicht als Snapshot gespeichert, sondern in jeder Berechnung live aus der Vorphase fortgeschrieben. Damit wirken sich nachträgliche Änderungen an frühen Phasen automatisch auf alle Folgephasen aus.
Referenz: src/app/api/plans/[planId]/phases/route.ts.
3.3.2 Phase bearbeiten
Klick auf einen Phasenkopf öffnet ein Popup („Lebensphase: ") mit Bezeichnung und Dauer – konsistent zu allen anderen Eingaben (Element-Zellen, Übergänge). Speichern schliesst das Popup. Die Dauer wird auch hier gekappt. Eine phasenspezifische Inflationsrate gibt es nicht; das Panel weist darauf hin: „Die Inflationsrate gilt plan-weit und wird in den Plan-Einstellungen gesetzt."
Referenz: src/components/PhaseDetail.tsx.
3.3.3 Phase löschen
Nur die letzte Phase kann gelöscht werden – serverseitig geprüft (HTTP 400: „Nur die letzte
Phase kann geloescht werden."). Damit bleibt die Kette der sequenceNumber lückenlos. Der
Löschen-Button erscheint im Detail-Panel nur bei der letzten Phase.
Referenz: src/app/api/phases/[phaseId]/route.ts Zeilen 55–77.
3.4 Finanzielle Elemente
3.4.1 Kategorien und Personenzuordnung
| Kategorie | Label | Zuordnung |
|---|---|---|
INCOME |
Einkommen | frei: Gemeinsam / Person A / Person B |
EXPENSE |
Ausgaben | frei |
AHV |
AHV | zwingend genau eine Person |
PENSION_FUND |
Pensionskasse | zwingend genau eine Person |
PILLAR_3A |
Säule 3a | zwingend genau eine Person |
REAL_ESTATE |
Immobilie | frei |
OTHER_ASSET |
Sonstiges Vermögen | frei |
OTHER_DEBT |
Sonstige Schulden | frei |
Die Vorsorge-Kategorien (PERSON_ONLY_CATEGORIES) sind zwingend personengebunden, weil AHV-Rente,
PK-Verrentung und 3a-Bezug am individuellen Pensionierungszeitpunkt hängen. Wird für diese
Kategorien kein PERSON_A/PERSON_B übergeben → HTTP 400.
Für die übrigen Kategorien gilt: fehlt die Zuordnung, wird serverseitig HOUSEHOLD gesetzt.
Referenz: src/lib/elements.ts Zeilen 19–23, src/app/api/plans/[planId]/elements/route.ts Zeilen 43–54.
3.4.2 Element anlegen
Der Dialog „Finanzielles Element" fragt Kategorie, Zuordnung, Bezeichnung und direkt die
Werte der ersten Lebensphase ab. Nach dem Anlegen (POST .../elements) werden die Werte per
PUT .../phase/<firstPhaseId> gespeichert, sofern mindestens ein Feld gesetzt wurde. Der
Bezeichnungs-Default ist das Kategorie-Label.
orderIndex = bisheriges Maximum + 1; bestimmt die Reihenfolge innerhalb der Kategoriegruppe.
Referenz: src/components/PlanView.tsx Zeilen 799–947.
3.4.3 Element bearbeiten und löschen
Ein Klick auf eine Phasenzelle öffnet den Dialog „Lebensphase: " mit den
kategorie- und kontextabhängigen Feldern (siehe 3.4.4) sowie
dem Button „Element löschen". Löschen entfernt das Element aus allen Phasen
(Browser-confirm(), dann Cascade auf ElementPhaseValue und ElementTransitionValue).
Umbenennen ist per API möglich (PATCH /api/elements/<id>), im aktuellen UI aber nicht
angebunden.
3.4.4 Feldkatalog je Kategorie
Die angezeigten Felder hängen von drei Kontextgrössen ab:
carried– ob der Basiswert aus der Vorphase fortgeschrieben wird (ab Phase 2)ownerWorking– ob der zugeordnete Besitzer in dieser Phase erwerbstätig istdurationYears– Phasendauer (begrenzt z. B. die Ausfalljahre)
Referenz: src/components/ElementDetail.tsx Zeilen 124–340.
INCOME (Einkommen)
Einkommen ist der NETTOLOHN – der Betrag, der nach allen Lohnabzügen (AHV/ALV, Pensionskasse, NBU) tatsächlich aufs Konto kommt. Das ist die für den Cash-Fluss richtige Grösse und die, in der man denkt.
| Feld | JSON | Semantik |
|---|---|---|
| Jahreseinkommen NETTO, nominal (erstes Jahr) | amount |
Basiswert Jahr 1. Ab Phase 2 vorbelegt mit dem fortgeschriebenen Wert, bewusst änderbar (Teilzeit, Beförderung). |
| ≈ real (heutige Kaufkraft) | – | Read-only Info: amount / deflatorStart |
| Nominale Lohnerhöhung (%/Jahr) | teuerungsausgleich |
Default 0 %. 0 % = nominal gleichbleibend, real sinkend. |
Für die AHV rechnet das Tool intern auf den Bruttolohn zurück – siehe
4.4.5. Warum die Definition überhaupt nötig war:
Für den Cash-Fluss sind beide Konventionen gleichwertig (brutto − Ausgaben inkl. Abzüge
≡ netto − Ausgaben ohne Abzüge), weshalb die Ambiguität lange folgenlos blieb. Mit der
einkommensabhängigen AHV hängt daran aber eine Rente.
EXPENSE (Ausgaben)
Hinweistext: „Ausgaben werden REAL erfasst (in heutiger Kaufkraft)."
| Feld | JSON | Semantik |
|---|---|---|
| Jahresausgaben REAL (erstes Jahr) | amount |
Basiswert in heutiger Kaufkraft. |
| ≈ nominal (in diesem Jahr) | – | Read-only Info: amount * deflatorStart |
| Reale Mehrausgaben (%/Jahr) | teuerungsausgleich |
Zusätzlich zur Inflation. 0 % = gleicher Lebensstandard. |
Die Asymmetrie (Einkommen nominal, Ausgaben real) ist der Kern des V5-Modells: Man kennt seinen Lohn nominal, aber seinen Lebensstandard real.
AHV
| Zustand | Anzeige |
|---|---|
| Besitzer erwerbstätig | Eingabefeld Geplante Ausfalljahre (gapYears), 0 bis Phasendauer. Hilfetext: „Jedes Ausfalljahr kuerzt die spaetere Rente um 1/44." |
| Besitzer pensioniert | Nur Hinweistext – die Rente wird automatisch berechnet. |
PENSION_FUND (Pensionskasse)
| Zustand | Felder |
|---|---|
| erwerbstätig, Phase 1 | Aktueller PK-Wert (currentValue), Jährliche Einzahlung (annualContribution), Erwartete Rendite (expectedReturn) |
| erwerbstätig, ab Phase 2 | Startwert (fortgeschrieben) (read-only), Zusatzeinlage aus Kapital (additionalInvestment), Einzahlung, Rendite |
| pensioniert | Hinweistext (Rente aus Umwandlungssatz bzw. „Vollständig bezogen") |
Wichtig (Hilfetext im UI): Die PK-Einzahlung „Fliesst NICHT in die Sparquote ein (bereits in den Ausgaben beruecksichtigt)" – Lohnabzüge sind im Nettoeinkommen bereits weg.
PILLAR_3A (Säule 3a)
Wie PK, aber:
- Die Einzahlung zählt zur Sparquote (verlässt das Cash).
- Das Feld ist auf
PILLAR_3A_MAX_ANNUAL= 7'258 CHF (2026, mit PK) hart geklammert. - Bei Pensionierung: „Die Saeule 3a wird beim Pensions-Uebergang vollstaendig bezogen."
REAL_ESTATE (Immobilie)
| Feld | JSON | Semantik |
|---|---|---|
| Kaufpreis | purchasePrice |
nur Phase 1 / Neukauf; ab Phase 2 read-only fortgeschrieben |
| Hypothek | mortgage |
dito |
| Startwert Netto (fortgeschrieben) | – | read-only ab Phase 2: Verkehrswert − Resthypothek |
| Resthypothek (fortgeschrieben) | – | read-only ab Phase 2 |
| Amortisation (CHF/Jahr) | amortization |
endet, sobald die Hypothek abbezahlt ist |
| Hypothekarzins (%/Jahr) | interestRate |
Zinssatz auf der Restschuld |
| Hypothekarzins-Betrag (Beginn → Ende) | – | read-only: Zinsbetrag im ersten und im letzten Jahr der Phase |
| Geschätzte Wertsteigerung (%/Jahr) | valueGrowth |
wirkt auf die Liegenschaft |
| Sind die Zinsen bereits in den Ausgaben enthalten? | interestHandling |
INCLUDED (Default) / ADD |
Der Zinsbetrag sinkt automatisch mit der Amortisation – das kann kein manueller
Ausgabenposten. Beispiel: Hypothek 1'000'000, Amortisation 10'000/Jahr, Zins 1 %, 10 Jahre →
Anzeige 10'000 → 9'100 (Jahr 1 auf 1'000'000, Jahr 10 auf 910'000).
Der Schalter interestHandling verhindert die Doppelzählung: Bestehende Pläne haben die
Zinsen im Ausgabenbetrag, deshalb ist INCLUDED der Default und das Tool zieht nichts ab.
Erst ADD rechnet die Zinsen dazu – dann gehören sie aus dem Ausgabenbetrag entfernt. Der
Schalter sitzt bewusst auf der Immobilie und nicht auf dem Ausgaben-Element: Bei mehreren
Ausgaben-Elementen wäre sonst unklar, welches die Zinsen trägt (und zwei auf „Ja" würden
doppelt zählen).
OTHER_ASSET (Sonstiges Vermögen)
| Feld | JSON | Semantik |
|---|---|---|
| Startwert / Startwert (fortgeschrieben) | startValue |
ab Phase 2 read-only |
| Zusatzinvestition aus Kapital | additionalInvestment |
nur ab Phase 2 |
| Erwartete Rendite (%/Jahr) | expectedReturn |
|
| Jährlicher Sparbeitrag | annualContribution |
fliesst ins Vermögen, verlässt das Cash → geplante Sparrate |
| Jährliche Bezugsrate | annualWithdrawal |
Entnahme aus dem Vermögen ins Cash → geplante Verzehrrate |
Die Bezugsrate ist das Instrument für den laufenden Kapitalverzehr innerhalb einer Phase. Für einen einmaligen Zugriff am Übergang gibt es zusätzlich den Teilverkauf (3.5.2, 4.9.3).
OTHER_DEBT (Sonstige Schulden)
| Feld | JSON | Semantik |
|---|---|---|
| Restschuld / (fortgeschrieben) | startValue |
ab Phase 2 read-only |
| Jährliche Tilgung | annualRepayment |
verlässt das Cash → geplante Sparrate |
Schulden gehen mit negativem Vorzeichen ins Vermögen ein.
3.5 Übergänge
3.5.1 Konzept
Zwischen zwei Phasen liegt ein Übergang. Er ist der Ort, an dem einmalige Entscheide getroffen
werden. Fünf Kategorien haben Übergangs-Entscheide (TRANSITION_CATEGORIES):
PENSION_FUND, PILLAR_3A, REAL_ESTATE, OTHER_ASSET, OTHER_DEBT. Dazu kommt der
Cash-Entscheid (siehe 3.5.5), der an
jedem Übergang zu treffen ist.
Für INCOME, EXPENSE und AHV erscheint: „Fuer diese Kategorie gibt es im Uebergang keine
Eingaben."
3.5.2 Normaler Übergang vs. Pensions-Übergang
Ein Übergang ist für ein Element ein Pensions-Übergang, wenn dessen Besitzer in der Von-Phase
erwerbstätig und in der Ziel-Phase pensioniert ist (isRetirementTransition). Nur
personenzugeordnete Elemente (nicht HOUSEHOLD) können das auslösen.
| Kategorie | Normaler Übergang (Vorbezug) | Pensions-Übergang |
|---|---|---|
PENSION_FUND |
Bezug? Kein Bezug / Bezug + Bruttobetrag (max. Endwert) + Kapitalbezugssteuer (%) | Bezugsart: Rente / Kapitalbezug / Kombination |
PILLAR_3A |
Bezug? Kein Bezug / Bezug + Bruttobetrag + Kapitalbezugssteuer (%) | Nur Kapitalbezugssteuer (%) – Bezug ist zwingend vollständig |
REAL_ESTATE |
Halten (+ optionale Sonderamortisation) / Verkaufen (+ Verkaufspreis, Grundstückgewinnsteuer) | identisch |
OTHER_ASSET |
Halten / Verkaufen / Teilverkauf (+ Betrag) | identisch |
OTHER_DEBT |
Sofortige Tilgung (CHF) | identisch |
Ein Vorbezug (vor der Pensionierung, z. B. für Wohneigentum oder Selbstständigkeit) ist wie der Bezug bei Pensionierung kapitalbezugssteuerpflichtig. Der eingegebene Betrag ist der Bruttobezug: er wird in voller Höhe dem Vorsorgekapital entnommen, ins Cash fliesst der Betrag nach Abzug der Steuer. Der Dialog zeigt die resultierende Netto-Auszahlung als read-only Vorschau an.
Bei PK-Bezugsart:
- Rente: Feld Umwandlungssatz (Default 6 %)
- Kapitalbezug: Feld Kapitalbezugssteuer (Default 8 %)
- Kombination: beide Felder plus „Davon Kapitalbezug (CHF)", geklammert am Endwert
Referenz: src/components/ElementDetail.tsx Zeilen 342–462.
3.5.3 Ampel-Logik: „offene" Entscheide
Ein Entscheid gilt als beantwortet (isTransitionAnswered), wenn das jeweilige
Entscheidungsfeld gesetzt ist:
| Kategorie | Beantwortet, wenn |
|---|---|
REAL_ESTATE, OTHER_ASSET |
decision gesetzt (inkl. PARTIAL) |
PENSION_FUND |
Pensions-Übergang: payoutMode gesetzt; sonst: withdrawalMode gesetzt |
PILLAR_3A |
Pensions-Übergang: immer beantwortet; sonst: withdrawalMode gesetzt |
| Cash | mode gesetzt (isCashTransitionAnswered) |
| alle anderen | immer beantwortet |
Der Übergangs-Spaltenkopf zeigt entweder „N offen" (Akzentfarbe) oder „geprüft" (grün, Häkchen). Offene Zellen sind farblich hervorgehoben und zeigen „?".
Ein Element ist am Übergang inaktiv (transitionInactive), wenn es bereits verkauft/getilgt
ist oder wenn es eine PK/3a ist, deren Besitzer schon zu Beginn der Von-Phase pensioniert war
(dann ist bereits bezogen/verrentet). Inaktive Zellen zeigen „–" und sind nicht anklickbar.
Referenz: src/components/PlanView.tsx Zeilen 200–232, src/components/ElementDetail.tsx Zeilen 107–120.
3.5.4 Geführter Übergang (Review-Dialog)
Klick auf einen Übergangs-Spaltenkopf öffnet „Übergang prüfen: → ". Der Dialog listet zuoberst den Cash-Entscheid (einmalige Sonderein-/ausgaben, betrifft jeden Übergang) und darunter alle noch aktiven Elemente der Übergangs-Kategorien mit ihren Entscheidfeldern und kontextabhängigen Hinweisen:
- PK/3a, normaler Übergang: „Hier könnten Sie optional Kapital beziehen."
- PK, Pensionierung: „Pensionierung: Bezugsart wählen (Rente / Kapital / Kombination)."
- 3a, Pensionierung: „Wird bei Pensionierung vollständig bezogen."
„Alle speichern" schreibt jeden Entscheid einzeln per PUT. Entscheidend: Die Formulare sind mit
withTransitionDefaults vorbelegt (Halten / Kein Bezug / Rente), damit ein blosses Speichern den
sichtbaren Default auch tatsächlich persistiert und die Ampel auf grün geht.
Referenz: src/components/PlanView.tsx (TransitionReviewDialog), src/components/ElementDetail.tsx
(withTransitionDefaults).
3.5.5 Cash-Übergang: einmalige Sonderein-/ausgaben
Einmalige Ereignisse (Erbschaft, Poolbau, Autokauf, grössere Anschaffung) werden nicht als finanzielles Element modelliert, sondern als Entscheid auf dem Cash-Konto am Phasenübergang. Sie belasten bzw. speisen das Cash direkt.
Der Entscheid hat vier Ausprägungen (CashTransitionMode):
| Modus | Bedeutung | Felder |
|---|---|---|
NONE |
1:1 übernehmen – Cash läuft unverändert weiter (Default) | – |
INFLOW |
Einmaliger Zufluss | Bezeichnung, Betrag (nominal), Steuer (%) |
OUTFLOW |
Einmalige Kosten | Bezeichnung, Betrag (real) |
BOTH |
Zufluss und Kosten am selben Übergang | beide Feldgruppen |
Erfassungs-Konventionen – bewusst analog zu den laufenden Flows:
- Zufluss: nominal erfasst, real angezeigt (wie Einkommen). Man kennt den Betrag, der effektiv aufs Konto kommt. Der Realwert erscheint read-only als Info.
- Kosten: real erfasst, nominal angezeigt (wie Ausgaben). Man denkt „ein Pool kostet heute 20'000"; die Inflation rechnet daraus den Betrag zum Ereigniszeitpunkt. Der Nominalwert erscheint read-only als Info.
- Steuersatz nur beim Zufluss, Default 0 % (Erbschaften an direkte Nachkommen sind in den meisten Kantonen steuerfrei). Ins Cash fliesst der Betrag nach Abzug der Steuer.
Weder Zufluss noch Kosten gehen in die Spar-/Verzehrquote. Sie sind keine laufenden Flows; eine Erbschaft von 250'000 würde die Quote zu einem sinnlosen Ausschlag treiben. Sie wirken ausschliesslich auf das Cash und damit auf Vermögensverlauf, Endvermögen und Ruinalter.
Bedienung: Klick auf eine Übergangszelle der Cash-Zeile öffnet den Dialog „Uebergang: Cash".
Die Zelle zeigt 1:1, +100'000, −20'000 bzw. +100'000 / −20'000, solange offen ein ?.
Der Entscheid ist Teil des geführten Übergangs (3.5.4) und zählt im „offen"-Badge mit – eine
neu angelegte Phase erzeugt damit automatisch einen offenen Cash-Entscheid am neuen Übergang.
Referenz: src/components/ElementDetail.tsx (CashTransitionFields), src/components/PlanView.tsx
(CashTransitionDialog).
3.5.6 AHV-Prüfung am Pensions-Übergang
Die AHV-Rente hängt vom massgebenden durchschnittlichen Jahreseinkommen (mdJE) über die ganze Beitragsdauer ab. Diese Grösse kann das Tool nicht allein aus dem Plan bestimmen: Die Beitragspflicht beginnt mit 21, der Plan aber erst beim heutigen Alter. Bei einer 45-jährigen Person liegen 24 Beitragsjahre vor dem Planbeginn.
Deshalb ist die Beitragskarriere am Pensions-Übergang zu prüfen – dort, wo bereits die PK-Bezugsart und der 3a-Bezug entschieden werden. Der Dialog zeigt:
| Feld | Art |
|---|---|
| Geplantes Durchschnittseinkommen (aus dem Plan) | read-only, real |
| Beitragsjahre im Plan | read-only |
| Durchschnittseinkommen vor Planbeginn (real) | Eingabe – nur wenn Alter bei Planbeginn > 21 |
| Ausfalljahre vor Planbeginn | Eingabe – nur wenn Alter bei Planbeginn > 21 |
| Massgebendes durchschnittliches Jahreseinkommen | read-only, live berechnet |
| Resultierende AHV-Rente pro Jahr | read-only, live berechnet |
Die beiden Eingabefelder erscheinen also nur, wenn sie fachlich gebraucht werden. Die Zelle
zeigt Geprueft bzw. ?; der Entscheid zählt im „offen"-Badge mit. An allen anderen
Übergängen ist die AHV-Zelle inaktiv (–).
Wichtig zum Feld „vor Planbeginn": Der Wert ist real (heutige Kaufkraft). Der AHV-Kontoauszug listet Einkommen historisch-nominal – ein Lohn von 2008 steht dort mit dem Betrag von 2008 und wäre zu tief. Die Zahl stammt idealerweise aus der Rentenvorausberechnung, dort ist die Aufwertung bereits enthalten. Der Hilfetext im Feld sagt das.
Sonderfall „bei Planbeginn bereits pensioniert": Dann gibt es keinen Pensions-Übergang. Die gleichen Felder erscheinen stattdessen in der AHV-Phasenzelle der ersten Phase, zusammen mit der resultierenden Rente als Live-Vorschau.
Referenz: src/components/ElementDetail.tsx (AhvReviewFields), Formeln in
src/lib/calculations.ts (ahvMdje, ahvAnnualPension).
3.5.7 Warnhinweis bei Änderungen in früheren Phasen
Seit dem V3-Rework werden Werte live fortgeschrieben (Endwert = Startwert der Folgephase). Eine Änderung in einer frühen Phase wirkt damit bis ans Planende durch – oft unbemerkt. Seit Roadmap Nr. 3 gilt das verschärft: Ein geändertes Einkommen in Phase 1 verschiebt über das mdJE auch die AHV-Rente in Phase 5.
Beim Bearbeiten einer Phase, der noch Phasen folgen, erscheint deshalb ein rot abgesetzter Warnhinweis mit der Anzahl betroffener Folgephasen. Er erscheint in:
- Phasenzellen (Werte eines Elements in einer Phase)
- Phasen-Detail (Bezeichnung/Dauer – eine geänderte Dauer verschiebt alle Folgephasen)
Nicht in Übergangs-Dialogen (dort ist die Wirkung auf die Folgephase offensichtlich) und nicht beim Anlegen eines Elements (dort gibt es noch nichts zu überschreiben).
Referenz: src/components/ElementDetail.tsx (CarryWarning).
3.6 Auswertung und Visualisierung
3.6.1 Anzeigemodus nominal / beide / real
Ein Umschalter oben in der Planansicht steuert die Darstellung aller Geldbeträge in Matrix und Phasenköpfen:
| Modus | Darstellung |
|---|---|
| Nominal | 1'234'567 |
| Beide | 1'234'567 (890'123) – nominal, real in Klammern |
| Real | 890'123 |
„real" bedeutet kaufkraftbereinigt auf den Planbeginn: nominal / Deflator. Die Wahl wird in
localStorage unter fpt-value-mode gespeichert.
Es werden zwei verschiedene Deflatoren verwendet – siehe 4.5.3.
Referenz: src/components/PlanView.tsx Zeilen 665–699.
3.6.2 Zeitachse
Horizontale Achse über das Alter (von jüngster Person bis Planende) mit:
- den Lebensphasen als Segmente: Breite proportional zur Dauer, Einfärbung nach Phasentyp (Erwerb kräftig, Misch mittel, Pension hell), beschriftet mit Name, Dauer und – sofern ein Planstart gesetzt ist – der Jahresspanne (z. B. „2026–2046")
- Flaggen-Marker je Person am Pensionsalter (Farbe: Person A indigo, Person B hellblau)
- rotem „Ruin "-Marker, falls zutreffend
- Alters- und Jahres-Beschriftung an beiden Enden
Die Kalenderjahre stammen aus dem Profilfeld Planstart (Scenario.startYear). Ist es nicht
gesetzt, zeigt die Achse nur Alter – die Berechnung ist davon nie betroffen (siehe 3.2.7).
Referenz: src/components/Timeline.tsx.
3.6.3 Phasenkopf-Kennzahlen
Jeder Phasenkopf zeigt kompakt:
| Kennzahl | Bedeutung |
|---|---|
| Name + Status-Icon | grünes Häkchen oder rotes Warnsymbol (Liquiditätslücke) |
| Typ-Badge + Dauer | Erwerb / Pension / Misch, „N J." |
| Alter je Person | <Name> <StartAlter> → <EndAlter> |
| Einkommen | Jahr 1 → letztes Jahr |
| Ausgaben | Jahr 1 → letztes Jahr (nominal) |
| Quote bzw. Verzehr | Einkommen − Ausgaben; Label wechselt auf „Verzehr", wenn Jahr 1 negativ |
| Geplante Sparrate | 3a + Sparbeitrag + Amortisation + Tilgung, im ersten Phasenjahr |
| Geplante Verzehrrate | Summe der Bezugsraten |
| Kapitalzufluss | nur wenn > 0: Verkäufe + PK-/3a-Bezüge aus dem Übergang in diese Phase |
| Kapitalinvestitionen | nur wenn > 0: Zusatzinvestitionen + Sofort-Tilgungen |
| Einmaliger Zufluss | nur wenn > 0: Bezeichnung + Betrag (grün), aus dem Übergang in diese Phase |
| Einmalige Kosten | nur wenn > 0: Bezeichnung + Betrag (rot) |
| Vermögen | Start → Ende (inkl. Cash) |
Die Einmalposten stehen bewusst getrennt von Kapitalzufluss/-investitionen: Eine Erbschaft ist kein Verkaufserlös und ein Poolbau keine Kapitalinvestition – eine Vermischung würde die Kennzahl falsch beschriften.
Referenz: src/components/PlanView.tsx Zeilen 701–769.
3.6.4 Analyse-Bereich „Grafiken"
Die Auswertungen liegen nicht unter der Matrix, sondern in einem eigenen Bereich: Der Button Grafiken in der oberen Aktionsleiste (neben „Neues Szenario aus diesem" und „Monte-Carlo-Simulation") öffnet sie als breiten Dialog. So bleibt die Matrix die ruhige Hauptansicht.
Kennzahl-Karten: Endvermögen nominal und Endvermögen real. (Die frühere Karte „Geschätzter Nachlass" ist entfallen – sie war rechnerisch identisch mit dem nominalen Endvermögen und suggerierte eine zusätzliche Information, die es nicht gab.)
Grafik 1 – Einkommen vs. Ausgaben pro Jahr (SparquoteChart): Ein Datenpunkt pro Jahr
über alle Phasen. Grüne Linie = Einkommen nominal (inkl. Renten), rote Linie = Ausgaben nominal,
graue gestrichelte Linie = Ausgaben real. Die Fläche zwischen Einkommen und nominalen Ausgaben
ist grün (Sparquote) oder rot (Verzehr) eingefärbt. Der Keil zwischen roter und grauer Linie ist
anschaulich „das, was die Inflation frisst".
Grafik 2 – Vermögensverlauf nach Alter (WealthChart): Liniendiagramm über das Alter von
Person A. Je Szenario eine durchgezogene Linie (nominal) und eine gestrichelte (real).
Datenpunkte: jedes Planjahr (nicht nur die Phasengrenzen) – dadurch werden Verläufe
innerhalb einer Phase sichtbar, etwa das Abschmelzen im Kapitalverzehr. Grundlage ist
YearPoint.wealthNominal/wealthReal (4.10). Über Checkboxen lassen sich die Geschwister-
Szenarien überlagern; deren Daten werden bei Bedarf nachgeladen und im Client
zwischengespeichert.
Grafik 3 – Vermögensaufteilung pro Phase: Gestapeltes Balkendiagramm mit zwei Balken je Phase
(Beginn / Ende). Gestapelt werden alle Elemente der Kategorien PK, 3a, Immobilie, Sonstiges
Vermögen, die irgendwann einen positiven Wert haben. Stapelung nach elementId (nicht Name),
damit gleichnamige Elemente nicht kollidieren. Negative Werte werden auf 0 geklammert.
Referenz: src/components/Dashboard.tsx, src/components/WealthChart.tsx, src/components/SparquoteChart.tsx.
3.6.5 CSV-Export
GET /api/plans/<planId>/export liefert eine semikolon-getrennte CSV, eine Zeile pro Phase:
Phase;Typ;Dauer;Einkommen (Beginn);Ausgaben (Beginn);Quote (Beginn);Quote (Ende);Cash (Ende);Endvermoegen (nominal);Endvermoegen (real)
Bei vorhandenem Ruin folgt eine Schlusszeile Ruin: Kapital aufgebraucht mit Alter <N>.
Dateiname = Planname, nicht-alphanumerische Zeichen durch _ ersetzt.
Referenz: src/lib/calculations.ts Zeilen 620–648.
3.6.6 Analyse-Bereich „Einflussfaktoren"
Der Button Einflussfaktoren berechnen in der oberen Aktionsleiste (neben „Grafiken" und „Monte-Carlo-Simulation") öffnet die Sensitivitätsanalyse als eigenen Dialog. Aufbau bewusst analog zur Monte-Carlo-Simulation:
- Erklärung – was die Analyse beantwortet, wie sie rechnet, was man davon hat, und die zwei ehrlichen Grenzen (Bandbreiten-Abhängigkeit, keine Wechselwirkungen).
- Zielgrösse – Endvermögen real (Default) oder nominal.
- Parameter – je Treiber eine Checkbox; erst angehakt erscheinen die beiden Pflichtfelder „tief" und „hoch" in der Einheit des Treibers, mit Hilfe-Bubble zu plausiblen Bandbreiten. Nicht anwendbare Treiber werden gar nicht erst angezeigt.
- Ergebnis – Basisfall, Tornado-Chart und Tabelle.
Der Dialog ist bewusst nicht Teil des Grafiken-Bereichs: Dieser ist reine Ausgabe, während der Tornado Pflichteingaben braucht. Ein Hinweis am Ende der Parameterliste benennt, warum das Pensionsalter nicht enthalten ist (siehe 9.18).
Referenz: src/components/SensitivityDialog.tsx, src/components/AppShell.tsx.
3.7 Bedienoberfläche
3.7.1 Layout
- Sidebar (Desktop ab
lgfix, mobil als Overlay): Logo, „Übersicht", Planliste mit Phasenanzahl, „+"-Button für neuen Plan. - Header: Menü-Button (mobil), Plantitel, Profilmenü.
- Hauptbereich: Übersicht (Plan-Kacheln) oder Planansicht (Umschalter, Zeitachse, Grundprofil-Leiste, Aktionsbuttons, Ruin-Banner, Matrix, Detail-Panel, Dashboard).
3.7.2 Farbschemata
Drei Themes: Hell, Dunkel, Warm (cremefarben, Koralle-Akzent). Wahl im Profilmenü,
persistiert in localStorage (fpt-theme), gesetzt als data-theme am <html>. Ohne
gespeicherte Wahl folgt die Oberfläche prefers-color-scheme. Ein Inline-Script im <head>
setzt das Attribut vor dem ersten Paint (verhindert FOUC).
Alle Farben laufen über semantische CSS-Variablen (--bg, --surface, --accent, --danger, …).
Referenz: src/lib/theme.ts, src/app/globals.css, src/app/layout.tsx Zeilen 1181–1196.
3.7.3 Geldeingabefelder
Die MoneyInput-Komponente ist ein spezialisiertes Betragsfeld:
- Unfokussiert Anzeige mit Apostroph-Tausendertrennung (
1'234'567), fokussiert reine Ziffern - Ein Default-Wert 0 wird beim Fokussieren geleert, sonst der Text markiert
- Pfeiltasten: ↑/↓ = ±1, Shift+↑/↓ = ±100
- Pfeil-Buttons mit Klick-und-Halten-Beschleunigung: nach 400 ms Wiederholung im 70-ms-Takt, Schrittweite wächst mit der Haltedauer (1 → 10 → 100 → 1'000 → 10'000)
- Optionale harte Klammerung über
min(Default 0) undmax
Zahlenformat: Bewusst nicht über toLocaleString("de-CH"), weil dessen Trennzeichen das
typografische Apostroph (U+2019) ist. Das Tool verwendet durchgehend das gerade Apostroph.
Referenz: src/components/FormField.tsx Zeilen 60–200, src/lib/format.ts.
3.7.4 Hilfe-Bubbles
Feldbeschriftungen können ein Info-Icon tragen (InfoBubble), das per Hover oder Klick einen
Erklärtext einblendet. Wird durchgehend für die fachlich heiklen Felder verwendet.
3.7.5 Reaktivität
Die Anwendung rechnet nicht im Client. Jede Änderung führt zu einem PUT/PATCH/POST,
gefolgt von onChanged() → loadDetail(planId, silent = true). Der „silent"-Refresh lädt Plan
und Berechnung neu, ohne die PlanView zu demontieren – so bleibt die Scrollposition nach dem
Schliessen eines Popups erhalten.
Referenz: src/components/AppShell.tsx Zeilen 46–74.
4. Berechnungsmodell
Dies ist der fachliche Kern des Tools. Der gesamte Abschnitt beschreibt
src/lib/calculations.ts, FunktioncomputePlan(plan: PlanInput): PlanComputed. Die Funktion ist rein (keine I/O, keine Datenbank) und dadurch isoliert testbar.
4.1 Ablauf im Überblick
für jede Phase i (nach sequenceNumber sortiert):
1. Personen-Infos berechnen (Alter, erwerbstätig?), Phasentyp ableiten
2. AHV: Ausfalljahre kumulieren, Renten der Pensionierten berechnen, plafonieren
3. Element-Setup: je Element Startwerte/Raten bestimmen, in Arbeitslisten einordnen
4. Investitionen vom Cash abziehen → cashStart
5. Jahresschleife t = 1..duration:
Flows indexieren, Vermögen verzinsen, Bezugsraten entnehmen,
Cash fortschreiben, Ruin prüfen, YearPoint anfügen
6. Endwerte je Element setzen, Phasen-Kennzahlen zusammenstellen
7. Übergang: Entscheide anwenden, Carry aktualisieren, Cash der Folgephase bilden
4.2 Zustand über Phasengrenzen: der Carry
Zwischen den Phasen wird pro Element ein Carry-Objekt fortgeschrieben:
| Feld | Bedeutung |
|---|---|
status |
ACTIVE / SOLD / SETTLED |
value |
Aktiven-Saldo (PK / 3a / Sonstiges Vermögen) am Ende der Vorphase |
mortgage |
Immobilie: Resthypothek |
owed |
Schulden: Restschuld (positiv geführt) |
pkPensionAnnual |
PK: jährliche Rente nach Verrentung |
flowBasis |
Einkommen/Ausgaben: indexierter Basiswert der nächsten Phase |
hasCarry |
ob überhaupt eine Vorphase existiert (steuert read-only vs. Eingabe im UI) |
Zusätzlich laufen phasenübergreifend mit: cashCarryIn, cumulativeInflation,
gapYearsByPerson, yearsBefore, ruinAge, incomingInflow, incomingImmediateRepay.
Referenz: src/lib/calculations.ts Zeilen 103–115, 132–147.
4.3 Personen und Phasentyp
Für jede Person und jede Phase:
startAge = person.age + yearsBefore
endAge = startAge + duration
working = startAge < retirementAge
retiresAtStart = startAge === retirementAge
yearsBefore ist die Summe der Dauern aller vorangehenden Phasen. Alter wird also relativ
gezählt – es gibt keine Kalenderdaten im Modell, nur „Jahre ab Planbeginn".
maxDurationYears = Minimum über retirementAge − startAge aller noch erwerbstätigen Personen
(nur positive Werte), sonst null.
4.4 AHV-Rente
Die Rente hängt an zwei Achsen: der Beitragsdauer (Rentenskala 44) und dem massgebenden durchschnittlichen Jahreseinkommen (mdJE).
4.4.1 Die amtliche Rentenformel (Skala 44)
Alle Schwellen sind Vielfache von R0, dem Mindestbetrag der vollen Monatsrente
(AHV_MIN_MONTHLY_FULL = 1'260). Mit x = mdJE / (12 × R0):
mdJE ≤ 12 × R0 (= 15'120) → Rente = R0 (Mindestrente 1'260)
12 × R0 < mdJE ≤ 36 × R0 → Rente = R0 × (0.74 + 0.26 × x) Formel 1
36 × R0 < mdJE < 72 × R0 → Rente = R0 × (1.04 + 0.16 × x) Formel 2
mdJE ≥ 72 × R0 (= 90'720) → Rente = 2 × R0 (Maximalrente 2'520)
Der Wendepunkt liegt bei 36 × R0 = 45'360 → 1'915/Monat; beide Formelteile sind dort stetig.
Quelle und Verifikation: BSV/MAS „Berechnungsvorschriften der AHV/IV-Renten", gültig ab
1.1.2026 (liefert die Schwellenstruktur 12/36/72 × R0), und die amtliche Tabelle
318.117.1 – Monatliche Vollrenten, Skala 44. Die implementierte Formel reproduziert alle 51
Zeilen dieser Tabelle exakt; Stützstellen sind als Golden Tests hinterlegt (Kap. 8.2).
Die Funktion ahvMonthlyFullPension(mdJE) rechnet bewusst stetig. Amtlich wird das mdJE auf
Vielfache von 1.2 × R0 gestuft (daher die 51 Tabellenzeilen); für eine Planung ist der stetige
Wert näher an der Wahrheit, die Abweichung liegt unter 20/Monat.
4.4.2 Beitragskarriere und mdJE
Pro Person wird über die Phasen hinweg akkumuliert (AhvCareer):
| Feld | Bedeutung |
|---|---|
plannedAvgGrossIncome |
reales Brutto-Durchschnittseinkommen der Beitragsjahre im Plan (siehe 4.4.5) |
planYears |
Beitragsjahre im Plan = Σ (Phasendauer − Ausfalljahre der Phase) |
yearsBeforePlan |
max(0, Alter bei Planbeginn − 21) |
gapYearsInPlan |
Summe der Ausfalljahre im Plan |
Das mdJE ist der gewichtete Mittelwert über alle Beitragsjahre:
yearsBefore = max(0, yearsBeforePlan − gapYearsBefore)
mdJE = (avgIncomeBefore × yearsBefore + plannedAvgIncome × planYears)
/ (yearsBefore + planYears)
Ausfalljahre reduzieren die Gewichtung (und die Skala), nicht das Durchschnittseinkommen – genau wie in der echten AHV: Wer zwei Jahre aussetzt, hat deswegen kein tieferes Durchschnittseinkommen, aber weniger Beitragsjahre.
4.4.3 Warum real gerechnet wird
Sämtliche Einkommen gehen real (Kaufkraft bei Planbeginn) in das mdJE ein, und die Schwellen sind heutige Werte. Das ist kein Vereinfachungs-, sondern ein Genauigkeitsentscheid: Die echte AHV wertet vergangene Einkommen auf (Lohnindex) und indexiert die Schwellen (Mischindex). Beide Bewegungen heben sich in realer Betrachtung weitgehend auf – wer nominal mittelt und gegen heutige Schwellen hält, vergleicht Franken von 2046 mit Schwellen von 2026 und überschätzt die Rente systematisch.
Grössenordnung: 45-jährig, 85'000 Lohn, +1.5 %/Jahr bei 2 % Inflation, 20 Erwerbsjahre. Nominal gemittelt: mdJE 98'276 → Maximalrente 32'760. Real gemittelt: mdJE 81'156 → 31'096. Differenz 1'664/Jahr, über 25 Rentenjahre rund 41'600 – und der Fehler geht immer nach oben.
Die reale Berechnung eines Phasen-Durchschnitts erfolgt analytisch (avgRealFlow) als
geometrische Reihe mit q = (1 + Lohnerhöhung) / (1 + Inflation).
Bekannte Unschärfe: Die Schwellen folgen dem Mischindex, die Aufwertung dem Lohnindex. Da Löhne langfristig schneller steigen als Preise, ist die Deflationierung mit der Preisinflation leicht konservativ. Bewusst in Kauf genommen, statt eine dritte Indexannahme einzuführen.
4.4.4 Woher die Karriere-Werte kommen
| Situation | Quelle |
|---|---|
| Person retires innerhalb des Plans | transitionValues des AHV-Elements am Pensions-Übergang |
| Person bei Planbeginn bereits pensioniert | phaseValues des AHV-Elements in der ersten Phase |
| Prüfung noch nicht erfolgt | Fallback: avgIncomeBefore = plannedAvgIncome |
Der Fallback ist bewusst gewählt: Ohne erfassten Wert gilt der geplante Durchschnitt als Schätzung für die Jahre davor – exakt der Wert, den der Dialog vorbelegt. Ein Fallback auf 0 würde die Rente still und massiv zu tief rechnen (bei einer 45-jährigen Person auf rund 45 %).
Einkommen wird einer Person nur zugerechnet, wenn das INCOME-Element ihr zugeordnet ist. Bei
einem Einzelplan zählt „Gemeinsam" (HOUSEHOLD) zur Person A; bei einem Paar-Plan nicht
(siehe 9.9).
4.4.5 Netto-Brutto-Umrechnung für die AHV
Das Tool erfasst das Einkommen netto, die AHV bemisst sich am Bruttolohn. Beim Aufbau der Karriere wird deshalb hochgerechnet:
plannedAvgGrossIncome = avgRealFlow(...) × AHV_GROSS_FROM_NET_FACTOR // = 1.12
Sämtliche Werte in AhvCareer, im Prüf-Dialog und im mdJE sind damit brutto – auch das
Feld „Durchschnittliches Bruttoeinkommen vor Planbeginn", das der Benutzer aus der
Rentenvorausberechnung übernimmt (die ohnehin brutto-basiert ist). Eine Einheit im ganzen
Ablauf, keine Umrechnung an der Feldgrenze.
Herleitung des Faktors (Arbeitnehmer-Abzüge in % des Bruttolohns):
| Abzug | Satz | Bemerkung |
|---|---|---|
| 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 Hälfte |
Total ~9–13 % je nach Alter und Lohn → Faktor 1/(1−q) = 1.10 bis 1.16, Mittel 1.12.
Warum ein fixer Faktor genügt: Das mdJE ist selbst ein Durchschnitt über die ganze Beitragskarriere (44 Jahre). Der altersabhängige PK-Satz mittelt sich dabei heraus – ein Karriere-Faktor gegen einen Karriere-Durchschnitt ist konzeptionell stimmig, nicht bloss eine Näherung. Die Restunschärfe (~3 %) ist deutlich kleiner als der Fehler, den die Umrechnung behebt (~11 %, siehe 9.13).
Grenzen: Überobligatorische PK-Pläne, vom Arbeitgeber getragene NBU, Selbstständige und Löhne über 148'200 (ALV sinkt auf 0.5 %) weichen ab. Der Faktor ist heute eine nicht sichtbare Konstante; mit Roadmap Nr. 41 wird er in der Formel-Erklärung ausgewiesen.
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).
4.4.6 Jahresrente, Skala und Plafonierung
factor = max(0, (44 − Ausfalljahre total) / 44) // Rentenskala 44
rente = round(ahvMonthlyFullPension(mdJE) × 13 × factor)
AHV_PENSION_MONTHS = 13 – seit 1.1.2026 gibt es die 13. Altersrente (Art. 34bis AHVG).
Die Formel liefert Monatsrenten; der Jahresbetrag ist deshalb × 13, nicht × 12.
AHV_MAX_ANNUAL_SINGLE ist neu abgeleitet: 2 × R0 × 13 = 32'760.
Ehepaar-Plafonierung – nur bei householdType = COUPLE und wenn für beide Personen
eine Rente vorliegt:
cap = 32'760 × 1.5 = 49'140
falls (renteA + renteB) > cap:
beide Renten proportional kürzen: rente × cap / summe
Die Rente ist danach nominal fix – sie wird über die Phasen hinweg nicht indexiert und
verliert damit real an Kaufkraft (siehe 9.11).
Sie fliesst in renteTotal und wird im Einkommen mitgeführt.
Referenz: src/lib/calculations.ts (ahvMonthlyFullPension, ahvMdje, ahvAnnualPension),
src/lib/constants.ts.
4.5 Nominal, real und die Deflatoren
4.5.1 Das V5-Modell
| Grösse | Erfassung | Indexierung über die Phasenjahre |
|---|---|---|
| Einkommen | nominal | basis × (1 + Lohnerhöhung)^(t−1) |
| Renten (AHV, PK) | nominal | keine – konstant |
| Ausgaben | real | real: basis × (1 + reale Mehrausgaben)^(t−1), dann × kumulierte Inflation |
Die Inflation ist seit V5 plan-weit (plan.inflationRateDefault) und gilt einheitlich für
alle Phasen; eine phasenspezifische Überschreibung existiert nicht (mehr).
4.5.2 Kumulierte Inflation
cumInflStart(Phase 1) = 1
cumInflStart(Phase n) = cumInflStart(Phase n−1) × (1 + infl/100)^duration(n−1)
4.5.3 Die drei Deflatoren
Ein subtiler, aber wichtiger Punkt: Bestandswerte und Flow-Werte haben am Phasenende nicht
denselben Deflator, weil ein Flow im Jahr duration anfällt, ein Bestand aber nach dem
Jahr duration gemessen wird.
| Deflator | Formel | Verwendung |
|---|---|---|
cumulativeInflationStart |
s. o. | Bestände zu Phasenbeginn, Flows im Jahr 1 |
cumulativeInflationEnd |
cumInflStart × (1+infl)^duration |
Bestände am Phasenende (Cash, Vermögen) |
flowDeflatorEnd |
cumInflStart × (1+infl)^(duration−1) |
Flow-Endwerte (Einkommen, Ausgaben, Quote) – eine Kaufkraft-Stufe weniger |
Referenz: src/lib/calculations.ts Zeilen 427–429; Anwendung in src/components/PlanView.tsx Zeilen 686–687, 716–717.
4.6 Element-Setup je Kategorie
Für jedes Element (sortiert nach orderIndex) wird ein ElementPhaseComputed erzeugt.
Vorab-Abbruch: Ist der Carry-Status SOLD → Notiz „Verkauft"; ist er SETTLED und die Kategorie
OTHER_DEBT → „Getilgt". Solche Elemente werden nicht weiter gerechnet.
4.6.1 INCOME / EXPENSE
idx = phaseData.teuerungsausgleich ?? 0
baseValue = hasCarry ? round(carry.flowBasis) : round(phaseData.amount)
basis = !hasCarry → round(phaseData.amount)
phaseData.amount ist Zahl → round(phaseData.amount) // bewusster Override
sonst → baseValue // live vererbt
carry.flowBasis = basis × (1 + idx/100)^duration // für die Folgephase
Die Vererbungsregel (src/components/ElementDetail.tsx Zeilen 480–490): Beim Speichern wird
das Feld amount gelöscht, wenn es exakt dem fortgeschriebenen Wert entspricht. Dadurch
bleibt der Wert „live vererbt" – eine spätere Änderung in einer früheren Phase wirkt sich weiter
durch. Nur ein bewusst abweichender Wert wird fix gespeichert.
Man beachte den Exponenten-Unterschied: der Endwert der Phase nutzt duration − 1
(letztes Jahr), der Carry für die Folgephase nutzt duration (ein Jahr weiter).
4.6.2 AHV
Erwerbstätig → nur Zusammenfassung („N Ausfalljahre" / „Keine Ausfalljahre"), kein Wert.
Pensioniert → startValue = endValue = rente, Summand in renteTotal.
4.6.3 PENSION_FUND
| Fall | Verhalten |
|---|---|
pensioniert, pkPensionAnnual > 0 |
Rente: startValue = endValue = pkPensionAnnual, Summand in renteTotal |
| pensioniert, keine Rente | Notiz „Vollständig bezogen" |
| erwerbstätig | base = hasCarry ? carry.value : phaseData.currentValue; topUp = hasCarry ? additionalInvestment : 0; start = base + topUp; Rate = annualContribution |
Die PK-Rate wird nicht zu fixedRatesTotal addiert – sie belastet das Cash nicht.
topUp wird ab Phase 2 zu investmentsFromCash addiert.
4.6.4 PILLAR_3A
Identisch zu PK, mit zwei Unterschieden:
- Die Rate wird zu
fixedRatesTotaladdiert (belastet das Cash). - Ist der Besitzer pensioniert, gibt es nie eine Rente, nur „Vollständig bezogen".
4.6.5 REAL_ESTATE (Immobilie)
purchase = hasCarry ? carry.propertyPurchase : round(phaseData.purchasePrice) // Kaufpreis
valueStart = hasCarry ? carry.propertyValue : round(phaseData.purchasePrice) // Verkehrswert
mortgage = hasCarry ? carry.mortgage : round(phaseData.mortgage)
amort = round(phaseData.amortization) // wird JÄHRLICH am Restsaldo gekappt
equity = valueStart − mortgage → startValue, wealthStart
Kaufpreis und Verkehrswert laufen getrennt. Der Verkehrswert wächst mit valueGrowth, der
Kaufpreis bleibt der ursprüngliche – er ist die Basis der Grundstückgewinnsteuer beim Verkauf
(4.9.4). Beide werden über die Phasen fortgeschrieben (carry.propertyValue,
carry.propertyPurchase); die Hypothek läuft als Saldo in der Jahresschleife (4.7).
Die Wertsteigerung wirkt auf die Liegenschaft, nicht auf das Eigenkapital. Das ist der Hebel, der Wohneigentum finanziell auszeichnet: 1 % von 1'000'000 sind 10'000 im Jahr – bei einem Eigenkapital von 100'000 also 10 % darauf. Würde man die Wertsteigerung auf das Eigenkapital rechnen, ergäbe dieselbe Annahme nur 1'000 im Jahr:
Kaufpreis 1'000'000 · Hypothek 900'000 · EK 100'000 · 1 %/J. · Amortisation 10'000/J. · 10 J. → korrekt 304'622; auf das Eigenkapital gerechnet nur 210'462. Differenz 94'160, und sie wächst mit dem Belehnungsgrad.
Der angezeigte Elementwert bleibt das Eigenkapital (Verkehrswert − Restschuld); nur die
Basis der Verzinsung ist die Liegenschaft.
Falls nicht fortgeschrieben und nicht Phase 1 (= Neukauf in einer späteren Phase):
investmentsFromCash += max(0, equity) – das Eigenkapital wird aus dem Cash finanziert.
4.6.6 OTHER_ASSET
base = hasCarry ? carry.value : round(phaseData.startValue)
topUp = hasCarry ? round(phaseData.additionalInvestment) : 0
start = base + topUp
rate = round(phaseData.annualContribution) → fixedRatesTotal += rate
withdrawal = round(phaseData.annualWithdrawal) → plannedWithdrawTotal += withdrawal
4.6.7 OTHER_DEBT
owedStart = hasCarry ? carry.owed : round(phaseData.startValue)
repay = round(phaseData.annualRepayment) // wird JÄHRLICH am Restsaldo gekappt
startValue = −owedStart // negatives Vorzeichen im Vermögen
Wie bei der Immobilie ist die Restschuld ein laufender Saldo; ist sie getilgt, entfällt die Tilgungsrate.
4.7 Die Jahresschleife
Zunächst wird der Cash-Startwert gebildet:
cash = cashCarryIn − (isFirstPhase ? 0 : investmentsFromCash)
cashStart = cash
Die Investitionen werden also am Phasenanfang abgezogen. Grund (Kommentar im Code, Fix in
Commit f768e01): Der Cash-Startwert zeigt damit den Bestand nach den Investitionen – die
investierten Mittel erscheinen im Vermögen und nicht doppelt auch im Cash.
Dann für t = 1 .. duration:
// 1. Einkommen (nominal)
incomeFlow = renteTotal + Σ (inc.basis × (1 + inc.idx/100)^(t−1))
// 2. Ausgaben (real → nominal) + Hypothekarzins
inflFactor = cumInflStart × (1 + infl/100)^(t−1)
expenseRealBase = Σ (exp.basis × (1 + exp.idx/100)^(t−1))
interestNominal = Σ (re.mortgage × re.interestRate/100) // nur wo interestHandling = ADD
expenseNominal = expenseRealBase × inflFactor + interestNominal
expenseReal = expenseRealBase + interestNominal / inflFactor
// 3. Quote
quote = incomeFlow − expenseNominal
// 4. YearPoint anlegen (year, age Person A, income, expenseNominal, expenseReal);
// wealthNominal/wealthReal werden nach Schritt 8 nachgetragen
// 5. Vermögen: verzinsen, Sparbeitrag, Bezugsrate
für jedes Asset a:
grown = a.value × (1 + a.r/100) + a.rate // Zins zuerst, dann Einzahlung (nachschüssig)
w = min(a.withdrawal, max(0, grown)) // Bezug am Bestand gekappt
a.value = grown − w
cashFromWithdraw += w
// 6. Amortisation und Tilgung – jeweils am Restsaldo gekappt
debtRates = 0
für jede Immobilie re:
pay = min(re.amort, re.mortgage) // nie mehr als die Restschuld
re.mortgage −= pay
debtRates += pay
re.value ×= (1 + re.growth/100) // Wertsteigerung auf die LIEGENSCHAFT
für jede Schuld d:
pay = min(d.repay, d.owed)
d.owed −= pay
debtRates += pay
falls t === 1 → plannedSaveRate = fixedRatesTotal + debtRates
// 7. Cash fortschreiben
cash += quote − fixedRatesTotal − debtRates + cashFromWithdraw
falls cash < 0 → cashNegative = true
// 8. Ruin prüfen (Gesamtvermögen zum Jahresende)
total = cash + Σ asset.value + Σ (re.value − re.mortgage) + Σ (−d.owed)
yearPoint.wealthNominal = round(total) // Grundlage des Verlaufs je Jahr
yearPoint.wealthReal = round(total / cumInfl[Jahr])
falls ruinAge === null und total < 0 → ruinAge = age(Person A) + yearsBefore + t
Wichtige Details:
- Verzinsung ist nachschüssig: der Sparbeitrag des Jahres wird nicht mitverzinst.
- Amortisation und Tilgung enden mit der Schuld. Hypothek und Restschuld sind laufende
Salden; die Rate ist pro Jahr auf den Restsaldo gekappt (
min(rate, saldo)). Ist die Schuld abbezahlt, fliesst kein Franken mehr ab – weder aus dem Cash noch in die Sparrate. Im letzten Zahlungsjahr wird nur noch der Restbetrag fällig, nicht die volle Rate. plannedSaveRate(Kopf-Kennzahl) ist die tatsächliche Rate des ersten Phasenjahres. In späteren Jahren kann sie tiefer liegen, wenn eine Schuld ausläuft.cashNegativewird gesetzt, sobald der Cash-Bestand irgendwann innerhalb der Phase unter 0 fällt – auch wenn er am Phasenende wieder positiv ist.- Der Ruin bezieht sich auf das Gesamtvermögen inkl. Immobilien, nicht auf das Cash.
ruinAgewird nur einmal gesetzt (erstes Auftreten, plan-weit).
Referenz: src/lib/calculations.ts Zeilen 365–425.
4.8 Endwerte und Phasen-Kennzahlen
Einkommen: startValue = basis
endValue = basis × (1 + idx/100)^(duration−1)
Ausgaben: startValue = basis × cumInflStart
endValue = basis × (1 + idx/100)^(duration−1) × flowDeflatorEnd
Assets: endValue = a.value (nach der Jahresschleife)
Immobilie: endValue = re.value − re.mortgage (Verkehrswert inkl. Wertsteigerung, minus Restschuld)
Schulden: endValue = −d.owed (0, falls getilgt; + Notiz „Wird getilgt")
Aggregate:
startWealthNominal = Σ Element-Startwerte + cashStart
endWealthNominal = Σ Element-Endwerte + cashEnd
endWealthReal = endWealthNominal / cumulativeInflationEnd
isConsumption = quotaStart < 0
incomplete = cashNegative // „roter Status" = Liquiditätslücke
capitalInflow = incomingInflow // aus dem Übergang IN diese Phase
capitalInvest = investmentsFromCash + incomingImmediateRepay
oneOffInflow = incomingOneOffInflow // einmaliger Zufluss (netto nach Steuer)
oneOffOutflow = incomingOneOffOutflow // einmalige Kosten (nominal)
4.9 Der Übergang
Nach jeder Phase (auch nach der letzten) läuft die Übergangs-Logik. Sie liest
transitionValues[phase.id] – der Übergang ist also am Von-Phasen-Schlüssel gespeichert.
ownerRetiresNext = owner existiert
∧ nextPhase existiert
∧ owner ist in DIESER Phase erwerbstätig
∧ owner.age + yearsBefore + duration >= owner.retirementAge
INCOME/EXPENSE sowie nicht-aktive Elemente überspringen die Logik (nur hasCarry = true).
4.9.1 PENSION_FUND
Pensions-Übergang (ownerRetiresNext), value = ec.endValue, Default-Modus PENSION:
payoutMode |
Wirkung |
|---|---|
CAPITAL |
txInflow += round(value × (1 − capitalTaxRate/100)); carry.value = 0; pkPensionAnnual = 0 |
PENSION |
carry.pkPensionAnnual = round(value × conversionRate / 100); carry.value = 0 |
COMBI |
capital = min(value, capitalAmount); txInflow += round(capital × (1 − tax/100)); pkPensionAnnual = round((value − capital) × conversionRate / 100); carry.value = 0 |
Normaler Übergang (Vorbezug) – brutto entnommen, netto ins Cash:
withdrawal = min(ec.endValue, round(td.withdrawal)) // brutto
carry.value = ec.endValue − withdrawal
txInflow += round(withdrawal × (1 − capitalTaxRate/100))
4.9.2 PILLAR_3A
Pensions-Übergang: immer vollständiger Bezug –
txInflow += round(ec.endValue × (1 − capitalTaxRate/100)); carry.value = 0.
Normaler Übergang (Vorbezug): wie PK – Bruttoentnahme, Netto-Zufluss nach
Kapitalbezugssteuer.
4.9.3 OTHER_ASSET
decision |
Wirkung |
|---|---|
SELL |
txInflow += ec.endValue; carry.status = "SOLD" (kein Steuerabzug) |
PARTIAL |
part = min(ec.endValue, partialSaleAmount); txInflow += part; carry.value = ec.endValue − part – Rest bleibt aktiv und wächst weiter |
HOLD / sonst |
carry.value = ec.endValue |
Der Teilverkauf (Roadmap Nr. 42) fliesst über txInflow ins Cash der Folgephase und erscheint
dort im Phasenkopf als „Kapitalzufluss" (capitalInflow) – das „zu investierende Kapital".
Er ist die einmalige Entnahme am Übergang; die laufende Entnahme während einer Phase
bleibt die jährliche Bezugsrate (annualWithdrawal, 4.6.6). Beide dürfen koexistieren.
4.9.4 REAL_ESTATE
Gelesen werden die laufenden Werte aus der Jahresschleife (re.value, re.mortgage,
re.purchase).
decision = "SELL":
gain = max(0, salePrice − re.purchase) // URSPRÜNGLICHER Kaufpreis, nicht der Verkehrswert
tax = gain × (saleTaxRate / 100)
txInflow += round(salePrice − re.mortgage − tax)
carry.status = "SOLD"
Der Nettoerlös ist Verkaufspreis minus Hypothekenablösung minus Grundstückgewinnsteuer. Ein
Verlustverkauf erzeugt keine Steuer (gain bei 0 geklammert).
Die Steuer bemisst sich am ursprünglichen Kaufpreis, nicht am zwischenzeitlich gestiegenen Verkehrswert – deshalb führt das Modell beide getrennt (4.6.5). Nicht modelliert sind wertvermehrende Investitionen und die Haltedauer-Abstufung (Roadmap Nr. 23).
Halten (decision ≠ SELL) – mit optionaler Sonderamortisation (Roadmap Nr. 15):
extra = min(re.mortgage, extraAmortization) // am Restsaldo gekappt
txImmediateRepay += extra // Einmaltilgung aus dem Cash
carry.mortgage = re.mortgage − extra
carry.propertyValue / carry.propertyPurchase // fortgeschrieben
Die Einmaltilgung bringt REAL_ESTATE auf Augenhöhe mit OTHER_DEBT (dort schon
immediateRepayment, 4.9.5): Sie senkt die Restschuld und damit die künftige Zinslast und
erscheint im Phasenkopf als „Kapitalinvestition" (capitalInvest). Damit lässt sich die
indirekte Amortisation mechanisch nachbilden – 3a wachsen lassen, bei Pensionierung ins
Cash beziehen, dann per Sonderamortisation die Hypothek tilgen.
Die Steuerwirkung der indirekten Amortisation (3a-Abzug, erhaltener Zinsabzug) ist bewusst nicht modelliert; sie gehört zum zurückgestellten Steuer-Bündel (siehe 9.14).
4.9.5 OTHER_DEBT
carry.owed = −ec.endValue
immediate = min(carry.owed, round(td.immediateRepayment))
falls immediate > 0:
carry.owed −= immediate
txImmediateRepay += immediate
falls carry.owed === 0 → carry.status = "SETTLED"
4.9.6 Cash: einmalige Sonderein-/ausgaben
Gelesen wird phase.cashTransition – der Entscheid hängt an der Von-Phase. Er wird nur
ausgewertet, wenn eine Folgephase existiert; nach der letzten Phase gibt es keinen Übergang,
ein dort erfasster Betrag bleibt wirkungslos.
Der Umrechnungskurs zwischen real und nominal ist an dieser Grenze cumulativeInflation, also
der Bestands-Deflator am Phasenende (vgl. 4.5.3) – denn Cash ist
ein Bestand, und das Ereignis fällt exakt auf die Grenze.
mode = cashTransition.mode ?? "NONE"
falls mode ∈ {INFLOW, BOTH}: // nominal erfasst
brutto = round(inflowAmount)
txOneOffInflow = round(brutto × (1 − inflowTaxRate/100))
falls mode ∈ {OUTFLOW, BOTH}: // real erfasst
txOneOffOutflow = round(outflowAmount × cumulativeInflation)
Beide Grössen fliessen ausschliesslich ins Cash der Folgephase (4.9.7) und nicht in die
Jahresschleife – damit bleiben sie per Konstruktion aus Einkommen, Ausgaben und Quote heraus.
Ein Zufluss/eine Kostenposition, die das Cash unter 0 drückt, wird über den bestehenden
Startwert-Check der Folgephase (cashNegative = cash < 0) automatisch als Liquiditätslücke
erkannt.
4.9.7 Abschluss des Übergangs
cashCarryIn = cashEnd + txInflow + txOneOffInflow − txImmediateRepay − txOneOffOutflow
incomingInflow = txInflow // Kopf-Kennzahlen der Folgephase
incomingImmediateRepay = txImmediateRepay
incomingOneOffInflow = txOneOffInflow // inkl. Bezeichnung
incomingOneOffOutflow = txOneOffOutflow // inkl. Bezeichnung
yearsBefore += duration
4.10 Ergebnisstruktur
PlanComputed {
phases: PhaseComputed[] // alle Kennzahlen je Phase, inkl. elements[]
yearly: YearPoint[] // ein Punkt pro Jahr über alle Phasen (inkl. Vermögen je Jahr)
nachlass: number // = endWealthNominal der letzten Phase, sonst 0
ruinAge: number | null // Alter Person A beim ersten Gesamtvermögen < 0
}
4.11 Systemparameter
Zentral in src/lib/constants.ts geführt, weil sie sich periodisch durch Bundesanpassungen ändern:
| Konstante | Wert | Bedeutung |
|---|---|---|
AHV_MIN_MONTHLY_FULL |
1'260 | R0 – Mindestbetrag der vollen Monatsrente (Skala 44). Alle Schwellen der Rentenformel sind Vielfache davon |
AHV_PENSION_MONTHS |
13 | Rentenzahlungen pro Jahr – 13. Altersrente ab 1.1.2026 |
AHV_CONTRIBUTION_START_AGE |
21 | Beitragspflicht ab 1. Januar nach dem 20. Geburtstag |
AHV_MAX_ANNUAL_SINGLE |
32'760 | abgeleitet: 2 × R0 × 13 |
AHV_GROSS_FROM_NET_FACTOR |
1.12 | Netto → Brutto für die AHV; Herleitung siehe 4.4.5 |
AHV_COUPLE_CAP_FACTOR |
1.5 | Ehepaar-Plafonierung: 150 % der Einzel-Maximalrente |
AHV_FULL_CONTRIBUTION_YEARS |
44 | Volle Beitragsdauer (Rentenskala 44) |
PILLAR_3A_MAX_ANNUAL |
7'258 | Max. 3a-Beitrag/Jahr für PK-Versicherte (2026) |
DEFAULT_PK_CONVERSION_RATE |
6 % | Umwandlungssatz |
DEFAULT_CAPITAL_TAX_RATE |
8 % | Kapitalbezugssteuer |
DEFAULT_PROPERTY_GAINS_TAX_RATE |
20 % | Grundstückgewinnsteuer |
Die drei Default-Sätze werden sowohl als UI-Vorschlag als auch in der Berechnung als Fallback
verwendet (num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE)). Grund laut Code-Kommentar: Damit
ein nicht angetippter Wert nicht fälschlich als 0 gerechnet wird.
4.12 Monte-Carlo-Simulation
Die deterministische Berechnung nimmt pro Anlage eine feste Rendite und eine feste Inflation
an. Real schwanken beide. Die Monte-Carlo-Simulation (Roadmap Nr. 19, Stufe A, montecarlo.ts)
würfelt viele tausend mögliche Verläufe und weist die Erfolgswahrscheinlichkeit des Plans aus.
4.12.1 Die Nahtstelle in computePlan
computePlan(plan, sample?) nimmt optional ein PlanSample:
interface PlanSample {
inflation: number[]; // Inflation %/Jahr (Index 0 = Jahr 1)
assetReturn: (elementId: string, year: number) => number; // Rendite %/Jahr (1-basiert)
}
Ohne sample rechnet die Funktion exakt wie bisher (die geplanten Annahmen). Mit sample
liefert sie einen einzelnen simulierten Pfad. Voraussetzung war eine Umstellung der Inflation
auf ein kumulatives Deflator-Array cumInfl[] (statt der geschlossenen (1+i)^t-Formel),
damit die Inflation pro Jahr variieren kann – deterministisch bitgenau identisch, durch die
Golden Tests abgesichert. Die AHV-Karriere bleibt bewusst auf der festen Plan-Inflation (sie
ist eine Real-Grösse auf Planungsbasis, sie wird nicht mitgewürfelt).
Architektur-Vorteil: computePlan ist eine reine Funktion ohne Server-Abhängigkeiten und
läuft damit im Browser. Die gesamte Simulation rechnet client-seitig – null Serverlast.
~10'000 Läufe in rund 1 Sekunde; die Ausführung gibt alle 500 Läufe die Kontrolle ab
(Fortschrittsbalken, keine eingefrorene Oberfläche).
4.12.2 Das statistische Modell
Pro Jahr ein gemeinsamer Marktschock z_markt; je Element und Jahr:
rendite = mittelwert + σ × (ρ × z_markt + √(1−ρ²) × z_eigen)
z_markt,z_eigen: standardisierte Student-t (ν = 5) – „fette Ränder", damit Extremcrashs realistisch häufig auftreten. Eine Normalverteilung macht ein −40%-Jahr zu einem 1-in-250-Ereignis; real ist es ~1-in-15. Standardisiert auf Einheitsvarianz → die eingegebene Standardabweichung σ bleibt die tatsächliche.ρ = 0.7→ Korrelation zweier riskanter Anlagen ≈ 0.5: alle riskanten Anlagen fallen im Crash gemeinsam. Unabhängiges Würfeln würde das Absturzrisiko systematisch unterschätzen.- Böden:
max(0, rendite)für PK/3a (schreiben keine negative Rendite gut),max(−100, rendite)sonst.
Die Inflation wird analog gezogen (eigener Student-t-Schock, Mittelwert + σ), unabhängig vom Marktschock. Der Zufallsgenerator ist seedbar (reproduzierbare Läufe).
4.12.3 Zwei Renditezahlen — und warum
Pro renditetragendem Element (PK, 3a, Sonstiges Vermögen, Immobilie) gibt es im Simulations-Dialog zwei Renditen mit verschiedenen Rollen:
| Zahl | Rolle |
|---|---|
| Geplante Rendite (im Plan) | zeichnet die deterministische Linie = der Zielbalken |
| Historische Ø-Rendite (im Dialog, Pflicht) | der Mittelpunkt, um den die Simulation streut |
Ohne diese Trennung wäre die Kennzahl „P(erreiche mein geplantes Endvermögen)" immer ~50 %, egal welche Rendite man annimmt (der Zielbetrag wüchse ja mit). Erst weil die Simulation um die historische Rendite streut, während der Zielbalken auf der geplanten steht, wird ein konservativer Plan (tiefe Planannahme) korrekt mit einer höheren Erfolgsquote belohnt als ein optimistischer. Analog auf Plan-Ebene für die Inflation.
Ehrliche Grenze (im Dialog ausgewiesen): Die Simulation misst das Risiko um deine Annahmen herum – sie beurteilt nicht, ob deine Mittelwerte realistisch sind. Ein zu optimistischer Mittelwert liefert eine beruhigende, aber unrealistische Erfolgsquote (siehe 9.15).
4.12.4 Streuungsstufen
| Stufe (Rendite) | σ | Beispiele | Stufe (Inflation) | σ | |
|---|---|---|---|---|---|
| Sehr niedrig | 3 % | Staatsanleihen, Geldmarkt | Sehr niedrig | 1 % | |
| Niedrig | 6 % | Immobilien, defensive Mischportfolios | Niedrig | 2 % | |
| Moderat | 15 % | breit diversifizierte Aktien-ETFs/Fonds | Manuell | frei | |
| Hoch | 25 % | Einzelaktien, Branchen-/Schwellenländerfonds | |||
| Sehr hoch | 55 % | Kryptowährungen, hochspekulative Anlagen | |||
| Manuell | frei | eigene Eingabe |
Default-Stufe je Typ: PK → sehr niedrig · 3a/Immobilie → niedrig · Sonstiges Vermögen → moderat. Bei Inflation gibt es bewusst nur zwei Stufen (höhere wären Hyperinflations-Annahmen).
Quellen der σ-Werte: Anleihen ~6 %, globale Aktien ~15–18 %, Schweizer Immobilien(fonds) ~2 %, Bitcoin ~54 %, Schweizer Inflation SD der letzten 20 J. ~1 %. Belege: BSV/Weltbank sowie Markt-/Volatilitätsstatistiken (recherchiert 2026-07-17).
4.12.5 Ergebnis
| Kennzahl | Bedeutung |
|---|---|
| Ruinwahrscheinlichkeit | Anteil der Läufe mit ruinAge !== null (Vermögen fällt vor Planende unter 0) |
| Erfolgswahrscheinlichkeit | Anteil der Läufe mit Endvermögen ≥ Zielbetrag (nominal, vorbelegt mit dem geplanten Nachlass) |
| Fächer | je Alterspunkt (Phasengrenzen) das 10-/50-/90-Perzentil des Vermögens; dazu die deterministische Planungslinie |
Der Median liegt typischerweise unter der deterministischen Linie – der „Volatilitäts-Drag"
(geometrisch ≈ arithmetisch − σ²/2) macht sichtbar, dass die glatte Ein-Zahl-Planung schon
leicht zu optimistisch ist. Pflichtfelder: ohne die historischen Ø-Werte startet die Simulation
nicht.
4.12.6 Mehrere Szenarien im Vergleich
Der Dialog rechnet auf Wunsch mehrere Szenarien desselben Plans in einem Lauf. Drei Entscheide machen den Vergleich überhaupt aussagekräftig.
(1) Eine Parametereingabe je logischem Element. Die MC-Parameter hängen an der elementId,
und Element-IDs sind szenario-spezifisch – eine Kopie bekommt neue IDs. Ohne Zuordnung müsste
dieselbe Anlage pro Szenario erneut erfasst werden. Das wäre nicht nur mühsam, es würde den
Vergleich zerstören: Mit 5 % im einen und 6 % im anderen Szenario vergleicht man die
Eingaben statt der Szenarien.
Die Zuordnung läuft über die Herkunfts-Kette sourceElementId – dieselbe Grundlage wie beim
Diff (3.2.6). resolveRootElementId folgt ihr bis zum
Ursprung; alle Elemente mit derselben Wurzel bilden eine Gruppe und teilen einen
Parametersatz. Deshalb lädt der Dialog beim Öffnen alle Szenarien des Plans, nicht nur die
ausgewählten: Nur so löst sich die Kette auch über ein übersprungenes Zwischen-Szenario auf
(Basis → S1 → S2 bei Auswahl von Basis und S2). Ein Element, das es nur in einem Szenario gibt,
bildet eine eigene Gruppe und wird im Dialog entsprechend gekennzeichnet.
(2) Gemeinsamer Seed. Alle Szenarien eines Laufs verwenden denselben Zufalls-Seed (Common Random Numbers). 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 87 % und 90 % zeigen. Mit gemeinsamem Seed teilen strukturgleiche Szenarien exakt dieselben Marktpfade, und die Unterschiede sind rein strukturell. Einschränkung: Die Pfade sind nur dort identisch, wo die Struktur es ist – abweichende Laufzeit oder Elementzahl verschiebt die Ziehungsreihenfolge.
(3) Zielbetrag je Szenario. Der Zielbetrag ist bewusst nicht gemeinsam, sondern je Szenario mit dessen geplantem Endvermögen vorbelegt (einzeln editierbar). Damit misst die Erfolgswahrscheinlichkeit, wie oft ein Szenario sein eigenes Versprechen hält.
Warum das der entscheidende Punkt ist: In der Simulation wird die geplante Rendite vollständig durch die gewürfelte ersetzt. Unterscheiden sich zwei Szenarien nur in der geplanten Rendite (5 % vs. 6 %), sind ihre simulierten Verteilungen identisch – gleicher Median, gleicher Fächer, gleiche Ruinwahrscheinlichkeit. Der einzige Unterschied ist der Zielbetrag. Mit einem gemeinsamen Zielbetrag zeigte der Vergleich zwei identische Zeilen; mit szenario-eigenem Zielbetrag zeigt er die eigentliche Aussage: Das pessimistisch geplante Szenario erreicht sein tieferes Ziel häufiger und ist damit das belastbarere. Durch einen Test abgedeckt (Kap. 8.2).
Folge für die Darstellung: Die Ruinwahrscheinlichkeit ist zielbetrags-unabhängig und damit die direkt vergleichbare Kennzahl; die Erfolgswahrscheinlichkeit bezieht sich je Zeile auf eine andere Messlatte. Deshalb steht der Zielbetrag als eigene Spalte in der Vergleichstabelle.
Darstellung: eine Vergleichstabelle (Szenario, Ziel, Erfolg, Ruin, P10/Median/P90) als Hauptinstrument, dazu ein Chart mit der Median-Linie je Szenario. Übereinandergelegte 10–90 %-Bänder wären unlesbar; der vollständige Fächer inklusive deterministischer Linie erscheint deshalb nur, wenn genau ein Szenario ausgewählt ist – dann verhält sich der Dialog exakt wie zuvor.
Laufzeit: Die Szenarien laufen sequenziell, der Fortschritt weist Szenario und Gesamtanteil aus. Die Schätzung skaliert mit der Anzahl Szenarien.
Referenz: src/lib/montecarlo.ts (resolveRootElementId, buildElementGroups,
paramsForScenario, runMonteCarloMulti), src/components/MonteCarloDialog.tsx.
4.13 Sensitivitätsanalyse (Tornado)
Die Monte-Carlo-Simulation würfelt alle Unsicherheiten gleichzeitig und beantwortet „wie
wahrscheinlich geht mein Plan auf?". Die Sensitivitätsanalyse (Roadmap Nr. 20, sensitivity.ts)
beantwortet die komplementäre Frage: „Welche meiner Annahmen entscheidet überhaupt über das
Ergebnis?"
4.13.1 Verfahren
One-at-a-time (OAT):
base = Zielgrösse(Plan)
für jeden ausgewählten Treiber d:
lowResult = Zielgrösse(applyDriver(Plan, d, d.low))
highResult = Zielgrösse(applyDriver(Plan, d, d.high))
swing = |highResult − lowResult|
sortiere absteigend nach swing → Trichterform, längster Balken zuoberst
Alle übrigen Parameter bleiben dabei auf dem Planwert. Das sind 2 Aufrufe je Treiber – bei
sieben Treibern 14 computePlan-Aufrufe, also Millisekunden. Wie die Monte-Carlo-Simulation
läuft alles im Browser; applyDriver ist rein und lässt den Ausgangsplan unberührt.
Zielgrösse ist das Endvermögen der letzten Phase, wahlweise real (Default,
kaufkraftbereinigt) oder nominal. Das Ruinalter wäre als Balkengrösse untauglich, weil es in
vielen Plänen null ist.
4.13.2 Die Treiber und ihre Einheiten
Die Einheit ist je Treiber verschieden und lässt sich nicht vereinheitlichen, ohne fachlich falsch zu werden:
| Treiber | Einheit | Wirkung |
|---|---|---|
| Ausgaben | relativ % | skaliert amount aller EXPENSE-Elemente |
| Rendite (PK, 3a, Sonstiges Vermögen) | Δ Prozentpunkte | verschiebt expectedReturn |
| Lebensdauer | Δ Jahre | verlängert/verkürzt die letzte Phase (min. 1 Jahr) |
| Inflation | absolut % | setzt inflationRateDefault |
| Einkommen | relativ % | skaliert amount aller INCOME-Elemente |
| Lohnentwicklung | Δ Prozentpunkte | verschiebt teuerungsausgleich der INCOME-Elemente |
| Wertsteigerung der Immobilie | Δ Prozentpunkte | verschiebt valueGrowth |
Die Begründungen im Einzelnen:
- Absolut nur bei der Inflation – es gibt genau einen plan-weiten Wert.
- Δ Prozentpunkte bei den Renditen, weil die Elemente je eigene Sätze tragen. Ein absolutes „3 % bis 7 %" würde die PK auf ETF-Rendite plätten.
- Relativ % bei Einkommen und Ausgaben, weil die Elemente je eigene Beträge tragen.
- Immobilien-Wertsteigerung ist ein eigener Treiber und nicht Teil von „Rendite", damit sie nicht doppelt zählt.
Die Skalierung von Einkommen/Ausgaben greift nur dort, wo amount gesetzt ist. Das ist korrekt
und beabsichtigt: Ab Phase 2 ist der Wert in der Regel live vererbt (4.6.1),
und die Fortschreibung leitet ihn aus dem skalierten Basiswert ab – die Skalierung wirkt damit
automatisch über alle Folgephasen.
Ein Treiber erscheint nur, wenn der Plan passende Elemente enthält (applies).
4.13.3 Bandbreiten sind Pflicht – ohne Default
Je Treiber gibt der Benutzer eine tiefe und eine hohe Ausprägung an; Vorgabewerte gibt es bewusst nicht. Grund: Die Balkenlänge hängt direkt von diesen Bandbreiten ab. Ein stiller Default würde nicht hinterfragt, und das Ranking wäre dann eine Aussage über unsere Vorgabe statt über den Plan – dieselbe Begründung wie bei den Monte-Carlo-Mittelwerten (9.15).
Die Hilfe-Bubble je Treiber nennt stattdessen plausible Grössenordnungen. Entscheidend ist, die Bandbreiten ähnlich plausibel zu wählen, nicht ähnlich gross: „±10 % Inflation" (1.5 → 1.65 %) und „±10 % Ausgaben" sind völlig ungleich wahrscheinlich.
Zwei weitere Regeln: mindestens zwei Treiber (ein Tornado ist eine Rangliste – ein einzelner Balken ordnet nichts), und tiefer und hoher Wert dürfen nicht identisch sein (Spannweite 0).
4.13.4 Darstellung
Waagrechtes Balkendiagramm, je Balken die Spanne min…max der Zielgrösse, senkrechte
Referenzlinie beim Basisfall, sortiert nach Spannweite. Darunter eine Tabelle mit der
eingegebenen Bandbreite, den beiden Ergebniswerten und der Spannweite.
Die Richtung kann sich umkehren – tiefe Ausgaben ergeben ein hohes Endvermögen. Der Balken
spannt deshalb über min…max; welche Eingabe zu welchem Ende gehört, zeigt die Tabelle.
Referenz: src/lib/sensitivity.ts, src/components/SensitivityDialog.tsx.
5. Technische Spezifikation
5.1 Technologie-Stack
| Bereich | Technologie | Version |
|---|---|---|
| Framework | Next.js (App Router) | 16.2.10 |
| UI | React | 19.2.4 |
| Sprache | TypeScript | ^5 |
| Styling | Tailwind CSS | ^4 (via @tailwindcss/postcss) |
| Icons | lucide-react | ^1.24.0 |
| Charts | recharts | ^3.9.2 |
| ORM | Prisma | ^7.8.0 (Client-Output nach src/generated/prisma) |
| Datenbank | PostgreSQL | 16-alpine |
| DB-Treiber | pg + @prisma/adapter-pg |
^8.22.0 / ^7.8.0 |
| Validierung | Zod | ^4.4.3 |
| Auth | jose (JWT) + bcryptjs | ^6.2.3 / ^3.0.3 |
| Tests | Vitest | ^4.1.10 |
| Build | Docker (multi-stage), output: "standalone" |
Hinweis für Entwickler: Gemäss
AGENTS.mdweicht diese Next.js-Version von verbreiteten Konventionen ab. Vor Änderungen ist der relevante Guide unternode_modules/next/dist/docs/zu konsultieren.
5.2 Verzeichnisstruktur
FPT/
├── prisma/
│ ├── schema.prisma Datenmodell
│ └── migrations/ 12 Migrationen (chronologisch, siehe 5.4.6)
├── src/
│ ├── app/
│ │ ├── api/ Route Handlers (siehe Kapitel 6)
│ │ ├── login/page.tsx Login-/Registrierseite
│ │ ├── page.tsx Einstiegsseite (lädt /api/auth/me → AppShell)
│ │ ├── layout.tsx Root-Layout, Theme-Init-Script, Metadata
│ │ └── globals.css Tailwind + semantische Farb-Tokens (3 Themes)
│ ├── components/ 15 React-Komponenten (alle "use client")
│ ├── generated/prisma/ Generierter Prisma-Client (nicht editieren)
│ ├── lib/ Domänenlogik (siehe 5.3)
│ └── middleware.ts Zugriffsschutz (Edge-Runtime)
├── Info Dateien/ Fachdokumente, Roadmap (historisch)
├── docker-compose.yml, Dockerfile, docker-entrypoint.sh
└── .gitea/workflows/deploy.yaml CI/CD
5.3 Schichtenmodell und src/lib
Ein bewusster Entkopplungs-Entscheid (src/lib/types.ts Zeilen 1–4): Die Berechnungslogik
arbeitet auf eigenen Domänentypen, nicht auf den generierten Prisma-Typen. Dadurch ist
computePlan ohne Datenbank testbar.
Prisma-Modelle (DB)
│ toPlanInput() ← queries.ts: parst + validiert die JSON-Felder
▼
PlanInput (types.ts) ← reine Domänentypen
│ computePlan() ← calculations.ts: pure function
▼
PlanComputed ← an den Client geliefert
| Datei | Verantwortung |
|---|---|
calculations.ts |
Berechnungskern + CSV-Export. Keine I/O. |
elements.ts |
Kategorien, Labels, Reihenfolge, JSON-Payload-Typen, Zod-Schemas, num() |
types.ts |
Domänentypen für API und Berechnung |
constants.ts |
Schweizer Systemparameter |
montecarlo.ts |
Monte-Carlo-Simulation (Sampler + Treiber), Szenario-Vergleich und Element-Gruppierung über die Herkunfts-Kette. Keine I/O, läuft im Browser. |
sensitivity.ts |
Sensitivitätsanalyse / Tornado: Treiber-Katalog, Parameter-Transformationen, computeTornado. Rein, läuft im Browser. |
diff.ts |
Abweichungs-Erkennung eines Szenarios gegen sein Eltern-Szenario (Kap. 3.2.6) |
queries.ts |
Prisma-Includes, toPlanInput(), Ownership-Abfragen |
db.ts |
Prisma-Singleton (Global-Cache im Dev, verhindert Connection-Leak bei HMR) |
auth.ts |
JWT erzeugen/prüfen (Edge-kompatibel via jose) |
session.ts |
getCurrentUserId() aus dem Cookie (Node-Runtime) |
users.ts |
Registrierung, Credential-Prüfung, Passwortwechsel (bcrypt). Nie aus der Middleware importieren – Edge-Runtime hat keinen DB-Zugriff. |
format.ts |
formatChf / parseChfInput |
theme.ts |
Theme-Verwaltung (localStorage + data-theme) |
api-client.ts |
Typisierter fetch-Wrapper mit einheitlicher Fehlerextraktion |
5.4 Datenmodell
5.4.1 Tabellen
User
| Feld | Typ | Constraints |
|---|---|---|
id |
String | PK, cuid() |
username |
String | unique |
passwordHash |
String | bcrypt, Cost 12 |
createdAt |
DateTime | now() |
Person
| Feld | Typ | Constraints |
|---|---|---|
id |
String | PK, cuid() |
scenarioId |
String | FK → Scenario, Cascade |
role |
PersonRole |
PERSON_A | PERSON_B |
name |
String? | optional |
age |
Int | aktuelles Alter |
retirementAge |
Int | szenario-eigenes Pensionsalter |
@@unique([scenarioId, role]) |
Plan (Behälter – trägt keine Finanzdaten)
| Feld | Typ | Constraints |
|---|---|---|
id |
String | PK, cuid() |
userId |
String | FK → User, Cascade |
name |
String | |
createdAt / updatedAt |
DateTime |
Scenario (die berechenbare Einheit)
| Feld | Typ | Constraints |
|---|---|---|
id |
String | PK, cuid() |
planId |
String | FK → Plan, Cascade |
name |
String | |
isBase |
Boolean | Default false; genau eines je Plan ist true |
parentScenarioId |
String? | FK → Scenario (Self-Relation „ScenarioTree"), SetNull – Baum und Vergleichsbasis |
householdType |
HouseholdType |
SINGLE | COUPLE |
inflationRateDefault |
Float | szenario-weite Inflation in % |
initialCash |
Float | Default 0 |
startYear |
Int? | Kalenderjahr des Planbeginns – nur Darstellung (siehe 3.2.7) |
createdAt / updatedAt |
DateTime |
Phase
| Feld | Typ | Constraints |
|---|---|---|
id |
String | PK, cuid() |
scenarioId |
String | FK → Scenario, Cascade |
sequenceNumber |
Int | 1-basiert, lückenlos |
name |
String | |
durationYears |
Int | 1–80 |
cashTransition |
Json? | Cash-Entscheid beim Übergang nach dieser Phase (siehe 5.4.5) |
sourcePhaseId |
String? | Gegenstück in der Vorlage (lose Referenz, kein FK) – Diff-Grundlage |
createdAt / updatedAt |
DateTime | |
@@unique([scenarioId, sequenceNumber]) |
cashTransition liegt an der Phase und nicht in ElementTransitionValue, weil Cash kein
FinancialElement ist und damit keine elementId besitzt. Die Verschlüsselung folgt derselben
Logik wie dort: Der Übergang gehört der Von-Phase.
FinancialElement
| Feld | Typ | Constraints |
|---|---|---|
id |
String | PK, cuid() |
scenarioId |
String | FK → Scenario, Cascade |
category |
ElementCategory |
8 Werte |
name |
String | |
ownerRole |
OwnerRole? |
PERSON_A | PERSON_B | HOUSEHOLD |
orderIndex |
Int | Default 0 |
sourceElementId |
String? | Gegenstück in der Vorlage (lose Referenz, kein FK) – Diff-Grundlage |
createdAt |
DateTime |
Die Herkunfts-Verweise sind bewusst lose (kein Fremdschlüssel): Wird das Gegenstück in der Vorlage gelöscht, soll die Kopie bestehen bleiben und im Diff einfach als „neu" gelten – ein Cascade wäre hier falsch.
ElementPhaseValue
| Feld | Typ | Constraints |
|---|---|---|
id |
String | PK, cuid() |
elementId |
String | FK → FinancialElement, Cascade |
phaseId |
String | FK → Phase, Cascade |
data |
Json | Payload gemäss PhaseData |
@@unique([elementId, phaseId]) |
ElementTransitionValue
| Feld | Typ | Constraints |
|---|---|---|
id |
String | PK, cuid() |
elementId |
String | FK → FinancialElement, Cascade |
fromPhaseId |
String | FK → Phase, Cascade |
data |
Json | Payload gemäss TransitionData |
@@unique([elementId, fromPhaseId]) |
5.4.2 Warum JSON?
Die kategoriespezifischen Felder liegen als JSON, weil sich sonst pro Kategorie eine eigene
Tabelle (oder eine breite Sparse-Tabelle) ergäbe. Die Typisierung und Validierung findet in der
Applikationsschicht statt (src/lib/elements.ts): PhaseData / TransitionData als
TypeScript-Interfaces, phaseDataSchema / transitionDataSchema als Zod-Schemas an der
API-Grenze.
Die Interfaces sind bewusst tolerant getippt (alle Felder optional): die Berechnung liest
defensiv über num(value, fallback), das UI zeigt kontextabhängig nur die relevanten Felder.
Referenz: prisma/schema.prisma Zeilen 4–6, src/lib/elements.ts Zeilen 48–51.
5.4.3 JSON-Payload PhaseData
| Feld | Kategorien | Zod-Regel |
|---|---|---|
amount |
INCOME, EXPENSE | ≥ 0 |
teuerungsausgleich |
INCOME, EXPENSE | −20 bis 50 |
gapYears |
AHV | Integer ≥ 0 |
avgIncomeBefore |
AHV – nur wenn bei Planbeginn bereits pensioniert | ≥ 0, real |
gapYearsBefore |
AHV – dito | Integer 0–50 |
currentValue |
PENSION_FUND, PILLAR_3A | ≥ 0 |
startValue |
OTHER_ASSET, OTHER_DEBT | ≥ 0 |
expectedReturn |
PK, 3a, OTHER_ASSET | −50 bis 100 |
annualContribution |
PK, 3a, OTHER_ASSET | ≥ 0 |
annualWithdrawal |
OTHER_ASSET | ≥ 0 |
additionalInvestment |
PK, 3a, OTHER_ASSET (ab Phase 2) | ≥ 0 |
purchasePrice |
REAL_ESTATE | ≥ 0 |
mortgage |
REAL_ESTATE | ≥ 0 |
amortization |
REAL_ESTATE | ≥ 0 |
interestRate |
REAL_ESTATE – Hypothekarzins %/Jahr | 0–20 |
interestHandling |
REAL_ESTATE – Doppelzählungs-Schalter | INCLUDED (Default) | ADD |
valueGrowth |
REAL_ESTATE – Wertsteigerung %/Jahr auf die Liegenschaft | −20 bis 20 |
annualRepayment |
OTHER_DEBT | ≥ 0 |
5.4.4 JSON-Payload TransitionData
| Feld | Kategorien | Zod-Regel |
|---|---|---|
reviewed |
AHV (Pensions-Übergang) – Beitragskarriere geprüft | Boolean |
avgIncomeBefore |
AHV (Pensions-Übergang) | ≥ 0, real |
gapYearsBefore |
AHV (Pensions-Übergang) | Integer 0–50 |
withdrawalMode |
PK, 3a (normal) | NONE | AMOUNT |
withdrawal |
PK, 3a (normal) | ≥ 0, brutto |
payoutMode |
PK (Pensionierung) | CAPITAL | PENSION | COMBI |
capitalAmount |
PK (COMBI) | ≥ 0 |
conversionRate |
PK | 0–20 |
capitalTaxRate |
PK + 3a, sowohl Vorbezug als auch Pensionierung | 0–100 |
decision |
REAL_ESTATE, OTHER_ASSET | HOLD | SELL | PARTIAL |
salePrice |
REAL_ESTATE | ≥ 0 |
partialSaleAmount |
OTHER_ASSET (Teilverkauf) | ≥ 0 |
extraAmortization |
REAL_ESTATE (Sonderamortisation) | ≥ 0 |
saleTaxRate |
REAL_ESTATE | 0–100 |
immediateRepayment |
OTHER_DEBT | ≥ 0 |
5.4.5 JSON-Payload CashTransitionData
Liegt in Phase.cashTransition. Validierung über cashTransitionSchema.
| Feld | Bedeutung | Zod-Regel |
|---|---|---|
mode |
Entscheid | NONE | INFLOW | OUTFLOW | BOTH |
inflowLabel |
Bezeichnung des Zuflusses (z. B. „Erbschaft") | ≤ 120 Zeichen |
inflowAmount |
Betrag nominal | ≥ 0 |
inflowTaxRate |
Steuer auf den Zufluss, Default 0 % | 0–100 |
outflowLabel |
Bezeichnung der Kosten (z. B. „Poolbau") | ≤ 120 Zeichen |
outflowAmount |
Betrag real (heutige Kaufkraft) | ≥ 0 |
Pro Übergang ist genau ein Zufluss und eine Kostenposition möglich – siehe 9.7.
Alle Schemas verwenden .strip() – unbekannte Felder werden verworfen, nicht abgelehnt.
Beim Lesen aus der DB gilt zusätzlich: schlägt safeParse fehl, wird {} zurückgegeben
(parsePhaseData / parseTransitionData) – korrupte Daten führen also nie zu einem Absturz,
sondern zu leeren Werten.
5.4.6 Migrationshistorie
| Migration | Inhalt |
|---|---|
20260708171600_init |
Initiales Schema |
20260709010000_add_transition_automation |
Übergangs-Automatik |
20260709120000_rework_realestate |
Immobilien-Überarbeitung |
20260711090000_multi_user |
User-Entität, Ownership |
20260713100000_element_model_rework |
Elemente auf Plan-Ebene, Phase-/Transition-Werte als JSON |
20260713150000_profile_to_plan_v3 |
Grundprofil von Household auf Plan verschoben |
20260714120000_person_name |
Person.name |
20260715120000_plan_initial_cash |
Plan.initialCash |
20260716210000_drop_phase_inflation_rate |
Phase.inflationRate entfernt (Inflation ist plan-weit) |
20260716230000_phase_cash_transition |
Phase.cashTransition (JSONB) für einmalige Sonderein-/ausgaben |
20260718090000_plan_scenario_hierarchy |
V6: Plan → Scenario (IDs erhalten), neuer Behälter Plan, planId → scenarioId, Herkunfts-Verweise |
20260718140000_scenario_start_year |
Scenario.startYear (Kalenderjahr des Planbeginns), bestehende auf das laufende Jahr gesetzt |
Zur V6-Migration: Sie benennt die bisherige Plan-Tabelle in Scenario um – dadurch
bleiben alle IDs und damit sämtliche Kind-Fremdschlüssel gültig. Für jedes bisherige
Wurzel-Element entsteht ein neuer Behälter ('plan_' || id, deterministisch ableitbar, daher
ohne Hilfstabelle); der bisherige Plan-Name wandert dorthin, das Szenario heisst „Basisszenario".
Bestehende Szenarien werden per rekursivem CTE demselben Behälter zugeordnet – auch mehrfach
verschachtelte. Ein Sicherheitsnetz fängt verwaiste Szenarien ab und macht sie eigenständig.
Die Migration wurde vor dem Deploy gegen echtes PostgreSQL verifiziert (PGlite, in-process): alle Vorgänger-Migrationen einspielen, realistische Daten inkl. verschachtelter Szenarien anlegen, migrieren, Ergebnis und Cascade prüfen.
5.5 Frontend-Architektur
5.5.1 Datenfluss
page.tsx (Client)
└─ GET /api/auth/me → username
└─ AppShell
├─ GET /api/plans → Planliste (Sidebar, Kacheln)
├─ GET /api/plans/<id> → { plan: PlanInput, computed: PlanComputed }
│ └─ PlanView (Matrix, Dialoge) → onChanged() → silent reload
│ └─ Dashboard (Kennzahlen, 3 Grafiken, Vergleich, Export)
└─ Dialoge: PlanDialog, ScenarioDialog
Es gibt keinen State-Management-Layer (kein Redux/Zustand/React Query). Der Zustand lebt in
AppShell (Planliste, ausgewählter Plan, Detail) und lokal in den Dialogen. Nach jeder Mutation
wird der Plan neu geladen; die Berechnung kommt immer vom Server.
5.5.2 Komponenten
| Komponente | Zeilen | Rolle |
|---|---|---|
AppShell |
433 | Layout, Sidebar, Planliste, Laden, Plan-/Szenario-Dialoge |
PlanView |
1304 | Matrix, Spaltenaufbau, Kontextbildung, alle Zell-Dialoge |
ElementDetail |
548 | Feldgruppen je Kategorie/Kontext, Speicherlogik, Übergangs-Defaults |
Dashboard |
187 | Kennzahlkarten, 3 Grafiken, Planvergleich, CSV-Link |
FormField |
275 | NumberField, MoneyInput/MoneyField, TextField, SelectField |
ProfileMenu |
166 | Benutzer, Theme-Wahl, Passwortwechsel, Logout |
Timeline |
108 | Zeitachse mit Pensions- und Ruin-Markern |
WealthChart |
106 | Vermögensverlauf, mehrere Serien |
PlanProfileFields |
101 | Wiederverwendete Grundprofil-Felder |
PhaseDetail |
95 | Phase bearbeiten/löschen |
SparquoteChart |
64 | Einkommen/Ausgaben pro Jahr mit Spar-/Verzehrbändern |
MonteCarloDialog |
~600 | Monte-Carlo-Dialog: Erklärung, Szenario-Auswahl, Eingaben, Lauf, Vergleichstabelle + Fächer |
SensitivityDialog |
~330 | Einflussfaktoren: Erklärung, Treiber-Auswahl mit Bandbreiten, Tornado + Tabelle |
SpecView |
50 | Rendert SPEZIFIKATION.md (via /api/spec) als lesbares Dokument |
InfoBubble |
28 | Hilfe-Tooltip |
5.5.3 Wiederverwendungsmuster
ElementPhaseFields und ElementTransitionFields sind aus ElementDetail exportiert und
werden an drei Stellen wiederverwendet: im Zell-Dialog, im Erstell-Dialog („Werte (erste
Lebensphase)") und im geführten Übergangs-Review. Dadurch gibt es genau eine Definition der
Felder je Kategorie.
Der CellContext ist die einheitliche Kontext-Schnittstelle dieser Feldgruppen; er wird in
PlanView durch buildPhaseContext / buildTransitionContext aus der Berechnung befüllt.
5.5.4 React-Detail: der key auf Dialogen
Zell-Dialoge tragen einen key aus elementId + phaseId. Grund (Kommentar Zeilen 479–480):
Beim Wechsel von Zelle zu Zelle wird ein Neuaufbau erzwungen, damit der lokale Formularzustand
nicht vom vorher geöffneten Element übrig bleibt.
5.6 Sicherheit
| Aspekt | Umsetzung |
|---|---|
| Passwortspeicherung | bcrypt, Cost 12 |
| Session | JWT HS256, 30 Tage, HttpOnly-Cookie, sameSite=lax, secure in Produktion |
| Secret | SESSION_SECRET aus der Umgebung; Fehlen wirft beim ersten Zugriff |
| Autorisierung | Middleware (grob) + Ownership-Query je Route (fein) |
| Information Disclosure | Fremde/nicht existierende Ressourcen → einheitlich 404; Login-Fehler unspezifisch |
| Eingabevalidierung | Zod an jeder API-Grenze; .strip() gegen Mass-Assignment |
| SQL-Injection | Prisma (parametrisiert) |
| XSS | React-Escaping; einziges dangerouslySetInnerHTML ist das statische Theme-Init-Script |
| CSRF | Kein Token. Schutz beruht allein auf sameSite=lax – siehe 9.4 |
5.7 Konfiguration
| Variable | Zweck |
|---|---|
DATABASE_URL |
Postgres-Connection-String |
SESSION_SECRET |
JWT-Signaturschlüssel (z. B. openssl rand -hex 32) |
POSTGRES_PASSWORD |
Nur für docker-compose: Passwort des db-Containers |
NODE_ENV |
Steuert u. a. das secure-Flag des Cookies und den Prisma-Global-Cache |
Es gibt kein konfiguriertes Login-Passwort: Konten werden über die Registrierung angelegt.
6. API-Referenz
Alle Routen liefern JSON. Fehlerformat einheitlich: { "error": "<Meldung>" } (bei
Zod-Fehlern in POST /api/plans: { "error": <flatten()-Objekt> }).
Alle Routen ausser login/register erfordern ein gültiges Session-Cookie.
6.1 Authentifizierung
| Methode | Pfad | Body | Antwort |
|---|---|---|---|
| POST | /api/auth/register |
{ username, password } |
201 { ok, username } + Cookie · 400 Validierung · 409 Name vergeben |
| POST | /api/auth/login |
{ username, password } |
200 { ok, username } + Cookie · 400 · 401 |
| POST | /api/auth/logout |
– | 200 { ok }, Cookie gelöscht |
| GET | /api/auth/me |
– | 200 { user: { id, username, createdAt } } · 401 · 404 |
| POST | /api/auth/change-password |
{ currentPassword, newPassword } |
200 { ok } · 400 · 401 |
6.2 Pläne
GET /api/plans
Liste der eigenen Pläne, sortiert nach createdAt aufsteigend.
{ "plans": [ { "id", "name", "parentPlanId", "branchFromPhaseId", "createdAt",
"phases": [ { "id", "name", "sequenceNumber" } ] } ] }
POST /api/plans
{ "name": "Basisplan", "householdType": "COUPLE", "inflationRateDefault": 1.5,
"persons": [ { "role": "PERSON_A", "name": "Anna", "age": 40, "retirementAge": 65 },
{ "role": "PERSON_B", "name": null, "age": 38, "retirementAge": 64 } ] }
Validierung: name 1–120; inflationRateDefault −20…50; persons 1–2 Einträge;
age 0–120; retirementAge 30–100; name je Person ≤ 60. Zusätzlich Konsistenzregel
SINGLE=1 / COUPLE=2 Personen. Legt Plan und Basisszenario an.
→ 201 { plan: { id }, scenario: { id } }
GET /api/plans liefert die Pläne inkl. Szenario-Kopfdaten:
{ "plans": [ { "id", "name", "createdAt",
"scenarios": [ { "id", "planId", "name", "isBase", "parentScenarioId" } ] } ] }
PATCH /api/plans/<planId>
{ name } – der Plan trägt nur noch den Namen. → 200 { plan: { id, name } }
DELETE /api/plans/<planId>
→ 200 { ok: true }, Cascade über alle Szenarien.
6.3 Szenarien
GET /api/scenarios/<scenarioId>
Liefert Eingabe, Berechnung und die Vergleichsbasis in einem Zug:
{ "plan": <PlanInput>, "computed": <PlanComputed>,
"base": <PlanInput|null>,
"meta": { "id", "planId", "planName", "name", "isBase", "parentScenarioId" } }
base ist das Eltern-Szenario (null beim Basisszenario) – daraus rechnet der Client den Diff.
Dies ist der einzige Endpunkt, der die Berechnung ausführt (neben export). → 404 wenn fremd.
PATCH /api/scenarios/<scenarioId>
Akzeptiert eine Union von zwei Formen:
- Vollständiges Profil:
{ householdType, inflationRateDefault, persons[], name? }– ersetzt die Personen in einer Transaktion. - Teilaktualisierung:
{ name?, initialCash? }–initialCash0…1'000'000'000, gerundet.
→ 200 { scenario: { id, name } }
DELETE /api/scenarios/<scenarioId>
→ 200 { ok: true } · 400 wenn es das Basisszenario ist.
POST /api/scenarios/<scenarioId>/copy
{ name } – vollständige Kopie; setzt parentScenarioId sowie die Herkunfts-Verweise.
→ 201 { scenarioId: "<neue Id>" }
GET /api/scenarios/<scenarioId>/export
→ text/csv; charset=utf-8, Content-Disposition: attachment.
6.4 Phasen
POST /api/scenarios/<scenarioId>/phases
Body optional: { name?, durationYears? }.
Hängt eine Phase am Ende an, kappt die Dauer, vergibt Default-Name, legt vorbelegte
ElementPhaseValue für alle aktiven Elemente an (alles in einer Transaktion).
→ 201 { phase: { id } }
PUT /api/phases/<phaseId>
{ name?, durationYears? } – durationYears 1–80, wird gekappt.
→ 200 { phase: { id } }
DELETE /api/phases/<phaseId>
Nur die letzte Phase. → 200 { ok } · 400 „Nur die letzte Phase kann geloescht werden."
PUT /api/phases/<phaseId>/cash-transition
Body = CashTransitionData (siehe 5.4.5). Speichert den Cash-Entscheid für den Übergang nach
dieser Phase (einmalige Sonderein-/ausgaben). Schreibt die Spalte Phase.cashTransition.
→ 200 { ok } · 404 wenn die Phase nicht dem Benutzer gehört.
6.5 Elemente
POST /api/scenarios/<scenarioId>/elements
{ category, name, ownerRole? } → 201 { element: { id } }
PERSON_ONLY_CATEGORIESohne Person → 400- fehlendes
ownerRolesonst →HOUSEHOLD orderIndex= Max + 1
PATCH /api/elements/<elementId>
{ name } (1–120). → 200 { ok }
DELETE /api/elements/<elementId>
→ 200 { ok }, Cascade auf alle Phasen-/Übergangswerte.
PUT /api/elements/<elementId>/phase/<phaseId>
Body = PhaseData. Upsert auf @@unique([elementId, phaseId]). Prüft zusätzlich, dass die
Phase zum selben Plan gehört wie das Element. → 200 { ok }
PUT /api/elements/<elementId>/transition/<fromPhaseId>
Body = TransitionData. Upsert auf @@unique([elementId, fromPhaseId]). → 200 { ok }
7. Betrieb und Deployment
7.1 Container
Dockerfile – vierstufiger Multi-Stage-Build auf node:20-alpine:
base– Arbeitsverzeichnis/appdeps–npm ci(mitprisma/für denpostinstall-Hookprisma generate)builder–npx prisma generate+npm run buildrunner– nur Laufzeit-Artefakte;ENTRYPOINT ./docker-entrypoint.sh,CMD npm start, Port 3000
docker-entrypoint.sh führt vor dem Start npx prisma migrate deploy aus – Migrationen
laufen also automatisch bei jedem Container-Start.
7.2 docker-compose
Zwei Services:
app– hängt an zwei Netzwerken:agent-net(extern, Traefik) undinternal(DB).db–postgres:16-alpine, nur iminternal-Netz, persistiert auf Volumefpt_db_data. Die Datenbank ist von aussen nicht erreichbar.
Traefik-Labels:
traefik.enable=true
traefik.docker.network=agent-net ← kritisch, siehe unten
traefik.http.routers.fpt.rule=Host(`fpt.aicds.ch`)
traefik.http.routers.fpt.entrypoints=websecure
traefik.http.routers.fpt.tls.certresolver=myresolver
traefik.http.services.fpt.loadbalancer.server.port=3000
Zwei projektspezifische Fallstricke sind im Code dokumentiert bzw. durch die Konventionen gesetzt:
traefik.docker.network=agent-netist zwingend. Der Container hängt an zwei Netzwerken; ohne diese Angabe wählt Traefik zufällig eines – landet es im internen DB-Netzwerk, ist das Backend unerreichbar (Timeout). (Kommentar indocker-compose.yml.)- Der Router-Name muss projektspezifisch sein (hier
fpt, nichtapp), sonst kollidiert er mit anderen Projekten auf derselben VM.
7.3 CI/CD
.gitea/workflows/deploy.yaml: Push auf main = automatisches Live-Deployment
(bewusst kein Review-Gate). Ablauf:
- Checkout
.envaus den Gitea-SecretsSESSION_SECRETundPOSTGRES_PASSWORDschreiben- Code nach
/opt/aicds/apps/FPT/kopieren docker compose down→docker compose up -d --build→docker image prune -f
Zielumgebung: Hetzner CX23, Traefik als Reverse Proxy, Domain fpt.aicds.ch, Gitea unter
git.aicds.ch.
8. Qualitätssicherung
8.1 Teststrategie
Getestet wird ausschliesslich der Berechnungskern – bewusst, da dort die Fachlogik und das
Regressionsrisiko liegen. Ausgeführt mit Vitest in der Node-Umgebung (vitest.config.ts,
Include src/**/*.test.ts). Es gibt keine Komponenten-, API- oder E2E-Tests.
| Datei | Tests | Schwerpunkt |
|---|---|---|
calculations.test.ts |
43 | AHV-Rentenformel, Immobilie, Teilverkauf, Sonderamortisation, AHV einkommensabhängig, „V5 Golden Tests" |
sensitivity.test.ts |
14 | Treiber-Transformationen (Reinheit, Einheiten, Kappung), Verfügbarkeit, Tornado-Sortierung und -Richtung |
montecarlo.test.ts |
13 | Determinismus, Volatilität/Vol-Drag, Böden, Reproduzierbarkeit; Element-Gruppierung und Szenario-Vergleich |
diff.test.ts |
9 | Abweichungs-Erkennung gegen das Eltern-Szenario |
migrations.test.ts |
1 | spielt alle Migrationen gegen echtes PostgreSQL (PGlite) ein |
| Total | 80 |
8.2 Testfälle
| Test | Prüft |
|---|---|
| Amtliche Tabelle 318.117.1 | ahvMonthlyFullPension reproduziert 11 Stützstellen der amtlichen Rentenskala exakt (Mindestrente, Formel 1, Wendepunkt 45'360 → 1'915, Formel 2, Maximalrente) |
| Kappung / Stetigkeit | unter 15'120 → 1'260, über 90'720 → 2'520; kein Sprung am Wendepunkt |
| AHV volle Karriere | mdJE 100'000 → Maximalrente 32'760 |
| AHV abgestuft / Mindestrente | mdJE 60'000 → Formel 2; mdJE 10'000 → 1'260 × 13 |
| AHV Vorgeschichte dominiert | 39 Jahre à 40'000 + 5 Jahre à 200'000 → mdJE 58'182 |
| AHV Ausfalljahre | 4 Ausfalljahre → 32'760 × 40/44; Ausfalljahre im Plan senken nur die Skala, nicht das mdJE |
| AHV ohne Prüfung | ohne erfassten Wert gilt der geplante Durchschnitt (nicht 0) |
| AHV bereits pensioniert | Karriere aus der Phasenzelle der ersten Phase |
| AHV Plafonierung | zwei Maximalrenten im Paar-Plan → gekappt auf 32'760 × 1.5 |
| AHV Netto → Brutto | mdJE = 70'000 × 1.12, nicht 70'000 |
| Immobilie: Hebel | 1 Mio / 900k Hypothek / 1 % / 10 J. → Endwert 304'622; deutlich mehr als eine Wertsteigerung auf das Eigenkapital ergäbe |
| Immobilie: ohne Wertsteigerung | Verhalten unverändert (1 Mio − 800k = 200'000) |
| Immobilie: Zins-Schalter | INCLUDED → kein Cash-Abzug; ADD → 10 × 1 % von 900'000 = 90'000 |
| Immobilie: Zins sinkt | Jahr 1: 10'000, Jahr 10: 9'100; schlägt auf die Quote durch |
| Immobilie: Verkauf | Grundstückgewinnsteuer auf Verkaufspreis − Kaufpreis, nicht auf den Verkehrswert |
| Teilverkauf (42) | Betrag ins Cash (→ capitalInflow), Rest bleibt aktiv; am Endwert gekappt; Halten/Vollverkauf unverändert |
| Sonderamortisation (15) | Einmaltilgung senkt Restschuld, belastet Cash (→ capitalInvest), am Restsaldo gekappt |
| MC: Determinismus | Streuung 0 reproduziert exakt das deterministische Ergebnis (Bänder kollabieren) |
| MC: Volatilität / Vol-Drag | σ > 0 spreizt p10<median<p90; Median unter dem deterministischen Wert |
| MC: Erfolg / Reproduzierbarkeit | P(≥ Ziel) fällt mit steigendem Ziel; gleicher Seed → identisches Ergebnis |
| MC: Boden / Ruin | 0%-Boden hält PK/3a ≥ Startwert; sicherer Verzehr → Ruinwahrscheinlichkeit 100 % |
| MC: Herkunfts-Kette | resolveRootElementId folgt der Kette bis zum Ursprung; Verweis ins Leere → eigenes Element ist Wurzel; defekte Kette terminiert |
| MC: Element-Gruppen | Kopie und Original ergeben eine Gruppe (nicht zwei); Auflösung auch über ein nicht ausgewähltes Zwischen-Szenario; ein nur in einem Szenario neues Element bildet eine eigene Gruppe |
| MC: Parameter-Übersetzung | paramsForScenario bildet die Gruppen-Parameter auf die Element-Ids des jeweiligen Szenarios ab |
| MC: Szenario-Vergleich | Zwei Szenarien, die sich nur in der geplanten Rendite unterscheiden: identischer Median/P10/Ruin (gleicher Seed, gleiche Struktur), aber höhere Erfolgswahrscheinlichkeit beim pessimistisch geplanten – der einzige Unterschied ist der Zielbetrag |
| Tornado: Reinheit | applyDriver lässt den Ausgangsplan unverändert |
| Tornado: Einheiten | Inflation absolut gesetzt; Rendite/Lohnentwicklung in pp verschoben; Ausgaben relativ skaliert (Einkommen unberührt) |
| Tornado: Lebensdauer | verschiebt nur die letzte Phase; Kappung bei mindestens 1 Jahr |
| Tornado: Verfügbarkeit | Treiber ohne passende Elemente werden ausgeblendet (z. B. Immobilien-Wertsteigerung ohne Immobilie) |
| Tornado: Sortierung/Richtung | absteigend nach Spannweite; Treiber ohne Bandbreite hat Spannweite 0 und steht zuunterst; höhere Ausgaben → tieferes, höhere Rendite → höheres Endvermögen |
| Vermögen je Jahr | 100k @ 10 % über 3 J. → 110k/121k/133.1k je Jahrespunkt; Endjahr = Phasen-Endvermögen |
| Vermögen real | 100k bei 10 % Inflation → real 90'909 |
| Test 1 – Ansparen | Einkommen +2 % nominal, Ausgaben real flach: Endvermögen 761'654 nominal / 565'928 real (±1 %), kein negatives Cash |
| Test 2 – Verzehr/Ruin | Rente nominal fix 60k, Ausgaben real 100k, Vermögen 900k @3 %: ruinAge === 94 |
| Test 3 – Cash-Ausgleich | Sparrate 6'364: cashEnd === 5472, nie negativ |
| Test 4 – Liquiditätslücke | Sparrate 10'000: cashEnd === -5436, cashNegative === true |
| Ausgaben real → nominal | Jahr 1 nominal = real; Jahr 5 = 100'000 × 1.02^4 |
| Einkommen flach | Lohnerhöhung 0 % → incomeStart === incomeEnd === 80'000 |
| Bezugsrate | 3 × 10'000 Entnahme → cashEnd === 30'000, Asset-Endwert 0, plannedWithdrawRate === 10'000 |
| Kapitalzufluss/-investition | Verkauf in P1, Reinvestition in P2: capitalInflow === 10'000, capitalInvest === 10'000, cashStart === 0, startWealthNominal === 10'000 (kein Doppelzählen) |
| Cash-Anfangswert | initialCash 50'000 fliesst in Phase 1 ein |
| Tilgung stoppt | Schuld 25'000, Tilgung 10'000/J., 5 Jahre: Gesamtabfluss 25'000 (nicht 50'000), cashEnd === 75'000, Restschuld 0 |
| Amortisation stoppt | Hypothek 15'000, Amortisation 10'000/J., 4 Jahre: cashEnd === 85'000 (nicht 60'000), Immobilie schuldenfrei |
| Vorbezugssteuer | PK-Vorbezug 100'000 brutto @ 8 %: capitalInflow === 92'000, Restkapital 200'000 (brutto entnommen) |
| Einmaliger Zufluss | 100'000 nominal @ 10 % Steuer → oneOffInflow === 90'000, Cash-Start Folgephase +90'000; Phase 1 hat keinen Zufluss |
| Einmalige Kosten | 20'000 real, 2 % Inflation, Grenze nach 10 J. → 20'000 × 1.02^10, entsprechend vom Cash abgezogen |
| Zufluss + Kosten / NONE | BOTH: +50'000 −20'000 → Cash-Start 30'000. NONE mit erfassten Beträgen → keine Wirkung |
| Liquiditätslücke durch Kosten | Kosten 25'000 bei Cash 10'000 → cashStart === −15'000, cashNegative, incomplete |
| Letzte Phase | Cash-Entscheid der letzten Phase bleibt wirkungslos (kein Übergang mehr) |
| Fortschreibung | Einkommens-Basiswert P1 → Startwert P2 = 100'000 × 1.02^5; cashStart(P2) === cashEnd(P1) |
8.3 Ausführung
npm test # vitest run
npm run lint # eslint
npm run build # next build
Verifikationseinschränkung: Lokal steht keine Datenbank/Docker zur Verfügung. Die
Verifikation erfolgt über npm run build und die isolierten Berechnungstests; End-to-End-Prüfung
erst gegen das Deployment (fpt.aicds.ch).
9. Bekannte Einschränkungen und Modellentscheide
Dieser Abschnitt hält fest, was im Code steht und beim Weiterentwickeln bekannt sein muss.
9.1 Cash wird nicht automatisch ausgeglichen
Wird das Cash negativ, meldet das Tool eine Liquiditätslücke (rotes Icon, incomplete = true),
greift aber nicht ein – es wird kein Vermögen automatisch verkauft und kein Kredit aufgenommen.
Negatives Cash geht mit negativem Vorzeichen ins Gesamtvermögen ein.
9.2 Verwaiste PERSON_B-Elemente
Die Haushaltsform ist eine Szenario-Eigenschaft und lässt sich im Dialog „Plan-Einstellungen"
auch bei einem bestehenden Szenario nachträglich ändern (nicht pro Phase – innerhalb eines Szenarios
gilt sie durchgehend). Wechselt ein Szenario dabei von COUPLE auf SINGLE, schneidet
PlanProfileFields die Personen auf eine zusammen und PATCH /api/scenarios/<id> löscht Person B
aus der Datenbank. Elemente mit ownerRole = "PERSON_B" bleiben bestehen. In der Berechnung
liefert personByRole dann null:
AHV: wird perif (!owner || …) continue;übersprungen – keine Rente.PENSION_FUND/PILLAR_3A:owneristnull, deshalb fälltownerWorkingaufanyWorkingzurück, d. h. das Element folgt dem Erwerbsstatus des Haushalts statt der (nicht mehr existierenden) Person.
Es gibt keine Bereinigung und keine Warnung im UI. Da die Haushaltsform normalerweise nicht nachträglich geändert wird, ist der Fall selten – er ist aber erreichbar.
9.3 Immobilien: was noch fehlt
Seit Version 0.5 kennt das Modul Hypothekarzins und Wertsteigerung. Nicht modelliert sind:
- Nebenkosten und Unterhalt (Faustregel ~1 % des Werts). Lassen sich heute als normales Ausgaben-Element erfassen.
- Eigenmietwert – ohne echte Steuerlogik (Roadmap Nr. 23) nur halb wirksam.
- Mieteinnahmen (Renditeliegenschaften) – anderer Anwendungsfall.
- Wertvermehrende Investitionen und die Haltedauer-Abstufung der Grundstückgewinnsteuer (kantonal, teils stark degressiv).
- Zinsänderungsrisiko: Der Zinssatz gilt für die ganze Phase. Ein Zinsschock lässt sich nur abbilden, indem man an dieser Stelle eine Phasengrenze zieht und den Satz neu setzt.
9.4 Kein CSRF-Token
Zustandsändernde Requests sind allein durch sameSite=lax geschützt. Das deckt klassische
Cross-Site-Formular-POSTs ab, ist aber schwächer als ein expliziter Token.
9.5 Logout invalidiert das Token nicht serverseitig
Das JWT ist zustandslos und bis zu 30 Tage gültig. logout löscht nur das Cookie.
9.6 Spar- und Bezugsraten werden nicht indexiert
annualContribution, annualWithdrawal, amortization und annualRepayment sind flache
Nominalbeträge, die über die Phasenjahre konstant bleiben. Eine Sparrate von 10'000 bleibt
20 Jahre lang 10'000 nominal und verliert dabei real an Gewicht. Wer eine mitwachsende Rate
abbilden will, muss die Phase teilen und den Betrag in der Folgephase erhöhen.
9.7 Nur ein Zufluss und eine Kostenposition pro Übergang
CashTransitionData hält genau ein Zufluss- und ein Kostenpaar (Bezeichnung + Betrag).
„Erbschaft + Autoverkauf + Poolbau + Küche" am selben Übergang lässt sich nur durch
Zusammenfassen abbilden („Diverses, 45'000") – die Aufschlüsselung geht dabei verloren.
Bewusster Entscheid zugunsten eines einfachen UI; erweiterbar auf Listen.
9.8 Einmalige Ereignisse nur an Phasengrenzen
Ein Ereignis kann nur an einem Phasenübergang liegen. Ein Poolbau in Jahr 3 einer 10-jährigen Phase ist nur abbildbar, wenn dort eine Phasengrenze gezogen wird. Nach der letzten Phase gibt es keinen Übergang – ein dort erfasster Betrag bleibt wirkungslos (durch Test abgedeckt).
9.9 Gemeinsames Einkommen zählt bei Paaren nicht für die AHV
Das mdJE ist eine personenbezogene Grösse. Einkommen wird deshalb nur einer Person
zugerechnet, wenn das INCOME-Element ihr zugeordnet ist (PERSON_A/PERSON_B). Bei einem
Einzelplan zählt HOUSEHOLD zur Person A – es gibt ja nur eine. Bei einem Paar-Plan
bleibt HOUSEHOLD-Einkommen für die AHV unberücksichtigt.
Wer in einem Paar-Plan den Lohn als „Gemeinsam" erfasst, sieht deshalb im Prüf-Dialog ein geplantes Durchschnittseinkommen von 0 und bekäme die Mindestrente. Der Dialog zeigt den Wert prominent an, sodass der Fehler auffällt – aber es gibt keine aktive Warnung. Erwerbseinkommen sollte in Paar-Plänen immer personenscharf erfasst werden.
Ebenfalls nicht modelliert: das Einkommenssplitting verheirateter Paare (während der Ehe werden die Einkommen hälftig geteilt) sowie Erziehungs- und Betreuungsgutschriften. Beides würde das mdJE real beeinflussen und wäre der nächste Ausbauschritt.
9.10 Verhaltensänderung: AHV-Rente bestehender Pläne
Bis Version 0.3 erhielt jede AHV-Position pauschal die Maximalrente (32'760), gekürzt nur um Ausfalljahre – unabhängig vom Einkommen. Seit 0.4 folgt sie der Rentenformel. Bestehende Pläne zeigen dadurch eine andere, in der Regel tiefere AHV-Rente, sobald das geplante Durchschnittseinkommen unter 90'720 liegt. Das ist keine Regression, sondern die Korrektur einer zu optimistischen Pauschale.
Zwei Fälle brauchen Aufmerksamkeit:
- Bereits pensionierte Personen (bei Planbeginn): Ohne erfasstes Durchschnittseinkommen ergibt das mdJE 0 → Mindestrente. Vorher war es die Maximalrente. Die Felder stehen in der AHV-Phasenzelle der ersten Phase; solange sie leer sind, ist die Rente bewusst konservativ.
- Paar-Pläne mit
HOUSEHOLD-Einkommen: siehe 9.9.
9.11 AHV-Rente wird nach der Pensionierung nicht indexiert
Die Rente wird zum Pensionierungszeitpunkt in heutigem Geld berechnet und danach nominal eingefroren. Die echte AHV wird alle zwei Jahre an den Mischindex angepasst. Über 25 Rentenjahre verliert die modellierte Rente damit real spürbar an Wert – das Modell ist an dieser Stelle deutlich konservativ. Bewusster Alt-Entscheid, unabhängig von der Rentenformel; der grösste verbliebene Hebel im AHV-Modell.
9.12 PILLAR_3A_MAX_ANNUAL wird nur im UI erzwungen
Das Feld ist per max-Prop hart geklammert. Das Zod-Schema kennt für annualContribution nur
≥ 0 – ein direkter API-Aufruf kann die Obergrenze überschreiten.
9.13 Verhaltensänderung: Nettolohn-Klarstellung und AHV
Bis Version 0.4 war nirgends definiert, ob INCOME netto oder brutto ist – der Hilfetext sagte
nur „die Zahl auf dem Lohnausweis", und dort stehen beide. Für den Cash-Fluss war das folgenlos,
weil sich beide Konventionen aufheben (brutto − Ausgaben inkl. Abzüge ≡ netto − Ausgaben ohne Abzüge). Mit der einkommensabhängigen AHV (0.4) hing daran aber plötzlich eine Rente.
Seit 0.5 gilt: amount ist der Nettolohn, und die AHV rechnet mit × 1.12 auf brutto hoch.
Konsequenzen:
- Wer bisher netto erfasst hat (die dokumentierte Absicht): Die AHV-Rente steigt – sie war zuvor um bis zu ~1'900/Jahr zu tief, am stärksten bei mittleren Einkommen (bei 80'000 brutto: 28'974 statt 30'902). Über 90'720 brutto verschwindet der Effekt, weil beide Werte in die Maximalrente laufen.
- Wer brutto erfasst hat: Cash-Fluss und AHV sind nun beide zu hoch. Der Einkommensbetrag gehört auf netto korrigiert und die Lohnabzüge aus dem Ausgabenbetrag entfernt.
9.14 Keine Steuerschätzung
Bewusst nicht umgesetzt: eine automatische Schätzung von Einkommens- und Vermögenssteuer.
Die Bemessungsgrundlage ist das steuerbare Einkommen, nicht der Nettolohn – also brutto minus PK, 3a, Berufsauslagen, Versicherungs- und Kinderabzüge. Darauf kommen drei Ebenen (Bund, Kanton, Gemeinde); allein der Gemeindesteuerfuss variiert innerhalb eines Kantons um rund den Faktor zwei. Dazu Zivilstand, Kinder, Konfession und für die Vermögenssteuer 26 kantonale Tarife mit eigenen Freibeträgen. Das Tool kennt weder Wohnort noch Kinder.
Eine Schätzung daraus läge im Einzelfall schnell 30–50 % daneben – bei vielen Haushalten dem grössten Ausgabenposten. Eine selbst berechnete Zahl wirkt zudem autoritativ und wird nicht hinterfragt. Der Benutzer kennt seine Steuerrechnung dagegen exakt aus der letzten Veranlagung.
Heutiger Weg: ein normales Ausgaben-Element „Steuern" – dafür braucht es kein neues Feld. Echte Steuerlogik ist Roadmap Nr. 23, mit Kanton und Gemeinde als Eingabe.
9.15 Monte-Carlo misst Risiko um die Annahmen, nicht deren Richtigkeit
Die Simulation streut um die eingegebenen Mittelwerte (historische Rendite/Inflation). Sie beurteilt nicht, ob diese Mittelwerte realistisch sind. Ein zu optimistischer Mittelwert liefert eine beruhigende, aber unrealistische Erfolgsquote – die präzise Prozentzahl täuscht dann Sicherheit vor. Der Dialog weist das explizit aus; die Mittelwerte sind Pflichtfelder ohne Default, damit sie bewusst gesetzt werden.
Weitere bewusste Vereinfachungen der Stufe A: Inflation und Renditen werden unabhängig gezogen (real sind sie negativ korreliert); die Normalverteilungs-Alternative wird gar nicht angeboten (fette Ränder fest eingebaut); die Simulationsparameter werden nicht persistiert (ephemer im Dialog). Ein historischer Backtest (Stufe B) und korrelierte/vollständigere Modelle (Stufe C) sind offen.
9.16 Diff ist live gegen die Vorlage
Die Abweichungs-Markierung vergleicht immer den aktuellen Stand des Eltern-Szenarios. Ändert man die Vorlage nachträglich, verschiebt sich die Markierung in allen Kindern rückwirkend: Setzt man in der Vorlage einen Wert auf das, was ein Szenario ohnehin hatte, verschwindet dort die gelbe Markierung, ohne dass das Szenario angefasst wurde.
Das ist logisch korrekt („weicht ab von der Vorlage"), kann aber überraschen. Die Alternative – ein eingefrorener Snapshot beim Kopieren – wäre schnell veraltet und würde Abweichungen anzeigen, die keine mehr sind. Bewusster Entscheid zugunsten des Live-Vergleichs.
Verwandt: Wird ein Element in der Vorlage gelöscht, gilt das Gegenstück im Kind ab dann als neu (der Herkunfts-Verweis zeigt ins Leere). Auch das folgt aus dem Live-Vergleich.
9.17 Kleinere Beobachtungen
planToCsv(plan, computed)erhält denplan-Parameter, verwendet ihn aber nicht.- Der Typ
SelectioninPlanViewhat nur eine Variante ({ type: "phase" }) – ein Rest der früheren Struktur mit mehreren Auswahlarten. Plan.branchFromPhaseIdhat keine Fremdschlüssel-Constraint; die Phase kann gelöscht werden, ohne dass das Feld bereinigt wird.- Die Element-Umbenennung ist als API vorhanden, im UI aber nicht erreichbar.
- Die
README.mdist noch der unverändertecreate-next-app-Text. npm run lintmeldet einen bestehenden Fehler inProfileMenu.tsxZeile 15 (react-hooks/set-state-in-effect); an vergleichbaren Stellen ist die Regel andernorts bewusst pereslint-disabledeaktiviert.
9.18 Tornado: was der Chart nicht leistet
Die Balkenlänge hängt von den eingegebenen Bandbreiten ab. Wer Renditen mit ±2 Prozentpunkten und Ausgaben mit ±20 % variiert, misst zu einem Teil die eigene Wahl dieser Bandbreiten. Deshalb sind sie Pflichteingabe ohne Default und im Ergebnis sichtbar (4.13.3). Aussagekräftig ist die Reihenfolge, nicht der absolute Betrag.
One-at-a-time sieht keine Wechselwirkungen. Schlechte Renditen und hohe Ausgaben treffen härter als die Summe der Einzelbalken – weil in der Folge Kapital verzehrt wird, das später zur Verzinsung fehlt. Für Kombinationen ist die Monte-Carlo-Simulation zuständig.
Das Pensionsalter ist bewusst nicht enthalten. Die Roadmap nennt es als Top-Hebel, aber
retirementAge lässt sich im aktuellen Datenmodell nicht isoliert variieren, ohne die
Phasengrenzen mitzuverschieben – und das Ergebnis wäre nicht ungenau, sondern irreführend.
Beispiel: Person 45, Pension mit 65, Phase 1 = 20 Jahre (45→65), Phase 2 = 20 Jahre.
- Pensionsalter auf 62: Phase 1 beginnt mit 45, also
45 < 62→ die Phase bleibt vollständig Erwerbsphase. Die Person arbeitet im Modell weiterhin bis 65, der Pensions-Übergang liegt an derselben Grenze. Wirkung auf das Ergebnis: praktisch null. - Pensionsalter auf 68: Phase 2 beginnt mit 65, also
65 < 68→ Phase 2 wird zur Erwerbsphase, das Einkommen läuft weiter. Zugleich wirdownerRetiresNextan der Grenze nach Phase 1 falsch (65 ≥ 68 trifft nicht zu), womit der Pensions-Übergang komplett entfällt: keine PK-Verrentung, kein 3a-Bezug, keine AHV-Rente. Der Balken wäre riesig – er misst aber den Ausfall der Vorsorgelogik, nicht „drei Jahre länger arbeiten".
Fachlich korrekt wäre nur, retirementAge und die Phasengrenze gemeinsam zu verschieben
(Erwerbsphase kürzer, Pensionsphase länger). Das hat eigene Sonderfälle – Paare mit
unterschiedlichem Pensionsalter, Grenzen abseits des Pensionsereignisses, Verschiebung grösser als
die Phasendauer – und ist als eigener Arbeitsschritt offen. Der verwandte Treiber Lebensdauer
(Dauer der letzten Phase) ist dagegen sauber abgebildet und deckt einen Teil des Bedürfnisses ab.
9.19 Simulationsparameter werden nicht gespeichert
Weder die Monte-Carlo-Annahmen noch die Tornado-Bandbreiten werden persistiert; beide leben nur im geöffneten Dialog. Das ist ein bewusster Entscheid (kein Datenmodell für Annahmen, keine Migration), hat aber den Preis, dass eine Analyse bei jedem Öffnen neu parametrisiert werden muss. Bei der Monte-Carlo-Simulation über mehrere Szenarien fällt das stärker ins Gewicht als zuvor, weil dort mehr Eingaben zusammenkommen.
10. Glossar
| Begriff | Bedeutung im FPT |
|---|---|
| Plan | Selbsttragende Planungseinheit: Grundprofil + Phasenkette + Elemente |
| Szenario | Die berechenbare Einheit (seit V6): trägt Grundprofil, Phasenkette und Elemente. Jeder Plan hat genau ein Basisszenario; weitere entstehen als vollständige Kopie eines beliebigen Szenarios und hängen als Baum darunter |
| Logisches Element | Dasselbe finanzielle Element über Szenariogrenzen hinweg, erkannt über die Herkunfts-Kette sourceElementId – Grundlage der einmaligen Parametereingabe im Szenario-Vergleich (4.12.6) |
| Spannweite (Tornado) | Differenz zwischen dem Ergebnis beim tiefen und beim hohen Wert eines Treibers; bestimmt Balkenlänge und Rangfolge |
| Grundprofil | Haushaltsform, Personen (Alter, Pensionsalter, Name), Inflationsannahme |
| Lebensphase | Zeitabschnitt mit fester Dauer; darf keine Pensionierung überspannen |
| Phasentyp | ERWERB / PENSION / MIXED; abgeleitet, nie gespeichert |
| Finanzielles Element | Plan-weite Entität einer der 8 Kategorien, über alle Phasen identisch |
| Übergang | Grenze zwischen zwei Phasen; Ort der einmaligen Entscheide |
| Einmalige Sonderein-/ausgabe | Ereignis am Übergang (Erbschaft, Poolbau), das direkt aufs Cash wirkt und nicht in die Quote eingeht |
| Pensions-Übergang | Übergang, bei dem der Besitzer des Elements pensioniert wird |
| Carry / Fortschreibung | Live-Übertragung des Endwerts einer Phase in die nächste |
| Cash | Systemseitiges Ausgleichskonto; darf negativ werden (Liquiditätslücke) |
| Quote | Einkommen − nominale Ausgaben eines Jahres; negativ = Verzehr |
| Geplante Sparrate | 3a-Beiträge + Sparbeiträge + Amortisationen + Tilgungen |
| Geplante Verzehrrate | Summe der Bezugsraten aus Sonstigem Vermögen |
| Kapitalzufluss | Verkaufserlöse + PK-/3a-Bezüge aus dem Übergang in die Phase |
| Kapitalinvestition | Zusatzinvestitionen + Sofort-Tilgungen |
| Nominal | Betrag in Franken des jeweiligen Jahres |
| Real | Kaufkraftbereinigt auf den Planbeginn (nominal / Deflator) |
| Deflator | Kumulierte Inflation seit Planbeginn |
| Ausfalljahr | Jahr ohne AHV-Beiträge; kürzt die Rente um 1/44 |
| mdJE | Massgebendes durchschnittliches Jahreseinkommen – Mittel der Beitragsjahre, bestimmt die Rentenhöhe |
| R0 | Mindestbetrag der vollen AHV-Monatsrente (1'260); alle Schwellen sind Vielfache davon |
| Wendepunkt | mdJE = 36 × R0 = 45'360; dort wechselt die Rentenformel von Teil 1 auf Teil 2 |
| Plafonierung | Deckelung der Ehepaar-AHV auf 150 % der Einzel-Maximalrente |
| Umwandlungssatz | Prozentsatz zur Verrentung des PK-Kapitals |
| Ruin(alter) | Alter von Person A, in dem das Gesamtvermögen erstmals unter 0 fällt |
| Nachlass | Endvermögen der letzten Phase (nominal) |
Ende der Spezifikation v1.0