Einmalige Sonderein-/ausgaben am Cash-Uebergang (Roadmap Nr. 1)
Deploy App / deploy (push) Successful in 1m51s

Der Cash-Uebergang zwischen zwei Phasen ist neu ein eigener Entscheid:
1:1 uebernehmen / einmaliger Zufluss / einmalige Kosten / beides. Die Betraege
gehen direkt aufs Cash-Konto und bleiben aus der Spar-/Verzehrquote heraus.

- Zufluss NOMINAL erfasst, real angezeigt (wie Einkommen), optionaler Steuersatz
  (Default 0 %). Kosten REAL erfasst, nominal angezeigt (wie Ausgaben).
  Umrechnung ueber den Bestands-Deflator an der Phasengrenze.
- Entscheid startet unbeantwortet und zaehlt im "offen"-Badge mit; eine neue Phase
  erzeugt damit automatisch einen offenen Cash-Entscheid am neuen Uebergang.
- Eigene Kopf-Kennzahlen statt Vermischung mit Kapitalzufluss/-investitionen:
  eine Erbschaft ist kein Verkaufserloes, ein Poolbau keine Investition.
- Cash ist kein FinancialElement -> der Entscheid haengt als JSON an der Von-Phase
  (neue Spalte Phase.cashTransition + Migration). Szenario-Kopie nimmt ihn mit.

Fuenf Regressionstests ergaenzt (13 -> 18). Spezifikation auf v0.3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 08:14:44 +02:00
parent 87e6a5f564
commit 9f5bd754eb
12 changed files with 665 additions and 30 deletions
+149 -2
View File
@@ -2,7 +2,7 @@
import { useState } from "react";
import { Trash2 } from "lucide-react";
import { FieldLabel, MoneyField, NumberField, SelectField } from "@/components/FormField";
import { FieldLabel, MoneyField, NumberField, SelectField, TextField } from "@/components/FormField";
import { formatChf } from "@/lib/format";
import { api } from "@/lib/api-client";
import { CATEGORY_LABELS, num } from "@/lib/elements";
@@ -12,7 +12,13 @@ import {
DEFAULT_PROPERTY_GAINS_TAX_RATE,
PILLAR_3A_MAX_ANNUAL,
} from "@/lib/constants";
import type { ElementCategory, PhaseData, TransitionData } from "@/lib/elements";
import type {
CashTransitionData,
CashTransitionMode,
ElementCategory,
PhaseData,
TransitionData,
} from "@/lib/elements";
export interface CellContext {
kind: "phase" | "transition";
@@ -121,6 +127,147 @@ export function withTransitionDefaults(category: ElementCategory, isRetirement:
return out;
}
// --- Cash-Uebergang: einmalige Sonderein-/ausgaben ---
export function withCashTransitionDefaults(ct: CashTransitionData): CashTransitionData {
return ct.mode === undefined ? { ...ct, mode: "NONE" } : { ...ct };
}
export function isCashTransitionAnswered(ct: CashTransitionData): boolean {
return ct.mode !== undefined;
}
// Kurzfassung fuer die Uebergangszelle der Cash-Zeile.
export function cashTransitionSummary(ct: CashTransitionData): string {
const mode = ct.mode;
if (mode === undefined) return "?";
const inn = `+${formatChf(num(ct.inflowAmount))}`;
const out = `${formatChf(num(ct.outflowAmount))}`;
switch (mode) {
case "INFLOW":
return inn;
case "OUTFLOW":
return out;
case "BOTH":
return `${inn} / ${out}`;
default:
return "1:1";
}
}
// Eingabefelder fuer den Cash-Entscheid. Erfassungs-Konventionen bewusst wie bei den
// laufenden Flows: Zufluss nominal (wie Einkommen), Kosten real (wie Ausgaben).
export function CashTransitionFields({
ct,
setC,
deflatorEnd,
}: {
ct: CashTransitionData;
setC: (patch: Partial<CashTransitionData>) => void;
deflatorEnd: number; // Bestands-Deflator an der Phasengrenze
}) {
const mode = ct.mode ?? "NONE";
const showIn = mode === "INFLOW" || mode === "BOTH";
const showOut = mode === "OUTFLOW" || mode === "BOTH";
const d = deflatorEnd || 1;
const inflowGross = num(ct.inflowAmount);
const taxRate = num(ct.inflowTaxRate, 0);
const inflowNet = Math.round(inflowGross * (1 - taxRate / 100));
return (
<>
<div className="sm:col-span-2">
<SelectField
label="Entscheid fuer das Cash-Konto"
help="Was passiert beim Uebergang in die naechste Lebensphase mit dem Cash-Bestand?"
value={mode}
onChange={(v: CashTransitionMode) =>
setC(
v === "NONE"
? { mode: v, inflowAmount: 0, outflowAmount: 0 }
: v === "INFLOW"
? { mode: v, outflowAmount: 0 }
: v === "OUTFLOW"
? { mode: v, inflowAmount: 0 }
: { mode: v }
)
}
options={[
{ value: "NONE", label: "1:1 uebernehmen" },
{ value: "INFLOW", label: "Einmaliger Zufluss" },
{ value: "OUTFLOW", label: "Einmalige Kosten" },
{ value: "BOTH", label: "Zufluss und Kosten" },
]}
/>
</div>
{showIn && (
<>
<div className="sm:col-span-2 rounded-lg bg-surface-2 px-3 py-2 text-xs text-muted">
<strong className="text-fg">Einmaliger Zufluss</strong> (z. B. Erbschaft). Wird NOMINAL erfasst
der Betrag, der zu diesem Zeitpunkt tatsaechlich aufs Konto kommt.
</div>
<TextField
label="Bezeichnung"
value={ct.inflowLabel ?? ""}
placeholder="z. B. Erbschaft"
onChange={(v) => setC({ inflowLabel: v })}
/>
<MoneyField
label="Betrag NOMINAL (CHF)"
value={inflowGross}
onChange={(v) => setC({ inflowAmount: v })}
/>
<NumberField
label="Steuer (%)"
help="Z. B. Erbschafts-/Schenkungssteuer. Direkte Nachkommen sind in den meisten Kantonen befreit Default 0 %."
step={0.5}
value={taxRate}
onChange={(v) => setC({ inflowTaxRate: v })}
/>
<DerivedField
label="≈ real (heutige Kaufkraft)"
value={Math.round(inflowNet / d)}
help="Netto-Zufluss nach Steuer, zurueckgerechnet auf die Kaufkraft bei Planbeginn. Nur zur Info."
/>
{taxRate > 0 && (
<DerivedField
label="Netto aufs Cash (nominal)"
value={inflowNet}
help="Betrag abzueglich Steuer. Wird automatisch berechnet."
/>
)}
</>
)}
{showOut && (
<>
<div className="sm:col-span-2 rounded-lg bg-surface-2 px-3 py-2 text-xs text-muted">
<strong className="text-fg">Einmalige Kosten</strong> (z. B. Poolbau). Werden REAL erfasst
in heutiger Kaufkraft. Die Inflation rechnet daraus automatisch den nominalen Betrag.
</div>
<TextField
label="Bezeichnung"
value={ct.outflowLabel ?? ""}
placeholder="z. B. Poolbau"
onChange={(v) => setC({ outflowLabel: v })}
/>
<MoneyField
label="Betrag REAL (CHF, heutige Kaufkraft)"
value={num(ct.outflowAmount)}
onChange={(v) => setC({ outflowAmount: v })}
/>
<DerivedField
label="≈ nominal (zum Zeitpunkt)"
value={Math.round(num(ct.outflowAmount) * d)}
help="Der Betrag, der zu diesem Zeitpunkt tatsaechlich faellig ist. Nur zur Info."
/>
</>
)}
</>
);
}
// "Beantwortet" = ein konkreter Entscheid liegt vor (kein offenes Fragezeichen).
export function isTransitionAnswered(category: ElementCategory, isRetirement: boolean, td: TransitionData): boolean {
switch (category) {
+144 -5
View File
@@ -20,10 +20,14 @@ import {
} from "lucide-react";
import { Timeline } from "@/components/Timeline";
import {
CashTransitionFields,
ElementDetail,
ElementPhaseFields,
ElementTransitionFields,
cashTransitionSummary,
isCashTransitionAnswered,
isTransitionAnswered,
withCashTransitionDefaults,
withTransitionDefaults,
type CellContext,
} from "@/components/ElementDetail";
@@ -37,6 +41,7 @@ import {
CATEGORY_ORDER,
PERSON_ONLY_CATEGORIES,
num,
type CashTransitionData,
type ElementCategory,
type PhaseData,
type TransitionData,
@@ -96,6 +101,8 @@ export function PlanView({
const [editTransition, setEditTransition] = useState<{ elementId: string; fromPhaseId: string } | null>(null);
const [editPhaseCell, setEditPhaseCell] = useState<{ elementId: string; phaseId: string } | null>(null);
const [showCashInit, setShowCashInit] = useState(false);
// fromPhaseId des Cash-Uebergangs, der gerade bearbeitet wird.
const [editCashTransition, setEditCashTransition] = useState<string | null>(null);
const [valueMode, setValueMode] = useState<ValueMode>("nominal");
useEffect(() => {
@@ -205,9 +212,14 @@ export function PlanView({
return false;
}
function cashTransitionFor(phaseId: string): CashTransitionData {
return plan.phases.find((p) => p.id === phaseId)?.cashTransition ?? {};
}
// Anzahl offener (noch nicht getroffener) Uebergangs-Entscheide an einer Grenze.
// Der Cash-Entscheid (einmalige Sonderein-/ausgaben) zaehlt mit.
function transitionOpenCount(fromPhase: PhaseComputed, toPhase: PhaseComputed): number {
let n = 0;
let n = isCashTransitionAnswered(cashTransitionFor(fromPhase.id)) ? 0 : 1;
for (const el of plan.elements) {
if (!TRANSITION_CATEGORIES.includes(el.category)) continue;
if (transitionInactive(el, fromPhase)) continue;
@@ -379,9 +391,23 @@ export function PlanView({
</span>
</td>
) : (
<td key={`cash-t-${col.fromPhase.id}`} className="border-b border-r border-border px-2 py-1.5 text-center text-[11px] text-faint">
</td>
(() => {
// Uebergangszelle der Cash-Zeile: einmalige Sonderein-/ausgaben.
const ct = cashTransitionFor(col.fromPhase.id);
const open = !isCashTransitionAnswered(ct);
return (
<td
key={`cash-t-${col.fromPhase.id}`}
onClick={() => setEditCashTransition(col.fromPhase.id)}
title="Einmalige Sonderein-/ausgaben"
className={`cursor-pointer border-b border-r border-border px-2 py-1.5 text-center text-[11px] ${
open ? "bg-accent font-semibold text-accent-fg" : "bg-accent-soft/40 text-accent"
}`}
>
{cashTransitionSummary(ct)}
</td>
);
})()
);
})}
</tr>
@@ -520,6 +546,26 @@ export function PlanView({
/>
)}
{editCashTransition && (() => {
const fromPhase = computed.phases.find((p) => p.id === editCashTransition);
if (!fromPhase) return null;
const toIndex = computed.phases.findIndex((p) => p.id === fromPhase.id) + 1;
const toPhase = computed.phases[toIndex];
return (
<CashTransitionDialog
key={`ct-${editCashTransition}`}
fromPhase={fromPhase}
toPhase={toPhase}
initial={cashTransitionFor(fromPhase.id)}
onClose={() => setEditCashTransition(null)}
onSaved={() => {
setEditCashTransition(null);
onChanged();
}}
/>
);
})()}
{reviewFromPhaseId && (() => {
const fromPhase = computed.phases.find((p) => p.id === reviewFromPhaseId);
if (!fromPhase) return null;
@@ -531,6 +577,7 @@ export function PlanView({
fromPhase={fromPhase}
toPhase={toPhase}
elements={els}
initialCash={cashTransitionFor(fromPhase.id)}
buildContext={(el) => buildTransitionContext(fromPhase, toPhase, el)}
isRetirement={(el) => (toPhase ? isRetirementTransition(el, fromPhase, toPhase) : false)}
onClose={() => setReviewFromPhaseId(null)}
@@ -753,6 +800,22 @@ function PhaseHeader({
</>
)}
{(phase.oneOffInflow > 0 || phase.oneOffOutflow > 0) && (
<>
<div className="my-1 border-t border-border" />
{phase.oneOffInflow > 0 && (
<div className="text-success">
+ {phase.oneOffInflowLabel ?? "Einmaliger Zufluss"} {valStr(phase.oneOffInflow, dS, mode)}
</div>
)}
{phase.oneOffOutflow > 0 && (
<div className="text-danger">
{phase.oneOffOutflowLabel ?? "Einmalige Kosten"} {valStr(phase.oneOffOutflow, dS, mode)}
</div>
)}
</>
)}
<div className="my-1 border-t border-border" />
<div>
Vermoegen {valStr(phase.startWealthNominal, dS, mode)} {valStr(phase.endWealthNominal, dE, mode)}
@@ -1034,6 +1097,7 @@ function TransitionReviewDialog({
fromPhase,
toPhase,
elements,
initialCash,
buildContext,
isRetirement,
onClose,
@@ -1042,6 +1106,7 @@ function TransitionReviewDialog({
fromPhase: PhaseComputed;
toPhase: PhaseComputed | undefined;
elements: ElementInput[];
initialCash: CashTransitionData;
buildContext: (el: ElementInput) => CellContext;
isRetirement: (el: ElementInput) => boolean;
onClose: () => void;
@@ -1052,6 +1117,7 @@ function TransitionReviewDialog({
elements.map((e) => [e.id, withTransitionDefaults(e.category, isRetirement(e), e.transitionValues[fromPhase.id] ?? {})])
)
);
const [ct, setCt] = useState<CashTransitionData>(() => withCashTransitionDefaults(initialCash));
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -1059,6 +1125,7 @@ function TransitionReviewDialog({
setSaving(true);
setError(null);
try {
await api.put(`/api/phases/${fromPhase.id}/cash-transition`, ct);
for (const e of elements) {
await api.put(`/api/elements/${e.id}/transition/${fromPhase.id}`, tds[e.id] ?? {});
}
@@ -1077,9 +1144,31 @@ function TransitionReviewDialog({
Bezug). Danach werden gehaltene Werte automatisch in die nächste Phase fortgeschrieben.
</p>
<div className="flex flex-col gap-3">
{/* Cash zuerst: einmalige Sonderein-/ausgaben betreffen jeden Uebergang. */}
<div className="rounded-xl border border-border bg-surface-2 p-3">
<div className="mb-2 flex items-center gap-2">
<span className="text-accent">
<Wallet className="h-4 w-4" />
</span>
<span className="text-sm font-semibold text-fg">Cash</span>
<span className="text-xs text-faint">Einmalige Sonderein-/ausgaben</span>
</div>
<p className="mb-2 text-xs text-accent-soft-fg">
Einmalige Ereignisse wie Erbschaft, Autokauf oder Poolbau werden hier direkt dem Cash-Konto
gutgeschrieben bzw. belastet.
</p>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<CashTransitionFields
ct={ct}
setC={(patch) => setCt((prev) => ({ ...prev, ...patch }))}
deflatorEnd={fromPhase.cumulativeInflationEnd}
/>
</div>
</div>
{elements.length === 0 && (
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-3 text-sm text-muted">
An diesem Übergang gibt es keine zu entscheidenden Positionen.
An diesem Übergang gibt es sonst keine zu entscheidenden Positionen.
</p>
)}
{elements.map((el) => {
@@ -1119,6 +1208,56 @@ function TransitionReviewDialog({
);
}
// --- Dialog: Cash-Uebergang (einmalige Sonderein-/ausgaben) ---
function CashTransitionDialog({
fromPhase,
toPhase,
initial,
onClose,
onSaved,
}: {
fromPhase: PhaseComputed;
toPhase: PhaseComputed | undefined;
initial: CashTransitionData;
onClose: () => void;
onSaved: () => void;
}) {
// Vorbelegung, damit ein blosses "Speichern" den sichtbaren Default (1:1) persistiert.
const [ct, setCt] = useState<CashTransitionData>(() => withCashTransitionDefaults(initial));
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
async function save() {
setSaving(true);
setError(null);
try {
await api.put(`/api/phases/${fromPhase.id}/cash-transition`, ct);
onSaved();
} catch (e) {
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
} finally {
setSaving(false);
}
}
return (
<DialogShell title="Uebergang: Cash" onClose={onClose} wide>
<div className="text-xs text-muted">
Einmalige Sonderein-/ausgaben · {fromPhase.name} {toPhase?.name ?? "Ende"}
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<CashTransitionFields
ct={ct}
setC={(patch) => setCt((prev) => ({ ...prev, ...patch }))}
deflatorEnd={fromPhase.cumulativeInflationEnd}
/>
</div>
{error && <p className="text-sm text-danger">{error}</p>}
<DialogActions saving={saving} onConfirm={save} onClose={onClose} confirmLabel="Speichern" />
</DialogShell>
);
}
// --- Dialog: Cash-Anfangswert (erste Lebensphase) ---
function CashInitialDialog({ plan, onClose, onSaved }: { plan: PlanInput; onClose: () => void; onSaved: () => void }) {
const [value, setValue] = useState(plan.initialCash);