diff --git a/prisma/migrations/20260716210000_drop_phase_inflation_rate/migration.sql b/prisma/migrations/20260716210000_drop_phase_inflation_rate/migration.sql
new file mode 100644
index 0000000..ae65e35
--- /dev/null
+++ b/prisma/migrations/20260716210000_drop_phase_inflation_rate/migration.sql
@@ -0,0 +1,3 @@
+-- Die Inflation liegt seit dem V5-Modell plan-weit am Plan (Plan.inflationRateDefault).
+-- Die Phasen-Ueberschreibung wurde von der Berechnung nie gelesen und wird nun entfernt.
+ALTER TABLE "Phase" DROP COLUMN "inflationRate";
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 5cbf450..b672f0f 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -93,6 +93,7 @@ model Plan {
// Ein Lebensabschnitt innerhalb eines Plans. Der Phasentyp (Erwerb/Pension/Mischung)
// wird NICHT gespeichert, sondern aus Alter + Pensionsalter abgeleitet (lib/calculations).
+// Die Inflation liegt seit V5 plan-weit am Plan (inflationRateDefault), nicht mehr hier.
model Phase {
id String @id @default(cuid())
planId String
@@ -100,7 +101,6 @@ model Phase {
sequenceNumber Int
name String
durationYears Int
- inflationRate Float?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
diff --git a/src/app/api/phases/[phaseId]/route.ts b/src/app/api/phases/[phaseId]/route.ts
index 02df3e8..83772c4 100644
--- a/src/app/api/phases/[phaseId]/route.ts
+++ b/src/app/api/phases/[phaseId]/route.ts
@@ -8,7 +8,6 @@ import { maxPhaseDuration } from "@/lib/calculations";
const updatePhaseSchema = z.object({
name: z.string().min(1).max(120).optional(),
durationYears: z.number().int().min(1).max(80).optional(),
- inflationRate: z.number().min(-20).max(50).nullable().optional(),
});
export async function PUT(
@@ -46,7 +45,6 @@ export async function PUT(
data: {
name: parsed.data.name ?? undefined,
durationYears: duration ?? undefined,
- inflationRate: parsed.data.inflationRate === undefined ? undefined : parsed.data.inflationRate,
},
});
return NextResponse.json({ phase: { id: phase.id } });
diff --git a/src/app/api/plans/[planId]/phases/route.ts b/src/app/api/plans/[planId]/phases/route.ts
index aaeac7a..fb7bd52 100644
--- a/src/app/api/plans/[planId]/phases/route.ts
+++ b/src/app/api/plans/[planId]/phases/route.ts
@@ -10,7 +10,6 @@ import { num, type PhaseData } from "@/lib/elements";
const createPhaseSchema = z.object({
name: z.string().min(1).max(120).optional(),
durationYears: z.number().int().min(1).max(80).optional(),
- inflationRate: z.number().min(-20).max(50).nullable().optional(),
});
// Legt eine neue Lebensphase am Ende der Kette an. Die Dauer wird ans naechste
@@ -59,7 +58,6 @@ export async function POST(
sequenceNumber: nextSequence,
name: defaultName,
durationYears: duration,
- inflationRate: parsed.data.inflationRate ?? undefined,
},
});
diff --git a/src/app/api/plans/[planId]/scenario/route.ts b/src/app/api/plans/[planId]/scenario/route.ts
index 6eb82cb..c96ec2a 100644
--- a/src/app/api/plans/[planId]/scenario/route.ts
+++ b/src/app/api/plans/[planId]/scenario/route.ts
@@ -58,7 +58,6 @@ export async function POST(
sequenceNumber: phase.sequenceNumber,
name: phase.name,
durationYears: phase.durationYears,
- inflationRate: phase.inflationRate,
},
});
phaseIdMap.set(phase.id, created.id);
diff --git a/src/components/ElementDetail.tsx b/src/components/ElementDetail.tsx
index 4507469..5c99da7 100644
--- a/src/components/ElementDetail.tsx
+++ b/src/components/ElementDetail.tsx
@@ -24,7 +24,6 @@ export interface CellContext {
carriedEndValue: number; // Endwert des Elements in der (Vor-)Phase, fuer Bezugs-Maxima
carried: boolean; // Phase >= 2: Basiswert wird aus der Vorphase fortgeschrieben
derivedStart: number; // fortgeschriebener Basiswert (read-only Anzeige)
- phaseInflation: number; // Plan-Inflationsrate (Info)
deflatorStart: number; // Kaufkraft-Deflator zu Phasenbeginn (real <-> nominal, erstes Jahr)
}
@@ -49,7 +48,9 @@ function DerivedField({ label, value, help }: { label: string; value: number; he
);
}
-// PK/3a-Bezugs-Entscheid im normalen Uebergang: Kein Bezug / Bezug (+ Betrag).
+// PK/3a-Bezugs-Entscheid im normalen Uebergang (Vorbezug): Kein Bezug / Bezug (+ Betrag +
+// Kapitalbezugssteuer). Der Bezugsbetrag wird brutto dem Kapital entnommen; ins Cash fliesst
+// der Betrag nach Abzug der Steuer.
function WithdrawalDecision({
td,
setT,
@@ -62,6 +63,8 @@ function WithdrawalDecision({
label: string;
}) {
const mode = td.withdrawalMode ?? "NONE";
+ const gross = num(td.withdrawal);
+ const taxRate = num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE);
return (
<>
{mode === "AMOUNT" && (
- setT({ withdrawal: v })}
- />
+ <>
+ setT({ withdrawal: v })}
+ />
+ setT({ capitalTaxRate: v })}
+ />
+
+ >
)}
>
);
diff --git a/src/components/PlanProfileFields.tsx b/src/components/PlanProfileFields.tsx
index fa56e4c..3ef89d6 100644
--- a/src/components/PlanProfileFields.tsx
+++ b/src/components/PlanProfileFields.tsx
@@ -91,7 +91,7 @@ export function PlanProfileFields({
onChange({ ...draft, inflationRateDefault: v })}
diff --git a/src/components/PlanView.tsx b/src/components/PlanView.tsx
index d70b3dc..7d64e7a 100644
--- a/src/components/PlanView.tsx
+++ b/src/components/PlanView.tsx
@@ -154,10 +154,6 @@ export function PlanView({
return !!before?.working && !!after && !after.working;
}
- function phaseInflationFor(phaseId: string): number {
- return plan.phases.find((p) => p.id === phaseId)?.inflationRate ?? plan.inflationRateDefault;
- }
-
// Baut den Kontext fuer eine Phasenzelle.
function buildPhaseContext(phase: PhaseComputed, element: ElementInput): CellContext {
const ce = computedElement(phase.id, element.id);
@@ -175,7 +171,6 @@ export function PlanView({
carriedEndValue: ce?.endValue ?? 0,
carried: ce?.carried ?? false,
derivedStart: ce?.baseValue ?? 0,
- phaseInflation: phaseInflationFor(phase.id),
deflatorStart: phase.cumulativeInflationStart,
};
}
@@ -192,7 +187,6 @@ export function PlanView({
carriedEndValue: ce?.endValue ?? 0,
carried: ce?.carried ?? false,
derivedStart: 0,
- phaseInflation: phaseInflationFor(fromPhase.id),
deflatorStart: fromPhase.cumulativeInflationStart,
};
}
@@ -241,7 +235,7 @@ export function PlanView({
.sort((a, b) => a.orderIndex - b.orderIndex);
}
- async function handleAddPhase(payload: { name?: string; durationYears?: number; inflationRate?: number | null }) {
+ async function handleAddPhase(payload: { name?: string; durationYears?: number }) {
await api.post(`/api/plans/${plan.id}/phases`, payload);
setShowAddPhase(false);
onChanged();
@@ -855,7 +849,6 @@ function AddElementDialog({
carriedEndValue: 0,
carried: false,
derivedStart: 0,
- phaseInflation: plan.inflationRateDefault,
deflatorStart: firstPhase.cumulativeInflationStart,
};
@@ -954,7 +947,7 @@ function AddPhaseDialog({
}: {
maxDurationYears: number | null;
onClose: () => void;
- onCreate: (payload: { name?: string; durationYears?: number; inflationRate?: number | null }) => void;
+ onCreate: (payload: { name?: string; durationYears?: number }) => void;
}) {
const cap = maxDurationYears;
const [name, setName] = useState("");
diff --git a/src/lib/calculations.test.ts b/src/lib/calculations.test.ts
index 99b8778..06deb1f 100644
--- a/src/lib/calculations.test.ts
+++ b/src/lib/calculations.test.ts
@@ -32,7 +32,7 @@ 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, inflationRate: null })),
+ phases: opts.phases.map((p, i) => ({ id: p.id, sequenceNumber: i + 1, name: p.id, durationYears: p.durationYears })),
elements: opts.elements,
};
}
@@ -185,6 +185,72 @@ describe("V5 Golden Tests", () => {
expect(ph.cashEnd).toBe(50000);
});
+ it("Tilgung stoppt, sobald die Schuld getilgt ist (belastet Cash und Sparrate nicht weiter)", () => {
+ // Schuld 25'000, Tilgung 10'000/Jahr, 5 Jahre: getilgt im Jahr 3 (10'000 + 10'000 + 5'000).
+ // Gesamtabfluss = 25'000, NICHT 50'000. Einkommen = Ausgaben, damit nur die Tilgung wirkt.
+ const p = plan({
+ age: 40,
+ retirementAge: 70,
+ inflation: 0,
+ initialCash: 100000,
+ phases: [{ id: "p1", durationYears: 5 }],
+ elements: [
+ el("INCOME", "PERSON_A", { p1: { amount: 50000, teuerungsausgleich: 0 } }),
+ el("EXPENSE", "HOUSEHOLD", { p1: { amount: 50000, teuerungsausgleich: 0 } }),
+ el("OTHER_DEBT", "HOUSEHOLD", { p1: { startValue: 25000, annualRepayment: 10000 } }),
+ ],
+ });
+ const ph = computePlan(p).phases[0];
+ expect(ph.cashEnd).toBe(75000); // 100'000 - 25'000
+ expect(ph.plannedSaveRate).toBe(10000); // erstes Jahr volle Rate
+ const debt = ph.elements.find((e) => e.category === "OTHER_DEBT")!;
+ expect(debt.endValue).toBe(0);
+ });
+
+ it("Amortisation stoppt, sobald die Hypothek abbezahlt ist", () => {
+ // Hypothek 15'000, Amortisation 10'000/Jahr, 4 Jahre: abbezahlt im Jahr 2 (10'000 + 5'000).
+ const p = plan({
+ age: 40,
+ retirementAge: 70,
+ inflation: 0,
+ initialCash: 100000,
+ phases: [{ id: "p1", durationYears: 4 }],
+ elements: [
+ el("REAL_ESTATE", "HOUSEHOLD", { p1: { purchasePrice: 500000, mortgage: 15000, amortization: 10000 } }),
+ ],
+ });
+ const ph = computePlan(p).phases[0];
+ expect(ph.cashEnd).toBe(85000); // 100'000 - 15'000 (nicht - 40'000)
+ const re = ph.elements.find((e) => e.category === "REAL_ESTATE")!;
+ expect(re.endValue).toBe(500000); // schuldenfrei
+ });
+
+ it("Vorbezug PK vor Pensionierung: Kapitalbezugssteuer wird abgezogen", () => {
+ // Bezug 100'000 brutto bei 8% Steuer -> 92'000 netto ins Cash der Folgephase.
+ const p = plan({
+ age: 40,
+ retirementAge: 70,
+ inflation: 0,
+ phases: [
+ { id: "p1", durationYears: 1 },
+ { id: "p2", durationYears: 1 },
+ ],
+ elements: [
+ el(
+ "PENSION_FUND",
+ "PERSON_A",
+ { p1: { currentValue: 300000, expectedReturn: 0, annualContribution: 0 }, p2: {} },
+ { p1: { withdrawalMode: "AMOUNT", withdrawal: 100000, capitalTaxRate: 8 } }
+ ),
+ ],
+ });
+ const r = computePlan(p);
+ expect(r.phases[1].capitalInflow).toBe(92000); // netto nach 8% Steuer
+ expect(r.phases[1].cashStart).toBe(92000);
+ const pk = r.phases[1].elements.find((e) => e.category === "PENSION_FUND")!;
+ expect(pk.startValue).toBe(200000); // brutto 100'000 dem Kapital entnommen
+ });
+
it("Fortschreibung: nominaler Einkommens-Basiswert Phase 1 -> Startwert Phase 2; Cash laeuft fort", () => {
const p = plan({
age: 40,
diff --git a/src/lib/calculations.ts b/src/lib/calculations.ts
index 5ccdd0a..fe5336e 100644
--- a/src/lib/calculations.ts
+++ b/src/lib/calculations.ts
@@ -54,7 +54,10 @@ export interface PhaseComputed {
quotaStart: number;
quotaEnd: number;
isConsumption: boolean;
- plannedSaveRate: number; // geplante Sparrate: 3a + Sonstiges-Vermoegen-Sparbeitrag + Amort. + Tilgung
+ // Geplante Sparrate im ERSTEN Phasenjahr: 3a + Sonstiges-Vermoegen-Sparbeitrag + Amort. +
+ // Tilgung. Amortisation/Tilgung entfallen, sobald Hypothek/Schuld abbezahlt sind -- die Rate
+ // kann in spaeteren Phasenjahren also tiefer liegen.
+ plannedSaveRate: number;
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
@@ -208,9 +211,11 @@ export function computePlan(plan: PlanInput): PlanComputed {
const expenses: { basis: number; idx: number; ec: ElementPhaseComputed }[] = [];
let renteTotal = 0; // AHV + PK-Renten (nominal fix)
const assets: { value: number; rate: number; r: number; withdrawal: number; ec: ElementPhaseComputed }[] = [];
- const realEstates: { purchase: number; mortgageStart: number; amort: number; ec: ElementPhaseComputed }[] = [];
- const debts: { owedStart: number; repay: number; ec: ElementPhaseComputed }[] = [];
- let plannedRatesTotal = 0; // Sparraten (verlassen das Cash): 3a + Sonstiges Vermoegen + Amort. + Tilgung
+ // mortgage/owed sind LAUFENDE Salden: sie werden in der Jahresschleife abgebaut und am
+ // Nullpunkt gestoppt (keine Rate mehr, sobald abbezahlt).
+ const realEstates: { purchase: number; mortgage: number; amort: number; ec: ElementPhaseComputed }[] = [];
+ const debts: { owed: number; repay: number; ec: ElementPhaseComputed }[] = [];
+ let fixedRatesTotal = 0; // Sparraten mit konstantem Jahresbetrag: 3a + Sonstiges Vermoegen
let plannedWithdrawTotal = 0; // Bezugsraten (fliessen ins Cash): Sonstiges Vermoegen
let investmentsFromCash = 0; // Neuinvestitionen/Aufstockungen (ab Phase 2, aus Cash)
let wealthStart = 0;
@@ -312,7 +317,7 @@ export function computePlan(plan: PlanInput): PlanComputed {
const topUp = carry.hasCarry ? Math.round(num(pd.additionalInvestment)) : 0;
const start = base + topUp;
const rate = Math.round(num(pd.annualContribution));
- plannedRatesTotal += rate;
+ fixedRatesTotal += rate;
if (!isFirstPhase) investmentsFromCash += topUp;
ec.baseValue = base;
ec.startValue = start;
@@ -327,7 +332,7 @@ export function computePlan(plan: PlanInput): PlanComputed {
const start = base + topUp;
const rate = Math.round(num(pd.annualContribution));
const withdrawal = Math.round(num(pd.annualWithdrawal));
- plannedRatesTotal += rate;
+ fixedRatesTotal += rate;
plannedWithdrawTotal += withdrawal;
if (!isFirstPhase) investmentsFromCash += topUp;
ec.baseValue = base;
@@ -340,23 +345,21 @@ export function computePlan(plan: PlanInput): PlanComputed {
const purchase = Math.round(num(pd.purchasePrice));
const mortgageStart = carry.hasCarry ? carry.mortgage : Math.round(num(pd.mortgage));
const amort = Math.round(num(pd.amortization));
- plannedRatesTotal += amort;
const equity = purchase - mortgageStart;
if (!carry.hasCarry && !isFirstPhase) investmentsFromCash += Math.max(0, equity);
ec.baseValue = equity;
ec.startValue = equity;
wealthStart += equity;
- realEstates.push({ purchase, mortgageStart, amort, ec });
+ realEstates.push({ purchase, mortgage: mortgageStart, amort, ec });
break;
}
case "OTHER_DEBT": {
const owedStart = carry.hasCarry ? carry.owed : Math.round(num(pd.startValue));
const repay = Math.round(num(pd.annualRepayment));
- plannedRatesTotal += repay;
ec.baseValue = -owedStart;
ec.startValue = -owedStart;
wealthStart += -owedStart;
- debts.push({ owedStart, repay, ec });
+ debts.push({ owed: owedStart, repay, ec });
break;
}
}
@@ -374,6 +377,7 @@ export function computePlan(plan: PlanInput): PlanComputed {
let expenseEnd = 0;
let quotaStart = 0;
let quotaEnd = 0;
+ let plannedSaveRate = 0; // Kopf-Kennzahl: die tatsaechliche Sparrate im ersten Phasenjahr
for (let t = 1; t <= duration; t++) {
// Einkommen: nominal (Basis x (1+Lohnerhoehung)^(t-1)) + Renten (nominal fix).
@@ -413,14 +417,30 @@ export function computePlan(plan: PlanInput): PlanComputed {
a.value = grown - w;
cashFromWithdraw += w;
}
- cash += quote - plannedRatesTotal + cashFromWithdraw;
+
+ // Amortisation/Tilgung: nur so lange und so viel, wie noch Restschuld besteht. Ist die
+ // Hypothek/Schuld abbezahlt, entfaellt die Rate -- sie belastet weder Cash noch Sparquote.
+ let debtRates = 0;
+ for (const re of realEstates) {
+ const pay = Math.min(re.amort, re.mortgage);
+ re.mortgage -= pay;
+ debtRates += pay;
+ }
+ for (const d of debts) {
+ const pay = Math.min(d.repay, d.owed);
+ d.owed -= pay;
+ debtRates += pay;
+ }
+ if (t === 1) plannedSaveRate = fixedRatesTotal + debtRates;
+
+ cash += quote - fixedRatesTotal - debtRates + cashFromWithdraw;
if (cash < 0) cashNegative = true;
// Gesamtvermoegen zum Jahresende t (fuer Ruin-Erkennung).
let total = cash;
for (const a of assets) total += a.value;
- for (const re of realEstates) total += re.purchase - Math.max(0, re.mortgageStart - re.amort * t);
- for (const d of debts) total += -Math.max(0, d.owedStart - d.repay * t);
+ for (const re of realEstates) total += re.purchase - re.mortgage;
+ for (const d of debts) total += -d.owed;
if (ruinAge === null && total < 0) ruinAge = personA.age + yearsBefore + t;
}
@@ -445,17 +465,15 @@ export function computePlan(plan: PlanInput): PlanComputed {
wealthEnd += a.ec.endValue;
}
for (const re of realEstates) {
- const mortgageEnd = Math.max(0, re.mortgageStart - re.amort * duration);
- re.ec.endValue = re.purchase - mortgageEnd;
+ re.ec.endValue = re.purchase - re.mortgage;
re.ec.summary = fmt(re.ec.endValue);
wealthEnd += re.ec.endValue;
}
for (const d of debts) {
- const owedEnd = Math.max(0, d.owedStart - d.repay * duration);
- d.ec.endValue = -owedEnd;
+ d.ec.endValue = d.owed > 0 ? -d.owed : 0;
d.ec.summary = fmt(d.ec.endValue);
wealthEnd += d.ec.endValue;
- if (owedEnd === 0) d.ec.note = "Wird getilgt";
+ if (d.owed === 0) d.ec.note = "Wird getilgt";
}
const cashEnd = Math.round(cash);
@@ -478,7 +496,7 @@ export function computePlan(plan: PlanInput): PlanComputed {
quotaStart: Math.round(quotaStart),
quotaEnd: Math.round(quotaEnd),
isConsumption: quotaStart < 0,
- plannedSaveRate: plannedRatesTotal,
+ plannedSaveRate,
plannedWithdrawRate: plannedWithdrawTotal,
capitalInflow: Math.round(incomingInflow),
capitalInvest: Math.round(investmentsFromCash + incomingImmediateRepay),
@@ -538,9 +556,11 @@ export function computePlan(plan: PlanInput): PlanComputed {
carry.value = 0;
}
} else {
+ // Vorbezug (z. B. Wohneigentum/Selbststaendigkeit): ebenfalls kapitalbezugssteuerpflichtig.
+ // Das Kapital wird brutto entnommen, netto (nach Steuer) fliesst es ins Cash.
const withdrawal = Math.min(ec.endValue, Math.round(num(td.withdrawal)));
carry.value = ec.endValue - withdrawal;
- txInflow += withdrawal;
+ txInflow += Math.round(withdrawal * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100));
}
break;
}
@@ -551,7 +571,7 @@ export function computePlan(plan: PlanInput): PlanComputed {
} else {
const withdrawal = Math.min(ec.endValue, Math.round(num(td.withdrawal)));
carry.value = ec.endValue - withdrawal;
- txInflow += withdrawal;
+ txInflow += Math.round(withdrawal * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100));
}
break;
}
diff --git a/src/lib/queries.ts b/src/lib/queries.ts
index e60e749..bf06dee 100644
--- a/src/lib/queries.ts
+++ b/src/lib/queries.ts
@@ -44,7 +44,6 @@ export function toPlanInput(plan: PlanWithRelations): PlanInput {
sequenceNumber: phase.sequenceNumber,
name: phase.name,
durationYears: phase.durationYears,
- inflationRate: phase.inflationRate,
})),
elements: plan.elements.map((e) => {
const phaseValues: Record = {};
diff --git a/src/lib/types.ts b/src/lib/types.ts
index b721907..59d3f5c 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -21,7 +21,6 @@ export interface PhaseInput {
sequenceNumber: number;
name: string;
durationYears: number;
- inflationRate: number | null;
}
export interface ElementInput {