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 {