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
Deploy App / deploy (push) Successful in 1m31s

This commit is contained in:
2026-07-08 21:35:10 +02:00
parent 9609de67ae
commit e9dacc2026
12 changed files with 204 additions and 145 deletions
@@ -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;
+16 -12
View File
@@ -176,19 +176,21 @@ model Security {
transitionItems PhaseTransitionItem[] 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 { model RealEstate {
id String @id @default(cuid()) id String @id @default(cuid())
phaseId String phaseId String
phase Phase @relation(fields: [phaseId], references: [id], onDelete: Cascade) phase Phase @relation(fields: [phaseId], references: [id], onDelete: Cascade)
name String name String
marketValue Float purchasePrice Float
mortgage Float mortgage Float
valueGrowth Float amortization Float
amortization Float // Verweist auf die Immobilie der Vorphase, aus der automatisch uebernommen wurde
salePrice Float? // (nur intern zur Deduplizierung bei wiederholtem Speichern des Uebergangs, kein FK).
// Geschaetzte Grundstueckgewinnsteuer (%), direkt am ausloesenden Ereignis erfasst (Kap. 9) carriedFromRealEstateId String?
saleTaxRate Float @default(20)
transitionItems PhaseTransitionItem[] transitionItems PhaseTransitionItem[]
} }
@@ -242,4 +244,6 @@ model PhaseTransitionItem {
realEstate RealEstate? @relation(fields: [realEstateId], references: [id], onDelete: Cascade) realEstate RealEstate? @relation(fields: [realEstateId], references: [id], onDelete: Cascade)
decision TransitionDecision decision TransitionDecision
salePrice Float? salePrice Float?
// Nur bei Immobilien-Verkauf erfasst (Grundstueckgewinnsteuer in %), siehe RealEstate.
saleTaxRate Float?
} }
+3 -5
View File
@@ -23,12 +23,10 @@ const securitySchema = z.object({
}); });
const realEstateSchema = z.object({ const realEstateSchema = z.object({
name: z.string().min(1), 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(), mortgage: z.number(),
valueGrowth: z.number(),
amortization: z.number(), amortization: z.number(),
salePrice: z.number().nullable().optional(),
saleTaxRate: z.number().min(0).max(100),
}); });
const oneTimeEventSchema = z.object({ const oneTimeEventSchema = z.object({
type: z.enum(["INCOME", "EXPENSE"]), 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 })) }, 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 })) }, expenseEntries: { create: data.expenseEntries.map((e) => ({ ...e, label: e.label ?? null })) },
securities: { create: data.securities }, 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 })) }, oneTimeEvents: { create: data.oneTimeEvents.map((e) => ({ ...e, description: e.description ?? null })) },
retirementInfos: { create: data.retirementInfos }, retirementInfos: { create: data.retirementInfos },
}, },
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { computeSecurityYearlyValues } from "@/lib/calculations"; import { computeMortgageYearly, computeSecurityYearlyValues } from "@/lib/calculations";
import { floorToThousand } from "@/lib/format"; import { floorToThousand } from "@/lib/format";
const transitionItemSchema = z.object({ const transitionItemSchema = z.object({
@@ -10,6 +10,8 @@ const transitionItemSchema = z.object({
realEstateId: z.string().nullable().optional(), realEstateId: z.string().nullable().optional(),
decision: z.enum(["CARRY_OVER", "SELL"]), decision: z.enum(["CARRY_OVER", "SELL"]),
salePrice: z.number().nullable().optional(), 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({ const putTransitionSchema = z.object({
@@ -51,10 +53,10 @@ export async function GET(
}); });
} }
// Speichert die Entscheidungen (Uebernehmen/Verkaufen) fuer jede Position der Vorphase. // Speichert die Entscheidungen (Uebernehmen/Verkaufen bzw. Halten/Verkaufen) fuer jede
// Uebernommene Wertschriften werden automatisch als neue Wertschrift in der Folgephase // Position der Vorphase. Uebernommene/gehaltene Positionen werden automatisch 1:1 (mit
// angelegt (Startwert = Endwert dieser Phase). Verkaufte Positionen fliessen als // zurueckgesetztem Sparbeitrag/Amortisation) in der Folgephase angelegt. Verkaufte
// "verfuegbares Startkapital" (Phase.incomingCapital) in die Folgephase ein. // Positionen fliessen als "verfuegbares Startkapital" (Phase.incomingCapital) ein.
export async function PUT( export async function PUT(
request: NextRequest, request: NextRequest,
{ params }: { params: Promise<{ phaseId: string }> } { params }: { params: Promise<{ phaseId: string }> }
@@ -94,7 +96,7 @@ export async function PUT(
const missing = [...requiredIds].filter((id) => !providedIds.has(id)); const missing = [...requiredIds].filter((id) => !providedIds.has(id));
if (missing.length > 0) { if (missing.length > 0) {
return NextResponse.json( 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 } { status: 400 }
); );
} }
@@ -104,6 +106,7 @@ export async function PUT(
let incomingCapital = 0; let incomingCapital = 0;
const securitiesToCarry: { source: (typeof phase.securities)[number]; endValue: number }[] = []; 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) { for (const item of parsed.data.items) {
if (item.positionType === "SECURITY" && item.securityId) { if (item.positionType === "SECURITY" && item.securityId) {
@@ -125,11 +128,22 @@ export async function PUT(
} }
} else if (item.positionType === "REAL_ESTATE" && item.realEstateId) { } else if (item.positionType === "REAL_ESTATE" && item.realEstateId) {
const realEstate = realEstateById.get(item.realEstateId); const realEstate = realEstateById.get(item.realEstateId);
if (!realEstate || item.decision !== "SELL") continue; if (!realEstate) continue;
const salePrice = item.salePrice ?? 0; const remainingMortgage = computeMortgageYearly(
const gain = Math.max(0, salePrice - realEstate.marketValue); realEstate.mortgage,
const tax = gain * (realEstate.saleTaxRate / 100); realEstate.amortization,
incomingCapital += salePrice - tax; 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) => { const transition = await prisma.$transaction(async (tx) => {
await tx.phaseTransition.deleteMany({ where: { fromPhaseId: phaseId } }); 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 // 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({ await tx.security.deleteMany({
where: { where: {
phaseId: nextPhase.id, phaseId: nextPhase.id,
carriedFromSecurityId: { in: phase.securities.map((s) => s.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) { for (const { source, endValue } of securitiesToCarry) {
await tx.security.create({ 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({ await tx.phase.update({
where: { id: nextPhase.id }, where: { id: nextPhase.id },
data: { incomingCapital }, data: { incomingCapital },
@@ -183,6 +216,7 @@ export async function PUT(
realEstateId: i.realEstateId ?? null, realEstateId: i.realEstateId ?? null,
decision: i.decision, decision: i.decision,
salePrice: i.salePrice ?? null, salePrice: i.salePrice ?? null,
saleTaxRate: i.saleTaxRate ?? null,
})), })),
}, },
}, },
+18 -1
View File
@@ -31,9 +31,12 @@ export async function POST(
const lastPhase = await prisma.phase.findFirst({ const lastPhase = await prisma.phase.findFirst({
where: { planId }, where: { planId },
orderBy: { sequenceNumber: "desc" }, orderBy: { sequenceNumber: "desc" },
include: { incomeEntries: true, expenseEntries: true },
}); });
const nextSequence = (lastPhase?.sequenceNumber ?? 0) + 1; 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({ const phase = await prisma.phase.create({
data: { data: {
planId, planId,
@@ -41,7 +44,21 @@ export async function POST(
name: parsed.data.name, name: parsed.data.name,
durationYears: parsed.data.durationYears, durationYears: parsed.data.durationYears,
inflationRate: parsed.data.inflationRate ?? null, 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, include: phaseInclude,
}); });
+1 -4
View File
@@ -80,12 +80,9 @@ export async function POST(
realEstates: { realEstates: {
create: phase.realEstates.map((re) => ({ create: phase.realEstates.map((re) => ({
name: re.name, name: re.name,
marketValue: re.marketValue, purchasePrice: re.purchasePrice,
mortgage: re.mortgage, mortgage: re.mortgage,
valueGrowth: re.valueGrowth,
amortization: re.amortization, amortization: re.amortization,
salePrice: re.salePrice,
saleTaxRate: re.saleTaxRate,
})), })),
}, },
oneTimeEvents: { oneTimeEvents: {
+1 -1
View File
@@ -63,7 +63,7 @@ export function Dashboard({
computed.phases.map((phase) => { computed.phases.map((phase) => {
const row: Record<string, number | string> = { phase: phase.name }; const row: Record<string, number | string> = { phase: phase.name };
for (const s of phase.securities) row[s.name] = s.endValue; 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; return row;
}), }),
[computed] [computed]
+21 -29
View File
@@ -57,7 +57,11 @@ export function PhaseForm({ household, phase, onSaved, onCancel }: Props) {
const totalIncome = incomeEntries.reduce((s, e) => s + e.amount, 0); const totalIncome = incomeEntries.reduce((s, e) => s + e.amount, 0);
const totalExpense = expenseEntries.reduce((s, e) => s + e.amount, 0); const totalExpense = expenseEntries.reduce((s, e) => s + e.amount, 0);
const savingsQuota = totalIncome - totalExpense; 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 overAllocated = allocated > savingsQuota;
const savingsRemaining = savingsQuota - allocated > 0.5; 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; const startCapitalRemaining = phase.incomingCapital - allocatedStartCapital > 0.5;
async function handleSave() { 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); setSaving(true);
setError(null); setError(null);
try { 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)))} onChange={(v) => setRealEstates((prev) => prev.map((x, idx) => (idx === i ? { ...x, name: v } : x)))}
/> />
<MoneyField <MoneyField
label="Aktueller Marktwert (CHF)" label="Kaufpreis (CHF)"
help="Geschaetzter heutiger Verkehrswert der Liegenschaft." help="Pflichtfeld. Der Kaufpreis bleibt ueber die ganze Haltedauer fix -- es wird keine Wertsteigerung angenommen, nur die Hypothek sinkt durch Amortisation."
value={re.marketValue} value={re.purchasePrice}
onChange={(v) => setRealEstates((prev) => prev.map((x, idx) => (idx === i ? { ...x, marketValue: v } : x)))} onChange={(v) => setRealEstates((prev) => prev.map((x, idx) => (idx === i ? { ...x, purchasePrice: v } : x)))}
/> />
<MoneyField <MoneyField
label="Aktuelle Hypothek (CHF)" label="Hypothek (CHF)"
help="Ausstehender Hypothekarbetrag." help="Ausstehender Hypothekarbetrag zu Beginn der Phase."
value={re.mortgage} value={re.mortgage}
onChange={(v) => setRealEstates((prev) => prev.map((x, idx) => (idx === i ? { ...x, mortgage: v } : x)))} onChange={(v) => setRealEstates((prev) => prev.map((x, idx) => (idx === i ? { ...x, mortgage: v } : x)))}
/> />
<NumberField
label="Wertsteigerung (%/Jahr)"
help="Ihre Annahme zur Wertentwicklung der Immobilie pro Jahr."
step={0.1}
value={re.valueGrowth}
onChange={(v) => setRealEstates((prev) => prev.map((x, idx) => (idx === i ? { ...x, valueGrowth: v } : x)))}
/>
<MoneyField <MoneyField
label="Jaehrliche Amortisation (CHF)" label="Amortisationsrate (CHF/Jahr)"
help="Betrag, um den die Hypothek pro Jahr reduziert wird." help="Betrag, um den die Hypothek pro Jahr reduziert wird. Zaehlt zusammen mit den Sparbeitraegen der Wertschriften gegen die verfuegbare Sparquote."
value={re.amortization} value={re.amortization}
onChange={(v) => setRealEstates((prev) => prev.map((x, idx) => (idx === i ? { ...x, amortization: v } : x)))} onChange={(v) => setRealEstates((prev) => prev.map((x, idx) => (idx === i ? { ...x, amortization: v } : x)))}
/> />
<MoneyField
label="Geschaetzter Verkaufspreis (CHF)"
help="Nur bei geplantem Verkauf am Ende der Phase auszufuellen."
value={re.salePrice ?? 0}
onChange={(v) => setRealEstates((prev) => prev.map((x, idx) => (idx === i ? { ...x, salePrice: v || null } : x)))}
/>
<NumberField
label="Geschaetzte Grundstueckgewinnsteuer (%)"
help="Kantonale Steuer auf den Verkaufsgewinn, ca. 10-30% je nach Kanton und Besitzdauer."
value={re.saleTaxRate}
onChange={(v) => setRealEstates((prev) => prev.map((x, idx) => (idx === i ? { ...x, saleTaxRate: v } : x)))}
/>
<div className="flex items-end"> <div className="flex items-end">
<RemoveButton onClick={() => setRealEstates((prev) => prev.filter((_, idx) => idx !== i))} /> <RemoveButton onClick={() => setRealEstates((prev) => prev.filter((_, idx) => idx !== i))} />
</div> </div>
@@ -348,7 +340,7 @@ export function PhaseForm({ household, phase, onSaved, onCancel }: Props) {
onClick={() => onClick={() =>
setRealEstates((prev) => [ setRealEstates((prev) => [
...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 },
]) ])
} }
/> />
+61 -23
View File
@@ -13,10 +13,13 @@ interface ItemDraft {
name: string; name: string;
decision: TransitionDecision; decision: TransitionDecision;
salePrice: number | null; salePrice: number | null;
// Referenzwerte fuer die Anzeige/Berechnung des verfuegbaren Startkapitals // Nur fuer Immobilien editierbar (poppt bei "Verkaufen" auf); bei Wertschriften der
carryOverValue: number; // fixe, am Wertpapier hinterlegte Steuersatz.
originalValue: number;
saleTaxRate: number; 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({ export function TransitionPanel({
@@ -48,30 +51,42 @@ export function TransitionPanel({
name: s.name, name: s.name,
decision: "CARRY_OVER" as TransitionDecision, decision: "CARRY_OVER" as TransitionDecision,
salePrice: null, salePrice: null,
saleTaxRate: s.saleTaxRate,
carryOverValue: c?.endValue ?? 0, carryOverValue: c?.endValue ?? 0,
originalValue: c?.startValue ?? 0, originalValue: c?.startValue ?? 0,
saleTaxRate: s.saleTaxRate, remainingMortgage: 0,
}; };
}), }),
...phase.realEstates.map((re) => { ...phase.realEstates.map((re) => {
const c = computed.realEstates.find((cr) => cr.id === re.id); const c = computed.realEstates.find((cr) => cr.id === re.id);
const remainingMortgage = c ? c.mortgages[phase.durationYears] : 0;
return { return {
positionType: "REAL_ESTATE" as const, positionType: "REAL_ESTATE" as const,
id: re.id, id: re.id,
name: re.name, name: re.name,
decision: "CARRY_OVER" as TransitionDecision, decision: "CARRY_OVER" as TransitionDecision,
salePrice: re.marketValue, salePrice: re.purchasePrice,
carryOverValue: c?.endNetIfKept ?? 0, saleTaxRate: 20,
originalValue: re.marketValue, carryOverValue: c?.endNet ?? 0,
saleTaxRate: re.saleTaxRate, originalValue: re.purchasePrice,
remainingMortgage,
}; };
}), }),
]; ];
try { try {
const data = await api.get<{ transition: { items: { positionType: string; securityId: string | null; realEstateId: string | null; decision: TransitionDecision; salePrice: number | null }[] } | null }>( const data = await api.get<{
`/api/phases/${phase.id}/transition` 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 (cancelled) return;
if (data.transition) { if (data.transition) {
for (const savedItem of data.transition.items) { for (const savedItem of data.transition.items) {
@@ -81,6 +96,7 @@ export function TransitionPanel({
if (target) { if (target) {
target.decision = savedItem.decision; target.decision = savedItem.decision;
if (savedItem.salePrice != null) target.salePrice = savedItem.salePrice; 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( const totalAvailableCapital = floorToThousand(
items.reduce((sum, it) => { items.reduce((sum, it) => {
if (it.decision === "CARRY_OVER") return sum;
if (it.positionType === "SECURITY") { if (it.positionType === "SECURITY") {
if (it.decision === "CARRY_OVER") return sum;
const gain = Math.max(0, it.carryOverValue - it.originalValue); const gain = Math.max(0, it.carryOverValue - it.originalValue);
const tax = gain * (it.saleTaxRate / 100); const tax = gain * (it.saleTaxRate / 100);
return sum + (it.carryOverValue - tax); return sum + (it.carryOverValue - tax);
} }
if (it.decision === "CARRY_OVER") return sum;
const salePrice = it.salePrice ?? 0; const salePrice = it.salePrice ?? 0;
const gain = Math.max(0, salePrice - it.originalValue); const gain = Math.max(0, salePrice - it.originalValue);
const tax = gain * (it.saleTaxRate / 100); const tax = gain * (it.saleTaxRate / 100);
return sum + (salePrice - tax); return sum + (salePrice - it.remainingMortgage - tax);
}, 0) }, 0)
); );
@@ -134,6 +149,7 @@ export function TransitionPanel({
realEstateId: it.positionType === "REAL_ESTATE" ? it.id : null, realEstateId: it.positionType === "REAL_ESTATE" ? it.id : null,
decision: it.decision, decision: it.decision,
salePrice: it.positionType === "REAL_ESTATE" && it.decision === "SELL" ? it.salePrice : null, salePrice: it.positionType === "REAL_ESTATE" && it.decision === "SELL" ? it.salePrice : null,
saleTaxRate: it.positionType === "REAL_ESTATE" && it.decision === "SELL" ? it.saleTaxRate : null,
})), })),
}); });
setSaved(true); setSaved(true);
@@ -159,7 +175,8 @@ export function TransitionPanel({
<tr className="text-left text-xs text-zinc-500"> <tr className="text-left text-xs text-zinc-500">
<th className="pb-1 font-normal">Position</th> <th className="pb-1 font-normal">Position</th>
<th className="pb-1 font-normal">Entscheidung</th> <th className="pb-1 font-normal">Entscheidung</th>
<th className="pb-1 font-normal">Verkaufspreis / Wert</th> <th className="pb-1 font-normal">Verkaufspreis</th>
<th className="pb-1 font-normal">Grundstueckgewinnsteuer</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -176,21 +193,41 @@ export function TransitionPanel({
) )
} }
> >
<option value="CARRY_OVER">Uebernehmen</option> <option value="CARRY_OVER">{it.positionType === "REAL_ESTATE" ? "Halten" : "Uebernehmen"}</option>
<option value="SELL">Verkaufen</option> <option value="SELL">Verkaufen</option>
</select> </select>
</td> </td>
<td className="py-2 pr-2">
{it.decision === "SELL" ? (
it.positionType === "REAL_ESTATE" ? (
<MoneyInput
className="w-32"
value={it.salePrice ?? 0}
onChange={(v) =>
setItems((prev) => prev.map((x, idx) => (idx === i ? { ...x, salePrice: v } : x)))
}
/>
) : (
<span className="text-xs text-zinc-500">{formatChf(it.carryOverValue)} CHF</span>
)
) : (
<span className="text-xs text-zinc-500">{formatChf(it.carryOverValue)} CHF</span>
)}
</td>
<td className="py-2"> <td className="py-2">
{it.positionType === "REAL_ESTATE" && it.decision === "SELL" ? ( {it.positionType === "REAL_ESTATE" && it.decision === "SELL" ? (
<MoneyInput <input
className="w-32" type="number"
value={it.salePrice ?? 0} className="w-20 rounded-md border border-zinc-300 bg-white px-2 py-1 text-xs dark:border-zinc-600 dark:bg-zinc-900"
onChange={(v) => value={it.saleTaxRate}
setItems((prev) => prev.map((x, idx) => (idx === i ? { ...x, salePrice: v } : x))) onChange={(e) =>
setItems((prev) =>
prev.map((x, idx) => (idx === i ? { ...x, saleTaxRate: e.target.valueAsNumber || 0 } : x))
)
} }
/> />
) : ( ) : (
<span className="text-xs text-zinc-500">{formatChf(it.carryOverValue)} CHF</span> <span className="text-xs text-zinc-500"></span>
)} )}
</td> </td>
</tr> </tr>
@@ -201,7 +238,8 @@ export function TransitionPanel({
Verfuegbares Startkapital fuer neue Phase (aus Verkaeufen): <strong>{formatChf(totalAvailableCapital)} CHF</strong> Verfuegbares Startkapital fuer neue Phase (aus Verkaeufen): <strong>{formatChf(totalAvailableCapital)} CHF</strong>
<p className="mt-1 text-xs text-zinc-500"> <p className="mt-1 text-xs text-zinc-500">
Wird beim Speichern automatisch in &quot;{nextPhaseName}&quot; als verfuegbares Startkapital hinterlegt. Wird beim Speichern automatisch in &quot;{nextPhaseName}&quot; 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.
</p> </p>
</div> </div>
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>} {error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
+24 -49
View File
@@ -14,14 +14,10 @@ export interface SecurityComputed {
export interface RealEstateComputed { export interface RealEstateComputed {
id: string; id: string;
name: string; name: string;
purchasePrice: number; // fix ueber die Haltedauer, keine Wertsteigerung im vereinfachten Modell
startNet: number; startNet: number;
endNetIfKept: number; endNet: number;
sold: boolean; mortgages: number[]; // Index 0 = Start, Index durationYears = Ende
saleNetProceeds: number | null;
taxAmount: number;
endContribution: number; // was tatsaechlich in die Endvermoegens-Summe der Phase einfliesst
marketValues: number[];
mortgages: number[];
} }
export interface RetirementComputed { export interface RetirementComputed {
@@ -88,20 +84,18 @@ export function computeSecurityYearlyValues(
return values; return values;
} }
export function computeRealEstateYearly( // Vereinfachtes Modell (keine Wertsteigerung): der Kaufpreis bleibt ueber die ganze
marketValue: number, // Haltedauer fix, nur die Hypothek sinkt jaehrlich um die Amortisationsrate.
export function computeMortgageYearly(
mortgage: number, mortgage: number,
valueGrowth: number,
amortization: number, amortization: number,
durationYears: number durationYears: number
): { marketValues: number[]; mortgages: number[] } { ): number[] {
const marketValues = [floorToThousand(marketValue)];
const mortgages = [floorToThousand(mortgage)]; const mortgages = [floorToThousand(mortgage)];
for (let year = 1; year <= durationYears; year++) { 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))); mortgages.push(floorToThousand(Math.max(0, mortgages[year - 1] - amortization)));
} }
return { marketValues, mortgages }; return mortgages;
} }
function computeRetirement( function computeRetirement(
@@ -149,7 +143,11 @@ function computePhase(
const retirement = computeRetirement(household, phase); const retirement = computeRetirement(household, phase);
const effectiveIncome = incomeFromEntries + (retirement?.totalPensionIncome ?? 0); const effectiveIncome = incomeFromEntries + (retirement?.totalPensionIncome ?? 0);
const savingsQuota = effectiveIncome - expenseTotal; 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 savingsWarning = allocatedSavings > savingsQuota;
const securities: SecurityComputed[] = phase.securities.map((s) => { const securities: SecurityComputed[] = phase.securities.map((s) => {
@@ -170,34 +168,14 @@ function computePhase(
}); });
const realEstates: RealEstateComputed[] = phase.realEstates.map((re) => { const realEstates: RealEstateComputed[] = phase.realEstates.map((re) => {
const { marketValues, mortgages } = computeRealEstateYearly( const mortgages = computeMortgageYearly(re.mortgage, re.amortization, phase.durationYears);
re.marketValue, const purchasePrice = floorToThousand(re.purchasePrice);
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);
}
return { return {
id: re.id, id: re.id,
name: re.name, name: re.name,
startNet, purchasePrice,
endNetIfKept, startNet: purchasePrice - mortgages[0],
sold, endNet: purchasePrice - mortgages[phase.durationYears],
saleNetProceeds,
taxAmount,
endContribution: sold ? saleNetProceeds! : endNetIfKept,
marketValues,
mortgages, mortgages,
}; };
}); });
@@ -213,7 +191,7 @@ function computePhase(
const endWealthNominal = const endWealthNominal =
securities.reduce((sum, s) => sum + s.endValue, 0) + 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 + oneTimeNet +
(retirement?.lumpSumNetTotal ?? 0); (retirement?.lumpSumNetTotal ?? 0);
@@ -226,16 +204,13 @@ function computePhase(
for (let year = 1; year <= phase.durationYears; year++) { for (let year = 1; year <= phase.durationYears; year++) {
let value = let value =
securities.reduce((sum, s) => sum + s.yearly[year], 0) + 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) { if (year === phase.durationYears) {
// Einmalige Ereignisse, Verkaufserloese und Kapitalbezuege schlagen erst am Ende // Einmalige Ereignisse und Kapitalbezuege schlagen erst am Ende der Phase zu
// der Phase zu Buche (siehe Phasenuebergang, TDD Kapitel 10). // 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); 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); yearlyNominal.push(value);
} }
+1 -4
View File
@@ -74,12 +74,9 @@ export function toPlanInput(plan: PlanWithRelations): PlanInput {
realEstates: phase.realEstates.map((re) => ({ realEstates: phase.realEstates.map((re) => ({
id: re.id, id: re.id,
name: re.name, name: re.name,
marketValue: re.marketValue, purchasePrice: re.purchasePrice,
mortgage: re.mortgage, mortgage: re.mortgage,
valueGrowth: re.valueGrowth,
amortization: re.amortization, amortization: re.amortization,
salePrice: re.salePrice,
saleTaxRate: re.saleTaxRate,
})), })),
oneTimeEvents: phase.oneTimeEvents.map((e) => ({ oneTimeEvents: phase.oneTimeEvents.map((e) => ({
id: e.id, id: e.id,
+1 -4
View File
@@ -53,12 +53,9 @@ export interface SecurityInput {
export interface RealEstateInput { export interface RealEstateInput {
id: string; id: string;
name: string; name: string;
marketValue: number; purchasePrice: number;
mortgage: number; mortgage: number;
valueGrowth: number;
amortization: number; amortization: number;
salePrice: number | null;
saleTaxRate: number;
} }
export interface OneTimeEventInput { export interface OneTimeEventInput {