diff --git a/SPEZIFIKATION.md b/SPEZIFIKATION.md index e38c5ac..6cdcdaa 100644 --- a/SPEZIFIKATION.md +++ b/SPEZIFIKATION.md @@ -4,10 +4,10 @@ | | | |---|---| | **Dokument** | Funktionale und Technische Spezifikation FPT | -| **Version** | 0.27 | +| **Version** | 0.28 | | **Datum** | 2026-07-24 | | **Status** | Lebendes Dokument | -| **Codestand** | Arbeitsstand nach `d6855ef` inkl. Sicherheits-Nachbesserungen Auth (Branch `main`) | +| **Codestand** | Arbeitsstand nach `2bb243b` inkl. Modul-Review 2 (Onboarding) (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` | @@ -17,6 +17,7 @@ | Version | Datum | Autor | Änderung | |---|---|---|---| +| 0.28 | 2026-07-24 | Claude (Opus 4.8) | **Modul-Review 2 (Onboarding & Ansicht): Layout-Umbau, Tour und Assistenten-Redesign.** (1) **Szenario-Ansicht neu geordnet** (Kap. 3.7.7): oben eine schlanke Funktions-Leiste (Änderungshistorie · Tour · Neues Szenario · Rechenwege · CSV-Export · Löschen · Abweichungs-Badge), darunter Nächste Schritte → Grundprofil → Zeitachse → Anzeige-Umschalter → Matrix. Grafiken, Effektive Werte, Live-Simulation, Monte-Carlo und Einflussfaktoren sind aus der Leiste **entfernt** – sie laufen über die eigenen Menüpunkte (Analysen / Effektive Werte). (2) **Tour** liegt neu in `AppShell` (statt `PlanView`) und startet nach **jeder** Plan-Erstellung – Assistent, Beispielplan **und** leerer Plan – unabhängig davon, ob sie schon einmal beendet wurde (Kap. 3.7.8); die Erfolgsmeldung hält damit ihr Versprechen. (3) **Assistent durchgehend in Du-Form** im Einzelmodus (Paarmodus weiter «ihr» / je Person). (4) **Schritt-Redesign** (Kap. 3.2.8): Schritt «Vorsorge & Vermögen» erfasst nur noch **Bestandswerte**; ein **neuer Schritt «Sparen & Verteilen»** zeigt die Sparquote (Nettoeinkommen − Ausgaben) und lässt sie auf 3a, Wertschriften, Amortisation und Schuldtilgung verteilen – der Rest bleibt sichtbar auf dem Cash-Konto. Die **PK-Einzahlung** steht dort bewusst separat, mit dem Hinweis, dass sie vom Bruttolohn kommt und die Sparquote **nicht** schmälert (deckt sich mit dem Rechenkern). Immobilien fragen im Assistenten neu **Wertsteigerung** und den **Zins-in-Ausgaben-Schalter** ab; der Lohn bekommt 1 % Default-Erhöhung. (5) **«Grosse Säule 3a» für Selbstständige** (Roadmap-Feedback): ein Schalter am 3a-Element (und im Assistenten) hebt die Beitrags-Obergrenze von 7’258 auf ca. 36’288 CHF an (neue Konstante `PILLAR_3A_MAX_SELF_EMPLOYED`, **2026-Wert zu verifizieren**; neues Feld `selfEmployed3a`). (6) Nebenbei: `POST /plans` liefert bei Validierungsfehlern eine lesbare Meldung statt eines rohen zod-Objekts. Keine Änderung am Rechenkern; 267 Tests unverändert grün. | | 0.27 | 2026-07-24 | Claude (Opus 4.8) | **Modul-Review 1 (Zugang & App-Rahmen): fünf Nachbesserungen an der Authentifizierung.** Ergebnis der ersten gemeinsamen Test- und Review-Runde. (1) **Timing-Ausgleich beim Login:** Ein unbekannter Benutzer wird neu gegen einen Dummy-bcrypt-Hash geprüft, damit die Antwortzeit dieselbe ist wie bei einem bekannten -- vorher liess sich aus der Dauer ablesen, ob ein Benutzername existiert (Kap. 3.1.2). (2) **Zurück-Knopf nach Logout:** Die aus dem Browser-Cache (bfcache) zurückgeholte, eingefrorene Ansicht prüft neu beim `pageshow` die Session und leitet ohne Anmeldung sofort auf `/login` -- vorher blieb die alte Ansicht sichtbar (Kap. 3.1.3). (3) **Rate-Limiting:** Login (10/15 min je IP), Registrierung (5/h je IP) und Passwortänderung (10/15 min je Benutzer) sind gegen Durchprobieren gebremst; neues In-Memory-Modul `rate-limit.ts`, 429 mit `Retry-After` (Kap. 3.1.6). Der Login-Zähler läuft je IP **und Benutzername** -- ein Konto sperrt nicht die anderen Konten derselben IP. (4) **Passwort-Dialog** läuft neu über die zentrale `Modal`-Komponente und schliesst damit auf Esc (Fokus-Falle, aria-modal inklusive) -- war zuvor von Hand gebaut (Kap. 3.1.4). (5) **Registrierungs-Fehler** werden sauber getrennt: nur der belegte Benutzername ist ein 409 mit freundlicher Meldung, jeder andere Fehler ein 500 statt einer rohen Prisma-Meldung als Konflikt. 6 Tests ergänzt (261 → 267). Offen für Roadmap #34: kein Passwort-Längen-Maximum (bcrypt-72-Byte-Grenze), keine ARIA-Labels auf Login/Palette. | | 0.26 | 2026-07-21 | Claude (Opus 4.8) | **Pensionsalter anpassen** (Roadmap Nr. 44, neue Kapitel 3.12, 4.4.7 und 4.16). Bisher war das Pensionsalter faktisch unantastbar: Es bestimmt, wo eine Lebensphase endet – ein frei geändertes Alter hätte die Phasengrenze zerrissen. Neu wird nicht das Alter geändert, sondern **die Grenze verschoben**: Die Phase davor wird länger, die danach kürzer, die Gesamtdauer bleibt gleich. Der Spielraum endet dort, wo eine angrenzende Phase unter ein Jahr fiele; ein Schritt weiter **entfällt sie ganz**, was vorher bestätigt wird, weil dabei zwei Übergänge **zusammengelegt** werden (bereits getroffene Entscheide bleiben, leere Felder werden aus dem entfallenden Übergang ergänzt, einmalige Cash-Beträge werden addiert – der Steuersatz betragsgewichtet). Das Pensionsalter ist damit auch **Treiber im Tornado** und **Regler in der Live-Simulation**. **AHV-Referenzalter (4.4.7):** Die Rente beginnt neu **immer mit 65**, unabhängig vom Pensionsalter – wer länger arbeitet, erhält sie zusätzlich zum Lohn; wer früher aufhört, zahlt bis 65 einen **Beitrag als Nichterwerbstätige(r)**, der als laufende Ausgabe auf die Verzehrquote schlägt und mit 65 wegfällt (neues Feld `ahvContribution`, ohne Default, Hilfetext nennt die reale Bandbreite von rund 530 bis 26'500 CHF pro Jahr). Beide Wechsel können **innerhalb** einer Phase liegen, die AHV wird deshalb **jahresweise** statt phasenweise gerechnet. **Punkt B:** Ein Einkommen, das einer **Person** zugeordnet ist, fällt bei deren Pensionierung auf 0 – bisher lief der Lohn stillschweigend in die Pension weiter. Gemeinsame Einkommen (Mieterträge o. Ä.) bleiben; ein ausdrücklich erfasster Betrag gewinnt, damit ein Teilzeitpensum modellierbar bleibt. **Punkt A:** Wiederkehr-Parameter (Raten, Beiträge, Amortisation, Wertsteigerung, Zinssatz) werden neu **live aus der Vorphase geerbt** statt beim Anlegen der Phase kopiert – sichtbar als angehaktes **«Aus Vorphase übernehmen»** je Feld. Vorher blieb die Kopie stehen, wenn man die Vorphase später änderte. **Punkt C:** Der Kapitalzufluss am Pensions-Übergang (PK, 3a, Verkaufserlös) lässt sich in **Prozent** auf Amortisation, Anlage und Cash aufteilen – bewusst nicht in Franken, weil sich der Betrag mit dem Pensionsalter ändert und eine Quote mitskaliert. **Nebenbei ein echter Fehler behoben:** Der fortgeschriebene Basiswert für Einkommen und Ausgaben wurde **vor** der Jahresschleife berechnet – effektive Werte kamen dadurch nie in der Folgephase an. Neues Modul `retirement.ts`, neuer Endpunkt `POST /api/scenarios//retirement`; 40 Tests ergänzt (221 → 261). | | 0.25 | 2026-07-21 | Claude (Opus 4.8) | **PDF-Berichte** (Roadmap Nr. 11, neues Kapitel 3.11). Neuer Unterpunkt **«Berichte»** auf Plan-Ebene: Liste der erzeugten Berichte plus Assistent zum Anlegen (Titel, Notiz, nominal **oder** real, Plan- oder effektive Daten, bis zu **drei** Szenarien, beliebige gespeicherte Analysen). Das **Layout ist immer gleich**; die Auswahl bestimmt nur, welche Bausteine erscheinen: Deckblatt mit Zusammenfassung und drei Kernaussagen, dann je Szenario Kennzahlen, Vermögensverlauf, Lebensphasen und Annahmen, danach Vergleich, Plan/Ist, Analysen und die Hinweise. **Die PDF-Datei wird als Datei abgelegt** (BYTEA in Postgres, nicht im Container-Dateisystem, das jeder Deploy neu baut): Ein Bericht muss in drei Jahren byte-identisch wieder herunterladbar sein – eine Neuerzeugung könnte das nach Änderungen an Plan, Rechenkern oder Layout nicht garantieren. **Kennzahlen je Szenario:** Endvermögen, Kapitalreichweite, Vermögen und Vorsorgekapital bei Pensionierung, AHV- und PK-Rente sowie die **offenen Entscheide** – die einzige unmittelbar handlungsleitende Zahl. Damit Bericht und Matrix nie verschiedene Zahlen nennen, liegt deren Zählung neu als reine Funktion in `decisions.ts`, die beide benutzen. **Zu jeder Kennzahl steht ihre Grundlage** als kurzer Verweis; die vollständigen Annahmen (Startwerte, Renditen, Raten je Element) stehen **einmal** je Szenario, statt bei jeder Kennzahl wiederholt zu werden. Ein **Haftungsausschluss** ist verpflichtend und durch einen Test gesichert – ein formal gesetztes PDF wird sonst als Beratung gelesen. **Technik:** `pdfkit` in der Node-Runtime statt Headless-Browser (kein Chromium im Image); `@react-pdf/renderer` schied aus, weil es mit React 19 / Next 16 bricht. Diagramme entstehen als **echte Vektoren** aus den gespeicherten Zahlen – genau dafür wurden die Analysen in 0.24 als Zahlen und nicht als Bilder abgelegt. `pdfkit` ist als externes Paket deklariert, weil es Font-Metriken über Dateipfade lädt und gebündelt erst in der Produktion bräche. Neue Tabelle `Report`, Endpunkte unter `/api/plans//reports`, neue Module `report.ts`, `report-pdf.ts`, `decisions.ts`; 9 Tests ergänzt (212 → 221). | @@ -411,8 +412,10 @@ gesetzt. Kalenderjahr eines Planjahrs: `startYear + (Jahr − 1)`. ### 3.2.8 Geführter Assistent und Beispielplan **Der Plan-Assistent** (Roadmap Nr. 10: «Schritt für Schritt statt leerer Matrix») fragt in -fünf Schritten in Alltagssprache: (1) Grundprofil, (2) Lebensphasen, (3) Einkommen und Ausgaben -plus Kontostand, (4) Vorsorge und Vermögen, (5) Zusammenfassung. +**sechs** Schritten in Alltagssprache: (1) Grundprofil, (2) Lebensphasen, (3) Einkommen und +Ausgaben plus Kontostand, (4) Vorsorge und Vermögen (**nur Bestandswerte**), (5) Sparen und +Verteilen, (6) Zusammenfassung. Im **Einzelmodus** durchgehend in **Du-Form** («Was verdienst +du?»); im Paarmodus je Person bzw. «ihr». **Schritt 2 ist an den fixen Pensionierungszeitpunkten ausgerichtet.** Das Pensionsalter jeder Person ist ein Fixpunkt auf der Lebenslinie; dazwischen entstehen Abschnitte mit konstantem @@ -444,7 +447,22 @@ Phasen lesbar bleiben. **Schritt 4 (Vorsorge & Vermögen)** ist bei Paaren in **Gemeinsam / Person A / Person B** aufgeteilt. Pensionskasse und Säule 3a sind immer persönlich (personengebundene Kategorien); -Wertschriften, Wohneigentum und Schulden lassen sich gemeinsam **oder** je Person erfassen. +Wertschriften, Wohneigentum und Schulden lassen sich gemeinsam **oder** je Person erfassen. Hier +werden nur die **heutigen Bestandswerte** erfasst (Guthaben, Kaufpreis, Hypothek, Restschuld) – +die laufenden Jahresbeträge folgen in Schritt 5. Zusätzlich fragt das Wohneigentum die +**Wertsteigerung** und den **Zins-in-Ausgaben-Schalter** ab (damit Hypothekarzinsen nicht +doppelt zählen), und die Säule 3a einen Schalter **«Selbstständig ohne PK (grosse Säule 3a)»**, +der die Beitrags-Obergrenze anhebt (siehe 4.11 / Feld `selfEmployed3a`). + +**Schritt 5 (Sparen & Verteilen)** bringt das Kernmodell des Tools zum Anfassen: Aus +`Nettoeinkommen − Ausgaben` entsteht die **Sparquote**; der Nutzer verteilt sie auf Säule 3a, +Wertschriften, Amortisation und Schuldtilgung, und der **Rest bleibt sichtbar auf dem +Cash-Konto** (live gerechnet, negativer Rest wird gewarnt). Die **PK-Einzahlung** steht bewusst +in einem **eigenen** Block mit dem Hinweis, dass sie vom **Bruttolohn** bezahlt wird – also +**vor** dem Nettoeinkommen – und die Sparquote deshalb **nicht** schmälert. Das deckt sich exakt +mit dem Rechenkern, wo der PK-Beitrag nicht zur Quote zählt ([4.6.3](#463-pension_fund)). Der +Schritt entfällt fachlich nie, aber wenn keine Spar-/Vorsorgeposten angehakt sind, weist er nur +darauf hin, dass der ganze Betrag auf dem Cash-Konto wächst. Zwei bewusste Entscheide bleiben: - **Einkommen wird pro Person erfasst**, nie als «Gemeinsam» – in Paar-Plänen zählt @@ -1273,13 +1291,31 @@ Neuaufbau des Formulars wie zuvor bei den Dialogen. Der frühere Dialog «Plan-Einstellungen» heisst im Panel korrekt **«Szenario-Profil»** – er bearbeitet seit V6 das Szenario, nicht den Plan. +**Aufbau der Szenario-Ansicht (seit 0.28):** von oben nach unten eine schlanke +**Funktions-Leiste** (Änderungshistorie · Tour · Neues Szenario aus diesem · Rechenwege · +CSV-Export · Szenario löschen · Abweichungs-Badge), dann **Nächste Schritte**, das +**Grundprofil**, die **Zeitachse**, der **Anzeige-Umschalter** (Nominal/Beide/Real, Plan/Ist) +und zuunterst die **Matrix** mit ihren «Element»-/«Lebensphase»-Knöpfen. Die Analyse-Werkzeuge +(Grafiken, Live-Simulation, Monte-Carlo, Einflussfaktoren) und die Effektiven Werte sind hier +bewusst **nicht** mehr verlinkt – sie laufen ausschliesslich über ihre eigenen Menüpunkte +([3.10](#310-navigation-auf-plan-ebene-und-gespeicherte-analysen)), damit es je Funktion genau +einen Ort gibt. + ### 3.7.8 Tour und «Nächste Schritte» -Die **Tour** (`Tour.tsx`) startet einmalig beim ersten Öffnen eines Plans mit Phasen -(localStorage `fpt-tour-done`) und führt in bis zu sechs Schritten über Profil, Zeitachse, -Matrix, Übergänge, Cash und Analysen – als Karte am unteren Rand plus pulsierender Rahmen um -das Ziel (`data-tour`-Attribute). Schritte ohne vorhandenes Ziel werden übersprungen; der -«Tour»-Knopf in der Werkzeugleiste startet sie jederzeit neu. Grenze: 9.24. +Die **Tour** (`Tour.tsx`) führt in bis zu sechs Schritten über Profil, Zeitachse, Matrix, +Übergänge, Cash und Analysen – als Karte am unteren Rand plus pulsierender Rahmen um das Ziel +(`data-tour`-Attribute). Schritte ohne vorhandenes Ziel werden übersprungen; der «Tour»-Knopf +in der oberen Funktions-Leiste startet sie jederzeit neu. Grenze: 9.24. + +Sie liegt seit 0.28 in `AppShell` (nicht mehr in `PlanView`), weil dort die Plan-Erstellung +zusammenläuft, und hat **zwei Auslöser**: Nach **jeder** Plan-Erstellung (Assistent, +Beispielplan, leerer Plan) startet sie **einmal** – unabhängig davon, ob sie schon beendet +wurde (die Erfolgsmeldung «die Tour zeigt dir gleich …» hält damit ihr Versprechen). Zusätzlich +startet sie beim ersten Öffnen eines bestehenden Plans mit Phasen, solange sie noch nie beendet +wurde (localStorage `fpt-tour-done`). Ein aus dem DOM gelesenes Ziel setzt voraus, dass die +Szenario-Ansicht bereits gerendert ist; der Auslöser feuert deshalb erst, wenn das Szenario +geladen ist. Die Karte **«Nächste Schritte»** über der Matrix leitet aus den vorhandenen Daten ab, was sinnvollerweise als Nächstes ansteht – offene Übergangs-Entscheide (mit Direktsprung in den @@ -2455,6 +2491,7 @@ Zentral in `src/lib/constants.ts` geführt, weil sie sich periodisch durch Bunde | `AHV_FULL_CONTRIBUTION_YEARS` | 44 | Volle Beitragsdauer (Rentenskala 44) | | `AHV_REFERENCE_AGE` | 65 | Referenzalter – ab hier fliesst die Rente, **unabhängig vom Pensionsalter** ([4.4.7](#447-referenzalter-die-rente-beginnt-mit-65-nicht-mit-der-pensionierung)) | | `PILLAR_3A_MAX_ANNUAL` | 7'258 | Max. 3a-Beitrag/Jahr für PK-Versicherte (2026) | +| `PILLAR_3A_MAX_SELF_EMPLOYED` | 36'288 | Max. 3a-Beitrag/Jahr «grosse Säule 3a» (Selbstständige ohne PK). **2026-Wert zu verifizieren** (2025: 36'288) | | `DEFAULT_PK_CONVERSION_RATE` | 6 % | Umwandlungssatz | | `DEFAULT_CAPITAL_TAX_RATE` | 8 % | Kapitalbezugssteuer | | `DEFAULT_PROPERTY_GAINS_TAX_RATE` | 20 % | Grundstückgewinnsteuer | @@ -3338,6 +3375,7 @@ Referenz: `prisma/schema.prisma` Zeilen 4–6, `src/lib/elements.ts` Zeilen 48 | `startValue` | OTHER_ASSET, OTHER_DEBT | ≥ 0 | | `expectedReturn` | PK, 3a, OTHER_ASSET | −50 bis 100 | | `annualContribution` | PK, 3a, OTHER_ASSET | ≥ 0 | +| `selfEmployed3a` | PILLAR_3A – «grosse Säule 3a» (Selbstständige ohne PK) → höhere Obergrenze | Boolean | | `annualWithdrawal` | OTHER_ASSET | ≥ 0 | | `additionalInvestment` | PK, 3a, OTHER_ASSET (ab Phase 2) | ≥ 0 | | `purchasePrice` | REAL_ESTATE | ≥ 0 | diff --git a/src/app/api/plans/route.ts b/src/app/api/plans/route.ts index f1ab866..87a8f31 100644 --- a/src/app/api/plans/route.ts +++ b/src/app/api/plans/route.ts @@ -65,7 +65,14 @@ export async function POST(request: NextRequest) { const body = await request.json(); const parsed = createPlanSchema.safeParse(body); - if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }); + if (!parsed.success) { + // Lesbare Meldung statt des rohen zod-Objekts (sonst sieht der Nutzer einen JSON-Blob). + const first = parsed.error.issues[0]; + return NextResponse.json( + { error: first ? `${first.path.join(".") || "Eingabe"}: ${first.message}` : "Ungültige Eingabe." }, + { status: 400 } + ); + } const error = validatePersonsForType(parsed.data); if (error) return NextResponse.json({ error }, { status: 400 }); diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index 5ef3a06..2859290 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -1,19 +1,20 @@ "use client"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { BarChart3, BookOpen, ChevronDown, ChevronRight, Copy, - Dices, FileText, FileSpreadsheet, FolderKanban, CalendarClock, GitBranch, + HelpCircle, History, + Download, Layers, LayoutDashboard, Menu, @@ -22,7 +23,6 @@ import { Search, SlidersHorizontal, Sparkles, - Tornado, Trash2, X, } from "lucide-react"; @@ -44,6 +44,7 @@ import { PlanTraceDialog } from "@/components/DetailView"; import { computePlan } from "@/lib/calculations"; import { ProfileMenu } from "@/components/ProfileMenu"; import { PlanWizard } from "@/components/PlanWizard"; +import { Tour, TOUR_DONE_KEY } from "@/components/Tour"; import { CommandPalette, type PaletteAction } from "@/components/CommandPalette"; import { PlanProfileFields, emptyProfileDraft, type ProfileDraft } from "@/components/PlanProfileFields"; import { @@ -117,6 +118,33 @@ function AppShellInner({ username }: { username: string }) { // Sprungmarke in die SPEZIFIKATION, gesetzt aus einem Rechenweg heraus. const [specAnchor, setSpecAnchor] = useState(null); + // Tour (seit dem Layout-Umbau hier statt in PlanView -- sie liest die data-tour-Ziele im + // DOM der Szenario-Ansicht). Zwei Auslöser: + // * pendingTourRef: nach JEDER Plan-Erstellung genau einmal erzwingen (Roadmap-Feedback F14). + // * Erst-Öffnen eines Plans mit Phasen, solange die Tour noch nie beendet wurde. + const [showTour, setShowTour] = useState(false); + const pendingTourRef = useRef(false); + const autoTourCheckedRef = useRef>(new Set()); + + useEffect(() => { + if (!detail || detail.plan.phases.length === 0) return; + const sid = detail.meta.id; + if (pendingTourRef.current) { + pendingTourRef.current = false; + autoTourCheckedRef.current.add(sid); + setShowTour(true); + return; + } + if (autoTourCheckedRef.current.has(sid)) return; // pro Szenario nur einmal, nicht bei Refresh + autoTourCheckedRef.current.add(sid); + try { + // eslint-disable-next-line react-hooks/set-state-in-effect -- einmalige Initialisierung aus localStorage + if (!localStorage.getItem(TOUR_DONE_KEY)) setShowTour(true); + } catch { + /* localStorage nicht verfügbar */ + } + }, [detail]); + function openSpecAt(anchor: string) { setSpecAnchor(anchor); setShowSpec(true); @@ -257,8 +285,9 @@ function AppShellInner({ username }: { username: string }) { const sid = await createDemoPlan(); await loadPlans(); setShowNewPlanChooser(false); + pendingTourRef.current = true; // Tour bei jeder Plan-Erstellung (F14) openScenario(sid); - toast("success", "Beispielplan angelegt – schau dich um!"); + toast("success", "Beispielplan angelegt – die Tour zeigt dir gleich die wichtigsten Stellen."); } catch (e) { toast("error", e instanceof Error ? e.message : "Beispielplan konnte nicht angelegt werden."); } finally { @@ -597,53 +626,52 @@ function AppShellInner({ username }: { username: string }) { {!showSpec && !showSystemParams && !loading && detail && selectedScenarioId && (
+ {/* Funktions-Buttons der Szenario-Ansicht. Grafiken/Effektive Werte/Live-Sim/ + Monte-Carlo/Einflussfaktoren sind hier bewusst NICHT mehr -- sie laufen über + die eigenen Menüpunkte (Analysen / Effektive Werte). Hier bleiben die reinen + Szenario-Aktionen plus Tour und CSV-Export. */}
+ {detail.plan.phases.length > 0 && ( + + )} + {detail.plan.phases.length > 0 && ( + + )} + {detail.plan.phases.length > 0 && ( + + )} + {detail.plan.phases.length > 0 && ( + + + CSV-Export + + )} {!detail.meta.isBase && ( )} - {detail.plan.phases.length > 0 && ( - <> - - - - - - - - - )} {diff && detail.base && ( @@ -690,6 +718,7 @@ function AppShellInner({ username }: { username: string }) { onCreated={async (sid) => { setShowWizard(false); await loadPlans(); + pendingTourRef.current = true; // Tour bei jeder Plan-Erstellung (F14) openScenario(sid); toast("success", "Plan erstellt – die Tour zeigt dir gleich die wichtigsten Stellen."); }} @@ -706,8 +735,9 @@ function AppShellInner({ username }: { username: string }) { ); setShowEmptyPlanDialog(false); await loadPlans(); + pendingTourRef.current = true; // Tour bei jeder Plan-Erstellung (F14) openScenario(scenario.id); - toast("success", "Plan erstellt."); + toast("success", "Plan erstellt – die Tour zeigt dir gleich die wichtigsten Stellen."); }} onClose={() => setShowEmptyPlanDialog(false)} /> @@ -817,6 +847,8 @@ function AppShellInner({ username }: { username: string }) { actions={paletteActions} onOpenScenario={openScenario} /> + + {showTour && setShowTour(false)} />}
); } diff --git a/src/components/ElementDetail.tsx b/src/components/ElementDetail.tsx index 88687d0..247e7cd 100644 --- a/src/components/ElementDetail.tsx +++ b/src/components/ElementDetail.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import { Trash2 } from "lucide-react"; import { AlertTriangle } from "lucide-react"; import { FieldLabel, InheritableField, MoneyField, NumberField, SelectField, TextField } from "@/components/FormField"; +import { InfoBubble } from "@/components/InfoBubble"; import { formatChf } from "@/lib/format"; import { api } from "@/lib/api-client"; // Die reinen Uebergangs-Regeln liegen in lib/transitions.ts (auch serverseitig nutzbar) und @@ -31,6 +32,7 @@ import { DEFAULT_PK_CONVERSION_RATE, DEFAULT_PROPERTY_GAINS_TAX_RATE, PILLAR_3A_MAX_ANNUAL, + PILLAR_3A_MAX_SELF_EMPLOYED, } from "@/lib/constants"; import type { CashTransitionData, @@ -664,10 +666,12 @@ export function ElementPhaseFields({ )} ); - case "PILLAR_3A": + case "PILLAR_3A": { if (!context.ownerWorking) { return

Die Säule 3a wird beim Pensions-Übergang vollständig bezogen.

; } + // «Grosse Säule 3a» für Selbstständige ohne PK: höhere Obergrenze (Roadmap-Feedback C7c). + const max3a = pd.selfEmployed3a ? PILLAR_3A_MAX_SELF_EMPLOYED : PILLAR_3A_MAX_ANNUAL; return ( <> {carried ? ( @@ -677,21 +681,30 @@ export function ElementPhaseFields({ label="Zusatzeinlage aus Kapital (CHF)" help="Aufstockung aus dem verfügbaren Kapital dieser Phase." value={num(pd.additionalInvestment)} - + onChange={(v) => setP({ additionalInvestment: v })} /> ) : ( setP({ currentValue: v })} /> )} + {inh( "annualContribution", "Jährliche Einzahlung (CHF)", (v, set) => ( - + ), asChf, - `Maximal CHF ${PILLAR_3A_MAX_ANNUAL.toLocaleString("de-CH")} (2026, mit PK) und höchstens die Sparquote. Wird von der Sparquote abgezogen.` + `Maximal CHF ${max3a.toLocaleString("de-CH")} (2026, ${pd.selfEmployed3a ? "Selbstständig ohne PK" : "mit PK"}) und höchstens die Sparquote. Wird von der Sparquote abgezogen.` )} {inh( "expectedReturn", @@ -701,6 +714,7 @@ export function ElementPhaseFields({ )} ); + } case "REAL_ESTATE": { // Zinsbetrag zu Phasenbeginn und -ende: die Restschuld sinkt mit der Amortisation, // der Zinsbetrag also mit. Am Nullpunkt gekappt (analog zur Berechnung). diff --git a/src/components/PlanView.tsx b/src/components/PlanView.tsx index a097313..d535fa1 100644 --- a/src/components/PlanView.tsx +++ b/src/components/PlanView.tsx @@ -9,8 +9,6 @@ import { ChevronDown, ChevronRight, CreditCard, - Download, - HelpCircle, Home, Landmark, ListTodo, @@ -33,7 +31,6 @@ import { transitionInactive as inactiveAtTransition, TRANSITION_CATEGORIES, } from "@/lib/decisions"; -import { Tour, TOUR_DONE_KEY } from "@/components/Tour"; import { Button, EmptyState, InspectorShell, Modal, useConfirm, useToast } from "@/components/ui"; import { ElementDetailDialog, PhaseDetailDialog } from "@/components/DetailView"; import { @@ -165,24 +162,12 @@ export function PlanView({ const [addCategory, setAddCategory] = useState(null); const [showAddPhase, setShowAddPhase] = useState(false); const [reviewFromPhaseId, setReviewFromPhaseId] = useState(null); - const [showTour, setShowTour] = useState(false); // Offener Verteil-Dialog (Kapital bzw. Spar-/Verzehrquote) -- bewusst ein eigenes Popup, // weil beide mehrere Elemente auf einmal bearbeiten. const [distribute, setDistribute] = useState<{ kind: "capital" | "rates"; phaseId: string } | null>(null); const [valueMode, setValueMode] = useState("nominal"); - - // Tour beim ersten Besuch eines Plans mit Phasen automatisch starten. - useEffect(() => { - try { - if (computed.phases.length > 0 && !localStorage.getItem(TOUR_DONE_KEY)) { - // eslint-disable-next-line react-hooks/set-state-in-effect -- einmalige Initialisierung aus localStorage - setShowTour(true); - } - } catch { - /* localStorage nicht verfügbar */ - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + // Die Tour (Start-Knopf, Auto-Start bei Plan-Erstellung, Rendering) liegt seit dem + // Layout-Umbau in AppShell -- sie liest die data-tour-Ziele im DOM dieser Ansicht. // Nur-Lese-Detailansicht (Roadmap Nr. 43). Der Rechenweg wird erst beim Öffnen erzeugt. const [detailFor, setDetailFor] = useState<{ kind: "element"; id: string } | { kind: "phase"; id: string } | null>(null); @@ -361,60 +346,19 @@ export function PlanView({ return (
-
- Anzeige -
- {(["nominal", "both", "real"] as ValueMode[]).map((m) => ( - - ))} -
- real = kaufkraftbereinigt (Planbeginn) - - {/* Zweite Achse: Datenquelle. Erscheint nur, wenn es überhaupt Ist-Werte gibt -- - sonst wäre es ein Umschalter ohne Gegenstück. */} - {hasActuals && ( - <> - Zahlen -
- {DATA_SOURCE_OPTIONS.map((o) => ( - - ))} -
- {dataSource === "ACTUAL" && ( - Abweichung gegenüber Plan farbig - )} - - )} -
- -
- -
+ setReviewFromPhaseId(fromPhaseId)} + onAddElement={(cat) => { + setAddCategory(cat); + setShowAdd(true); + }} + onAddPhase={() => setShowAddPhase(true)} + onOpenSensitivity={onOpenSensitivity} + /> {/* Szenario-Profil */}
- setReviewFromPhaseId(fromPhaseId)} - onAddElement={(cat) => { - setAddCategory(cat); - setShowAdd(true); - }} - onAddPhase={() => setShowAddPhase(true)} - onOpenSensitivity={onOpenSensitivity} - /> +
+ +
+ + {/* Anzeige-Umschalter (nominal/real, Plan/Ist) -- direkt über der Matrix, weil er nur + deren Zahlen steuert. */} + {hasPhases && ( +
+ Anzeige +
+ {(["nominal", "both", "real"] as ValueMode[]).map((m) => ( + + ))} +
+ real = kaufkraftbereinigt (Planbeginn) + + {/* Zweite Achse: Datenquelle. Erscheint nur, wenn es überhaupt Ist-Werte gibt -- + sonst wäre es ein Umschalter ohne Gegenstück. */} + {hasActuals && ( + <> + Zahlen +
+ {DATA_SOURCE_OPTIONS.map((o) => ( + + ))} +
+ {dataSource === "ACTUAL" && ( + Abweichung gegenüber Plan farbig + )} + + )} +
+ )} {!hasPhases && ( Lebensphase - {/* CSV-Export der Matrix. Liest immer den aktuellen Stand aus der Datenbank. */} - - CSV-Export - -
)} @@ -852,8 +826,6 @@ export function PlanView({ }} /> )} - - {showTour && setShowTour(false)} />}
); diff --git a/src/components/PlanWizard.tsx b/src/components/PlanWizard.tsx index 9260549..7ff2926 100644 --- a/src/components/PlanWizard.tsx +++ b/src/components/PlanWizard.tsx @@ -16,7 +16,7 @@ // Ehrliche Grenze: Bricht die Sequenz mittendrin ab (Netzfehler), existiert ein Teil-Plan // (siehe SPEZIFIKATION 9.23). -import { useMemo, useRef, useState } from "react"; +import { Fragment, useMemo, useRef, useState } from "react"; import { ArrowLeft, ArrowRight, Flag, Plus, Sparkles, Trash2 } from "lucide-react"; import { Modal, Button } from "@/components/ui"; import { MoneyField, NumberField, TextField } from "@/components/FormField"; @@ -24,7 +24,7 @@ import { InfoBubble } from "@/components/InfoBubble"; import { PlanProfileFields, emptyProfileDraft, type ProfileDraft } from "@/components/PlanProfileFields"; import { api } from "@/lib/api-client"; import { planSegments, defaultOpenDuration, type PlanSegment, type SegmentType } from "@/lib/phaseplan"; -import { PILLAR_3A_MAX_ANNUAL } from "@/lib/constants"; +import { PILLAR_3A_MAX_ANNUAL, PILLAR_3A_MAX_SELF_EMPLOYED } from "@/lib/constants"; import type { PhaseData } from "@/lib/elements"; interface PhaseDraft { @@ -32,7 +32,14 @@ interface PhaseDraft { durationYears: number; } -const STEP_TITLES = ["Über dich", "Deine Lebensphasen", "Einkommen & Ausgaben", "Vorsorge & Vermögen", "Fertig"]; +const STEP_TITLES = [ + "Über dich", + "Deine Lebensphasen", + "Einkommen & Ausgaben", + "Vorsorge & Vermögen", + "Sparen & Verteilen", + "Fertig", +]; const SEGMENT_META: Record = { ERWERB: { @@ -80,18 +87,20 @@ type Category = "pk" | "p3a" | "etf" | "re" | "debt"; interface ScopeAssets { pk: { on: boolean; value: number; contribution: number }; - p3a: { on: boolean; value: number; contribution: number }; + // selfEmployed: «grosse Säule 3a» ohne PK -> höhere Obergrenze (Roadmap-Feedback C7c). + p3a: { on: boolean; value: number; contribution: number; selfEmployed: boolean }; etf: { on: boolean; value: number; contribution: number }; - re: { on: boolean; price: number; mortgage: number; amortization: number; interest: number }; + // growth = Wertsteigerung %/Jahr; interestIncluded = Zinsen bereits in den Ausgaben. + re: { on: boolean; price: number; mortgage: number; amortization: number; interest: number; growth: number; interestIncluded: boolean }; debt: { on: boolean; value: number; repayment: number }; } function emptyScope(): ScopeAssets { return { pk: { on: false, value: 0, contribution: 0 }, - p3a: { on: false, value: 0, contribution: 0 }, + p3a: { on: false, value: 0, contribution: 0, selfEmployed: false }, etf: { on: false, value: 0, contribution: 0 }, - re: { on: false, price: 0, mortgage: 0, amortization: 0, interest: 1.5 }, + re: { on: false, price: 0, mortgage: 0, amortization: 0, interest: 1.5, growth: 1, interestIncluded: true }, debt: { on: false, value: 0, repayment: 0 }, }; } @@ -219,7 +228,8 @@ export function PlanWizard({ for (const p of profile.persons) { const amount = incomes[p.role] ?? 0; - if (amount > 0) await addEl("INCOME", `Lohn ${personLabel(p.role)}`, p.role, { amount, teuerungsausgleich: 0 }); + // 1 % nominale Lohnerhöhung als dezenter Default -- 0 % liesse den Lohn real verlieren. + if (amount > 0) await addEl("INCOME", `Lohn ${personLabel(p.role)}`, p.role, { amount, teuerungsausgleich: 1 }); } if (expenses > 0) await addEl("EXPENSE", "Lebenshaltung", "HOUSEHOLD", { amount: expenses, teuerungsausgleich: 0 }); for (const p of profile.persons) { @@ -241,8 +251,9 @@ export function PlanWizard({ if (a.p3a.on) { await addEl("PILLAR_3A", `Säule 3a${suffix}`, scope.vorsorgeRole!, { currentValue: a.p3a.value, - annualContribution: Math.min(a.p3a.contribution, PILLAR_3A_MAX_ANNUAL), + annualContribution: Math.min(a.p3a.contribution, max3aFor(scope.id)), expectedReturn: 2, + selfEmployed3a: a.p3a.selfEmployed, }); } } @@ -259,6 +270,8 @@ export function PlanWizard({ mortgage: a.re.mortgage, amortization: a.re.amortization, interestRate: a.re.interest, + valueGrowth: a.re.growth, + interestHandling: a.re.interestIncluded ? "INCLUDED" : "ADD", }); } if (a.debt.on) { @@ -307,6 +320,30 @@ export function PlanWizard({ return n + sc.cats.filter((c) => a[c].on).length; }, 0); + // --- Sparquote (Schritt "Sparen & Verteilen") --- + // Die Sparquote entsteht aus Nettoeinkommen minus Ausgaben. PK-Beiträge kommen VOM + // Bruttolohn (vor dem Netto) und reduzieren sie deshalb NICHT -- genau wie im Rechenkern. + const totalIncome = profile.persons.reduce((s, p) => s + (incomes[p.role] ?? 0), 0); + const sparBase = totalIncome - expenses; + const allocatedFromQuote = renderedScopes.reduce((sum, sc) => { + const a = assets[sc.id]; + return ( + sum + + (a.p3a.on ? a.p3a.contribution : 0) + + (a.etf.on ? a.etf.contribution : 0) + + (a.re.on ? a.re.amortization : 0) + + (a.debt.on ? a.debt.repayment : 0) + ); + }, 0); + const restToCash = sparBase - allocatedFromQuote; + const anyPk = renderedScopes.some((sc) => assets[sc.id].pk.on); + const anyQuoteTarget = renderedScopes.some((sc) => { + const a = assets[sc.id]; + return a.p3a.on || a.etf.on || a.re.on || a.debt.on; + }); + const scopeTag = (sc: { label: string }) => (isCouple ? ` · ${sc.label}` : ""); + const max3aFor = (sc: Scope) => (assets[sc].p3a.selfEmployed ? PILLAR_3A_MAX_SELF_EMPLOYED : PILLAR_3A_MAX_ANNUAL); + return ( -

Die zwei wichtigsten Zahlen deiner Planung – grob geschätzt reicht für den Anfang.

- {profile.persons.map((p) => ( - setIncomes((prev) => ({ ...prev, [p.role]: v }))} - /> - ))} +

Die wichtigsten Zahlen deiner Planung – grob geschätzt reicht für den Anfang.

+ {profile.persons.map((p) => { + const nm = profile.persons.find((x) => x.role === p.role)?.name?.trim(); + return ( + setIncomes((prev) => ({ ...prev, [p.role]: v }))} + /> + ); + })} @@ -444,8 +488,10 @@ export function PlanWizard({ {step === 3 && (

- Was besitzt ihr? Hake an, was zutrifft – nur das Angehakte fragt nach Details. - {isCouple && " Pensionskasse und Säule 3a sind immer persönlich; Wertschriften, Wohneigentum und Schulden kannst du gemeinsam oder je Person erfassen."} + {isCouple ? "Was besitzt ihr?" : "Was besitzt du?"} Hake an, was zutrifft – nur das Angehakte fragt nach Details. + Hier geht es nur um die heutigen Werte; die jährlichen Einzahlungen und + Amortisationen verteilst du im nächsten Schritt aus deiner Sparquote. + {isCouple && " Pensionskasse und Säule 3a sind immer persönlich; Wertschriften, Wohneigentum und Schulden könnt ihr gemeinsam oder je Person erfassen."}

{renderedScopes.map((sc) => (
@@ -456,58 +502,72 @@ export function PlanWizard({ {sc.cats.includes("pk") && ( setScope(sc.id, "pk", { on: v })} > setScope(sc.id, "pk", { value: v })} /> - setScope(sc.id, "pk", { contribution: v })} /> )} {sc.cats.includes("p3a") && ( setScope(sc.id, "p3a", { on: v })} > setScope(sc.id, "p3a", { value: v })} /> - setScope(sc.id, "p3a", { contribution: v })} /> + )} {sc.cats.includes("etf") && ( setScope(sc.id, "etf", { on: v })} > setScope(sc.id, "etf", { value: v })} /> - setScope(sc.id, "etf", { contribution: v })} /> )} {sc.cats.includes("re") && ( setScope(sc.id, "re", { on: v })} > setScope(sc.id, "re", { price: v })} /> setScope(sc.id, "re", { mortgage: v })} /> - setScope(sc.id, "re", { amortization: v })} /> setScope(sc.id, "re", { interest: v })} /> + setScope(sc.id, "re", { growth: v })} /> + )} {sc.cats.includes("debt") && ( setScope(sc.id, "debt", { on: v })} > setScope(sc.id, "debt", { value: v })} /> - setScope(sc.id, "debt", { repayment: v })} /> )}
@@ -521,6 +581,118 @@ export function PlanWizard({ )} {step === 4 && ( +
+ {/* Sparquote-Karte: das Kernmodell des Tools zum Anfassen. */} +
= 0 ? "border-accent bg-accent-soft/30" : "border-danger bg-danger-soft"}`}> +
Deine jährliche Sparquote
+
+ {totalIncome.toLocaleString("de-CH")} CHF Einkommen + + {expenses.toLocaleString("de-CH")} CHF Ausgaben + = + = 0 ? "text-fg" : "text-danger"}`}> + {sparBase.toLocaleString("de-CH")} CHF / Jahr + +
+

+ {sparBase >= 0 + ? "Dieser Betrag bleibt dir jährlich zum Sparen, Amortisieren oder Anlegen. Verteile ihn unten – was übrig bleibt, wächst auf dem Cash-Konto." + : "Du gibst mehr aus, als du einnimmst – die Differenz wird laufend aus deinem Vermögen bzw. Cash gedeckt. Einzahlungen unten sind dann nur sinnvoll, wenn genügend Cash vorhanden ist."} +

+
+ + {/* PK-Einzahlung: bewusst SEPARAT, weil sie vom Bruttolohn kommt und die Sparquote + nicht schmälert -- exakt wie der Rechenkern es behandelt. */} + {anyPk && ( +
+
+ Pensionskassen-Einzahlung +
+

+ Wird direkt vom Bruttolohn bezahlt – also bevor dein + Nettoeinkommen entsteht. Sie schmälert deine Sparquote deshalb nicht. +

+
+ {renderedScopes.filter((sc) => assets[sc.id].pk.on).map((sc) => ( + setScope(sc.id, "pk", { contribution: v })} + /> + ))} +
+
+ )} + + {/* Verteilung der Sparquote auf 3a / Wertschriften / Amortisation / Schuldtilgung. */} + {anyQuoteTarget ? ( +
+
+ Wohin fliesst deine Sparquote? +
+
+ {renderedScopes.map((sc) => ( + + {assets[sc.id].p3a.on && ( + setScope(sc.id, "p3a", { contribution: v })} + /> + )} + {assets[sc.id].etf.on && ( + setScope(sc.id, "etf", { contribution: v })} + /> + )} + {assets[sc.id].re.on && ( + setScope(sc.id, "re", { amortization: v })} + /> + )} + {assets[sc.id].debt.on && ( + setScope(sc.id, "debt", { repayment: v })} + /> + )} + + ))} +
+ + {/* Live-Rest: was nach der Verteilung aufs Cash geht. */} +
+ Rest bleibt auf dem Cash-Konto + + {restToCash.toLocaleString("de-CH")} CHF / Jahr + +
+ {restToCash < 0 && ( +

+ Du verteilst mehr, als deine Sparquote hergibt – die Differenz muss aus dem Cash-Bestand kommen und kann + ihn ins Minus ziehen. +

+ )} +
+ ) : ( +

+ Du hast im vorigen Schritt keine Spar-/Vorsorgeposten angehakt, in die deine Sparquote fliessen könnte. Der + ganze Betrag wächst dann auf dem Cash-Konto. Zurück im vorigen Schritt kannst du z. B. Wertschriften oder die + Säule 3a ergänzen. +

+ )} +
+ )} + + {step === 5 && (

Das wird angelegt:

    diff --git a/src/lib/constants.ts b/src/lib/constants.ts index aa97a86..d81ac09 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -53,6 +53,13 @@ export const AHV_FULL_CONTRIBUTION_YEARS = 44; // Maximaler jährlicher Säule-3a-Beitrag für PK-Versicherte (2026). export const PILLAR_3A_MAX_ANNUAL = 7258; +// Maximaler jährlicher Säule-3a-Beitrag für Selbstständigerwerbende OHNE Pensionskasse +// («grosse Säule 3a»): 20 % des Nettoerwerbseinkommens, gedeckelt auf diesen Betrag. +// TODO(Verifikation): Wert gegen die aktuellen BSV-Angaben für 2026 prüfen -- 2025 galt +// CHF 36'288. Das Tool klammert das Eingabefeld hart auf diesen Deckel; die 20-%-Regel +// selbst wird (noch) nicht geprüft. +export const PILLAR_3A_MAX_SELF_EMPLOYED = 36288; + // Default-Annahmen für Übergangs-Ereignisse (pro Ereignis editierbar). Werden SOWOHL als // UI-Vorschlag als auch in der Berechnung als Fallback verwendet -- damit ein nicht // angetupfter Wert nicht fälschlich als 0 gerechnet wird. @@ -175,7 +182,7 @@ export const SYSTEM_PARAMETERS: SystemParameter[] = [ }, { key: "PILLAR_3A_MAX_ANNUAL", - label: "Maximaler Säule-3a-Beitrag pro Jahr", + label: "Maximaler Säule-3a-Beitrag pro Jahr (mit PK)", value: PILLAR_3A_MAX_ANNUAL, unit: "CHF", meaning: "Höchstbetrag für Personen mit Pensionskasse. Im Eingabefeld hart geklammert.", @@ -183,6 +190,17 @@ export const SYSTEM_PARAMETERS: SystemParameter[] = [ validFrom: "1.1.2026", group: "Vorsorge", }, + { + key: "PILLAR_3A_MAX_SELF_EMPLOYED", + label: "Maximaler Säule-3a-Beitrag pro Jahr (Selbstständige ohne PK)", + value: PILLAR_3A_MAX_SELF_EMPLOYED, + unit: "CHF", + meaning: + "«Grosse Säule 3a»: 20 % des Nettoerwerbseinkommens, gedeckelt auf diesen Betrag. Gilt für Selbstständige ohne Pensionskasse. Wert für 2026 noch zu verifizieren (2025: 36'288).", + source: 'BSV «Beträge gültig ab 1.1.2026» (zu verifizieren)', + validFrom: "1.1.2026", + group: "Vorsorge", + }, { key: "DEFAULT_PK_CONVERSION_RATE", label: "Umwandlungssatz Pensionskasse (Vorschlag)", diff --git a/src/lib/elements.ts b/src/lib/elements.ts index 74685d9..d38c39a 100644 --- a/src/lib/elements.ts +++ b/src/lib/elements.ts @@ -71,6 +71,8 @@ export interface PhaseData { startValue?: number; expectedReturn?: number; annualContribution?: number; + // PILLAR_3A: «grosse Säule 3a» für Selbstständige ohne PK -> höhere Beitrags-Obergrenze. + selfEmployed3a?: boolean; // OTHER_ASSET: jährliche Bezugsrate (Entnahme). Mindert das Vermögen und fliesst ins Cash. annualWithdrawal?: number; // PENSION_FUND / PILLAR_3A / OTHER_ASSET (ab Phase 2): zusätzliche Einlage aus dem @@ -178,6 +180,7 @@ export const phaseDataSchema = z startValue: nonNeg.optional(), expectedReturn: z.number().min(-50).max(100).optional(), annualContribution: nonNeg.optional(), + selfEmployed3a: z.boolean().optional(), annualWithdrawal: nonNeg.optional(), additionalInvestment: nonNeg.optional(), purchasePrice: nonNeg.optional(),