Memo 2026081701: Kapitalverwendung mit waehlbarem Tilgungsziel, AHV automatisch, Farb- und Textkorrekturen
Deploy App / deploy (push) Successful in 1m18s
Deploy App / deploy (push) Successful in 1m18s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 "Vollständig bezogen".
|
||||
</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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user