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:
@@ -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