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