From 9f5bd754eb524df30cfece585f14093f7aeac930 Mon Sep 17 00:00:00 2001 From: kelle Date: Fri, 17 Jul 2026 08:14:44 +0200 Subject: [PATCH] Einmalige Sonderein-/ausgaben am Cash-Uebergang (Roadmap Nr. 1) 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 --- SPEZIFIKATION.md | 164 ++++++++++++++++-- .../migration.sql | 4 + prisma/schema.prisma | 5 + .../phases/[phaseId]/cash-transition/route.ts | 31 ++++ src/app/api/plans/[planId]/scenario/route.ts | 2 + src/components/ElementDetail.tsx | 151 +++++++++++++++- src/components/PlanView.tsx | 149 +++++++++++++++- src/lib/calculations.test.ts | 101 ++++++++++- src/lib/calculations.ts | 45 ++++- src/lib/elements.ts | 29 ++++ src/lib/queries.ts | 10 +- src/lib/types.ts | 4 +- 12 files changed, 665 insertions(+), 30 deletions(-) create mode 100644 prisma/migrations/20260716230000_phase_cash_transition/migration.sql create mode 100644 src/app/api/phases/[phaseId]/cash-transition/route.ts diff --git a/SPEZIFIKATION.md b/SPEZIFIKATION.md index 3468b68..77a22ef 100644 --- a/SPEZIFIKATION.md +++ b/SPEZIFIKATION.md @@ -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//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: ". 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/` Nur die letzte Phase. → 200 `{ ok }` · 400 „Nur die letzte Phase kann geloescht werden." +### `PUT /api/phases//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//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) | diff --git a/prisma/migrations/20260716230000_phase_cash_transition/migration.sql b/prisma/migrations/20260716230000_phase_cash_transition/migration.sql new file mode 100644 index 0000000..d5c8c28 --- /dev/null +++ b/prisma/migrations/20260716230000_phase_cash_transition/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b672f0f..8d4ffeb 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 diff --git a/src/app/api/phases/[phaseId]/cash-transition/route.ts b/src/app/api/phases/[phaseId]/cash-transition/route.ts new file mode 100644 index 0000000..04c10f6 --- /dev/null +++ b/src/app/api/phases/[phaseId]/cash-transition/route.ts @@ -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 }); +} diff --git a/src/app/api/plans/[planId]/scenario/route.ts b/src/app/api/plans/[planId]/scenario/route.ts index c96ec2a..40b56da 100644 --- a/src/app/api/plans/[planId]/scenario/route.ts +++ b/src/app/api/plans/[planId]/scenario/route.ts @@ -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); diff --git a/src/components/ElementDetail.tsx b/src/components/ElementDetail.tsx index 5c99da7..de8ec42 100644 --- a/src/components/ElementDetail.tsx +++ b/src/components/ElementDetail.tsx @@ -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) => 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 ( + <> +
+ + 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" }, + ]} + /> +
+ + {showIn && ( + <> +
+ Einmaliger Zufluss (z. B. Erbschaft). Wird NOMINAL erfasst – + der Betrag, der zu diesem Zeitpunkt tatsaechlich aufs Konto kommt. +
+ setC({ inflowLabel: v })} + /> + setC({ inflowAmount: v })} + /> + setC({ inflowTaxRate: v })} + /> + + {taxRate > 0 && ( + + )} + + )} + + {showOut && ( + <> +
+ Einmalige Kosten (z. B. Poolbau). Werden REAL erfasst – + in heutiger Kaufkraft. Die Inflation rechnet daraus automatisch den nominalen Betrag. +
+ setC({ outflowLabel: v })} + /> + setC({ outflowAmount: v })} + /> + + + )} + + ); +} + // "Beantwortet" = ein konkreter Entscheid liegt vor (kein offenes Fragezeichen). export function isTransitionAnswered(category: ElementCategory, isRetirement: boolean, td: TransitionData): boolean { switch (category) { diff --git a/src/components/PlanView.tsx b/src/components/PlanView.tsx index 7d64e7a..a6b3ec5 100644 --- a/src/components/PlanView.tsx +++ b/src/components/PlanView.tsx @@ -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(null); const [valueMode, setValueMode] = useState("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({ ) : ( - - → - + (() => { + // Uebergangszelle der Cash-Zeile: einmalige Sonderein-/ausgaben. + const ct = cashTransitionFor(col.fromPhase.id); + const open = !isCashTransitionAnswered(ct); + return ( + 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)} + + ); + })() ); })} @@ -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 ( + 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) && ( + <> +
+ {phase.oneOffInflow > 0 && ( +
+ + {phase.oneOffInflowLabel ?? "Einmaliger Zufluss"} {valStr(phase.oneOffInflow, dS, mode)} +
+ )} + {phase.oneOffOutflow > 0 && ( +
+ − {phase.oneOffOutflowLabel ?? "Einmalige Kosten"} {valStr(phase.oneOffOutflow, dS, mode)} +
+ )} + + )} +
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(() => withCashTransitionDefaults(initialCash)); const [saving, setSaving] = useState(false); const [error, setError] = useState(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.

+ {/* Cash zuerst: einmalige Sonderein-/ausgaben betreffen jeden Uebergang. */} +
+
+ + + + Cash + Einmalige Sonderein-/ausgaben +
+

+ Einmalige Ereignisse wie Erbschaft, Autokauf oder Poolbau werden hier direkt dem Cash-Konto + gutgeschrieben bzw. belastet. +

+
+ setCt((prev) => ({ ...prev, ...patch }))} + deflatorEnd={fromPhase.cumulativeInflationEnd} + /> +
+
+ {elements.length === 0 && (

- An diesem Übergang gibt es keine zu entscheidenden Positionen. + An diesem Übergang gibt es sonst keine zu entscheidenden Positionen.

)} {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(() => withCashTransitionDefaults(initial)); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(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 ( + +
+ Einmalige Sonderein-/ausgaben · {fromPhase.name} → {toPhase?.name ?? "Ende"} +
+
+ setCt((prev) => ({ ...prev, ...patch }))} + deflatorEnd={fromPhase.cumulativeInflationEnd} + /> +
+ {error &&

{error}

} + +
+ ); +} + // --- Dialog: Cash-Anfangswert (erste Lebensphase) --- function CashInitialDialog({ plan, onClose, onSaved }: { plan: PlanInput; onClose: () => void; onSaved: () => void }) { const [value, setValue] = useState(plan.initialCash); diff --git a/src/lib/calculations.test.ts b/src/lib/calculations.test.ts index 06deb1f..6e8a7b9 100644 --- a/src/lib/calculations.test.ts +++ b/src/lib/calculations.test.ts @@ -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[]; 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, diff --git a/src/lib/calculations.ts b/src/lib/calculations.ts index fe5336e..dfb2181 100644 --- a/src/lib/calculations.ts +++ b/src/lib/calculations.ts @@ -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; } diff --git a/src/lib/elements.ts b/src/lib/elements.ts index ed2ba1f..e23874b 100644 --- a/src/lib/elements.ts +++ b/src/lib/elements.ts @@ -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(), diff --git a/src/lib/queries.ts b/src/lib/queries.ts index bf06dee..0cd3d9c 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -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 = {}; diff --git a/src/lib/types.ts b/src/lib/types.ts index 59d3f5c..da58666 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -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 {