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:
@@ -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.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 "{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>}
|
||||
|
||||
Reference in New Issue
Block a user