Einmalige Sonderein-/ausgaben am Cash-Uebergang (Roadmap Nr. 1)
Deploy App / deploy (push) Successful in 1m51s
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:
@@ -0,0 +1,31 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getOwnedPhase } from "@/lib/queries";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
import { cashTransitionSchema } from "@/lib/elements";
|
||||
|
||||
// Speichert den Cash-Entscheid beim UEBERGANG nach dieser Phase: 1:1 uebernehmen oder
|
||||
// einmaliger Zufluss / einmalige Kosten (Roadmap Nr. 1). Cash ist kein FinancialElement,
|
||||
// deshalb liegt der Entscheid direkt an der Von-Phase.
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ phaseId: string }> }
|
||||
) {
|
||||
const userId = await getCurrentUserId();
|
||||
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||
const { phaseId } = await params;
|
||||
|
||||
const phase = await getOwnedPhase(phaseId, userId);
|
||||
if (!phase) return NextResponse.json({ error: "Phase nicht gefunden." }, { status: 404 });
|
||||
|
||||
const body = await request.json();
|
||||
const parsed = cashTransitionSchema.safeParse(body);
|
||||
if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 });
|
||||
|
||||
await prisma.phase.update({
|
||||
where: { id: phase.id },
|
||||
data: { cashTransition: parsed.data },
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -58,6 +58,8 @@ export async function POST(
|
||||
sequenceNumber: phase.sequenceNumber,
|
||||
name: phase.name,
|
||||
durationYears: phase.durationYears,
|
||||
// Cash-Entscheid (einmalige Sonderein-/ausgaben) mitkopieren.
|
||||
cashTransition: phase.cashTransition ?? undefined,
|
||||
},
|
||||
});
|
||||
phaseIdMap.set(phase.id, created.id);
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { computePlan } from "@/lib/calculations";
|
||||
import type { ElementCategory, PhaseData, TransitionData } from "@/lib/elements";
|
||||
import type { CashTransitionData, ElementCategory, PhaseData, TransitionData } from "@/lib/elements";
|
||||
import type { PlanInput } from "@/lib/types";
|
||||
|
||||
// --- kleine Bau-Helfer ---
|
||||
@@ -21,7 +21,7 @@ function plan(opts: {
|
||||
retirementAge: number;
|
||||
inflation?: number;
|
||||
initialCash?: number;
|
||||
phases: { id: string; durationYears: number }[];
|
||||
phases: { id: string; durationYears: number; cashTransition?: CashTransitionData }[];
|
||||
elements: ReturnType<typeof el>[];
|
||||
household?: "SINGLE" | "COUPLE";
|
||||
}): PlanInput {
|
||||
@@ -32,7 +32,13 @@ function plan(opts: {
|
||||
inflationRateDefault: opts.inflation ?? 2,
|
||||
initialCash: opts.initialCash ?? 0,
|
||||
persons: [{ id: "A", role: "PERSON_A", name: null, age: opts.age, retirementAge: opts.retirementAge }],
|
||||
phases: opts.phases.map((p, i) => ({ id: p.id, sequenceNumber: i + 1, name: p.id, durationYears: p.durationYears })),
|
||||
phases: opts.phases.map((p, i) => ({
|
||||
id: p.id,
|
||||
sequenceNumber: i + 1,
|
||||
name: p.id,
|
||||
durationYears: p.durationYears,
|
||||
cashTransition: p.cashTransition ?? {},
|
||||
})),
|
||||
elements: opts.elements,
|
||||
};
|
||||
}
|
||||
@@ -251,6 +257,95 @@ describe("V5 Golden Tests", () => {
|
||||
expect(pk.startValue).toBe(200000); // brutto 100'000 dem Kapital entnommen
|
||||
});
|
||||
|
||||
it("Einmaliger Zufluss am Uebergang: nominal erfasst, Steuer abgezogen, direkt ins Cash", () => {
|
||||
const p = plan({
|
||||
age: 40,
|
||||
retirementAge: 70,
|
||||
inflation: 0,
|
||||
initialCash: 1000,
|
||||
phases: [
|
||||
{ id: "p1", durationYears: 2, cashTransition: { mode: "INFLOW", inflowLabel: "Erbschaft", inflowAmount: 100000, inflowTaxRate: 10 } },
|
||||
{ id: "p2", durationYears: 1 },
|
||||
],
|
||||
elements: [],
|
||||
});
|
||||
const r = computePlan(p);
|
||||
expect(r.phases[1].oneOffInflow).toBe(90000); // 100'000 abzueglich 10% Steuer
|
||||
expect(r.phases[1].oneOffInflowLabel).toBe("Erbschaft");
|
||||
expect(r.phases[1].cashStart).toBe(91000); // 1'000 + 90'000
|
||||
expect(r.phases[0].oneOffInflow).toBe(0); // Phase 1 hat keinen eingehenden Uebergang
|
||||
});
|
||||
|
||||
it("Einmalige Kosten am Uebergang: real erfasst, mit Inflation aufgewertet", () => {
|
||||
// Kosten 20'000 real, 2% Inflation, Grenze nach 10 Jahren -> 20'000 x 1.02^10 = 24'380.
|
||||
const p = plan({
|
||||
age: 40,
|
||||
retirementAge: 70,
|
||||
inflation: 2,
|
||||
initialCash: 100000,
|
||||
phases: [
|
||||
{ id: "p1", durationYears: 10, cashTransition: { mode: "OUTFLOW", outflowLabel: "Poolbau", outflowAmount: 20000 } },
|
||||
{ id: "p2", durationYears: 1 },
|
||||
],
|
||||
elements: [],
|
||||
});
|
||||
const r = computePlan(p);
|
||||
const erwartet = Math.round(20000 * Math.pow(1.02, 10));
|
||||
expect(r.phases[1].oneOffOutflow).toBe(erwartet);
|
||||
expect(r.phases[1].oneOffOutflowLabel).toBe("Poolbau");
|
||||
expect(r.phases[1].cashStart).toBe(100000 - erwartet);
|
||||
});
|
||||
|
||||
it("Zufluss und Kosten zusammen (BOTH); Modus NONE bleibt wirkungslos", () => {
|
||||
const beide = plan({
|
||||
age: 40, retirementAge: 70, inflation: 0, initialCash: 0,
|
||||
phases: [
|
||||
{ id: "p1", durationYears: 1, cashTransition: { mode: "BOTH", inflowAmount: 50000, outflowAmount: 20000 } },
|
||||
{ id: "p2", durationYears: 1 },
|
||||
],
|
||||
elements: [],
|
||||
});
|
||||
const r1 = computePlan(beide);
|
||||
expect(r1.phases[1].cashStart).toBe(30000); // +50'000 -20'000
|
||||
|
||||
// Betraege sind erfasst, aber der Entscheid lautet "1:1 uebernehmen" -> keine Wirkung.
|
||||
const keine = plan({
|
||||
age: 40, retirementAge: 70, inflation: 0, initialCash: 0,
|
||||
phases: [
|
||||
{ id: "p1", durationYears: 1, cashTransition: { mode: "NONE", inflowAmount: 50000, outflowAmount: 20000 } },
|
||||
{ id: "p2", durationYears: 1 },
|
||||
],
|
||||
elements: [],
|
||||
});
|
||||
expect(computePlan(keine).phases[1].cashStart).toBe(0);
|
||||
});
|
||||
|
||||
it("Einmalige Kosten koennen eine Liquiditaetsluecke ausloesen", () => {
|
||||
const p = plan({
|
||||
age: 40, retirementAge: 70, inflation: 0, initialCash: 10000,
|
||||
phases: [
|
||||
{ id: "p1", durationYears: 1, cashTransition: { mode: "OUTFLOW", outflowAmount: 25000 } },
|
||||
{ id: "p2", durationYears: 1 },
|
||||
],
|
||||
elements: [],
|
||||
});
|
||||
const p2 = computePlan(p).phases[1];
|
||||
expect(p2.cashStart).toBe(-15000);
|
||||
expect(p2.cashNegative).toBe(true);
|
||||
expect(p2.incomplete).toBe(true);
|
||||
});
|
||||
|
||||
it("Cash-Entscheid der LETZTEN Phase bleibt wirkungslos (kein Uebergang mehr)", () => {
|
||||
const p = plan({
|
||||
age: 40, retirementAge: 70, inflation: 0, initialCash: 5000,
|
||||
phases: [{ id: "p1", durationYears: 1, cashTransition: { mode: "INFLOW", inflowAmount: 999999 } }],
|
||||
elements: [],
|
||||
});
|
||||
const r = computePlan(p);
|
||||
expect(r.phases[0].cashEnd).toBe(5000);
|
||||
expect(r.nachlass).toBe(5000);
|
||||
});
|
||||
|
||||
it("Fortschreibung: nominaler Einkommens-Basiswert Phase 1 -> Startwert Phase 2; Cash laeuft fort", () => {
|
||||
const p = plan({
|
||||
age: 40,
|
||||
|
||||
+44
-1
@@ -61,6 +61,13 @@ export interface PhaseComputed {
|
||||
plannedWithdrawRate: number; // geplante Verzehrrate: Bezugsraten aus Sonstigem Vermoegen
|
||||
capitalInflow: number; // Kapitalzufluss: PK-/3a-Bezuege + Verkaeufe (aus dem Uebergang in diese Phase)
|
||||
capitalInvest: number; // Kapitalinvestitionen: Zusatz-/Neuinvestitionen + sofortige Tilgungen
|
||||
// Einmalige Sonderein-/ausgaben aus dem Uebergang in DIESE Phase (nominal, netto nach Steuer).
|
||||
// Bewusst getrennt von capitalInflow/capitalInvest: eine Erbschaft ist kein Verkaufserloes,
|
||||
// ein Poolbau keine Kapitalinvestition.
|
||||
oneOffInflow: number;
|
||||
oneOffInflowLabel: string | null;
|
||||
oneOffOutflow: number;
|
||||
oneOffOutflowLabel: string | null;
|
||||
cashStart: number;
|
||||
cashEnd: number;
|
||||
cashNegative: boolean; // Cash faellt in dieser Phase (irgendwann) unter 0 -> Liquiditaetsluecke
|
||||
@@ -147,6 +154,10 @@ export function computePlan(plan: PlanInput): PlanComputed {
|
||||
// Aus dem Uebergang der Vorphase in DIESE Phase fliessende Groessen (Kopf-Kennzahlen).
|
||||
let incomingInflow = 0; // Brutto-Zufluss: Verkaeufe + PK-/3a-Bezuege
|
||||
let incomingImmediateRepay = 0; // sofortige Schuldentilgungen (Abfluss)
|
||||
let incomingOneOffInflow = 0; // einmaliger Sonderzufluss (netto nach Steuer)
|
||||
let incomingOneOffInflowLabel: string | null = null;
|
||||
let incomingOneOffOutflow = 0; // einmalige Sonderkosten (nominal)
|
||||
let incomingOneOffOutflowLabel: string | null = null;
|
||||
let ruinAge: number | null = null;
|
||||
|
||||
for (let i = 0; i < phases.length; i++) {
|
||||
@@ -500,6 +511,10 @@ export function computePlan(plan: PlanInput): PlanComputed {
|
||||
plannedWithdrawRate: plannedWithdrawTotal,
|
||||
capitalInflow: Math.round(incomingInflow),
|
||||
capitalInvest: Math.round(investmentsFromCash + incomingImmediateRepay),
|
||||
oneOffInflow: Math.round(incomingOneOffInflow),
|
||||
oneOffInflowLabel: incomingOneOffInflowLabel,
|
||||
oneOffOutflow: Math.round(incomingOneOffOutflow),
|
||||
oneOffOutflowLabel: incomingOneOffOutflowLabel,
|
||||
cashStart: Math.round(cashStart),
|
||||
cashEnd,
|
||||
cashNegative,
|
||||
@@ -516,6 +531,30 @@ export function computePlan(plan: PlanInput): PlanComputed {
|
||||
// --- Uebergang: Carry aktualisieren, Cash der Folgephase bilden ---
|
||||
let txInflow = 0;
|
||||
let txImmediateRepay = 0;
|
||||
|
||||
// Einmalige Sonderein-/ausgaben auf dem Cash-Konto. Nur sinnvoll, wenn eine Folgephase
|
||||
// existiert -- nach der letzten Phase gibt es keinen Uebergang. Der Wechselkurs zwischen
|
||||
// real und nominal ist an dieser Grenze `cumulativeInflation` (Bestands-Deflator am
|
||||
// Phasenende), denn Cash ist ein Bestand.
|
||||
let txOneOffInflow = 0;
|
||||
let txOneOffOutflow = 0;
|
||||
let txOneOffInflowLabel: string | null = null;
|
||||
let txOneOffOutflowLabel: string | null = null;
|
||||
if (nextPhase) {
|
||||
const ct = phase.cashTransition ?? {};
|
||||
const mode = ct.mode ?? "NONE";
|
||||
if (mode === "INFLOW" || mode === "BOTH") {
|
||||
// Zufluss ist NOMINAL erfasst (wie Einkommen); Steuer mindert den Netto-Zufluss.
|
||||
const gross = Math.round(num(ct.inflowAmount));
|
||||
txOneOffInflow = Math.round(gross * (1 - num(ct.inflowTaxRate, 0) / 100));
|
||||
txOneOffInflowLabel = ct.inflowLabel?.trim() || null;
|
||||
}
|
||||
if (mode === "OUTFLOW" || mode === "BOTH") {
|
||||
// Kosten sind REAL erfasst (wie Ausgaben) -> mit der kumulierten Inflation aufwerten.
|
||||
txOneOffOutflow = Math.round(num(ct.outflowAmount) * cumulativeInflation);
|
||||
txOneOffOutflowLabel = ct.outflowLabel?.trim() || null;
|
||||
}
|
||||
}
|
||||
for (const e of orderedElements) {
|
||||
const carry = carries.get(e.id)!;
|
||||
const ec = ecById.get(e.id)!;
|
||||
@@ -616,9 +655,13 @@ export function computePlan(plan: PlanInput): PlanComputed {
|
||||
carry.hasCarry = true;
|
||||
}
|
||||
|
||||
cashCarryIn = cashEnd + txInflow - txImmediateRepay;
|
||||
cashCarryIn = cashEnd + txInflow + txOneOffInflow - txImmediateRepay - txOneOffOutflow;
|
||||
incomingInflow = txInflow;
|
||||
incomingImmediateRepay = txImmediateRepay;
|
||||
incomingOneOffInflow = txOneOffInflow;
|
||||
incomingOneOffInflowLabel = txOneOffInflowLabel;
|
||||
incomingOneOffOutflow = txOneOffOutflow;
|
||||
incomingOneOffOutflowLabel = txOneOffOutflowLabel;
|
||||
yearsBefore += duration;
|
||||
}
|
||||
|
||||
|
||||
@@ -97,10 +97,39 @@ export interface TransitionData {
|
||||
immediateRepayment?: number;
|
||||
}
|
||||
|
||||
// --- Cash-Uebergang: einmalige Sonderein-/ausgaben ---
|
||||
// Entscheid am UEBERGANG zwischen zwei Phasen, direkt auf dem Cash-Konto (Cash ist kein
|
||||
// Element, der Entscheid haengt darum an der Von-Phase). Erfassungs-Konventionen analog zu
|
||||
// den laufenden Flows: Zufluss NOMINAL (wie Einkommen), Kosten REAL (wie Ausgaben).
|
||||
|
||||
export type CashTransitionMode = "NONE" | "INFLOW" | "OUTFLOW" | "BOTH";
|
||||
|
||||
export interface CashTransitionData {
|
||||
mode?: CashTransitionMode;
|
||||
// Einmaliger Zufluss (z. B. Erbschaft): NOMINAL erfasst, Steuersatz optional (Default 0 %).
|
||||
inflowLabel?: string;
|
||||
inflowAmount?: number;
|
||||
inflowTaxRate?: number;
|
||||
// Einmalige Kosten (z. B. Poolbau): REAL erfasst (heutige Kaufkraft).
|
||||
outflowLabel?: string;
|
||||
outflowAmount?: number;
|
||||
}
|
||||
|
||||
// --- Zod-Schemas (nachsichtig: unbekannte Felder werden verworfen) ---
|
||||
|
||||
const nonNeg = z.number().min(0);
|
||||
|
||||
export const cashTransitionSchema = z
|
||||
.object({
|
||||
mode: z.enum(["NONE", "INFLOW", "OUTFLOW", "BOTH"]).optional(),
|
||||
inflowLabel: z.string().max(120).optional(),
|
||||
inflowAmount: nonNeg.optional(),
|
||||
inflowTaxRate: z.number().min(0).max(100).optional(),
|
||||
outflowLabel: z.string().max(120).optional(),
|
||||
outflowAmount: nonNeg.optional(),
|
||||
})
|
||||
.strip();
|
||||
|
||||
export const phaseDataSchema = z
|
||||
.object({
|
||||
amount: nonNeg.optional(),
|
||||
|
||||
+8
-2
@@ -1,7 +1,7 @@
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { phaseDataSchema, transitionDataSchema } from "@/lib/elements";
|
||||
import type { PhaseData, TransitionData } from "@/lib/elements";
|
||||
import { cashTransitionSchema, phaseDataSchema, transitionDataSchema } from "@/lib/elements";
|
||||
import type { CashTransitionData, PhaseData, TransitionData } from "@/lib/elements";
|
||||
import type { PlanInput } from "@/lib/types";
|
||||
|
||||
export const planInclude = {
|
||||
@@ -25,6 +25,11 @@ function parseTransitionData(raw: unknown): TransitionData {
|
||||
return parsed.success ? parsed.data : {};
|
||||
}
|
||||
|
||||
function parseCashTransition(raw: unknown): CashTransitionData {
|
||||
const parsed = cashTransitionSchema.safeParse(raw);
|
||||
return parsed.success ? parsed.data : {};
|
||||
}
|
||||
|
||||
export function toPlanInput(plan: PlanWithRelations): PlanInput {
|
||||
return {
|
||||
id: plan.id,
|
||||
@@ -44,6 +49,7 @@ export function toPlanInput(plan: PlanWithRelations): PlanInput {
|
||||
sequenceNumber: phase.sequenceNumber,
|
||||
name: phase.name,
|
||||
durationYears: phase.durationYears,
|
||||
cashTransition: parseCashTransition(phase.cashTransition),
|
||||
})),
|
||||
elements: plan.elements.map((e) => {
|
||||
const phaseValues: Record<string, PhaseData> = {};
|
||||
|
||||
+3
-1
@@ -3,7 +3,7 @@
|
||||
//
|
||||
// V3-Rework: Das Grundprofil (Haushaltsform, Personen, Inflation) liegt neu direkt am Plan.
|
||||
|
||||
import type { ElementCategory, OwnerRole, PhaseData, TransitionData } from "@/lib/elements";
|
||||
import type { CashTransitionData, ElementCategory, OwnerRole, PhaseData, TransitionData } from "@/lib/elements";
|
||||
|
||||
export type HouseholdType = "SINGLE" | "COUPLE";
|
||||
export type PersonRole = "PERSON_A" | "PERSON_B";
|
||||
@@ -21,6 +21,8 @@ export interface PhaseInput {
|
||||
sequenceNumber: number;
|
||||
name: string;
|
||||
durationYears: number;
|
||||
// Cash-Entscheid beim Uebergang NACH dieser Phase (einmalige Sonderein-/ausgaben).
|
||||
cashTransition: CashTransitionData;
|
||||
}
|
||||
|
||||
export interface ElementInput {
|
||||
|
||||
Reference in New Issue
Block a user