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
+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>}