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
Deploy App / deploy (push) Successful in 1m31s
This commit is contained in:
@@ -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;
|
||||
+10
-6
@@ -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
|
||||
purchasePrice Float
|
||||
mortgage Float
|
||||
valueGrowth Float
|
||||
amortization Float
|
||||
salePrice Float?
|
||||
// Geschaetzte Grundstueckgewinnsteuer (%), direkt am ausloesenden Ereignis erfasst (Kap. 9)
|
||||
saleTaxRate Float @default(20)
|
||||
// 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?
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
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 gain = Math.max(0, salePrice - realEstate.marketValue);
|
||||
const tax = gain * (realEstate.saleTaxRate / 100);
|
||||
incomingCapital += salePrice - tax;
|
||||
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,
|
||||
})),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -63,7 +63,7 @@ export function Dashboard({
|
||||
computed.phases.map((phase) => {
|
||||
const row: Record<string, number | string> = { 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]
|
||||
|
||||
@@ -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)))}
|
||||
/>
|
||||
<MoneyField
|
||||
label="Aktueller Marktwert (CHF)"
|
||||
help="Geschaetzter heutiger Verkehrswert der Liegenschaft."
|
||||
value={re.marketValue}
|
||||
onChange={(v) => 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)))}
|
||||
/>
|
||||
<MoneyField
|
||||
label="Aktuelle Hypothek (CHF)"
|
||||
help="Ausstehender Hypothekarbetrag."
|
||||
label="Hypothek (CHF)"
|
||||
help="Ausstehender Hypothekarbetrag zu Beginn der Phase."
|
||||
value={re.mortgage}
|
||||
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
|
||||
label="Jaehrliche Amortisation (CHF)"
|
||||
help="Betrag, um den die Hypothek pro Jahr reduziert wird."
|
||||
label="Amortisationsrate (CHF/Jahr)"
|
||||
help="Betrag, um den die Hypothek pro Jahr reduziert wird. Zaehlt zusammen mit den Sparbeitraegen der Wertschriften gegen die verfuegbare Sparquote."
|
||||
value={re.amortization}
|
||||
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">
|
||||
<RemoveButton onClick={() => setRealEstates((prev) => prev.filter((_, idx) => idx !== i))} />
|
||||
</div>
|
||||
@@ -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 },
|
||||
])
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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.positionType === "SECURITY") {
|
||||
if (it.decision === "CARRY_OVER") return sum;
|
||||
if (it.positionType === "SECURITY") {
|
||||
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({
|
||||
<tr className="text-left text-xs text-zinc-500">
|
||||
<th className="pb-1 font-normal">Position</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>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -176,12 +193,13 @@ 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>
|
||||
</select>
|
||||
</td>
|
||||
<td className="py-2">
|
||||
{it.positionType === "REAL_ESTATE" && it.decision === "SELL" ? (
|
||||
<td className="py-2 pr-2">
|
||||
{it.decision === "SELL" ? (
|
||||
it.positionType === "REAL_ESTATE" ? (
|
||||
<MoneyInput
|
||||
className="w-32"
|
||||
value={it.salePrice ?? 0}
|
||||
@@ -191,6 +209,25 @@ export function TransitionPanel({
|
||||
/>
|
||||
) : (
|
||||
<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">
|
||||
{it.positionType === "REAL_ESTATE" && it.decision === "SELL" ? (
|
||||
<input
|
||||
type="number"
|
||||
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"
|
||||
value={it.saleTaxRate}
|
||||
onChange={(e) =>
|
||||
setItems((prev) =>
|
||||
prev.map((x, idx) => (idx === i ? { ...x, saleTaxRate: e.target.valueAsNumber || 0 } : x))
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-zinc-500">—</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -201,7 +238,8 @@ export function TransitionPanel({
|
||||
Verfuegbares Startkapital fuer neue Phase (aus Verkaeufen): <strong>{formatChf(totalAvailableCapital)} CHF</strong>
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
|
||||
|
||||
+24
-49
@@ -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);
|
||||
}
|
||||
|
||||
+1
-4
@@ -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,
|
||||
|
||||
+1
-4
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user