Files
FPT/SPEZIFIKATION.md
T
admGitAICDS 5bf35bd6da
Deploy App / deploy (push) Successful in 1m1s
Teilverkauf (Sonstiges Vermoegen) + Sonderamortisation (Immobilie)
Roadmap Nr. 42: OTHER_ASSET am Uebergang neu mit Teilverkauf. Neben Halten und
Vollverkauf kann ein Betrag ins Cash fliessen, der Rest bleibt investiert und
waechst weiter. Der Teilverkauf erscheint als "Kapitalzufluss" im Phasenkopf
("zu investierendes Kapital"). Abgegrenzt zur laufenden Bezugsrate: der Teilverkauf
ist die einmalige Entnahme AM Uebergang, die Bezugsrate der laufende Verzehr IN der
Phase -- beide koexistieren.

Roadmap Nr. 15: REAL_ESTATE im Halten-Fall neu mit Sonderamortisation -- Einmaltilgung
der Hypothek aus dem Cash, am Restsaldo gekappt, bringt die Immobilie auf Augenhoehe
mit OTHER_DEBT (immediateRepayment). Erscheint als "Kapitalinvestition" im Phasenkopf.
Damit laesst sich die indirekte Amortisation via 3a MECHANISCH nachbilden (3a wachsen
lassen -> bei Pensionierung ins Cash -> Sonderamortisation).

Die Steuerwirkung der indirekten Amortisation (3a-Abzug, Zinsabzug) bleibt bewusst
zurueckgestellt mit dem Steuer-Buendel 13/14/23 (SPEZ 9.14).

Fuenf Regressionstests (36 -> 41). Spezifikation auf v0.6. Keine Verhaltensaenderung
fuer bestehende Plaene.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 21:02:44 +02:00

2178 lines
107 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# FPT Financial Planning Tool
## Funktionale und Technische Spezifikation
| | |
|---|---|
| **Dokument** | Funktionale und Technische Spezifikation FPT |
| **Version** | 0.6 |
| **Datum** | 2026-07-17 |
| **Status** | Lebendes Dokument |
| **Codestand** | Arbeitsstand nach `a97de5b` inkl. Teilverkauf (Sonstiges Vermögen) und Sonderamortisation (Immobilie) (Branch `main`) |
| **Ersetzt** | `FDD_TDD_FPT.docx` (v1v5) 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.6 | 2026-07-17 | Claude (Opus 4.8) | **Teilverkauf** von Sonstigem Vermögen (Roadmap Nr. 42) und **Sonderamortisation** der Hypothek (Roadmap Nr. 15). `OTHER_ASSET` am Übergang neu: Halten / Verkaufen / **Teilverkauf** ein Betrag fliesst ins Cash (erscheint als „Kapitalzufluss" im Phasenkopf), der Rest bleibt investiert. `REAL_ESTATE` im Halten-Fall neu mit **Einmaltilgung** aus dem Cash (analog zur Sofort-Tilgung bei Schulden; erscheint als „Kapitalinvestition"). Damit lässt sich die indirekte Amortisation via 3a mechanisch nachbilden. Die eigentliche Roadmap Nr. 15 (Steuerwirkung der indirekten Amortisation) bleibt mit dem Steuer-Bündel 13/14/23 zurückgestellt siehe 9.14. Kapitel 4.9.3/4.9.4 ergänzt. Fünf Regressionstests (36 → 41). Keine Verhaltensänderung für bestehende Pläne. |
| 0.5 | 2026-07-17 | Claude (Opus 4.8) | **Netto/Brutto geklärt** (Roadmap Nr. 9, reduziert) und **Immobilien-Modul erweitert** (Roadmap Nr. 8). Einkommen ist neu explizit als **Nettolohn** definiert (Label und Hilfetext); für die AHV rechnet das Tool intern mit `AHV_GROSS_FROM_NET_FACTOR = 1.12` auf den Bruttolohn hoch die AHV bemisst sich am Brutto, die bisherige Netto-Basis unterschätzte die Rente um bis zu ~1'900/Jahr. Immobilie neu mit **Hypothekarzins** (% der Restschuld, sinkt mit der Amortisation, mit Doppelzählungs-Schalter) und **Wertsteigerung** (auf die **Liegenschaft**, nicht auf das Eigenkapital Hebeleffekt). Grundstückgewinnsteuer bemisst sich neu explizit am ursprünglichen Kaufpreis. Keine Aufschlüsselung bei Einkommen oder Ausgaben, keine Steuerschätzung (Begründung: 9.14). Neues Kapitel 4.4.5; 4.6.5 und 4.9.4 überarbeitet; Abschnitt 9 um zwei Punkte ergänzt. Sechs Regressionstests (30 → 36). **Verhaltensänderung:** siehe 9.13. |
| 0.4 | 2026-07-17 | Claude (Opus 4.8) | **AHV-Rente einkommensabhängig** (Roadmap Nr. 3) und **Fortschreibungs-Warnhinweis** (Roadmap Nr. 4). Die AHV-Rente folgt neu der amtlichen Rentenformel (Skala 44) über das massgebende durchschnittliche Jahreseinkommen statt pauschal der Maximalrente; verifiziert gegen die amtliche Tabelle 318.117.1 (51/51 Zeilen). Prüfung der Beitragskarriere am Pensions-Übergang, mit Zusatzfeldern für die Jahre vor Planbeginn (ab Alter 21). Alles **real** gerechnet. Neue Konstanten `AHV_MIN_MONTHLY_FULL`, `AHV_PENSION_MONTHS`, `AHV_CONTRIBUTION_START_AGE`; `AHV_MAX_ANNUAL_SINGLE` neu abgeleitet. Warnhinweis in Phasenzellen und Phasen-Detail, wenn Folgephasen existieren. Neue Kapitel 3.5.6, 4.4; Abschnitt 9 um zwei Punkte ergänzt. Zwölf Regressionstests (18 → 30). **Verhaltensänderung:** siehe 9.10. |
| 0.3 | 2026-07-16 | Claude (Opus 4.8) | **Einmalige Sonderein-/ausgaben** umgesetzt (Roadmap Nr. 1). Der Cash-Übergang zwischen zwei Phasen ist neu ein eigener Entscheid: 1:1 übernehmen · einmaliger Zufluss · einmalige Kosten · beides. Beträge gehen direkt aufs Cash-Konto. Zufluss wird nominal erfasst (real angezeigt) mit optionalem Steuersatz (Default 0 %), Kosten werden real erfasst (nominal angezeigt); beide bleiben aus der Sparquote heraus. Der Entscheid startet unbeantwortet und zählt im „offen"-Badge mit. Neue Spalte `Phase.cashTransition` (JSON) + Migration, neue Route `PUT /api/phases/<id>/cash-transition`, eigene Kennzahlen im Phasenkopf. Fünf Regressionstests ergänzt (13 → 18). Neue Kapitel 3.5.5, 4.9.6; Abschnitt 9 um zwei Grenzen ergänzt. |
| 0.2 | 2026-07-16 | Claude (Opus 4.8) | Drei Fixes umgesetzt und dokumentiert: **(1)** `Phase.inflationRate` ersatzlos entfernt (DB-Migration, API, Typen, UI) die Inflation liegt seit V5 plan-weit; das Feld war wirkungslos. **(2)** Amortisation und Tilgung stoppen neu, sobald Hypothek bzw. Schuld abbezahlt sind sie belasten danach weder Cash noch Sparquote (Kap. 4.6.5, 4.6.7, 4.7, 4.8). **(3)** Kapitalbezugssteuer greift neu auch bei PK-/3a-Vorbezügen vor der Pensionierung, inkl. Steuerfeld und Netto-Vorschau im UI (Kap. 3.5.2, 4.9.1, 4.9.2). Kennzahl `plannedSaveRate` ist neu die Rate des **ersten** Phasenjahres. Drei Regressionstests ergänzt (10 → 13). Abschnitt 9 neu nummeriert (erledigte Punkte entfernt, „Sparraten werden nicht indexiert" ergänzt). |
| 0.1 | 2026-07-16 | Claude (Opus 4.8) | Erstfassung. Vollständige Neuerstellung aus dem Code (Stand `f768e01`). Ersetzt die bisherigen FDD/TDD-Dokumente v1v5 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 810, `src/lib/types.ts` Zeilen 3849.
---
# 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 110, 114151.
## 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 111120 (Spaltenaufbau), 333477 (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 158172; Kommentar in `prisma/schema.prisma` Zeilen 9495.
## 2.4 Cash als Ausgleichskonto
Cash ist kein vom Benutzer erfassbares Element, sondern das systemseitige Ausgleichskonto:
- Es startet mit `Plan.initialCash` (Phase 1).
- Es nimmt jährlich die **Spar-/Verzehrquote** (Einkommen nominale Ausgaben) auf.
- Es finanziert die **geplanten Sparraten** (3a-Beiträge, Sparbeiträge, Amortisationen, Tilgungen).
- Es empfängt die **Bezugsraten** aus Sonstigem Vermögen.
- Es empfängt an Übergängen **Kapitalzuflüsse** (Verkäufe, PK-/3a-Bezüge) und finanziert
**Sofort-Tilgungen** sowie **Zusatzinvestitionen** der Folgephase.
- Es nimmt an Übergängen **einmalige Sonderein-/ausgaben** auf (Erbschaft, Poolbau, Autokauf)
siehe [3.5.5](#355-cash-übergang-einmalige-sonderein-ausgaben).
- Es darf **negativ werden** dies ist die Definition einer Liquiditätslücke und wird rot
markiert, aber nicht automatisch korrigiert.
Cash ist die einzige Zeile der Matrix ohne `FinancialElement`-Datensatz. Zwei Zellen sind
dennoch bearbeitbar: die **erste** Phasenzelle (Cash-Anfangswert) und **jede Übergangszelle**
(einmalige Sonderein-/ausgaben).
Referenz: `src/lib/calculations.ts` (Jahresschleife und Übergang).
---
# 3. Funktionale Spezifikation
## 3.1 Authentifizierung und Benutzerkonto
### 3.1.1 Registrierung
- Öffentlich zugänglich (kein Einladungscode, keine Freischaltung).
- Benutzername: Muster `^[a-zA-Z0-9._-]{3,32}$` 3 bis 32 Zeichen, Buchstaben, Zahlen, Punkt,
Unterstrich, Bindestrich. Muss plan-übergreifend eindeutig sein (`User.username @unique`).
- Passwort: mindestens 6 Zeichen; wird mit bcrypt (Cost-Faktor 12) gehasht.
- Bei Erfolg wird sofort eine Session gesetzt (Login inbegriffen), HTTP 201.
- Bei belegtem Benutzernamen: HTTP 409 mit Meldung „Dieser Benutzername ist bereits vergeben."
Referenz: `src/lib/users.ts` Zeilen 622, `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 117140.
### 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=<pfad>` 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" | 1120 Zeichen |
| Haushaltsform | Auswahl | `SINGLE` | `SINGLE` / `COUPLE` |
| Name je Person | Text (optional) | leer | ≤ 60 Zeichen |
| Aktuelles Alter | Zahl | 35 | 0120 |
| Pensionierungsalter | Zahl | 65 | 30100 |
| 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 10121018, `src/app/api/plans/route.ts`
Zeilen 635.
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 5570.
### 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 11301156, `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 5577.
## 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 1923, `src/app/api/plans/[planId]/elements/route.ts` Zeilen 4354.
### 3.4.2 Element anlegen
Der Dialog „Finanzielles Element" fragt Kategorie, Zuordnung, Bezeichnung **und direkt die
Werte der ersten Lebensphase** ab. Nach dem Anlegen (`POST .../elements`) werden die Werte per
`PUT .../phase/<firstPhaseId>` gespeichert, sofern mindestens ein Feld gesetzt wurde. Der
Bezeichnungs-Default ist das Kategorie-Label.
`orderIndex` = bisheriges Maximum + 1; bestimmt die Reihenfolge innerhalb der Kategoriegruppe.
Referenz: `src/components/PlanView.tsx` Zeilen 799947.
### 3.4.3 Element bearbeiten und löschen
Ein Klick auf eine **Phasenzelle** öffnet den Dialog „Lebensphase: <Name>" 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/<id>`), im aktuellen UI aber nicht
angebunden.
### 3.4.4 Feldkatalog je Kategorie
Die angezeigten Felder hängen von drei Kontextgrössen ab:
- **`carried`** ob der Basiswert aus der Vorphase fortgeschrieben wird (ab Phase 2)
- **`ownerWorking`** ob der zugeordnete Besitzer in dieser Phase erwerbstätig ist
- **`durationYears`** Phasendauer (begrenzt z. B. die Ausfalljahre)
Referenz: `src/components/ElementDetail.tsx` Zeilen 124340.
#### INCOME (Einkommen)
**Einkommen ist der NETTOLOHN** der Betrag, der nach allen Lohnabzügen (AHV/ALV,
Pensionskasse, NBU) tatsächlich aufs Konto kommt. Das ist die für den Cash-Fluss richtige
Grösse und die, in der man denkt.
| Feld | JSON | Semantik |
|---|---|---|
| Jahreseinkommen NETTO, nominal (erstes Jahr) | `amount` | Basiswert Jahr 1. Ab Phase 2 vorbelegt mit dem fortgeschriebenen Wert, bewusst änderbar (Teilzeit, Beförderung). |
| ≈ real (heutige Kaufkraft) | | Read-only Info: `amount / deflatorStart` |
| Nominale Lohnerhöhung (%/Jahr) | `teuerungsausgleich` | Default 0 %. 0 % = nominal gleichbleibend, real sinkend. |
Für die **AHV** rechnet das Tool intern auf den Bruttolohn zurück siehe
[4.4.5](#445-netto-brutto-umrechnung-für-die-ahv). Warum die Definition überhaupt nötig war:
Für den Cash-Fluss sind beide Konventionen gleichwertig (`brutto Ausgaben inkl. Abzüge`
`netto Ausgaben ohne Abzüge`), weshalb die Ambiguität lange folgenlos blieb. Mit der
einkommensabhängigen AHV hängt daran aber eine Rente.
#### EXPENSE (Ausgaben)
Hinweistext: „Ausgaben werden REAL erfasst (in heutiger Kaufkraft)."
| Feld | JSON | Semantik |
|---|---|---|
| Jahresausgaben REAL (erstes Jahr) | `amount` | Basiswert in heutiger Kaufkraft. |
| ≈ nominal (in diesem Jahr) | | Read-only Info: `amount * deflatorStart` |
| Reale Mehrausgaben (%/Jahr) | `teuerungsausgleich` | **Zusätzlich** zur Inflation. 0 % = gleicher Lebensstandard. |
Die Asymmetrie (Einkommen nominal, Ausgaben real) ist der Kern des V5-Modells: Man kennt seinen
Lohn nominal, aber seinen Lebensstandard real.
#### AHV
| Zustand | Anzeige |
|---|---|
| Besitzer erwerbstätig | Eingabefeld **Geplante Ausfalljahre** (`gapYears`), 0 bis Phasendauer. Hilfetext: „Jedes Ausfalljahr kuerzt die spaetere Rente um 1/44." |
| Besitzer pensioniert | Nur Hinweistext die Rente wird automatisch berechnet. |
#### PENSION_FUND (Pensionskasse)
| Zustand | Felder |
|---|---|
| erwerbstätig, Phase 1 | **Aktueller PK-Wert** (`currentValue`), **Jährliche Einzahlung** (`annualContribution`), **Erwartete Rendite** (`expectedReturn`) |
| erwerbstätig, ab Phase 2 | **Startwert (fortgeschrieben)** (read-only), **Zusatzeinlage aus Kapital** (`additionalInvestment`), Einzahlung, Rendite |
| pensioniert | Hinweistext (Rente aus Umwandlungssatz bzw. „Vollständig bezogen") |
Wichtig (Hilfetext im UI): Die PK-Einzahlung „Fliesst NICHT in die Sparquote ein (bereits in den
Ausgaben beruecksichtigt)" Lohnabzüge sind im Nettoeinkommen bereits weg.
#### PILLAR_3A (Säule 3a)
Wie PK, aber:
- Die Einzahlung **zählt** zur Sparquote (verlässt das Cash).
- Das Feld ist auf `PILLAR_3A_MAX_ANNUAL` = **7'258 CHF** (2026, mit PK) hart geklammert.
- Bei Pensionierung: „Die Saeule 3a wird beim Pensions-Uebergang vollstaendig bezogen."
#### REAL_ESTATE (Immobilie)
| Feld | JSON | Semantik |
|---|---|---|
| Kaufpreis | `purchasePrice` | nur Phase 1 / Neukauf; ab Phase 2 read-only fortgeschrieben |
| Hypothek | `mortgage` | dito |
| Startwert Netto (fortgeschrieben) | | read-only ab Phase 2: **Verkehrswert** Resthypothek |
| Resthypothek (fortgeschrieben) | | read-only ab Phase 2 |
| Amortisation (CHF/Jahr) | `amortization` | endet, sobald die Hypothek abbezahlt ist |
| **Hypothekarzins (%/Jahr)** | `interestRate` | Zinssatz auf der **Restschuld** |
| **Hypothekarzins-Betrag (Beginn → Ende)** | | read-only: Zinsbetrag im ersten und im letzten Jahr der Phase |
| **Geschätzte Wertsteigerung (%/Jahr)** | `valueGrowth` | wirkt auf die **Liegenschaft** |
| **Sind die Zinsen bereits in den Ausgaben enthalten?** | `interestHandling` | `INCLUDED` (Default) / `ADD` |
Der Zinsbetrag sinkt automatisch mit der Amortisation das kann kein manueller
Ausgabenposten. Beispiel: Hypothek 1'000'000, Amortisation 10'000/Jahr, Zins 1 %, 10 Jahre →
Anzeige `10'000 → 9'100` (Jahr 1 auf 1'000'000, Jahr 10 auf 910'000).
Der Schalter `interestHandling` verhindert die Doppelzählung: Bestehende Pläne haben die
Zinsen im Ausgabenbetrag, deshalb ist `INCLUDED` der Default und das Tool zieht **nichts** ab.
Erst `ADD` rechnet die Zinsen dazu dann gehören sie aus dem Ausgabenbetrag entfernt. Der
Schalter sitzt bewusst auf der **Immobilie** und nicht auf dem Ausgaben-Element: Bei mehreren
Ausgaben-Elementen wäre sonst unklar, welches die Zinsen trägt (und zwei auf „Ja" würden
doppelt zählen).
#### OTHER_ASSET (Sonstiges Vermögen)
| Feld | JSON | Semantik |
|---|---|---|
| Startwert / Startwert (fortgeschrieben) | `startValue` | ab Phase 2 read-only |
| Zusatzinvestition aus Kapital | `additionalInvestment` | nur ab Phase 2 |
| Erwartete Rendite (%/Jahr) | `expectedReturn` | |
| Jährlicher Sparbeitrag | `annualContribution` | fliesst ins Vermögen, verlässt das Cash → **geplante Sparrate** |
| Jährliche Bezugsrate | `annualWithdrawal` | Entnahme aus dem Vermögen ins Cash → **geplante Verzehrrate** |
Die Bezugsrate ist das Instrument für den **laufenden** Kapitalverzehr *innerhalb* einer Phase.
Für einen **einmaligen** Zugriff *am Übergang* gibt es zusätzlich den Teilverkauf (3.5.2, 4.9.3).
#### OTHER_DEBT (Sonstige Schulden)
| Feld | JSON | Semantik |
|---|---|---|
| Restschuld / (fortgeschrieben) | `startValue` | ab Phase 2 read-only |
| Jährliche Tilgung | `annualRepayment` | verlässt das Cash → geplante Sparrate |
Schulden gehen mit **negativem** Vorzeichen ins Vermögen ein.
## 3.5 Übergänge
### 3.5.1 Konzept
Zwischen zwei Phasen liegt ein Übergang. Er ist der Ort, an dem einmalige Entscheide getroffen
werden. Fünf Kategorien haben Übergangs-Entscheide (`TRANSITION_CATEGORIES`):
`PENSION_FUND`, `PILLAR_3A`, `REAL_ESTATE`, `OTHER_ASSET`, `OTHER_DEBT`. Dazu kommt der
**Cash-Entscheid** (siehe [3.5.5](#355-cash-übergang-einmalige-sonderein-ausgaben)), der an
jedem Übergang zu treffen ist.
Für `INCOME`, `EXPENSE` und `AHV` erscheint: „Fuer diese Kategorie gibt es im Uebergang keine
Eingaben."
### 3.5.2 Normaler Übergang vs. Pensions-Übergang
Ein Übergang ist für ein Element ein **Pensions-Übergang**, wenn dessen Besitzer in der Von-Phase
erwerbstätig und in der Ziel-Phase pensioniert ist (`isRetirementTransition`). Nur
personenzugeordnete Elemente (nicht `HOUSEHOLD`) können das auslösen.
| Kategorie | Normaler Übergang (Vorbezug) | Pensions-Übergang |
|---|---|---|
| `PENSION_FUND` | Bezug? Kein Bezug / Bezug + Bruttobetrag (max. Endwert) + **Kapitalbezugssteuer (%)** | **Bezugsart**: Rente / Kapitalbezug / Kombination |
| `PILLAR_3A` | Bezug? Kein Bezug / Bezug + Bruttobetrag + **Kapitalbezugssteuer (%)** | Nur **Kapitalbezugssteuer (%)** Bezug ist zwingend vollständig |
| `REAL_ESTATE` | Halten (+ optionale **Sonderamortisation**) / Verkaufen (+ Verkaufspreis, Grundstückgewinnsteuer) | identisch |
| `OTHER_ASSET` | Halten / Verkaufen / **Teilverkauf** (+ Betrag) | identisch |
| `OTHER_DEBT` | Sofortige Tilgung (CHF) | identisch |
Ein **Vorbezug** (vor der Pensionierung, z. B. für Wohneigentum oder Selbstständigkeit) ist wie
der Bezug bei Pensionierung kapitalbezugssteuerpflichtig. Der eingegebene Betrag ist der
**Bruttobezug**: er wird in voller Höhe dem Vorsorgekapital entnommen, ins Cash fliesst der
Betrag nach Abzug der Steuer. Der Dialog zeigt die resultierende Netto-Auszahlung als
read-only Vorschau an.
Bei PK-Bezugsart:
- **Rente**: Feld Umwandlungssatz (Default 6 %)
- **Kapitalbezug**: Feld Kapitalbezugssteuer (Default 8 %)
- **Kombination**: beide Felder plus „Davon Kapitalbezug (CHF)", geklammert am Endwert
Referenz: `src/components/ElementDetail.tsx` Zeilen 342462.
### 3.5.3 Ampel-Logik: „offene" Entscheide
Ein Entscheid gilt als **beantwortet** (`isTransitionAnswered`), wenn das jeweilige
Entscheidungsfeld gesetzt ist:
| Kategorie | Beantwortet, wenn |
|---|---|
| `REAL_ESTATE`, `OTHER_ASSET` | `decision` gesetzt (inkl. `PARTIAL`) |
| `PENSION_FUND` | Pensions-Übergang: `payoutMode` gesetzt; sonst: `withdrawalMode` gesetzt |
| `PILLAR_3A` | Pensions-Übergang: **immer** beantwortet; sonst: `withdrawalMode` gesetzt |
| **Cash** | `mode` gesetzt (`isCashTransitionAnswered`) |
| alle anderen | immer beantwortet |
Der Übergangs-Spaltenkopf zeigt entweder „N offen" (Akzentfarbe) oder „geprüft" (grün, Häkchen).
Offene Zellen sind farblich hervorgehoben und zeigen „?".
**Ein Element ist am Übergang inaktiv** (`transitionInactive`), wenn es bereits verkauft/getilgt
ist **oder** wenn es eine PK/3a ist, deren Besitzer schon zu Beginn der Von-Phase pensioniert war
(dann ist bereits bezogen/verrentet). Inaktive Zellen zeigen „–" und sind nicht anklickbar.
Referenz: `src/components/PlanView.tsx` Zeilen 200232, `src/components/ElementDetail.tsx` Zeilen 107120.
### 3.5.4 Geführter Übergang (Review-Dialog)
Klick auf einen Übergangs-Spaltenkopf öffnet „Übergang prüfen: <Von> → <Nach>". Der Dialog
listet zuoberst den **Cash-Entscheid** (einmalige Sonderein-/ausgaben, betrifft jeden Übergang)
und darunter **alle** noch aktiven Elemente der Übergangs-Kategorien mit ihren Entscheidfeldern
und kontextabhängigen Hinweisen:
- PK/3a, normaler Übergang: „Hier könnten Sie optional Kapital beziehen."
- PK, Pensionierung: „Pensionierung: Bezugsart wählen (Rente / Kapital / Kombination)."
- 3a, Pensionierung: „Wird bei Pensionierung vollständig bezogen."
„Alle speichern" schreibt jeden Entscheid einzeln per `PUT`. Entscheidend: Die Formulare sind mit
`withTransitionDefaults` vorbelegt (Halten / Kein Bezug / Rente), damit ein blosses Speichern den
**sichtbaren** Default auch tatsächlich persistiert und die Ampel auf grün geht.
Referenz: `src/components/PlanView.tsx` (`TransitionReviewDialog`), `src/components/ElementDetail.tsx`
(`withTransitionDefaults`).
### 3.5.5 Cash-Übergang: einmalige Sonderein-/ausgaben
Einmalige Ereignisse (Erbschaft, Poolbau, Autokauf, grössere Anschaffung) werden **nicht** als
finanzielles Element modelliert, sondern als Entscheid auf dem **Cash-Konto am Phasenübergang**.
Sie belasten bzw. speisen das Cash direkt.
Der Entscheid hat vier Ausprägungen (`CashTransitionMode`):
| Modus | Bedeutung | Felder |
|---|---|---|
| `NONE` | **1:1 übernehmen** Cash läuft unverändert weiter (Default) | |
| `INFLOW` | **Einmaliger Zufluss** | Bezeichnung, Betrag (nominal), Steuer (%) |
| `OUTFLOW` | **Einmalige Kosten** | Bezeichnung, Betrag (real) |
| `BOTH` | Zufluss **und** Kosten am selben Übergang | beide Feldgruppen |
**Erfassungs-Konventionen** bewusst analog zu den laufenden Flows:
- **Zufluss: nominal erfasst, real angezeigt** (wie Einkommen). Man kennt den Betrag, der
effektiv aufs Konto kommt. Der Realwert erscheint read-only als Info.
- **Kosten: real erfasst, nominal angezeigt** (wie Ausgaben). Man denkt „ein Pool kostet heute
20'000"; die Inflation rechnet daraus den Betrag zum Ereigniszeitpunkt. Der Nominalwert
erscheint read-only als Info.
- **Steuersatz nur beim Zufluss**, Default 0 % (Erbschaften an direkte Nachkommen sind in den
meisten Kantonen steuerfrei). Ins Cash fliesst der Betrag nach Abzug der Steuer.
**Weder Zufluss noch Kosten gehen in die Spar-/Verzehrquote.** Sie sind keine laufenden Flows;
eine Erbschaft von 250'000 würde die Quote zu einem sinnlosen Ausschlag treiben. Sie wirken
ausschliesslich auf das Cash und damit auf Vermögensverlauf, Endvermögen und Ruinalter.
**Bedienung:** Klick auf eine Übergangszelle der Cash-Zeile öffnet den Dialog „Uebergang: Cash".
Die Zelle zeigt `1:1`, `+100'000`, `20'000` bzw. `+100'000 / 20'000`, solange offen ein `?`.
Der Entscheid ist Teil des geführten Übergangs (3.5.4) und zählt im „offen"-Badge mit eine
neu angelegte Phase erzeugt damit automatisch einen offenen Cash-Entscheid am neuen Übergang.
Referenz: `src/components/ElementDetail.tsx` (`CashTransitionFields`), `src/components/PlanView.tsx`
(`CashTransitionDialog`).
### 3.5.6 AHV-Prüfung am Pensions-Übergang
Die AHV-Rente hängt vom **massgebenden durchschnittlichen Jahreseinkommen (mdJE)** über die
ganze Beitragsdauer ab. Diese Grösse kann das Tool nicht allein aus dem Plan bestimmen: Die
Beitragspflicht beginnt mit 21, der Plan aber erst beim heutigen Alter. Bei einer 45-jährigen
Person liegen 24 Beitragsjahre vor dem Planbeginn.
Deshalb ist die Beitragskarriere **am Pensions-Übergang** zu prüfen dort, wo bereits die
PK-Bezugsart und der 3a-Bezug entschieden werden. Der Dialog zeigt:
| Feld | Art |
|---|---|
| Geplantes Durchschnittseinkommen (aus dem Plan) | read-only, real |
| Beitragsjahre im Plan | read-only |
| Durchschnittseinkommen vor Planbeginn (real) | Eingabe **nur wenn Alter bei Planbeginn > 21** |
| Ausfalljahre vor Planbeginn | Eingabe **nur wenn Alter bei Planbeginn > 21** |
| Massgebendes durchschnittliches Jahreseinkommen | read-only, live berechnet |
| Resultierende AHV-Rente pro Jahr | read-only, live berechnet |
Die beiden Eingabefelder erscheinen also nur, wenn sie fachlich gebraucht werden. Die Zelle
zeigt `Geprueft` bzw. `?`; der Entscheid zählt im „offen"-Badge mit. An allen **anderen**
Übergängen ist die AHV-Zelle inaktiv (``).
**Wichtig zum Feld „vor Planbeginn":** Der Wert ist **real** (heutige Kaufkraft). Der
AHV-Kontoauszug listet Einkommen historisch-nominal ein Lohn von 2008 steht dort mit dem
Betrag von 2008 und wäre zu tief. Die Zahl stammt idealerweise aus der **Rentenvorausberechnung**,
dort ist die Aufwertung bereits enthalten. Der Hilfetext im Feld sagt das.
**Sonderfall „bei Planbeginn bereits pensioniert":** Dann gibt es keinen Pensions-Übergang. Die
gleichen Felder erscheinen stattdessen in der **AHV-Phasenzelle der ersten Phase**, zusammen mit
der resultierenden Rente als Live-Vorschau.
Referenz: `src/components/ElementDetail.tsx` (`AhvReviewFields`), Formeln in
`src/lib/calculations.ts` (`ahvMdje`, `ahvAnnualPension`).
### 3.5.7 Warnhinweis bei Änderungen in früheren Phasen
Seit dem V3-Rework werden Werte **live fortgeschrieben** (Endwert = Startwert der Folgephase).
Eine Änderung in einer frühen Phase wirkt damit bis ans Planende durch oft unbemerkt. Seit
Roadmap Nr. 3 gilt das verschärft: Ein geändertes Einkommen in Phase 1 verschiebt über das mdJE
auch die AHV-Rente in Phase 5.
Beim Bearbeiten einer Phase, der noch Phasen folgen, erscheint deshalb ein rot abgesetzter
Warnhinweis mit der Anzahl betroffener Folgephasen. Er erscheint in:
- **Phasenzellen** (Werte eines Elements in einer Phase)
- **Phasen-Detail** (Bezeichnung/Dauer eine geänderte Dauer verschiebt alle Folgephasen)
Nicht in Übergangs-Dialogen (dort ist die Wirkung auf die Folgephase offensichtlich) und nicht
beim Anlegen eines Elements (dort gibt es noch nichts zu überschreiben).
Referenz: `src/components/ElementDetail.tsx` (`CarryWarning`).
## 3.6 Auswertung und Visualisierung
### 3.6.1 Anzeigemodus nominal / beide / real
Ein Umschalter oben in der Planansicht steuert die Darstellung aller Geldbeträge in Matrix und
Phasenköpfen:
| Modus | Darstellung |
|---|---|
| Nominal | `1'234'567` |
| Beide | `1'234'567 (890'123)` nominal, real in Klammern |
| Real | `890'123` |
„real" bedeutet kaufkraftbereinigt auf den **Planbeginn**: `nominal / Deflator`. Die Wahl wird in
`localStorage` unter `fpt-value-mode` gespeichert.
Es werden zwei verschiedene Deflatoren verwendet siehe [4.5.3](#453-die-drei-deflatoren).
Referenz: `src/components/PlanView.tsx` Zeilen 665699.
### 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 <Alter>"-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 | `<Name> <StartAlter> → <EndAlter>` |
| Einkommen | Jahr 1 → letztes Jahr |
| Ausgaben | Jahr 1 → letztes Jahr (nominal) |
| **Quote** bzw. **Verzehr** | Einkommen Ausgaben; Label wechselt auf „Verzehr", wenn Jahr 1 negativ |
| Geplante Sparrate | 3a + Sparbeitrag + Amortisation + Tilgung, im **ersten** Phasenjahr |
| Geplante Verzehrrate | Summe der Bezugsraten |
| Kapitalzufluss | nur wenn > 0: Verkäufe + PK-/3a-Bezüge aus dem Übergang **in** diese Phase |
| Kapitalinvestitionen | nur wenn > 0: Zusatzinvestitionen + Sofort-Tilgungen |
| **Einmaliger Zufluss** | nur wenn > 0: Bezeichnung + Betrag (grün), aus dem Übergang in diese Phase |
| **Einmalige Kosten** | nur wenn > 0: Bezeichnung + Betrag (rot) |
| Vermögen | Start → Ende (inkl. Cash) |
Die Einmalposten stehen bewusst **getrennt** von Kapitalzufluss/-investitionen: Eine Erbschaft
ist kein Verkaufserlös und ein Poolbau keine Kapitalinvestition eine Vermischung würde die
Kennzahl falsch beschriften.
Referenz: `src/components/PlanView.tsx` Zeilen 701769.
### 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/<planId>/export` liefert eine semikolon-getrennte CSV, eine Zeile pro Phase:
```
Phase;Typ;Dauer;Einkommen (Beginn);Ausgaben (Beginn);Quote (Beginn);Quote (Ende);Cash (Ende);Endvermoegen (nominal);Endvermoegen (real)
```
Bei vorhandenem Ruin folgt eine Schlusszeile `Ruin: Kapital aufgebraucht mit Alter <N>`.
Dateiname = Planname, nicht-alphanumerische Zeichen durch `_` ersetzt.
Referenz: `src/lib/calculations.ts` Zeilen 620648.
## 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 `<html>`. Ohne
gespeicherte Wahl folgt die Oberfläche `prefers-color-scheme`. Ein Inline-Script im `<head>`
setzt das Attribut vor dem ersten Paint (verhindert FOUC).
Alle Farben laufen über semantische CSS-Variablen (`--bg`, `--surface`, `--accent`, `--danger`, …).
Referenz: `src/lib/theme.ts`, `src/app/globals.css`, `src/app/layout.tsx` Zeilen 11811196.
### 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 60200, `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 4674.
---
# 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 103115, 132147.
## 4.3 Personen und Phasentyp
Für jede Person und jede Phase:
```
startAge = person.age + yearsBefore
endAge = startAge + duration
working = startAge < retirementAge
retiresAtStart = startAge === retirementAge
```
`yearsBefore` ist die Summe der Dauern aller vorangehenden Phasen. **Alter wird also relativ
gezählt** es gibt keine Kalenderdaten im Modell, nur „Jahre ab Planbeginn".
`maxDurationYears` = Minimum über `retirementAge startAge` aller noch erwerbstätigen Personen
(nur positive Werte), sonst `null`.
## 4.4 AHV-Rente
Die Rente hängt an zwei Achsen: der **Beitragsdauer** (Rentenskala 44) und dem **massgebenden
durchschnittlichen Jahreseinkommen** (mdJE).
### 4.4.1 Die amtliche Rentenformel (Skala 44)
Alle Schwellen sind Vielfache von `R0`, dem Mindestbetrag der vollen Monatsrente
(`AHV_MIN_MONTHLY_FULL = 1'260`). Mit `x = mdJE / (12 × R0)`:
```
mdJE ≤ 12 × R0 (= 15'120) → Rente = R0 (Mindestrente 1'260)
12 × R0 < mdJE ≤ 36 × R0 → Rente = R0 × (0.74 + 0.26 × x) Formel 1
36 × R0 < mdJE < 72 × R0 → Rente = R0 × (1.04 + 0.16 × x) Formel 2
mdJE ≥ 72 × R0 (= 90'720) → Rente = 2 × R0 (Maximalrente 2'520)
```
Der Wendepunkt liegt bei `36 × R0 = 45'360` → 1'915/Monat; beide Formelteile sind dort stetig.
**Quelle und Verifikation:** BSV/MAS „Berechnungsvorschriften der AHV/IV-Renten", gültig ab
1.1.2026 (liefert die Schwellenstruktur `12/36/72 × R0`), und die amtliche Tabelle
`318.117.1 Monatliche Vollrenten, Skala 44`. Die implementierte Formel reproduziert **alle 51
Zeilen dieser Tabelle exakt**; Stützstellen sind als Golden Tests hinterlegt (Kap. 8.2).
Die Funktion `ahvMonthlyFullPension(mdJE)` rechnet bewusst **stetig**. Amtlich wird das mdJE auf
Vielfache von `1.2 × R0` gestuft (daher die 51 Tabellenzeilen); für eine Planung ist der stetige
Wert näher an der Wahrheit, die Abweichung liegt unter 20/Monat.
### 4.4.2 Beitragskarriere und mdJE
Pro Person wird über die Phasen hinweg akkumuliert (`AhvCareer`):
| Feld | Bedeutung |
|---|---|
| `plannedAvgGrossIncome` | reales **Brutto**-Durchschnittseinkommen der Beitragsjahre **im Plan** (siehe 4.4.5) |
| `planYears` | Beitragsjahre im Plan = Σ (Phasendauer Ausfalljahre der Phase) |
| `yearsBeforePlan` | `max(0, Alter bei Planbeginn 21)` |
| `gapYearsInPlan` | Summe der Ausfalljahre im Plan |
Das mdJE ist der **gewichtete Mittelwert über alle Beitragsjahre**:
```
yearsBefore = max(0, yearsBeforePlan gapYearsBefore)
mdJE = (avgIncomeBefore × yearsBefore + plannedAvgIncome × planYears)
/ (yearsBefore + planYears)
```
Ausfalljahre reduzieren die **Gewichtung** (und die Skala), nicht das Durchschnittseinkommen
genau wie in der echten AHV: Wer zwei Jahre aussetzt, hat deswegen kein tieferes
Durchschnittseinkommen, aber weniger Beitragsjahre.
### 4.4.3 Warum real gerechnet wird
Sämtliche Einkommen gehen **real** (Kaufkraft bei Planbeginn) in das mdJE ein, und die
Schwellen sind heutige Werte. Das ist kein Vereinfachungs-, sondern ein Genauigkeitsentscheid:
Die echte AHV **wertet vergangene Einkommen auf** (Lohnindex) **und indexiert die Schwellen**
(Mischindex). Beide Bewegungen heben sich in realer Betrachtung weitgehend auf wer nominal
mittelt und gegen heutige Schwellen hält, vergleicht Franken von 2046 mit Schwellen von 2026 und
überschätzt die Rente systematisch.
> **Grössenordnung:** 45-jährig, 85'000 Lohn, +1.5 %/Jahr bei 2 % Inflation, 20 Erwerbsjahre.
> Nominal gemittelt: mdJE 98'276 → Maximalrente 32'760. Real gemittelt: mdJE 81'156 → 31'096.
> Differenz 1'664/Jahr, über 25 Rentenjahre rund 41'600 und der Fehler geht immer nach oben.
Die reale Berechnung eines Phasen-Durchschnitts erfolgt analytisch (`avgRealFlow`) als
geometrische Reihe mit `q = (1 + Lohnerhöhung) / (1 + Inflation)`.
**Bekannte Unschärfe:** Die Schwellen folgen dem Mischindex, die Aufwertung dem Lohnindex. Da
Löhne langfristig schneller steigen als Preise, ist die Deflationierung mit der Preisinflation
leicht **konservativ**. Bewusst in Kauf genommen, statt eine dritte Indexannahme einzuführen.
### 4.4.4 Woher die Karriere-Werte kommen
| Situation | Quelle |
|---|---|
| Person retires innerhalb des Plans | `transitionValues` des AHV-Elements am Pensions-Übergang |
| Person bei Planbeginn bereits pensioniert | `phaseValues` des AHV-Elements in der ersten Phase |
| Prüfung noch nicht erfolgt | Fallback: `avgIncomeBefore = plannedAvgIncome` |
Der Fallback ist bewusst gewählt: Ohne erfassten Wert gilt der geplante Durchschnitt als
Schätzung für die Jahre davor exakt der Wert, den der Dialog vorbelegt. Ein Fallback auf 0
würde die Rente still und massiv zu tief rechnen (bei einer 45-jährigen Person auf rund 45 %).
Einkommen wird einer Person nur zugerechnet, wenn das `INCOME`-Element ihr zugeordnet ist. Bei
einem **Einzelplan** zählt „Gemeinsam" (`HOUSEHOLD`) zur Person A; bei einem **Paar-Plan** nicht
(siehe [9.9](#99-gemeinsames-einkommen-zählt-bei-paaren-nicht-für-die-ahv)).
### 4.4.5 Netto-Brutto-Umrechnung für die AHV
Das Tool erfasst das Einkommen **netto**, die AHV bemisst sich am **Bruttolohn**. Beim Aufbau
der Karriere wird deshalb hochgerechnet:
```
plannedAvgGrossIncome = avgRealFlow(...) × AHV_GROSS_FROM_NET_FACTOR // = 1.12
```
Sämtliche Werte in `AhvCareer`, im Prüf-Dialog und im mdJE sind damit **brutto** auch das
Feld „Durchschnittliches Bruttoeinkommen vor Planbeginn", das der Benutzer aus der
Rentenvorausberechnung übernimmt (die ohnehin brutto-basiert ist). Eine Einheit im ganzen
Ablauf, keine Umrechnung an der Feldgrenze.
**Herleitung des Faktors** (Arbeitnehmer-Abzüge in % des Bruttolohns):
| Abzug | Satz | Bemerkung |
|---|---|---|
| AHV/IV/EO | 5.30 % | 10.6 % total, hälftig geteilt |
| ALV | 1.10 % | 2.2 % total, hälftig geteilt, bis 148'200 |
| NBU | ~1.00 % | variiert 0.41.6 %, teils vom Arbeitgeber getragen |
| PK | ~25 % | BVG-Altersgutschrift 7/10/15/18 % auf den **koordinierten** Lohn (Brutto 26'460, max. 90'720), Arbeitnehmer ≥ die Hälfte |
Total ~913 % je nach Alter und Lohn → Faktor `1/(1q)` = **1.10 bis 1.16**, Mittel **1.12**.
**Warum ein fixer Faktor genügt:** Das mdJE ist selbst ein Durchschnitt über die ganze
Beitragskarriere (44 Jahre). Der altersabhängige PK-Satz mittelt sich dabei heraus ein
Karriere-Faktor gegen einen Karriere-Durchschnitt ist konzeptionell stimmig, nicht bloss eine
Näherung. Die Restunschärfe (~3 %) ist deutlich kleiner als der Fehler, den die Umrechnung
behebt (~11 %, siehe 9.13).
**Grenzen:** Überobligatorische PK-Pläne, vom Arbeitgeber getragene NBU, Selbstständige und
Löhne über 148'200 (ALV sinkt auf 0.5 %) weichen ab. Der Faktor ist heute eine nicht
sichtbare Konstante; mit Roadmap Nr. 41 wird er in der Formel-Erklärung ausgewiesen.
Quellen: BSV „Beträge gültig ab 1.1.2026" (Koordinationsabzug 26'460, obere Limite 90'720,
„keine Änderung gegenüber 2025"); AHV-Merkblätter 2.01 (AHV/IV/EO) und 2.08 (ALV).
### 4.4.6 Jahresrente, Skala und Plafonierung
```
factor = max(0, (44 Ausfalljahre total) / 44) // Rentenskala 44
rente = round(ahvMonthlyFullPension(mdJE) × 13 × factor)
```
`AHV_PENSION_MONTHS = 13` seit 1.1.2026 gibt es die **13. Altersrente** (Art. 34bis AHVG).
Die Formel liefert Monatsrenten; der Jahresbetrag ist deshalb `× 13`, nicht `× 12`.
`AHV_MAX_ANNUAL_SINGLE` ist neu abgeleitet: `2 × R0 × 13 = 32'760`.
**Ehepaar-Plafonierung** nur bei `householdType = COUPLE` **und** wenn für **beide** Personen
eine Rente vorliegt:
```
cap = 32'760 × 1.5 = 49'140
falls (renteA + renteB) > cap:
beide Renten proportional kürzen: rente × cap / summe
```
Die Rente ist danach **nominal fix** sie wird über die Phasen hinweg nicht indexiert und
verliert damit real an Kaufkraft (siehe [9.11](#911-ahv-rente-wird-nach-der-pensionierung-nicht-indexiert)).
Sie fliesst in `renteTotal` und wird im Einkommen mitgeführt.
Referenz: `src/lib/calculations.ts` (`ahvMonthlyFullPension`, `ahvMdje`, `ahvAnnualPension`),
`src/lib/constants.ts`.
## 4.5 Nominal, real und die Deflatoren
### 4.5.1 Das V5-Modell
| Grösse | Erfassung | Indexierung über die Phasenjahre |
|---|---|---|
| Einkommen | **nominal** | `basis × (1 + Lohnerhöhung)^(t1)` |
| Renten (AHV, PK) | **nominal** | keine konstant |
| Ausgaben | **real** | real: `basis × (1 + reale Mehrausgaben)^(t1)`, 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 n1) × (1 + infl/100)^duration(n1)
```
### 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)^(duration1)` | Flow-Endwerte (Einkommen, Ausgaben, Quote) **eine Kaufkraft-Stufe weniger** |
Referenz: `src/lib/calculations.ts` Zeilen 427429; Anwendung in `src/components/PlanView.tsx` Zeilen 686687, 716717.
## 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 480490): 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 = hasCarry ? carry.propertyPurchase : round(phaseData.purchasePrice) // Kaufpreis
valueStart = hasCarry ? carry.propertyValue : round(phaseData.purchasePrice) // Verkehrswert
mortgage = hasCarry ? carry.mortgage : round(phaseData.mortgage)
amort = round(phaseData.amortization) // wird JÄHRLICH am Restsaldo gekappt
equity = valueStart mortgage → startValue, wealthStart
```
**Kaufpreis und Verkehrswert laufen getrennt.** Der Verkehrswert wächst mit `valueGrowth`, der
Kaufpreis bleibt der ursprüngliche er ist die Basis der Grundstückgewinnsteuer beim Verkauf
(4.9.4). Beide werden über die Phasen fortgeschrieben (`carry.propertyValue`,
`carry.propertyPurchase`); die Hypothek läuft als Saldo in der Jahresschleife (4.7).
**Die Wertsteigerung wirkt auf die Liegenschaft, nicht auf das Eigenkapital.** Das ist der
Hebel, der Wohneigentum finanziell auszeichnet: 1 % von 1'000'000 sind 10'000 im Jahr bei
einem Eigenkapital von 100'000 also 10 % darauf. Würde man die Wertsteigerung auf das
Eigenkapital rechnen, ergäbe dieselbe Annahme nur 1'000 im Jahr:
> Kaufpreis 1'000'000 · Hypothek 900'000 · EK 100'000 · 1 %/J. · Amortisation 10'000/J. · 10 J.
> → korrekt **304'622**; auf das Eigenkapital gerechnet nur **210'462**. Differenz **94'160**,
> und sie wächst mit dem Belehnungsgrad.
Der angezeigte Elementwert bleibt das **Eigenkapital** (`Verkehrswert Restschuld`); nur die
Basis der Verzinsung ist die Liegenschaft.
Falls **nicht** fortgeschrieben und **nicht** Phase 1 (= Neukauf in einer späteren Phase):
`investmentsFromCash += max(0, equity)` das Eigenkapital wird aus dem Cash finanziert.
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)^(t1))
// 2. Ausgaben (real → nominal) + Hypothekarzins
inflFactor = cumInflStart × (1 + infl/100)^(t1)
expenseRealBase = Σ (exp.basis × (1 + exp.idx/100)^(t1))
interestNominal = Σ (re.mortgage × re.interestRate/100) // nur wo interestHandling = ADD
expenseNominal = expenseRealBase × inflFactor + interestNominal
expenseReal = expenseRealBase + interestNominal / inflFactor
// 3. Quote
quote = incomeFlow expenseNominal
// 4. YearPoint 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
re.value ×= (1 + re.growth/100) // Wertsteigerung auf die LIEGENSCHAFT
für jede Schuld d:
pay = min(d.repay, d.owed)
d.owed = pay
debtRates += pay
falls t === 1 → plannedSaveRate = fixedRatesTotal + debtRates
// 7. Cash fortschreiben
cash += quote fixedRatesTotal debtRates + cashFromWithdraw
falls cash < 0 → cashNegative = true
// 8. Ruin prüfen (Gesamtvermögen zum Jahresende)
total = cash + Σ asset.value + Σ (re.value re.mortgage) + Σ (d.owed)
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 365425.
## 4.8 Endwerte und Phasen-Kennzahlen
```
Einkommen: startValue = basis
endValue = basis × (1 + idx/100)^(duration1)
Ausgaben: startValue = basis × cumInflStart
endValue = basis × (1 + idx/100)^(duration1) × flowDeflatorEnd
Assets: endValue = a.value (nach der Jahresschleife)
Immobilie: endValue = re.value re.mortgage (Verkehrswert inkl. Wertsteigerung, minus Restschuld)
Schulden: endValue = d.owed (0, falls getilgt; + Notiz „Wird getilgt")
```
Aggregate:
```
startWealthNominal = Σ Element-Startwerte + cashStart
endWealthNominal = Σ Element-Endwerte + cashEnd
endWealthReal = endWealthNominal / cumulativeInflationEnd
isConsumption = quotaStart < 0
incomplete = cashNegative // „roter Status" = Liquiditätslücke
capitalInflow = incomingInflow // aus dem Übergang IN diese Phase
capitalInvest = investmentsFromCash + incomingImmediateRepay
oneOffInflow = incomingOneOffInflow // einmaliger Zufluss (netto nach Steuer)
oneOffOutflow = incomingOneOffOutflow // einmalige Kosten (nominal)
```
## 4.9 Der Übergang
Nach jeder Phase (auch nach der letzten) läuft die Übergangs-Logik. Sie liest
`transitionValues[phase.id]` der Übergang ist also am **Von**-Phasen-Schlüssel gespeichert.
```
ownerRetiresNext = owner existiert
∧ nextPhase existiert
∧ owner ist in DIESER Phase erwerbstätig
∧ owner.age + yearsBefore + duration >= owner.retirementAge
```
`INCOME`/`EXPENSE` sowie nicht-aktive Elemente überspringen die Logik (nur `hasCarry = true`).
### 4.9.1 PENSION_FUND
**Pensions-Übergang** (`ownerRetiresNext`), `value = ec.endValue`, Default-Modus `PENSION`:
| `payoutMode` | Wirkung |
|---|---|
| `CAPITAL` | `txInflow += round(value × (1 capitalTaxRate/100))`; `carry.value = 0`; `pkPensionAnnual = 0` |
| `PENSION` | `carry.pkPensionAnnual = round(value × conversionRate / 100)`; `carry.value = 0` |
| `COMBI` | `capital = min(value, capitalAmount)`; `txInflow += round(capital × (1 tax/100))`; `pkPensionAnnual = round((value capital) × conversionRate / 100)`; `carry.value = 0` |
**Normaler Übergang (Vorbezug)** brutto entnommen, netto ins Cash:
```
withdrawal = min(ec.endValue, round(td.withdrawal)) // brutto
carry.value = ec.endValue withdrawal
txInflow += round(withdrawal × (1 capitalTaxRate/100))
```
### 4.9.2 PILLAR_3A
**Pensions-Übergang**: immer vollständiger Bezug
`txInflow += round(ec.endValue × (1 capitalTaxRate/100))`; `carry.value = 0`.
**Normaler Übergang (Vorbezug)**: wie PK Bruttoentnahme, Netto-Zufluss nach
Kapitalbezugssteuer.
### 4.9.3 OTHER_ASSET
| `decision` | Wirkung |
|---|---|
| `SELL` | `txInflow += ec.endValue`; `carry.status = "SOLD"` (kein Steuerabzug) |
| `PARTIAL` | `part = min(ec.endValue, partialSaleAmount)`; `txInflow += part`; `carry.value = ec.endValue part` Rest bleibt **aktiv** und wächst weiter |
| `HOLD` / sonst | `carry.value = ec.endValue` |
Der Teilverkauf (Roadmap Nr. 42) fliesst über `txInflow` ins Cash der Folgephase und erscheint
dort im Phasenkopf als **„Kapitalzufluss"** (`capitalInflow`) das „zu investierende Kapital".
Er ist die **einmalige** Entnahme am Übergang; die laufende Entnahme *während* einer Phase
bleibt die jährliche Bezugsrate (`annualWithdrawal`, 4.6.6). Beide dürfen koexistieren.
### 4.9.4 REAL_ESTATE
Gelesen werden die laufenden Werte aus der Jahresschleife (`re.value`, `re.mortgage`,
`re.purchase`).
`decision = "SELL"`:
```
gain = max(0, salePrice re.purchase) // URSPRÜNGLICHER Kaufpreis, nicht der Verkehrswert
tax = gain × (saleTaxRate / 100)
txInflow += round(salePrice re.mortgage tax)
carry.status = "SOLD"
```
Der Nettoerlös ist Verkaufspreis minus Hypothekenablösung minus Grundstückgewinnsteuer. Ein
Verlustverkauf erzeugt keine Steuer (`gain` bei 0 geklammert).
**Die Steuer bemisst sich am ursprünglichen Kaufpreis**, nicht am zwischenzeitlich gestiegenen
Verkehrswert deshalb führt das Modell beide getrennt (4.6.5). Nicht modelliert sind
wertvermehrende Investitionen und die Haltedauer-Abstufung (Roadmap Nr. 23).
**Halten (`decision ≠ SELL`)** mit optionaler **Sonderamortisation** (Roadmap Nr. 15):
```
extra = min(re.mortgage, extraAmortization) // am Restsaldo gekappt
txImmediateRepay += extra // Einmaltilgung aus dem Cash
carry.mortgage = re.mortgage extra
carry.propertyValue / carry.propertyPurchase // fortgeschrieben
```
Die Einmaltilgung bringt `REAL_ESTATE` auf Augenhöhe mit `OTHER_DEBT` (dort schon
`immediateRepayment`, 4.9.5): Sie senkt die Restschuld und damit die künftige Zinslast und
erscheint im Phasenkopf als **„Kapitalinvestition"** (`capitalInvest`). Damit lässt sich die
**indirekte Amortisation** mechanisch nachbilden 3a wachsen lassen, bei Pensionierung ins
Cash beziehen, dann per Sonderamortisation die Hypothek tilgen.
Die **Steuerwirkung** der indirekten Amortisation (3a-Abzug, erhaltener Zinsabzug) ist bewusst
**nicht** modelliert; sie gehört zum zurückgestellten Steuer-Bündel (siehe 9.14).
### 4.9.5 OTHER_DEBT
```
carry.owed = ec.endValue
immediate = min(carry.owed, round(td.immediateRepayment))
falls immediate > 0:
carry.owed = immediate
txImmediateRepay += immediate
falls carry.owed === 0 → carry.status = "SETTLED"
```
### 4.9.6 Cash: einmalige Sonderein-/ausgaben
Gelesen wird `phase.cashTransition` der Entscheid hängt an der **Von**-Phase. Er wird nur
ausgewertet, **wenn eine Folgephase existiert**; nach der letzten Phase gibt es keinen Übergang,
ein dort erfasster Betrag bleibt wirkungslos.
Der Umrechnungskurs zwischen real und nominal ist an dieser Grenze `cumulativeInflation`, also
der **Bestands-Deflator am Phasenende** (vgl. [4.5.3](#453-die-drei-deflatoren)) denn Cash ist
ein Bestand, und das Ereignis fällt exakt auf die Grenze.
```
mode = cashTransition.mode ?? "NONE"
falls mode ∈ {INFLOW, BOTH}: // nominal erfasst
brutto = round(inflowAmount)
txOneOffInflow = round(brutto × (1 inflowTaxRate/100))
falls mode ∈ {OUTFLOW, BOTH}: // real erfasst
txOneOffOutflow = round(outflowAmount × cumulativeInflation)
```
Beide Grössen fliessen ausschliesslich ins Cash der Folgephase (4.9.7) und **nicht** in die
Jahresschleife damit bleiben sie per Konstruktion aus Einkommen, Ausgaben und Quote heraus.
Ein Zufluss/eine Kostenposition, die das Cash unter 0 drückt, wird über den bestehenden
Startwert-Check der Folgephase (`cashNegative = cash < 0`) automatisch als Liquiditätslücke
erkannt.
### 4.9.7 Abschluss des Übergangs
```
cashCarryIn = cashEnd + txInflow + txOneOffInflow txImmediateRepay txOneOffOutflow
incomingInflow = txInflow // Kopf-Kennzahlen der Folgephase
incomingImmediateRepay = txImmediateRepay
incomingOneOffInflow = txOneOffInflow // inkl. Bezeichnung
incomingOneOffOutflow = txOneOffOutflow // inkl. Bezeichnung
yearsBefore += duration
```
## 4.10 Ergebnisstruktur
```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_MIN_MONTHLY_FULL` | 1'260 | **R0** Mindestbetrag der vollen Monatsrente (Skala 44). Alle Schwellen der Rentenformel sind Vielfache davon |
| `AHV_PENSION_MONTHS` | 13 | Rentenzahlungen pro Jahr 13. Altersrente ab 1.1.2026 |
| `AHV_CONTRIBUTION_START_AGE` | 21 | Beitragspflicht ab 1. Januar nach dem 20. Geburtstag |
| `AHV_MAX_ANNUAL_SINGLE` | 32'760 | **abgeleitet**: `2 × R0 × 13` |
| `AHV_GROSS_FROM_NET_FACTOR` | 1.12 | Netto → Brutto für die AHV; Herleitung siehe [4.4.5](#445-netto-brutto-umrechnung-für-die-ahv) |
| `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 14): 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 | 180 |
| `cashTransition` | Json? | Cash-Entscheid beim Übergang **nach** dieser Phase (siehe 5.4.5) |
| `createdAt` / `updatedAt` | DateTime | |
| | | `@@unique([planId, sequenceNumber])` |
`cashTransition` liegt an der Phase und nicht in `ElementTransitionValue`, weil Cash kein
`FinancialElement` ist und damit keine `elementId` besitzt. Die Verschlüsselung folgt derselben
Logik wie dort: Der Übergang gehört der **Von**-Phase.
**FinancialElement**
| Feld | Typ | Constraints |
|---|---|---|
| `id` | String | PK, `cuid()` |
| `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 46, `src/lib/elements.ts` Zeilen 4851.
### 5.4.3 JSON-Payload `PhaseData`
| Feld | Kategorien | Zod-Regel |
|---|---|---|
| `amount` | INCOME, EXPENSE | ≥ 0 |
| `teuerungsausgleich` | INCOME, EXPENSE | 20 bis 50 |
| `gapYears` | AHV | Integer ≥ 0 |
| `avgIncomeBefore` | AHV nur wenn bei Planbeginn **bereits pensioniert** | ≥ 0, **real** |
| `gapYearsBefore` | AHV dito | Integer 050 |
| `currentValue` | PENSION_FUND, PILLAR_3A | ≥ 0 |
| `startValue` | OTHER_ASSET, OTHER_DEBT | ≥ 0 |
| `expectedReturn` | PK, 3a, OTHER_ASSET | 50 bis 100 |
| `annualContribution` | PK, 3a, OTHER_ASSET | ≥ 0 |
| `annualWithdrawal` | OTHER_ASSET | ≥ 0 |
| `additionalInvestment` | PK, 3a, OTHER_ASSET (ab Phase 2) | ≥ 0 |
| `purchasePrice` | REAL_ESTATE | ≥ 0 |
| `mortgage` | REAL_ESTATE | ≥ 0 |
| `amortization` | REAL_ESTATE | ≥ 0 |
| `interestRate` | REAL_ESTATE Hypothekarzins %/Jahr | 020 |
| `interestHandling` | REAL_ESTATE Doppelzählungs-Schalter | `INCLUDED` (Default) \| `ADD` |
| `valueGrowth` | REAL_ESTATE Wertsteigerung %/Jahr auf die Liegenschaft | 20 bis 20 |
| `annualRepayment` | OTHER_DEBT | ≥ 0 |
### 5.4.4 JSON-Payload `TransitionData`
| Feld | Kategorien | Zod-Regel |
|---|---|---|
| `reviewed` | AHV (Pensions-Übergang) Beitragskarriere geprüft | Boolean |
| `avgIncomeBefore` | AHV (Pensions-Übergang) | ≥ 0, **real** |
| `gapYearsBefore` | AHV (Pensions-Übergang) | Integer 050 |
| `withdrawalMode` | PK, 3a (normal) | `NONE` \| `AMOUNT` |
| `withdrawal` | PK, 3a (normal) | ≥ 0, **brutto** |
| `payoutMode` | PK (Pensionierung) | `CAPITAL` \| `PENSION` \| `COMBI` |
| `capitalAmount` | PK (COMBI) | ≥ 0 |
| `conversionRate` | PK | 020 |
| `capitalTaxRate` | PK + 3a, **sowohl Vorbezug als auch Pensionierung** | 0100 |
| `decision` | REAL_ESTATE, OTHER_ASSET | `HOLD` \| `SELL` \| `PARTIAL` |
| `salePrice` | REAL_ESTATE | ≥ 0 |
| `partialSaleAmount` | OTHER_ASSET (Teilverkauf) | ≥ 0 |
| `extraAmortization` | REAL_ESTATE (Sonderamortisation) | ≥ 0 |
| `saleTaxRate` | REAL_ESTATE | 0100 |
| `immediateRepayment` | OTHER_DEBT | ≥ 0 |
### 5.4.5 JSON-Payload `CashTransitionData`
Liegt in `Phase.cashTransition`. Validierung über `cashTransitionSchema`.
| Feld | Bedeutung | Zod-Regel |
|---|---|---|
| `mode` | Entscheid | `NONE` \| `INFLOW` \| `OUTFLOW` \| `BOTH` |
| `inflowLabel` | Bezeichnung des Zuflusses (z. B. „Erbschaft") | ≤ 120 Zeichen |
| `inflowAmount` | Betrag **nominal** | ≥ 0 |
| `inflowTaxRate` | Steuer auf den Zufluss, Default 0 % | 0100 |
| `outflowLabel` | Bezeichnung der Kosten (z. B. „Poolbau") | ≤ 120 Zeichen |
| `outflowAmount` | Betrag **real** (heutige Kaufkraft) | ≥ 0 |
Pro Übergang ist **genau ein** Zufluss und **eine** Kostenposition möglich siehe
[9.7](#97-nur-ein-zufluss-und-eine-kostenposition-pro-übergang).
Alle Schemas verwenden `.strip()` **unbekannte Felder werden verworfen**, nicht abgelehnt.
Beim Lesen aus der DB gilt zusätzlich: schlägt `safeParse` fehl, wird `{}` zurückgegeben
(`parsePhaseData` / `parseTransitionData`) korrupte Daten führen also nie zu einem Absturz,
sondern zu leeren Werten.
### 5.4.6 Migrationshistorie
| Migration | Inhalt |
|---|---|
| `20260708171600_init` | Initiales Schema |
| `20260709010000_add_transition_automation` | Übergangs-Automatik |
| `20260709120000_rework_realestate` | Immobilien-Überarbeitung |
| `20260711090000_multi_user` | User-Entität, Ownership |
| `20260713100000_element_model_rework` | Elemente auf Plan-Ebene, Phase-/Transition-Werte als JSON |
| `20260713150000_profile_to_plan_v3` | Grundprofil von Household auf Plan verschoben |
| `20260714120000_person_name` | `Person.name` |
| `20260715120000_plan_initial_cash` | `Plan.initialCash` |
| `20260716210000_drop_phase_inflation_rate` | `Phase.inflationRate` entfernt (Inflation ist plan-weit) |
| `20260716230000_phase_cash_transition` | `Phase.cashTransition` (JSONB) für einmalige Sonderein-/ausgaben |
## 5.5 Frontend-Architektur
### 5.5.1 Datenfluss
```
page.tsx (Client)
└─ GET /api/auth/me → username
└─ AppShell
├─ GET /api/plans → Planliste (Sidebar, Kacheln)
├─ GET /api/plans/<id> → { plan: PlanInput, computed: PlanComputed }
│ └─ PlanView (Matrix, Dialoge) → onChanged() → silent reload
│ └─ Dashboard (Kennzahlen, 3 Grafiken, Vergleich, Export)
└─ Dialoge: PlanDialog, ScenarioDialog
```
Es gibt **keinen State-Management-Layer** (kein Redux/Zustand/React Query). Der Zustand lebt in
`AppShell` (Planliste, ausgewählter Plan, Detail) und lokal in den Dialogen. Nach jeder Mutation
wird der Plan neu geladen; die Berechnung kommt immer vom Server.
### 5.5.2 Komponenten
| Komponente | Zeilen | Rolle |
|---|---|---|
| `AppShell` | 433 | Layout, Sidebar, Planliste, Laden, Plan-/Szenario-Dialoge |
| `PlanView` | 1304 | Matrix, Spaltenaufbau, Kontextbildung, alle Zell-Dialoge |
| `ElementDetail` | 548 | Feldgruppen je Kategorie/Kontext, Speicherlogik, Übergangs-Defaults |
| `Dashboard` | 187 | Kennzahlkarten, 3 Grafiken, Planvergleich, CSV-Link |
| `FormField` | 275 | `NumberField`, `MoneyInput`/`MoneyField`, `TextField`, `SelectField` |
| `ProfileMenu` | 166 | Benutzer, Theme-Wahl, Passwortwechsel, Logout |
| `Timeline` | 108 | Zeitachse mit Pensions- und Ruin-Markern |
| `WealthChart` | 106 | Vermögensverlauf, mehrere Serien |
| `PlanProfileFields` | 101 | Wiederverwendete Grundprofil-Felder |
| `PhaseDetail` | 95 | Phase bearbeiten/löschen |
| `SparquoteChart` | 64 | Einkommen/Ausgaben pro Jahr mit Spar-/Verzehrbändern |
| `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 479480):
Beim Wechsel von Zelle zu Zelle wird ein Neuaufbau erzwungen, damit der lokale Formularzustand
nicht vom vorher geöffneten Element übrig bleibt.
## 5.6 Sicherheit
| Aspekt | Umsetzung |
|---|---|
| Passwortspeicherung | bcrypt, Cost 12 |
| Session | JWT HS256, 30 Tage, HttpOnly-Cookie, `sameSite=lax`, `secure` in Produktion |
| Secret | `SESSION_SECRET` aus der Umgebung; Fehlen wirft beim ersten Zugriff |
| Autorisierung | Middleware (grob) + Ownership-Query je Route (fein) |
| Information Disclosure | Fremde/nicht existierende Ressourcen → einheitlich 404; Login-Fehler unspezifisch |
| Eingabevalidierung | Zod an jeder API-Grenze; `.strip()` gegen Mass-Assignment |
| SQL-Injection | Prisma (parametrisiert) |
| XSS | React-Escaping; einziges `dangerouslySetInnerHTML` ist das statische Theme-Init-Script |
| CSRF | Kein Token. Schutz beruht allein auf `sameSite=lax` siehe 9.4 |
## 5.7 Konfiguration
| Variable | Zweck |
|---|---|
| `DATABASE_URL` | Postgres-Connection-String |
| `SESSION_SECRET` | JWT-Signaturschlüssel (z. B. `openssl rand -hex 32`) |
| `POSTGRES_PASSWORD` | Nur für docker-compose: Passwort des `db`-Containers |
| `NODE_ENV` | Steuert u. a. das `secure`-Flag des Cookies und den Prisma-Global-Cache |
Es gibt **kein** konfiguriertes Login-Passwort: Konten werden über die Registrierung angelegt.
---
# 6. API-Referenz
Alle Routen liefern JSON. Fehlerformat einheitlich: `{ "error": "<Meldung>" }` (bei
Zod-Fehlern in `POST /api/plans`: `{ "error": <flatten()-Objekt> }`).
Alle Routen ausser `login`/`register` erfordern ein gültiges Session-Cookie.
## 6.1 Authentifizierung
| Methode | Pfad | Body | Antwort |
|---|---|---|---|
| POST | `/api/auth/register` | `{ username, password }` | 201 `{ ok, username }` + Cookie · 400 Validierung · 409 Name vergeben |
| POST | `/api/auth/login` | `{ username, password }` | 200 `{ ok, username }` + Cookie · 400 · 401 |
| POST | `/api/auth/logout` | | 200 `{ ok }`, Cookie gelöscht |
| GET | `/api/auth/me` | | 200 `{ user: { id, username, createdAt } }` · 401 · 404 |
| POST | `/api/auth/change-password` | `{ currentPassword, newPassword }` | 200 `{ ok }` · 400 · 401 |
## 6.2 Pläne
### `GET /api/plans`
Liste der eigenen Pläne, sortiert nach `createdAt` aufsteigend.
```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` 1120; `inflationRateDefault` 20…50; `persons` 12 Einträge;
`age` 0120; `retirementAge` 30100; `name` je Person ≤ 60. Zusätzlich Konsistenzregel
SINGLE=1 / COUPLE=2 Personen. → 201 `{ plan: { id } }`
### `GET /api/plans/<planId>`
Liefert **Eingabe und Berechnung** in einem Zug:
```json
{ "plan": <PlanInput>, "computed": <PlanComputed> }
```
Dies ist der einzige Endpunkt, der die Berechnung ausführt (neben `export`). → 404 wenn fremd.
### `PATCH /api/plans/<planId>`
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/<planId>`
→ 200 `{ ok: true }`, Cascade-Löschung.
### `POST /api/plans/<planId>/scenario`
```json
{ "name": "Frühpensionierung", "branchFromPhaseId": "<phaseId>" }
```
→ 201 `{ planId: "<neue Id>" }`
### `GET /api/plans/<planId>/export`
`text/csv; charset=utf-8`, `Content-Disposition: attachment`.
## 6.3 Phasen
### `POST /api/plans/<planId>/phases`
Body optional: `{ name?, durationYears? }`.
Hängt eine Phase am Ende an, kappt die Dauer, vergibt Default-Name, legt vorbelegte
`ElementPhaseValue` für alle aktiven Elemente an (alles in einer Transaktion).
→ 201 `{ phase: { id } }`
### `PUT /api/phases/<phaseId>`
`{ name?, durationYears? }` `durationYears` 180, wird gekappt.
→ 200 `{ phase: { id } }`
### `DELETE /api/phases/<phaseId>`
Nur die letzte Phase. → 200 `{ ok }` · 400 „Nur die letzte Phase kann geloescht werden."
### `PUT /api/phases/<phaseId>/cash-transition`
Body = `CashTransitionData` (siehe 5.4.5). Speichert den Cash-Entscheid für den Übergang **nach**
dieser Phase (einmalige Sonderein-/ausgaben). Schreibt die Spalte `Phase.cashTransition`.
→ 200 `{ ok }` · 404 wenn die Phase nicht dem Benutzer gehört.
## 6.4 Elemente
### `POST /api/plans/<planId>/elements`
`{ category, name, ownerRole? }` → 201 `{ element: { id } }`
- `PERSON_ONLY_CATEGORIES` ohne Person → 400
- fehlendes `ownerRole` sonst → `HOUSEHOLD`
- `orderIndex` = Max + 1
### `PATCH /api/elements/<elementId>`
`{ name }` (1120). → 200 `{ ok }`
### `DELETE /api/elements/<elementId>`
→ 200 `{ ok }`, Cascade auf alle Phasen-/Übergangswerte.
### `PUT /api/elements/<elementId>/phase/<phaseId>`
Body = `PhaseData`. **Upsert** auf `@@unique([elementId, phaseId])`. Prüft zusätzlich, dass die
Phase zum selben Plan gehört wie das Element. → 200 `{ ok }`
### `PUT /api/elements/<elementId>/transition/<fromPhaseId>`
Body = `TransitionData`. **Upsert** auf `@@unique([elementId, fromPhaseId])`. → 200 `{ ok }`
---
# 7. Betrieb und Deployment
## 7.1 Container
**Dockerfile** vierstufiger Multi-Stage-Build auf `node:20-alpine`:
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 41 Tests (AHV-Rentenformel,
Immobilie, Teilverkauf, Sonderamortisation, AHV einkommensabhängig, „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 |
|---|---|
| **Amtliche Tabelle 318.117.1** | `ahvMonthlyFullPension` reproduziert 11 Stützstellen der amtlichen Rentenskala exakt (Mindestrente, Formel 1, Wendepunkt 45'360 → 1'915, Formel 2, Maximalrente) |
| **Kappung / Stetigkeit** | unter 15'120 → 1'260, über 90'720 → 2'520; kein Sprung am Wendepunkt |
| **AHV volle Karriere** | mdJE 100'000 → Maximalrente 32'760 |
| **AHV abgestuft / Mindestrente** | mdJE 60'000 → Formel 2; mdJE 10'000 → 1'260 × 13 |
| **AHV Vorgeschichte dominiert** | 39 Jahre à 40'000 + 5 Jahre à 200'000 → mdJE 58'182 |
| **AHV Ausfalljahre** | 4 Ausfalljahre → `32'760 × 40/44`; Ausfalljahre im Plan senken nur die Skala, nicht das mdJE |
| **AHV ohne Prüfung** | ohne erfassten Wert gilt der geplante Durchschnitt (nicht 0) |
| **AHV bereits pensioniert** | Karriere aus der Phasenzelle der ersten Phase |
| **AHV Plafonierung** | zwei Maximalrenten im Paar-Plan → gekappt auf `32'760 × 1.5` |
| **AHV Netto → Brutto** | mdJE = `70'000 × 1.12`, nicht 70'000 |
| **Immobilie: Hebel** | 1 Mio / 900k Hypothek / 1 % / 10 J. → Endwert 304'622; deutlich mehr als eine Wertsteigerung auf das Eigenkapital ergäbe |
| **Immobilie: ohne Wertsteigerung** | Verhalten unverändert (1 Mio 800k = 200'000) |
| **Immobilie: Zins-Schalter** | `INCLUDED` → kein Cash-Abzug; `ADD` → 10 × 1 % von 900'000 = 90'000 |
| **Immobilie: Zins sinkt** | Jahr 1: 10'000, Jahr 10: 9'100; schlägt auf die Quote durch |
| **Immobilie: Verkauf** | Grundstückgewinnsteuer auf `Verkaufspreis Kaufpreis`, nicht auf den Verkehrswert |
| **Teilverkauf (42)** | Betrag ins Cash (→ `capitalInflow`), Rest bleibt aktiv; am Endwert gekappt; Halten/Vollverkauf unverändert |
| **Sonderamortisation (15)** | Einmaltilgung senkt Restschuld, belastet Cash (→ `capitalInvest`), am Restsaldo gekappt |
| Test 1 Ansparen | Einkommen +2 % nominal, Ausgaben real flach: Endvermögen 761'654 nominal / 565'928 real (±1 %), kein negatives Cash |
| Test 2 Verzehr/Ruin | Rente nominal fix 60k, Ausgaben real 100k, Vermögen 900k @3 %: `ruinAge === 94` |
| Test 3 Cash-Ausgleich | Sparrate 6'364: `cashEnd === 5472`, nie negativ |
| Test 4 Liquiditätslücke | Sparrate 10'000: `cashEnd === -5436`, `cashNegative === true` |
| Ausgaben real → nominal | Jahr 1 nominal = real; Jahr 5 = `100'000 × 1.02^4` |
| Einkommen flach | Lohnerhöhung 0 % → `incomeStart === incomeEnd === 80'000` |
| Bezugsrate | 3 × 10'000 Entnahme → `cashEnd === 30'000`, Asset-Endwert 0, `plannedWithdrawRate === 10'000` |
| Kapitalzufluss/-investition | Verkauf in P1, Reinvestition in P2: `capitalInflow === 10'000`, `capitalInvest === 10'000`, `cashStart === 0`, `startWealthNominal === 10'000` (kein Doppelzählen) |
| Cash-Anfangswert | `initialCash` 50'000 fliesst in Phase 1 ein |
| **Tilgung stoppt** | Schuld 25'000, Tilgung 10'000/J., 5 Jahre: Gesamtabfluss 25'000 (nicht 50'000), `cashEnd === 75'000`, Restschuld 0 |
| **Amortisation stoppt** | Hypothek 15'000, Amortisation 10'000/J., 4 Jahre: `cashEnd === 85'000` (nicht 60'000), Immobilie schuldenfrei |
| **Vorbezugssteuer** | PK-Vorbezug 100'000 brutto @ 8 %: `capitalInflow === 92'000`, Restkapital 200'000 (brutto entnommen) |
| **Einmaliger Zufluss** | 100'000 nominal @ 10 % Steuer → `oneOffInflow === 90'000`, Cash-Start Folgephase +90'000; Phase 1 hat keinen Zufluss |
| **Einmalige Kosten** | 20'000 real, 2 % Inflation, Grenze nach 10 J. → `20'000 × 1.02^10`, entsprechend vom Cash abgezogen |
| **Zufluss + Kosten / NONE** | `BOTH`: +50'000 20'000 → Cash-Start 30'000. `NONE` mit erfassten Beträgen → keine Wirkung |
| **Liquiditätslücke durch Kosten** | Kosten 25'000 bei Cash 10'000 → `cashStart === 15'000`, `cashNegative`, `incomplete` |
| **Letzte Phase** | Cash-Entscheid der letzten Phase bleibt wirkungslos (kein Übergang mehr) |
| Fortschreibung | Einkommens-Basiswert P1 → Startwert P2 = `100'000 × 1.02^5`; `cashStart(P2) === cashEnd(P1)` |
## 8.3 Ausführung
```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/<id>` 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: was noch fehlt
Seit Version 0.5 kennt das Modul Hypothekarzins und Wertsteigerung. Nicht modelliert sind:
- **Nebenkosten und Unterhalt** (Faustregel ~1 % des Werts). Lassen sich heute als normales
Ausgaben-Element erfassen.
- **Eigenmietwert** ohne echte Steuerlogik (Roadmap Nr. 23) nur halb wirksam.
- **Mieteinnahmen** (Renditeliegenschaften) anderer Anwendungsfall.
- **Wertvermehrende Investitionen** und die **Haltedauer-Abstufung** der
Grundstückgewinnsteuer (kantonal, teils stark degressiv).
- **Zinsänderungsrisiko**: Der Zinssatz gilt für die ganze Phase. Ein Zinsschock lässt sich
nur abbilden, indem man an dieser Stelle eine Phasengrenze zieht und den Satz neu setzt.
## 9.4 Kein CSRF-Token
Zustandsändernde Requests sind allein durch `sameSite=lax` geschützt. Das deckt klassische
Cross-Site-Formular-POSTs ab, ist aber schwächer als ein expliziter Token.
## 9.5 Logout invalidiert das Token nicht serverseitig
Das JWT ist zustandslos und bis zu 30 Tage gültig. `logout` löscht nur das Cookie.
## 9.6 Spar- und Bezugsraten werden nicht indexiert
`annualContribution`, `annualWithdrawal`, `amortization` und `annualRepayment` sind flache
Nominalbeträge, die über die Phasenjahre **konstant** bleiben. Eine Sparrate von 10'000 bleibt
20 Jahre lang 10'000 nominal und verliert dabei real an Gewicht. Wer eine mitwachsende Rate
abbilden will, muss die Phase teilen und den Betrag in der Folgephase erhöhen.
## 9.7 Nur ein Zufluss und eine Kostenposition pro Übergang
`CashTransitionData` hält genau ein Zufluss- und ein Kostenpaar (Bezeichnung + Betrag).
„Erbschaft + Autoverkauf + Poolbau + Küche" am selben Übergang lässt sich nur durch
Zusammenfassen abbilden („Diverses, 45'000") die Aufschlüsselung geht dabei verloren.
Bewusster Entscheid zugunsten eines einfachen UI; erweiterbar auf Listen.
## 9.8 Einmalige Ereignisse nur an Phasengrenzen
Ein Ereignis kann nur an einem Phasenübergang liegen. Ein Poolbau in Jahr 3 einer 10-jährigen
Phase ist nur abbildbar, wenn dort eine Phasengrenze gezogen wird. Nach der letzten Phase gibt
es keinen Übergang ein dort erfasster Betrag bleibt wirkungslos (durch Test abgedeckt).
## 9.9 Gemeinsames Einkommen zählt bei Paaren nicht für die AHV
Das mdJE ist eine **personenbezogene** Grösse. Einkommen wird deshalb nur einer Person
zugerechnet, wenn das `INCOME`-Element ihr zugeordnet ist (`PERSON_A`/`PERSON_B`). Bei einem
**Einzelplan** zählt `HOUSEHOLD` zur Person A es gibt ja nur eine. Bei einem **Paar-Plan**
bleibt `HOUSEHOLD`-Einkommen für die AHV unberücksichtigt.
Wer in einem Paar-Plan den Lohn als „Gemeinsam" erfasst, sieht deshalb im Prüf-Dialog ein
geplantes Durchschnittseinkommen von 0 und bekäme die Mindestrente. Der Dialog zeigt den Wert
prominent an, sodass der Fehler auffällt aber es gibt keine aktive Warnung. Erwerbseinkommen
sollte in Paar-Plänen immer personenscharf erfasst werden.
Ebenfalls nicht modelliert: das **Einkommenssplitting** verheirateter Paare (während der Ehe
werden die Einkommen hälftig geteilt) sowie Erziehungs- und Betreuungsgutschriften. Beides würde
das mdJE real beeinflussen und wäre der nächste Ausbauschritt.
## 9.10 Verhaltensänderung: AHV-Rente bestehender Pläne
Bis Version 0.3 erhielt jede AHV-Position pauschal die **Maximalrente** (32'760), gekürzt nur um
Ausfalljahre unabhängig vom Einkommen. Seit 0.4 folgt sie der Rentenformel. Bestehende Pläne
zeigen dadurch eine **andere, in der Regel tiefere** AHV-Rente, sobald das geplante
Durchschnittseinkommen unter 90'720 liegt. Das ist keine Regression, sondern die Korrektur einer
zu optimistischen Pauschale.
Zwei Fälle brauchen Aufmerksamkeit:
- **Bereits pensionierte Personen** (bei Planbeginn): Ohne erfasstes Durchschnittseinkommen
ergibt das mdJE 0 → **Mindestrente**. Vorher war es die Maximalrente. Die Felder stehen in der
AHV-Phasenzelle der ersten Phase; solange sie leer sind, ist die Rente bewusst konservativ.
- **Paar-Pläne mit `HOUSEHOLD`-Einkommen**: siehe 9.9.
## 9.11 AHV-Rente wird nach der Pensionierung nicht indexiert
Die Rente wird zum Pensionierungszeitpunkt in heutigem Geld berechnet und danach **nominal
eingefroren**. Die echte AHV wird alle zwei Jahre an den Mischindex angepasst. Über 25
Rentenjahre verliert die modellierte Rente damit real spürbar an Wert das Modell ist an dieser
Stelle deutlich konservativ. Bewusster Alt-Entscheid, unabhängig von der Rentenformel; der
grösste verbliebene Hebel im AHV-Modell.
## 9.12 `PILLAR_3A_MAX_ANNUAL` wird nur im UI erzwungen
Das Feld ist per `max`-Prop hart geklammert. Das Zod-Schema kennt für `annualContribution` nur
`≥ 0` ein direkter API-Aufruf kann die Obergrenze überschreiten.
## 9.13 Verhaltensänderung: Nettolohn-Klarstellung und AHV
Bis Version 0.4 war nirgends definiert, ob `INCOME` netto oder brutto ist der Hilfetext sagte
nur „die Zahl auf dem Lohnausweis", und dort stehen beide. Für den Cash-Fluss war das folgenlos,
weil sich beide Konventionen aufheben (`brutto Ausgaben inkl. Abzüge``netto Ausgaben ohne
Abzüge`). Mit der einkommensabhängigen AHV (0.4) hing daran aber plötzlich eine Rente.
Seit 0.5 gilt: **`amount` ist der Nettolohn**, und die AHV rechnet mit `× 1.12` auf brutto hoch.
Konsequenzen:
- **Wer bisher netto erfasst hat** (die dokumentierte Absicht): Die AHV-Rente **steigt** sie
war zuvor um bis zu ~1'900/Jahr zu tief, am stärksten bei mittleren Einkommen (bei 80'000
brutto: 28'974 statt 30'902). Über 90'720 brutto verschwindet der Effekt, weil beide Werte in
die Maximalrente laufen.
- **Wer brutto erfasst hat**: Cash-Fluss und AHV sind nun beide zu hoch. Der Einkommensbetrag
gehört auf netto korrigiert und die Lohnabzüge aus dem Ausgabenbetrag entfernt.
## 9.14 Keine Steuerschätzung
Bewusst **nicht** umgesetzt: eine automatische Schätzung von Einkommens- und Vermögenssteuer.
Die Bemessungsgrundlage ist das *steuerbare* Einkommen, nicht der Nettolohn also brutto minus
PK, 3a, Berufsauslagen, Versicherungs- und Kinderabzüge. Darauf kommen drei Ebenen (Bund,
Kanton, Gemeinde); allein der Gemeindesteuerfuss variiert innerhalb eines Kantons um rund den
Faktor zwei. Dazu Zivilstand, Kinder, Konfession und für die Vermögenssteuer 26 kantonale
Tarife mit eigenen Freibeträgen. Das Tool kennt weder Wohnort noch Kinder.
Eine Schätzung daraus läge im Einzelfall schnell 3050 % daneben bei vielen Haushalten dem
grössten Ausgabenposten. Eine selbst berechnete Zahl wirkt zudem autoritativ und wird nicht
hinterfragt. Der Benutzer kennt seine Steuerrechnung dagegen exakt aus der letzten Veranlagung.
**Heutiger Weg:** ein normales Ausgaben-Element „Steuern" dafür braucht es kein neues Feld.
Echte Steuerlogik ist Roadmap Nr. 23, mit Kanton und Gemeinde als Eingabe.
## 9.15 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 |
| **Einmalige Sonderein-/ausgabe** | Ereignis am Übergang (Erbschaft, Poolbau), das direkt aufs Cash wirkt und nicht in die Quote eingeht |
| **Pensions-Übergang** | Übergang, bei dem der Besitzer des Elements pensioniert wird |
| **Carry / Fortschreibung** | Live-Übertragung des Endwerts einer Phase in die nächste |
| **Cash** | Systemseitiges Ausgleichskonto; darf negativ werden (Liquiditätslücke) |
| **Quote** | Einkommen nominale Ausgaben eines Jahres; negativ = **Verzehr** |
| **Geplante Sparrate** | 3a-Beiträge + Sparbeiträge + Amortisationen + Tilgungen |
| **Geplante Verzehrrate** | Summe der Bezugsraten aus Sonstigem Vermögen |
| **Kapitalzufluss** | Verkaufserlöse + PK-/3a-Bezüge aus dem Übergang in die Phase |
| **Kapitalinvestition** | Zusatzinvestitionen + Sofort-Tilgungen |
| **Nominal** | Betrag in Franken des jeweiligen Jahres |
| **Real** | Kaufkraftbereinigt auf den Planbeginn (`nominal / Deflator`) |
| **Deflator** | Kumulierte Inflation seit Planbeginn |
| **Ausfalljahr** | Jahr ohne AHV-Beiträge; kürzt die Rente um 1/44 |
| **mdJE** | Massgebendes durchschnittliches Jahreseinkommen Mittel der Beitragsjahre, bestimmt die Rentenhöhe |
| **R0** | Mindestbetrag der vollen AHV-Monatsrente (1'260); alle Schwellen sind Vielfache davon |
| **Wendepunkt** | mdJE = 36 × R0 = 45'360; dort wechselt die Rentenformel von Teil 1 auf Teil 2 |
| **Plafonierung** | Deckelung der Ehepaar-AHV auf 150 % der Einzel-Maximalrente |
| **Umwandlungssatz** | Prozentsatz zur Verrentung des PK-Kapitals |
| **Ruin(alter)** | Alter von Person A, in dem das Gesamtvermögen erstmals unter 0 fällt |
| **Nachlass** | Endvermögen der letzten Phase (nominal) |
---
*Ende der Spezifikation v0.1*