From a1e689167ccc5e258849d49b30b31a14df2b05d4 Mon Sep 17 00:00:00 2001 From: kelle Date: Wed, 8 Jul 2026 20:20:20 +0200 Subject: [PATCH] Automate transition: carried-over securities auto-appear in next phase, sale proceeds tracked as incoming capital with allocation status indicators --- .../migration.sql | 6 ++ prisma/schema.prisma | 44 +++++++----- src/app/api/phases/[phaseId]/route.ts | 1 + .../api/phases/[phaseId]/transition/route.ts | 72 ++++++++++++++++++- src/app/api/plans/[planId]/scenario/route.ts | 2 + src/components/AppShell.tsx | 7 +- src/components/PhaseCard.tsx | 1 + src/components/PhaseForm.tsx | 55 +++++++++++--- src/components/TransitionPanel.tsx | 7 +- src/lib/queries.ts | 2 + src/lib/types.ts | 6 ++ 11 files changed, 172 insertions(+), 31 deletions(-) create mode 100644 prisma/migrations/20260709010000_add_transition_automation/migration.sql diff --git a/prisma/migrations/20260709010000_add_transition_automation/migration.sql b/prisma/migrations/20260709010000_add_transition_automation/migration.sql new file mode 100644 index 0000000..8462b70 --- /dev/null +++ b/prisma/migrations/20260709010000_add_transition_automation/migration.sql @@ -0,0 +1,6 @@ +-- AlterTable +ALTER TABLE "Phase" ADD COLUMN "incomingCapital" DOUBLE PRECISION NOT NULL DEFAULT 0; + +-- AlterTable +ALTER TABLE "Security" ADD COLUMN "carriedBaseValue" DOUBLE PRECISION NOT NULL DEFAULT 0, +ADD COLUMN "carriedFromSecurityId" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6da3e54..d8f2e11 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -104,14 +104,17 @@ model Plan { // Ein Lebensabschnitt innerhalb eines Plans model Phase { - id String @id @default(cuid()) - planId String - plan Plan @relation(fields: [planId], references: [id], onDelete: Cascade) - sequenceNumber Int - name String - durationYears Int - inflationRate Float? - incomeMode IncomeMode @default(HOUSEHOLD) + id String @id @default(cuid()) + planId String + plan Plan @relation(fields: [planId], references: [id], onDelete: Cascade) + sequenceNumber Int + name String + durationYears Int + inflationRate Float? + incomeMode IncomeMode @default(HOUSEHOLD) + // Aus Verkaeufen im Uebergang aus der Vorphase verfuegbares Startkapital (wird beim + // Speichern des Uebergangs automatisch gesetzt, siehe PhaseTransition). + incomingCapital Float @default(0) incomeEntries IncomeEntry[] expenseEntries ExpenseEntry[] @@ -152,16 +155,23 @@ model ExpenseEntry { // Eine Wertschrift innerhalb einer Phase (inkl. jaehrlichem Sparbeitrag) model Security { - id String @id @default(cuid()) - phaseId String - phase Phase @relation(fields: [phaseId], references: [id], onDelete: Cascade) - name String - startValue Float - expectedReturn Float - annualContribution Float @default(0) - ownerTag OwnerTag @default(HOUSEHOLD) + id String @id @default(cuid()) + phaseId String + phase Phase @relation(fields: [phaseId], references: [id], onDelete: Cascade) + name String + startValue Float + expectedReturn Float + annualContribution Float @default(0) + ownerTag OwnerTag @default(HOUSEHOLD) // Steuersatz auf Verkaufsgewinn bei Uebernahme in PhaseTransitionItem (Default 0%, siehe Kap. 9) - saleTaxRate Float @default(0) + saleTaxRate Float @default(0) + // Baseline-Wert bei automatischer Uebernahme aus der Vorphase (0 bei manuell angelegten + // Wertschriften). Dient dazu, im UI zu erkennen, wie viel vom verfuegbaren Startkapital + // bereits (on top of der Uebernahme) zugewiesen wurde. + carriedBaseValue Float @default(0) + // Verweist auf die Wertschrift der Vorphase, aus der automatisch uebernommen wurde + // (nur intern zur Deduplizierung bei wiederholtem Speichern des Uebergangs, kein FK). + carriedFromSecurityId String? transitionItems PhaseTransitionItem[] } diff --git a/src/app/api/phases/[phaseId]/route.ts b/src/app/api/phases/[phaseId]/route.ts index b0c4b04..9b17e57 100644 --- a/src/app/api/phases/[phaseId]/route.ts +++ b/src/app/api/phases/[phaseId]/route.ts @@ -19,6 +19,7 @@ const securitySchema = z.object({ annualContribution: z.number(), ownerTag: z.enum(["PERSON_A", "PERSON_B", "HOUSEHOLD"]), saleTaxRate: z.number().min(0).max(100), + carriedBaseValue: z.number().default(0), }); const realEstateSchema = z.object({ name: z.string().min(1), diff --git a/src/app/api/phases/[phaseId]/transition/route.ts b/src/app/api/phases/[phaseId]/transition/route.ts index 66b1025..979c832 100644 --- a/src/app/api/phases/[phaseId]/transition/route.ts +++ b/src/app/api/phases/[phaseId]/transition/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { prisma } from "@/lib/db"; +import { computeSecurityYearlyValues } from "@/lib/calculations"; const transitionItemSchema = z.object({ positionType: z.enum(["SECURITY", "REAL_ESTATE"]), @@ -50,6 +51,9 @@ 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. export async function PUT( request: NextRequest, { params }: { params: Promise<{ phaseId: string }> } @@ -94,8 +98,74 @@ export async function PUT( ); } + 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 }[] = []; + + 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 || 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; + } + } + const transition = await prisma.$transaction(async (tx) => { await tx.phaseTransition.deleteMany({ where: { fromPhaseId: phaseId } }); + + // Vorherige automatisch uebernommene Wertschriften aus einem frueheren Speichern + // dieses Uebergangs entfernen, damit sie nicht dupliziert werden. Manuell vom + // Benutzer angelegte Wertschriften (carriedFromSecurityId = null) bleiben unberuehrt. + await tx.security.deleteMany({ + where: { + phaseId: nextPhase.id, + carriedFromSecurityId: { in: phase.securities.map((s) => s.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, + }, + }); + } + + await tx.phase.update({ + where: { id: nextPhase.id }, + data: { incomingCapital }, + }); + return tx.phaseTransition.create({ data: { fromPhaseId: phaseId, @@ -114,5 +184,5 @@ export async function PUT( }); }); - return NextResponse.json({ transition }); + return NextResponse.json({ transition, incomingCapital }); } diff --git a/src/app/api/plans/[planId]/scenario/route.ts b/src/app/api/plans/[planId]/scenario/route.ts index ff52242..cac6b1a 100644 --- a/src/app/api/plans/[planId]/scenario/route.ts +++ b/src/app/api/plans/[planId]/scenario/route.ts @@ -55,6 +55,7 @@ export async function POST( durationYears: phase.durationYears, inflationRate: phase.inflationRate, incomeMode: phase.incomeMode, + incomingCapital: phase.incomingCapital, incomeEntries: { create: phase.incomeEntries.map((e) => ({ personId: e.personId, @@ -73,6 +74,7 @@ export async function POST( annualContribution: s.annualContribution, ownerTag: s.ownerTag, saleTaxRate: s.saleTaxRate, + carriedBaseValue: s.carriedBaseValue, })), }, realEstates: { diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index 34bb9de..42548bf 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -220,7 +220,12 @@ export function AppShell({ initialHousehold }: { initialHousehold: HouseholdInpu onChanged={refreshCurrent} /> {nextPhase && ( - + )} ); diff --git a/src/components/PhaseCard.tsx b/src/components/PhaseCard.tsx index 66bc5c5..ba0cdab 100644 --- a/src/components/PhaseCard.tsx +++ b/src/components/PhaseCard.tsx @@ -88,6 +88,7 @@ export function PhaseCard({ {expanded && ( { diff --git a/src/components/PhaseForm.tsx b/src/components/PhaseForm.tsx index fe78aba..39ee1c0 100644 --- a/src/components/PhaseForm.tsx +++ b/src/components/PhaseForm.tsx @@ -58,6 +58,13 @@ export function PhaseForm({ household, phase, onSaved, onCancel }: Props) { const savingsQuota = totalIncome - totalExpense; const allocated = securities.reduce((s, sec) => s + sec.annualContribution, 0); const overAllocated = allocated > savingsQuota; + const savingsRemaining = savingsQuota - allocated > 0.5; + + const allocatedStartCapital = securities.reduce( + (s, sec) => s + Math.max(0, sec.startValue - sec.carriedBaseValue), + 0 + ); + const startCapitalRemaining = phase.incomingCapital - allocatedStartCapital > 0.5; async function handleSave() { setSaving(true); @@ -193,16 +200,9 @@ export function PhaseForm({ household, phase, onSaved, onCancel }: Props) { label="+ Ausgabenposten" onClick={() => setExpenseEntries((prev) => [...prev, { id: tempId(), label: null, amount: 0 }])} /> -
+
Verfuegbare Sparquote (CHF/Jahr): {savingsQuota.toLocaleString("de-CH")} - {" "}— zugewiesen an Wertschriften: {allocated.toLocaleString("de-CH")} - {overAllocated && " ⚠ Die zugewiesenen Sparbeitraege uebersteigen die verfuegbare Sparquote."} + {" "}(Details und Verteilung siehe Wertschriften weiter unten)
@@ -258,10 +258,36 @@ export function PhaseForm({ household, phase, onSaved, onCancel }: Props) { onClick={() => setSecurities((prev) => [ ...prev, - { id: tempId(), name: "", startValue: 0, expectedReturn: 0, annualContribution: 0, ownerTag: "HOUSEHOLD", saleTaxRate: 0 }, + { + id: tempId(), + name: "", + startValue: 0, + expectedReturn: 0, + annualContribution: 0, + ownerTag: "HOUSEHOLD", + saleTaxRate: 0, + carriedBaseValue: 0, + }, ]) } /> +
+
+ + Verfuegbares Startkapital (aus Verkaeufen der Vorphase): {phase.incomingCapital.toLocaleString("de-CH")} CHF + {" "}— zugewiesen: {allocatedStartCapital.toLocaleString("de-CH")} +
+
+ + Verfuegbare Sparquote (CHF/Jahr): {savingsQuota.toLocaleString("de-CH")} + {" "}— zugewiesen: {allocated.toLocaleString("de-CH")} +
+ {overAllocated && ( +
+ ⚠ Die zugewiesenen Sparbeitraege uebersteigen die verfuegbare Sparquote. +
+ )} +
{/* Immobilien */} @@ -461,6 +487,15 @@ function AddButton({ label, onClick }: { label: string; onClick: () => void }) { ); } +function StatusDot({ ok }: { ok: boolean }) { + return ( + + ); +} + function RemoveButton({ onClick }: { onClick: () => void }) { return (