From 113616cc1f4544a319790cc30c9cc0924e9df99f Mon Sep 17 00:00:00 2001 From: kelle Date: Mon, 17 Aug 2026 20:04:45 +0200 Subject: [PATCH] Memo 2026081701: Kapitalverwendung mit waehlbarem Tilgungsziel, AHV automatisch, Farb- und Textkorrekturen Co-Authored-By: Claude Opus 5 --- SPEZIFIKATION.md | 94 ++++++++++-- src/components/ElementDetail.tsx | 35 ++++- src/components/InventoryDialog.tsx | 33 ++++- src/components/PlanView.tsx | 91 ++++++++++-- src/components/RetirementFields.tsx | 216 ++++++++++++++++++---------- src/components/ReviewTile.tsx | 4 +- src/lib/calculations.ts | 31 +++- src/lib/capitaluse.test.ts | 92 ++++++++++++ src/lib/retirement-decision.ts | 5 + 9 files changed, 485 insertions(+), 116 deletions(-) create mode 100644 src/lib/capitaluse.test.ts diff --git a/SPEZIFIKATION.md b/SPEZIFIKATION.md index 5b63917..766d0d5 100644 --- a/SPEZIFIKATION.md +++ b/SPEZIFIKATION.md @@ -4,10 +4,10 @@ | | | |---|---| | **Dokument** | Funktionale und Technische Spezifikation FPT | -| **Version** | 0.40.1 | +| **Version** | 0.41 | | **Datum** | 2026-07-25 | | **Status** | Lebendes Dokument | -| **Codestand** | Arbeitsstand nach `ce987b9` inkl. «ein Wert, ein Ort» (Branch `main`) | +| **Codestand** | Arbeitsstand nach `f9d7ca0` inkl. Memo 2026081701 (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.41 | 2026-08-17 | Claude (Opus 5) | **Memo 2026081701** – zehn Punkte aus einer Testrunde, mehrheitlich Feinschliff, zwei davon inhaltlich. (1) **Die Kapitalverwendung heisst neu Schuldentilgung · Investition · Cash, und die Tilgung hat ein wählbares Ziel** (`capitalUseDebtTargetElementId`): eine Immobilie **oder** eine sonstige Schuld. Bisher floss die Quote stur in die erstbeste Immobilie – wer neben einer Hypothek zu 1,5 % einen Konsumkredit zu 6 % trägt, konnte den Kredit gar nicht zuerst tilgen. Ohne Wahl bleibt es beim alten Verhalten, damit bestehende Pläne unverändert rechnen. **Cash ist neu eine gleichrangige Zeile** statt einer blassen Fussnote: «ich lasse es liegen» ist ein legitimer Entscheid. Rechnerisch bleibt es der Rest – alle drei frei eintippbar zu machen hiesse, dass beim Tippen still eine andere Zahl wandert. (2) **Die Maske gibt es neu auch bei der Säule 3a.** Dort stellt sich die Frage sogar zwingender als bei der PK: Ein 3a-Konto wird IMMER vollständig bezogen, es fliesst also in jedem Fall ein grosser Betrag – der bis 0.40 stumm auf dem Cash landete. (3) **Je Person entsteht beim Abschluss der Bestandsaufnahme automatisch ein AHV-Element**, idempotent (wer die Bestandsaufnahme später korrigiert, bekommt kein zweites). Die AHV ist die einzige Kategorie ohne Bestand – ein Plus-Knopf mit leerer Karte wäre sinnlos, das Element wegzulassen aber auch: Ohne es rechnet der Plan ab der Pensionierung mit einer AHV-Rente von 0. Die Karte in der Bestandsaufnahme sagt neu, warum dort nichts zu erfassen ist. (4) **Farben folgen der Bedeutung, nicht dem Zustand.** Ein einmaliger Cash-Zufluss am Übergang trug die Akzentfarbe – im warmen Schema ein Orangerot, das eine Erbschaft wie einen Fehler aussehen liess; neu ist er grün, ein Abfluss neutral. Die Knöpfe «Kapital verteilen» und «Sparquote verteilen» sind **grün mit Haken, sobald erledigt** – vorher sah man einer vollständig verteilten Phase nicht an, dass sie fertig war. (5) **Sechs Stellen mit unlesbarer Schrift behoben:** `text-attention-fg` ist **weiss** und für vollflächigen Attention-Grund gedacht; auf hellem oder 10-%-Grund war der Text im warmen und im hellen Schema praktisch unsichtbar (PK-Einkaufswarnung, 3a-Kollision, fehlendes Anlageziel, zwei Stellen in der Kachel «Offene Punkte»). (6) **Der Kapitalanteil-Regler hatte keine sichtbare Schiene**: `appearance-none` schaltet die native Darstellung ab, womit auch `accent-color` nicht mehr greift – übrig blieb der blosse Knopf. (7) **Der Umwandlungssatz verschwindet bei 100 % Kapital**, spiegelbildlich zur Kapitalbezugssteuer bei 0 %. Bei vollem Kapitalbezug wird nichts verrentet. (8) **Der Dialog «Neue Lebensphase» erklärt die Dauer-Kappung** – welches Ereignis sie setzt und warum (die Rechnung leitet Erwerbsstatus und Bezüge am Phasen**beginn** ab). Vorher stand dort nur «max. N». (9) **Zustand statt Regel nach der Pensionierung:** Die Zellen von PK und 3a beschrieben in der Pensionsphase die Regel im Futur («wird berechnet», «wird bezogen»), obwohl der Bezug längst geschehen war. Neu steht dort der gerechnete Stand – bei der PK die Rente in Franken oder «Vollständig bezogen», bei der 3a «Vollständig bezogen». (10) **Eine Vokabel für einen Gedanken:** Die Bestätigung der Pensionsentscheide heisst neu «angeschaut» wie die der Phasenzellen, und der Erklärtext sagt ausdrücklich, dass der Haken **keine Zahl ändert**. Zwei Wörter für dieselbe Sache hatten mehr verwirrt als geholfen. Die Fixpunkt-Kappung um AHV-, PK- und 3a-Bezugsalter zu erweitern wurde **bewusst zurückgestellt** – im Basisszenario ist das Pensionsalter fest 65, das Thema gehört zur Szenario-Art «Frühpensionierung» (Roadmap Nr. 48). 3 Tests ergänzt (334 → 337). | | 0.40.1 | 2026-08-16 | Claude (Opus 5) | **Zwei Fehler aus der Testrunde.** (1) **«Ein Wert, ein Ort» gilt jetzt im Rechenkern, nicht nur in der Oberfläche.** Eine Immobilie zeigte in der ersten Lebensphase Startwert 0 statt Kaufpreis minus Hypothek. Ursache: Der Rechenkern legte die Phasenwerte über die Stammdaten (`{...baseData, ...phaseValues}`) – eine in 0.39 versehentlich gespeicherte 0 gewann damit gegen die Bestandsaufnahme. Seit 0.40 ist das Feld read-only, wodurch dieser Altwert **unerreichbar** war und den Kaufpreis dauerhaft verdeckt hätte. Neu gewinnen bei den fünf Bestandsfeldern (`amount`, `currentValue`, `startValue`, `purchasePrice`, `mortgage`) **immer die Stammdaten**, wo sie einen Wert tragen (`firstPhaseValues`); der Rückfall auf den Phasenwert bleibt nur, solange die Stammdaten leer sind – sonst fielen Pläne aus der Zeit vor 0.36 schlagartig auf 0. Die Annahmen (Rendite, Zins, Wertsteigerung, Teuerung) bleiben in Phase 1 überschreibbar: Sie gelten für einen Zeitraum, nicht für den Anfangsbestand. Damit heilen bestehende Daten von selbst. (2) **«Alle bestätigen» war zu grob.** Der Knopf in der Annahmen-Maske schrieb `confirmed: true` auf jede Zeile – man konnte ihn drücken, ohne je gescrollt zu haben, also genau das Durchwinken, das der Mechanismus verhindern soll. Neu trägt **jede Zeile ein Häkchen «Angeschaut»**, anfangs leer; wer ein Feld ändert, hakt es automatisch an (bearbeiten IST anschauen); oben markiert ein Klick alle auf einmal, aber als sichtbarer Akt. Gespeichert werden die Werte aller Zeilen, bestätigt nur die angehakten – der Knopf nennt die Zahl. 4 Tests ergänzt (330 → 334). | | 0.40 | 2026-08-16 | Claude (Opus 5) | **Ein Wert, ein Ort** (neues Kap. 3.14.3, aus einer Testrunde des Nutzers). Der rote Faden aller acht Punkte: Für dieselbe Zahl gab es an mehreren Stellen ein Eingabefeld, und der Rechenkern entschied still, welches gewinnt. (1) **Startwerte sind in der ersten Lebensphase read-only.** Sie standen dort als Eingabefeld, das `phaseValues[phase1]` schrieb – der Rechenkern legt die Phase über die Stammdaten (`{...baseData, ...phaseValues}`), also überschattete jede Eingabe still die Bestandsaufnahme. Neu wird der Stammdatenwert angezeigt, mit Absprung in die Spalte «Start». Ab Phase 2 bleibt der Betrag bei Einkommen und Ausgaben **änderbar** – Teilzeit, Beförderung, Jobwechsel sind echte Entscheide dieser Phase; die Bestände sind dort ohnehin schon fortgeschrieben und read-only. (2) **Jährliche Raten entstehen nur noch im Verteil-Dialog**, einmalige Kapitalverwendungen nur noch im Kapital-Dialog. In der Zelle stehen sie weiterhin, aber read-only mit Absprung. Nur im Dialog sieht man, wie viel überhaupt zu verteilen ist und ob die Summe aufgeht. (3) **Der PK-Beitrag wandert in den Verteil-Dialog** – in einen **eigenen Block** «Aus dem Bruttolohn (ausserhalb der Quote)». Er ist bewusst nicht Teil der Quote ([4.6.3](#463-pension_fund)), aber der Dialog ist neu der einzige Ort für jährliche Beträge; ohne ihn wäre er unerreichbar und fiele still auf 0. (4) **Deckel und Schalter «grosse Säule 3a» stehen im Verteil-Dialog**, direkt über der Einzahlung, deren Obergrenze sie bestimmen – getrennt davon war der Schalter eine Einstellung ohne sichtbare Wirkung. (5) **Sonderamortisation und Sofort-Tilgung verlassen die Übergangszelle.** Sie zehren vom Kapital der FOLGEphase und werden dort entschieden; angezeigt werden sie in der Phasenzelle, wo auch die Zusatzeinlage steht. (6) **Der «Kapital verteilen»-Knopf ist immer sichtbar.** Er hing an `pot.total > 0` – wer alles verteilt hätte, käme an seine eigene Zuteilung nie mehr heran, sobald die Elementfelder read-only sind. (7) **Cash in der Bestandsaufnahme.** Es fehlte ganz: Die Summe «Vermögen heute» rechnete es bereits mit, erfassen konnte man es dort nirgends. Kein Plus-Knopf, sondern ein festes Feld – Cash ist kein Element, sondern `Scenario.initialCash`. (8) **Die Tour startet direkt nach dem Anlegen des Plans.** Sie hing an `phases.length > 0`, einem Überbleibsel der Spotlight-Tour, die echte DOM-Ziele brauchte; die Attrappe braucht nichts – und die Tour gehört genau dorthin, wo man noch nicht weiss, wie der Plan aufgebaut ist. (9) **Das Abzeichen im Phasenkopf öffnet eine Maske mit allen offenen Annahmen** (`PhaseReviewDialog`), analog zum Übergangs-Review. Bis 0.39 fragte es nur «N Annahmen bestätigen?» – eine Zustimmung zu etwas Ungesehenem, also genau die Bewegung, die der Mechanismus verhindern soll. (10) **Unbestätigte Zellen sind deutlicher markiert**: getönter Grund, kräftiger linker Balken, Warnzeichen. Der dünne Ring aus 0.39 ging in einer vollen Matrix unter. (11) **Cash-Zeile mit `ValuePair`**: der Realwert steht im Modus «Beide» darunter statt daneben, wie in jeder anderen Zeile. (12) Zwei Folgen daraus: Der Dialog **«Element anlegen» schreibt neu in die Stammdaten** statt in Phase 1 und **braucht keine Lebensphase mehr**; und `needsConfirmation` verlangt keine Bestätigung mehr für Zellen **ohne jede Annahme** – eine «Sonstige Schuld» trägt seit (2) nichts mehr in der Phasenzelle. 1 Test ergänzt (329 → 330). | | 0.39.1 | 2026-08-16 | Claude (Opus 5) | **Fehlerbehebung: Die Bestandsaufnahme schloss sich beim ersten Plus-Knopf.** `InventoryDialog` kannte nur einen Rückkanal nach oben (`onSaved`) und benutzte ihn für zwei verschiedene Ereignisse: «Element angelegt, bitte Plan neu laden» und «Dialog fertig». In `PlanView` hängt an `onSaved` aber das Schliessen -- ein Klick auf «Ausgaben» legte das Element korrekt an und beendete den Dialog sofort, womit sich genau der eine Bildschirm nicht bedienen liess, der alles erfassen soll. Neu trägt der Dialog beide Rückkanäle getrennt: `onChanged` lädt nur nach (der Entwurf im Dialog überlebt das, weil `loadDetail(id, true)` still nachlädt und die `PlanView` montiert bleibt), `onSaved` schliesst. Betraf auch das Löschen einer Position aus dem Dialog heraus. | @@ -474,12 +475,28 @@ Neue Phasen werden **immer am Ende der Kette** angehängt (`sequenceNumber = Anz **Automatische Dauer-Kappung:** Die Dauer wird ans nächste Pensionsereignis gekappt. Formel (`maxPhaseDuration`): für jede Person, die zu Phasenbeginn noch erwerbstätig ist, gilt `retirementAge − (age + yearsBefore)`; das Minimum dieser Werte ist die Obergrenze. Ist keine -Person mehr erwerbstätig, gibt es keine Obergrenze (`null`). Diese Kappung ist im Dialog -sichtbar („max. N") **und** wird serverseitig erzwungen. +Person mehr erwerbstätig, gibt es keine Obergrenze (`null`). Diese Kappung wird +serverseitig erzwungen. Fachliche Begründung: Eine Phase darf keine Pensionierung überspannen, weil der Phasentyp und die AHV-/PK-Renten am Phasenbeginn ausgewertet werden. +**Der Dialog nennt seit 0.41 den Grund, nicht nur die Zahl.** Vorher stand dort «max. 12» – +eine Sperre ohne Erklärung wirkt willkürlich, und man vermutet einen Fehler. Neu steht im +Klartext, welches Ereignis die Grenze setzt («Anna geht mit 65 in Pension»), warum das so ist +(die Rechnung wertet Erwerbsstatus und Renten am Phasen**beginn** aus) und dass es weitergeht +(«danach legst du einfach die nächste Phase an»). Sind alle Personen bereits pensioniert, sagt +der Dialog stattdessen, dass die Dauer frei ist. + +> **Bekannte Lücke.** `maxPhaseDuration` kann zusätzliche Fixpunkte entgegennehmen (AHV-Beginn, +> PK-Bezug, jedes 3a-Konto, siehe [3.14.5](#3145-fixpunkte-jeder-bezugsbeginn-erzwingt-eine-phasengrenze)), +> aber weder der Dialog noch der Endpunkt übergeben sie – gekappt wird nur am Erwerbsende. Im +> Basisszenario fällt das nicht ins Gewicht, weil das Pensionsalter dort fest **65** ist. Wer +> im Übergangs-Dialog ein abweichendes 3a-Bezugsalter setzt, kann die Grenze jedoch +> überspannen; der Bezug rutscht dann auf die nächste Phasengrenze. Bewusst zurückgestellt bis +> zur Szenario-Art «Frühpensionierung» (Roadmap Nr. 48), wo abweichende Bezugsalter zum +> eigentlichen Thema werden. + **Default-Dauer:** die Kappung, sonst 10 Jahre. **Default-Name:** Phase 1 → „Erste Lebensphase"; sonst „Pensionsphase" wenn zu Phasenbeginn mindestens eine Person pensioniert ist, sonst „Erwerbsphase". @@ -756,7 +773,7 @@ Seit 0.35 gibt es **drei** Zustände statt zwei. Der dritte ist der interessante |---|---| | **unbeantwortet** | Das System weiss nichts. Verkauf/Halten, Tilgung, Cash-Übergang. | | **auf Vorgabe** | Das System **hat** eine Antwort – nur nicht die des Benutzers. Gilt für die Pensionierungs-Entscheide (AHV/PK/3a), die seit 0.35 durchgängige Vorgaben haben. | -| **bestätigt** | Der Benutzer hat hingeschaut (`retirementDecision.confirmed`), je Säule. | +| **bestätigt** | Der Benutzer hat hingeschaut (`retirementDecision.confirmed`), je Säule. Seit 0.41 heisst das im UI durchgehend «angeschaut» – dieselbe Vokabel wie bei den Phasenzellen ([3.14.2](#3142-bestätigen-heisst-ich-habe-hingeschaut)), weil es derselbe Gedanke ist: dort auf den Werten der Vorphase, hier auf einer Vorgabe des Tools. Zwei Wörter für eine Sache hatten mehr verwirrt als geholfen. Der Erklärtext sagt neu ausdrücklich, dass der Haken **keine Zahl ändert**. | Warum der dritte Zustand nötig wurde: Die Vorgaben sind Absicht – ohne sie müsste man am Anfang Fragen beantworten, die man erst am Ende beantworten kann, und der Plan wäre bis dahin @@ -1199,6 +1216,13 @@ Was nicht verteilt wird, **bleibt automatisch auf dem Cash** – dafür braucht ist das Verhalten des Modells. Wird mehr verteilt als vorhanden, startet die Folgephase mit negativem Cash; der Dialog weist das als Liquiditätslücke aus. +**Der Knopf ist grün mit Haken, sobald etwas zugeteilt ist**, und trägt sonst die +Attention-Farbe. Vorher sah man einer fertig verteilten Phase nicht an, dass sie fertig war – +der Knopf sah immer gleich aus. «Erledigt» heisst hier: Es ist Kapital da und **etwas davon** +ist zugeteilt; wer bewusst nur einen Teil verteilt und den Rest liegen lässt, hat entschieden. +Anders als bei der Quote gibt es dafür kein gespeichertes Kennzeichen – das Kapital ist keine +Frage, die man beantworten MUSS, sondern eine Gelegenheit. + **Der Knopf ist immer sichtbar**, auch wenn der Topf leer ist. Bis 0.39 hing er an `pot.total > 0` – also am **Rest**. Wer alles verteilt hatte, sah ihn nicht mehr. Solange die Beträge im Element noch änderbar waren, ging das; seit 0.40 sind sie read-only, und der Knopf @@ -1318,6 +1342,19 @@ Referenz: `src/lib/ratefields.ts`, `src/components/ElementDetail.tsx`. ### 3.7.2 Farbschemata +> **Der Akzent ist keine Bedeutung** (Lehre aus 0.41). Im warmen Schema ist er ein Orangerot, +> im dunklen ein Indigo. Wer ihn benutzt, um «erledigt» oder «hier ist Geld hereingekommen» zu +> sagen, sagt im einen Schema etwas anderes als im anderen: Ein einmaliger Erbschafts-Zufluss +> sah warm aus wie ein Fehler. Bedeutung trägt deshalb **Grün** (positiv, erledigt), +> **Attention** (hier fehlt etwas) und **Danger** (hier ist etwas falsch) – der Akzent nur +> «hier kannst du klicken». +> +> Ebenso hat jede Farbe **zwei** Vordergrund-Töne, und sie sind nicht austauschbar: +> `--attention-fg` ist weiss und gehört auf **vollflächigen** Attention-Grund; +> `--attention-soft-fg` ist dunkel und gehört auf den weichen. Verwechselt man sie, ist die +> Schrift im warmen und im hellen Schema praktisch unsichtbar – in 0.41 an sechs Stellen +> behoben. + Drei Themes: **Hell**, **Dunkel**, **Warm** (cremefarben, Koralle-Akzent). Wahl im Profilmenü, persistiert in `localStorage` (`fpt-theme`), gesetzt als `data-theme` am ``. Ohne gespeicherte Wahl folgt die Oberfläche `prefers-color-scheme`. Ein Inline-Script im `` @@ -1977,11 +2014,31 @@ gegen die Vererbung, bis man das Häkchen aktiv setzt. Am Pensions-Übergang kommt oft ein grosser Betrag auf einmal herein (PK-Kapital, Säule 3a). Ihn vollständig als Cash liegen zu lassen ist selten die Absicht. Beim **Bezugs-Entscheid des -jeweiligen Guthabens** lässt sich deshalb erfassen, wie viel **in Prozent** in die Amortisation -der Hypothek und in eine Anlage fliesst; der Rest bleibt Cash. +jeweiligen Guthabens** lässt sich deshalb erfassen, wie viel **in Prozent** wohin fliesst – seit +0.41 in drei gleichrangigen Zeilen: + +| Zeile | Ziel | wirkt als | +|---|---|---| +| **Schuldentilgung** | wählbar: eine Immobilie **oder** eine sonstige Schuld | Sonderamortisation bzw. Sofort-Tilgung | +| **Investition** | wählbar: ein Element «Sonstiges Vermögen» | Zusatzeinlage in der Folgephase | +| **Cash** | – | bleibt liegen | + +**Das Tilgungs-Ziel ist wählbar, seit es Schulden ausserhalb der Hypothek gibt.** Bis 0.40 floss +die Quote stur in die erstbeste Immobilie; wer neben einer Hypothek zu 1,5 % einen Konsumkredit +zu 6 % trägt, konnte den Kredit gar nicht zuerst tilgen. Ohne getroffene Wahl bleibt es beim +alten Verhalten (Immobilien der Reihe nach) – ein Bestandsschutz, damit bestehende Pläne +unverändert rechnen; ein Schuld-Element wird nur getilgt, wenn es **ausdrücklich** gewählt ist. + +**Cash ist eine Zeile, kein Rest.** Rechnerisch ist es beides – die Zahl ergibt sich aus +100 % minus den beiden anderen und ist deshalb nicht eintippbar. Dargestellt wird sie trotzdem +gleichrangig: «ich lasse es liegen und entscheide später» ist ein Entscheid, keine Unterlassung. +Alle drei frei eintippbar zu machen hiesse, dass beim Tippen still eine der anderen Zahlen +wandert. Die Frage steht seit 0.33 dort, wo der Bezug entschieden wird – bei der **Pensionskasse** (nur -wenn Kapital bezogen wird) und bei der **Säule 3a**. Bis 0.32 hing sie am **Cash-Übergang**: +wenn Kapital bezogen wird) und bei der **Säule 3a**. Bei der 3a fehlte sie bis 0.40 allerdings +in der Oberfläche, obwohl sie sich dort **zwingender** stellt: Ein 3a-Konto lässt sich nur ganz +auflösen, es fliesst also in jedem Fall ein grosser Betrag – und der landete stumm auf dem Cash. Bis 0.32 hing sie am **Cash-Übergang**: Das war der falsche Ort, weil sich mit zwei Guthaben nicht getrennt festlegen liess, welches wohin fliesst. Beide Dialoge führen deshalb neu in der Reihenfolge, in der man tatsächlich entscheidet: **Bezugsart → Betrag brutto → Steuersatz → Betrag netto → Verteilung**. @@ -1990,8 +2047,7 @@ entscheidet: **Bezugsart → Betrag brutto → Steuersatz → Betrag netto → V Kapital. Ein Frankenbetrag müsste von Hand nachgezogen werden – und würde bis dahin still eine falsche Aufteilung rechnen. Eine Quote skaliert mit. -Die Amortisations-Quote ist am Restsaldo der Hypothek gekappt; ist sie grösser, bleibt der Rest -Cash. Die Anlage-Quote fliesst in ein wählbares Vermögens-Element (Vorgabe: das erste aktive). +Die Tilgungs-Quote ist am Restsaldo gekappt; ist sie grösser, bleibt der Rest Cash. Die Anlage-Quote fliesst in ein wählbares Vermögens-Element (Vorgabe: das erste aktive). Beide sind mechanisch nichts Neues – die eine wirkt wie eine Sonderamortisation, die andere wie eine Zusatzeinlage, und beide laufen dadurch korrekt durch die zwei Wasserfall-Brücken. @@ -2192,6 +2248,15 @@ Der **PK-Beitrag** steht bewusst bei den Annahmen und nicht in der Sparquoten-Ve stammt aus dem Bruttolohn und belastet das Cash-Konto nicht ([4.6.3](#463-pension_fund)) -- aus der Quote liesse er sich gar nicht verteilen. +**Beim Abschliessen entsteht je Person ein AHV-Element** (seit 0.41), idempotent – wer die +Bestandsaufnahme später korrigiert und erneut abschliesst, bekommt kein zweites. Die AHV ist +die einzige Kategorie **ohne Bestand**: Es gibt kein Guthaben zum Nachschlagen, nur eine +Beitragskarriere, die FPT aus den Einkommen dieses Plans ableitet. Ein Plus-Knopf mit einer +leeren Karte darunter wäre deshalb sinnlos gewesen – sie wegzulassen aber auch: In der Schweiz +hat jede Person eine AHV, und ohne das Element rechnet der Plan ab der Pensionierung mit einer +Rente von 0. Erscheint die Karte später in der Liste, sagt sie, warum dort nichts zu erfassen +ist. Wer die Bestandsaufnahme nie öffnet, legt das Element selbst an. + **Die Bestandsaufnahme bleibt als eigener Dialog** (`InventoryDialog`): als grosser Knopf im leeren Plan und dauerhaft unter den Schnellaktionen. Sie ist der einzige Sammel-Dialog, der geblieben ist, weil sie als einzige etwas leistet, das die Matrix nicht kann -- sieben @@ -4064,9 +4129,10 @@ den Wert der Vorphase. Alle übrigen Felder bedeuten «nicht gesetzt = 0» wie b | `extraAmortization` | REAL_ESTATE (Sonderamortisation) | ≥ 0 | | `saleTaxRate` | REAL_ESTATE | 0–100 | | `immediateRepayment` | OTHER_DEBT | ≥ 0 | -| `capitalUseAmortizationPct` | PENSION_FUND (Kapitalbezug), PILLAR_3A – Anteil des bezogenen Kapitals in die Amortisation | 0–100 | -| `capitalUseInvestPct` | dito – Anteil in eine Anlage | 0–100 | -| `capitalUseTargetElementId` | Ziel der Anlage-Quote; ohne Angabe das erste aktive Sonstige Vermögen | ≤ 60 Zeichen | +| `capitalUseAmortizationPct` | PENSION_FUND (Kapitalbezug), PILLAR_3A – Anteil des bezogenen Kapitals in die **Schuldentilgung** | 0–100 | +| `capitalUseDebtTargetElementId` | Ziel der Tilgungs-Quote: eine Immobilie **oder** eine sonstige Schuld. Ohne Angabe die Immobilien der Reihe nach (Bestandsschutz); ein Schuld-Element wird nur getilgt, wenn es ausdrücklich gewählt ist | ≤ 60 Zeichen | +| `capitalUseInvestPct` | dito – Anteil in eine **Investition** | 0–100 | +| `capitalUseTargetElementId` | Ziel der Investitions-Quote; ohne Angabe das erste aktive Sonstige Vermögen | ≤ 60 Zeichen | ### 5.4.5 JSON-Payload `CashTransitionData` @@ -4494,7 +4560,7 @@ Include `src/**/*.test.ts`). Es gibt **keine** Komponenten-, API- oder E2E-Tests | `migrations.test.ts` | 3 | spielt alle Migrationen gegen echtes PostgreSQL (PGlite) ein; prüft zusätzlich die V7-**Datenübernahme** (Basisszenario gewinnt, Pensionsalter bleiben szenario-eigen) | | `rate-limit.test.ts` | 6 | Fixed-Window: erlaubt bis Limit, blockt danach, startet nach Fensterablauf neu, trennt je Schlüssel; Client-IP aus X-Forwarded-For / X-Real-IP | | `csv.test.ts` | 7 | BOM, alle vier Blöcke, jedes Element als Zeile, Beginn-/Ende-/Übergangsspalten, Entscheid im Klartext, ein Eintrag je Planjahr, Maskierung von `;` und `"` | -| **Total** | **334** | | +| **Total** | **337** | | ## 8.2 Testfälle diff --git a/src/components/ElementDetail.tsx b/src/components/ElementDetail.tsx index eab807c..9bb6592 100644 --- a/src/components/ElementDetail.tsx +++ b/src/components/ElementDetail.tsx @@ -75,6 +75,13 @@ export interface CellContext { // Stammdaten des Elements. In der ERSTEN Phase ist der Startwert genau das -- er wird dort // nur noch ANGEZEIGT, denn sonst entstünde derselbe Betrag an zwei Orten (Kap. 3.14.3). baseData: PhaseData; + // Der TATSÄCHLICH gerechnete Stand dieser Zelle. In einer Phase, in der die Person schon + // pensioniert ist, stand hier bis 0.40 eine Regelbeschreibung im Futur («wird berechnet», + // «wird bezogen»), obwohl der Bezug längst geschehen war. Was die Zelle braucht, ist der + // Zustand, nicht die Regel. + computedSummary: string; + computedNote: string | null; + computedValue: number; // Einmalige Kapitalverwendungen, die am Übergang IN diese Phase beschlossen wurden. Sie // liegen technisch an der Vorphase, gehören aber sichtbar hierher -- zusammen mit der // Zusatzeinlage, die aus demselben Topf stammt. @@ -750,11 +757,23 @@ export function ElementPhaseFields({ } case "PENSION_FUND": if (!context.ownerWorking) { + // Zustand statt Regel: entweder läuft eine Rente, oder das Guthaben ist bezogen. + const hasPension = context.computedValue > 0; return ( -

- Die PK-Rente wird aus dem beim Pensions-Übergang gewählten Umwandlungssatz berechnet (siehe - Kennzahl). Bei reinem Kapitalbezug erscheint hier "Vollständig bezogen". -

+
+ {hasPension ? ( + <> + Rente: {formatChf(context.computedValue)} pro Jahr. Das + Guthaben wurde beim Übergang in die Pension verrentet – hier gibt es nichts mehr einzustellen. + Den Umwandlungssatz und den Kapitalanteil änderst du in der Übergangsspalte davor. + + ) : ( + <> + Vollständig bezogen. Das Guthaben wurde beim Übergang in die + Pension als Kapital ausbezahlt; wohin es geflossen ist, steht dort im Bezugs-Entscheid. + + )} +
); } return ( @@ -786,7 +805,13 @@ export function ElementPhaseFields({ ); case "PILLAR_3A": { if (!context.ownerWorking) { - return

Die Säule 3a wird beim Pensions-Übergang vollständig bezogen.

; + return ( +
+ Vollständig bezogen. Ein 3a-Konto lässt sich nur ganz auflösen – + das ist beim Übergang geschehen. Bezugsalter, Steuersatz und die Verwendung des Kapitals stehen in der + Übergangsspalte davor. +
+ ); } // «Grosse Säule 3a» für Selbstständige ohne PK: höhere Obergrenze (Roadmap-Feedback C7c). // Der Schalter steht seit 0.40 im Verteil-Dialog, weil er dort die Obergrenze der diff --git a/src/components/InventoryDialog.tsx b/src/components/InventoryDialog.tsx index 6a17c19..5448828 100644 --- a/src/components/InventoryDialog.tsx +++ b/src/components/InventoryDialog.tsx @@ -481,7 +481,18 @@ function BaseDataCard({ onChange={(v) => setDraft((d) => ({ ...d, names: { ...d.names, [el.id]: v } }))} /> - {kind?.baseFields(values, set)} + {kind ? ( + kind.baseFields(values, set) + ) : ( + // Die AHV ist die einzige Kategorie ohne Bestand: Es gibt kein Guthaben zum + // Nachschlagen, nur eine Beitragskarriere. Eine leere Karte sähe nach einem Fehler + // aus -- deshalb steht hier, warum nichts zu tun ist. +

+ Hier gibt es nichts zu erfassen. Die AHV-Rente entsteht aus deiner Beitragskarriere – FPT leitet sie + aus den Einkommen dieses Plans ab. Beim Übergang in die Pension kannst du sie prüfen und um + Beitragslücken vor Planbeginn ergänzen. +

+ )} ); @@ -502,6 +513,26 @@ export async function saveInventoryDraft(plan: PlanInput, draft: InventoryDraft) if (draft.cash !== null && draft.cash !== plan.initialCash) { await api.patch(`/api/scenarios/${plan.id}`, { initialCash: draft.cash }); } + + // Die AHV entsteht am Schluss von selbst -- je Person eine. + // + // Sie hat als einzige Kategorie KEINEN Bestand: Es gibt kein Guthaben, das man nachschlagen + // könnte, nur eine Beitragskarriere. Ein Plus-Knopf mit einer leeren Karte darunter wäre + // deshalb sinnlos gewesen. Weglassen ist aber keine Option: In der Schweiz hat jede Person + // eine AHV, und ohne das Element rechnet der Plan ab der Pensionierung mit einer Rente von 0. + // + // IDEMPOTENT: Wer die Bestandsaufnahme später korrigiert und erneut abschliesst, bekommt + // kein zweites Element -- geprüft wird je Person, nicht global. + for (const person of plan.persons) { + const exists = plan.elements.some((e) => e.category === "AHV" && e.ownerRole === person.role); + if (exists) continue; + const name = person.name?.trim() ? `AHV ${person.name.trim()}` : "AHV"; + await api.post(`/api/scenarios/${plan.id}/elements`, { + category: "AHV", + name, + ownerRole: person.role, + }); + } } diff --git a/src/components/PlanView.tsx b/src/components/PlanView.tsx index da56fcf..558bec0 100644 --- a/src/components/PlanView.tsx +++ b/src/components/PlanView.tsx @@ -333,6 +333,9 @@ export function PlanView({ capitalFromTransfer: ce?.capitalFromTransfer ?? 0, capitalFromTransferSources: ce?.capitalFromTransferSources ?? [], baseData: element.baseData ?? {}, + computedSummary: ce?.summary ?? "", + computedNote: ce?.note ?? null, + computedValue: ce?.startValue ?? 0, oneOffAmortization: num(prevTd.extraAmortization), oneOffRepayment: num(prevTd.immediateRepayment), onEditBase: () => setPanel({ kind: "base", elementId: element.id }), @@ -367,6 +370,9 @@ export function PlanView({ capitalFromTransfer: 0, capitalFromTransferSources: [], baseData: element.baseData ?? {}, + computedSummary: ce?.summary ?? "", + computedNote: ce?.note ?? null, + computedValue: ce?.startValue ?? 0, // Einmalige Kapitalverwendungen gehören in die FOLGEphase, nicht an den Übergang selbst. oneOffAmortization: 0, oneOffRepayment: 0, @@ -690,6 +696,11 @@ Erste Lebensphase anlegen // Übergangszelle der Cash-Zeile: einmalige Sonderein-/ausgaben. const ct = cashTransitionFor(col.fromPhase.id); const open = !isCashTransitionAnswered(ct); + // Die Farbe folgt der RICHTUNG des Geldes, nicht dem Zustand der Zelle. + // Vorher trug jede beantwortete Zelle die Akzentfarbe -- im warmen Schema + // ein Orangerot, das eine Erbschaft wie einen Fehler aussehen liess. + const inflow = ct.mode === "INFLOW" || ct.mode === "BOTH"; + const outflow = ct.mode === "OUTFLOW" || ct.mode === "BOTH"; return ( {cashTransitionSummary(ct)} @@ -966,7 +981,7 @@ Erste Lebensphase anlegen {showAddPhase && ( setShowAddPhase(false)} onCreate={handleAddPhase} /> @@ -1140,13 +1155,22 @@ Erste Lebensphase anlegen } } - function nextPhaseCap(): number | null { + // Obergrenze der nächsten Phase UND ihr Grund. Die blosse Zahl («max. 12») beantwortet nicht, + // warum sie gilt -- und ohne diese Antwort wirkt die Sperre willkürlich. + function nextPhaseCap(): { years: number | null; reason: string | null } { // Simpel aus den Personen ableiten (Jahre nach Planbeginn = Summe der Dauern). const yearsBefore = plan.phases.reduce((s, p) => s + p.durationYears, 0); const caps = plan.persons - .map((p) => p.retirementAge - (p.age + yearsBefore)) - .filter((d) => d > 0); - return caps.length > 0 ? Math.min(...caps) : null; + .map((p) => ({ years: p.retirementAge - (p.age + yearsBefore), person: p })) + .filter((c) => c.years > 0) + .sort((a, b) => a.years - b.years); + if (caps.length === 0) return { years: null, reason: null }; + const first = caps[0]; + const name = first.person.name?.trim() || (first.person.role === "PERSON_B" ? "Person B" : "Person A"); + return { + years: first.years, + reason: `${name} geht mit ${first.person.retirementAge} in Pension – dort endet diese Phase zwingend.`, + }; } // Inhalt des Inspector-Panels je nach Auswahl. @@ -1458,6 +1482,11 @@ function PhaseHeader({ }) { const quotaLabel = phase.isConsumption ? "Verzehrquote" : "Sparquote"; const pot = capitalPot(phase); + // «Offen» heisst hier: Es ist Kapital da und noch nichts davon zugeteilt. Wer bewusst nur + // einen Teil verteilt und den Rest liegen lässt, hat entschieden -- das gilt als erledigt. + // Anders als bei der Quote gibt es dafür kein gespeichertes Kennzeichen; das Kapital ist + // aber auch keine Frage, die man beantworten MUSS, sondern eine Gelegenheit. + const capitalOpen = pot.total > 0 && pot.allocatedInvestments + pot.allocatedRepayments === 0; const dS = phase.cumulativeInflationStart; const dE = phase.cumulativeInflationEnd; // Bestandswerte (Cash, Vermögen) const dF = phase.flowDeflatorEnd; // Flow-Werte (Einkommen, Ausgaben, Quote) @@ -1576,15 +1605,27 @@ function PhaseHeader({ Rest auf Cash + {/* Grün, sobald nichts mehr offen ist: Der Knopf sagt damit auf einen Blick + «erledigt» statt bloss «hier kannst du klicken». Im warmen Schema war die + Akzentfarbe ein Orangerot und las sich wie eine Rüge. */} )} @@ -1601,14 +1642,15 @@ function PhaseHeader({ e.stopPropagation(); onDistributeRates(); }} - title={ratesOpen ? "Noch nicht verteilt -- was du nicht zuteilst, bleibt auf dem Cash-Konto" : undefined} - className={`mt-1 w-full rounded px-1.5 py-0.5 text-[10px] font-semibold transition-colors ${ + title={ratesOpen ? "Noch nicht verteilt -- was du nicht zuteilst, bleibt auf dem Cash-Konto" : "Verteilt"} + className={`mt-1 w-full rounded border px-1.5 py-0.5 text-[10px] font-semibold transition-colors ${ ratesOpen - ? "border border-attention bg-attention text-attention-fg hover:opacity-90" - : "border border-accent text-accent hover:bg-accent hover:text-accent-fg" + ? "border-attention bg-attention text-attention-fg hover:opacity-90" + : "border-success text-success hover:bg-success hover:text-white" }`} > {phase.isConsumption ? "Bezug verteilen" : "Sparquote verteilen"} + {ratesOpen ? "" : " ✓"} @@ -1818,15 +1860,15 @@ function AddElementDialog({ // --- Dialog: neue Lebensphase --- function AddPhaseDialog({ - maxDurationYears, + cap: capInfo, onClose, onCreate, }: { - maxDurationYears: number | null; + cap: { years: number | null; reason: string | null }; onClose: () => void; onCreate: (payload: { name?: string; durationYears?: number }) => void; }) { - const cap = maxDurationYears; + const cap = capInfo.years; const [name, setName] = useState(""); const [durationYears, setDurationYears] = useState(cap ?? 10); const [saving, setSaving] = useState(false); @@ -1834,6 +1876,25 @@ function AddPhaseDialog({ return (
+ {/* Warum die Dauer begrenzt ist. Die Rechnung leitet Erwerbsstatus und Bezuege am + PHASENBEGINN ab -- faellt die Pensionierung mitten in eine Phase, rutscht sie auf + die naechste Grenze und die Zahlen waeren still falsch. */} +
+ {cap == null ? ( + <> + Alle Personen sind bereits pensioniert – diese Phase darf so lang sein, wie du willst. Der + Planungshorizont ergibt sich aus der Summe aller Lebensphasen. + + ) : ( + <> + Diese Phase kann höchstens {cap} {cap === 1 ? "Jahr" : "Jahre"}{" "} + dauern. {capInfo.reason} FPT bestimmt Erwerbsstatus und Renten immer am{" "} + Anfang einer Phase – fiele die Pensionierung mitten hinein, + rutschte sie auf die nächste Phasengrenze und die Zahlen wären still falsch. Danach legst du einfach + die nächste Phase an. + + )} +
: } {title} {subtitle} + {/* Dieselbe Vokabel wie bei den Phasenzellen (SPEZIFIKATION 3.14.2): «angeschaut». + Es ist derselbe Gedanke -- dort auf den Werten der Vorphase, hier auf einer Vorgabe + des Tools -- und zwei Wörter für eine Sache haben nur verwirrt. */} {confirmed ? ( - bestätigt + angeschaut ) : ( @@ -112,7 +116,7 @@ export function Pillar({ {open && (
{children}
-
@@ -232,7 +239,6 @@ export function PkBlock({ defaultOpen?: boolean; }) { const share = rd.capitalSharePct ?? 0; - const targets = plan.elements.filter((e) => e.category === "OTHER_ASSET"); return ( patch(el.id, { capitalSharePct: Number(e.target.value) })} - className="h-2 flex-1 cursor-pointer appearance-none rounded-full bg-surface-2 accent-[var(--accent)]" + // KEIN appearance-none: Damit zeichnet der Browser die Schiene nicht mehr selbst, + // und accent-color greift nicht -- uebrig blieb der blosse Knopf. Im warmen Schema + // war die Schiene dadurch unsichtbar. + // Gleiche Schreibweise wie der Regler der Live-Simulation, der immer funktioniert + // hat: keine erzwungene Hoehe, kein appearance-none. + className="flex-1 cursor-pointer accent-[var(--accent)]" /> {share} % Kapital @@ -273,15 +284,19 @@ export function PkBlock({
- patch(el.id, { conversionRate: v })} - /> +{/* Bei 100 % Kapital wird nichts verrentet -- dann ist der Satz gegenstandslos, genau wie + die Kapitalbezugssteuer bei 0 % Kapital. */} + {share < 100 && ( + patch(el.id, { conversionRate: v })} + /> + )} {share > 0 && ( {rd.recentBuyIn && ( -

+ // attention-fg ist WEISS -- gedacht fuer vollflaechigen Attention-Grund. Auf + // 10 % Deckkraft war der Text im warmen und im hellen Schema unlesbar. +

Ein Kapitalbezug innerhalb von {PK_BUYIN_BLOCKING_YEARS} Jahren nach einem Einkauf lässt den Steuerabzug für diesen Einkauf nachträglich entfallen (Art. 79b Abs. 3 BVG). Das Tool rechnet diesen @@ -317,7 +334,7 @@ export function PkBlock({

)} - + )} @@ -325,6 +342,7 @@ export function PkBlock({ } export function Pillar3aBlock({ + plan, el, rd, patch, @@ -332,6 +350,7 @@ export function Pillar3aBlock({ siblings, defaultOpen, }: { + plan: PlanInput; el: ElementInput; rd: RetirementDecision; patch: (elementId: string, p: Partial) => void; @@ -379,90 +398,143 @@ export function Pillar3aBlock({ )} {clash && ( -

+

Ein weiteres 3a-Konto wird im selben Jahr bezogen. Die Beträge werden steuerlich zusammengezählt – ein anderes Bezugsjahr senkt die Progression.

)} + {/* Die Frage stellt sich hier sogar zwingender als bei der PK: Ein 3a-Konto wird IMMER + vollständig bezogen, es fliesst also in jedem Fall ein grosser Betrag. Bis 0.40 gab + es die Maske nur bei der Pensionskasse -- das 3a-Kapital landete stumm auf dem Cash. */} + ); } +// Eine Zeile der Kapitalverwendung: Quote plus, sobald sie > 0 ist, das Ziel. Eigene Komponente +// auf Modulebene -- innerhalb von CapitalUse deklariert wuerde sie bei jedem Tastendruck neu +// erzeugt und verloere ihren Zustand (der React Compiler weist das zu Recht zurueck). +function CapitalUseRow({ + title, + help, + value, + max, + onChange, + targets, + targetValue, + onTarget, + targetLabel, + missing, + label, +}: { + title: string; + help: string; + value: number; + max: number; + onChange: (v: number) => void; + targets: ElementInput[]; + targetValue?: string; + onTarget: (id: string) => void; + targetLabel: string; + missing: string; + label: (e: ElementInput) => string; +}) { + return ( +
+
+ + {value > 0 && targets.length > 0 && ( + onTarget(v)} + options={targets.map((t) => ({ value: t.id, label: label(t) }))} + /> + )} + {value > 0 && targets.length === 0 && ( +

+ + {missing} +

+ )} +
+
+ ); +} + // Verwendung des bezogenen Kapitals (Punkt C). Dieselbe Frage wie in der Matrix-Zelle -- hier // nur an dem Ort, an dem man ohnehin über den Bezug nachdenkt. +// +// Drei gleichrangige Zeilen: Schuldentilgung, Investition, Cash. Cash ist rechnerisch der +// REST, wird aber wie eine Wahl dargestellt und nicht wie eine Fussnote -- «ich lasse es +// liegen und entscheide später» ist ein legitimer Entscheid. Alle drei frei eintippbar zu +// machen hiesse, dass beim Tippen still eine der anderen Zahlen wandert; das überrascht mehr, +// als es hilft. function CapitalUse({ rd, elementId, patch, - targets, plan, }: { rd: RetirementDecision; elementId: string; patch: (elementId: string, p: Partial) => void; - targets: ElementInput[]; plan: PlanInput; }) { const amort = Math.max(0, Math.min(100, rd.capitalUseAmortizationPct ?? 0)); const invest = Math.max(0, Math.min(100 - amort, rd.capitalUseInvestPct ?? 0)); const cash = Math.max(0, 100 - amort - invest); - const hasMortgage = plan.elements.some((e) => e.category === "REAL_ESTATE"); + + // Tilgungs-Ziele: Hypotheken UND sonstige Schulden. Wer einen Konsumkredit zu 6 % neben + // einer Hypothek zu 1,5 % trägt, tilgt zuerst den Kredit. + const debtTargets = plan.elements.filter((e) => e.category === "REAL_ESTATE" || e.category === "OTHER_DEBT"); + const investTargets = plan.elements.filter((e) => e.category === "OTHER_ASSET"); + const label = (e: ElementInput) => `${e.name} · ${ownerLabel(plan.persons, e.ownerRole)}`; return ( - <> -
-

- Wohin fliesst das bezogene Kapital? -

-
- patch(elementId, { capitalUseAmortizationPct: Math.max(0, Math.min(100, v)) })} - /> - patch(elementId, { capitalUseInvestPct: Math.max(0, Math.min(100 - amort, v)) })} - /> - {invest > 0 && targets.length > 0 && ( -
- patch(elementId, { capitalUseTargetElementId: v })} - options={targets.map((t) => ({ - value: t.id, - label: `${t.name} · ${ownerLabel(plan.persons, t.ownerRole)}`, - }))} - /> -
- )} - {invest > 0 && targets.length === 0 && ( -

- - Es gibt kein Element «Sonstiges Vermögen», in das die Anlage-Quote fliessen könnte. Der Betrag bliebe auf - dem Cash-Konto liegen. -

- )} -

- Nicht zugeteilt: {cash} % – bleibt auf dem Cash-Konto. +

+

+ Wohin fliesst das bezogene Kapital? +

+
+ patch(elementId, { capitalUseAmortizationPct: Math.max(0, Math.min(100, v)) })} + targets={debtTargets} + targetValue={rd.capitalUseDebtTargetElementId} + onTarget={(v) => patch(elementId, { capitalUseDebtTargetElementId: v })} + targetLabel="Welche Schuld?" + missing="Es gibt weder Immobilie noch Schuld in diesem Szenario – der Anteil bliebe auf dem Cash-Konto." + /> + patch(elementId, { capitalUseInvestPct: Math.max(0, Math.min(100 - amort, v)) })} + targets={investTargets} + targetValue={rd.capitalUseTargetElementId} + onTarget={(v) => patch(elementId, { capitalUseTargetElementId: v })} + targetLabel="Wohin investieren?" + missing="Es gibt kein Element «Sonstiges Vermögen» – der Anteil bliebe auf dem Cash-Konto." + /> + {/* Cash ist der Rest, aber als vollwertige Zeile dargestellt. */} +
+
+ Cash (%) + {cash} % +
+

+ Was du nicht zuteilst, bleibt auf dem Cash-Konto – unverzinst, aber jederzeit verfügbar.

- +
); } diff --git a/src/components/ReviewTile.tsx b/src/components/ReviewTile.tsx index 586b1de..f027599 100644 --- a/src/components/ReviewTile.tsx +++ b/src/components/ReviewTile.tsx @@ -38,7 +38,7 @@ export function ReviewTile({ {done ? ( ) : ( - + )} Offene Punkte {!starting && ( @@ -99,7 +99,7 @@ export function ReviewTile({ > {g.title} - {g.open} + {g.open} {g.reasons.join(" · ")} diff --git a/src/lib/calculations.ts b/src/lib/calculations.ts index 2cf46a5..5652645 100644 --- a/src/lib/calculations.ts +++ b/src/lib/calculations.ts @@ -1918,16 +1918,33 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp const investPct = Math.max(0, Math.min(100 - amortPct, num(use.td.capitalUseInvestPct))); if (use.net <= 0 || (amortPct === 0 && investPct === 0)) continue; + // Schuldentilgung. Das Ziel ist seit 0.41 waehlbar und darf eine Immobilie ODER eine + // sonstige Schuld sein: Wer einen Konsumkredit zu 6 % neben einer Hypothek zu 1,5 % + // traegt, tilgt zuerst den Kredit -- vorher floss der Betrag stur in die erstbeste + // Immobilie. Ohne Wahl bleibt es beim bisherigen Verhalten (alle Immobilien der Reihe + // nach), damit bestehende Plaene unveraendert rechnen. let amortBudget = Math.round((use.net * amortPct) / 100); - for (const e of orderedElements) { + const chosenDebt = orderedElements.find((e) => e.id === use.td.capitalUseDebtTargetElementId); + const debtOrder = chosenDebt ? [chosenDebt, ...orderedElements.filter((e) => e !== chosenDebt)] : orderedElements; + for (const e of debtOrder) { if (amortBudget <= 0) break; - if (e.category !== "REAL_ESTATE") continue; const c = carries.get(e.id)!; - if (c.status !== "ACTIVE" || c.mortgage <= 0) continue; - const pay = Math.min(amortBudget, c.mortgage); - c.mortgage -= pay; - amortBudget -= pay; - txImmediateRepay += pay; + if (c.status !== "ACTIVE") continue; + if (e.category === "REAL_ESTATE") { + if (c.mortgage <= 0) continue; + const pay = Math.min(amortBudget, c.mortgage); + c.mortgage -= pay; + amortBudget -= pay; + txImmediateRepay += pay; + } else if (e.category === "OTHER_DEBT") { + // Nur das AUSDRUECKLICH gewaehlte Schuld-Element -- sonst wuerden bestehende Plaene, + // die bisher nur Hypotheken kannten, ploetzlich anders rechnen. + if (e !== chosenDebt || c.owed <= 0) continue; + const pay = Math.min(amortBudget, c.owed); + c.owed -= pay; + amortBudget -= pay; + txImmediateRepay += pay; + } } const investBudget = Math.round((use.net * investPct) / 100); diff --git a/src/lib/capitaluse.test.ts b/src/lib/capitaluse.test.ts new file mode 100644 index 0000000..8417584 --- /dev/null +++ b/src/lib/capitaluse.test.ts @@ -0,0 +1,92 @@ +// Verwendung des bezogenen Alterskapitals: das Tilgungs-Ziel ist wählbar (0.41). +// +// Bis 0.40 floss die Tilgungs-Quote stur in die erstbeste Immobilie. Wer neben einer Hypothek +// zu 1,5 % einen Konsumkredit zu 6 % trägt, will aber zuerst den Kredit los -- und hatte keine +// Möglichkeit, das zu sagen. + +import { describe, it, expect } from "vitest"; +import { computePlan } from "@/lib/calculations"; +import type { PlanInput } from "@/lib/types"; + +function plan(debtTarget?: string): PlanInput { + return { + id: "plan", + name: "T", + householdType: "SINGLE", + inflationRateDefault: 0, + initialCash: 0, + startYear: 2026, + persons: [{ id: "A", role: "PERSON_A", name: null, age: 64, retirementAge: 65 }], + phases: [ + { id: "p1", sequenceNumber: 1, name: "Erwerb", durationYears: 1, cashTransition: { mode: "NONE" } }, + { id: "p2", sequenceNumber: 2, name: "Pension", durationYears: 5, cashTransition: { mode: "NONE" } }, + ], + elements: [ + { + id: "pk", + category: "PENSION_FUND" as never, + name: "PK", + ownerRole: "PERSON_A" as never, + orderIndex: 1, + baseData: { currentValue: 400000, expectedReturn: 0 }, + phaseValues: { p1: {}, p2: {} }, + transitionValues: {}, + // Volles Kapital, keine Steuer, alles in die Tilgung: 400'000 stehen zur Verfügung. + retirementDecision: { + capitalSharePct: 100, + capitalTaxRate: 0, + conversionRate: 6, + capitalUseAmortizationPct: 100, + ...(debtTarget ? { capitalUseDebtTargetElementId: debtTarget } : {}), + } as never, + }, + { + id: "haus", + category: "REAL_ESTATE" as never, + name: "Haus", + ownerRole: "HOUSEHOLD" as never, + orderIndex: 2, + baseData: { purchasePrice: 900000, mortgage: 300000, interestRate: 1.5, valueGrowth: 0 }, + phaseValues: { p1: {}, p2: {} }, + transitionValues: { p1: { decision: "HOLD" as never } }, + }, + { + id: "kredit", + category: "OTHER_DEBT" as never, + name: "Konsumkredit", + ownerRole: "HOUSEHOLD" as never, + orderIndex: 3, + baseData: { startValue: 100000 }, + phaseValues: { p1: {}, p2: {} }, + transitionValues: {}, + }, + ], + }; +} + +const restOf = (p: PlanInput, id: string) => { + const c = computePlan(p); + const ph = c.phases[1]; + const ce = ph.elements.find((e) => e.elementId === id)!; + return id === "haus" ? ce.mortgageStart : Math.abs(ce.startValue); +}; + +describe("Schuldentilgung aus dem Alterskapital", () => { + it("tilgt ohne Wahl weiterhin die Hypothek", () => { + // Bestandsschutz: Pläne aus der Zeit vor der Zielwahl müssen unverändert rechnen. + const p = plan(); + expect(restOf(p, "haus")).toBe(0); + expect(restOf(p, "kredit")).toBe(100000); + }); + + it("tilgt den gewählten Kredit zuerst", () => { + const p = plan("kredit"); + expect(restOf(p, "kredit")).toBe(0); + }); + + it("lässt eine nicht gewählte Schuld unberührt, wenn die Hypothek gewählt ist", () => { + const p = plan("haus"); + expect(restOf(p, "haus")).toBe(0); + expect(restOf(p, "kredit")).toBe(100000); + }); +}); diff --git a/src/lib/retirement-decision.ts b/src/lib/retirement-decision.ts index bcfc0b2..e6cb6dc 100644 --- a/src/lib/retirement-decision.ts +++ b/src/lib/retirement-decision.ts @@ -79,7 +79,11 @@ export interface RetirementDecision { // --- gemeinsam: Kapitalbezug und seine Verwendung --------------------------------------- capitalTaxRate?: number; + // Quote in die SCHULDENTILGUNG. Ziel ist seit 0.41 waehlbar: eine Hypothek (Immobilie) + // ODER eine sonstige Schuld -- vorher floss der Betrag stets in die erstbeste Immobilie, + // was bei einem teuren Konsumkredit daneben die falsche Wahl war. capitalUseAmortizationPct?: number; + capitalUseDebtTargetElementId?: string; capitalUseInvestPct?: number; capitalUseTargetElementId?: string; } @@ -110,6 +114,7 @@ export const retirementDecisionSchema = z capitalTaxRate: z.number().min(0).max(100).optional(), capitalUseAmortizationPct: pct.optional(), + capitalUseDebtTargetElementId: z.string().max(60).optional(), capitalUseInvestPct: pct.optional(), capitalUseTargetElementId: z.string().max(60).optional(), })