import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { prisma } from "@/lib/db"; import { computeMortgageYearly, computeSecurityYearlyValues } from "@/lib/calculations"; import { floorToThousand } from "@/lib/format"; const transitionItemSchema = z.object({ positionType: z.enum(["SECURITY", "REAL_ESTATE"]), securityId: z.string().nullable().optional(), 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({ items: z.array(transitionItemSchema), }); // Liefert die aktuellen Positionen der Phase (Wertschriften + Immobilien) sowie eine // evtl. bereits vorhandene Entscheidung, damit die UI den Uebergangs-Screen (TDD 4.4) // rendern kann. export async function GET( _request: NextRequest, { params }: { params: Promise<{ phaseId: string }> } ) { const { phaseId } = await params; const phase = await prisma.phase.findUnique({ where: { id: phaseId }, include: { securities: true, realEstates: true }, }); if (!phase) { return NextResponse.json({ error: "Phase nicht gefunden." }, { status: 404 }); } const nextPhase = await prisma.phase.findFirst({ where: { planId: phase.planId, sequenceNumber: phase.sequenceNumber + 1 }, }); const transition = await prisma.phaseTransition.findUnique({ where: { fromPhaseId: phaseId }, include: { items: true }, }); return NextResponse.json({ positions: { securities: phase.securities, realEstates: phase.realEstates, }, nextPhase, transition, }); } // 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 }> } ) { const { phaseId } = await params; const body = await request.json(); const parsed = putTransitionSchema.safeParse(body); if (!parsed.success) { return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }); } const phase = await prisma.phase.findUnique({ where: { id: phaseId }, include: { securities: true, realEstates: true }, }); if (!phase) { return NextResponse.json({ error: "Phase nicht gefunden." }, { status: 404 }); } const nextPhase = await prisma.phase.findFirst({ where: { planId: phase.planId, sequenceNumber: phase.sequenceNumber + 1 }, }); if (!nextPhase) { return NextResponse.json( { error: "Es existiert noch keine Folgephase fuer diesen Uebergang." }, { status: 400 } ); } const requiredIds = new Set([ ...phase.securities.map((s) => `SECURITY:${s.id}`), ...phase.realEstates.map((re) => `REAL_ESTATE:${re.id}`), ]); const providedIds = new Set( parsed.data.items.map((i) => `${i.positionType}:${i.securityId ?? i.realEstateId}`) ); const missing = [...requiredIds].filter((id) => !providedIds.has(id)); if (missing.length > 0) { return NextResponse.json( { error: "Fuer jede bestehende Position muss Uebernehmen/Halten oder Verkaufen gewaehlt werden." }, { status: 400 } ); } const securityById = new Map(phase.securities.map((s) => [s.id, s])); const realEstateById = new Map(phase.realEstates.map((re) => [re.id, re])); 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) { const security = securityById.get(item.securityId); if (!security) continue; const endValue = computeSecurityYearlyValues( security.startValue, security.expectedReturn, security.annualContribution, phase.durationYears )[phase.durationYears]; if (item.decision === "CARRY_OVER") { securitiesToCarry.push({ source: security, endValue }); } else { const gain = Math.max(0, endValue - security.startValue); const tax = gain * (security.saleTaxRate / 100); incomingCapital += endValue - tax; } } else if (item.positionType === "REAL_ESTATE" && item.realEstateId) { const realEstate = realEstateById.get(item.realEstateId); 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; } } } // Auf ein Vielfaches von 1'000 abrunden, damit der Betrag ueber Wertschriften // (die nur in 1'000er-Schritten Sparbeitraege/Startwerte annehmen) vollstaendig // verteilbar bleibt. incomingCapital = floorToThousand(incomingCapital); const transition = await prisma.$transaction(async (tx) => { await tx.phaseTransition.deleteMany({ where: { fromPhaseId: phaseId } }); // Vorherige automatisch uebernommene Positionen aus einem frueheren Speichern // dieses Uebergangs entfernen, damit sie nicht dupliziert werden. Manuell vom // 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({ data: { phaseId: nextPhase.id, name: source.name, startValue: endValue, carriedBaseValue: endValue, expectedReturn: source.expectedReturn, annualContribution: 0, ownerTag: source.ownerTag, saleTaxRate: source.saleTaxRate, carriedFromSecurityId: source.id, }, }); } 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 }, }); return tx.phaseTransition.create({ data: { fromPhaseId: phaseId, toPhaseId: nextPhase.id, items: { create: parsed.data.items.map((i) => ({ positionType: i.positionType, securityId: i.securityId ?? null, realEstateId: i.realEstateId ?? null, decision: i.decision, salePrice: i.salePrice ?? null, saleTaxRate: i.saleTaxRate ?? null, })), }, }, include: { items: true }, }); }); return NextResponse.json({ transition, incomingCapital }); }