Einmalige Sonderein-/ausgaben am Cash-Uebergang (Roadmap Nr. 1)
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>
This commit is contained in:
2026-07-17 08:14:44 +02:00
parent 87e6a5f564
commit 9f5bd754eb
12 changed files with 665 additions and 30 deletions
+148 -16
View File
@@ -4,10 +4,10 @@
| | | | | |
|---|---| |---|---|
| **Dokument** | Funktionale und Technische Spezifikation FPT | | **Dokument** | Funktionale und Technische Spezifikation FPT |
| **Version** | 0.2 | | **Version** | 0.3 |
| **Datum** | 2026-07-16 | | **Datum** | 2026-07-16 |
| **Status** | Lebendes Dokument | | **Status** | Lebendes Dokument |
| **Codestand** | Arbeitsstand nach `f768e01` inkl. Fixes zu Phaseninflation, Tilgungsraten und Vorbezugssteuer (Branch `main`) | | **Codestand** | Arbeitsstand nach `87e6a5f` inkl. einmaliger Sonderein-/ausgaben am Cash-Übergang (Branch `main`) |
| **Ersetzt** | `FDD_TDD_FPT.docx` (v1v5) im Ordner `Info Dateien` diese sind ab Version 0.1 dieses Dokuments obsolet | | **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` | | **Geltungsbereich** | Gesamter Code im Verzeichnis `FPT` |
@@ -17,6 +17,7 @@
| Version | Datum | Autor | Änderung | | 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.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. | | 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. |
@@ -139,10 +140,16 @@ Cash ist kein vom Benutzer erfassbares Element, sondern das systemseitige Ausgle
- Es empfängt die **Bezugsraten** aus Sonstigem Vermögen. - 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 - Es empfängt an Übergängen **Kapitalzuflüsse** (Verkäufe, PK-/3a-Bezüge) und finanziert
**Sofort-Tilgungen** sowie **Zusatzinvestitionen** der Folgephase. **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 - Es darf **negativ werden** dies ist die Definition einer Liquiditätslücke und wird rot
markiert, aber nicht automatisch korrigiert. markiert, aber nicht automatisch korrigiert.
Referenz: `src/lib/calculations.ts` Zeilen 365425, 599. 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).
--- ---
@@ -456,8 +463,10 @@ Schulden gehen mit **negativem** Vorzeichen ins Vermögen ein.
### 3.5.1 Konzept ### 3.5.1 Konzept
Zwischen zwei Phasen liegt ein Übergang. Er ist der Ort, an dem einmalige Entscheide getroffen Zwischen zwei Phasen liegt ein Übergang. Er ist der Ort, an dem einmalige Entscheide getroffen
werden. Nur fünf Kategorien haben Übergangs-Entscheide (`TRANSITION_CATEGORIES`): werden. Fünf Kategorien haben Übergangs-Entscheide (`TRANSITION_CATEGORIES`):
`PENSION_FUND`, `PILLAR_3A`, `REAL_ESTATE`, `OTHER_ASSET`, `OTHER_DEBT`. `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 Für `INCOME`, `EXPENSE` und `AHV` erscheint: „Fuer diese Kategorie gibt es im Uebergang keine
Eingaben." Eingaben."
@@ -499,6 +508,7 @@ Entscheidungsfeld gesetzt ist:
| `REAL_ESTATE`, `OTHER_ASSET` | `decision` gesetzt | | `REAL_ESTATE`, `OTHER_ASSET` | `decision` gesetzt |
| `PENSION_FUND` | Pensions-Übergang: `payoutMode` gesetzt; sonst: `withdrawalMode` gesetzt | | `PENSION_FUND` | Pensions-Übergang: `payoutMode` gesetzt; sonst: `withdrawalMode` gesetzt |
| `PILLAR_3A` | Pensions-Übergang: **immer** beantwortet; sonst: `withdrawalMode` gesetzt | | `PILLAR_3A` | Pensions-Übergang: **immer** beantwortet; sonst: `withdrawalMode` gesetzt |
| **Cash** | `mode` gesetzt (`isCashTransitionAnswered`) |
| alle anderen | immer beantwortet | | alle anderen | immer beantwortet |
Der Übergangs-Spaltenkopf zeigt entweder „N offen" (Akzentfarbe) oder „geprüft" (grün, Häkchen). Der Übergangs-Spaltenkopf zeigt entweder „N offen" (Akzentfarbe) oder „geprüft" (grün, Häkchen).
@@ -513,8 +523,9 @@ Referenz: `src/components/PlanView.tsx` Zeilen 200232, `src/components/Elemen
### 3.5.4 Geführter Übergang (Review-Dialog) ### 3.5.4 Geführter Übergang (Review-Dialog)
Klick auf einen Übergangs-Spaltenkopf öffnet „Übergang prüfen: <Von> → <Nach>". Der Dialog Klick auf einen Übergangs-Spaltenkopf öffnet „Übergang prüfen: <Von> → <Nach>". Der Dialog
listet **alle** noch aktiven Elemente der Übergangs-Kategorien untereinander mit ihren listet zuoberst den **Cash-Entscheid** (einmalige Sonderein-/ausgaben, betrifft jeden Übergang)
Entscheidfeldern und kontextabhängigen Hinweisen: 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/3a, normaler Übergang: „Hier könnten Sie optional Kapital beziehen."
- PK, Pensionierung: „Pensionierung: Bezugsart wählen (Rente / Kapital / Kombination)." - PK, Pensionierung: „Pensionierung: Bezugsart wählen (Rente / Kapital / Kombination)."
@@ -524,7 +535,45 @@ Entscheidfeldern und kontextabhängigen Hinweisen:
`withTransitionDefaults` vorbelegt (Halten / Kein Bezug / Rente), damit ein blosses Speichern den `withTransitionDefaults` vorbelegt (Halten / Kein Bezug / Rente), damit ein blosses Speichern den
**sichtbaren** Default auch tatsächlich persistiert und die Ampel auf grün geht. **sichtbaren** Default auch tatsächlich persistiert und die Ampel auf grün geht.
Referenz: `src/components/PlanView.tsx` Zeilen 10401127, `src/components/ElementDetail.tsx` Zeilen 89105. 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 Auswertung und Visualisierung
@@ -571,8 +620,14 @@ Jeder Phasenkopf zeigt kompakt:
| Geplante Verzehrrate | Summe der Bezugsraten | | Geplante Verzehrrate | Summe der Bezugsraten |
| Kapitalzufluss | nur wenn > 0: Verkäufe + PK-/3a-Bezüge aus dem Übergang **in** diese Phase | | Kapitalzufluss | nur wenn > 0: Verkäufe + PK-/3a-Bezüge aus dem Übergang **in** diese Phase |
| Kapitalinvestitionen | nur wenn > 0: Zusatzinvestitionen + Sofort-Tilgungen | | 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) | | 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. Referenz: `src/components/PlanView.tsx` Zeilen 701769.
### 3.6.4 Dashboard ### 3.6.4 Dashboard
@@ -974,6 +1029,8 @@ isConsumption = quotaStart < 0
incomplete = cashNegative // „roter Status" = Liquiditätslücke incomplete = cashNegative // „roter Status" = Liquiditätslücke
capitalInflow = incomingInflow // aus dem Übergang IN diese Phase capitalInflow = incomingInflow // aus dem Übergang IN diese Phase
capitalInvest = investmentsFromCash + incomingImmediateRepay capitalInvest = investmentsFromCash + incomingImmediateRepay
oneOffInflow = incomingOneOffInflow // einmaliger Zufluss (netto nach Steuer)
oneOffOutflow = incomingOneOffOutflow // einmalige Kosten (nominal)
``` ```
## 4.9 Der Übergang ## 4.9 Der Übergang
@@ -1046,12 +1103,41 @@ falls immediate > 0:
falls carry.owed === 0 → carry.status = "SETTLED" falls carry.owed === 0 → carry.status = "SETTLED"
``` ```
### 4.9.6 Abschluss des Übergangs ### 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.
``` ```
cashCarryIn = cashEnd + txInflow txImmediateRepay mode = cashTransition.mode ?? "NONE"
incomingInflow = txInflow // Kopf-Kennzahl der Folgephase
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 incomingImmediateRepay = txImmediateRepay
incomingOneOffInflow = txOneOffInflow // inkl. Bezeichnung
incomingOneOffOutflow = txOneOffOutflow // inkl. Bezeichnung
yearsBefore += duration yearsBefore += duration
``` ```
@@ -1212,9 +1298,14 @@ PlanComputed ← an den Client geliefert
| `sequenceNumber` | Int | 1-basiert, lückenlos | | `sequenceNumber` | Int | 1-basiert, lückenlos |
| `name` | String | | | `name` | String | |
| `durationYears` | Int | 180 | | `durationYears` | Int | 180 |
| `cashTransition` | Json? | Cash-Entscheid beim Übergang **nach** dieser Phase (siehe 5.4.5) |
| `createdAt` / `updatedAt` | DateTime | | | `createdAt` / `updatedAt` | DateTime | |
| | | `@@unique([planId, sequenceNumber])` | | | | `@@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** **FinancialElement**
| Feld | Typ | Constraints | | Feld | Typ | Constraints |
@@ -1293,12 +1384,28 @@ Referenz: `prisma/schema.prisma` Zeilen 46, `src/lib/elements.ts` Zeilen 48
| `saleTaxRate` | REAL_ESTATE | 0100 | | `saleTaxRate` | REAL_ESTATE | 0100 |
| `immediateRepayment` | OTHER_DEBT | ≥ 0 | | `immediateRepayment` | OTHER_DEBT | ≥ 0 |
Beide Schemas verwenden `.strip()` **unbekannte Felder werden verworfen**, nicht abgelehnt. ### 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 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, (`parsePhaseData` / `parseTransitionData`) korrupte Daten führen also nie zu einem Absturz,
sondern zu leeren Werten. sondern zu leeren Werten.
### 5.4.5 Migrationshistorie ### 5.4.6 Migrationshistorie
| Migration | Inhalt | | Migration | Inhalt |
|---|---| |---|---|
@@ -1311,6 +1418,7 @@ sondern zu leeren Werten.
| `20260714120000_person_name` | `Person.name` | | `20260714120000_person_name` | `Person.name` |
| `20260715120000_plan_initial_cash` | `Plan.initialCash` | | `20260715120000_plan_initial_cash` | `Plan.initialCash` |
| `20260716210000_drop_phase_inflation_rate` | `Phase.inflationRate` entfernt (Inflation ist plan-weit) | | `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 Frontend-Architektur
@@ -1469,6 +1577,11 @@ Hängt eine Phase am Ende an, kappt die Dauer, vergibt Default-Name, legt vorbel
### `DELETE /api/phases/<phaseId>` ### `DELETE /api/phases/<phaseId>`
Nur die letzte Phase. → 200 `{ ok }` · 400 „Nur die letzte Phase kann geloescht werden." 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 ## 6.4 Elemente
### `POST /api/plans/<planId>/elements` ### `POST /api/plans/<planId>/elements`
@@ -1548,7 +1661,7 @@ Zielumgebung: Hetzner CX23, Traefik als Reverse Proxy, Domain `fpt.aicds.ch`, Gi
## 8.1 Teststrategie ## 8.1 Teststrategie
Getestet wird ausschliesslich der Berechnungskern bewusst, da dort die Fachlogik und das Getestet wird ausschliesslich der Berechnungskern bewusst, da dort die Fachlogik und das
Regressionsrisiko liegen. `src/lib/calculations.test.ts` enthält 13 Tests („V5 Golden Tests"), 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`). ausgeführt mit Vitest in der Node-Umgebung (`vitest.config.ts`, Include `src/**/*.test.ts`).
Es gibt **keine** Komponenten-, API- oder E2E-Tests. Es gibt **keine** Komponenten-, API- oder E2E-Tests.
@@ -1568,6 +1681,11 @@ Es gibt **keine** Komponenten-, API- oder E2E-Tests.
| **Tilgung stoppt** | Schuld 25'000, Tilgung 10'000/J., 5 Jahre: Gesamtabfluss 25'000 (nicht 50'000), `cashEnd === 75'000`, Restschuld 0 | | **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 | | **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) | | **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)` | | Fortschreibung | Einkommens-Basiswert P1 → Startwert P2 = `100'000 × 1.02^5`; `cashStart(P2) === cashEnd(P1)` |
## 8.3 Ausführung ## 8.3 Ausführung
@@ -1635,12 +1753,25 @@ Nominalbeträge, die über die Phasenjahre **konstant** bleiben. Eine Sparrate v
20 Jahre lang 10'000 nominal und verliert dabei real an Gewicht. Wer eine mitwachsende Rate 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. abbilden will, muss die Phase teilen und den Betrag in der Folgephase erhöhen.
## 9.7 `PILLAR_3A_MAX_ANNUAL` wird nur im UI erzwungen ## 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 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. `≥ 0` ein direkter API-Aufruf kann die Obergrenze überschreiten.
## 9.8 Kleinere Beobachtungen ## 9.10 Kleinere Beobachtungen
- `planToCsv(plan, computed)` erhält den `plan`-Parameter, verwendet ihn aber nicht. - `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 - Der Typ `Selection` in `PlanView` hat nur eine Variante (`{ type: "phase" }`) ein Rest der
@@ -1666,6 +1797,7 @@ Das Feld ist per `max`-Prop hart geklammert. Das Zod-Schema kennt für `annualCo
| **Phasentyp** | `ERWERB` / `PENSION` / `MIXED`; abgeleitet, nie gespeichert | | **Phasentyp** | `ERWERB` / `PENSION` / `MIXED`; abgeleitet, nie gespeichert |
| **Finanzielles Element** | Plan-weite Entität einer der 8 Kategorien, über alle Phasen identisch | | **Finanzielles Element** | Plan-weite Entität einer der 8 Kategorien, über alle Phasen identisch |
| **Übergang** | Grenze zwischen zwei Phasen; Ort der einmaligen Entscheide | | **Ü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 | | **Pensions-Übergang** | Übergang, bei dem der Besitzer des Elements pensioniert wird |
| **Carry / Fortschreibung** | Live-Übertragung des Endwerts einer Phase in die nächste | | **Carry / Fortschreibung** | Live-Übertragung des Endwerts einer Phase in die nächste |
| **Cash** | Systemseitiges Ausgleichskonto; darf negativ werden (Liquiditätslücke) | | **Cash** | Systemseitiges Ausgleichskonto; darf negativ werden (Liquiditätslücke) |
@@ -0,0 +1,4 @@
-- Einmalige Sonderein-/ausgaben (Roadmap Nr. 1): Entscheid fuer das Cash-Konto beim Uebergang
-- NACH dieser Phase. Cash ist kein FinancialElement und kann deshalb keinen
-- ElementTransitionValue tragen -- der Entscheid haengt darum direkt an der Von-Phase.
ALTER TABLE "Phase" ADD COLUMN "cashTransition" JSONB;
+5
View File
@@ -102,6 +102,11 @@ model Phase {
name String name String
durationYears Int durationYears Int
// Entscheid fuer das Cash-Konto beim UEBERGANG NACH dieser Phase (JSON, validiert in
// lib/elements.ts): 1:1 uebernehmen oder einmaliger Zufluss/einmalige Kosten. Liegt hier
// und nicht in ElementTransitionValue, weil Cash kein FinancialElement ist.
cashTransition Json?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { getOwnedPhase } from "@/lib/queries";
import { getCurrentUserId } from "@/lib/session";
import { cashTransitionSchema } from "@/lib/elements";
// Speichert den Cash-Entscheid beim UEBERGANG nach dieser Phase: 1:1 uebernehmen oder
// einmaliger Zufluss / einmalige Kosten (Roadmap Nr. 1). Cash ist kein FinancialElement,
// deshalb liegt der Entscheid direkt an der Von-Phase.
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ phaseId: string }> }
) {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { phaseId } = await params;
const phase = await getOwnedPhase(phaseId, userId);
if (!phase) return NextResponse.json({ error: "Phase nicht gefunden." }, { status: 404 });
const body = await request.json();
const parsed = cashTransitionSchema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 });
await prisma.phase.update({
where: { id: phase.id },
data: { cashTransition: parsed.data },
});
return NextResponse.json({ ok: true });
}
@@ -58,6 +58,8 @@ export async function POST(
sequenceNumber: phase.sequenceNumber, sequenceNumber: phase.sequenceNumber,
name: phase.name, name: phase.name,
durationYears: phase.durationYears, durationYears: phase.durationYears,
// Cash-Entscheid (einmalige Sonderein-/ausgaben) mitkopieren.
cashTransition: phase.cashTransition ?? undefined,
}, },
}); });
phaseIdMap.set(phase.id, created.id); phaseIdMap.set(phase.id, created.id);
+149 -2
View File
@@ -2,7 +2,7 @@
import { useState } from "react"; import { useState } from "react";
import { Trash2 } from "lucide-react"; import { Trash2 } from "lucide-react";
import { FieldLabel, MoneyField, NumberField, SelectField } from "@/components/FormField"; import { FieldLabel, MoneyField, NumberField, SelectField, TextField } from "@/components/FormField";
import { formatChf } from "@/lib/format"; import { formatChf } from "@/lib/format";
import { api } from "@/lib/api-client"; import { api } from "@/lib/api-client";
import { CATEGORY_LABELS, num } from "@/lib/elements"; import { CATEGORY_LABELS, num } from "@/lib/elements";
@@ -12,7 +12,13 @@ import {
DEFAULT_PROPERTY_GAINS_TAX_RATE, DEFAULT_PROPERTY_GAINS_TAX_RATE,
PILLAR_3A_MAX_ANNUAL, PILLAR_3A_MAX_ANNUAL,
} from "@/lib/constants"; } from "@/lib/constants";
import type { ElementCategory, PhaseData, TransitionData } from "@/lib/elements"; import type {
CashTransitionData,
CashTransitionMode,
ElementCategory,
PhaseData,
TransitionData,
} from "@/lib/elements";
export interface CellContext { export interface CellContext {
kind: "phase" | "transition"; kind: "phase" | "transition";
@@ -121,6 +127,147 @@ export function withTransitionDefaults(category: ElementCategory, isRetirement:
return out; return out;
} }
// --- Cash-Uebergang: einmalige Sonderein-/ausgaben ---
export function withCashTransitionDefaults(ct: CashTransitionData): CashTransitionData {
return ct.mode === undefined ? { ...ct, mode: "NONE" } : { ...ct };
}
export function isCashTransitionAnswered(ct: CashTransitionData): boolean {
return ct.mode !== undefined;
}
// Kurzfassung fuer die Uebergangszelle der Cash-Zeile.
export function cashTransitionSummary(ct: CashTransitionData): string {
const mode = ct.mode;
if (mode === undefined) return "?";
const inn = `+${formatChf(num(ct.inflowAmount))}`;
const out = `${formatChf(num(ct.outflowAmount))}`;
switch (mode) {
case "INFLOW":
return inn;
case "OUTFLOW":
return out;
case "BOTH":
return `${inn} / ${out}`;
default:
return "1:1";
}
}
// Eingabefelder fuer den Cash-Entscheid. Erfassungs-Konventionen bewusst wie bei den
// laufenden Flows: Zufluss nominal (wie Einkommen), Kosten real (wie Ausgaben).
export function CashTransitionFields({
ct,
setC,
deflatorEnd,
}: {
ct: CashTransitionData;
setC: (patch: Partial<CashTransitionData>) => void;
deflatorEnd: number; // Bestands-Deflator an der Phasengrenze
}) {
const mode = ct.mode ?? "NONE";
const showIn = mode === "INFLOW" || mode === "BOTH";
const showOut = mode === "OUTFLOW" || mode === "BOTH";
const d = deflatorEnd || 1;
const inflowGross = num(ct.inflowAmount);
const taxRate = num(ct.inflowTaxRate, 0);
const inflowNet = Math.round(inflowGross * (1 - taxRate / 100));
return (
<>
<div className="sm:col-span-2">
<SelectField
label="Entscheid fuer das Cash-Konto"
help="Was passiert beim Uebergang in die naechste Lebensphase mit dem Cash-Bestand?"
value={mode}
onChange={(v: CashTransitionMode) =>
setC(
v === "NONE"
? { mode: v, inflowAmount: 0, outflowAmount: 0 }
: v === "INFLOW"
? { mode: v, outflowAmount: 0 }
: v === "OUTFLOW"
? { mode: v, inflowAmount: 0 }
: { mode: v }
)
}
options={[
{ value: "NONE", label: "1:1 uebernehmen" },
{ value: "INFLOW", label: "Einmaliger Zufluss" },
{ value: "OUTFLOW", label: "Einmalige Kosten" },
{ value: "BOTH", label: "Zufluss und Kosten" },
]}
/>
</div>
{showIn && (
<>
<div className="sm:col-span-2 rounded-lg bg-surface-2 px-3 py-2 text-xs text-muted">
<strong className="text-fg">Einmaliger Zufluss</strong> (z. B. Erbschaft). Wird NOMINAL erfasst
der Betrag, der zu diesem Zeitpunkt tatsaechlich aufs Konto kommt.
</div>
<TextField
label="Bezeichnung"
value={ct.inflowLabel ?? ""}
placeholder="z. B. Erbschaft"
onChange={(v) => setC({ inflowLabel: v })}
/>
<MoneyField
label="Betrag NOMINAL (CHF)"
value={inflowGross}
onChange={(v) => setC({ inflowAmount: v })}
/>
<NumberField
label="Steuer (%)"
help="Z. B. Erbschafts-/Schenkungssteuer. Direkte Nachkommen sind in den meisten Kantonen befreit Default 0 %."
step={0.5}
value={taxRate}
onChange={(v) => setC({ inflowTaxRate: v })}
/>
<DerivedField
label="≈ real (heutige Kaufkraft)"
value={Math.round(inflowNet / d)}
help="Netto-Zufluss nach Steuer, zurueckgerechnet auf die Kaufkraft bei Planbeginn. Nur zur Info."
/>
{taxRate > 0 && (
<DerivedField
label="Netto aufs Cash (nominal)"
value={inflowNet}
help="Betrag abzueglich Steuer. Wird automatisch berechnet."
/>
)}
</>
)}
{showOut && (
<>
<div className="sm:col-span-2 rounded-lg bg-surface-2 px-3 py-2 text-xs text-muted">
<strong className="text-fg">Einmalige Kosten</strong> (z. B. Poolbau). Werden REAL erfasst
in heutiger Kaufkraft. Die Inflation rechnet daraus automatisch den nominalen Betrag.
</div>
<TextField
label="Bezeichnung"
value={ct.outflowLabel ?? ""}
placeholder="z. B. Poolbau"
onChange={(v) => setC({ outflowLabel: v })}
/>
<MoneyField
label="Betrag REAL (CHF, heutige Kaufkraft)"
value={num(ct.outflowAmount)}
onChange={(v) => setC({ outflowAmount: v })}
/>
<DerivedField
label="≈ nominal (zum Zeitpunkt)"
value={Math.round(num(ct.outflowAmount) * d)}
help="Der Betrag, der zu diesem Zeitpunkt tatsaechlich faellig ist. Nur zur Info."
/>
</>
)}
</>
);
}
// "Beantwortet" = ein konkreter Entscheid liegt vor (kein offenes Fragezeichen). // "Beantwortet" = ein konkreter Entscheid liegt vor (kein offenes Fragezeichen).
export function isTransitionAnswered(category: ElementCategory, isRetirement: boolean, td: TransitionData): boolean { export function isTransitionAnswered(category: ElementCategory, isRetirement: boolean, td: TransitionData): boolean {
switch (category) { switch (category) {
+144 -5
View File
@@ -20,10 +20,14 @@ import {
} from "lucide-react"; } from "lucide-react";
import { Timeline } from "@/components/Timeline"; import { Timeline } from "@/components/Timeline";
import { import {
CashTransitionFields,
ElementDetail, ElementDetail,
ElementPhaseFields, ElementPhaseFields,
ElementTransitionFields, ElementTransitionFields,
cashTransitionSummary,
isCashTransitionAnswered,
isTransitionAnswered, isTransitionAnswered,
withCashTransitionDefaults,
withTransitionDefaults, withTransitionDefaults,
type CellContext, type CellContext,
} from "@/components/ElementDetail"; } from "@/components/ElementDetail";
@@ -37,6 +41,7 @@ import {
CATEGORY_ORDER, CATEGORY_ORDER,
PERSON_ONLY_CATEGORIES, PERSON_ONLY_CATEGORIES,
num, num,
type CashTransitionData,
type ElementCategory, type ElementCategory,
type PhaseData, type PhaseData,
type TransitionData, type TransitionData,
@@ -96,6 +101,8 @@ export function PlanView({
const [editTransition, setEditTransition] = useState<{ elementId: string; fromPhaseId: string } | null>(null); const [editTransition, setEditTransition] = useState<{ elementId: string; fromPhaseId: string } | null>(null);
const [editPhaseCell, setEditPhaseCell] = useState<{ elementId: string; phaseId: string } | null>(null); const [editPhaseCell, setEditPhaseCell] = useState<{ elementId: string; phaseId: string } | null>(null);
const [showCashInit, setShowCashInit] = useState(false); const [showCashInit, setShowCashInit] = useState(false);
// fromPhaseId des Cash-Uebergangs, der gerade bearbeitet wird.
const [editCashTransition, setEditCashTransition] = useState<string | null>(null);
const [valueMode, setValueMode] = useState<ValueMode>("nominal"); const [valueMode, setValueMode] = useState<ValueMode>("nominal");
useEffect(() => { useEffect(() => {
@@ -205,9 +212,14 @@ export function PlanView({
return false; return false;
} }
function cashTransitionFor(phaseId: string): CashTransitionData {
return plan.phases.find((p) => p.id === phaseId)?.cashTransition ?? {};
}
// Anzahl offener (noch nicht getroffener) Uebergangs-Entscheide an einer Grenze. // Anzahl offener (noch nicht getroffener) Uebergangs-Entscheide an einer Grenze.
// Der Cash-Entscheid (einmalige Sonderein-/ausgaben) zaehlt mit.
function transitionOpenCount(fromPhase: PhaseComputed, toPhase: PhaseComputed): number { function transitionOpenCount(fromPhase: PhaseComputed, toPhase: PhaseComputed): number {
let n = 0; let n = isCashTransitionAnswered(cashTransitionFor(fromPhase.id)) ? 0 : 1;
for (const el of plan.elements) { for (const el of plan.elements) {
if (!TRANSITION_CATEGORIES.includes(el.category)) continue; if (!TRANSITION_CATEGORIES.includes(el.category)) continue;
if (transitionInactive(el, fromPhase)) continue; if (transitionInactive(el, fromPhase)) continue;
@@ -379,9 +391,23 @@ export function PlanView({
</span> </span>
</td> </td>
) : ( ) : (
<td key={`cash-t-${col.fromPhase.id}`} className="border-b border-r border-border px-2 py-1.5 text-center text-[11px] text-faint"> (() => {
// Uebergangszelle der Cash-Zeile: einmalige Sonderein-/ausgaben.
</td> const ct = cashTransitionFor(col.fromPhase.id);
const open = !isCashTransitionAnswered(ct);
return (
<td
key={`cash-t-${col.fromPhase.id}`}
onClick={() => setEditCashTransition(col.fromPhase.id)}
title="Einmalige Sonderein-/ausgaben"
className={`cursor-pointer border-b border-r border-border px-2 py-1.5 text-center text-[11px] ${
open ? "bg-accent font-semibold text-accent-fg" : "bg-accent-soft/40 text-accent"
}`}
>
{cashTransitionSummary(ct)}
</td>
);
})()
); );
})} })}
</tr> </tr>
@@ -520,6 +546,26 @@ export function PlanView({
/> />
)} )}
{editCashTransition && (() => {
const fromPhase = computed.phases.find((p) => p.id === editCashTransition);
if (!fromPhase) return null;
const toIndex = computed.phases.findIndex((p) => p.id === fromPhase.id) + 1;
const toPhase = computed.phases[toIndex];
return (
<CashTransitionDialog
key={`ct-${editCashTransition}`}
fromPhase={fromPhase}
toPhase={toPhase}
initial={cashTransitionFor(fromPhase.id)}
onClose={() => setEditCashTransition(null)}
onSaved={() => {
setEditCashTransition(null);
onChanged();
}}
/>
);
})()}
{reviewFromPhaseId && (() => { {reviewFromPhaseId && (() => {
const fromPhase = computed.phases.find((p) => p.id === reviewFromPhaseId); const fromPhase = computed.phases.find((p) => p.id === reviewFromPhaseId);
if (!fromPhase) return null; if (!fromPhase) return null;
@@ -531,6 +577,7 @@ export function PlanView({
fromPhase={fromPhase} fromPhase={fromPhase}
toPhase={toPhase} toPhase={toPhase}
elements={els} elements={els}
initialCash={cashTransitionFor(fromPhase.id)}
buildContext={(el) => buildTransitionContext(fromPhase, toPhase, el)} buildContext={(el) => buildTransitionContext(fromPhase, toPhase, el)}
isRetirement={(el) => (toPhase ? isRetirementTransition(el, fromPhase, toPhase) : false)} isRetirement={(el) => (toPhase ? isRetirementTransition(el, fromPhase, toPhase) : false)}
onClose={() => setReviewFromPhaseId(null)} onClose={() => setReviewFromPhaseId(null)}
@@ -753,6 +800,22 @@ function PhaseHeader({
</> </>
)} )}
{(phase.oneOffInflow > 0 || phase.oneOffOutflow > 0) && (
<>
<div className="my-1 border-t border-border" />
{phase.oneOffInflow > 0 && (
<div className="text-success">
+ {phase.oneOffInflowLabel ?? "Einmaliger Zufluss"} {valStr(phase.oneOffInflow, dS, mode)}
</div>
)}
{phase.oneOffOutflow > 0 && (
<div className="text-danger">
{phase.oneOffOutflowLabel ?? "Einmalige Kosten"} {valStr(phase.oneOffOutflow, dS, mode)}
</div>
)}
</>
)}
<div className="my-1 border-t border-border" /> <div className="my-1 border-t border-border" />
<div> <div>
Vermoegen {valStr(phase.startWealthNominal, dS, mode)} {valStr(phase.endWealthNominal, dE, mode)} Vermoegen {valStr(phase.startWealthNominal, dS, mode)} {valStr(phase.endWealthNominal, dE, mode)}
@@ -1034,6 +1097,7 @@ function TransitionReviewDialog({
fromPhase, fromPhase,
toPhase, toPhase,
elements, elements,
initialCash,
buildContext, buildContext,
isRetirement, isRetirement,
onClose, onClose,
@@ -1042,6 +1106,7 @@ function TransitionReviewDialog({
fromPhase: PhaseComputed; fromPhase: PhaseComputed;
toPhase: PhaseComputed | undefined; toPhase: PhaseComputed | undefined;
elements: ElementInput[]; elements: ElementInput[];
initialCash: CashTransitionData;
buildContext: (el: ElementInput) => CellContext; buildContext: (el: ElementInput) => CellContext;
isRetirement: (el: ElementInput) => boolean; isRetirement: (el: ElementInput) => boolean;
onClose: () => void; onClose: () => void;
@@ -1052,6 +1117,7 @@ function TransitionReviewDialog({
elements.map((e) => [e.id, withTransitionDefaults(e.category, isRetirement(e), e.transitionValues[fromPhase.id] ?? {})]) elements.map((e) => [e.id, withTransitionDefaults(e.category, isRetirement(e), e.transitionValues[fromPhase.id] ?? {})])
) )
); );
const [ct, setCt] = useState<CashTransitionData>(() => withCashTransitionDefaults(initialCash));
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -1059,6 +1125,7 @@ function TransitionReviewDialog({
setSaving(true); setSaving(true);
setError(null); setError(null);
try { try {
await api.put(`/api/phases/${fromPhase.id}/cash-transition`, ct);
for (const e of elements) { for (const e of elements) {
await api.put(`/api/elements/${e.id}/transition/${fromPhase.id}`, tds[e.id] ?? {}); await api.put(`/api/elements/${e.id}/transition/${fromPhase.id}`, tds[e.id] ?? {});
} }
@@ -1077,9 +1144,31 @@ function TransitionReviewDialog({
Bezug). Danach werden gehaltene Werte automatisch in die nächste Phase fortgeschrieben. Bezug). Danach werden gehaltene Werte automatisch in die nächste Phase fortgeschrieben.
</p> </p>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{/* Cash zuerst: einmalige Sonderein-/ausgaben betreffen jeden Uebergang. */}
<div className="rounded-xl border border-border bg-surface-2 p-3">
<div className="mb-2 flex items-center gap-2">
<span className="text-accent">
<Wallet className="h-4 w-4" />
</span>
<span className="text-sm font-semibold text-fg">Cash</span>
<span className="text-xs text-faint">Einmalige Sonderein-/ausgaben</span>
</div>
<p className="mb-2 text-xs text-accent-soft-fg">
Einmalige Ereignisse wie Erbschaft, Autokauf oder Poolbau werden hier direkt dem Cash-Konto
gutgeschrieben bzw. belastet.
</p>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<CashTransitionFields
ct={ct}
setC={(patch) => setCt((prev) => ({ ...prev, ...patch }))}
deflatorEnd={fromPhase.cumulativeInflationEnd}
/>
</div>
</div>
{elements.length === 0 && ( {elements.length === 0 && (
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-3 text-sm text-muted"> <p className="rounded-lg border border-dashed border-border bg-surface-2 p-3 text-sm text-muted">
An diesem Übergang gibt es keine zu entscheidenden Positionen. An diesem Übergang gibt es sonst keine zu entscheidenden Positionen.
</p> </p>
)} )}
{elements.map((el) => { {elements.map((el) => {
@@ -1119,6 +1208,56 @@ function TransitionReviewDialog({
); );
} }
// --- Dialog: Cash-Uebergang (einmalige Sonderein-/ausgaben) ---
function CashTransitionDialog({
fromPhase,
toPhase,
initial,
onClose,
onSaved,
}: {
fromPhase: PhaseComputed;
toPhase: PhaseComputed | undefined;
initial: CashTransitionData;
onClose: () => void;
onSaved: () => void;
}) {
// Vorbelegung, damit ein blosses "Speichern" den sichtbaren Default (1:1) persistiert.
const [ct, setCt] = useState<CashTransitionData>(() => withCashTransitionDefaults(initial));
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
async function save() {
setSaving(true);
setError(null);
try {
await api.put(`/api/phases/${fromPhase.id}/cash-transition`, ct);
onSaved();
} catch (e) {
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
} finally {
setSaving(false);
}
}
return (
<DialogShell title="Uebergang: Cash" onClose={onClose} wide>
<div className="text-xs text-muted">
Einmalige Sonderein-/ausgaben · {fromPhase.name} {toPhase?.name ?? "Ende"}
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<CashTransitionFields
ct={ct}
setC={(patch) => setCt((prev) => ({ ...prev, ...patch }))}
deflatorEnd={fromPhase.cumulativeInflationEnd}
/>
</div>
{error && <p className="text-sm text-danger">{error}</p>}
<DialogActions saving={saving} onConfirm={save} onClose={onClose} confirmLabel="Speichern" />
</DialogShell>
);
}
// --- Dialog: Cash-Anfangswert (erste Lebensphase) --- // --- Dialog: Cash-Anfangswert (erste Lebensphase) ---
function CashInitialDialog({ plan, onClose, onSaved }: { plan: PlanInput; onClose: () => void; onSaved: () => void }) { function CashInitialDialog({ plan, onClose, onSaved }: { plan: PlanInput; onClose: () => void; onSaved: () => void }) {
const [value, setValue] = useState(plan.initialCash); const [value, setValue] = useState(plan.initialCash);
+98 -3
View File
@@ -1,6 +1,6 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { computePlan } from "@/lib/calculations"; import { computePlan } from "@/lib/calculations";
import type { ElementCategory, PhaseData, TransitionData } from "@/lib/elements"; import type { CashTransitionData, ElementCategory, PhaseData, TransitionData } from "@/lib/elements";
import type { PlanInput } from "@/lib/types"; import type { PlanInput } from "@/lib/types";
// --- kleine Bau-Helfer --- // --- kleine Bau-Helfer ---
@@ -21,7 +21,7 @@ function plan(opts: {
retirementAge: number; retirementAge: number;
inflation?: number; inflation?: number;
initialCash?: number; initialCash?: number;
phases: { id: string; durationYears: number }[]; phases: { id: string; durationYears: number; cashTransition?: CashTransitionData }[];
elements: ReturnType<typeof el>[]; elements: ReturnType<typeof el>[];
household?: "SINGLE" | "COUPLE"; household?: "SINGLE" | "COUPLE";
}): PlanInput { }): PlanInput {
@@ -32,7 +32,13 @@ function plan(opts: {
inflationRateDefault: opts.inflation ?? 2, inflationRateDefault: opts.inflation ?? 2,
initialCash: opts.initialCash ?? 0, initialCash: opts.initialCash ?? 0,
persons: [{ id: "A", role: "PERSON_A", name: null, age: opts.age, retirementAge: opts.retirementAge }], persons: [{ id: "A", role: "PERSON_A", name: null, age: opts.age, retirementAge: opts.retirementAge }],
phases: opts.phases.map((p, i) => ({ id: p.id, sequenceNumber: i + 1, name: p.id, durationYears: p.durationYears })), phases: opts.phases.map((p, i) => ({
id: p.id,
sequenceNumber: i + 1,
name: p.id,
durationYears: p.durationYears,
cashTransition: p.cashTransition ?? {},
})),
elements: opts.elements, elements: opts.elements,
}; };
} }
@@ -251,6 +257,95 @@ describe("V5 Golden Tests", () => {
expect(pk.startValue).toBe(200000); // brutto 100'000 dem Kapital entnommen expect(pk.startValue).toBe(200000); // brutto 100'000 dem Kapital entnommen
}); });
it("Einmaliger Zufluss am Uebergang: nominal erfasst, Steuer abgezogen, direkt ins Cash", () => {
const p = plan({
age: 40,
retirementAge: 70,
inflation: 0,
initialCash: 1000,
phases: [
{ id: "p1", durationYears: 2, cashTransition: { mode: "INFLOW", inflowLabel: "Erbschaft", inflowAmount: 100000, inflowTaxRate: 10 } },
{ id: "p2", durationYears: 1 },
],
elements: [],
});
const r = computePlan(p);
expect(r.phases[1].oneOffInflow).toBe(90000); // 100'000 abzueglich 10% Steuer
expect(r.phases[1].oneOffInflowLabel).toBe("Erbschaft");
expect(r.phases[1].cashStart).toBe(91000); // 1'000 + 90'000
expect(r.phases[0].oneOffInflow).toBe(0); // Phase 1 hat keinen eingehenden Uebergang
});
it("Einmalige Kosten am Uebergang: real erfasst, mit Inflation aufgewertet", () => {
// Kosten 20'000 real, 2% Inflation, Grenze nach 10 Jahren -> 20'000 x 1.02^10 = 24'380.
const p = plan({
age: 40,
retirementAge: 70,
inflation: 2,
initialCash: 100000,
phases: [
{ id: "p1", durationYears: 10, cashTransition: { mode: "OUTFLOW", outflowLabel: "Poolbau", outflowAmount: 20000 } },
{ id: "p2", durationYears: 1 },
],
elements: [],
});
const r = computePlan(p);
const erwartet = Math.round(20000 * Math.pow(1.02, 10));
expect(r.phases[1].oneOffOutflow).toBe(erwartet);
expect(r.phases[1].oneOffOutflowLabel).toBe("Poolbau");
expect(r.phases[1].cashStart).toBe(100000 - erwartet);
});
it("Zufluss und Kosten zusammen (BOTH); Modus NONE bleibt wirkungslos", () => {
const beide = plan({
age: 40, retirementAge: 70, inflation: 0, initialCash: 0,
phases: [
{ id: "p1", durationYears: 1, cashTransition: { mode: "BOTH", inflowAmount: 50000, outflowAmount: 20000 } },
{ id: "p2", durationYears: 1 },
],
elements: [],
});
const r1 = computePlan(beide);
expect(r1.phases[1].cashStart).toBe(30000); // +50'000 -20'000
// Betraege sind erfasst, aber der Entscheid lautet "1:1 uebernehmen" -> keine Wirkung.
const keine = plan({
age: 40, retirementAge: 70, inflation: 0, initialCash: 0,
phases: [
{ id: "p1", durationYears: 1, cashTransition: { mode: "NONE", inflowAmount: 50000, outflowAmount: 20000 } },
{ id: "p2", durationYears: 1 },
],
elements: [],
});
expect(computePlan(keine).phases[1].cashStart).toBe(0);
});
it("Einmalige Kosten koennen eine Liquiditaetsluecke ausloesen", () => {
const p = plan({
age: 40, retirementAge: 70, inflation: 0, initialCash: 10000,
phases: [
{ id: "p1", durationYears: 1, cashTransition: { mode: "OUTFLOW", outflowAmount: 25000 } },
{ id: "p2", durationYears: 1 },
],
elements: [],
});
const p2 = computePlan(p).phases[1];
expect(p2.cashStart).toBe(-15000);
expect(p2.cashNegative).toBe(true);
expect(p2.incomplete).toBe(true);
});
it("Cash-Entscheid der LETZTEN Phase bleibt wirkungslos (kein Uebergang mehr)", () => {
const p = plan({
age: 40, retirementAge: 70, inflation: 0, initialCash: 5000,
phases: [{ id: "p1", durationYears: 1, cashTransition: { mode: "INFLOW", inflowAmount: 999999 } }],
elements: [],
});
const r = computePlan(p);
expect(r.phases[0].cashEnd).toBe(5000);
expect(r.nachlass).toBe(5000);
});
it("Fortschreibung: nominaler Einkommens-Basiswert Phase 1 -> Startwert Phase 2; Cash laeuft fort", () => { it("Fortschreibung: nominaler Einkommens-Basiswert Phase 1 -> Startwert Phase 2; Cash laeuft fort", () => {
const p = plan({ const p = plan({
age: 40, age: 40,
+44 -1
View File
@@ -61,6 +61,13 @@ export interface PhaseComputed {
plannedWithdrawRate: number; // geplante Verzehrrate: Bezugsraten aus Sonstigem Vermoegen plannedWithdrawRate: number; // geplante Verzehrrate: Bezugsraten aus Sonstigem Vermoegen
capitalInflow: number; // Kapitalzufluss: PK-/3a-Bezuege + Verkaeufe (aus dem Uebergang in diese Phase) capitalInflow: number; // Kapitalzufluss: PK-/3a-Bezuege + Verkaeufe (aus dem Uebergang in diese Phase)
capitalInvest: number; // Kapitalinvestitionen: Zusatz-/Neuinvestitionen + sofortige Tilgungen capitalInvest: number; // Kapitalinvestitionen: Zusatz-/Neuinvestitionen + sofortige Tilgungen
// Einmalige Sonderein-/ausgaben aus dem Uebergang in DIESE Phase (nominal, netto nach Steuer).
// Bewusst getrennt von capitalInflow/capitalInvest: eine Erbschaft ist kein Verkaufserloes,
// ein Poolbau keine Kapitalinvestition.
oneOffInflow: number;
oneOffInflowLabel: string | null;
oneOffOutflow: number;
oneOffOutflowLabel: string | null;
cashStart: number; cashStart: number;
cashEnd: number; cashEnd: number;
cashNegative: boolean; // Cash faellt in dieser Phase (irgendwann) unter 0 -> Liquiditaetsluecke cashNegative: boolean; // Cash faellt in dieser Phase (irgendwann) unter 0 -> Liquiditaetsluecke
@@ -147,6 +154,10 @@ export function computePlan(plan: PlanInput): PlanComputed {
// Aus dem Uebergang der Vorphase in DIESE Phase fliessende Groessen (Kopf-Kennzahlen). // Aus dem Uebergang der Vorphase in DIESE Phase fliessende Groessen (Kopf-Kennzahlen).
let incomingInflow = 0; // Brutto-Zufluss: Verkaeufe + PK-/3a-Bezuege let incomingInflow = 0; // Brutto-Zufluss: Verkaeufe + PK-/3a-Bezuege
let incomingImmediateRepay = 0; // sofortige Schuldentilgungen (Abfluss) let incomingImmediateRepay = 0; // sofortige Schuldentilgungen (Abfluss)
let incomingOneOffInflow = 0; // einmaliger Sonderzufluss (netto nach Steuer)
let incomingOneOffInflowLabel: string | null = null;
let incomingOneOffOutflow = 0; // einmalige Sonderkosten (nominal)
let incomingOneOffOutflowLabel: string | null = null;
let ruinAge: number | null = null; let ruinAge: number | null = null;
for (let i = 0; i < phases.length; i++) { for (let i = 0; i < phases.length; i++) {
@@ -500,6 +511,10 @@ export function computePlan(plan: PlanInput): PlanComputed {
plannedWithdrawRate: plannedWithdrawTotal, plannedWithdrawRate: plannedWithdrawTotal,
capitalInflow: Math.round(incomingInflow), capitalInflow: Math.round(incomingInflow),
capitalInvest: Math.round(investmentsFromCash + incomingImmediateRepay), capitalInvest: Math.round(investmentsFromCash + incomingImmediateRepay),
oneOffInflow: Math.round(incomingOneOffInflow),
oneOffInflowLabel: incomingOneOffInflowLabel,
oneOffOutflow: Math.round(incomingOneOffOutflow),
oneOffOutflowLabel: incomingOneOffOutflowLabel,
cashStart: Math.round(cashStart), cashStart: Math.round(cashStart),
cashEnd, cashEnd,
cashNegative, cashNegative,
@@ -516,6 +531,30 @@ export function computePlan(plan: PlanInput): PlanComputed {
// --- Uebergang: Carry aktualisieren, Cash der Folgephase bilden --- // --- Uebergang: Carry aktualisieren, Cash der Folgephase bilden ---
let txInflow = 0; let txInflow = 0;
let txImmediateRepay = 0; let txImmediateRepay = 0;
// Einmalige Sonderein-/ausgaben auf dem Cash-Konto. Nur sinnvoll, wenn eine Folgephase
// existiert -- nach der letzten Phase gibt es keinen Uebergang. Der Wechselkurs zwischen
// real und nominal ist an dieser Grenze `cumulativeInflation` (Bestands-Deflator am
// Phasenende), denn Cash ist ein Bestand.
let txOneOffInflow = 0;
let txOneOffOutflow = 0;
let txOneOffInflowLabel: string | null = null;
let txOneOffOutflowLabel: string | null = null;
if (nextPhase) {
const ct = phase.cashTransition ?? {};
const mode = ct.mode ?? "NONE";
if (mode === "INFLOW" || mode === "BOTH") {
// Zufluss ist NOMINAL erfasst (wie Einkommen); Steuer mindert den Netto-Zufluss.
const gross = Math.round(num(ct.inflowAmount));
txOneOffInflow = Math.round(gross * (1 - num(ct.inflowTaxRate, 0) / 100));
txOneOffInflowLabel = ct.inflowLabel?.trim() || null;
}
if (mode === "OUTFLOW" || mode === "BOTH") {
// Kosten sind REAL erfasst (wie Ausgaben) -> mit der kumulierten Inflation aufwerten.
txOneOffOutflow = Math.round(num(ct.outflowAmount) * cumulativeInflation);
txOneOffOutflowLabel = ct.outflowLabel?.trim() || null;
}
}
for (const e of orderedElements) { for (const e of orderedElements) {
const carry = carries.get(e.id)!; const carry = carries.get(e.id)!;
const ec = ecById.get(e.id)!; const ec = ecById.get(e.id)!;
@@ -616,9 +655,13 @@ export function computePlan(plan: PlanInput): PlanComputed {
carry.hasCarry = true; carry.hasCarry = true;
} }
cashCarryIn = cashEnd + txInflow - txImmediateRepay; cashCarryIn = cashEnd + txInflow + txOneOffInflow - txImmediateRepay - txOneOffOutflow;
incomingInflow = txInflow; incomingInflow = txInflow;
incomingImmediateRepay = txImmediateRepay; incomingImmediateRepay = txImmediateRepay;
incomingOneOffInflow = txOneOffInflow;
incomingOneOffInflowLabel = txOneOffInflowLabel;
incomingOneOffOutflow = txOneOffOutflow;
incomingOneOffOutflowLabel = txOneOffOutflowLabel;
yearsBefore += duration; yearsBefore += duration;
} }
+29
View File
@@ -97,10 +97,39 @@ export interface TransitionData {
immediateRepayment?: number; immediateRepayment?: number;
} }
// --- Cash-Uebergang: einmalige Sonderein-/ausgaben ---
// Entscheid am UEBERGANG zwischen zwei Phasen, direkt auf dem Cash-Konto (Cash ist kein
// Element, der Entscheid haengt darum an der Von-Phase). Erfassungs-Konventionen analog zu
// den laufenden Flows: Zufluss NOMINAL (wie Einkommen), Kosten REAL (wie Ausgaben).
export type CashTransitionMode = "NONE" | "INFLOW" | "OUTFLOW" | "BOTH";
export interface CashTransitionData {
mode?: CashTransitionMode;
// Einmaliger Zufluss (z. B. Erbschaft): NOMINAL erfasst, Steuersatz optional (Default 0 %).
inflowLabel?: string;
inflowAmount?: number;
inflowTaxRate?: number;
// Einmalige Kosten (z. B. Poolbau): REAL erfasst (heutige Kaufkraft).
outflowLabel?: string;
outflowAmount?: number;
}
// --- Zod-Schemas (nachsichtig: unbekannte Felder werden verworfen) --- // --- Zod-Schemas (nachsichtig: unbekannte Felder werden verworfen) ---
const nonNeg = z.number().min(0); const nonNeg = z.number().min(0);
export const cashTransitionSchema = z
.object({
mode: z.enum(["NONE", "INFLOW", "OUTFLOW", "BOTH"]).optional(),
inflowLabel: z.string().max(120).optional(),
inflowAmount: nonNeg.optional(),
inflowTaxRate: z.number().min(0).max(100).optional(),
outflowLabel: z.string().max(120).optional(),
outflowAmount: nonNeg.optional(),
})
.strip();
export const phaseDataSchema = z export const phaseDataSchema = z
.object({ .object({
amount: nonNeg.optional(), amount: nonNeg.optional(),
+8 -2
View File
@@ -1,7 +1,7 @@
import { Prisma } from "@/generated/prisma/client"; import { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { phaseDataSchema, transitionDataSchema } from "@/lib/elements"; import { cashTransitionSchema, phaseDataSchema, transitionDataSchema } from "@/lib/elements";
import type { PhaseData, TransitionData } from "@/lib/elements"; import type { CashTransitionData, PhaseData, TransitionData } from "@/lib/elements";
import type { PlanInput } from "@/lib/types"; import type { PlanInput } from "@/lib/types";
export const planInclude = { export const planInclude = {
@@ -25,6 +25,11 @@ function parseTransitionData(raw: unknown): TransitionData {
return parsed.success ? parsed.data : {}; return parsed.success ? parsed.data : {};
} }
function parseCashTransition(raw: unknown): CashTransitionData {
const parsed = cashTransitionSchema.safeParse(raw);
return parsed.success ? parsed.data : {};
}
export function toPlanInput(plan: PlanWithRelations): PlanInput { export function toPlanInput(plan: PlanWithRelations): PlanInput {
return { return {
id: plan.id, id: plan.id,
@@ -44,6 +49,7 @@ export function toPlanInput(plan: PlanWithRelations): PlanInput {
sequenceNumber: phase.sequenceNumber, sequenceNumber: phase.sequenceNumber,
name: phase.name, name: phase.name,
durationYears: phase.durationYears, durationYears: phase.durationYears,
cashTransition: parseCashTransition(phase.cashTransition),
})), })),
elements: plan.elements.map((e) => { elements: plan.elements.map((e) => {
const phaseValues: Record<string, PhaseData> = {}; const phaseValues: Record<string, PhaseData> = {};
+3 -1
View File
@@ -3,7 +3,7 @@
// //
// V3-Rework: Das Grundprofil (Haushaltsform, Personen, Inflation) liegt neu direkt am Plan. // V3-Rework: Das Grundprofil (Haushaltsform, Personen, Inflation) liegt neu direkt am Plan.
import type { ElementCategory, OwnerRole, PhaseData, TransitionData } from "@/lib/elements"; import type { CashTransitionData, ElementCategory, OwnerRole, PhaseData, TransitionData } from "@/lib/elements";
export type HouseholdType = "SINGLE" | "COUPLE"; export type HouseholdType = "SINGLE" | "COUPLE";
export type PersonRole = "PERSON_A" | "PERSON_B"; export type PersonRole = "PERSON_A" | "PERSON_B";
@@ -21,6 +21,8 @@ export interface PhaseInput {
sequenceNumber: number; sequenceNumber: number;
name: string; name: string;
durationYears: number; durationYears: number;
// Cash-Entscheid beim Uebergang NACH dieser Phase (einmalige Sonderein-/ausgaben).
cashTransition: CashTransitionData;
} }
export interface ElementInput { export interface ElementInput {