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
+3 -5
View File
@@ -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;
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,
})),
},
},
+18 -1
View File
@@ -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,
});
+1 -4
View File
@@ -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: {
+1 -1
View File
@@ -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]
+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 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 },
])
}
/>
+61 -23
View File
@@ -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({
<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,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>
</select>
</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">
{it.positionType === "REAL_ESTATE" && it.decision === "SELL" ? (
<MoneyInput
className="w-32"
value={it.salePrice ?? 0}
onChange={(v) =>
setItems((prev) => prev.map((x, idx) => (idx === i ? { ...x, salePrice: v } : x)))
<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">{formatChf(it.carryOverValue)} CHF</span>
<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 &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>
</div>
{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 {
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
View File
@@ -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
View File
@@ -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 {