# FPT – Financial Planning Tool ## Funktionale und Technische Spezifikation | | | |---|---| | **Dokument** | Funktionale und Technische Spezifikation FPT | | **Version** | 0.2 | | **Datum** | 2026-07-16 | | **Status** | Lebendes Dokument | | **Codestand** | Arbeitsstand nach `f768e01` inkl. Fixes zu Phaseninflation, Tilgungsraten und Vorbezugssteuer (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 | |---|---|---|---| | 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 1. [Einleitung und Abgrenzung](#1-einleitung-und-abgrenzung) 2. [Fachliche Grundkonzepte](#2-fachliche-grundkonzepte) 3. [Funktionale Spezifikation](#3-funktionale-spezifikation) 4. [Berechnungsmodell](#4-berechnungsmodell) 5. [Technische Spezifikation](#5-technische-spezifikation) 6. [API-Referenz](#6-api-referenz) 7. [Betrieb und Deployment](#7-betrieb-und-deployment) 8. [Qualitätssicherung](#8-qualitätssicherung) 9. [Bekannte Einschränkungen und Modellentscheide](#9-bekannte-einschränkungen-und-modellentscheide) 10. [Glossar](#10-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. - **Keine Monte-Carlo-Simulation / keine Stochastik.** Alle Renditen sind deterministische Jahresprozentsätze. - **Keine Hypothekarzinsen.** Eine Hypothek reduziert nur den Nettowert der Immobilie; Zinskosten sind vom Benutzer in den Ausgaben zu erfassen. - **Keine Wertentwicklung von Immobilien.** Der Kaufpreis ist über die Phasendauer konstant (Details siehe [4.6.5](#465-real_estate-immobilie)). - **Keine Mehrbenutzer-Kollaboration.** Pläne gehören genau einem Benutzer. ## 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 vier Ebenen ``` User └── Plan (Grundprofil: Haushaltsform, Personen, Inflation, Cash-Startwert) ├── Person[] (1 bei SINGLE, 2 bei COUPLE) ├── Phase[] (geordnete Kette: sequenceNumber 1..n, je mit Dauer in Jahren) └── FinancialElement[] (plan-weit, kategorisiert, optional personenzugeordnet) ├── ElementPhaseValue[] (Werte je Phase, JSON) └── ElementTransitionValue[] (Entscheide je Übergang, JSON) ``` Das zentrale Designprinzip (Element-Rework 07/2026, Migration `20260713100000_element_model_rework`): **Ein finanzielles Element ist über alle Lebensphasen hinweg dieselbe Entität.** Eine Pensionskasse "PK Arbeitgeber" existiert einmal pro Plan; 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 für die Vermögensaufteilungs-Grafik. Referenz: `prisma/schema.prisma` Zeilen 1–10, 114–151. ## 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 darf **negativ werden** – dies ist die Definition einer Liquiditätslücke und wird rot markiert, aber nicht automatisch korrigiert. Referenz: `src/lib/calculations.ts` Zeilen 365–425, 599. --- # 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-Cookie `fpt_session` (`sameSite=lax`, `secure` nur in Produktion, `maxAge` 30 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: 1. **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=` umgeleitet. 2. **Ownership-Check in jeder API-Route**: `getCurrentUserId()` plus eine Abfrage, die den Datensatz nur zurückgibt, wenn er dem Benutzer gehört (`getOwnedPlan`, `getOwnedPhase`, `getOwnedElement` in `src/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](#92-verwaiste-person_b-elemente). 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 Ein Szenario ist eine **Deep-Copy eines Plans bis zu einer gewählten Verzweigungsphase (inklusive)**. Kopiert werden: - Grundprofil (Haushaltsform, Inflation, `initialCash`) und alle Personen - alle Phasen mit `sequenceNumber <= branchPhase.sequenceNumber` - **alle** Elemente (unabhängig von der Verzweigungsphase) - Phasenwerte nur für kopierte Phasen; Übergangswerte nur für Übergänge aus kopierten Phasen Gesetzt werden `parentPlanId` (Ursprungsplan) und `branchFromPhaseId` (die **neue** ID der letzten kopierten Phase). Der Benutzer landet direkt im neuen Szenario, das anschliessend unabhängig weiterentwickelt wird. In der Planliste erscheint ein Szenario mit dem Zusatz „· Szenario". Referenz: `src/app/api/plans/[planId]/scenario/route.ts`. ## 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 Detail-Panel unterhalb der Matrix mit Bezeichnung und Dauer. 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/` 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](#344-feldkatalog-je-kategorie)) 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/`), 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 ist - **`durationYears`** – Phasendauer (begrenzt z. B. die Ausfalljahre) Referenz: `src/components/ElementDetail.tsx` Zeilen 124–340. #### INCOME (Einkommen) Hinweistext: „Einkommen wird NOMINAL erfasst (die Zahl auf dem Lohnausweis)." | Feld | JSON | Semantik | |---|---|---| | Jahreseinkommen 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. | #### 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) | Zustand | Felder | |---|---| | Phase 1 / Neukauf | **Kaufpreis** (`purchasePrice`), **Hypothek** (`mortgage`), **Amortisation CHF/Jahr** (`amortization`) | | ab Phase 2, fortgeschrieben | **Startwert Netto (fortgeschrieben)** (read-only = Kaufpreis − Resthypothek), **Amortisation** | #### 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 Kapitalverzehr im Alter. #### 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. Nur fünf Kategorien haben Übergangs-Entscheide (`TRANSITION_CATEGORIES`): `PENSION_FUND`, `PILLAR_3A`, `REAL_ESTATE`, `OTHER_ASSET`, `OTHER_DEBT`. 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 / Verkaufen (+ Verkaufspreis, Grundstückgewinnsteuer) | identisch | | `OTHER_ASSET` | Halten / Verkaufen | 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 | | `PENSION_FUND` | Pensions-Übergang: `payoutMode` gesetzt; sonst: `withdrawalMode` gesetzt | | `PILLAR_3A` | Pensions-Übergang: **immer** beantwortet; sonst: `withdrawalMode` gesetzt | | 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 **alle** noch aktiven Elemente der Übergangs-Kategorien untereinander 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` Zeilen 1040–1127, `src/components/ElementDetail.tsx` Zeilen 89–105. ## 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](#453-die-drei-deflatoren). Referenz: `src/components/PlanView.tsx` Zeilen 665–699. ### 3.6.2 Zeitachse Horizontaler Balken über das Alter (von jüngster Person bis Planende) mit: - Flaggen-Marker je Person am Pensionsalter (Farbe: Person A indigo, Person B hellblau) - Trennstriche an den Phasengrenzen - rotem „Ruin "-Marker, falls zutreffend 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 | ` ` | | 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 | | Vermögen | Start → Ende (inkl. Cash) | Referenz: `src/components/PlanView.tsx` Zeilen 701–769. ### 3.6.4 Dashboard Erscheint unterhalb der Matrix, sobald mindestens eine Phase existiert. **Kennzahl-Karten:** Endvermögen nominal, Endvermögen real, Geschätzter Nachlass (= Endvermögen der letzten Phase, „potenziell vererbbar"). **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 Plan eine durchgezogene Linie (nominal) und eine gestrichelte (real). Datenpunkte: Startvermögen Phase 1 plus je ein Endwert pro Phase. Über Checkboxen lassen sich **andere Pläne überlagern** (Szenariovergleich); 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//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 `. Dateiname = Planname, nicht-alphanumerische Zeichen durch `_` ersetzt. Referenz: `src/lib/calculations.ts` Zeilen 620–648. ## 3.7 Bedienoberfläche ### 3.7.1 Layout - **Sidebar** (Desktop ab `lg` fix, 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 ``. Ohne gespeicherte Wahl folgt die Oberfläche `prefers-color-scheme`. Ein Inline-Script im `` 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) und `max` **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`, Funktion `computePlan(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 Zwei Durchgänge pro Phase über alle AHV-Elemente: **1. Ausfalljahre kumulieren** – für Elemente, deren Besitzer in dieser Phase **erwerbstätig** ist: ``` gapYearsByPerson[owner] += max(0, round(phaseData.gapYears)) ``` Die Ausfalljahre akkumulieren also über alle Erwerbsphasen hinweg. **2. Rente berechnen** – für Elemente, deren Besitzer in dieser Phase **pensioniert** ist: ``` factor = max(0, (44 − gapYears) / 44) rente = round(32'760 × factor) ``` - `AHV_MAX_ANNUAL_SINGLE = 32'760` – maximale einfache Altersrente pro Jahr inkl. 13. Rente (2'520/Monat × 13), Stand 2026, Quelle BSV. - `AHV_FULL_CONTRIBUTION_YEARS = 44` – volle Beitragsdauer (Rentenskala 44). **3. 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. Sie fliesst in `renteTotal` und wird im Einkommen mitgeführt. Referenz: `src/lib/calculations.ts` Zeilen 180–202, `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 `fixedRatesTotal` addiert (belastet das Cash). - Ist der Besitzer pensioniert, gibt es nie eine Rente, nur „Vollständig bezogen". ### 4.6.5 REAL_ESTATE (Immobilie) ``` purchase = round(phaseData.purchasePrice) // in JEDER Phase aus den Phasendaten mortgageStart = hasCarry ? carry.mortgage : round(phaseData.mortgage) amort = round(phaseData.amortization) // wird JÄHRLICH am Restsaldo gekappt equity = purchase − mortgageStart → startValue, wealthStart ``` Die Hypothek wird als **laufender Saldo** in der Jahresschleife geführt (siehe 4.7), nicht per Linearformel. Sobald sie 0 erreicht, entfällt die Amortisationsrate. Falls **nicht** fortgeschrieben und **nicht** Phase 1 (= Neukauf in einer späteren Phase): `investmentsFromCash += max(0, equity)` – das Eigenkapital wird aus dem Cash finanziert. Der Wert der Immobilie ist über die Phasendauer **konstant der Kaufpreis**; nur die Hypothek sinkt. Es gibt keine Wertsteigerung – der Verkaufspreis wird erst am Übergang erfasst. ### 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) inflFactor = cumInflStart × (1 + infl/100)^(t−1) expenseReal = Σ (exp.basis × (1 + exp.idx/100)^(t−1)) expenseNominal = expenseReal × inflFactor // 3. Quote quote = incomeFlow − expenseNominal // 4. YearPoint für die Grafik anfügen (year, age Person A, income, expenseNominal, expenseReal) // 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 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.purchase − re.mortgage) + Σ (−d.owed) 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. - `cashNegative` wird 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. - `ruinAge` wird 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 = purchase − re.mortgage (laufender Saldo nach der Jahresschleife) 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 ``` ## 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 = "SELL"` → `txInflow += ec.endValue`; `carry.status = "SOLD"` (kein Steuerabzug). Sonst → `carry.value = ec.endValue`. ### 4.9.4 REAL_ESTATE Die Resthypothek wird zurückgerechnet: `restMortgage = purchase − ec.endValue`. `decision = "SELL"`: ``` gain = max(0, salePrice − purchase) tax = gain × (saleTaxRate / 100) txInflow += round(salePrice − restMortgage − tax) carry.status = "SOLD" ``` Der Nettoerlös ist also Verkaufspreis minus Hypothekenablösung minus Grundstückgewinnsteuer. Ein Verlustverkauf erzeugt keine Steuer (`gain` bei 0 geklammert). Sonst → `carry.mortgage = restMortgage`. ### 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 Abschluss des Übergangs ``` cashCarryIn = cashEnd + txInflow − txImmediateRepay incomingInflow = txInflow // Kopf-Kennzahl der Folgephase incomingImmediateRepay = txImmediateRepay yearsBefore += duration ``` ## 4.10 Ergebnisstruktur ```ts PlanComputed { phases: PhaseComputed[] // alle Kennzahlen je Phase, inkl. elements[] yearly: YearPoint[] // ein Punkt pro Jahr über alle Phasen (Grafik) 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_MAX_ANNUAL_SINGLE` | 32'760 | Max. einfache AHV-Altersrente/Jahr inkl. 13. Rente (2026) | | `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. --- # 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.md` weicht diese Next.js-Version von verbreiteten > Konventionen ab. Vor Änderungen ist der relevante Guide unter `node_modules/next/dist/docs/` > zu konsultieren. ## 5.2 Verzeichnisstruktur ``` FPT/ ├── prisma/ │ ├── schema.prisma Datenmodell │ └── migrations/ 8 Migrationen (chronologisch) ├── 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/ 12 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 | | `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()` | | `planId` | String | FK → Plan, **Cascade** | | `role` | `PersonRole` | `PERSON_A` \| `PERSON_B` | | `name` | String? | optional | | `age` | Int | aktuelles Alter | | `retirementAge` | Int | plan-eigenes Pensionsalter | | | | `@@unique([planId, role])` | **Plan** | Feld | Typ | Constraints | |---|---|---| | `id` | String | PK, `cuid()` | | `userId` | String | FK → User, **Cascade** | | `name` | String | | | `householdType` | `HouseholdType` | `SINGLE` \| `COUPLE` | | `inflationRateDefault` | Float | plan-weite Inflation in % | | `initialCash` | Float | Default 0 | | `parentPlanId` | String? | FK → Plan (Self-Relation „PlanScenarios"), **SetNull** | | `branchFromPhaseId` | String? | ID der letzten kopierten Phase (**keine** FK-Constraint) | | `createdAt` / `updatedAt` | DateTime | | **Phase** | Feld | Typ | Constraints | |---|---|---| | `id` | String | PK, `cuid()` | | `planId` | String | FK → Plan, **Cascade** | | `sequenceNumber` | Int | 1-basiert, lückenlos | | `name` | String | | | `durationYears` | Int | 1–80 | | `createdAt` / `updatedAt` | DateTime | | | | | `@@unique([planId, sequenceNumber])` | **FinancialElement** | Feld | Typ | Constraints | |---|---|---| | `id` | String | PK, `cuid()` | | `planId` | String | FK → Plan, **Cascade** | | `category` | `ElementCategory` | 8 Werte | | `name` | String | | | `ownerRole` | `OwnerRole?` | `PERSON_A` \| `PERSON_B` \| `HOUSEHOLD` | | `orderIndex` | Int | Default 0 | | `createdAt` | DateTime | | **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 | | `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 | | `annualRepayment` | OTHER_DEBT | ≥ 0 | ### 5.4.4 JSON-Payload `TransitionData` | Feld | Kategorien | Zod-Regel | |---|---|---| | `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` | | `salePrice` | REAL_ESTATE | ≥ 0 | | `saleTaxRate` | REAL_ESTATE | 0–100 | | `immediateRepayment` | OTHER_DEBT | ≥ 0 | Beide 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.5 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) | ## 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/ → { 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 | | `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": "" }` (bei Zod-Fehlern in `POST /api/plans`: `{ "error": }`). 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. ```json { "plans": [ { "id", "name", "parentPlanId", "branchFromPhaseId", "createdAt", "phases": [ { "id", "name", "sequenceNumber" } ] } ] } ``` ### `POST /api/plans` ```json { "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. → 201 `{ plan: { id } }` ### `GET /api/plans/` Liefert **Eingabe und Berechnung** in einem Zug: ```json { "plan": , "computed": } ``` Dies ist der einzige Endpunkt, der die Berechnung ausführt (neben `export`). → 404 wenn fremd. ### `PATCH /api/plans/` Akzeptiert eine **Union** von zwei Formen: 1. Vollständiges Profil: `{ householdType, inflationRateDefault, persons[], name? }` – ersetzt die Personen in einer Transaktion. 2. Teilaktualisierung: `{ name?, initialCash? }` – `initialCash` 0…1'000'000'000, gerundet. Die Unterscheidung erfolgt über das Vorhandensein von `householdType`. → 200 `{ plan: { id, name } }` ### `DELETE /api/plans/` → 200 `{ ok: true }`, Cascade-Löschung. ### `POST /api/plans//scenario` ```json { "name": "Frühpensionierung", "branchFromPhaseId": "" } ``` → 201 `{ planId: "" }` ### `GET /api/plans//export` → `text/csv; charset=utf-8`, `Content-Disposition: attachment`. ## 6.3 Phasen ### `POST /api/plans//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/` `{ name?, durationYears? }` – `durationYears` 1–80, wird gekappt. → 200 `{ phase: { id } }` ### `DELETE /api/phases/` Nur die letzte Phase. → 200 `{ ok }` · 400 „Nur die letzte Phase kann geloescht werden." ## 6.4 Elemente ### `POST /api/plans//elements` `{ category, name, ownerRole? }` → 201 `{ element: { id } }` - `PERSON_ONLY_CATEGORIES` ohne Person → 400 - fehlendes `ownerRole` sonst → `HOUSEHOLD` - `orderIndex` = Max + 1 ### `PATCH /api/elements/` `{ name }` (1–120). → 200 `{ ok }` ### `DELETE /api/elements/` → 200 `{ ok }`, Cascade auf alle Phasen-/Übergangswerte. ### `PUT /api/elements//phase/` 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//transition/` 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`: 1. `base` – Arbeitsverzeichnis `/app` 2. `deps` – `npm ci` (mit `prisma/` für den `postinstall`-Hook `prisma generate`) 3. `builder` – `npx prisma generate` + `npm run build` 4. `runner` – 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) und `internal` (DB). - **`db`** – `postgres:16-alpine`, nur im `internal`-Netz, persistiert auf Volume `fpt_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: 1. **`traefik.docker.network=agent-net` ist 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 in `docker-compose.yml`.) 2. **Der Router-Name muss projektspezifisch sein** (hier `fpt`, nicht `app`), 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: 1. Checkout 2. `.env` aus den Gitea-Secrets `SESSION_SECRET` und `POSTGRES_PASSWORD` schreiben 3. Code nach `/opt/aicds/apps/FPT/` kopieren 4. `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. `src/lib/calculations.test.ts` enthält 13 Tests („V5 Golden Tests"), ausgeführt mit Vitest in der Node-Umgebung (`vitest.config.ts`, Include `src/**/*.test.ts`). Es gibt **keine** Komponenten-, API- oder E2E-Tests. ## 8.2 Testfälle | Test | Prüft | |---|---| | 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) | | Fortschreibung | Einkommens-Basiswert P1 → Startwert P2 = `100'000 × 1.02^5`; `cashStart(P2) === cashEnd(P1)` | ## 8.3 Ausführung ```bash 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 **Plan-Eigenschaft** und lässt sich im Dialog „Plan-Einstellungen" auch bei einem bestehenden Plan nachträglich ändern (nicht pro Phase – innerhalb eines Plans gilt sie durchgehend). Wechselt ein Plan dabei von `COUPLE` auf `SINGLE`, schneidet `PlanProfileFields` die Personen auf eine zusammen und `PATCH /api/plans/` löscht Person B aus der Datenbank. Elemente mit `ownerRole = "PERSON_B"` bleiben bestehen. In der Berechnung liefert `personByRole` dann `null`: - `AHV`: wird per `if (!owner || …) continue;` übersprungen – keine Rente. - `PENSION_FUND` / `PILLAR_3A`: `owner` ist `null`, deshalb fällt `ownerWorking` auf `anyWorking` zurü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 ohne Wertentwicklung Der Immobilienwert ist in jeder Phase der aus den Phasendaten gelesene `purchasePrice`. Der Kaufpreis wird beim Anlegen einer Folgephase mitkopiert (`buildCarryData`). Eine Wertsteigerung lässt sich nur indirekt abbilden, indem man den `purchasePrice` in einer späteren Phase manuell erhöht – was dann allerdings auch die Berechnung der Grundstückgewinnsteuer beim Verkauf beeinflusst (`gain = salePrice − purchase` liest den `purchasePrice` der Verkaufsphase). ## 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 `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.8 Kleinere Beobachtungen - `planToCsv(plan, computed)` erhält den `plan`-Parameter, verwendet ihn aber nicht. - Der Typ `Selection` in `PlanView` hat nur eine Variante (`{ type: "phase" }`) – ein Rest der früheren Struktur mit mehreren Auswahlarten. - `Plan.branchFromPhaseId` hat 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.md` ist noch der unveränderte `create-next-app`-Text. - `npm run lint` meldet einen bestehenden Fehler in `ProfileMenu.tsx` Zeile 15 (`react-hooks/set-state-in-effect`); an vergleichbaren Stellen ist die Regel andernorts bewusst per `eslint-disable` deaktiviert. --- # 10. Glossar | Begriff | Bedeutung im FPT | |---|---| | **Plan** | Selbsttragende Planungseinheit: Grundprofil + Phasenkette + Elemente | | **Szenario** | Deep-Copy eines Plans bis zu einer Verzweigungsphase; danach unabhängig | | **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 | | **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 | | **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 v0.1*