Pensionsalter anpassen (Roadmap 44)
Deploy App / deploy (push) Successful in 1m1s

Das Pensionsalter laesst sich neu veraendern: Nicht das Alter wird gesetzt,
sondern die Phasengrenze verschoben -- Vorphase laenger, Folgephase kuerzer,
Gesamtdauer gleich. Faellt eine Phase dabei weg, werden die beiden Uebergaenge
nach Bestaetigung zusammengelegt.

- neues reines Modul lib/retirement.ts + POST /api/scenarios/<id>/retirement
- Pensionsalter als Tornado-Treiber und Live-Simulations-Regler
- AHV-Referenzalter 65: Rente ab 65 unabhaengig vom Pensionsalter; vor 65
  Beitrag als Nichterwerbstaetige(r) (neues Feld ahvContribution).
  AHV wird dafuer jahresweise statt phasenweise gerechnet.
- Punkt B: personenzugeordnetes Einkommen faellt bei Pensionierung auf 0
- Punkt A: Wiederkehr-Parameter werden live aus der Vorphase geerbt,
  sichtbar als "Aus Vorphase uebernehmen"
- Punkt C: Kapitalzufluss am Pensions-Uebergang per Quote auf Amortisation,
  Anlage und Cash verteilbar
- Fix: carry.flowBasis wurde vor der Jahresschleife berechnet, effektive
  Werte kamen deshalb nie in der Folgephase an

SPEZIFIKATION 0.26 (neue Kapitel 3.12, 4.4.7, 4.16). 221 -> 261 Tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 14:24:40 +02:00
parent c429624635
commit d6855ef1a0
20 changed files with 2057 additions and 184 deletions
+43 -1
View File
@@ -50,6 +50,7 @@ import {
} from "@/components/ElementDetail";
import { PhaseDetail } from "@/components/PhaseDetail";
import { PlanProfileFields, type ProfileDraft } from "@/components/PlanProfileFields";
import { RetirementAdjuster } from "@/components/RetirementAdjuster";
import { MoneyField } from "@/components/FormField";
import { api } from "@/lib/api-client";
import { formatChf } from "@/lib/format";
@@ -58,6 +59,7 @@ import {
CATEGORY_LABELS,
CATEGORY_ORDER,
PERSON_ONLY_CATEGORIES,
inheritedPhaseValues,
num,
type CashTransitionData,
type ElementCategory,
@@ -256,6 +258,16 @@ export function PlanView({
return computed.phases.length - phase.sequenceNumber;
}
// Phasen in ihrer Reihenfolge -- Grundlage der Vererbung (Roadmap Nr. 44, Punkt A).
const orderedPhaseIds = computed.phases.map((p) => p.id);
// Alter des Element-Besitzers im ersten Jahr einer Phase. Ohne Personenzuordnung gilt
// Person A, weil die Zeitachse ohnehin an ihr hängt.
function ownerAgeAtPhaseStart(phase: PhaseComputed, element: ElementInput): number {
const role = element.ownerRole && element.ownerRole !== "HOUSEHOLD" ? element.ownerRole : "PERSON_A";
return phase.persons.find((p) => p.role === role)?.startAge ?? 0;
}
// Baut den Kontext für eine Phasenzelle.
function buildPhaseContext(phase: PhaseComputed, element: ElementInput): CellContext {
const ce = computedElement(phase.id, element.id);
@@ -280,6 +292,8 @@ export function PlanView({
deflatorStart: phase.cumulativeInflationStart,
laterPhaseCount: laterPhaseCount(phase),
ahvCareer: careerFor(element),
inheritedValues: inheritedPhaseValues(element.phaseValues, orderedPhaseIds, phase.id),
ownerAgeStart: ownerAgeAtPhaseStart(phase, element),
};
}
@@ -302,6 +316,8 @@ export function PlanView({
deflatorStart: fromPhase.cumulativeInflationStart,
laterPhaseCount: laterPhaseCount(fromPhase),
ahvCareer: careerFor(element),
inheritedValues: {},
ownerAgeStart: ownerAgeAtPhaseStart(fromPhase, element),
};
}
@@ -1468,6 +1484,9 @@ function AddElementDialog({
deflatorStart: firstPhase.cumulativeInflationStart,
laterPhaseCount: 0, // beim Anlegen bewusst kein Warnhinweis
ahvCareer: null,
// Beim Anlegen gibt es keine Vorphase, aus der etwas zu erben waere.
inheritedValues: {},
ownerAgeStart: firstPhase.persons.find((p) => p.role === (owner ?? "PERSON_A"))?.startAge ?? 0,
};
async function create() {
@@ -1615,6 +1634,14 @@ function AddPhaseDialog({
);
}
// Ziele für die Anlage-Quote der Kapitalverwendung (Roadmap Nr. 44, Punkt C): alle
// Vermögens-Elemente, die in der Folgephase noch aktiv sind.
function investTargetsOf(toPhase: PhaseComputed | undefined): { id: string; name: string }[] {
return (toPhase?.elements ?? [])
.filter((e) => e.category === "OTHER_ASSET" && e.status === "ACTIVE")
.map((e) => ({ id: e.elementId, name: e.name }));
}
// --- Panel: Szenario-Profil (Grundprofil bearbeiten) ---
function ProfilePanel({ plan, onClose, onSaved }: { plan: PlanInput; onClose: () => void; onSaved: () => void }) {
const [draft, setDraft] = useState<ProfileDraft>({
@@ -1642,12 +1669,21 @@ function ProfilePanel({ plan, onClose, onSaved }: { plan: PlanInput; onClose: ()
return (
<InspectorShell title="Szenario-Profil" subtitle="Haushalt, Personen, Planstart und Inflation dieses Szenarios" onClose={onClose}>
<div className="flex flex-col gap-3">
<PlanProfileFields draft={draft} onChange={setDraft} />
<PlanProfileFields draft={draft} onChange={setDraft} lockRetirement={plan.phases.length > 0} />
{error && <p className="text-sm text-danger">{error}</p>}
<div className="flex gap-2 pt-1">
<Button disabled={saving} onClick={save}>{saving ? "…" : "Speichern"}</Button>
<Button variant="secondary" onClick={onClose}>Abbrechen</Button>
</div>
{plan.phases.length > 0 && (
<div className="mt-3 border-t border-border pt-3">
<h3 className="mb-2 text-sm font-semibold text-fg">Pensionsalter anpassen</h3>
{/* Wirkt SOFORT (eigener Endpunkt), weil dabei Phasendauern verschoben werden --
das lässt sich nicht sinnvoll mit dem Profil-Formular zusammen abspeichern. */}
<RetirementAdjuster plan={plan} onSaved={onSaved} />
</div>
)}
</div>
</InspectorShell>
);
@@ -1723,6 +1759,9 @@ function TransitionReviewDialog({
ct={ct}
setC={(patch) => setCt((prev) => ({ ...prev, ...patch }))}
deflatorEnd={fromPhase.cumulativeInflationEnd}
showCapitalUse={(toPhase?.capitalInflow ?? 0) > 0}
capitalInflow={toPhase?.capitalInflow ?? 0}
investTargets={investTargetsOf(toPhase)}
/>
</div>
</div>
@@ -1814,6 +1853,9 @@ function CashTransitionPanel({
ct={ct}
setC={(patch) => setCt((prev) => ({ ...prev, ...patch }))}
deflatorEnd={fromPhase.cumulativeInflationEnd}
showCapitalUse={(toPhase?.capitalInflow ?? 0) > 0}
capitalInflow={toPhase?.capitalInflow ?? 0}
investTargets={investTargetsOf(toPhase)}
/>
</div>
{error && <p className="mt-3 text-sm text-danger">{error}</p>}