Memo 2026081701: Kapitalverwendung mit waehlbarem Tilgungsziel, AHV automatisch, Farb- und Textkorrekturen
Deploy App / deploy (push) Successful in 1m18s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 20:04:45 +02:00
parent f9d7ca07fd
commit 113616cc1f
9 changed files with 485 additions and 116 deletions
+80 -14
View File
@@ -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` (v1v5) 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 `<html>`. Ohne
gespeicherte Wahl folgt die Oberfläche `prefers-color-scheme`. Ein Inline-Script im `<head>`
@@ -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 | 0100 |
| `immediateRepayment` | OTHER_DEBT | ≥ 0 |
| `capitalUseAmortizationPct` | PENSION_FUND (Kapitalbezug), PILLAR_3A Anteil des bezogenen Kapitals in die Amortisation | 0100 |
| `capitalUseInvestPct` | dito Anteil in eine Anlage | 0100 |
| `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** | 0100 |
| `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** | 0100 |
| `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
+30 -5
View File
@@ -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 (
<p className="col-span-2 text-sm text-muted">
Die PK-Rente wird aus dem beim Pensions-Übergang gewählten Umwandlungssatz berechnet (siehe
Kennzahl). Bei reinem Kapitalbezug erscheint hier &quot;Vollständig bezogen&quot;.
</p>
<div className="col-span-2 rounded-lg bg-surface-2 px-3 py-2 text-sm text-muted">
{hasPension ? (
<>
<strong className="text-fg">Rente: {formatChf(context.computedValue)} pro Jahr.</strong> 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.
</>
) : (
<>
<strong className="text-fg">Vollständig bezogen.</strong> Das Guthaben wurde beim Übergang in die
Pension als Kapital ausbezahlt; wohin es geflossen ist, steht dort im Bezugs-Entscheid.
</>
)}
</div>
);
}
return (
@@ -786,7 +805,13 @@ export function ElementPhaseFields({
);
case "PILLAR_3A": {
if (!context.ownerWorking) {
return <p className="col-span-2 text-sm text-muted">Die Säule 3a wird beim Pensions-Übergang vollständig bezogen.</p>;
return (
<div className="col-span-2 rounded-lg bg-surface-2 px-3 py-2 text-sm text-muted">
<strong className="text-fg">Vollständig bezogen.</strong> 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.
</div>
);
}
// «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
+32 -1
View File
@@ -481,7 +481,18 @@ function BaseDataCard({
onChange={(v) => setDraft((d) => ({ ...d, names: { ...d.names, [el.id]: v } }))}
/>
</div>
{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.
<p className="sm:col-span-2 rounded-lg bg-surface-2 px-3 py-2 text-xs text-muted">
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.
</p>
)}
</div>
</div>
);
@@ -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,
});
}
}
+76 -15
View File
@@ -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 (
<td
key={`cash-t-${col.fromPhase.id}`}
@@ -700,7 +711,11 @@ Erste Lebensphase anlegen
? "bg-attention font-semibold text-attention-fg"
: diff?.cashTransitionCell.has(col.fromPhase.id)
? "bg-diff-soft text-diff ring-1 ring-inset ring-diff/40"
: "bg-accent-soft/40 text-accent"
: inflow && !outflow
? "bg-success/10 font-semibold text-success"
: outflow
? "text-fg"
: "text-muted"
}`}
>
{cashTransitionSummary(ct)}
@@ -966,7 +981,7 @@ Erste Lebensphase anlegen
{showAddPhase && (
<AddPhaseDialog
maxDurationYears={nextPhaseCap()}
cap={nextPhaseCap()}
onClose={() => 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({
<span>Rest auf Cash</span>
<ValueSingle value={pot.rest} deflator={dS} mode={mode} />
</div>
{/* 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. */}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onDistributeCapital();
}}
className="mt-1 w-full rounded border border-accent px-1.5 py-0.5 text-[10px] font-semibold text-accent transition-colors hover:bg-accent hover:text-accent-fg"
title={
capitalOpen
? "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 ${
capitalOpen
? "border-attention bg-attention text-attention-fg hover:opacity-90"
: "border-success text-success hover:bg-success hover:text-white"
}`}
>
Kapital verteilen
Kapital verteilen{capitalOpen ? "" : " ✓"}
</button>
</div>
)}
@@ -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 ? "" : " ✓"}
</button>
</div>
@@ -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 (
<DialogShell title="Neue Lebensphase" onClose={onClose}>
<div className="grid grid-cols-1 gap-3">
{/* 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. */}
<div className="rounded-lg bg-surface-2 px-3 py-2 text-xs leading-relaxed text-muted">
{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 <strong className="text-fg">höchstens {cap} {cap === 1 ? "Jahr" : "Jahre"}</strong>{" "}
dauern. {capInfo.reason} FPT bestimmt Erwerbsstatus und Renten immer am{" "}
<strong className="text-fg">Anfang</strong> 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.
</>
)}
</div>
<div>
<label className="mb-1 block text-xs font-medium text-muted">Bezeichnung (optional)</label>
<input
+144 -72
View File
@@ -60,6 +60,7 @@ export function RetirementFields({
if (el.category === "PILLAR_3A")
return (
<Pillar3aBlock
plan={plan}
el={el}
rd={rd}
patch={patch}
@@ -99,9 +100,12 @@ export function Pillar({
{open ? <ChevronDown className="h-4 w-4 text-faint" /> : <ChevronRight className="h-4 w-4 text-faint" />}
<span className="text-sm font-semibold text-fg">{title}</span>
<span className="flex-1 truncate text-xs text-muted">{subtitle}</span>
{/* 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 ? (
<span className="flex items-center gap-1 whitespace-nowrap text-[11px] font-semibold text-success">
<Check className="h-3.5 w-3.5" /> bestätigt
<Check className="h-3.5 w-3.5" /> angeschaut
</span>
) : (
<span className="flex items-center gap-1 whitespace-nowrap rounded-full border border-border px-2 py-0.5 text-[11px] text-muted">
@@ -112,7 +116,7 @@ export function Pillar({
{open && (
<div className="border-t border-border px-3 py-3">
<div className="grid gap-3 sm:grid-cols-2">{children}</div>
<label className="mt-3 flex cursor-pointer items-start gap-2 text-xs text-muted">
<label className="mt-3 flex cursor-pointer items-start gap-2 rounded-lg bg-surface-2 px-3 py-2 text-xs text-muted">
<input
type="checkbox"
checked={confirmed}
@@ -120,8 +124,11 @@ export function Pillar({
className="mt-0.5"
/>
<span>
Ich habe das angeschaut und bestätige es. Ohne Häkchen rechnet das Tool mit der Vorgabe sie wird als
«ungeprüft» ausgewiesen, damit sie nicht unbemerkt durchgeht.
<strong className="text-fg">Angeschaut.</strong> FPT muss hier etwas rechnen, auch bevor du entschieden
hast deshalb gibt es eine Vorgabe (volle Rente, Bezug im Pensionsjahr). Der Haken hält fest, dass du
sie gesehen und für richtig befunden hast. <strong className="text-fg">Er ändert keine Zahl.</strong>{" "}
Ohne ihn rechnet FPT genauso weiter, weist die Vorgabe aber als «ungeprüft» aus, damit sie nicht
unbemerkt durchgeht.
</span>
</label>
</div>
@@ -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 (
<Pillar
@@ -261,7 +267,12 @@ export function PkBlock({
step={5}
value={share}
onChange={(e) => 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)]"
/>
<span className="w-28 whitespace-nowrap text-right text-sm tabular-nums text-fg">
{share} % Kapital
@@ -273,15 +284,19 @@ export function PkBlock({
</div>
</div>
<NumberField
label="Umwandlungssatz (%)"
help="Aus deinem PK-Ausweis. Der gesetzliche Mindestsatz von 6,8 % gilt nur für den obligatorischen Teil umhüllende Kassen liegen effektiv oft bei 5 bis 5,6 %, bei einer Frühpensionierung tiefer."
value={rd.conversionRate ?? 0}
step={0.1}
min={0}
max={20}
onChange={(v) => 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 && (
<NumberField
label="Umwandlungssatz (%)"
help="Aus deinem PK-Ausweis. Der gesetzliche Mindestsatz von 6,8 % gilt nur für den obligatorischen Teil umhüllende Kassen liegen effektiv oft bei 5 bis 5,6 %, bei einer Frühpensionierung tiefer."
value={rd.conversionRate ?? 0}
step={0.1}
min={0}
max={20}
onChange={(v) => patch(el.id, { conversionRate: v })}
/>
)}
{share > 0 && (
<NumberField
label="Kapitalbezugssteuer (%)"
@@ -309,7 +324,9 @@ export function PkBlock({
</span>
</label>
{rd.recentBuyIn && (
<p className="mt-1 flex items-start gap-2 rounded-lg border border-attention bg-attention/10 px-3 py-2 text-xs text-attention-fg">
// attention-fg ist WEISS -- gedacht fuer vollflaechigen Attention-Grund. Auf
// 10 % Deckkraft war der Text im warmen und im hellen Schema unlesbar.
<p className="mt-1 flex items-start gap-2 rounded-lg border border-attention bg-attention-soft px-3 py-2 text-xs text-attention-soft-fg">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
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({
</p>
)}
</div>
<CapitalUse rd={rd} elementId={el.id} patch={patch} targets={targets} plan={plan} />
<CapitalUse rd={rd} elementId={el.id} patch={patch} plan={plan} />
</>
)}
</Pillar>
@@ -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<RetirementDecision>) => void;
@@ -379,90 +398,143 @@ export function Pillar3aBlock({
)}
</div>
{clash && (
<p className="sm:col-span-2 flex items-start gap-2 text-xs text-attention-fg">
<p className="sm:col-span-2 flex items-start gap-2 text-xs text-attention-soft-fg">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
Ein weiteres 3a-Konto wird im selben Jahr bezogen. Die Beträge werden steuerlich zusammengezählt ein
anderes Bezugsjahr senkt die Progression.
</p>
)}
{/* 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. */}
<CapitalUse rd={rd} elementId={el.id} patch={patch} plan={plan} />
</Pillar>
);
}
// 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 (
<div className="rounded-lg border border-border p-3">
<div className="grid gap-3 sm:grid-cols-2">
<NumberField label={title} help={help} value={value} step={5} min={0} max={max} onChange={onChange} />
{value > 0 && targets.length > 0 && (
<SelectField
label={targetLabel}
value={targetValue ?? targets[0].id}
onChange={(v: string) => onTarget(v)}
options={targets.map((t) => ({ value: t.id, label: label(t) }))}
/>
)}
{value > 0 && targets.length === 0 && (
<p className="flex items-start gap-2 self-end text-xs text-attention-soft-fg">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
{missing}
</p>
)}
</div>
</div>
);
}
// 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<RetirementDecision>) => 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 (
<>
<div className="sm:col-span-2 mt-1 border-t border-border pt-3">
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-faint">
Wohin fliesst das bezogene Kapital?
</p>
<div className="grid gap-3 sm:grid-cols-2">
<NumberField
label="… in die Hypothek (%)"
help={
hasMortgage
? "Einmalige Amortisation. Am Restsaldo gekappt ist die Hypothek kleiner, bleibt der Rest Cash."
: "Es gibt keine Immobilie in diesem Szenario dieser Anteil bliebe wirkungslos auf dem Cash."
}
value={amort}
step={5}
min={0}
max={100}
onChange={(v) => patch(elementId, { capitalUseAmortizationPct: Math.max(0, Math.min(100, v)) })}
/>
<NumberField
label="… in die Anlage (%)"
help="Fliesst als Zusatzeinlage in das gewählte Vermögens-Element und wächst dort weiter."
value={invest}
step={5}
min={0}
max={100 - amort}
onChange={(v) => patch(elementId, { capitalUseInvestPct: Math.max(0, Math.min(100 - amort, v)) })}
/>
{invest > 0 && targets.length > 0 && (
<div className="sm:col-span-2">
<SelectField
label="Ziel der Anlage-Quote"
value={rd.capitalUseTargetElementId ?? targets[0].id}
onChange={(v: string) => patch(elementId, { capitalUseTargetElementId: v })}
options={targets.map((t) => ({
value: t.id,
label: `${t.name} · ${ownerLabel(plan.persons, t.ownerRole)}`,
}))}
/>
</div>
)}
{invest > 0 && targets.length === 0 && (
<p className="sm:col-span-2 flex items-start gap-2 text-xs text-attention-fg">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
Es gibt kein Element «Sonstiges Vermögen», in das die Anlage-Quote fliessen könnte. Der Betrag bliebe auf
dem Cash-Konto liegen.
</p>
)}
<p className="sm:col-span-2 text-xs text-faint">
Nicht zugeteilt: <strong className="text-fg">{cash} %</strong> bleibt auf dem Cash-Konto.
<div className="sm:col-span-2 mt-1 border-t border-border pt-3">
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-faint">
Wohin fliesst das bezogene Kapital?
</p>
<div className="flex flex-col gap-2">
<CapitalUseRow
label={label}
title="Schuldentilgung (%)"
help="Einmalige Tilgung. Am Restsaldo gekappt ist die Schuld kleiner als der Anteil, bleibt der Rest Cash."
value={amort}
max={100}
onChange={(v) => 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."
/>
<CapitalUseRow
label={label}
title="Investition (%)"
help="Fliesst als Zusatzeinlage in das gewählte Vermögens-Element und wächst dort weiter."
value={invest}
max={100 - amort}
onChange={(v) => 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. */}
<div className="rounded-lg border border-dashed border-border bg-surface-2 p-3">
<div className="flex items-baseline justify-between gap-3">
<span className="text-xs font-medium text-muted">Cash (%)</span>
<span className="text-sm font-semibold tabular-nums text-fg">{cash} %</span>
</div>
<p className="mt-1 text-[11px] text-faint">
Was du nicht zuteilst, bleibt auf dem Cash-Konto unverzinst, aber jederzeit verfügbar.
</p>
</div>
</div>
</>
</div>
);
}
+2 -2
View File
@@ -38,7 +38,7 @@ export function ReviewTile({
{done ? (
<CheckCircle2 className="h-4 w-4 text-success" />
) : (
<ClipboardList className="h-4 w-4 text-attention-fg" />
<ClipboardList className="h-4 w-4 text-attention" />
)}
<span className="text-xs font-semibold uppercase tracking-wide text-fg">Offene Punkte</span>
{!starting && (
@@ -99,7 +99,7 @@ export function ReviewTile({
>
<span className="flex items-baseline gap-1.5">
<span className="min-w-0 flex-1 truncate text-xs font-semibold text-fg">{g.title}</span>
<span className="shrink-0 text-[11px] font-semibold text-attention-fg">{g.open}</span>
<span className="shrink-0 text-[11px] font-semibold text-attention">{g.open}</span>
</span>
<span className="block truncate text-[11px] text-muted">{g.reasons.join(" · ")}</span>
</button>
+24 -7
View File
@@ -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);
+92
View File
@@ -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);
});
});
+5
View File
@@ -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(),
})