Einmalige Sonderein-/ausgaben am Cash-Uebergang (Roadmap Nr. 1)
Deploy App / deploy (push) Successful in 1m51s
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:
+148
-16
@@ -4,10 +4,10 @@
|
||||
| | |
|
||||
|---|---|
|
||||
| **Dokument** | Funktionale und Technische Spezifikation FPT |
|
||||
| **Version** | 0.2 |
|
||||
| **Version** | 0.3 |
|
||||
| **Datum** | 2026-07-16 |
|
||||
| **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` (v1–v5) im Ordner `Info Dateien` – diese sind ab Version 0.1 dieses Dokuments obsolet |
|
||||
| **Geltungsbereich** | Gesamter Code im Verzeichnis `FPT` |
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
|
||||
| Version | Datum | Autor | Änderung |
|
||||
|---|---|---|---|
|
||||
| 0.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. |
|
||||
|
||||
@@ -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 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.
|
||||
|
||||
Referenz: `src/lib/calculations.ts` Zeilen 365–425, 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
|
||||
|
||||
Zwischen zwei Phasen liegt ein Übergang. Er ist der Ort, an dem einmalige Entscheide getroffen
|
||||
werden. Nur fünf Kategorien haben Übergangs-Entscheide (`TRANSITION_CATEGORIES`):
|
||||
`PENSION_FUND`, `PILLAR_3A`, `REAL_ESTATE`, `OTHER_ASSET`, `OTHER_DEBT`.
|
||||
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."
|
||||
@@ -499,6 +508,7 @@ Entscheidungsfeld gesetzt ist:
|
||||
| `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).
|
||||
@@ -513,8 +523,9 @@ Referenz: `src/components/PlanView.tsx` Zeilen 200–232, `src/components/Elemen
|
||||
### 3.5.4 Geführter Übergang (Review-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
|
||||
Entscheidfeldern und kontextabhängigen Hinweisen:
|
||||
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)."
|
||||
@@ -524,7 +535,45 @@ Entscheidfeldern und kontextabhängigen Hinweisen:
|
||||
`withTransitionDefaults` vorbelegt (Halten / Kein Bezug / Rente), damit ein blosses Speichern den
|
||||
**sichtbaren** Default auch tatsächlich persistiert und die Ampel auf grün geht.
|
||||
|
||||
Referenz: `src/components/PlanView.tsx` Zeilen 1040–1127, `src/components/ElementDetail.tsx` Zeilen 89–105.
|
||||
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
|
||||
|
||||
@@ -571,8 +620,14 @@ Jeder Phasenkopf zeigt kompakt:
|
||||
| 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
|
||||
@@ -974,6 +1029,8 @@ 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
|
||||
@@ -1046,12 +1103,41 @@ falls immediate > 0:
|
||||
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
|
||||
incomingInflow = txInflow // Kopf-Kennzahl der Folgephase
|
||||
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
|
||||
```
|
||||
|
||||
@@ -1212,9 +1298,14 @@ PlanComputed ← an den Client geliefert
|
||||
| `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 |
|
||||
@@ -1293,12 +1384,28 @@ Referenz: `prisma/schema.prisma` Zeilen 4–6, `src/lib/elements.ts` Zeilen 48
|
||||
| `saleTaxRate` | REAL_ESTATE | 0–100 |
|
||||
| `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 % | 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.5 Migrationshistorie
|
||||
### 5.4.6 Migrationshistorie
|
||||
|
||||
| Migration | Inhalt |
|
||||
|---|---|
|
||||
@@ -1311,6 +1418,7 @@ sondern zu leeren Werten.
|
||||
| `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
|
||||
|
||||
@@ -1469,6 +1577,11 @@ Hängt eine Phase am Ende an, kappt die Dauer, vergibt Default-Name, legt vorbel
|
||||
### `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`
|
||||
@@ -1548,7 +1661,7 @@ Zielumgebung: Hetzner CX23, Traefik als Reverse Proxy, Domain `fpt.aicds.ch`, Gi
|
||||
## 8.1 Teststrategie
|
||||
|
||||
Getestet wird ausschliesslich der Berechnungskern – bewusst, da dort die Fachlogik und das
|
||||
Regressionsrisiko liegen. `src/lib/calculations.test.ts` enthält 13 Tests („V5 Golden Tests"),
|
||||
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.
|
||||
|
||||
@@ -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 |
|
||||
| **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
|
||||
@@ -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
|
||||
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
|
||||
`≥ 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.
|
||||
- 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 |
|
||||
| **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) |
|
||||
|
||||
@@ -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;
|
||||
@@ -102,6 +102,11 @@ model Phase {
|
||||
name String
|
||||
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())
|
||||
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,
|
||||
name: phase.name,
|
||||
durationYears: phase.durationYears,
|
||||
// Cash-Entscheid (einmalige Sonderein-/ausgaben) mitkopieren.
|
||||
cashTransition: phase.cashTransition ?? undefined,
|
||||
},
|
||||
});
|
||||
phaseIdMap.set(phase.id, created.id);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from "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 { api } from "@/lib/api-client";
|
||||
import { CATEGORY_LABELS, num } from "@/lib/elements";
|
||||
@@ -12,7 +12,13 @@ import {
|
||||
DEFAULT_PROPERTY_GAINS_TAX_RATE,
|
||||
PILLAR_3A_MAX_ANNUAL,
|
||||
} from "@/lib/constants";
|
||||
import type { ElementCategory, PhaseData, TransitionData } from "@/lib/elements";
|
||||
import type {
|
||||
CashTransitionData,
|
||||
CashTransitionMode,
|
||||
ElementCategory,
|
||||
PhaseData,
|
||||
TransitionData,
|
||||
} from "@/lib/elements";
|
||||
|
||||
export interface CellContext {
|
||||
kind: "phase" | "transition";
|
||||
@@ -121,6 +127,147 @@ export function withTransitionDefaults(category: ElementCategory, isRetirement:
|
||||
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).
|
||||
export function isTransitionAnswered(category: ElementCategory, isRetirement: boolean, td: TransitionData): boolean {
|
||||
switch (category) {
|
||||
|
||||
+144
-5
@@ -20,10 +20,14 @@ import {
|
||||
} from "lucide-react";
|
||||
import { Timeline } from "@/components/Timeline";
|
||||
import {
|
||||
CashTransitionFields,
|
||||
ElementDetail,
|
||||
ElementPhaseFields,
|
||||
ElementTransitionFields,
|
||||
cashTransitionSummary,
|
||||
isCashTransitionAnswered,
|
||||
isTransitionAnswered,
|
||||
withCashTransitionDefaults,
|
||||
withTransitionDefaults,
|
||||
type CellContext,
|
||||
} from "@/components/ElementDetail";
|
||||
@@ -37,6 +41,7 @@ import {
|
||||
CATEGORY_ORDER,
|
||||
PERSON_ONLY_CATEGORIES,
|
||||
num,
|
||||
type CashTransitionData,
|
||||
type ElementCategory,
|
||||
type PhaseData,
|
||||
type TransitionData,
|
||||
@@ -96,6 +101,8 @@ export function PlanView({
|
||||
const [editTransition, setEditTransition] = useState<{ elementId: string; fromPhaseId: string } | null>(null);
|
||||
const [editPhaseCell, setEditPhaseCell] = useState<{ elementId: string; phaseId: string } | null>(null);
|
||||
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");
|
||||
|
||||
useEffect(() => {
|
||||
@@ -205,9 +212,14 @@ export function PlanView({
|
||||
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.
|
||||
// Der Cash-Entscheid (einmalige Sonderein-/ausgaben) zaehlt mit.
|
||||
function transitionOpenCount(fromPhase: PhaseComputed, toPhase: PhaseComputed): number {
|
||||
let n = 0;
|
||||
let n = isCashTransitionAnswered(cashTransitionFor(fromPhase.id)) ? 0 : 1;
|
||||
for (const el of plan.elements) {
|
||||
if (!TRANSITION_CATEGORIES.includes(el.category)) continue;
|
||||
if (transitionInactive(el, fromPhase)) continue;
|
||||
@@ -379,9 +391,23 @@ export function PlanView({
|
||||
</span>
|
||||
</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">
|
||||
→
|
||||
</td>
|
||||
(() => {
|
||||
// Uebergangszelle der Cash-Zeile: einmalige Sonderein-/ausgaben.
|
||||
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>
|
||||
@@ -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 && (() => {
|
||||
const fromPhase = computed.phases.find((p) => p.id === reviewFromPhaseId);
|
||||
if (!fromPhase) return null;
|
||||
@@ -531,6 +577,7 @@ export function PlanView({
|
||||
fromPhase={fromPhase}
|
||||
toPhase={toPhase}
|
||||
elements={els}
|
||||
initialCash={cashTransitionFor(fromPhase.id)}
|
||||
buildContext={(el) => buildTransitionContext(fromPhase, toPhase, el)}
|
||||
isRetirement={(el) => (toPhase ? isRetirementTransition(el, fromPhase, toPhase) : false)}
|
||||
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>
|
||||
Vermoegen {valStr(phase.startWealthNominal, dS, mode)} → {valStr(phase.endWealthNominal, dE, mode)}
|
||||
@@ -1034,6 +1097,7 @@ function TransitionReviewDialog({
|
||||
fromPhase,
|
||||
toPhase,
|
||||
elements,
|
||||
initialCash,
|
||||
buildContext,
|
||||
isRetirement,
|
||||
onClose,
|
||||
@@ -1042,6 +1106,7 @@ function TransitionReviewDialog({
|
||||
fromPhase: PhaseComputed;
|
||||
toPhase: PhaseComputed | undefined;
|
||||
elements: ElementInput[];
|
||||
initialCash: CashTransitionData;
|
||||
buildContext: (el: ElementInput) => CellContext;
|
||||
isRetirement: (el: ElementInput) => boolean;
|
||||
onClose: () => void;
|
||||
@@ -1052,6 +1117,7 @@ function TransitionReviewDialog({
|
||||
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 [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -1059,6 +1125,7 @@ function TransitionReviewDialog({
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.put(`/api/phases/${fromPhase.id}/cash-transition`, ct);
|
||||
for (const e of elements) {
|
||||
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.
|
||||
</p>
|
||||
<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 && (
|
||||
<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>
|
||||
)}
|
||||
{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) ---
|
||||
function CashInitialDialog({ plan, onClose, onSaved }: { plan: PlanInput; onClose: () => void; onSaved: () => void }) {
|
||||
const [value, setValue] = useState(plan.initialCash);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
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";
|
||||
|
||||
// --- kleine Bau-Helfer ---
|
||||
@@ -21,7 +21,7 @@ function plan(opts: {
|
||||
retirementAge: number;
|
||||
inflation?: number;
|
||||
initialCash?: number;
|
||||
phases: { id: string; durationYears: number }[];
|
||||
phases: { id: string; durationYears: number; cashTransition?: CashTransitionData }[];
|
||||
elements: ReturnType<typeof el>[];
|
||||
household?: "SINGLE" | "COUPLE";
|
||||
}): PlanInput {
|
||||
@@ -32,7 +32,13 @@ function plan(opts: {
|
||||
inflationRateDefault: opts.inflation ?? 2,
|
||||
initialCash: opts.initialCash ?? 0,
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -251,6 +257,95 @@ describe("V5 Golden Tests", () => {
|
||||
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", () => {
|
||||
const p = plan({
|
||||
age: 40,
|
||||
|
||||
+44
-1
@@ -61,6 +61,13 @@ export interface PhaseComputed {
|
||||
plannedWithdrawRate: number; // geplante Verzehrrate: Bezugsraten aus Sonstigem Vermoegen
|
||||
capitalInflow: number; // Kapitalzufluss: PK-/3a-Bezuege + Verkaeufe (aus dem Uebergang in diese Phase)
|
||||
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;
|
||||
cashEnd: number;
|
||||
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).
|
||||
let incomingInflow = 0; // Brutto-Zufluss: Verkaeufe + PK-/3a-Bezuege
|
||||
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;
|
||||
|
||||
for (let i = 0; i < phases.length; i++) {
|
||||
@@ -500,6 +511,10 @@ export function computePlan(plan: PlanInput): PlanComputed {
|
||||
plannedWithdrawRate: plannedWithdrawTotal,
|
||||
capitalInflow: Math.round(incomingInflow),
|
||||
capitalInvest: Math.round(investmentsFromCash + incomingImmediateRepay),
|
||||
oneOffInflow: Math.round(incomingOneOffInflow),
|
||||
oneOffInflowLabel: incomingOneOffInflowLabel,
|
||||
oneOffOutflow: Math.round(incomingOneOffOutflow),
|
||||
oneOffOutflowLabel: incomingOneOffOutflowLabel,
|
||||
cashStart: Math.round(cashStart),
|
||||
cashEnd,
|
||||
cashNegative,
|
||||
@@ -516,6 +531,30 @@ export function computePlan(plan: PlanInput): PlanComputed {
|
||||
// --- Uebergang: Carry aktualisieren, Cash der Folgephase bilden ---
|
||||
let txInflow = 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) {
|
||||
const carry = carries.get(e.id)!;
|
||||
const ec = ecById.get(e.id)!;
|
||||
@@ -616,9 +655,13 @@ export function computePlan(plan: PlanInput): PlanComputed {
|
||||
carry.hasCarry = true;
|
||||
}
|
||||
|
||||
cashCarryIn = cashEnd + txInflow - txImmediateRepay;
|
||||
cashCarryIn = cashEnd + txInflow + txOneOffInflow - txImmediateRepay - txOneOffOutflow;
|
||||
incomingInflow = txInflow;
|
||||
incomingImmediateRepay = txImmediateRepay;
|
||||
incomingOneOffInflow = txOneOffInflow;
|
||||
incomingOneOffInflowLabel = txOneOffInflowLabel;
|
||||
incomingOneOffOutflow = txOneOffOutflow;
|
||||
incomingOneOffOutflowLabel = txOneOffOutflowLabel;
|
||||
yearsBefore += duration;
|
||||
}
|
||||
|
||||
|
||||
@@ -97,10 +97,39 @@ export interface TransitionData {
|
||||
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) ---
|
||||
|
||||
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
|
||||
.object({
|
||||
amount: nonNeg.optional(),
|
||||
|
||||
+8
-2
@@ -1,7 +1,7 @@
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { phaseDataSchema, transitionDataSchema } from "@/lib/elements";
|
||||
import type { PhaseData, TransitionData } from "@/lib/elements";
|
||||
import { cashTransitionSchema, phaseDataSchema, transitionDataSchema } from "@/lib/elements";
|
||||
import type { CashTransitionData, PhaseData, TransitionData } from "@/lib/elements";
|
||||
import type { PlanInput } from "@/lib/types";
|
||||
|
||||
export const planInclude = {
|
||||
@@ -25,6 +25,11 @@ function parseTransitionData(raw: unknown): TransitionData {
|
||||
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 {
|
||||
return {
|
||||
id: plan.id,
|
||||
@@ -44,6 +49,7 @@ export function toPlanInput(plan: PlanWithRelations): PlanInput {
|
||||
sequenceNumber: phase.sequenceNumber,
|
||||
name: phase.name,
|
||||
durationYears: phase.durationYears,
|
||||
cashTransition: parseCashTransition(phase.cashTransition),
|
||||
})),
|
||||
elements: plan.elements.map((e) => {
|
||||
const phaseValues: Record<string, PhaseData> = {};
|
||||
|
||||
+3
-1
@@ -3,7 +3,7 @@
|
||||
//
|
||||
// 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 PersonRole = "PERSON_A" | "PERSON_B";
|
||||
@@ -21,6 +21,8 @@ export interface PhaseInput {
|
||||
sequenceNumber: number;
|
||||
name: string;
|
||||
durationYears: number;
|
||||
// Cash-Entscheid beim Uebergang NACH dieser Phase (einmalige Sonderein-/ausgaben).
|
||||
cashTransition: CashTransitionData;
|
||||
}
|
||||
|
||||
export interface ElementInput {
|
||||
|
||||
Reference in New Issue
Block a user