9f5bd754eb
Deploy App / deploy (push) Successful in 1m51s
Der Cash-Uebergang zwischen zwei Phasen ist neu ein eigener Entscheid: 1:1 uebernehmen / einmaliger Zufluss / einmalige Kosten / beides. Die Betraege gehen direkt aufs Cash-Konto und bleiben aus der Spar-/Verzehrquote heraus. - Zufluss NOMINAL erfasst, real angezeigt (wie Einkommen), optionaler Steuersatz (Default 0 %). Kosten REAL erfasst, nominal angezeigt (wie Ausgaben). Umrechnung ueber den Bestands-Deflator an der Phasengrenze. - Entscheid startet unbeantwortet und zaehlt im "offen"-Badge mit; eine neue Phase erzeugt damit automatisch einen offenen Cash-Entscheid am neuen Uebergang. - Eigene Kopf-Kennzahlen statt Vermischung mit Kapitalzufluss/-investitionen: eine Erbschaft ist kein Verkaufserloes, ein Poolbau keine Investition. - Cash ist kein FinancialElement -> der Entscheid haengt als JSON an der Von-Phase (neue Spalte Phase.cashTransition + Migration). Szenario-Kopie nimmt ihn mit. Fuenf Regressionstests ergaenzt (13 -> 18). Spezifikation auf v0.3. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1821 lines
82 KiB
Markdown
1821 lines
82 KiB
Markdown
# FPT – Financial Planning Tool
|
||
## Funktionale und Technische Spezifikation
|
||
|
||
| | |
|
||
|---|---|
|
||
| **Dokument** | Funktionale und Technische Spezifikation FPT |
|
||
| **Version** | 0.3 |
|
||
| **Datum** | 2026-07-16 |
|
||
| **Status** | Lebendes Dokument |
|
||
| **Codestand** | Arbeitsstand nach `87e6a5f` inkl. einmaliger Sonderein-/ausgaben am Cash-Übergang (Branch `main`) |
|
||
| **Ersetzt** | `FDD_TDD_FPT.docx` (v1–v5) im Ordner `Info Dateien` – diese sind ab Version 0.1 dieses Dokuments obsolet |
|
||
| **Geltungsbereich** | Gesamter Code im Verzeichnis `FPT` |
|
||
|
||
---
|
||
|
||
## Änderungshistorie
|
||
|
||
| Version | Datum | Autor | Änderung |
|
||
|---|---|---|---|
|
||
| 0.3 | 2026-07-16 | Claude (Opus 4.8) | **Einmalige Sonderein-/ausgaben** umgesetzt (Roadmap Nr. 1). Der Cash-Übergang zwischen zwei Phasen ist neu ein eigener Entscheid: 1:1 übernehmen · einmaliger Zufluss · einmalige Kosten · beides. Beträge gehen direkt aufs Cash-Konto. Zufluss wird nominal erfasst (real angezeigt) mit optionalem Steuersatz (Default 0 %), Kosten werden real erfasst (nominal angezeigt); beide bleiben aus der Sparquote heraus. Der Entscheid startet unbeantwortet und zählt im „offen"-Badge mit. Neue Spalte `Phase.cashTransition` (JSON) + Migration, neue Route `PUT /api/phases/<id>/cash-transition`, eigene Kennzahlen im Phasenkopf. Fünf Regressionstests ergänzt (13 → 18). Neue Kapitel 3.5.5, 4.9.6; Abschnitt 9 um zwei Grenzen ergänzt. |
|
||
| 0.2 | 2026-07-16 | Claude (Opus 4.8) | Drei Fixes umgesetzt und dokumentiert: **(1)** `Phase.inflationRate` ersatzlos entfernt (DB-Migration, API, Typen, UI) – die Inflation liegt seit V5 plan-weit; das Feld war wirkungslos. **(2)** Amortisation und Tilgung stoppen neu, sobald Hypothek bzw. Schuld abbezahlt sind – sie belasten danach weder Cash noch Sparquote (Kap. 4.6.5, 4.6.7, 4.7, 4.8). **(3)** Kapitalbezugssteuer greift neu auch bei PK-/3a-Vorbezügen vor der Pensionierung, inkl. Steuerfeld und Netto-Vorschau im UI (Kap. 3.5.2, 4.9.1, 4.9.2). Kennzahl `plannedSaveRate` ist neu die Rate des **ersten** Phasenjahres. Drei Regressionstests ergänzt (10 → 13). Abschnitt 9 neu nummeriert (erledigte Punkte entfernt, „Sparraten werden nicht indexiert" ergänzt). |
|
||
| 0.1 | 2026-07-16 | Claude (Opus 4.8) | Erstfassung. Vollständige Neuerstellung aus dem Code (Stand `f768e01`). Ersetzt die bisherigen FDD/TDD-Dokumente v1–v5 vollständig. |
|
||
|
||
> **Pflegehinweis:** Dieses Dokument ist ein lebendes Dokument. Bei jeder Aktualisierung wird
|
||
> (a) die Änderungshistorie um eine Zeile ergänzt, (b) die Versionsnummer im Kopf um 0.1 erhöht
|
||
> und (c) der referenzierte Codestand (Commit) aktualisiert.
|
||
|
||
---
|
||
|
||
## Inhaltsverzeichnis
|
||
|
||
1. [Einleitung und Abgrenzung](#1-einleitung-und-abgrenzung)
|
||
2. [Fachliche Grundkonzepte](#2-fachliche-grundkonzepte)
|
||
3. [Funktionale Spezifikation](#3-funktionale-spezifikation)
|
||
4. [Berechnungsmodell](#4-berechnungsmodell)
|
||
5. [Technische Spezifikation](#5-technische-spezifikation)
|
||
6. [API-Referenz](#6-api-referenz)
|
||
7. [Betrieb und Deployment](#7-betrieb-und-deployment)
|
||
8. [Qualitätssicherung](#8-qualitätssicherung)
|
||
9. [Bekannte Einschränkungen und Modellentscheide](#9-bekannte-einschränkungen-und-modellentscheide)
|
||
10. [Glossar](#10-glossar)
|
||
|
||
---
|
||
|
||
# 1. Einleitung und Abgrenzung
|
||
|
||
## 1.1 Zweck des Tools
|
||
|
||
Das FPT ist eine Webanwendung zur persönlichen Finanzplanung über Lebensabschnitte hinweg,
|
||
ausgelegt auf Schweizer Verhältnisse (AHV, Pensionskasse, Säule 3a, Grundstückgewinnsteuer).
|
||
Ein Benutzer bildet seine finanzielle Situation als Kette von **Lebensphasen** ab, hinterlegt
|
||
**finanzielle Elemente** (Einkommen, Ausgaben, Vorsorge, Immobilien, Vermögen, Schulden) und
|
||
trifft an den **Übergängen** zwischen den Phasen bewusste Entscheide (halten, verkaufen,
|
||
Kapital beziehen, verrenten). Das Tool rechnet daraus Jahr für Jahr das Vermögen, den
|
||
Cash-Bestand, Spar- bzw. Verzehrquoten sowie ein allfälliges Ruinalter fort.
|
||
|
||
## 1.2 Was das Tool nicht ist
|
||
|
||
Aus dem Code direkt ableitbare Abgrenzungen:
|
||
|
||
- **Keine Steuerberechnung** ausser den drei explizit modellierten Sätzen
|
||
(Kapitalbezugssteuer, Grundstückgewinnsteuer, PK-Umwandlungssatz). Einkommens- und
|
||
Vermögenssteuern sind nicht modelliert und müssen vom Benutzer in den Ausgaben erfasst werden.
|
||
- **Keine Monte-Carlo-Simulation / keine Stochastik.** Alle Renditen sind deterministische
|
||
Jahresprozentsätze.
|
||
- **Keine Hypothekarzinsen.** Eine Hypothek reduziert nur den Nettowert der Immobilie;
|
||
Zinskosten sind vom Benutzer in den Ausgaben zu erfassen.
|
||
- **Keine Wertentwicklung von Immobilien.** Der Kaufpreis ist über die Phasendauer konstant
|
||
(Details siehe [4.6.5](#465-real_estate-immobilie)).
|
||
- **Keine Mehrbenutzer-Kollaboration.** Pläne gehören genau einem Benutzer.
|
||
|
||
## 1.3 Kernprinzip: Plan als selbsttragende Einheit
|
||
|
||
Seit dem V3-Rework (Migration `20260713150000_profile_to_plan_v3`) trägt **jeder Plan sein
|
||
eigenes Grundprofil**: Haushaltsform, Personen (Alter, Pensionsalter, Name) und
|
||
Inflationsannahme. Es gibt keine übergeordnete Haushalts-Entität mehr. Ein Szenario ist
|
||
deshalb eine vollständige Deep-Copy und vom Ursprungsplan unabhängig veränderbar.
|
||
|
||
Referenz: `prisma/schema.prisma` Zeilen 8–10, `src/lib/types.ts` Zeilen 38–49.
|
||
|
||
---
|
||
|
||
# 2. Fachliche Grundkonzepte
|
||
|
||
## 2.1 Die vier Ebenen
|
||
|
||
```
|
||
User
|
||
└── Plan (Grundprofil: Haushaltsform, Personen, Inflation, Cash-Startwert)
|
||
├── Person[] (1 bei SINGLE, 2 bei COUPLE)
|
||
├── Phase[] (geordnete Kette: sequenceNumber 1..n, je mit Dauer in Jahren)
|
||
└── FinancialElement[] (plan-weit, kategorisiert, optional personenzugeordnet)
|
||
├── ElementPhaseValue[] (Werte je Phase, JSON)
|
||
└── ElementTransitionValue[] (Entscheide je Übergang, JSON)
|
||
```
|
||
|
||
Das zentrale Designprinzip (Element-Rework 07/2026, Migration `20260713100000_element_model_rework`):
|
||
**Ein finanzielles Element ist über alle Lebensphasen hinweg dieselbe Entität.** Eine
|
||
Pensionskasse "PK Arbeitgeber" existiert einmal pro Plan; sie hat pro Phase einen Werte-Satz und
|
||
pro Phasenübergang einen Entscheid-Satz. Dadurch bleibt die Identität eines Vermögensgegenstands
|
||
über die Zeit erhalten – Voraussetzung für die Fortschreibung (Carry) und für die
|
||
Vermögensaufteilungs-Grafik.
|
||
|
||
Referenz: `prisma/schema.prisma` Zeilen 1–10, 114–151.
|
||
|
||
## 2.2 Die Matrix als Leitmetapher
|
||
|
||
Die Hauptansicht ist eine Tabelle:
|
||
|
||
- **Zeilen** = finanzielle Elemente, gruppiert nach Kategorie; zuoberst die systemseitige,
|
||
read-only Zeile **Cash**.
|
||
- **Spalten** = abwechselnd **Phasenspalten** und **Übergangsspalten**
|
||
(Phase 1 → Übergang → Phase 2 → Übergang → Phase 3 …). Der letzten Phase folgt keine
|
||
Übergangsspalte.
|
||
- **Zellen** = anklickbar; Phasenzellen öffnen die Werte-Eingabe, Übergangszellen den
|
||
Entscheid-Dialog.
|
||
|
||
Referenz: `src/components/PlanView.tsx` Zeilen 111–120 (Spaltenaufbau), 333–477 (Matrix).
|
||
|
||
## 2.3 Phasentyp – abgeleitet, nicht gespeichert
|
||
|
||
Der Typ einer Phase wird **nie gespeichert**, sondern in jeder Berechnung aus Alter und
|
||
Pensionsalter der Personen abgeleitet:
|
||
|
||
| Bedingung (zu Phasenbeginn) | Typ |
|
||
|---|---|
|
||
| alle Personen `startAge < retirementAge` | `ERWERB` |
|
||
| alle Personen `startAge >= retirementAge` | `PENSION` |
|
||
| gemischt | `MIXED` |
|
||
|
||
Referenz: `src/lib/calculations.ts` Zeilen 158–172; Kommentar in `prisma/schema.prisma` Zeilen 94–95.
|
||
|
||
## 2.4 Cash als Ausgleichskonto
|
||
|
||
Cash ist kein vom Benutzer erfassbares Element, sondern das systemseitige Ausgleichskonto:
|
||
|
||
- Es startet mit `Plan.initialCash` (Phase 1).
|
||
- Es nimmt jährlich die **Spar-/Verzehrquote** (Einkommen − nominale Ausgaben) auf.
|
||
- Es finanziert die **geplanten Sparraten** (3a-Beiträge, Sparbeiträge, Amortisationen, Tilgungen).
|
||
- Es empfängt die **Bezugsraten** aus Sonstigem Vermögen.
|
||
- Es empfängt an Übergängen **Kapitalzuflüsse** (Verkäufe, PK-/3a-Bezüge) und finanziert
|
||
**Sofort-Tilgungen** sowie **Zusatzinvestitionen** der Folgephase.
|
||
- Es 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 6–22, `src/app/api/auth/register/route.ts`.
|
||
|
||
### 3.1.2 Anmeldung
|
||
|
||
- Benutzername + Passwort; Prüfung via `bcrypt.compare`.
|
||
- Fehlermeldung ist bewusst unspezifisch: „Benutzername oder Passwort falsch." (HTTP 401) –
|
||
verrät nicht, ob der Benutzer existiert.
|
||
- Bei Erfolg: JWT (HS256, Payload `{ userId }`, Gültigkeit 30 Tage) im HttpOnly-Cookie
|
||
`fpt_session` (`sameSite=lax`, `secure` nur in Produktion, `maxAge` 30 Tage).
|
||
|
||
Referenz: `src/app/api/auth/login/route.ts`, `src/lib/auth.ts`.
|
||
|
||
### 3.1.3 Abmeldung
|
||
|
||
`POST /api/auth/logout` löscht das Cookie. Da die Session zustandslos ist (JWT), bleibt ein
|
||
bereits kopiertes Token bis zum Ablauf technisch gültig – es gibt keine serverseitige
|
||
Token-Sperrliste.
|
||
|
||
### 3.1.4 Passwortänderung
|
||
|
||
Im Profilmenü. Erfordert das aktuelle Passwort; das neue Passwort muss ≥ 6 Zeichen haben und
|
||
wird im Dialog gegen eine Wiederholung geprüft (Client-seitig). Nach Erfolg erscheint 1.2 s lang
|
||
„Passwort geändert.", dann schliesst der Dialog.
|
||
|
||
Referenz: `src/app/api/auth/change-password/route.ts`, `src/components/ProfileMenu.tsx` Zeilen 117–140.
|
||
|
||
### 3.1.5 Zugriffsschutz
|
||
|
||
Zweistufig:
|
||
|
||
1. **Middleware** (`src/middleware.ts`, Edge-Runtime): schützt alle Pfade ausser
|
||
`/login`, `/api/auth/login`, `/api/auth/register`, `/_next/*`, `/favicon*`.
|
||
Ohne gültiges Token → API-Aufrufe erhalten HTTP 401, Seitenaufrufe werden nach
|
||
`/login?next=<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" | 1–120 Zeichen |
|
||
| Haushaltsform | Auswahl | `SINGLE` | `SINGLE` / `COUPLE` |
|
||
| Name je Person | Text (optional) | leer | ≤ 60 Zeichen |
|
||
| Aktuelles Alter | Zahl | 35 | 0–120 |
|
||
| Pensionierungsalter | Zahl | 65 | 30–100 |
|
||
| Erwartete Inflationsrate (%) | Zahl | 1.5 | −20 bis 50 |
|
||
|
||
Konsistenzregel: `SINGLE` erfordert genau eine Person, `COUPLE` genau zwei (Person A und B).
|
||
Verletzung → HTTP 400 mit Klartextmeldung.
|
||
|
||
Referenz: `src/components/PlanProfileFields.tsx` Zeilen 1012–1018, `src/app/api/plans/route.ts`
|
||
Zeilen 6–35.
|
||
|
||
Ein neu erstellter Plan hat **keine Phasen und keine Elemente**; `initialCash` ist 0.
|
||
|
||
### 3.2.2 Grundprofil ändern
|
||
|
||
Über „Einstellungen" in der Planansicht. Wichtig: Die Personen werden serverseitig in einer
|
||
Transaktion **gelöscht und neu angelegt** (`deleteMany` + `create`). Die Person-IDs ändern sich
|
||
dadurch. Da Elemente über `ownerRole` (nicht über `personId`) zugeordnet sind, bleibt die
|
||
Zuordnung erhalten.
|
||
|
||
Ein Wechsel von `COUPLE` auf `SINGLE` entfernt Person B. Elemente mit `ownerRole = PERSON_B`
|
||
bleiben in der Datenbank bestehen, finden aber keinen Owner mehr – siehe
|
||
[9.2](#92-verwaiste-person_b-elemente).
|
||
|
||
Referenz: `src/app/api/plans/[planId]/route.ts` Zeilen 55–70.
|
||
|
||
### 3.2.3 Cash-Anfangswert
|
||
|
||
Klick auf die erste Zelle der Cash-Zeile öffnet den Dialog „Cash-Anfangswert". Wertebereich
|
||
0 bis 1'000'000'000, wird auf ganze Franken gerundet.
|
||
|
||
Referenz: `src/components/PlanView.tsx` Zeilen 1130–1156, `src/app/api/plans/[planId]/route.ts` Zeile 36.
|
||
|
||
### 3.2.4 Plan löschen
|
||
|
||
Aus der Übersichtskachel oder der Planansicht, mit Browser-`confirm()`. Löscht per Datenbank-Cascade
|
||
Personen, Phasen, Elemente und alle Werte. Szenarien, die auf diesen Plan als `parentPlanId`
|
||
zeigen, werden **nicht** gelöscht – ihre `parentPlanId` wird auf `NULL` gesetzt
|
||
(`onDelete: SetNull`).
|
||
|
||
Referenz: `prisma/schema.prisma` Zeile 82.
|
||
|
||
### 3.2.5 Szenarien
|
||
|
||
Ein Szenario ist eine **Deep-Copy eines Plans bis zu einer gewählten Verzweigungsphase (inklusive)**.
|
||
|
||
Kopiert werden:
|
||
- Grundprofil (Haushaltsform, Inflation, `initialCash`) und alle Personen
|
||
- alle Phasen mit `sequenceNumber <= branchPhase.sequenceNumber`
|
||
- **alle** Elemente (unabhängig von der Verzweigungsphase)
|
||
- Phasenwerte nur für kopierte Phasen; Übergangswerte nur für Übergänge aus kopierten Phasen
|
||
|
||
Gesetzt werden `parentPlanId` (Ursprungsplan) und `branchFromPhaseId` (die **neue** ID der
|
||
letzten kopierten Phase). Der Benutzer landet direkt im neuen Szenario, das anschliessend
|
||
unabhängig weiterentwickelt wird.
|
||
|
||
In der Planliste erscheint ein Szenario mit dem Zusatz „· Szenario".
|
||
|
||
Referenz: `src/app/api/plans/[planId]/scenario/route.ts`.
|
||
|
||
## 3.3 Lebensphasen
|
||
|
||
### 3.3.1 Phase anlegen
|
||
|
||
Neue Phasen werden **immer am Ende der Kette** angehängt (`sequenceNumber = Anzahl + 1`).
|
||
|
||
**Automatische Dauer-Kappung:** Die Dauer wird ans nächste Pensionsereignis gekappt. Formel
|
||
(`maxPhaseDuration`): für jede Person, die zu Phasenbeginn noch erwerbstätig ist, gilt
|
||
`retirementAge − (age + yearsBefore)`; das Minimum dieser Werte ist die Obergrenze. Ist keine
|
||
Person mehr erwerbstätig, gibt es keine Obergrenze (`null`). Diese Kappung ist im Dialog
|
||
sichtbar („max. N") **und** wird serverseitig erzwungen.
|
||
|
||
Fachliche Begründung: Eine Phase darf keine Pensionierung überspannen, weil der Phasentyp
|
||
und die AHV-/PK-Renten am Phasenbeginn ausgewertet werden.
|
||
|
||
**Default-Dauer:** die Kappung, sonst 10 Jahre.
|
||
**Default-Name:** Phase 1 → „Erste Lebensphase"; sonst „Pensionsphase" wenn zu Phasenbeginn
|
||
mindestens eine Person pensioniert ist, sonst „Erwerbsphase".
|
||
|
||
**Vorbelegung der Elemente:** Beim Anlegen einer Phase wird für jedes noch aktive Element
|
||
(nicht `SOLD`, nicht `SETTLED`) ein `ElementPhaseValue` mit den **editierbaren** Feldern der
|
||
Vorphase erzeugt (`buildCarryData`):
|
||
|
||
| Kategorie | Übernommene Felder |
|
||
|---|---|
|
||
| `INCOME`, `EXPENSE` | nur `teuerungsausgleich` (Basis wird live fortgeschrieben) |
|
||
| `AHV` | `gapYears: 0` |
|
||
| `PENSION_FUND`, `PILLAR_3A`, `OTHER_ASSET` | `annualContribution`, `expectedReturn` |
|
||
| `REAL_ESTATE` | `purchasePrice`, `amortization` (Resthypothek wird live fortgeschrieben) |
|
||
| `OTHER_DEBT` | `annualRepayment` |
|
||
|
||
Bestände (PK-/3a-/Vermögenswert, Resthypothek, Restschuld) werden **bewusst nicht als Snapshot
|
||
gespeichert**, sondern in jeder Berechnung live aus der Vorphase fortgeschrieben. Damit wirken
|
||
sich nachträgliche Änderungen an frühen Phasen automatisch auf alle Folgephasen aus.
|
||
|
||
Referenz: `src/app/api/plans/[planId]/phases/route.ts`.
|
||
|
||
### 3.3.2 Phase bearbeiten
|
||
|
||
Klick auf einen Phasenkopf öffnet ein Detail-Panel unterhalb der Matrix mit Bezeichnung und
|
||
Dauer. Die Dauer wird auch hier gekappt. Eine phasenspezifische Inflationsrate gibt es nicht;
|
||
das Panel weist darauf hin: „Die Inflationsrate gilt plan-weit und wird in den Plan-Einstellungen
|
||
gesetzt."
|
||
|
||
Referenz: `src/components/PhaseDetail.tsx`.
|
||
|
||
### 3.3.3 Phase löschen
|
||
|
||
**Nur die letzte Phase** kann gelöscht werden – serverseitig geprüft (HTTP 400: „Nur die letzte
|
||
Phase kann geloescht werden."). Damit bleibt die Kette der `sequenceNumber` lückenlos. Der
|
||
Löschen-Button erscheint im Detail-Panel nur bei der letzten Phase.
|
||
|
||
Referenz: `src/app/api/phases/[phaseId]/route.ts` Zeilen 55–77.
|
||
|
||
## 3.4 Finanzielle Elemente
|
||
|
||
### 3.4.1 Kategorien und Personenzuordnung
|
||
|
||
| Kategorie | Label | Zuordnung |
|
||
|---|---|---|
|
||
| `INCOME` | Einkommen | frei: Gemeinsam / Person A / Person B |
|
||
| `EXPENSE` | Ausgaben | frei |
|
||
| `AHV` | AHV | **zwingend genau eine Person** |
|
||
| `PENSION_FUND` | Pensionskasse | **zwingend genau eine Person** |
|
||
| `PILLAR_3A` | Säule 3a | **zwingend genau eine Person** |
|
||
| `REAL_ESTATE` | Immobilie | frei |
|
||
| `OTHER_ASSET` | Sonstiges Vermögen | frei |
|
||
| `OTHER_DEBT` | Sonstige Schulden | frei |
|
||
|
||
Die Vorsorge-Kategorien (`PERSON_ONLY_CATEGORIES`) sind zwingend personengebunden, weil AHV-Rente,
|
||
PK-Verrentung und 3a-Bezug am **individuellen** Pensionierungszeitpunkt hängen. Wird für diese
|
||
Kategorien kein `PERSON_A`/`PERSON_B` übergeben → HTTP 400.
|
||
|
||
Für die übrigen Kategorien gilt: fehlt die Zuordnung, wird serverseitig `HOUSEHOLD` gesetzt.
|
||
|
||
Referenz: `src/lib/elements.ts` Zeilen 19–23, `src/app/api/plans/[planId]/elements/route.ts` Zeilen 43–54.
|
||
|
||
### 3.4.2 Element anlegen
|
||
|
||
Der Dialog „Finanzielles Element" fragt Kategorie, Zuordnung, Bezeichnung **und direkt die
|
||
Werte der ersten Lebensphase** ab. Nach dem Anlegen (`POST .../elements`) werden die Werte per
|
||
`PUT .../phase/<firstPhaseId>` gespeichert, sofern mindestens ein Feld gesetzt wurde. Der
|
||
Bezeichnungs-Default ist das Kategorie-Label.
|
||
|
||
`orderIndex` = bisheriges Maximum + 1; bestimmt die Reihenfolge innerhalb der Kategoriegruppe.
|
||
|
||
Referenz: `src/components/PlanView.tsx` Zeilen 799–947.
|
||
|
||
### 3.4.3 Element bearbeiten und löschen
|
||
|
||
Ein Klick auf eine **Phasenzelle** öffnet den Dialog „Lebensphase: <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 124–340.
|
||
|
||
#### INCOME (Einkommen)
|
||
|
||
Hinweistext: „Einkommen wird NOMINAL erfasst (die Zahl auf dem Lohnausweis)."
|
||
|
||
| Feld | JSON | Semantik |
|
||
|---|---|---|
|
||
| Jahreseinkommen NOMINAL (erstes Jahr) | `amount` | Basiswert Jahr 1. Ab Phase 2 vorbelegt mit dem fortgeschriebenen Wert, bewusst änderbar (Teilzeit, Beförderung). |
|
||
| ≈ real (heutige Kaufkraft) | – | Read-only Info: `amount / deflatorStart` |
|
||
| Nominale Lohnerhöhung (%/Jahr) | `teuerungsausgleich` | Default 0 %. 0 % = nominal gleichbleibend, real sinkend. |
|
||
|
||
#### EXPENSE (Ausgaben)
|
||
|
||
Hinweistext: „Ausgaben werden REAL erfasst (in heutiger Kaufkraft)."
|
||
|
||
| Feld | JSON | Semantik |
|
||
|---|---|---|
|
||
| Jahresausgaben REAL (erstes Jahr) | `amount` | Basiswert in heutiger Kaufkraft. |
|
||
| ≈ nominal (in diesem Jahr) | – | Read-only Info: `amount * deflatorStart` |
|
||
| Reale Mehrausgaben (%/Jahr) | `teuerungsausgleich` | **Zusätzlich** zur Inflation. 0 % = gleicher Lebensstandard. |
|
||
|
||
Die Asymmetrie (Einkommen nominal, Ausgaben real) ist der Kern des V5-Modells: Man kennt seinen
|
||
Lohn nominal, aber seinen Lebensstandard real.
|
||
|
||
#### AHV
|
||
|
||
| Zustand | Anzeige |
|
||
|---|---|
|
||
| Besitzer erwerbstätig | Eingabefeld **Geplante Ausfalljahre** (`gapYears`), 0 bis Phasendauer. Hilfetext: „Jedes Ausfalljahr kuerzt die spaetere Rente um 1/44." |
|
||
| Besitzer pensioniert | Nur Hinweistext – die Rente wird automatisch berechnet. |
|
||
|
||
#### PENSION_FUND (Pensionskasse)
|
||
|
||
| Zustand | Felder |
|
||
|---|---|
|
||
| erwerbstätig, Phase 1 | **Aktueller PK-Wert** (`currentValue`), **Jährliche Einzahlung** (`annualContribution`), **Erwartete Rendite** (`expectedReturn`) |
|
||
| erwerbstätig, ab Phase 2 | **Startwert (fortgeschrieben)** (read-only), **Zusatzeinlage aus Kapital** (`additionalInvestment`), Einzahlung, Rendite |
|
||
| pensioniert | Hinweistext (Rente aus Umwandlungssatz bzw. „Vollständig bezogen") |
|
||
|
||
Wichtig (Hilfetext im UI): Die PK-Einzahlung „Fliesst NICHT in die Sparquote ein (bereits in den
|
||
Ausgaben beruecksichtigt)" – Lohnabzüge sind im Nettoeinkommen bereits weg.
|
||
|
||
#### PILLAR_3A (Säule 3a)
|
||
|
||
Wie PK, aber:
|
||
- Die Einzahlung **zählt** zur Sparquote (verlässt das Cash).
|
||
- Das Feld ist auf `PILLAR_3A_MAX_ANNUAL` = **7'258 CHF** (2026, mit PK) hart geklammert.
|
||
- Bei Pensionierung: „Die Saeule 3a wird beim Pensions-Uebergang vollstaendig bezogen."
|
||
|
||
#### REAL_ESTATE (Immobilie)
|
||
|
||
| Zustand | Felder |
|
||
|---|---|
|
||
| Phase 1 / Neukauf | **Kaufpreis** (`purchasePrice`), **Hypothek** (`mortgage`), **Amortisation CHF/Jahr** (`amortization`) |
|
||
| ab Phase 2, fortgeschrieben | **Startwert Netto (fortgeschrieben)** (read-only = Kaufpreis − Resthypothek), **Amortisation** |
|
||
|
||
#### OTHER_ASSET (Sonstiges Vermögen)
|
||
|
||
| Feld | JSON | Semantik |
|
||
|---|---|---|
|
||
| Startwert / Startwert (fortgeschrieben) | `startValue` | ab Phase 2 read-only |
|
||
| Zusatzinvestition aus Kapital | `additionalInvestment` | nur ab Phase 2 |
|
||
| Erwartete Rendite (%/Jahr) | `expectedReturn` | |
|
||
| Jährlicher Sparbeitrag | `annualContribution` | fliesst ins Vermögen, verlässt das Cash → **geplante Sparrate** |
|
||
| Jährliche Bezugsrate | `annualWithdrawal` | Entnahme aus dem Vermögen ins Cash → **geplante Verzehrrate** |
|
||
|
||
Die Bezugsrate ist das Instrument für den Kapitalverzehr im Alter.
|
||
|
||
#### OTHER_DEBT (Sonstige Schulden)
|
||
|
||
| Feld | JSON | Semantik |
|
||
|---|---|---|
|
||
| Restschuld / (fortgeschrieben) | `startValue` | ab Phase 2 read-only |
|
||
| Jährliche Tilgung | `annualRepayment` | verlässt das Cash → geplante Sparrate |
|
||
|
||
Schulden gehen mit **negativem** Vorzeichen ins Vermögen ein.
|
||
|
||
## 3.5 Übergänge
|
||
|
||
### 3.5.1 Konzept
|
||
|
||
Zwischen zwei Phasen liegt ein Übergang. Er ist der Ort, an dem einmalige Entscheide getroffen
|
||
werden. 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 / Verkaufen (+ Verkaufspreis, Grundstückgewinnsteuer) | identisch |
|
||
| `OTHER_ASSET` | Halten / Verkaufen | identisch |
|
||
| `OTHER_DEBT` | Sofortige Tilgung (CHF) | identisch |
|
||
|
||
Ein **Vorbezug** (vor der Pensionierung, z. B. für Wohneigentum oder Selbstständigkeit) ist wie
|
||
der Bezug bei Pensionierung kapitalbezugssteuerpflichtig. Der eingegebene Betrag ist der
|
||
**Bruttobezug**: er wird in voller Höhe dem Vorsorgekapital entnommen, ins Cash fliesst der
|
||
Betrag nach Abzug der Steuer. Der Dialog zeigt die resultierende Netto-Auszahlung als
|
||
read-only Vorschau an.
|
||
|
||
Bei PK-Bezugsart:
|
||
- **Rente**: Feld Umwandlungssatz (Default 6 %)
|
||
- **Kapitalbezug**: Feld Kapitalbezugssteuer (Default 8 %)
|
||
- **Kombination**: beide Felder plus „Davon Kapitalbezug (CHF)", geklammert am Endwert
|
||
|
||
Referenz: `src/components/ElementDetail.tsx` Zeilen 342–462.
|
||
|
||
### 3.5.3 Ampel-Logik: „offene" Entscheide
|
||
|
||
Ein Entscheid gilt als **beantwortet** (`isTransitionAnswered`), wenn das jeweilige
|
||
Entscheidungsfeld gesetzt ist:
|
||
|
||
| Kategorie | Beantwortet, wenn |
|
||
|---|---|
|
||
| `REAL_ESTATE`, `OTHER_ASSET` | `decision` gesetzt |
|
||
| `PENSION_FUND` | Pensions-Übergang: `payoutMode` gesetzt; sonst: `withdrawalMode` gesetzt |
|
||
| `PILLAR_3A` | Pensions-Übergang: **immer** beantwortet; sonst: `withdrawalMode` gesetzt |
|
||
| **Cash** | `mode` gesetzt (`isCashTransitionAnswered`) |
|
||
| alle anderen | immer beantwortet |
|
||
|
||
Der Übergangs-Spaltenkopf zeigt entweder „N offen" (Akzentfarbe) oder „geprüft" (grün, Häkchen).
|
||
Offene Zellen sind farblich hervorgehoben und zeigen „?".
|
||
|
||
**Ein Element ist am Übergang inaktiv** (`transitionInactive`), wenn es bereits verkauft/getilgt
|
||
ist **oder** wenn es eine PK/3a ist, deren Besitzer schon zu Beginn der Von-Phase pensioniert war
|
||
(dann ist bereits bezogen/verrentet). Inaktive Zellen zeigen „–" und sind nicht anklickbar.
|
||
|
||
Referenz: `src/components/PlanView.tsx` Zeilen 200–232, `src/components/ElementDetail.tsx` Zeilen 107–120.
|
||
|
||
### 3.5.4 Geführter Übergang (Review-Dialog)
|
||
|
||
Klick auf einen Übergangs-Spaltenkopf öffnet „Übergang prüfen: <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.6 Auswertung und Visualisierung
|
||
|
||
### 3.6.1 Anzeigemodus nominal / beide / real
|
||
|
||
Ein Umschalter oben in der Planansicht steuert die Darstellung aller Geldbeträge in Matrix und
|
||
Phasenköpfen:
|
||
|
||
| Modus | Darstellung |
|
||
|---|---|
|
||
| Nominal | `1'234'567` |
|
||
| Beide | `1'234'567 (890'123)` – nominal, real in Klammern |
|
||
| Real | `890'123` |
|
||
|
||
„real" bedeutet kaufkraftbereinigt auf den **Planbeginn**: `nominal / Deflator`. Die Wahl wird in
|
||
`localStorage` unter `fpt-value-mode` gespeichert.
|
||
|
||
Es werden zwei verschiedene Deflatoren verwendet – siehe [4.5.3](#453-die-drei-deflatoren).
|
||
|
||
Referenz: `src/components/PlanView.tsx` Zeilen 665–699.
|
||
|
||
### 3.6.2 Zeitachse
|
||
|
||
Horizontaler Balken über das Alter (von jüngster Person bis Planende) mit:
|
||
- Flaggen-Marker je Person am Pensionsalter (Farbe: Person A indigo, Person B hellblau)
|
||
- Trennstriche an den Phasengrenzen
|
||
- rotem „Ruin <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 701–769.
|
||
|
||
### 3.6.4 Dashboard
|
||
|
||
Erscheint unterhalb der Matrix, sobald mindestens eine Phase existiert.
|
||
|
||
**Kennzahl-Karten:** Endvermögen nominal, Endvermögen real, Geschätzter Nachlass
|
||
(= Endvermögen der letzten Phase, „potenziell vererbbar").
|
||
|
||
**Grafik 1 – Einkommen vs. Ausgaben pro Jahr** (`SparquoteChart`): Ein Datenpunkt pro Jahr
|
||
über alle Phasen. Grüne Linie = Einkommen nominal (inkl. Renten), rote Linie = Ausgaben nominal,
|
||
graue gestrichelte Linie = Ausgaben real. Die Fläche zwischen Einkommen und nominalen Ausgaben
|
||
ist grün (Sparquote) oder rot (Verzehr) eingefärbt. Der Keil zwischen roter und grauer Linie ist
|
||
anschaulich „das, was die Inflation frisst".
|
||
|
||
**Grafik 2 – Vermögensverlauf nach Alter** (`WealthChart`): Liniendiagramm über das Alter von
|
||
Person A. Je Plan eine durchgezogene Linie (nominal) und eine gestrichelte (real). Datenpunkte:
|
||
Startvermögen Phase 1 plus je ein Endwert pro Phase. Über Checkboxen lassen sich **andere Pläne
|
||
überlagern** (Szenariovergleich); deren Daten werden bei Bedarf nachgeladen und im Client
|
||
zwischengespeichert.
|
||
|
||
**Grafik 3 – Vermögensaufteilung pro Phase**: Gestapeltes Balkendiagramm mit zwei Balken je Phase
|
||
(Beginn / Ende). Gestapelt werden alle Elemente der Kategorien PK, 3a, Immobilie, Sonstiges
|
||
Vermögen, die irgendwann einen positiven Wert haben. Stapelung nach `elementId` (nicht Name),
|
||
damit gleichnamige Elemente nicht kollidieren. Negative Werte werden auf 0 geklammert.
|
||
|
||
Referenz: `src/components/Dashboard.tsx`, `src/components/WealthChart.tsx`, `src/components/SparquoteChart.tsx`.
|
||
|
||
### 3.6.5 CSV-Export
|
||
|
||
`GET /api/plans/<planId>/export` liefert eine semikolon-getrennte CSV, eine Zeile pro Phase:
|
||
|
||
```
|
||
Phase;Typ;Dauer;Einkommen (Beginn);Ausgaben (Beginn);Quote (Beginn);Quote (Ende);Cash (Ende);Endvermoegen (nominal);Endvermoegen (real)
|
||
```
|
||
|
||
Bei vorhandenem Ruin folgt eine Schlusszeile `Ruin: Kapital aufgebraucht mit Alter <N>`.
|
||
Dateiname = Planname, nicht-alphanumerische Zeichen durch `_` ersetzt.
|
||
|
||
Referenz: `src/lib/calculations.ts` Zeilen 620–648.
|
||
|
||
## 3.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 1181–1196.
|
||
|
||
### 3.7.3 Geldeingabefelder
|
||
|
||
Die `MoneyInput`-Komponente ist ein spezialisiertes Betragsfeld:
|
||
- Unfokussiert Anzeige mit Apostroph-Tausendertrennung (`1'234'567`), fokussiert reine Ziffern
|
||
- Ein Default-Wert 0 wird beim Fokussieren geleert, sonst der Text markiert
|
||
- Pfeiltasten: ↑/↓ = ±1, Shift+↑/↓ = ±100
|
||
- Pfeil-Buttons mit **Klick-und-Halten-Beschleunigung**: nach 400 ms Wiederholung im 70-ms-Takt,
|
||
Schrittweite wächst mit der Haltedauer (1 → 10 → 100 → 1'000 → 10'000)
|
||
- Optionale harte Klammerung über `min` (Default 0) und `max`
|
||
|
||
**Zahlenformat:** Bewusst nicht über `toLocaleString("de-CH")`, weil dessen Trennzeichen das
|
||
typografische Apostroph (U+2019) ist. Das Tool verwendet durchgehend das gerade Apostroph.
|
||
|
||
Referenz: `src/components/FormField.tsx` Zeilen 60–200, `src/lib/format.ts`.
|
||
|
||
### 3.7.4 Hilfe-Bubbles
|
||
|
||
Feldbeschriftungen können ein Info-Icon tragen (`InfoBubble`), das per Hover oder Klick einen
|
||
Erklärtext einblendet. Wird durchgehend für die fachlich heiklen Felder verwendet.
|
||
|
||
### 3.7.5 Reaktivität
|
||
|
||
Die Anwendung rechnet **nicht im Client**. Jede Änderung führt zu einem `PUT`/`PATCH`/`POST`,
|
||
gefolgt von `onChanged()` → `loadDetail(planId, silent = true)`. Der „silent"-Refresh lädt Plan
|
||
und Berechnung neu, ohne die `PlanView` zu demontieren – so bleibt die Scrollposition nach dem
|
||
Schliessen eines Popups erhalten.
|
||
|
||
Referenz: `src/components/AppShell.tsx` Zeilen 46–74.
|
||
|
||
---
|
||
|
||
# 4. Berechnungsmodell
|
||
|
||
> Dies ist der fachliche Kern des Tools. Der gesamte Abschnitt beschreibt
|
||
> `src/lib/calculations.ts`, Funktion `computePlan(plan: PlanInput): PlanComputed`.
|
||
> Die Funktion ist **rein** (keine I/O, keine Datenbank) und dadurch isoliert testbar.
|
||
|
||
## 4.1 Ablauf im Überblick
|
||
|
||
```
|
||
für jede Phase i (nach sequenceNumber sortiert):
|
||
1. Personen-Infos berechnen (Alter, erwerbstätig?), Phasentyp ableiten
|
||
2. AHV: Ausfalljahre kumulieren, Renten der Pensionierten berechnen, plafonieren
|
||
3. Element-Setup: je Element Startwerte/Raten bestimmen, in Arbeitslisten einordnen
|
||
4. Investitionen vom Cash abziehen → cashStart
|
||
5. Jahresschleife t = 1..duration:
|
||
Flows indexieren, Vermögen verzinsen, Bezugsraten entnehmen,
|
||
Cash fortschreiben, Ruin prüfen, YearPoint anfügen
|
||
6. Endwerte je Element setzen, Phasen-Kennzahlen zusammenstellen
|
||
7. Übergang: Entscheide anwenden, Carry aktualisieren, Cash der Folgephase bilden
|
||
```
|
||
|
||
## 4.2 Zustand über Phasengrenzen: der Carry
|
||
|
||
Zwischen den Phasen wird pro Element ein `Carry`-Objekt fortgeschrieben:
|
||
|
||
| Feld | Bedeutung |
|
||
|---|---|
|
||
| `status` | `ACTIVE` / `SOLD` / `SETTLED` |
|
||
| `value` | Aktiven-Saldo (PK / 3a / Sonstiges Vermögen) am Ende der Vorphase |
|
||
| `mortgage` | Immobilie: Resthypothek |
|
||
| `owed` | Schulden: Restschuld (positiv geführt) |
|
||
| `pkPensionAnnual` | PK: jährliche Rente nach Verrentung |
|
||
| `flowBasis` | Einkommen/Ausgaben: indexierter Basiswert der nächsten Phase |
|
||
| `hasCarry` | ob überhaupt eine Vorphase existiert (steuert read-only vs. Eingabe im UI) |
|
||
|
||
Zusätzlich laufen phasenübergreifend mit: `cashCarryIn`, `cumulativeInflation`,
|
||
`gapYearsByPerson`, `yearsBefore`, `ruinAge`, `incomingInflow`, `incomingImmediateRepay`.
|
||
|
||
Referenz: `src/lib/calculations.ts` Zeilen 103–115, 132–147.
|
||
|
||
## 4.3 Personen und Phasentyp
|
||
|
||
Für jede Person und jede Phase:
|
||
|
||
```
|
||
startAge = person.age + yearsBefore
|
||
endAge = startAge + duration
|
||
working = startAge < retirementAge
|
||
retiresAtStart = startAge === retirementAge
|
||
```
|
||
|
||
`yearsBefore` ist die Summe der Dauern aller vorangehenden Phasen. **Alter wird also relativ
|
||
gezählt** – es gibt keine Kalenderdaten im Modell, nur „Jahre ab Planbeginn".
|
||
|
||
`maxDurationYears` = Minimum über `retirementAge − startAge` aller noch erwerbstätigen Personen
|
||
(nur positive Werte), sonst `null`.
|
||
|
||
## 4.4 AHV-Rente
|
||
|
||
Zwei Durchgänge pro Phase über alle AHV-Elemente:
|
||
|
||
**1. Ausfalljahre kumulieren** – für Elemente, deren Besitzer in dieser Phase **erwerbstätig** ist:
|
||
|
||
```
|
||
gapYearsByPerson[owner] += max(0, round(phaseData.gapYears))
|
||
```
|
||
|
||
Die Ausfalljahre akkumulieren also über alle Erwerbsphasen hinweg.
|
||
|
||
**2. Rente berechnen** – für Elemente, deren Besitzer in dieser Phase **pensioniert** ist:
|
||
|
||
```
|
||
factor = max(0, (44 − gapYears) / 44)
|
||
rente = round(32'760 × factor)
|
||
```
|
||
|
||
- `AHV_MAX_ANNUAL_SINGLE = 32'760` – maximale einfache Altersrente pro Jahr inkl. 13. Rente
|
||
(2'520/Monat × 13), Stand 2026, Quelle BSV.
|
||
- `AHV_FULL_CONTRIBUTION_YEARS = 44` – volle Beitragsdauer (Rentenskala 44).
|
||
|
||
**3. Ehepaar-Plafonierung** – nur bei `householdType = COUPLE` **und** wenn für **beide**
|
||
Personen eine Rente vorliegt:
|
||
|
||
```
|
||
cap = 32'760 × 1.5 = 49'140
|
||
falls (renteA + renteB) > cap:
|
||
beide Renten proportional kürzen: rente × cap / summe
|
||
```
|
||
|
||
Die Rente ist danach **nominal fix** – sie wird über die Phasen hinweg nicht indexiert und
|
||
verliert damit real an Kaufkraft. Sie fliesst in `renteTotal` und wird im Einkommen mitgeführt.
|
||
|
||
Referenz: `src/lib/calculations.ts` Zeilen 180–202, `src/lib/constants.ts`.
|
||
|
||
## 4.5 Nominal, real und die Deflatoren
|
||
|
||
### 4.5.1 Das V5-Modell
|
||
|
||
| Grösse | Erfassung | Indexierung über die Phasenjahre |
|
||
|---|---|---|
|
||
| Einkommen | **nominal** | `basis × (1 + Lohnerhöhung)^(t−1)` |
|
||
| Renten (AHV, PK) | **nominal** | keine – konstant |
|
||
| Ausgaben | **real** | real: `basis × (1 + reale Mehrausgaben)^(t−1)`, dann **× kumulierte Inflation** |
|
||
|
||
Die Inflation ist seit V5 **plan-weit** (`plan.inflationRateDefault`) und gilt einheitlich für
|
||
alle Phasen; eine phasenspezifische Überschreibung existiert nicht (mehr).
|
||
|
||
### 4.5.2 Kumulierte Inflation
|
||
|
||
```
|
||
cumInflStart(Phase 1) = 1
|
||
cumInflStart(Phase n) = cumInflStart(Phase n−1) × (1 + infl/100)^duration(n−1)
|
||
```
|
||
|
||
### 4.5.3 Die drei Deflatoren
|
||
|
||
Ein subtiler, aber wichtiger Punkt: Bestandswerte und Flow-Werte haben am Phasenende **nicht
|
||
denselben** Deflator, weil ein Flow im Jahr `duration` anfällt, ein Bestand aber **nach** dem
|
||
Jahr `duration` gemessen wird.
|
||
|
||
| Deflator | Formel | Verwendung |
|
||
|---|---|---|
|
||
| `cumulativeInflationStart` | s. o. | Bestände zu Phasenbeginn, Flows im Jahr 1 |
|
||
| `cumulativeInflationEnd` | `cumInflStart × (1+infl)^duration` | Bestände am Phasenende (Cash, Vermögen) |
|
||
| `flowDeflatorEnd` | `cumInflStart × (1+infl)^(duration−1)` | Flow-Endwerte (Einkommen, Ausgaben, Quote) – **eine Kaufkraft-Stufe weniger** |
|
||
|
||
Referenz: `src/lib/calculations.ts` Zeilen 427–429; Anwendung in `src/components/PlanView.tsx` Zeilen 686–687, 716–717.
|
||
|
||
## 4.6 Element-Setup je Kategorie
|
||
|
||
Für jedes Element (sortiert nach `orderIndex`) wird ein `ElementPhaseComputed` erzeugt.
|
||
Vorab-Abbruch: Ist der Carry-Status `SOLD` → Notiz „Verkauft"; ist er `SETTLED` und die Kategorie
|
||
`OTHER_DEBT` → „Getilgt". Solche Elemente werden nicht weiter gerechnet.
|
||
|
||
### 4.6.1 INCOME / EXPENSE
|
||
|
||
```
|
||
idx = phaseData.teuerungsausgleich ?? 0
|
||
baseValue = hasCarry ? round(carry.flowBasis) : round(phaseData.amount)
|
||
basis = !hasCarry → round(phaseData.amount)
|
||
phaseData.amount ist Zahl → round(phaseData.amount) // bewusster Override
|
||
sonst → baseValue // live vererbt
|
||
carry.flowBasis = basis × (1 + idx/100)^duration // für die Folgephase
|
||
```
|
||
|
||
**Die Vererbungsregel** (`src/components/ElementDetail.tsx` Zeilen 480–490): Beim Speichern wird
|
||
das Feld `amount` **gelöscht**, wenn es exakt dem fortgeschriebenen Wert entspricht. Dadurch
|
||
bleibt der Wert „live vererbt" – eine spätere Änderung in einer früheren Phase wirkt sich weiter
|
||
durch. Nur ein bewusst abweichender Wert wird fix gespeichert.
|
||
|
||
Man beachte den Exponenten-Unterschied: der **Endwert** der Phase nutzt `duration − 1`
|
||
(letztes Jahr), der **Carry** für die Folgephase nutzt `duration` (ein Jahr weiter).
|
||
|
||
### 4.6.2 AHV
|
||
|
||
Erwerbstätig → nur Zusammenfassung („N Ausfalljahre" / „Keine Ausfalljahre"), kein Wert.
|
||
Pensioniert → `startValue = endValue = rente`, Summand in `renteTotal`.
|
||
|
||
### 4.6.3 PENSION_FUND
|
||
|
||
| Fall | Verhalten |
|
||
|---|---|
|
||
| pensioniert, `pkPensionAnnual > 0` | Rente: `startValue = endValue = pkPensionAnnual`, Summand in `renteTotal` |
|
||
| pensioniert, keine Rente | Notiz „Vollständig bezogen" |
|
||
| erwerbstätig | `base = hasCarry ? carry.value : phaseData.currentValue`; `topUp = hasCarry ? additionalInvestment : 0`; `start = base + topUp`; Rate = `annualContribution` |
|
||
|
||
Die PK-Rate wird **nicht** zu `fixedRatesTotal` addiert – sie belastet das Cash nicht.
|
||
`topUp` wird ab Phase 2 zu `investmentsFromCash` addiert.
|
||
|
||
### 4.6.4 PILLAR_3A
|
||
|
||
Identisch zu PK, mit zwei Unterschieden:
|
||
- Die Rate **wird** zu `fixedRatesTotal` addiert (belastet das Cash).
|
||
- Ist der Besitzer pensioniert, gibt es nie eine Rente, nur „Vollständig bezogen".
|
||
|
||
### 4.6.5 REAL_ESTATE (Immobilie)
|
||
|
||
```
|
||
purchase = round(phaseData.purchasePrice) // in JEDER Phase aus den Phasendaten
|
||
mortgageStart = hasCarry ? carry.mortgage : round(phaseData.mortgage)
|
||
amort = round(phaseData.amortization) // wird JÄHRLICH am Restsaldo gekappt
|
||
equity = purchase − mortgageStart → startValue, wealthStart
|
||
```
|
||
|
||
Die Hypothek wird als **laufender Saldo** in der Jahresschleife geführt (siehe 4.7), nicht per
|
||
Linearformel. Sobald sie 0 erreicht, entfällt die Amortisationsrate.
|
||
|
||
Falls **nicht** fortgeschrieben und **nicht** Phase 1 (= Neukauf in einer späteren Phase):
|
||
`investmentsFromCash += max(0, equity)` – das Eigenkapital wird aus dem Cash finanziert.
|
||
|
||
Der Wert der Immobilie ist über die Phasendauer **konstant der Kaufpreis**; nur die Hypothek
|
||
sinkt. Es gibt keine Wertsteigerung – der Verkaufspreis wird erst am Übergang erfasst.
|
||
|
||
### 4.6.6 OTHER_ASSET
|
||
|
||
```
|
||
base = hasCarry ? carry.value : round(phaseData.startValue)
|
||
topUp = hasCarry ? round(phaseData.additionalInvestment) : 0
|
||
start = base + topUp
|
||
rate = round(phaseData.annualContribution) → fixedRatesTotal += rate
|
||
withdrawal = round(phaseData.annualWithdrawal) → plannedWithdrawTotal += withdrawal
|
||
```
|
||
|
||
### 4.6.7 OTHER_DEBT
|
||
|
||
```
|
||
owedStart = hasCarry ? carry.owed : round(phaseData.startValue)
|
||
repay = round(phaseData.annualRepayment) // wird JÄHRLICH am Restsaldo gekappt
|
||
startValue = −owedStart // negatives Vorzeichen im Vermögen
|
||
```
|
||
|
||
Wie bei der Immobilie ist die Restschuld ein **laufender Saldo**; ist sie getilgt, entfällt die
|
||
Tilgungsrate.
|
||
|
||
## 4.7 Die Jahresschleife
|
||
|
||
Zunächst wird der Cash-Startwert gebildet:
|
||
|
||
```
|
||
cash = cashCarryIn − (isFirstPhase ? 0 : investmentsFromCash)
|
||
cashStart = cash
|
||
```
|
||
|
||
Die Investitionen werden also **am Phasenanfang** abgezogen. Grund (Kommentar im Code, Fix in
|
||
Commit `f768e01`): Der Cash-Startwert zeigt damit den Bestand **nach** den Investitionen – die
|
||
investierten Mittel erscheinen im Vermögen und nicht doppelt auch im Cash.
|
||
|
||
Dann für `t = 1 .. duration`:
|
||
|
||
```
|
||
// 1. Einkommen (nominal)
|
||
incomeFlow = renteTotal + Σ (inc.basis × (1 + inc.idx/100)^(t−1))
|
||
|
||
// 2. Ausgaben (real → nominal)
|
||
inflFactor = cumInflStart × (1 + infl/100)^(t−1)
|
||
expenseReal = Σ (exp.basis × (1 + exp.idx/100)^(t−1))
|
||
expenseNominal = expenseReal × inflFactor
|
||
|
||
// 3. Quote
|
||
quote = incomeFlow − expenseNominal
|
||
|
||
// 4. YearPoint für die Grafik anfügen (year, age Person A, income, expenseNominal, expenseReal)
|
||
|
||
// 5. Vermögen: verzinsen, Sparbeitrag, Bezugsrate
|
||
für jedes Asset a:
|
||
grown = a.value × (1 + a.r/100) + a.rate // Zins zuerst, dann Einzahlung (nachschüssig)
|
||
w = min(a.withdrawal, max(0, grown)) // Bezug am Bestand gekappt
|
||
a.value = grown − w
|
||
cashFromWithdraw += w
|
||
|
||
// 6. Amortisation und Tilgung – jeweils am Restsaldo gekappt
|
||
debtRates = 0
|
||
für jede Immobilie re:
|
||
pay = min(re.amort, re.mortgage) // nie mehr als die Restschuld
|
||
re.mortgage −= pay
|
||
debtRates += pay
|
||
für jede Schuld d:
|
||
pay = min(d.repay, d.owed)
|
||
d.owed −= pay
|
||
debtRates += pay
|
||
falls t === 1 → plannedSaveRate = fixedRatesTotal + debtRates
|
||
|
||
// 7. Cash fortschreiben
|
||
cash += quote − fixedRatesTotal − debtRates + cashFromWithdraw
|
||
falls cash < 0 → cashNegative = true
|
||
|
||
// 8. Ruin prüfen (Gesamtvermögen zum Jahresende)
|
||
total = cash + Σ asset.value + Σ (re.purchase − re.mortgage) + Σ (−d.owed)
|
||
falls ruinAge === null und total < 0 → ruinAge = age(Person A) + yearsBefore + t
|
||
```
|
||
|
||
**Wichtige Details:**
|
||
- Verzinsung ist **nachschüssig**: der Sparbeitrag des Jahres wird nicht mitverzinst.
|
||
- **Amortisation und Tilgung enden mit der Schuld.** Hypothek und Restschuld sind laufende
|
||
Salden; die Rate ist pro Jahr auf den Restsaldo gekappt (`min(rate, saldo)`). Ist die Schuld
|
||
abbezahlt, fliesst kein Franken mehr ab – weder aus dem Cash noch in die Sparrate. Im letzten
|
||
Zahlungsjahr wird nur noch der Restbetrag fällig, nicht die volle Rate.
|
||
- `plannedSaveRate` (Kopf-Kennzahl) ist die tatsächliche Rate des **ersten** Phasenjahres. In
|
||
späteren Jahren kann sie tiefer liegen, wenn eine Schuld ausläuft.
|
||
- `cashNegative` wird gesetzt, sobald der Cash-Bestand **irgendwann innerhalb** der Phase unter 0
|
||
fällt – auch wenn er am Phasenende wieder positiv ist.
|
||
- Der Ruin bezieht sich auf das **Gesamtvermögen inkl. Immobilien**, nicht auf das Cash.
|
||
- `ruinAge` wird nur **einmal** gesetzt (erstes Auftreten, plan-weit).
|
||
|
||
Referenz: `src/lib/calculations.ts` Zeilen 365–425.
|
||
|
||
## 4.8 Endwerte und Phasen-Kennzahlen
|
||
|
||
```
|
||
Einkommen: startValue = basis
|
||
endValue = basis × (1 + idx/100)^(duration−1)
|
||
Ausgaben: startValue = basis × cumInflStart
|
||
endValue = basis × (1 + idx/100)^(duration−1) × flowDeflatorEnd
|
||
Assets: endValue = a.value (nach der Jahresschleife)
|
||
Immobilie: endValue = purchase − re.mortgage (laufender Saldo nach der Jahresschleife)
|
||
Schulden: endValue = −d.owed (0, falls getilgt; + Notiz „Wird getilgt")
|
||
```
|
||
|
||
Aggregate:
|
||
|
||
```
|
||
startWealthNominal = Σ Element-Startwerte + cashStart
|
||
endWealthNominal = Σ Element-Endwerte + cashEnd
|
||
endWealthReal = endWealthNominal / cumulativeInflationEnd
|
||
isConsumption = quotaStart < 0
|
||
incomplete = cashNegative // „roter Status" = Liquiditätslücke
|
||
capitalInflow = incomingInflow // aus dem Übergang IN diese Phase
|
||
capitalInvest = investmentsFromCash + incomingImmediateRepay
|
||
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 = "SELL"` → `txInflow += ec.endValue`; `carry.status = "SOLD"` (kein Steuerabzug).
|
||
Sonst → `carry.value = ec.endValue`.
|
||
|
||
### 4.9.4 REAL_ESTATE
|
||
|
||
Die Resthypothek wird zurückgerechnet: `restMortgage = purchase − ec.endValue`.
|
||
|
||
`decision = "SELL"`:
|
||
```
|
||
gain = max(0, salePrice − purchase)
|
||
tax = gain × (saleTaxRate / 100)
|
||
txInflow += round(salePrice − restMortgage − tax)
|
||
carry.status = "SOLD"
|
||
```
|
||
Der Nettoerlös ist also Verkaufspreis minus Hypothekenablösung minus Grundstückgewinnsteuer.
|
||
Ein Verlustverkauf erzeugt keine Steuer (`gain` bei 0 geklammert).
|
||
|
||
Sonst → `carry.mortgage = restMortgage`.
|
||
|
||
### 4.9.5 OTHER_DEBT
|
||
|
||
```
|
||
carry.owed = −ec.endValue
|
||
immediate = min(carry.owed, round(td.immediateRepayment))
|
||
falls immediate > 0:
|
||
carry.owed −= immediate
|
||
txImmediateRepay += immediate
|
||
falls carry.owed === 0 → carry.status = "SETTLED"
|
||
```
|
||
|
||
### 4.9.6 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_MAX_ANNUAL_SINGLE` | 32'760 | Max. einfache AHV-Altersrente/Jahr inkl. 13. Rente (2026) |
|
||
| `AHV_COUPLE_CAP_FACTOR` | 1.5 | Ehepaar-Plafonierung: 150 % der Einzel-Maximalrente |
|
||
| `AHV_FULL_CONTRIBUTION_YEARS` | 44 | Volle Beitragsdauer (Rentenskala 44) |
|
||
| `PILLAR_3A_MAX_ANNUAL` | 7'258 | Max. 3a-Beitrag/Jahr für PK-Versicherte (2026) |
|
||
| `DEFAULT_PK_CONVERSION_RATE` | 6 % | Umwandlungssatz |
|
||
| `DEFAULT_CAPITAL_TAX_RATE` | 8 % | Kapitalbezugssteuer |
|
||
| `DEFAULT_PROPERTY_GAINS_TAX_RATE` | 20 % | Grundstückgewinnsteuer |
|
||
|
||
Die drei Default-Sätze werden **sowohl als UI-Vorschlag als auch in der Berechnung als Fallback**
|
||
verwendet (`num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE)`). Grund laut Code-Kommentar: Damit
|
||
ein nicht angetippter Wert nicht fälschlich als 0 gerechnet wird.
|
||
|
||
---
|
||
|
||
# 5. Technische Spezifikation
|
||
|
||
## 5.1 Technologie-Stack
|
||
|
||
| Bereich | Technologie | Version |
|
||
|---|---|---|
|
||
| Framework | Next.js (App Router) | 16.2.10 |
|
||
| UI | React | 19.2.4 |
|
||
| Sprache | TypeScript | ^5 |
|
||
| Styling | Tailwind CSS | ^4 (via `@tailwindcss/postcss`) |
|
||
| Icons | lucide-react | ^1.24.0 |
|
||
| Charts | recharts | ^3.9.2 |
|
||
| ORM | Prisma | ^7.8.0 (Client-Output nach `src/generated/prisma`) |
|
||
| Datenbank | PostgreSQL | 16-alpine |
|
||
| DB-Treiber | `pg` + `@prisma/adapter-pg` | ^8.22.0 / ^7.8.0 |
|
||
| Validierung | Zod | ^4.4.3 |
|
||
| Auth | jose (JWT) + bcryptjs | ^6.2.3 / ^3.0.3 |
|
||
| Tests | Vitest | ^4.1.10 |
|
||
| Build | Docker (multi-stage), `output: "standalone"` | |
|
||
|
||
> **Hinweis für Entwickler:** Gemäss `AGENTS.md` weicht diese Next.js-Version von verbreiteten
|
||
> Konventionen ab. Vor Änderungen ist der relevante Guide unter `node_modules/next/dist/docs/`
|
||
> zu konsultieren.
|
||
|
||
## 5.2 Verzeichnisstruktur
|
||
|
||
```
|
||
FPT/
|
||
├── prisma/
|
||
│ ├── schema.prisma Datenmodell
|
||
│ └── migrations/ 8 Migrationen (chronologisch)
|
||
├── src/
|
||
│ ├── app/
|
||
│ │ ├── api/ Route Handlers (siehe Kapitel 6)
|
||
│ │ ├── login/page.tsx Login-/Registrierseite
|
||
│ │ ├── page.tsx Einstiegsseite (lädt /api/auth/me → AppShell)
|
||
│ │ ├── layout.tsx Root-Layout, Theme-Init-Script, Metadata
|
||
│ │ └── globals.css Tailwind + semantische Farb-Tokens (3 Themes)
|
||
│ ├── components/ 12 React-Komponenten (alle "use client")
|
||
│ ├── generated/prisma/ Generierter Prisma-Client (nicht editieren)
|
||
│ ├── lib/ Domänenlogik (siehe 5.3)
|
||
│ └── middleware.ts Zugriffsschutz (Edge-Runtime)
|
||
├── Info Dateien/ Fachdokumente, Roadmap (historisch)
|
||
├── docker-compose.yml, Dockerfile, docker-entrypoint.sh
|
||
└── .gitea/workflows/deploy.yaml CI/CD
|
||
```
|
||
|
||
## 5.3 Schichtenmodell und `src/lib`
|
||
|
||
Ein bewusster Entkopplungs-Entscheid (`src/lib/types.ts` Zeilen 1–4): Die Berechnungslogik
|
||
arbeitet auf **eigenen Domänentypen**, nicht auf den generierten Prisma-Typen. Dadurch ist
|
||
`computePlan` ohne Datenbank testbar.
|
||
|
||
```
|
||
Prisma-Modelle (DB)
|
||
│ toPlanInput() ← queries.ts: parst + validiert die JSON-Felder
|
||
▼
|
||
PlanInput (types.ts) ← reine Domänentypen
|
||
│ computePlan() ← calculations.ts: pure function
|
||
▼
|
||
PlanComputed ← an den Client geliefert
|
||
```
|
||
|
||
| Datei | Verantwortung |
|
||
|---|---|
|
||
| `calculations.ts` | Berechnungskern + CSV-Export. Keine I/O. |
|
||
| `elements.ts` | Kategorien, Labels, Reihenfolge, JSON-Payload-Typen, Zod-Schemas, `num()` |
|
||
| `types.ts` | Domänentypen für API und Berechnung |
|
||
| `constants.ts` | Schweizer Systemparameter |
|
||
| `queries.ts` | Prisma-Includes, `toPlanInput()`, Ownership-Abfragen |
|
||
| `db.ts` | Prisma-Singleton (Global-Cache im Dev, verhindert Connection-Leak bei HMR) |
|
||
| `auth.ts` | JWT erzeugen/prüfen (Edge-kompatibel via `jose`) |
|
||
| `session.ts` | `getCurrentUserId()` aus dem Cookie (Node-Runtime) |
|
||
| `users.ts` | Registrierung, Credential-Prüfung, Passwortwechsel (bcrypt). **Nie aus der Middleware importieren** – Edge-Runtime hat keinen DB-Zugriff. |
|
||
| `format.ts` | `formatChf` / `parseChfInput` |
|
||
| `theme.ts` | Theme-Verwaltung (localStorage + `data-theme`) |
|
||
| `api-client.ts` | Typisierter `fetch`-Wrapper mit einheitlicher Fehlerextraktion |
|
||
|
||
## 5.4 Datenmodell
|
||
|
||
### 5.4.1 Tabellen
|
||
|
||
**User**
|
||
|
||
| Feld | Typ | Constraints |
|
||
|---|---|---|
|
||
| `id` | String | PK, `cuid()` |
|
||
| `username` | String | **unique** |
|
||
| `passwordHash` | String | bcrypt, Cost 12 |
|
||
| `createdAt` | DateTime | `now()` |
|
||
|
||
**Person**
|
||
|
||
| Feld | Typ | Constraints |
|
||
|---|---|---|
|
||
| `id` | String | PK, `cuid()` |
|
||
| `planId` | String | FK → Plan, **Cascade** |
|
||
| `role` | `PersonRole` | `PERSON_A` \| `PERSON_B` |
|
||
| `name` | String? | optional |
|
||
| `age` | Int | aktuelles Alter |
|
||
| `retirementAge` | Int | plan-eigenes Pensionsalter |
|
||
| | | `@@unique([planId, role])` |
|
||
|
||
**Plan**
|
||
|
||
| Feld | Typ | Constraints |
|
||
|---|---|---|
|
||
| `id` | String | PK, `cuid()` |
|
||
| `userId` | String | FK → User, **Cascade** |
|
||
| `name` | String | |
|
||
| `householdType` | `HouseholdType` | `SINGLE` \| `COUPLE` |
|
||
| `inflationRateDefault` | Float | plan-weite Inflation in % |
|
||
| `initialCash` | Float | Default 0 |
|
||
| `parentPlanId` | String? | FK → Plan (Self-Relation „PlanScenarios"), **SetNull** |
|
||
| `branchFromPhaseId` | String? | ID der letzten kopierten Phase (**keine** FK-Constraint) |
|
||
| `createdAt` / `updatedAt` | DateTime | |
|
||
|
||
**Phase**
|
||
|
||
| Feld | Typ | Constraints |
|
||
|---|---|---|
|
||
| `id` | String | PK, `cuid()` |
|
||
| `planId` | String | FK → Plan, **Cascade** |
|
||
| `sequenceNumber` | Int | 1-basiert, lückenlos |
|
||
| `name` | String | |
|
||
| `durationYears` | Int | 1–80 |
|
||
| `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 4–6, `src/lib/elements.ts` Zeilen 48–51.
|
||
|
||
### 5.4.3 JSON-Payload `PhaseData`
|
||
|
||
| Feld | Kategorien | Zod-Regel |
|
||
|---|---|---|
|
||
| `amount` | INCOME, EXPENSE | ≥ 0 |
|
||
| `teuerungsausgleich` | INCOME, EXPENSE | −20 bis 50 |
|
||
| `gapYears` | AHV | Integer ≥ 0 |
|
||
| `currentValue` | PENSION_FUND, PILLAR_3A | ≥ 0 |
|
||
| `startValue` | OTHER_ASSET, OTHER_DEBT | ≥ 0 |
|
||
| `expectedReturn` | PK, 3a, OTHER_ASSET | −50 bis 100 |
|
||
| `annualContribution` | PK, 3a, OTHER_ASSET | ≥ 0 |
|
||
| `annualWithdrawal` | OTHER_ASSET | ≥ 0 |
|
||
| `additionalInvestment` | PK, 3a, OTHER_ASSET (ab Phase 2) | ≥ 0 |
|
||
| `purchasePrice` | REAL_ESTATE | ≥ 0 |
|
||
| `mortgage` | REAL_ESTATE | ≥ 0 |
|
||
| `amortization` | REAL_ESTATE | ≥ 0 |
|
||
| `annualRepayment` | OTHER_DEBT | ≥ 0 |
|
||
|
||
### 5.4.4 JSON-Payload `TransitionData`
|
||
|
||
| Feld | Kategorien | Zod-Regel |
|
||
|---|---|---|
|
||
| `withdrawalMode` | PK, 3a (normal) | `NONE` \| `AMOUNT` |
|
||
| `withdrawal` | PK, 3a (normal) | ≥ 0, **brutto** |
|
||
| `payoutMode` | PK (Pensionierung) | `CAPITAL` \| `PENSION` \| `COMBI` |
|
||
| `capitalAmount` | PK (COMBI) | ≥ 0 |
|
||
| `conversionRate` | PK | 0–20 |
|
||
| `capitalTaxRate` | PK + 3a, **sowohl Vorbezug als auch Pensionierung** | 0–100 |
|
||
| `decision` | REAL_ESTATE, OTHER_ASSET | `HOLD` \| `SELL` |
|
||
| `salePrice` | REAL_ESTATE | ≥ 0 |
|
||
| `saleTaxRate` | REAL_ESTATE | 0–100 |
|
||
| `immediateRepayment` | OTHER_DEBT | ≥ 0 |
|
||
|
||
### 5.4.5 JSON-Payload `CashTransitionData`
|
||
|
||
Liegt in `Phase.cashTransition`. Validierung über `cashTransitionSchema`.
|
||
|
||
| Feld | Bedeutung | Zod-Regel |
|
||
|---|---|---|
|
||
| `mode` | Entscheid | `NONE` \| `INFLOW` \| `OUTFLOW` \| `BOTH` |
|
||
| `inflowLabel` | Bezeichnung des Zuflusses (z. B. „Erbschaft") | ≤ 120 Zeichen |
|
||
| `inflowAmount` | Betrag **nominal** | ≥ 0 |
|
||
| `inflowTaxRate` | Steuer auf den Zufluss, Default 0 % | 0–100 |
|
||
| `outflowLabel` | Bezeichnung der Kosten (z. B. „Poolbau") | ≤ 120 Zeichen |
|
||
| `outflowAmount` | Betrag **real** (heutige Kaufkraft) | ≥ 0 |
|
||
|
||
Pro Übergang ist **genau ein** Zufluss und **eine** Kostenposition möglich – siehe
|
||
[9.7](#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 479–480):
|
||
Beim Wechsel von Zelle zu Zelle wird ein Neuaufbau erzwungen, damit der lokale Formularzustand
|
||
nicht vom vorher geöffneten Element übrig bleibt.
|
||
|
||
## 5.6 Sicherheit
|
||
|
||
| Aspekt | Umsetzung |
|
||
|---|---|
|
||
| Passwortspeicherung | bcrypt, Cost 12 |
|
||
| Session | JWT HS256, 30 Tage, HttpOnly-Cookie, `sameSite=lax`, `secure` in Produktion |
|
||
| Secret | `SESSION_SECRET` aus der Umgebung; Fehlen wirft beim ersten Zugriff |
|
||
| Autorisierung | Middleware (grob) + Ownership-Query je Route (fein) |
|
||
| Information Disclosure | Fremde/nicht existierende Ressourcen → einheitlich 404; Login-Fehler unspezifisch |
|
||
| Eingabevalidierung | Zod an jeder API-Grenze; `.strip()` gegen Mass-Assignment |
|
||
| SQL-Injection | Prisma (parametrisiert) |
|
||
| XSS | React-Escaping; einziges `dangerouslySetInnerHTML` ist das statische Theme-Init-Script |
|
||
| CSRF | Kein Token. Schutz beruht allein auf `sameSite=lax` – siehe 9.4 |
|
||
|
||
## 5.7 Konfiguration
|
||
|
||
| Variable | Zweck |
|
||
|---|---|
|
||
| `DATABASE_URL` | Postgres-Connection-String |
|
||
| `SESSION_SECRET` | JWT-Signaturschlüssel (z. B. `openssl rand -hex 32`) |
|
||
| `POSTGRES_PASSWORD` | Nur für docker-compose: Passwort des `db`-Containers |
|
||
| `NODE_ENV` | Steuert u. a. das `secure`-Flag des Cookies und den Prisma-Global-Cache |
|
||
|
||
Es gibt **kein** konfiguriertes Login-Passwort: Konten werden über die Registrierung angelegt.
|
||
|
||
---
|
||
|
||
# 6. API-Referenz
|
||
|
||
Alle Routen liefern JSON. Fehlerformat einheitlich: `{ "error": "<Meldung>" }` (bei
|
||
Zod-Fehlern in `POST /api/plans`: `{ "error": <flatten()-Objekt> }`).
|
||
Alle Routen ausser `login`/`register` erfordern ein gültiges Session-Cookie.
|
||
|
||
## 6.1 Authentifizierung
|
||
|
||
| Methode | Pfad | Body | Antwort |
|
||
|---|---|---|---|
|
||
| POST | `/api/auth/register` | `{ username, password }` | 201 `{ ok, username }` + Cookie · 400 Validierung · 409 Name vergeben |
|
||
| POST | `/api/auth/login` | `{ username, password }` | 200 `{ ok, username }` + Cookie · 400 · 401 |
|
||
| POST | `/api/auth/logout` | – | 200 `{ ok }`, Cookie gelöscht |
|
||
| GET | `/api/auth/me` | – | 200 `{ user: { id, username, createdAt } }` · 401 · 404 |
|
||
| POST | `/api/auth/change-password` | `{ currentPassword, newPassword }` | 200 `{ ok }` · 400 · 401 |
|
||
|
||
## 6.2 Pläne
|
||
|
||
### `GET /api/plans`
|
||
Liste der eigenen Pläne, sortiert nach `createdAt` aufsteigend.
|
||
```json
|
||
{ "plans": [ { "id", "name", "parentPlanId", "branchFromPhaseId", "createdAt",
|
||
"phases": [ { "id", "name", "sequenceNumber" } ] } ] }
|
||
```
|
||
|
||
### `POST /api/plans`
|
||
```json
|
||
{ "name": "Basisplan", "householdType": "COUPLE", "inflationRateDefault": 1.5,
|
||
"persons": [ { "role": "PERSON_A", "name": "Anna", "age": 40, "retirementAge": 65 },
|
||
{ "role": "PERSON_B", "name": null, "age": 38, "retirementAge": 64 } ] }
|
||
```
|
||
Validierung: `name` 1–120; `inflationRateDefault` −20…50; `persons` 1–2 Einträge;
|
||
`age` 0–120; `retirementAge` 30–100; `name` je Person ≤ 60. Zusätzlich Konsistenzregel
|
||
SINGLE=1 / COUPLE=2 Personen. → 201 `{ plan: { id } }`
|
||
|
||
### `GET /api/plans/<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` 1–80, wird gekappt.
|
||
→ 200 `{ phase: { id } }`
|
||
|
||
### `DELETE /api/phases/<phaseId>`
|
||
Nur die letzte Phase. → 200 `{ ok }` · 400 „Nur die letzte Phase kann geloescht werden."
|
||
|
||
### `PUT /api/phases/<phaseId>/cash-transition`
|
||
Body = `CashTransitionData` (siehe 5.4.5). Speichert den Cash-Entscheid für den Übergang **nach**
|
||
dieser Phase (einmalige Sonderein-/ausgaben). Schreibt die Spalte `Phase.cashTransition`.
|
||
→ 200 `{ ok }` · 404 wenn die Phase nicht dem Benutzer gehört.
|
||
|
||
## 6.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 }` (1–120). → 200 `{ ok }`
|
||
|
||
### `DELETE /api/elements/<elementId>`
|
||
→ 200 `{ ok }`, Cascade auf alle Phasen-/Übergangswerte.
|
||
|
||
### `PUT /api/elements/<elementId>/phase/<phaseId>`
|
||
Body = `PhaseData`. **Upsert** auf `@@unique([elementId, phaseId])`. Prüft zusätzlich, dass die
|
||
Phase zum selben Plan gehört wie das Element. → 200 `{ ok }`
|
||
|
||
### `PUT /api/elements/<elementId>/transition/<fromPhaseId>`
|
||
Body = `TransitionData`. **Upsert** auf `@@unique([elementId, fromPhaseId])`. → 200 `{ ok }`
|
||
|
||
---
|
||
|
||
# 7. Betrieb und Deployment
|
||
|
||
## 7.1 Container
|
||
|
||
**Dockerfile** – vierstufiger Multi-Stage-Build auf `node:20-alpine`:
|
||
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 18 Tests („V5 Golden Tests"),
|
||
ausgeführt mit Vitest in der Node-Umgebung (`vitest.config.ts`, Include `src/**/*.test.ts`).
|
||
Es gibt **keine** Komponenten-, API- oder E2E-Tests.
|
||
|
||
## 8.2 Testfälle
|
||
|
||
| Test | Prüft |
|
||
|---|---|
|
||
| Test 1 – Ansparen | Einkommen +2 % nominal, Ausgaben real flach: Endvermögen 761'654 nominal / 565'928 real (±1 %), kein negatives Cash |
|
||
| Test 2 – Verzehr/Ruin | Rente nominal fix 60k, Ausgaben real 100k, Vermögen 900k @3 %: `ruinAge === 94` |
|
||
| Test 3 – Cash-Ausgleich | Sparrate 6'364: `cashEnd === 5472`, nie negativ |
|
||
| Test 4 – Liquiditätslücke | Sparrate 10'000: `cashEnd === -5436`, `cashNegative === true` |
|
||
| Ausgaben real → nominal | Jahr 1 nominal = real; Jahr 5 = `100'000 × 1.02^4` |
|
||
| Einkommen flach | Lohnerhöhung 0 % → `incomeStart === incomeEnd === 80'000` |
|
||
| Bezugsrate | 3 × 10'000 Entnahme → `cashEnd === 30'000`, Asset-Endwert 0, `plannedWithdrawRate === 10'000` |
|
||
| Kapitalzufluss/-investition | Verkauf in P1, Reinvestition in P2: `capitalInflow === 10'000`, `capitalInvest === 10'000`, `cashStart === 0`, `startWealthNominal === 10'000` (kein Doppelzählen) |
|
||
| Cash-Anfangswert | `initialCash` 50'000 fliesst in Phase 1 ein |
|
||
| **Tilgung stoppt** | Schuld 25'000, Tilgung 10'000/J., 5 Jahre: Gesamtabfluss 25'000 (nicht 50'000), `cashEnd === 75'000`, Restschuld 0 |
|
||
| **Amortisation stoppt** | Hypothek 15'000, Amortisation 10'000/J., 4 Jahre: `cashEnd === 85'000` (nicht 60'000), Immobilie schuldenfrei |
|
||
| **Vorbezugssteuer** | PK-Vorbezug 100'000 brutto @ 8 %: `capitalInflow === 92'000`, Restkapital 200'000 (brutto entnommen) |
|
||
| **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 ohne Wertentwicklung
|
||
|
||
Der Immobilienwert ist in jeder Phase der aus den Phasendaten gelesene `purchasePrice`. Der
|
||
Kaufpreis wird beim Anlegen einer Folgephase mitkopiert (`buildCarryData`). Eine Wertsteigerung
|
||
lässt sich nur indirekt abbilden, indem man den `purchasePrice` in einer späteren Phase
|
||
manuell erhöht – was dann allerdings auch die Berechnung der Grundstückgewinnsteuer beim Verkauf
|
||
beeinflusst (`gain = salePrice − purchase` liest den `purchasePrice` der Verkaufsphase).
|
||
|
||
## 9.4 Kein CSRF-Token
|
||
|
||
Zustandsändernde Requests sind allein durch `sameSite=lax` geschützt. Das deckt klassische
|
||
Cross-Site-Formular-POSTs ab, ist aber schwächer als ein expliziter Token.
|
||
|
||
## 9.5 Logout invalidiert das Token nicht serverseitig
|
||
|
||
Das JWT ist zustandslos und bis zu 30 Tage gültig. `logout` löscht nur das Cookie.
|
||
|
||
## 9.6 Spar- und Bezugsraten werden nicht indexiert
|
||
|
||
`annualContribution`, `annualWithdrawal`, `amortization` und `annualRepayment` sind flache
|
||
Nominalbeträge, die über die Phasenjahre **konstant** bleiben. Eine Sparrate von 10'000 bleibt
|
||
20 Jahre lang 10'000 nominal und verliert dabei real an Gewicht. Wer eine mitwachsende Rate
|
||
abbilden will, muss die Phase teilen und den Betrag in der Folgephase erhöhen.
|
||
|
||
## 9.7 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 `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.10 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 |
|
||
| **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*
|