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
+98 -3
View File
@@ -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
View File
@@ -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;
}
+29
View File
@@ -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
View File
@@ -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
View File
@@ -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 {