From e9dacc202668b07636b5cb97f5fa45e2531f5bc8 Mon Sep 17 00:00:00 2001 From: kelle Date: Wed, 8 Jul 2026 21:35:10 +0200 Subject: [PATCH] Rework real estate model: purchase price + mortgage + amortization only, no appreciation; amortization counts against savings quota; sale price/tax entered at transition time with correct mortgage payoff; carry over income/expenses to new phases --- .../migration.sql | 10 +++ prisma/schema.prisma | 28 ++++--- src/app/api/phases/[phaseId]/route.ts | 8 +- .../api/phases/[phaseId]/transition/route.ts | 60 ++++++++++--- src/app/api/plans/[planId]/phases/route.ts | 19 ++++- src/app/api/plans/[planId]/scenario/route.ts | 5 +- src/components/Dashboard.tsx | 2 +- src/components/PhaseForm.tsx | 50 +++++------ src/components/TransitionPanel.tsx | 84 ++++++++++++++----- src/lib/calculations.ts | 73 ++++++---------- src/lib/queries.ts | 5 +- src/lib/types.ts | 5 +- 12 files changed, 204 insertions(+), 145 deletions(-) create mode 100644 prisma/migrations/20260709120000_rework_realestate/migration.sql diff --git a/prisma/migrations/20260709120000_rework_realestate/migration.sql b/prisma/migrations/20260709120000_rework_realestate/migration.sql new file mode 100644 index 0000000..7834201 --- /dev/null +++ b/prisma/migrations/20260709120000_rework_realestate/migration.sql @@ -0,0 +1,10 @@ +-- AlterTable: RealEstate vereinfacht (Kaufpreis statt Marktwert, keine Wertsteigerung +-- mehr, Verkaufspreis/-steuer wandern in PhaseTransitionItem) +ALTER TABLE "RealEstate" RENAME COLUMN "marketValue" TO "purchasePrice"; +ALTER TABLE "RealEstate" DROP COLUMN "valueGrowth"; +ALTER TABLE "RealEstate" DROP COLUMN "salePrice"; +ALTER TABLE "RealEstate" DROP COLUMN "saleTaxRate"; +ALTER TABLE "RealEstate" ADD COLUMN "carriedFromRealEstateId" TEXT; + +-- AlterTable: Verkaufssteuer wird beim Immobilien-Verkauf im Uebergang selbst erfasst +ALTER TABLE "PhaseTransitionItem" ADD COLUMN "saleTaxRate" DOUBLE PRECISION; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d8f2e11..bf77b58 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -176,19 +176,21 @@ model Security { transitionItems PhaseTransitionItem[] } -// Eine Immobilie innerhalb einer Phase +// Eine Immobilie innerhalb einer Phase. Vereinfachtes Modell: kein Wertsteigerungsfeld, +// der Kaufpreis bleibt ueber die Haltedauer fix -- einzig die Hypothek sinkt durch +// Amortisation. Verkaufspreis/-steuer werden nicht hier, sondern erst im Uebergangs- +// Screen im Moment des Verkaufs erfasst (siehe PhaseTransitionItem). model RealEstate { - id String @id @default(cuid()) - phaseId String - phase Phase @relation(fields: [phaseId], references: [id], onDelete: Cascade) - name String - marketValue Float - mortgage Float - valueGrowth Float - amortization Float - salePrice Float? - // Geschaetzte Grundstueckgewinnsteuer (%), direkt am ausloesenden Ereignis erfasst (Kap. 9) - saleTaxRate Float @default(20) + id String @id @default(cuid()) + phaseId String + phase Phase @relation(fields: [phaseId], references: [id], onDelete: Cascade) + name String + purchasePrice Float + mortgage Float + amortization Float + // Verweist auf die Immobilie der Vorphase, aus der automatisch uebernommen wurde + // (nur intern zur Deduplizierung bei wiederholtem Speichern des Uebergangs, kein FK). + carriedFromRealEstateId String? transitionItems PhaseTransitionItem[] } @@ -242,4 +244,6 @@ model PhaseTransitionItem { realEstate RealEstate? @relation(fields: [realEstateId], references: [id], onDelete: Cascade) decision TransitionDecision salePrice Float? + // Nur bei Immobilien-Verkauf erfasst (Grundstueckgewinnsteuer in %), siehe RealEstate. + saleTaxRate Float? } diff --git a/src/app/api/phases/[phaseId]/route.ts b/src/app/api/phases/[phaseId]/route.ts index 9b17e57..7de0c35 100644 --- a/src/app/api/phases/[phaseId]/route.ts +++ b/src/app/api/phases/[phaseId]/route.ts @@ -23,12 +23,10 @@ const securitySchema = z.object({ }); const realEstateSchema = z.object({ name: z.string().min(1), - marketValue: z.number(), + // Muss zwingend angegeben werden (siehe Anforderung: Kaufpreis ist Pflichtfeld). + purchasePrice: z.number().positive("Kaufpreis muss groesser als 0 sein."), mortgage: z.number(), - valueGrowth: z.number(), amortization: z.number(), - salePrice: z.number().nullable().optional(), - saleTaxRate: z.number().min(0).max(100), }); const oneTimeEventSchema = z.object({ type: z.enum(["INCOME", "EXPENSE"]), @@ -96,7 +94,7 @@ export async function PUT( incomeEntries: { create: data.incomeEntries.map((e) => ({ ...e, label: e.label ?? null, personId: e.personId ?? null })) }, expenseEntries: { create: data.expenseEntries.map((e) => ({ ...e, label: e.label ?? null })) }, securities: { create: data.securities }, - realEstates: { create: data.realEstates.map((re) => ({ ...re, salePrice: re.salePrice ?? null })) }, + realEstates: { create: data.realEstates }, oneTimeEvents: { create: data.oneTimeEvents.map((e) => ({ ...e, description: e.description ?? null })) }, retirementInfos: { create: data.retirementInfos }, }, diff --git a/src/app/api/phases/[phaseId]/transition/route.ts b/src/app/api/phases/[phaseId]/transition/route.ts index fda1bf0..bb4b9d0 100644 --- a/src/app/api/phases/[phaseId]/transition/route.ts +++ b/src/app/api/phases/[phaseId]/transition/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { prisma } from "@/lib/db"; -import { computeSecurityYearlyValues } from "@/lib/calculations"; +import { computeMortgageYearly, computeSecurityYearlyValues } from "@/lib/calculations"; import { floorToThousand } from "@/lib/format"; const transitionItemSchema = z.object({ @@ -10,6 +10,8 @@ const transitionItemSchema = z.object({ realEstateId: z.string().nullable().optional(), decision: z.enum(["CARRY_OVER", "SELL"]), salePrice: z.number().nullable().optional(), + // Nur bei Immobilien-Verkauf relevant (Grundstueckgewinnsteuer in %). + saleTaxRate: z.number().min(0).max(100).nullable().optional(), }); const putTransitionSchema = z.object({ @@ -51,10 +53,10 @@ export async function GET( }); } -// Speichert die Entscheidungen (Uebernehmen/Verkaufen) fuer jede Position der Vorphase. -// Uebernommene Wertschriften werden automatisch als neue Wertschrift in der Folgephase -// angelegt (Startwert = Endwert dieser Phase). Verkaufte Positionen fliessen als -// "verfuegbares Startkapital" (Phase.incomingCapital) in die Folgephase ein. +// Speichert die Entscheidungen (Uebernehmen/Verkaufen bzw. Halten/Verkaufen) fuer jede +// Position der Vorphase. Uebernommene/gehaltene Positionen werden automatisch 1:1 (mit +// zurueckgesetztem Sparbeitrag/Amortisation) in der Folgephase angelegt. Verkaufte +// Positionen fliessen als "verfuegbares Startkapital" (Phase.incomingCapital) ein. export async function PUT( request: NextRequest, { params }: { params: Promise<{ phaseId: string }> } @@ -94,7 +96,7 @@ export async function PUT( const missing = [...requiredIds].filter((id) => !providedIds.has(id)); if (missing.length > 0) { return NextResponse.json( - { error: "Fuer jede bestehende Position muss Uebernehmen oder Verkaufen gewaehlt werden." }, + { error: "Fuer jede bestehende Position muss Uebernehmen/Halten oder Verkaufen gewaehlt werden." }, { status: 400 } ); } @@ -104,6 +106,7 @@ export async function PUT( let incomingCapital = 0; const securitiesToCarry: { source: (typeof phase.securities)[number]; endValue: number }[] = []; + const realEstatesToCarry: { source: (typeof phase.realEstates)[number]; remainingMortgage: number }[] = []; for (const item of parsed.data.items) { if (item.positionType === "SECURITY" && item.securityId) { @@ -125,11 +128,22 @@ export async function PUT( } } else if (item.positionType === "REAL_ESTATE" && item.realEstateId) { const realEstate = realEstateById.get(item.realEstateId); - if (!realEstate || item.decision !== "SELL") continue; - const salePrice = item.salePrice ?? 0; - const gain = Math.max(0, salePrice - realEstate.marketValue); - const tax = gain * (realEstate.saleTaxRate / 100); - incomingCapital += salePrice - tax; + if (!realEstate) continue; + const remainingMortgage = computeMortgageYearly( + realEstate.mortgage, + realEstate.amortization, + phase.durationYears + )[phase.durationYears]; + + if (item.decision === "CARRY_OVER") { + realEstatesToCarry.push({ source: realEstate, remainingMortgage }); + } else { + const salePrice = item.salePrice ?? 0; + const saleTaxRate = item.saleTaxRate ?? 0; + const gain = Math.max(0, salePrice - realEstate.purchasePrice); + const tax = gain * (saleTaxRate / 100); + incomingCapital += salePrice - remainingMortgage - tax; + } } } @@ -141,15 +155,21 @@ export async function PUT( const transition = await prisma.$transaction(async (tx) => { await tx.phaseTransition.deleteMany({ where: { fromPhaseId: phaseId } }); - // Vorherige automatisch uebernommene Wertschriften aus einem frueheren Speichern + // Vorherige automatisch uebernommene Positionen aus einem frueheren Speichern // dieses Uebergangs entfernen, damit sie nicht dupliziert werden. Manuell vom - // Benutzer angelegte Wertschriften (carriedFromSecurityId = null) bleiben unberuehrt. + // Benutzer angelegte Positionen (carriedFrom...Id = null) bleiben unberuehrt. await tx.security.deleteMany({ where: { phaseId: nextPhase.id, carriedFromSecurityId: { in: phase.securities.map((s) => s.id) }, }, }); + await tx.realEstate.deleteMany({ + where: { + phaseId: nextPhase.id, + carriedFromRealEstateId: { in: phase.realEstates.map((re) => re.id) }, + }, + }); for (const { source, endValue } of securitiesToCarry) { await tx.security.create({ @@ -167,6 +187,19 @@ export async function PUT( }); } + for (const { source, remainingMortgage } of realEstatesToCarry) { + await tx.realEstate.create({ + data: { + phaseId: nextPhase.id, + name: source.name, + purchasePrice: source.purchasePrice, + mortgage: remainingMortgage, + amortization: 0, + carriedFromRealEstateId: source.id, + }, + }); + } + await tx.phase.update({ where: { id: nextPhase.id }, data: { incomingCapital }, @@ -183,6 +216,7 @@ export async function PUT( realEstateId: i.realEstateId ?? null, decision: i.decision, salePrice: i.salePrice ?? null, + saleTaxRate: i.saleTaxRate ?? null, })), }, }, diff --git a/src/app/api/plans/[planId]/phases/route.ts b/src/app/api/plans/[planId]/phases/route.ts index afe6adb..4bd0b4e 100644 --- a/src/app/api/plans/[planId]/phases/route.ts +++ b/src/app/api/plans/[planId]/phases/route.ts @@ -31,9 +31,12 @@ export async function POST( const lastPhase = await prisma.phase.findFirst({ where: { planId }, orderBy: { sequenceNumber: "desc" }, + include: { incomeEntries: true, expenseEntries: true }, }); const nextSequence = (lastPhase?.sequenceNumber ?? 0) + 1; + // Einkommen und Ausgaben werden 1:1 aus der letzten Phase uebernommen (manuell + // anpassbar), damit man sie nicht bei jeder neuen Phase erneut eintippen muss. const phase = await prisma.phase.create({ data: { planId, @@ -41,7 +44,21 @@ export async function POST( name: parsed.data.name, durationYears: parsed.data.durationYears, inflationRate: parsed.data.inflationRate ?? null, - incomeMode: parsed.data.incomeMode, + incomeMode: lastPhase?.incomeMode ?? parsed.data.incomeMode, + incomeEntries: lastPhase + ? { + create: lastPhase.incomeEntries.map((e) => ({ + personId: e.personId, + label: e.label, + amount: e.amount, + })), + } + : undefined, + expenseEntries: lastPhase + ? { + create: lastPhase.expenseEntries.map((e) => ({ label: e.label, amount: e.amount })), + } + : undefined, }, include: phaseInclude, }); diff --git a/src/app/api/plans/[planId]/scenario/route.ts b/src/app/api/plans/[planId]/scenario/route.ts index cac6b1a..1ed5505 100644 --- a/src/app/api/plans/[planId]/scenario/route.ts +++ b/src/app/api/plans/[planId]/scenario/route.ts @@ -80,12 +80,9 @@ export async function POST( realEstates: { create: phase.realEstates.map((re) => ({ name: re.name, - marketValue: re.marketValue, + purchasePrice: re.purchasePrice, mortgage: re.mortgage, - valueGrowth: re.valueGrowth, amortization: re.amortization, - salePrice: re.salePrice, - saleTaxRate: re.saleTaxRate, })), }, oneTimeEvents: { diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx index 33e0d84..e97212e 100644 --- a/src/components/Dashboard.tsx +++ b/src/components/Dashboard.tsx @@ -63,7 +63,7 @@ export function Dashboard({ computed.phases.map((phase) => { const row: Record = { phase: phase.name }; for (const s of phase.securities) row[s.name] = s.endValue; - for (const re of phase.realEstates) row[re.name] = re.endContribution; + for (const re of phase.realEstates) row[re.name] = re.endNet; return row; }), [computed] diff --git a/src/components/PhaseForm.tsx b/src/components/PhaseForm.tsx index 1bd25ba..5d2ea89 100644 --- a/src/components/PhaseForm.tsx +++ b/src/components/PhaseForm.tsx @@ -57,7 +57,11 @@ export function PhaseForm({ household, phase, onSaved, onCancel }: Props) { const totalIncome = incomeEntries.reduce((s, e) => s + e.amount, 0); const totalExpense = expenseEntries.reduce((s, e) => s + e.amount, 0); const savingsQuota = totalIncome - totalExpense; - const allocated = securities.reduce((s, sec) => s + sec.annualContribution, 0); + // Amortisationsraten aller Immobilien zaehlen gleichwertig mit den Sparbeitraegen der + // Wertschriften gegen dieselbe verfuegbare Sparquote. + const allocated = + securities.reduce((s, sec) => s + sec.annualContribution, 0) + + realEstates.reduce((s, re) => s + re.amortization, 0); const overAllocated = allocated > savingsQuota; const savingsRemaining = savingsQuota - allocated > 0.5; @@ -68,6 +72,13 @@ export function PhaseForm({ household, phase, onSaved, onCancel }: Props) { const startCapitalRemaining = phase.incomingCapital - allocatedStartCapital > 0.5; async function handleSave() { + const missingPurchasePrice = realEstates.find((re) => !re.purchasePrice || re.purchasePrice <= 0); + if (missingPurchasePrice) { + setError( + `Bitte fuer "${missingPurchasePrice.name || "Immobilie"}" einen Kaufpreis groesser als 0 eintragen.` + ); + return; + } setSaving(true); setError(null); try { @@ -302,42 +313,23 @@ export function PhaseForm({ household, phase, onSaved, onCancel }: Props) { onChange={(v) => setRealEstates((prev) => prev.map((x, idx) => (idx === i ? { ...x, name: v } : x)))} /> setRealEstates((prev) => prev.map((x, idx) => (idx === i ? { ...x, marketValue: v } : x)))} + label="Kaufpreis (CHF)" + help="Pflichtfeld. Der Kaufpreis bleibt ueber die ganze Haltedauer fix -- es wird keine Wertsteigerung angenommen, nur die Hypothek sinkt durch Amortisation." + value={re.purchasePrice} + onChange={(v) => setRealEstates((prev) => prev.map((x, idx) => (idx === i ? { ...x, purchasePrice: v } : x)))} /> setRealEstates((prev) => prev.map((x, idx) => (idx === i ? { ...x, mortgage: v } : x)))} /> - setRealEstates((prev) => prev.map((x, idx) => (idx === i ? { ...x, valueGrowth: v } : x)))} - /> setRealEstates((prev) => prev.map((x, idx) => (idx === i ? { ...x, amortization: v } : x)))} /> - setRealEstates((prev) => prev.map((x, idx) => (idx === i ? { ...x, salePrice: v || null } : x)))} - /> - setRealEstates((prev) => prev.map((x, idx) => (idx === i ? { ...x, saleTaxRate: v } : x)))} - />
setRealEstates((prev) => prev.filter((_, idx) => idx !== i))} />
@@ -348,7 +340,7 @@ export function PhaseForm({ household, phase, onSaved, onCancel }: Props) { onClick={() => setRealEstates((prev) => [ ...prev, - { id: tempId(), name: "", marketValue: 0, mortgage: 0, valueGrowth: 0, amortization: 0, salePrice: null, saleTaxRate: 20 }, + { id: tempId(), name: "", purchasePrice: 0, mortgage: 0, amortization: 0 }, ]) } /> diff --git a/src/components/TransitionPanel.tsx b/src/components/TransitionPanel.tsx index eb974fa..68ae164 100644 --- a/src/components/TransitionPanel.tsx +++ b/src/components/TransitionPanel.tsx @@ -13,10 +13,13 @@ interface ItemDraft { name: string; decision: TransitionDecision; salePrice: number | null; - // Referenzwerte fuer die Anzeige/Berechnung des verfuegbaren Startkapitals - carryOverValue: number; - originalValue: number; + // Nur fuer Immobilien editierbar (poppt bei "Verkaufen" auf); bei Wertschriften der + // fixe, am Wertpapier hinterlegte Steuersatz. saleTaxRate: number; + // Referenzwerte fuer die Anzeige/Berechnung des verfuegbaren Startkapitals + carryOverValue: number; // Wert bei "Halten": Endwert (Wertschrift) bzw. Nettowert (Immobilie) + originalValue: number; // Wertschrift: Startwert: Immobilie: Kaufpreis + remainingMortgage: number; // nur Immobilien: Resthypothek am Ende der Phase } export function TransitionPanel({ @@ -48,30 +51,42 @@ export function TransitionPanel({ name: s.name, decision: "CARRY_OVER" as TransitionDecision, salePrice: null, + saleTaxRate: s.saleTaxRate, carryOverValue: c?.endValue ?? 0, originalValue: c?.startValue ?? 0, - saleTaxRate: s.saleTaxRate, + remainingMortgage: 0, }; }), ...phase.realEstates.map((re) => { const c = computed.realEstates.find((cr) => cr.id === re.id); + const remainingMortgage = c ? c.mortgages[phase.durationYears] : 0; return { positionType: "REAL_ESTATE" as const, id: re.id, name: re.name, decision: "CARRY_OVER" as TransitionDecision, - salePrice: re.marketValue, - carryOverValue: c?.endNetIfKept ?? 0, - originalValue: re.marketValue, - saleTaxRate: re.saleTaxRate, + salePrice: re.purchasePrice, + saleTaxRate: 20, + carryOverValue: c?.endNet ?? 0, + originalValue: re.purchasePrice, + remainingMortgage, }; }), ]; try { - const data = await api.get<{ transition: { items: { positionType: string; securityId: string | null; realEstateId: string | null; decision: TransitionDecision; salePrice: number | null }[] } | null }>( - `/api/phases/${phase.id}/transition` - ); + const data = await api.get<{ + transition: { + items: { + positionType: string; + securityId: string | null; + realEstateId: string | null; + decision: TransitionDecision; + salePrice: number | null; + saleTaxRate: number | null; + }[]; + } | null; + }>(`/api/phases/${phase.id}/transition`); if (cancelled) return; if (data.transition) { for (const savedItem of data.transition.items) { @@ -81,6 +96,7 @@ export function TransitionPanel({ if (target) { target.decision = savedItem.decision; if (savedItem.salePrice != null) target.salePrice = savedItem.salePrice; + if (savedItem.saleTaxRate != null) target.saleTaxRate = savedItem.saleTaxRate; } } } @@ -108,17 +124,16 @@ export function TransitionPanel({ const totalAvailableCapital = floorToThousand( items.reduce((sum, it) => { + if (it.decision === "CARRY_OVER") return sum; if (it.positionType === "SECURITY") { - if (it.decision === "CARRY_OVER") return sum; const gain = Math.max(0, it.carryOverValue - it.originalValue); const tax = gain * (it.saleTaxRate / 100); return sum + (it.carryOverValue - tax); } - if (it.decision === "CARRY_OVER") return sum; const salePrice = it.salePrice ?? 0; const gain = Math.max(0, salePrice - it.originalValue); const tax = gain * (it.saleTaxRate / 100); - return sum + (salePrice - tax); + return sum + (salePrice - it.remainingMortgage - tax); }, 0) ); @@ -134,6 +149,7 @@ export function TransitionPanel({ realEstateId: it.positionType === "REAL_ESTATE" ? it.id : null, decision: it.decision, salePrice: it.positionType === "REAL_ESTATE" && it.decision === "SELL" ? it.salePrice : null, + saleTaxRate: it.positionType === "REAL_ESTATE" && it.decision === "SELL" ? it.saleTaxRate : null, })), }); setSaved(true); @@ -159,7 +175,8 @@ export function TransitionPanel({ Position Entscheidung - Verkaufspreis / Wert + Verkaufspreis + Grundstueckgewinnsteuer @@ -176,21 +193,41 @@ export function TransitionPanel({ ) } > - + + + {it.decision === "SELL" ? ( + it.positionType === "REAL_ESTATE" ? ( + + setItems((prev) => prev.map((x, idx) => (idx === i ? { ...x, salePrice: v } : x))) + } + /> + ) : ( + {formatChf(it.carryOverValue)} CHF + ) + ) : ( + {formatChf(it.carryOverValue)} CHF + )} + {it.positionType === "REAL_ESTATE" && it.decision === "SELL" ? ( - - setItems((prev) => prev.map((x, idx) => (idx === i ? { ...x, salePrice: v } : x))) + + setItems((prev) => + prev.map((x, idx) => (idx === i ? { ...x, saleTaxRate: e.target.valueAsNumber || 0 } : x)) + ) } /> ) : ( - {formatChf(it.carryOverValue)} CHF + )} @@ -201,7 +238,8 @@ export function TransitionPanel({ Verfuegbares Startkapital fuer neue Phase (aus Verkaeufen): {formatChf(totalAvailableCapital)} CHF

Wird beim Speichern automatisch in "{nextPhaseName}" als verfuegbares Startkapital hinterlegt. - Uebernommene Wertschriften erscheinen dort automatisch mit ihrem Endwert als neuer Startwert. + Gehaltene/uebernommene Positionen erscheinen dort automatisch mit ihrem Endwert (Wertschriften) bzw. + Kaufpreis/Resthypothek (Immobilien) als neue Ausgangswerte.

{error &&

{error}

} diff --git a/src/lib/calculations.ts b/src/lib/calculations.ts index 4d7969f..75e8785 100644 --- a/src/lib/calculations.ts +++ b/src/lib/calculations.ts @@ -14,14 +14,10 @@ export interface SecurityComputed { export interface RealEstateComputed { id: string; name: string; + purchasePrice: number; // fix ueber die Haltedauer, keine Wertsteigerung im vereinfachten Modell startNet: number; - endNetIfKept: number; - sold: boolean; - saleNetProceeds: number | null; - taxAmount: number; - endContribution: number; // was tatsaechlich in die Endvermoegens-Summe der Phase einfliesst - marketValues: number[]; - mortgages: number[]; + endNet: number; + mortgages: number[]; // Index 0 = Start, Index durationYears = Ende } export interface RetirementComputed { @@ -88,20 +84,18 @@ export function computeSecurityYearlyValues( return values; } -export function computeRealEstateYearly( - marketValue: number, +// Vereinfachtes Modell (keine Wertsteigerung): der Kaufpreis bleibt ueber die ganze +// Haltedauer fix, nur die Hypothek sinkt jaehrlich um die Amortisationsrate. +export function computeMortgageYearly( mortgage: number, - valueGrowth: number, amortization: number, durationYears: number -): { marketValues: number[]; mortgages: number[] } { - const marketValues = [floorToThousand(marketValue)]; +): number[] { const mortgages = [floorToThousand(mortgage)]; for (let year = 1; year <= durationYears; year++) { - marketValues.push(floorToThousand(marketValues[year - 1] * (1 + valueGrowth / 100))); mortgages.push(floorToThousand(Math.max(0, mortgages[year - 1] - amortization))); } - return { marketValues, mortgages }; + return mortgages; } function computeRetirement( @@ -149,7 +143,11 @@ function computePhase( const retirement = computeRetirement(household, phase); const effectiveIncome = incomeFromEntries + (retirement?.totalPensionIncome ?? 0); const savingsQuota = effectiveIncome - expenseTotal; - const allocatedSavings = phase.securities.reduce((sum, s) => sum + s.annualContribution, 0); + // Amortisationsraten aller Immobilien zaehlen gleichwertig mit den Sparbeitraegen der + // Wertschriften gegen dieselbe verfuegbare Sparquote (ein gemeinsamer Topf). + const allocatedSavings = + phase.securities.reduce((sum, s) => sum + s.annualContribution, 0) + + phase.realEstates.reduce((sum, re) => sum + re.amortization, 0); const savingsWarning = allocatedSavings > savingsQuota; const securities: SecurityComputed[] = phase.securities.map((s) => { @@ -170,34 +168,14 @@ function computePhase( }); const realEstates: RealEstateComputed[] = phase.realEstates.map((re) => { - const { marketValues, mortgages } = computeRealEstateYearly( - re.marketValue, - re.mortgage, - re.valueGrowth, - re.amortization, - phase.durationYears - ); - const startNet = marketValues[0] - mortgages[0]; - const endNetIfKept = marketValues[phase.durationYears] - mortgages[phase.durationYears]; - const sold = re.salePrice != null; - let saleNetProceeds: number | null = null; - let taxAmount = 0; - if (sold) { - // Vereinfachung gemaess TDD 3.3: Gewinn = Verkaufspreis - urspruenglich erfasster Startwert - const gain = Math.max(0, re.salePrice! - re.marketValue); - taxAmount = gain * (re.saleTaxRate / 100); - saleNetProceeds = floorToThousand(re.salePrice! - taxAmount); - } + const mortgages = computeMortgageYearly(re.mortgage, re.amortization, phase.durationYears); + const purchasePrice = floorToThousand(re.purchasePrice); return { id: re.id, name: re.name, - startNet, - endNetIfKept, - sold, - saleNetProceeds, - taxAmount, - endContribution: sold ? saleNetProceeds! : endNetIfKept, - marketValues, + purchasePrice, + startNet: purchasePrice - mortgages[0], + endNet: purchasePrice - mortgages[phase.durationYears], mortgages, }; }); @@ -213,7 +191,7 @@ function computePhase( const endWealthNominal = securities.reduce((sum, s) => sum + s.endValue, 0) + - realEstates.reduce((sum, re) => sum + re.endContribution, 0) + + realEstates.reduce((sum, re) => sum + re.endNet, 0) + oneTimeNet + (retirement?.lumpSumNetTotal ?? 0); @@ -226,16 +204,13 @@ function computePhase( for (let year = 1; year <= phase.durationYears; year++) { let value = securities.reduce((sum, s) => sum + s.yearly[year], 0) + - realEstates.reduce((sum, re) => sum + (re.marketValues[year] - re.mortgages[year]), 0); + realEstates.reduce((sum, re) => sum + (re.purchasePrice - re.mortgages[year]), 0); if (year === phase.durationYears) { - // Einmalige Ereignisse, Verkaufserloese und Kapitalbezuege schlagen erst am Ende - // der Phase zu Buche (siehe Phasenuebergang, TDD Kapitel 10). + // Einmalige Ereignisse und Kapitalbezuege schlagen erst am Ende der Phase zu + // Buche (siehe Phasenuebergang, TDD Kapitel 10). Immobilien-/Wertschriften- + // Verkaeufe wirken sich nur auf die naechste Phase aus (incomingCapital), nicht + // mehr auf das Endvermoegen dieser Phase selbst. value += oneTimeNet + (retirement?.lumpSumNetTotal ?? 0); - const soldReplacement = realEstates.reduce( - (sum, re) => sum + (re.sold ? re.saleNetProceeds! - (re.marketValues[year] - re.mortgages[year]) : 0), - 0 - ); - value += soldReplacement; } yearlyNominal.push(value); } diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 372e5a5..262bd65 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -74,12 +74,9 @@ export function toPlanInput(plan: PlanWithRelations): PlanInput { realEstates: phase.realEstates.map((re) => ({ id: re.id, name: re.name, - marketValue: re.marketValue, + purchasePrice: re.purchasePrice, mortgage: re.mortgage, - valueGrowth: re.valueGrowth, amortization: re.amortization, - salePrice: re.salePrice, - saleTaxRate: re.saleTaxRate, })), oneTimeEvents: phase.oneTimeEvents.map((e) => ({ id: e.id, diff --git a/src/lib/types.ts b/src/lib/types.ts index b496c00..9c27468 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -53,12 +53,9 @@ export interface SecurityInput { export interface RealEstateInput { id: string; name: string; - marketValue: number; + purchasePrice: number; mortgage: number; - valueGrowth: number; amortization: number; - salePrice: number | null; - saleTaxRate: number; } export interface OneTimeEventInput {