diff --git a/prisma/migrations/20260713100000_element_model_rework/migration.sql b/prisma/migrations/20260713100000_element_model_rework/migration.sql new file mode 100644 index 0000000..2aa273f --- /dev/null +++ b/prisma/migrations/20260713100000_element_model_rework/migration.sql @@ -0,0 +1,73 @@ +-- Kern-Rework: finanzielle Elemente auf Plan-Ebene (JSON pro Phase/Uebergang). +-- Fresh-Start (abgestimmt 13.07.2026): bestehende Test-Daten werden verworfen. + +-- Alle bestehenden Daten entfernen (Cascade raeumt Plaene, Phasen, alte Element-Tabellen ab) +DELETE FROM "Household"; + +-- Alte element-spezifische Tabellen entfernen +DROP TABLE IF EXISTS "PhaseTransitionItem" CASCADE; +DROP TABLE IF EXISTS "PhaseTransition" CASCADE; +DROP TABLE IF EXISTS "IncomeEntry" CASCADE; +DROP TABLE IF EXISTS "ExpenseEntry" CASCADE; +DROP TABLE IF EXISTS "Security" CASCADE; +DROP TABLE IF EXISTS "RealEstate" CASCADE; +DROP TABLE IF EXISTS "OneTimeEvent" CASCADE; +DROP TABLE IF EXISTS "RetirementInfo" CASCADE; + +-- Phase: nicht mehr benoetigte Spalten entfernen (VOR dem Drop der genutzten Enums) +ALTER TABLE "Phase" DROP COLUMN IF EXISTS "incomeMode"; +ALTER TABLE "Phase" DROP COLUMN IF EXISTS "incomingCapital"; + +-- Alte Enums entfernen (jetzt von keiner Spalte mehr referenziert) +DROP TYPE IF EXISTS "IncomeMode"; +DROP TYPE IF EXISTS "OwnerTag"; +DROP TYPE IF EXISTS "OneTimeEventType"; +DROP TYPE IF EXISTS "TransitionDecision"; +DROP TYPE IF EXISTS "PositionType"; + +-- Plan: plan-spezifische Pensionsalter-Overrides +ALTER TABLE "Plan" ADD COLUMN "retirementAgeA" INTEGER; +ALTER TABLE "Plan" ADD COLUMN "retirementAgeB" INTEGER; + +-- Neue Enums +CREATE TYPE "OwnerRole" AS ENUM ('PERSON_A', 'PERSON_B', 'HOUSEHOLD'); +CREATE TYPE "ElementCategory" AS ENUM ('INCOME', 'EXPENSE', 'AHV', 'PENSION_FUND', 'PILLAR_3A', 'REAL_ESTATE', 'OTHER_ASSET', 'OTHER_DEBT'); + +-- FinancialElement +CREATE TABLE "FinancialElement" ( + "id" TEXT NOT NULL, + "planId" TEXT NOT NULL, + "category" "ElementCategory" NOT NULL, + "name" TEXT NOT NULL, + "ownerRole" "OwnerRole", + "orderIndex" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "FinancialElement_pkey" PRIMARY KEY ("id") +); + +-- ElementPhaseValue +CREATE TABLE "ElementPhaseValue" ( + "id" TEXT NOT NULL, + "elementId" TEXT NOT NULL, + "phaseId" TEXT NOT NULL, + "data" JSONB NOT NULL, + CONSTRAINT "ElementPhaseValue_pkey" PRIMARY KEY ("id") +); +CREATE UNIQUE INDEX "ElementPhaseValue_elementId_phaseId_key" ON "ElementPhaseValue"("elementId", "phaseId"); + +-- ElementTransitionValue +CREATE TABLE "ElementTransitionValue" ( + "id" TEXT NOT NULL, + "elementId" TEXT NOT NULL, + "fromPhaseId" TEXT NOT NULL, + "data" JSONB NOT NULL, + CONSTRAINT "ElementTransitionValue_pkey" PRIMARY KEY ("id") +); +CREATE UNIQUE INDEX "ElementTransitionValue_elementId_fromPhaseId_key" ON "ElementTransitionValue"("elementId", "fromPhaseId"); + +-- Foreign Keys +ALTER TABLE "FinancialElement" ADD CONSTRAINT "FinancialElement_planId_fkey" FOREIGN KEY ("planId") REFERENCES "Plan"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "ElementPhaseValue" ADD CONSTRAINT "ElementPhaseValue_elementId_fkey" FOREIGN KEY ("elementId") REFERENCES "FinancialElement"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "ElementPhaseValue" ADD CONSTRAINT "ElementPhaseValue_phaseId_fkey" FOREIGN KEY ("phaseId") REFERENCES "Phase"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "ElementTransitionValue" ADD CONSTRAINT "ElementTransitionValue_elementId_fkey" FOREIGN KEY ("elementId") REFERENCES "FinancialElement"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "ElementTransitionValue" ADD CONSTRAINT "ElementTransitionValue_fromPhaseId_fkey" FOREIGN KEY ("fromPhaseId") REFERENCES "Phase"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 09c492b..ad150d1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1,5 +1,9 @@ -// FPT (Financial Planning Tool) — Datenmodell gemaess FDD/TDD Kapitel 2. -// Get a free hosted Postgres database in seconds: `npx create-db` +// FPT (Financial Planning Tool) — Datenmodell. +// Kernkonzept (Rework 07/2026): finanzielle Elemente leben auf PLAN-Ebene und sind +// ueber alle Lebensphasen hinweg dieselbe Entitaet. Pro Element existiert je Lebensphase +// ein Werte-Datensatz (ElementPhaseValue) und je Uebergang ein Entscheid-Datensatz +// (ElementTransitionValue). Die kategorie-/kontextspezifischen Felder liegen als JSON, +// validiert und typisiert in der Applikationsschicht (lib/elements.ts). generator client { provider = "prisma-client" @@ -10,9 +14,6 @@ datasource db { provider = "postgresql" } -// Benutzerkonto: offene Registrierung mit Benutzername + Passwort (bcrypt-Hash). -// Jeder Benutzer hat seinen eigenen Haushalt samt Plaenen -- Daten sind strikt -// pro Konto isoliert. model User { id String @id @default(cuid()) username String @unique @@ -32,33 +33,24 @@ enum PersonRole { PERSON_B } -enum IncomeMode { - PER_PERSON - HOUSEHOLD -} - -enum OwnerTag { +enum OwnerRole { PERSON_A PERSON_B HOUSEHOLD } -enum OneTimeEventType { +enum ElementCategory { INCOME EXPENSE -} - -enum TransitionDecision { - CARRY_OVER - SELL -} - -enum PositionType { - SECURITY + AHV + PENSION_FUND + PILLAR_3A REAL_ESTATE + OTHER_ASSET + OTHER_DEBT } -// Ein Haushalt (1 oder 2 Personen) - Wurzel-Objekt, gehoert genau einem Benutzer +// Ein Haushalt (1 oder 2 Personen), gehoert genau einem Benutzer. model Household { id String @id @default(cuid()) userId String @@ -72,7 +64,7 @@ model Household { plans Plan[] } -// Einzelperson im Haushalt (Alter, geplantes Pensionsalter) +// Einzelperson im Haushalt. retirementAge ist die Standard-Annahme (im Plan uebersteuerbar). model Person { id String @id @default(cuid()) householdId String @@ -81,22 +73,20 @@ model Person { age Int retirementAge Int - incomeEntries IncomeEntry[] - retirementInfos RetirementInfo[] - @@unique([householdId, role]) } -// Eine vollstaendige Phasenkette; kann Szenario eines anderen Plans sein +// Eine vollstaendige Phasenkette; kann Szenario eines anderen Plans sein. +// retirementAgeA/B uebersteuern das Pensionsalter der jeweiligen Person NUR fuer diesen +// Plan (null = Standard aus Person.retirementAge) -- ermoeglicht Fruehpensions-Szenarien. model Plan { - id String @id @default(cuid()) - householdId String - household Household @relation(fields: [householdId], references: [id], onDelete: Cascade) - name String + id String @id @default(cuid()) + householdId String + household Household @relation(fields: [householdId], references: [id], onDelete: Cascade) + name String + retirementAgeA Int? + retirementAgeB Int? - // Szenario-Verzweigung: ein Szenario ist ein eigener Plan mit Verweis auf den Ursprungsplan - // und die Phase, ab der die Ketten divergieren (branchFromPhaseId zeigt auf eine Phase - // dieses neuen Plans, welche die per Deep-Copy duplizierte letzte gemeinsame Phase ist). parentPlanId String? parentPlan Plan? @relation("PlanScenarios", fields: [parentPlanId], references: [id], onDelete: SetNull) scenarios Plan[] @relation("PlanScenarios") @@ -105,151 +95,65 @@ model Plan { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - phases Phase[] + phases Phase[] + elements FinancialElement[] } -// Ein Lebensabschnitt innerhalb eines Plans +// Ein Lebensabschnitt innerhalb eines Plans. Der Phasentyp (Erwerb/Pension/Mischung) +// wird NICHT gespeichert, sondern aus Alter + Pensionsalter abgeleitet (lib/calculations). 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) - // 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[] - securities Security[] - realEstates RealEstate[] - oneTimeEvents OneTimeEvent[] - retirementInfos RetirementInfo[] - - // Uebergang IN diese Phase (diese Phase ist Ziel) bzw. AUS dieser Phase (diese Phase ist Quelle) - transitionIn PhaseTransition? @relation("TransitionTarget") - transitionOut PhaseTransition? @relation("TransitionSource") + id String @id @default(cuid()) + planId String + plan Plan @relation(fields: [planId], references: [id], onDelete: Cascade) + sequenceNumber Int + name String + durationYears Int + inflationRate Float? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + phaseValues ElementPhaseValue[] + transitionValues ElementTransitionValue[] @relation("TransitionFromPhase") + @@unique([planId, sequenceNumber]) } -// Einkommensposten einer Phase (pro Person oder gemeinsam, je nach Phase.incomeMode) -model IncomeEntry { - id String @id @default(cuid()) - phaseId String - phase Phase @relation(fields: [phaseId], references: [id], onDelete: Cascade) - personId String? - person Person? @relation(fields: [personId], references: [id], onDelete: SetNull) - label String? - amount Float +// Ein finanzielles Element (plan-weit): Kategorie + optionale Personenzuordnung. +model FinancialElement { + id String @id @default(cuid()) + planId String + plan Plan @relation(fields: [planId], references: [id], onDelete: Cascade) + category ElementCategory + name String + ownerRole OwnerRole? + orderIndex Int @default(0) + createdAt DateTime @default(now()) + + phaseValues ElementPhaseValue[] + transitionValues ElementTransitionValue[] } -// Ausgabenposten einer Phase (immer Haushaltsebene, generischer Gesamtbetrag) -model ExpenseEntry { - id String @id @default(cuid()) - phaseId String - phase Phase @relation(fields: [phaseId], references: [id], onDelete: Cascade) - label String? - amount Float +// Werte eines Elements INNERHALB einer Lebensphase (kategorie-/kontextspezifisch, JSON). +model ElementPhaseValue { + id String @id @default(cuid()) + elementId String + element FinancialElement @relation(fields: [elementId], references: [id], onDelete: Cascade) + phaseId String + phase Phase @relation(fields: [phaseId], references: [id], onDelete: Cascade) + data Json + + @@unique([elementId, phaseId]) } -// 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) - // Steuersatz auf Verkaufsgewinn bei Uebernahme in PhaseTransitionItem (Default 0%, siehe Kap. 9) - 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[] -} - -// Eine Immobilie innerhalb einer Phase. Vereinfachtes Modell: kein Wertsteigerungsfeld, -// der Kaufpreis bleibt ueber die Haltedauer fix -- einzig die Hypothek sinkt durch -// Amortisation. Verkaufspreis/-steuer werden nicht hier, sondern erst im Uebergangs- -// Screen im Moment des Verkaufs erfasst (siehe PhaseTransitionItem). -model RealEstate { - id String @id @default(cuid()) - phaseId String - phase Phase @relation(fields: [phaseId], references: [id], onDelete: Cascade) - name String - purchasePrice Float - mortgage Float - amortization Float - // Verweist auf die Immobilie der Vorphase, aus der automatisch uebernommen wurde - // (nur intern zur Deduplizierung bei wiederholtem Speichern des Uebergangs, kein FK). - carriedFromRealEstateId String? - - transitionItems PhaseTransitionItem[] -} - -// Einmalige Sondereinnahme/-ausgabe -model OneTimeEvent { +// Entscheid/Werte eines Elements beim UEBERGANG nach der Phase fromPhase (JSON). +model ElementTransitionValue { id String @id @default(cuid()) - phaseId String - phase Phase @relation(fields: [phaseId], references: [id], onDelete: Cascade) - type OneTimeEventType - amount Float - description String? -} - -// Renten-/Kapitalbezugsangaben (nur in Pensionierungsphasen), pro Person -model RetirementInfo { - id String @id @default(cuid()) - phaseId String - phase Phase @relation(fields: [phaseId], references: [id], onDelete: Cascade) - personId String - person Person @relation(fields: [personId], references: [id], onDelete: Cascade) - ahvAmount Float @default(0) - pkPensionAmount Float @default(0) - lumpSumAmount Float @default(0) - // Geschaetzte Kapitalbezugssteuer (%), direkt am auslösenden Ereignis erfasst (Kap. 9) - lumpSumTaxRate Float @default(8) - - @@unique([phaseId, personId]) -} - -// Entscheidungen beim Uebergang zweier Phasen (uebernehmen/verkaufen je Position) -model PhaseTransition { - id String @id @default(cuid()) - fromPhaseId String @unique - fromPhase Phase @relation("TransitionSource", fields: [fromPhaseId], references: [id], onDelete: Cascade) - toPhaseId String @unique - toPhase Phase @relation("TransitionTarget", fields: [toPhaseId], references: [id], onDelete: Cascade) - - items PhaseTransitionItem[] -} - -// Einzelentscheidung fuer eine Position (Wertschrift oder Immobilie) beim Phasenuebergang -model PhaseTransitionItem { - id String @id @default(cuid()) - transitionId String - transition PhaseTransition @relation(fields: [transitionId], references: [id], onDelete: Cascade) - positionType PositionType - securityId String? - security Security? @relation(fields: [securityId], references: [id], onDelete: Cascade) - realEstateId String? - realEstate RealEstate? @relation(fields: [realEstateId], references: [id], onDelete: Cascade) - decision TransitionDecision - salePrice Float? - // Nur bei Immobilien-Verkauf erfasst (Grundstueckgewinnsteuer in %), siehe RealEstate. - saleTaxRate Float? + elementId String + element FinancialElement @relation(fields: [elementId], references: [id], onDelete: Cascade) + fromPhaseId String + fromPhase Phase @relation("TransitionFromPhase", fields: [fromPhaseId], references: [id], onDelete: Cascade) + data Json + + @@unique([elementId, fromPhaseId]) } diff --git a/src/app/api/elements/[elementId]/phase/[phaseId]/route.ts b/src/app/api/elements/[elementId]/phase/[phaseId]/route.ts new file mode 100644 index 0000000..7c4eaad --- /dev/null +++ b/src/app/api/elements/[elementId]/phase/[phaseId]/route.ts @@ -0,0 +1,33 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/db"; +import { getOwnedElement } from "@/lib/queries"; +import { getCurrentUserId } from "@/lib/session"; +import { phaseDataSchema } from "@/lib/elements"; + +// Speichert die Werte eines Elements innerhalb einer Lebensphase (Upsert). +export async function PUT( + request: NextRequest, + { params }: { params: Promise<{ elementId: string; phaseId: string }> } +) { + const userId = await getCurrentUserId(); + if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 }); + const { elementId, phaseId } = await params; + + const element = await getOwnedElement(elementId, userId); + if (!element) return NextResponse.json({ error: "Element nicht gefunden." }, { status: 404 }); + + const phase = await prisma.phase.findFirst({ where: { id: phaseId, planId: element.planId } }); + if (!phase) return NextResponse.json({ error: "Phase nicht gefunden." }, { status: 404 }); + + const body = await request.json(); + const parsed = phaseDataSchema.safeParse(body); + if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 }); + + await prisma.elementPhaseValue.upsert({ + where: { elementId_phaseId: { elementId, phaseId } }, + create: { elementId, phaseId, data: parsed.data }, + update: { data: parsed.data }, + }); + + return NextResponse.json({ ok: true }); +} diff --git a/src/app/api/elements/[elementId]/route.ts b/src/app/api/elements/[elementId]/route.ts new file mode 100644 index 0000000..8d273bc --- /dev/null +++ b/src/app/api/elements/[elementId]/route.ts @@ -0,0 +1,41 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { prisma } from "@/lib/db"; +import { getOwnedElement } from "@/lib/queries"; +import { getCurrentUserId } from "@/lib/session"; + +const patchSchema = z.object({ name: z.string().min(1).max(120) }); + +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ elementId: string }> } +) { + const userId = await getCurrentUserId(); + if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 }); + const { elementId } = await params; + + const element = await getOwnedElement(elementId, userId); + if (!element) return NextResponse.json({ error: "Element nicht gefunden." }, { status: 404 }); + + const body = await request.json(); + const parsed = patchSchema.safeParse(body); + if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 }); + + await prisma.financialElement.update({ where: { id: element.id }, data: { name: parsed.data.name } }); + return NextResponse.json({ ok: true }); +} + +export async function DELETE( + _request: NextRequest, + { params }: { params: Promise<{ elementId: string }> } +) { + const userId = await getCurrentUserId(); + if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 }); + const { elementId } = await params; + + const element = await getOwnedElement(elementId, userId); + if (!element) return NextResponse.json({ error: "Element nicht gefunden." }, { status: 404 }); + + await prisma.financialElement.delete({ where: { id: element.id } }); + return NextResponse.json({ ok: true }); +} diff --git a/src/app/api/elements/[elementId]/transition/[fromPhaseId]/route.ts b/src/app/api/elements/[elementId]/transition/[fromPhaseId]/route.ts new file mode 100644 index 0000000..9a451d4 --- /dev/null +++ b/src/app/api/elements/[elementId]/transition/[fromPhaseId]/route.ts @@ -0,0 +1,33 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/db"; +import { getOwnedElement } from "@/lib/queries"; +import { getCurrentUserId } from "@/lib/session"; +import { transitionDataSchema } from "@/lib/elements"; + +// Speichert den Uebergangs-Entscheid eines Elements nach der Phase fromPhase (Upsert). +export async function PUT( + request: NextRequest, + { params }: { params: Promise<{ elementId: string; fromPhaseId: string }> } +) { + const userId = await getCurrentUserId(); + if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 }); + const { elementId, fromPhaseId } = await params; + + const element = await getOwnedElement(elementId, userId); + if (!element) return NextResponse.json({ error: "Element nicht gefunden." }, { status: 404 }); + + const phase = await prisma.phase.findFirst({ where: { id: fromPhaseId, planId: element.planId } }); + if (!phase) return NextResponse.json({ error: "Phase nicht gefunden." }, { status: 404 }); + + const body = await request.json(); + const parsed = transitionDataSchema.safeParse(body); + if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 }); + + await prisma.elementTransitionValue.upsert({ + where: { elementId_fromPhaseId: { elementId, fromPhaseId } }, + create: { elementId, fromPhaseId, data: parsed.data }, + update: { data: parsed.data }, + }); + + return NextResponse.json({ ok: true }); +} diff --git a/src/app/api/phases/[phaseId]/route.ts b/src/app/api/phases/[phaseId]/route.ts index 0bd317e..240a631 100644 --- a/src/app/api/phases/[phaseId]/route.ts +++ b/src/app/api/phases/[phaseId]/route.ts @@ -1,139 +1,75 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { prisma } from "@/lib/db"; -import { phaseInclude, getOwnedPhase } from "@/lib/queries"; +import { getHouseholdOrNull, getOwnedPhase, getOwnedPlan, toPlanInput } from "@/lib/queries"; import { getCurrentUserId } from "@/lib/session"; - -const incomeEntrySchema = z.object({ - personId: z.string().nullable().optional(), - label: z.string().nullable().optional(), - amount: z.number(), -}); -const expenseEntrySchema = z.object({ - label: z.string().nullable().optional(), - amount: z.number(), -}); -const securitySchema = z.object({ - name: z.string().min(1), - startValue: z.number(), - expectedReturn: z.number(), - 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), - // Muss zwingend angegeben werden (siehe Anforderung: Kaufpreis ist Pflichtfeld). - purchasePrice: z.number().positive("Kaufpreis muss groesser als 0 sein."), - mortgage: z.number(), - amortization: z.number(), -}); -const oneTimeEventSchema = z.object({ - type: z.enum(["INCOME", "EXPENSE"]), - amount: z.number(), - description: z.string().nullable().optional(), -}); -const retirementInfoSchema = z.object({ - personId: z.string().min(1), - ahvAmount: z.number().min(0), - pkPensionAmount: z.number().min(0), - lumpSumAmount: z.number().min(0), - lumpSumTaxRate: z.number().min(0).max(100), -}); +import { maxPhaseDuration } from "@/lib/calculations"; const updatePhaseSchema = z.object({ - name: z.string().min(1).max(120), - durationYears: z.number().int().min(1).max(80), + name: z.string().min(1).max(120).optional(), + durationYears: z.number().int().min(1).max(80).optional(), inflationRate: z.number().min(-20).max(50).nullable().optional(), - incomeMode: z.enum(["PER_PERSON", "HOUSEHOLD"]), - incomeEntries: z.array(incomeEntrySchema).default([]), - expenseEntries: z.array(expenseEntrySchema).default([]), - securities: z.array(securitySchema).default([]), - realEstates: z.array(realEstateSchema).default([]), - oneTimeEvents: z.array(oneTimeEventSchema).default([]), - retirementInfos: z.array(retirementInfoSchema).default([]), }); -// Ersetzt eine Phase vollstaendig (Basisfelder + alle Unter-Sammlungen). Fuer ein -// Single-User-Tool ohne nennenswerte Nebenlaeufigkeit ist ein "delete + recreate" der -// Kindobjekte einfacher und robuster als granulares Diffing pro Zeile. export async function PUT( request: NextRequest, { params }: { params: Promise<{ phaseId: string }> } ) { const userId = await getCurrentUserId(); - if (!userId) { - return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 }); - } + if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 }); const { phaseId } = await params; - const body = await request.json(); - const parsed = updatePhaseSchema.safeParse(body); - if (!parsed.success) { - return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }); - } - const data = parsed.data; const existing = await getOwnedPhase(phaseId, userId); - if (!existing) { - return NextResponse.json({ error: "Phase nicht gefunden." }, { status: 404 }); + if (!existing) return NextResponse.json({ error: "Phase nicht gefunden." }, { status: 404 }); + + const body = await request.json(); + const parsed = updatePhaseSchema.safeParse(body); + if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 }); + + let duration = parsed.data.durationYears; + if (duration != null) { + // Dauer ans naechste Pensionsereignis kappen (Jahre vor dieser Phase). + const household = await getHouseholdOrNull(userId); + const plan = await getOwnedPlan(existing.planId, userId); + if (household && plan) { + const planInput = toPlanInput(plan); + const yearsBefore = planInput.phases + .filter((p) => p.sequenceNumber < existing.sequenceNumber) + .reduce((s, p) => s + p.durationYears, 0); + const cap = maxPhaseDuration(household.persons, planInput, yearsBefore); + if (cap != null) duration = Math.min(duration, cap); + duration = Math.max(1, duration); + } } - const phase = await prisma.$transaction(async (tx) => { - await Promise.all([ - tx.incomeEntry.deleteMany({ where: { phaseId } }), - tx.expenseEntry.deleteMany({ where: { phaseId } }), - tx.security.deleteMany({ where: { phaseId } }), - tx.realEstate.deleteMany({ where: { phaseId } }), - tx.oneTimeEvent.deleteMany({ where: { phaseId } }), - tx.retirementInfo.deleteMany({ where: { phaseId } }), - ]); - - return tx.phase.update({ - where: { id: phaseId }, - data: { - name: data.name, - durationYears: data.durationYears, - inflationRate: data.inflationRate ?? null, - incomeMode: data.incomeMode, - 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 }, - oneTimeEvents: { create: data.oneTimeEvents.map((e) => ({ ...e, description: e.description ?? null })) }, - retirementInfos: { create: data.retirementInfos }, - }, - include: phaseInclude, - }); + const phase = await prisma.phase.update({ + where: { id: phaseId }, + data: { + name: parsed.data.name ?? undefined, + durationYears: duration ?? undefined, + inflationRate: parsed.data.inflationRate === undefined ? undefined : parsed.data.inflationRate, + }, }); - - return NextResponse.json({ phase }); + return NextResponse.json({ phase: { id: phase.id } }); } -// Eine Phase kann nur geloescht werden, wenn sie die letzte in der Kette ist -- so -// bleibt die Verkettung (Schlussvermoegen = Startvermoegen der Folgephase) immer intakt. +// Nur die letzte Phase kann geloescht werden (Verkettung bleibt intakt). export async function DELETE( _request: NextRequest, { params }: { params: Promise<{ phaseId: string }> } ) { const userId = await getCurrentUserId(); - if (!userId) { - return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 }); - } + if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 }); const { phaseId } = await params; - const phase = await getOwnedPhase(phaseId, userId); - if (!phase) { - return NextResponse.json({ error: "Phase nicht gefunden." }, { status: 404 }); - } - const laterPhase = await prisma.phase.findFirst({ + const phase = await getOwnedPhase(phaseId, userId); + if (!phase) return NextResponse.json({ error: "Phase nicht gefunden." }, { status: 404 }); + + const later = await prisma.phase.findFirst({ where: { planId: phase.planId, sequenceNumber: { gt: phase.sequenceNumber } }, }); - if (laterPhase) { - return NextResponse.json( - { error: "Nur die letzte Phase eines Plans kann geloescht werden." }, - { status: 400 } - ); + if (later) { + return NextResponse.json({ error: "Nur die letzte Phase kann geloescht werden." }, { status: 400 }); } await prisma.phase.delete({ where: { id: phaseId } }); diff --git a/src/app/api/phases/[phaseId]/transition/route.ts b/src/app/api/phases/[phaseId]/transition/route.ts deleted file mode 100644 index e185ebc..0000000 --- a/src/app/api/phases/[phaseId]/transition/route.ts +++ /dev/null @@ -1,237 +0,0 @@ -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"; -import { getCurrentUserId } from "@/lib/session"; - -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 userId = await getCurrentUserId(); - if (!userId) { - return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 }); - } - const { phaseId } = await params; - const phase = await prisma.phase.findFirst({ - where: { id: phaseId, plan: { household: { userId } } }, - 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 userId = await getCurrentUserId(); - if (!userId) { - return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 }); - } - 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.findFirst({ - where: { id: phaseId, plan: { household: { userId } } }, - 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 }); -} diff --git a/src/app/api/plans/[planId]/elements/route.ts b/src/app/api/plans/[planId]/elements/route.ts new file mode 100644 index 0000000..9fb9a71 --- /dev/null +++ b/src/app/api/plans/[planId]/elements/route.ts @@ -0,0 +1,69 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { prisma } from "@/lib/db"; +import { getOwnedPlan } from "@/lib/queries"; +import { getCurrentUserId } from "@/lib/session"; +import { PERSON_ONLY_CATEGORIES } from "@/lib/elements"; + +const createSchema = z.object({ + category: z.enum([ + "INCOME", + "EXPENSE", + "AHV", + "PENSION_FUND", + "PILLAR_3A", + "REAL_ESTATE", + "OTHER_ASSET", + "OTHER_DEBT", + ]), + name: z.string().min(1).max(120), + ownerRole: z.enum(["PERSON_A", "PERSON_B", "HOUSEHOLD"]).nullable().optional(), +}); + +// Legt ein neues finanzielles Element (plan-weit) an. Personen-Pflicht je Kategorie. +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ planId: string }> } +) { + const userId = await getCurrentUserId(); + if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 }); + const { planId } = await params; + + const plan = await getOwnedPlan(planId, userId); + if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 }); + + const body = await request.json(); + const parsed = createSchema.safeParse(body); + if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 }); + + const { category, name } = parsed.data; + let ownerRole = parsed.data.ownerRole ?? null; + + if (PERSON_ONLY_CATEGORIES.includes(category)) { + if (ownerRole !== "PERSON_A" && ownerRole !== "PERSON_B") { + return NextResponse.json( + { error: "Diese Kategorie muss einer Person zugeordnet werden." }, + { status: 400 } + ); + } + } else if (ownerRole == null) { + ownerRole = "HOUSEHOLD"; + } + + const maxOrder = await prisma.financialElement.aggregate({ + where: { planId: plan.id }, + _max: { orderIndex: true }, + }); + + const element = await prisma.financialElement.create({ + data: { + planId: plan.id, + category, + name, + ownerRole, + orderIndex: (maxOrder._max.orderIndex ?? 0) + 1, + }, + }); + + return NextResponse.json({ element: { id: element.id } }, { status: 201 }); +} diff --git a/src/app/api/plans/[planId]/phases/route.ts b/src/app/api/plans/[planId]/phases/route.ts index b18e33b..b233fa2 100644 --- a/src/app/api/plans/[planId]/phases/route.ts +++ b/src/app/api/plans/[planId]/phases/route.ts @@ -1,72 +1,116 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { prisma } from "@/lib/db"; -import { phaseInclude } from "@/lib/queries"; +import { getHouseholdOrNull, getOwnedPlan, toHouseholdInput, toPlanInput } from "@/lib/queries"; import { getCurrentUserId } from "@/lib/session"; +import { Prisma } from "@/generated/prisma/client"; +import { computePlan, maxPhaseDuration } from "@/lib/calculations"; +import { num, type PhaseData } from "@/lib/elements"; const createPhaseSchema = z.object({ - name: z.string().min(1).max(120), - durationYears: z.number().int().min(1).max(80), - inflationRate: z.number().min(-20).max(50).nullable().optional(), - incomeMode: z.enum(["PER_PERSON", "HOUSEHOLD"]).default("HOUSEHOLD"), + name: z.string().min(1).max(120).optional(), + durationYears: z.number().int().min(1).max(80).optional(), }); -// Fuegt eine neue Lebensabschnittsphase am Ende der Phasenkette eines Plans an -// (TDD Kapitel 3: Phasen werden chronologisch aneinandergereiht). +// Legt eine neue Lebensphase am Ende der Kette an. Die Dauer wird ans naechste +// Pensionsereignis gekappt. Fuer bestehende Elemente werden die Werte 1:1 bzw. mit +// den fortgeschriebenen Endbestaenden aus der Vorphase vorbelegt. export async function POST( request: NextRequest, { params }: { params: Promise<{ planId: string }> } ) { const userId = await getCurrentUserId(); - if (!userId) { - return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 }); - } + if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 }); const { planId } = await params; - const body = await request.json(); + + const household = await getHouseholdOrNull(userId); + if (!household) return NextResponse.json({ error: "Kein Haushalt vorhanden." }, { status: 400 }); + const plan = await getOwnedPlan(planId, userId); + if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 }); + + const body = await request.json().catch(() => ({})); const parsed = createPhaseSchema.safeParse(body); - if (!parsed.success) { - return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }); - } + if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 }); - const plan = await prisma.plan.findFirst({ where: { id: planId, household: { userId } } }); - if (!plan) { - return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 }); - } + const householdInput = toHouseholdInput(household); + const planInput = toPlanInput(plan); + const yearsBefore = planInput.phases.reduce((s, p) => s + p.durationYears, 0); + const cap = maxPhaseDuration(household.persons, planInput, yearsBefore); - const lastPhase = await prisma.phase.findFirst({ - where: { planId }, - orderBy: { sequenceNumber: "desc" }, - include: { incomeEntries: true, expenseEntries: true }, + let duration = parsed.data.durationYears ?? (cap ?? 10); + if (cap != null) duration = Math.min(duration, cap); + duration = Math.max(1, duration); + + const nextSequence = planInput.phases.length + 1; + + // Phasentyp der neuen Phase fuer den Default-Namen bestimmen. + const anyRetiredAtStart = household.persons.some((p) => { + const ra = p.role === "PERSON_A" ? planInput.retirementAgeA ?? p.retirementAge : planInput.retirementAgeB ?? p.retirementAge; + return p.age + yearsBefore >= ra; }); - const nextSequence = (lastPhase?.sequenceNumber ?? 0) + 1; + const defaultName = + parsed.data.name ?? (nextSequence === 1 ? "Erste Lebensphase" : anyRetiredAtStart ? "Pensionsphase" : "Erwerbsphase"); - // 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, - sequenceNumber: nextSequence, - name: parsed.data.name, - durationYears: parsed.data.durationYears, - inflationRate: parsed.data.inflationRate ?? null, - 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, + // Endbestaende der bisher letzten Phase (fuer Carry-Vorbelegung). + const prevComputed = planInput.phases.length > 0 ? computePlan(planInput, householdInput) : null; + const lastPhaseId = planInput.phases.at(-1)?.id; + const prevPhase = prevComputed?.phases.find((p) => p.id === lastPhaseId) ?? null; + const prevElemById = new Map((prevPhase?.elements ?? []).map((e) => [e.elementId, e])); + + const phase = await prisma.$transaction(async (tx) => { + const created = await tx.phase.create({ + data: { + planId: plan.id, + sequenceNumber: nextSequence, + name: defaultName, + durationYears: duration, + }, + }); + + // Carry-Vorbelegung fuer bestehende Elemente. + for (const e of planInput.elements) { + const prev = prevElemById.get(e.id); + if (prev && prev.status !== "ACTIVE") continue; // verkauft/getilgt -> nicht mehr fortfuehren + const prevData: PhaseData = e.phaseValues[lastPhaseId ?? ""] ?? {}; + const data: PhaseData = buildCarryData(e.category, prevData, prev?.endValue); + await tx.elementPhaseValue.create({ + data: { elementId: e.id, phaseId: created.id, data: data as Prisma.InputJsonValue }, + }); + } + + return created; }); - return NextResponse.json({ phase }, { status: 201 }); + return NextResponse.json({ phase: { id: phase.id } }, { status: 201 }); +} + +function buildCarryData( + category: string, + prev: PhaseData, + prevEndValue: number | undefined +): PhaseData { + const endVal = Math.max(0, Math.round(prevEndValue ?? 0)); + switch (category) { + case "INCOME": + case "EXPENSE": + return { amount: num(prev.amount) }; + case "AHV": + return { gapYears: 0 }; + case "PENSION_FUND": + case "PILLAR_3A": + return { currentValue: endVal, annualContribution: num(prev.annualContribution), expectedReturn: num(prev.expectedReturn) }; + case "OTHER_ASSET": + return { startValue: endVal, annualContribution: num(prev.annualContribution), expectedReturn: num(prev.expectedReturn) }; + case "REAL_ESTATE": + // endValue = purchase - mortgage; Hypothek fortschreiben ueber purchasePrice - endValue. + return { + purchasePrice: num(prev.purchasePrice), + mortgage: Math.max(0, num(prev.purchasePrice) - endVal), + amortization: num(prev.amortization), + }; + case "OTHER_DEBT": + return { startValue: Math.abs(endVal), annualRepayment: num(prev.annualRepayment) }; + default: + return {}; + } } diff --git a/src/app/api/plans/[planId]/route.ts b/src/app/api/plans/[planId]/route.ts index 56f23e2..95432f6 100644 --- a/src/app/api/plans/[planId]/route.ts +++ b/src/app/api/plans/[planId]/route.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; import { prisma } from "@/lib/db"; import { toHouseholdInput, toPlanInput, getHouseholdOrNull, getOwnedPlan } from "@/lib/queries"; import { getCurrentUserId } from "@/lib/session"; @@ -9,19 +10,14 @@ export async function GET( { params }: { params: Promise<{ planId: string }> } ) { const userId = await getCurrentUserId(); - if (!userId) { - return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 }); - } + if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 }); const { planId } = await params; + const household = await getHouseholdOrNull(userId); - if (!household) { - return NextResponse.json({ error: "Kein Haushalt vorhanden." }, { status: 400 }); - } + if (!household) return NextResponse.json({ error: "Kein Haushalt vorhanden." }, { status: 400 }); const plan = await getOwnedPlan(planId, userId); - if (!plan) { - return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 }); - } + if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 }); const householdInput = toHouseholdInput(household); const planInput = toPlanInput(plan); @@ -30,19 +26,47 @@ export async function GET( return NextResponse.json({ plan: planInput, computed }); } +const patchSchema = z.object({ + name: z.string().min(1).max(120).optional(), + retirementAgeA: z.number().int().min(30).max(100).nullable().optional(), + retirementAgeB: z.number().int().min(30).max(100).nullable().optional(), +}); + +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ planId: string }> } +) { + const userId = await getCurrentUserId(); + if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 }); + const { planId } = await params; + + const plan = await prisma.plan.findFirst({ where: { id: planId, household: { userId } } }); + if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 }); + + const body = await request.json(); + const parsed = patchSchema.safeParse(body); + if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 }); + + const updated = await prisma.plan.update({ + where: { id: plan.id }, + data: { + name: parsed.data.name ?? undefined, + retirementAgeA: parsed.data.retirementAgeA === undefined ? undefined : parsed.data.retirementAgeA, + retirementAgeB: parsed.data.retirementAgeB === undefined ? undefined : parsed.data.retirementAgeB, + }, + }); + return NextResponse.json({ plan: { id: updated.id, name: updated.name } }); +} + export async function DELETE( _request: NextRequest, { params }: { params: Promise<{ planId: string }> } ) { const userId = await getCurrentUserId(); - if (!userId) { - return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 }); - } + if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 }); const { planId } = await params; const plan = await prisma.plan.findFirst({ where: { id: planId, household: { userId } } }); - if (!plan) { - return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 }); - } + if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 }); await prisma.plan.delete({ where: { id: plan.id } }); return NextResponse.json({ ok: true }); } diff --git a/src/app/api/plans/[planId]/scenario/route.ts b/src/app/api/plans/[planId]/scenario/route.ts index e774556..1dbb951 100644 --- a/src/app/api/plans/[planId]/scenario/route.ts +++ b/src/app/api/plans/[planId]/scenario/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { prisma } from "@/lib/db"; -import { phaseInclude, getOwnedPlan } from "@/lib/queries"; +import { getOwnedPlan } from "@/lib/queries"; import { getCurrentUserId } from "@/lib/session"; const scenarioSchema = z.object({ @@ -9,114 +9,87 @@ const scenarioSchema = z.object({ branchFromPhaseId: z.string().min(1), }); -// Erstellt ein neues Szenario als Kopie eines bestehenden Plans ab einer gewaehlten -// Phase (inklusive). Die Phasenkette bis zu diesem Punkt wird per Deep-Copy dupliziert; -// ab dort kann der Benutzer die Kette unabhaengig weiterentwickeln (TDD Kapitel 13). +// Erstellt ein Szenario als Deep-Copy eines Plans bis zur Verzweigungsphase (inkl.). export async function POST( request: NextRequest, { params }: { params: Promise<{ planId: string }> } ) { const userId = await getCurrentUserId(); - if (!userId) { - return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 }); - } + if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 }); const { planId } = await params; + const body = await request.json(); const parsed = scenarioSchema.safeParse(body); - if (!parsed.success) { - return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }); - } + if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 }); - const sourcePlan = await getOwnedPlan(planId, userId); - if (!sourcePlan) { - return NextResponse.json({ error: "Ursprungsplan nicht gefunden." }, { status: 404 }); - } + const source = await getOwnedPlan(planId, userId); + if (!source) return NextResponse.json({ error: "Ursprungsplan nicht gefunden." }, { status: 404 }); - const branchPhase = sourcePlan.phases.find((p) => p.id === parsed.data.branchFromPhaseId); - if (!branchPhase) { - return NextResponse.json({ error: "Verzweigungsphase nicht gefunden." }, { status: 404 }); - } + const branchPhase = source.phases.find((p) => p.id === parsed.data.branchFromPhaseId); + if (!branchPhase) return NextResponse.json({ error: "Verzweigungsphase nicht gefunden." }, { status: 404 }); - const phasesToCopy = sourcePlan.phases + const copiedPhases = source.phases .filter((p) => p.sequenceNumber <= branchPhase.sequenceNumber) .sort((a, b) => a.sequenceNumber - b.sequenceNumber); + const copiedPhaseIds = new Set(copiedPhases.map((p) => p.id)); const newPlanId = await prisma.$transaction(async (tx) => { const newPlan = await tx.plan.create({ data: { - householdId: sourcePlan.householdId, + householdId: source.householdId, name: parsed.data.name, - parentPlanId: sourcePlan.id, + retirementAgeA: source.retirementAgeA, + retirementAgeB: source.retirementAgeB, + parentPlanId: source.id, }, }); + // Phasen kopieren (alte -> neue Id). + const phaseIdMap = new Map(); let lastNewPhaseId = ""; - for (const phase of phasesToCopy) { - const newPhase = await tx.phase.create({ + for (const phase of copiedPhases) { + const created = await tx.phase.create({ data: { planId: newPlan.id, sequenceNumber: phase.sequenceNumber, name: phase.name, durationYears: phase.durationYears, inflationRate: phase.inflationRate, - incomeMode: phase.incomeMode, - incomingCapital: phase.incomingCapital, - incomeEntries: { - create: phase.incomeEntries.map((e) => ({ - personId: e.personId, - label: e.label, - amount: e.amount, - })), - }, - expenseEntries: { - create: phase.expenseEntries.map((e) => ({ label: e.label, amount: e.amount })), - }, - securities: { - create: phase.securities.map((s) => ({ - name: s.name, - startValue: s.startValue, - expectedReturn: s.expectedReturn, - annualContribution: s.annualContribution, - ownerTag: s.ownerTag, - saleTaxRate: s.saleTaxRate, - carriedBaseValue: s.carriedBaseValue, - })), - }, - realEstates: { - create: phase.realEstates.map((re) => ({ - name: re.name, - purchasePrice: re.purchasePrice, - mortgage: re.mortgage, - amortization: re.amortization, - })), - }, - oneTimeEvents: { - create: phase.oneTimeEvents.map((e) => ({ - type: e.type, - amount: e.amount, - description: e.description, - })), - }, - retirementInfos: { - create: phase.retirementInfos.map((r) => ({ - personId: r.personId, - ahvAmount: r.ahvAmount, - pkPensionAmount: r.pkPensionAmount, - lumpSumAmount: r.lumpSumAmount, - lumpSumTaxRate: r.lumpSumTaxRate, - })), - }, }, - include: phaseInclude, }); - lastNewPhaseId = newPhase.id; + phaseIdMap.set(phase.id, created.id); + lastNewPhaseId = created.id; } - await tx.plan.update({ - where: { id: newPlan.id }, - data: { branchFromPhaseId: lastNewPhaseId }, - }); + // Elemente + deren Phasen-/Uebergangswerte kopieren. + for (const el of source.elements) { + const newEl = await tx.financialElement.create({ + data: { + planId: newPlan.id, + category: el.category, + name: el.name, + ownerRole: el.ownerRole, + orderIndex: el.orderIndex, + }, + }); + for (const pv of el.phaseValues) { + const newPhaseId = phaseIdMap.get(pv.phaseId); + if (!newPhaseId) continue; + await tx.elementPhaseValue.create({ + data: { elementId: newEl.id, phaseId: newPhaseId, data: pv.data as object }, + }); + } + for (const tv of el.transitionValues) { + if (!copiedPhaseIds.has(tv.fromPhaseId)) continue; + const newFromId = phaseIdMap.get(tv.fromPhaseId); + if (!newFromId) continue; + await tx.elementTransitionValue.create({ + data: { elementId: newEl.id, fromPhaseId: newFromId, data: tv.data as object }, + }); + } + } + await tx.plan.update({ where: { id: newPlan.id }, data: { branchFromPhaseId: lastNewPhaseId } }); return newPlan.id; }); diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index f724ae4..5534b12 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -10,8 +10,7 @@ import { Trash2, X, } from "lucide-react"; -import { PhaseCard, formatAges } from "@/components/PhaseCard"; -import { TransitionPanel } from "@/components/TransitionPanel"; +import { PlanView } from "@/components/PlanView"; import { Dashboard } from "@/components/Dashboard"; import { HouseholdSettings } from "@/components/HouseholdSettings"; import { ProfileMenu } from "@/components/ProfileMenu"; @@ -81,16 +80,6 @@ export function AppShell({ if (selectedPlanId) loadDetail(selectedPlanId); } - async function handleAddPhase() { - if (!selectedPlanId || !detail) return; - await api.post(`/api/plans/${selectedPlanId}/phases`, { - name: detail.plan.phases.length === 0 ? "Erste Lebensphase" : `Neue Phase ${detail.plan.phases.length + 1}`, - durationYears: 10, - incomeMode: "HOUSEHOLD", - }); - refreshCurrent(); - } - async function handleDeletePlan(id: string) { if (!confirm("Diesen Plan wirklich loeschen?")) return; await api.delete(`/api/plans/${id}`); @@ -232,16 +221,7 @@ export function AppShell({ {!loading && detail && selectedPlanId && (
- {/* Plan-Kopf mit Aktionen */}
- {detail.plan.phases.length > 0 && (
- {/* Phasen mit Lebenslinie */} -
- {detail.plan.phases.map((phase, i) => { - const computedPhase = detail.computed.phases.find((c) => c.id === phase.id)!; - const nextPhase = detail.plan.phases[i + 1]; - const startAges = computedPhase.ages.map((a) => a.startAge).join("·"); - return ( -
- {/* Lebenslinie */} -
-
- {startAges} -
-
-
-
- - {nextPhase && ( - - )} -
-
- ); - })} - {detail.computed.phases.length > 0 && ( -
-
- {detail.computed.phases[detail.computed.phases.length - 1].ages - .map((a) => a.endAge) - .join("·")} -
-
- )} -
- - {detail.plan.phases.length === 0 && ( -

- Dieser Plan hat noch keine Phasen. Fuegen Sie oben die erste Lebensphase hinzu. -

- )} + {detail.plan.phases.length > 0 && ( diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx index 9992f05..49c59c7 100644 --- a/src/components/Dashboard.tsx +++ b/src/components/Dashboard.tsx @@ -50,23 +50,28 @@ export function Dashboard({ return result; }, [plan.name, computed, compareIds, compareData, allPlans]); + const ASSET_CATS = ["PENSION_FUND", "PILLAR_3A", "REAL_ESTATE", "OTHER_ASSET"]; const barKeys = useMemo(() => { const keys = new Set(); for (const phase of computed.phases) { - for (const s of phase.securities) keys.add(s.name); - for (const re of phase.realEstates) keys.add(re.name); + for (const el of phase.elements) { + if (ASSET_CATS.includes(el.category) && el.endValue > 0) keys.add(el.name); + } } return Array.from(keys); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [computed]); const barData = useMemo( () => computed.phases.map((phase) => { const row: Record = { phase: phase.name }; - for (const s of phase.securities) row[s.name] = s.endValue; - for (const re of phase.realEstates) row[re.name] = re.endNet; + for (const el of phase.elements) { + if (ASSET_CATS.includes(el.category) && el.endValue > 0) row[el.name] = el.endValue; + } return row; }), + // eslint-disable-next-line react-hooks/exhaustive-deps [computed] ); diff --git a/src/components/ElementDetail.tsx b/src/components/ElementDetail.tsx new file mode 100644 index 0000000..80670da --- /dev/null +++ b/src/components/ElementDetail.tsx @@ -0,0 +1,349 @@ +"use client"; + +import { useState } from "react"; +import { Trash2 } from "lucide-react"; +import { MoneyField, NumberField, SelectField } from "@/components/FormField"; +import { api } from "@/lib/api-client"; +import { CATEGORY_LABELS, num } from "@/lib/elements"; +import { PILLAR_3A_MAX_ANNUAL } from "@/lib/constants"; +import type { ElementCategory, PhaseData, TransitionData } from "@/lib/elements"; + +export interface CellContext { + kind: "phase" | "transition"; + phaseId: string; // bei transition: die fromPhaseId + ownerWorking: boolean; + isConsumption: boolean; + durationYears: number; + isRetirementTransition: boolean; + carriedEndValue: number; // Endwert des Elements in der (Vor-)Phase, fuer Bezugs-Maxima +} + +interface Props { + element: { id: string; category: ElementCategory; name: string; ownerRole: string | null }; + context: CellContext; + phaseData: PhaseData; + transitionData: TransitionData; + onSaved: () => void; + onDeleteElement: () => void; +} + +export function ElementDetail({ element, context, phaseData, transitionData, onSaved, onDeleteElement }: Props) { + const [pd, setPd] = useState({ ...phaseData }); + const [td, setTd] = useState({ ...transitionData }); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + const isTransition = context.kind === "transition"; + + async function save() { + setSaving(true); + setError(null); + try { + if (isTransition) { + await api.put(`/api/elements/${element.id}/transition/${context.phaseId}`, td); + } else { + await api.put(`/api/elements/${element.id}/phase/${context.phaseId}`, pd); + } + onSaved(); + } catch (e) { + setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen."); + } finally { + setSaving(false); + } + } + + return ( +
+
+
+
+ {CATEGORY_LABELS[element.category]} + {isTransition ? " · Uebergang" : ""} +
+
{element.name}
+
+ +
+ +
+ {isTransition ? renderTransitionFields() : renderPhaseFields()} +
+ + {error &&

{error}

} +
+ +
+
+ ); + + function setP(patch: Partial) { + setPd((prev) => ({ ...prev, ...patch })); + } + function setT(patch: Partial) { + setTd((prev) => ({ ...prev, ...patch })); + } + + function renderPhaseFields() { + switch (element.category) { + case "INCOME": + return ( + setP({ amount: v })} /> + ); + case "EXPENSE": + return ( + setP({ amount: v })} /> + ); + case "AHV": + if (!context.ownerWorking) { + return ( +

+ Die AHV-Rente wird automatisch aus den bisherigen Ausfalljahren berechnet (siehe Kennzahl in der + Matrix). Bei Ehepaaren greift die Plafonierung auf 150% der Maximalrente. +

+ ); + } + return ( + setP({ gapYears: Math.max(0, Math.min(context.durationYears, Math.round(v))) })} + /> + ); + case "PENSION_FUND": + if (!context.ownerWorking) { + return ( +

+ Die PK-Rente wird aus dem beim Pensions-Uebergang gewaehlten Umwandlungssatz berechnet (siehe + Kennzahl). Bei reinem Kapitalbezug erscheint hier "Vollstaendig bezogen". +

+ ); + } + return ( + <> + setP({ currentValue: v })} /> + setP({ annualContribution: v })} + /> + setP({ expectedReturn: v })} /> + + ); + case "PILLAR_3A": + if (!context.ownerWorking) { + return ( +

+ Die Saeule 3a wird beim Pensions-Uebergang vollstaendig bezogen. +

+ ); + } + return ( + <> + setP({ currentValue: v })} /> + setP({ annualContribution: Math.max(0, Math.min(PILLAR_3A_MAX_ANNUAL, Math.round(v / 100) * 100)) })} + /> + setP({ expectedReturn: v })} /> + + ); + case "REAL_ESTATE": + return ( + <> + setP({ purchasePrice: v })} /> + setP({ mortgage: v })} /> + setP({ amortization: v })} + /> + + ); + case "OTHER_ASSET": + return ( + <> + setP({ startValue: v })} /> + setP({ expectedReturn: v })} /> + setP({ annualContribution: v })} + /> + + ); + case "OTHER_DEBT": + return ( + <> + setP({ startValue: v })} /> + setP({ annualRepayment: v })} /> + + ); + } + } + + function renderTransitionFields() { + switch (element.category) { + case "INCOME": + case "EXPENSE": + case "AHV": + return ( +

+ Fuer diese Kategorie gibt es im Uebergang keine Eingaben. Die Werte werden 1:1 in die naechste + Lebensphase uebernommen und koennen dort angepasst werden. +

+ ); + case "PENSION_FUND": + if (context.isRetirementTransition) { + const mode = td.payoutMode ?? "PENSION"; + return ( + <> + setT({ payoutMode: v })} + options={[ + { value: "PENSION", label: "Rente" }, + { value: "CAPITAL", label: "Kapitalbezug" }, + { value: "COMBI", label: "Kombination" }, + ]} + /> + {(mode === "PENSION" || mode === "COMBI") && ( + setT({ conversionRate: v })} + /> + )} + {(mode === "CAPITAL" || mode === "COMBI") && ( + setT({ capitalTaxRate: v })} + /> + )} + {mode === "COMBI" && ( + setT({ capitalAmount: v })} + /> + )} + + ); + } + return ( + setT({ withdrawal: v })} + /> + ); + case "PILLAR_3A": + if (context.isRetirementTransition) { + return ( + setT({ capitalTaxRate: v })} + /> + ); + } + return ( + setT({ withdrawal: v })} + /> + ); + case "REAL_ESTATE": { + const decision = td.decision ?? "HOLD"; + return ( + <> + setT({ decision: v })} + options={[ + { value: "HOLD", label: "Halten" }, + { value: "SELL", label: "Verkaufen" }, + ]} + /> + {decision === "SELL" && ( + <> + setT({ salePrice: v })} /> + setT({ saleTaxRate: v })} + /> + + )} + + ); + } + case "OTHER_ASSET": { + const decision = td.decision ?? "HOLD"; + return ( + setT({ decision: v })} + options={[ + { value: "HOLD", label: "Halten" }, + { value: "SELL", label: "Verkaufen" }, + ]} + /> + ); + } + case "OTHER_DEBT": + return ( + setT({ immediateRepayment: v })} + /> + ); + } + } +} diff --git a/src/components/FormField.tsx b/src/components/FormField.tsx index 4f46211..d65a123 100644 --- a/src/components/FormField.tsx +++ b/src/components/FormField.tsx @@ -44,6 +44,7 @@ export function NumberField({ step={step ?? "any"} min={min} max={max} + onFocus={(e) => e.target.select()} onChange={(e) => onChange(e.target.valueAsNumber || 0)} />
diff --git a/src/components/PhaseCard.tsx b/src/components/PhaseCard.tsx deleted file mode 100644 index 225da42..0000000 --- a/src/components/PhaseCard.tsx +++ /dev/null @@ -1,125 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { LineChart, Line, ResponsiveContainer } from "recharts"; -import { AlertTriangle, ChevronDown, ChevronRight, Trash2, Users } from "lucide-react"; -import { PhaseForm } from "@/components/PhaseForm"; -import { api } from "@/lib/api-client"; -import { formatChf } from "@/lib/format"; -import type { HouseholdInput, PhaseInput } from "@/lib/types"; -import type { PhaseComputed } from "@/lib/calculations"; - -// Formatiert die Altersspannen der Personen einer Phase, z. B. "35–45" (Single) -// oder "A 35–45 · B 33–43" (Paar). -export function formatAges(computed: PhaseComputed): string { - if (computed.ages.length === 0) return ""; - if (computed.ages.length === 1) { - const a = computed.ages[0]; - return `${a.startAge}–${a.endAge}`; - } - return computed.ages - .map((a) => `${a.role === "PERSON_A" ? "A" : "B"} ${a.startAge}–${a.endAge}`) - .join(" · "); -} - -export function PhaseCard({ - household, - phase, - computed, - isFirst, - isLast, - onChanged, -}: { - household: HouseholdInput; - phase: PhaseInput; - computed: PhaseComputed; - isFirst: boolean; - isLast: boolean; - onChanged: () => void; -}) { - const [expanded, setExpanded] = useState(false); - const [deleting, setDeleting] = useState(false); - - const sparklineData = [computed.startWealthNominal, ...computed.yearlyNominal].map((v, i) => ({ - year: i, - value: v, - })); - - async function handleDelete() { - if (!confirm(`Phase "${phase.name}" wirklich loeschen?`)) return; - setDeleting(true); - try { - await api.delete(`/api/phases/${phase.id}`); - onChanged(); - } catch (e) { - alert(e instanceof Error ? e.message : "Loeschen fehlgeschlagen."); - } finally { - setDeleting(false); - } - } - - return ( -
- - {expanded && ( - { - onChanged(); - }} - onCancel={() => setExpanded(false)} - /> - )} -
- ); -} diff --git a/src/components/PhaseDetail.tsx b/src/components/PhaseDetail.tsx new file mode 100644 index 0000000..4be2e5c --- /dev/null +++ b/src/components/PhaseDetail.tsx @@ -0,0 +1,104 @@ +"use client"; + +import { useState } from "react"; +import { Trash2 } from "lucide-react"; +import { NumberField, TextField } from "@/components/FormField"; +import { api } from "@/lib/api-client"; +import type { HouseholdInput, PhaseInput } from "@/lib/types"; + +export function PhaseDetail({ + phase, + maxDurationYears, + isLast, + household, + onSaved, + onDeleted, +}: { + phase: PhaseInput; + maxDurationYears: number | null; + isLast: boolean; + household: HouseholdInput; + onSaved: () => void; + onDeleted: () => void; +}) { + const [name, setName] = useState(phase.name); + const [durationYears, setDurationYears] = useState(phase.durationYears); + const [inflationRate, setInflationRate] = useState(phase.inflationRate); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + const cap = maxDurationYears; + + async function save() { + setSaving(true); + setError(null); + try { + await api.put(`/api/phases/${phase.id}`, { name, durationYears, inflationRate }); + onSaved(); + } catch (e) { + setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen."); + } finally { + setSaving(false); + } + } + + async function remove() { + if (!confirm(`Phase "${phase.name}" wirklich loeschen?`)) return; + try { + await api.delete(`/api/phases/${phase.id}`); + onDeleted(); + } catch (e) { + alert(e instanceof Error ? e.message : "Loeschen fehlgeschlagen."); + } + } + + return ( +
+
+
+ Lebensphase +
+ {isLast && ( + + )} +
+ +
+ + setDurationYears(cap != null ? Math.min(v, cap) : v)} + /> + +
+ + {error &&

{error}

} +
+ +
+
+ ); +} diff --git a/src/components/PhaseForm.tsx b/src/components/PhaseForm.tsx deleted file mode 100644 index 0c44a16..0000000 --- a/src/components/PhaseForm.tsx +++ /dev/null @@ -1,594 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { - AlertTriangle, - CheckCircle2, - ChevronDown, - ChevronRight, - Gift, - Home, - Plus, - PiggyBank, - TrendingUp, - Wallet, - X, - XCircle, -} from "lucide-react"; -import { MoneyField, NumberField, SelectField, TextField } from "@/components/FormField"; -import { api } from "@/lib/api-client"; -import { formatChf } from "@/lib/format"; -import type { - ExpenseEntryInput, - HouseholdInput, - IncomeEntryInput, - IncomeMode, - OneTimeEventInput, - OneTimeEventType, - OwnerTag, - PhaseInput, - RealEstateInput, - RetirementInfoInput, - SecurityInput, -} from "@/lib/types"; - -let tempIdCounter = 0; -function tempId() { - tempIdCounter += 1; - return `tmp-${tempIdCounter}`; -} - -function personLabel(household: HouseholdInput, personId: string | null) { - if (!personId) return "Haushalt"; - const person = household.persons.find((p) => p.id === personId); - if (!person) return "Haushalt"; - return person.role === "PERSON_A" ? "Person A" : "Person B"; -} - -interface Props { - household: HouseholdInput; - phase: PhaseInput; - isFirstPhase: boolean; - onSaved: () => void; - onCancel: () => void; -} - -export function PhaseForm({ household, phase, isFirstPhase, onSaved, onCancel }: Props) { - const [name, setName] = useState(phase.name); - const [durationYears, setDurationYears] = useState(phase.durationYears); - const [inflationRate, setInflationRate] = useState(phase.inflationRate); - const [incomeMode, setIncomeMode] = useState(phase.incomeMode); - const [incomeEntries, setIncomeEntries] = useState(phase.incomeEntries); - const [expenseEntries, setExpenseEntries] = useState( - phase.expenseEntries.length > 0 ? phase.expenseEntries : [{ id: tempId(), label: null, amount: 0 }] - ); - const [securities, setSecurities] = useState(phase.securities); - const [realEstates, setRealEstates] = useState(phase.realEstates); - const [oneTimeEvents, setOneTimeEvents] = useState(phase.oneTimeEvents); - const [retirementInfos, setRetirementInfos] = useState(phase.retirementInfos); - const [saving, setSaving] = useState(false); - const [error, setError] = useState(null); - - const totalIncome = incomeEntries.reduce((s, e) => s + e.amount, 0); - const totalExpense = expenseEntries.reduce((s, e) => s + e.amount, 0); - const savingsQuota = totalIncome - totalExpense; - // Amortisationsraten aller Immobilien zaehlen gleichwertig mit den Sparbeitraegen der - // Wertschriften gegen dieselbe verfuegbare Sparquote (ein gemeinsamer Topf). - const allocated = - securities.reduce((s, sec) => s + sec.annualContribution, 0) + - realEstates.reduce((s, re) => s + re.amortization, 0); - 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; - - // Live-Kappung: pro Feld das noch verfuegbare Budget (eigener Anteil zaehlt nicht - // gegen sich selbst, damit man einen bestehenden Wert wieder erhoehen/senken kann). - function maxContributionFor(current: number): number { - return Math.max(0, savingsQuota - (allocated - current)); - } - function maxStartValueFor(sec: SecurityInput): number | undefined { - // In der ersten Phase wird der Ist-Bestand frei erfasst -- kein Limit. - if (isFirstPhase) return undefined; - const ownExtra = Math.max(0, sec.startValue - sec.carriedBaseValue); - const remaining = Math.max(0, phase.incomingCapital - (allocatedStartCapital - ownExtra)); - return sec.carriedBaseValue + remaining; - } - - 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 { - await api.put(`/api/phases/${phase.id}`, { - name, - durationYears, - inflationRate, - incomeMode, - incomeEntries: incomeEntries.map((e) => ({ - personId: incomeMode === "PER_PERSON" ? e.personId : null, - label: e.label, - amount: e.amount, - })), - expenseEntries: expenseEntries.map((e) => ({ label: e.label, amount: e.amount })), - securities, - realEstates, - oneTimeEvents, - retirementInfos, - }); - onSaved(); - } catch (e) { - setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen."); - } finally { - setSaving(false); - } - } - - return ( -
- {/* Basis-Kopfzeile */} -
-
- -
- - - {household.householdType === "COUPLE" && ( - - )} -
- - {/* Matrix: Kategorien als Spalten */} -
- {/* Einkommen & Ausgaben */} - } - summary={`${formatChf(totalIncome)} / ${formatChf(totalExpense)}`} - > -
-
Einkommen
- {incomeEntries.map((entry, i) => ( - setIncomeEntries((prev) => prev.filter((_, idx) => idx !== i))}> - {incomeMode === "PER_PERSON" ? ( - - setIncomeEntries((prev) => prev.map((e, idx) => (idx === i ? { ...e, personId: v } : e))) - } - options={household.persons.map((p) => ({ - value: p.id, - label: p.role === "PERSON_A" ? "Person A" : "Person B", - }))} - /> - ) : ( - - setIncomeEntries((prev) => prev.map((e, idx) => (idx === i ? { ...e, label: v || null } : e))) - } - /> - )} - - setIncomeEntries((prev) => prev.map((e, idx) => (idx === i ? { ...e, amount: v } : e))) - } - /> - - ))} - - setIncomeEntries((prev) => [ - ...prev, - { id: tempId(), personId: incomeMode === "PER_PERSON" ? household.persons[0]?.id ?? null : null, label: null, amount: 0 }, - ]) - } - /> - -
Ausgaben
- {expenseEntries.map((entry, i) => ( - setExpenseEntries((prev) => prev.filter((_, idx) => idx !== i))}> - - setExpenseEntries((prev) => prev.map((e, idx) => (idx === i ? { ...e, amount: v } : e))) - } - /> - - ))} - setExpenseEntries((prev) => [...prev, { id: tempId(), label: null, amount: 0 }])} - /> -
-
- - {/* Wertschriften */} - } - summary={`${securities.length}`} - > -
- {securities.map((s, i) => ( - setSecurities((prev) => prev.filter((_, idx) => idx !== i))}> - setSecurities((prev) => prev.map((x, idx) => (idx === i ? { ...x, name: v } : x)))} - /> - setSecurities((prev) => prev.map((x, idx) => (idx === i ? { ...x, startValue: v } : x)))} - /> - setSecurities((prev) => prev.map((x, idx) => (idx === i ? { ...x, expectedReturn: v } : x)))} - /> - setSecurities((prev) => prev.map((x, idx) => (idx === i ? { ...x, annualContribution: v } : x)))} - /> - setSecurities((prev) => prev.map((x, idx) => (idx === i ? { ...x, ownerTag: v } : x)))} - options={[ - { value: "HOUSEHOLD", label: "Gemeinsam" }, - { value: "PERSON_A", label: "Person A" }, - { value: "PERSON_B", label: "Person B" }, - ]} - /> - - ))} - - setSecurities((prev) => [ - ...prev, - { - id: tempId(), - name: "", - startValue: 0, - expectedReturn: 0, - annualContribution: 0, - ownerTag: "HOUSEHOLD", - saleTaxRate: 0, - carriedBaseValue: 0, - }, - ]) - } - /> -
-
- - {/* Immobilien */} - } summary={`${realEstates.length}`}> -
- {realEstates.map((re, i) => ( - setRealEstates((prev) => prev.filter((_, idx) => idx !== i))}> - setRealEstates((prev) => prev.map((x, idx) => (idx === i ? { ...x, name: v } : x)))} - /> - setRealEstates((prev) => prev.map((x, idx) => (idx === i ? { ...x, purchasePrice: v } : x)))} - /> - setRealEstates((prev) => prev.map((x, idx) => (idx === i ? { ...x, mortgage: v } : x)))} - /> - setRealEstates((prev) => prev.map((x, idx) => (idx === i ? { ...x, amortization: v } : x)))} - /> - - ))} - - setRealEstates((prev) => [ - ...prev, - { id: tempId(), name: "", purchasePrice: 0, mortgage: 0, amortization: 0 }, - ]) - } - /> -
-
- - {/* Sondereinnahmen / -ausgaben */} - } - summary={`${oneTimeEvents.length}`} - > -
- {oneTimeEvents.map((ev, i) => ( - setOneTimeEvents((prev) => prev.filter((_, idx) => idx !== i))}> - - setOneTimeEvents((prev) => prev.map((x, idx) => (idx === i ? { ...x, type: v } : x))) - } - options={[ - { value: "INCOME", label: "Einnahme" }, - { value: "EXPENSE", label: "Ausgabe" }, - ]} - /> - setOneTimeEvents((prev) => prev.map((x, idx) => (idx === i ? { ...x, amount: v } : x)))} - /> - - setOneTimeEvents((prev) => prev.map((x, idx) => (idx === i ? { ...x, description: v || null } : x))) - } - /> - - ))} - - setOneTimeEvents((prev) => [...prev, { id: tempId(), type: "EXPENSE", amount: 0, description: null }]) - } - /> -
-
- - {/* Pensionierung */} - } - summary={`${retirementInfos.length}`} - > -
- {retirementInfos.map((r, i) => ( - setRetirementInfos((prev) => prev.filter((_, idx) => idx !== i))}> - setRetirementInfos((prev) => prev.map((x, idx) => (idx === i ? { ...x, personId: v } : x)))} - options={household.persons.map((p) => ({ value: p.id, label: personLabel(household, p.id) }))} - /> - setRetirementInfos((prev) => prev.map((x, idx) => (idx === i ? { ...x, ahvAmount: v } : x)))} - /> - - setRetirementInfos((prev) => prev.map((x, idx) => (idx === i ? { ...x, pkPensionAmount: v } : x))) - } - /> - - setRetirementInfos((prev) => prev.map((x, idx) => (idx === i ? { ...x, lumpSumAmount: v } : x))) - } - /> - - setRetirementInfos((prev) => prev.map((x, idx) => (idx === i ? { ...x, lumpSumTaxRate: v } : x))) - } - /> - - ))} - - setRetirementInfos((prev) => [ - ...prev, - { - id: tempId(), - personId: household.persons[0]?.id ?? "", - ahvAmount: 0, - pkPensionAmount: 0, - lumpSumAmount: 0, - lumpSumTaxRate: 8, - }, - ]) - } - /> -
-
-
- - {/* Budget-Status */} -
- {!isFirstPhase && ( -
- - Verfuegbares Startkapital (aus Verkaeufen der Vorphase): {formatChf(phase.incomingCapital)} CHF - {" "}— zugewiesen: {formatChf(allocatedStartCapital)} -
- )} -
- - Verfuegbare Sparquote (CHF/Jahr): {formatChf(savingsQuota)} - {" "}— zugewiesen: {formatChf(allocated)} -
-
- - {error && ( -

- - {error} -

- )} - -
- - -
-
- ); -} - -// Eine einklappbare Kategorien-Spalte der Matrix (Phase x Kategorie). -function CollapsibleColumn({ - title, - icon, - summary, - children, -}: { - title: string; - icon: React.ReactNode; - summary?: string; - children: React.ReactNode; -}) { - const [open, setOpen] = useState(true); - return ( -
- - {open &&
{children}
} -
- ); -} - -// Kompakte Karte fuer einen einzelnen Eintrag (Felder vertikal gestapelt). -function EntryCard({ children, onRemove }: { children: React.ReactNode; onRemove: () => void }) { - return ( -
- - {children} -
- ); -} - -function AddButton({ label, onClick }: { label: string; onClick: () => void }) { - return ( - - ); -} - -function StatusDot({ ok }: { ok: boolean }) { - return ok ? ( - - ) : ( - - ); -} diff --git a/src/components/PlanView.tsx b/src/components/PlanView.tsx new file mode 100644 index 0000000..3b64cf7 --- /dev/null +++ b/src/components/PlanView.tsx @@ -0,0 +1,581 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { + AlertCircle, + Building2, + CheckCircle2, + ChevronDown, + ChevronRight, + CreditCard, + Home, + Landmark, + PiggyBank, + Plus, + ShoppingCart, + TrendingUp, + Wallet, +} from "lucide-react"; +import { Timeline } from "@/components/Timeline"; +import { ElementDetail, type CellContext } from "@/components/ElementDetail"; +import { PhaseDetail } from "@/components/PhaseDetail"; +import { api } from "@/lib/api-client"; +import { formatChf } from "@/lib/format"; +import { + CATEGORY_LABELS, + CATEGORY_ORDER, + PERSON_ONLY_CATEGORIES, + num, + type ElementCategory, +} from "@/lib/elements"; +import { resolveRetirementAge, type PhaseComputed, type PlanComputed } from "@/lib/calculations"; +import type { ElementInput, HouseholdInput, PlanInput } from "@/lib/types"; + +const PERSON_A_COLOR = "#4f46e5"; +const PERSON_B_COLOR = "#0ea5e9"; + +const CATEGORY_ICON: Record = { + INCOME: , + EXPENSE: , + AHV: , + PENSION_FUND: , + PILLAR_3A: , + REAL_ESTATE: , + OTHER_ASSET: , + OTHER_DEBT: , +}; + +const TRANSITION_CATEGORIES: ElementCategory[] = [ + "PENSION_FUND", + "PILLAR_3A", + "REAL_ESTATE", + "OTHER_ASSET", + "OTHER_DEBT", +]; + +type Column = + | { kind: "phase"; phase: PhaseComputed } + | { kind: "transition"; fromPhase: PhaseComputed; toPhase: PhaseComputed }; + +type Selection = + | { type: "phaseCell"; elementId: string; phaseId: string } + | { type: "transitionCell"; elementId: string; fromPhaseId: string } + | { type: "phase"; phaseId: string }; + +export function PlanView({ + plan, + household, + computed, + onChanged, +}: { + plan: PlanInput; + household: HouseholdInput; + computed: PlanComputed; + onChanged: () => void; +}) { + const [selected, setSelected] = useState(null); + const [collapsedCats, setCollapsedCats] = useState>(new Set()); + const [showAdd, setShowAdd] = useState(false); + + const columns = useMemo(() => { + const cols: Column[] = []; + computed.phases.forEach((p, i) => { + cols.push({ kind: "phase", phase: p }); + if (i < computed.phases.length - 1) { + cols.push({ kind: "transition", fromPhase: p, toPhase: computed.phases[i + 1] }); + } + }); + return cols; + }, [computed.phases]); + + const personAxes = household.persons.map((p) => ({ + role: p.role, + label: p.role === "PERSON_A" ? "Person A" : "Person B", + currentAge: p.age, + retirementAge: resolveRetirementAge(p.role, plan, p.retirementAge), + color: p.role === "PERSON_A" ? PERSON_A_COLOR : PERSON_B_COLOR, + })); + + const elementsByCategory = useMemo(() => { + const map = new Map(); + for (const cat of CATEGORY_ORDER) map.set(cat, []); + for (const e of [...plan.elements].sort((a, b) => a.orderIndex - b.orderIndex)) { + map.get(e.category)!.push(e); + } + return map; + }, [plan.elements]); + + function computedElement(phaseId: string, elementId: string) { + return computed.phases.find((p) => p.id === phaseId)?.elements.find((e) => e.elementId === elementId); + } + + function isRetirementTransition(element: ElementInput, fromPhase: PhaseComputed, toPhase: PhaseComputed): boolean { + if (!element.ownerRole || element.ownerRole === "HOUSEHOLD") return false; + const before = fromPhase.persons.find((p) => p.role === element.ownerRole); + const after = toPhase.persons.find((p) => p.role === element.ownerRole); + return !!before?.working && !!after && !after.working; + } + + async function handleAddPhase() { + await api.post(`/api/plans/${plan.id}/phases`, {}); + onChanged(); + } + + const hasPhases = computed.phases.length > 0; + + return ( +
+ + + {/* Pensionsalter-Overrides */} +
+ Pensionsalter (Plan) + {household.persons.map((p) => ( + + ))} + Standard aus Grundprofil, hier pro Plan uebersteuerbar. +
+ + {!hasPhases && ( +
+

Dieser Plan hat noch keine Lebensphasen.

+ +
+ )} + + {hasPhases && ( +
+ + +
+ )} + + {/* Matrix */} + {hasPhases && ( +
+ + + + + {columns.map((col) => + col.kind === "phase" ? ( + setSelected({ type: "phase", phaseId: col.phase.id })} + active={selected?.type === "phase" && selected.phaseId === col.phase.id} + /> + ) : ( + + ) + )} + + + + {CATEGORY_ORDER.map((cat) => { + const els = elementsByCategory.get(cat)!; + if (els.length === 0) return null; + const collapsed = collapsedCats.has(cat); + return ( + + + + + {!collapsed && + els.map((el) => ( + + + {columns.map((col) => { + if (col.kind === "phase") { + const ce = computedElement(col.phase.id, el.id); + const isSel = + selected?.type === "phaseCell" && + selected.elementId === el.id && + selected.phaseId === col.phase.id; + return ( + + ); + } + const canTransition = TRANSITION_CATEGORIES.includes(el.category); + const isSel = + selected?.type === "transitionCell" && + selected.elementId === el.id && + selected.fromPhaseId === col.fromPhase.id; + return ( + + ); + })} + + ))} + + ); + })} + {plan.elements.length === 0 && ( + + + + )} + +
+ Finanzielle Elemente + + Uebergang +
+ setCollapsedCats((prev) => { + const next = new Set(prev); + if (next.has(cat)) next.delete(cat); + else next.add(cat); + return next; + }) + } + > + + {collapsed ? : } + {CATEGORY_ICON[cat]} + {CATEGORY_LABELS[cat]} + + +
+
{el.name}
+ {el.ownerRole && el.ownerRole !== "HOUSEHOLD" && ( +
+ {el.ownerRole === "PERSON_A" ? "Person A" : "Person B"} +
+ )} +
setSelected({ type: "phaseCell", elementId: el.id, phaseId: col.phase.id })} + className={`cursor-pointer border-b border-r border-zinc-200 px-2 py-1.5 text-center text-xs dark:border-zinc-800 ${ + isSel ? "bg-indigo-100 dark:bg-indigo-500/20" : "" + } ${ce?.locked ? "text-zinc-400" : "text-zinc-700 dark:text-zinc-200"}`} + > + {ce?.summary ?? "–"} + + canTransition && + setSelected({ type: "transitionCell", elementId: el.id, fromPhaseId: col.fromPhase.id }) + } + className={`border-b border-r border-zinc-200 px-2 py-1.5 text-center text-[11px] dark:border-zinc-800 ${ + canTransition ? "cursor-pointer text-indigo-500" : "text-zinc-300 dark:text-zinc-600" + } ${isSel ? "bg-indigo-100 dark:bg-indigo-500/20" : "bg-indigo-50/30 dark:bg-indigo-500/5"}`} + > + {canTransition ? transitionSummary(el, col.fromPhase, col.toPhase) : "→"} +
+ Noch keine finanziellen Elemente. Fuegen Sie oben Ihr erstes Element hinzu. +
+
+ )} + + {/* Detail-Panel */} + {selected && ( +
+ {renderDetail()} +
+ )} + + {showAdd && ( + setShowAdd(false)} + onCreate={async (payload) => { + await api.post(`/api/plans/${plan.id}/elements`, payload); + setShowAdd(false); + onChanged(); + }} + /> + )} +
+ ); + + function renderDetail() { + if (!selected) return null; + + if (selected.type === "phase") { + const phase = computed.phases.find((p) => p.id === selected.phaseId); + const phaseInput = plan.phases.find((p) => p.id === selected.phaseId); + if (!phase || !phaseInput) return null; + const isLast = phase.sequenceNumber === computed.phases.length; + return ( + { + setSelected(null); + onChanged(); + }} + /> + ); + } + + const element = plan.elements.find((e) => e.id === selected.elementId); + if (!element) return null; + + if (selected.type === "phaseCell") { + const phase = computed.phases.find((p) => p.id === selected.phaseId)!; + const ownerWorking = element.ownerRole && element.ownerRole !== "HOUSEHOLD" + ? phase.persons.find((p) => p.role === element.ownerRole)?.working ?? false + : phase.type !== "PENSION"; + const ce = computedElement(phase.id, element.id); + const context: CellContext = { + kind: "phase", + phaseId: phase.id, + ownerWorking, + isConsumption: phase.isConsumption, + durationYears: phase.durationYears, + isRetirementTransition: false, + carriedEndValue: ce?.endValue ?? 0, + }; + return ( + deleteElement(element.id)} + /> + ); + } + + // transitionCell + const fromPhase = computed.phases.find((p) => p.id === selected.fromPhaseId)!; + const toIndex = computed.phases.findIndex((p) => p.id === fromPhase.id) + 1; + const toPhase = computed.phases[toIndex]; + const ce = computedElement(fromPhase.id, element.id); + const context: CellContext = { + kind: "transition", + phaseId: fromPhase.id, + ownerWorking: true, + isConsumption: fromPhase.isConsumption, + durationYears: fromPhase.durationYears, + isRetirementTransition: toPhase ? isRetirementTransition(element, fromPhase, toPhase) : false, + carriedEndValue: ce?.endValue ?? 0, + }; + return ( + deleteElement(element.id)} + /> + ); + } + + async function deleteElement(id: string) { + if (!confirm("Dieses Element wirklich loeschen (aus allen Phasen)?")) return; + await api.delete(`/api/elements/${id}`); + setSelected(null); + onChanged(); + } + + function transitionSummary(el: ElementInput, fromPhase: PhaseComputed, toPhase: PhaseComputed): string { + const td = el.transitionValues[fromPhase.id] ?? {}; + switch (el.category) { + case "REAL_ESTATE": + case "OTHER_ASSET": + return td.decision === "SELL" ? "Verkauf" : "Halten"; + case "PENSION_FUND": + if (isRetirementTransition(el, fromPhase, toPhase)) { + return td.payoutMode === "CAPITAL" ? "Kapital" : td.payoutMode === "COMBI" ? "Kombi" : "Rente"; + } + return num(td.withdrawal) > 0 ? `−${formatChf(num(td.withdrawal))}` : "→"; + case "PILLAR_3A": + if (isRetirementTransition(el, fromPhase, toPhase)) return "Bezug"; + return num(td.withdrawal) > 0 ? `−${formatChf(num(td.withdrawal))}` : "→"; + case "OTHER_DEBT": + return num(td.immediateRepayment) > 0 ? "Tilgung" : "→"; + default: + return "→"; + } + } +} + +function PhaseHeader({ phase, onClick, active }: { phase: PhaseComputed; onClick: () => void; active: boolean }) { + const quotaLabel = phase.isConsumption ? "Verzehr" : "Sparquote"; + return ( + +
+ {phase.name} + {phase.incomplete ? ( + + ) : ( + + )} +
+
+ + {phase.type === "ERWERB" ? "Erwerb" : phase.type === "PENSION" ? "Pension" : "Misch"} + + {phase.durationYears} J. + Alter {phase.persons.map((p) => p.startAge).join("/")} +
+
+
Einkommen {formatChf(phase.incomeTotal)}
+
Ausgaben {formatChf(phase.expenseTotal)}
+
+ {quotaLabel} {formatChf(Math.abs(phase.quota))} +
+
+ Kapital {phase.availableCapital === null ? "n.a." : formatChf(phase.availableCapital)} +
+
+ + ); +} + +function FragmentRows({ children }: { children: React.ReactNode }) { + return <>{children}; +} + +function AddElementDialog({ + household, + onClose, + onCreate, +}: { + household: HouseholdInput; + onClose: () => void; + onCreate: (payload: { category: ElementCategory; name: string; ownerRole: string | null }) => void; +}) { + const [category, setCategory] = useState("INCOME"); + const [name, setName] = useState(""); + const [ownerRole, setOwnerRole] = useState(household.householdType === "COUPLE" ? "PERSON_A" : "PERSON_A"); + + const needsPerson = PERSON_ONLY_CATEGORIES.includes(category); + const isCouple = household.householdType === "COUPLE"; + + const ownerOptions = needsPerson + ? isCouple + ? [ + { value: "PERSON_A", label: "Person A" }, + { value: "PERSON_B", label: "Person B" }, + ] + : [{ value: "PERSON_A", label: "Person A" }] + : isCouple + ? [ + { value: "HOUSEHOLD", label: "Gemeinsam" }, + { value: "PERSON_A", label: "Person A" }, + { value: "PERSON_B", label: "Person B" }, + ] + : [ + { value: "HOUSEHOLD", label: "Gemeinsam" }, + { value: "PERSON_A", label: "Person A" }, + ]; + + return ( +
+
e.stopPropagation()} + className="flex w-full max-w-md flex-col gap-3 rounded-2xl border border-zinc-200 bg-white p-6 shadow-xl dark:border-zinc-700 dark:bg-zinc-900" + > +

Finanzielles Element

+
+ + +
+
+ + setName(e.target.value)} + placeholder={CATEGORY_LABELS[category]} + className="w-full rounded-lg border border-zinc-300 px-3 py-2 text-sm focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 dark:border-zinc-600 dark:bg-zinc-950" + /> +
+
+ + +
+
+ + +
+
+
+ ); +} diff --git a/src/components/Timeline.tsx b/src/components/Timeline.tsx new file mode 100644 index 0000000..118dc8e --- /dev/null +++ b/src/components/Timeline.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { Flag } from "lucide-react"; +import type { PhaseComputed } from "@/lib/calculations"; + +interface PersonAxis { + role: "PERSON_A" | "PERSON_B"; + label: string; + currentAge: number; + retirementAge: number; + color: string; +} + +// Horizontale Zeitachse: Alter von links nach rechts, mit deutlich markiertem +// Pensionsalter je Person und Trennlinien an den Phasengrenzen. +export function Timeline({ phases, persons }: { phases: PhaseComputed[]; persons: PersonAxis[] }) { + if (phases.length === 0 || persons.length === 0) return null; + + const totalYears = phases.reduce((s, p) => s + p.durationYears, 0); + const minAge = Math.min(...persons.map((p) => p.currentAge)); + const maxAge = minAge + totalYears; + const span = Math.max(1, maxAge - minAge); + + const pct = (age: number) => `${(Math.max(0, Math.min(span, age - minAge)) / span) * 100}%`; + + // Phasengrenzen (kumulierte Jahre). + const boundaries: { year: number; label: string }[] = []; + let acc = 0; + for (const p of phases) { + boundaries.push({ year: acc, label: p.name }); + acc += p.durationYears; + } + + return ( +
+
+

Zeitachse

+
+ {persons.map((p) => ( + + + {p.label} (heute {p.currentAge}) + + ))} +
+
+ +
+ {/* Pensionsmarker je Person */} + {persons.map((p, i) => + p.retirementAge > minAge && p.retirementAge < maxAge ? ( +
+ + + {p.retirementAge} + +
+
+ ) : null + )} + + {/* Achse */} +
+ {boundaries.slice(1).map((b) => ( +
+ ))} +
+ + {/* Alters-Beschriftung */} +
+ {minAge} J. + {maxAge} J. +
+
+
+ ); +} diff --git a/src/components/TransitionPanel.tsx b/src/components/TransitionPanel.tsx deleted file mode 100644 index f09c75e..0000000 --- a/src/components/TransitionPanel.tsx +++ /dev/null @@ -1,280 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { ArrowDown, CheckCircle2, ChevronDown, ChevronRight } from "lucide-react"; -import { MoneyInput } from "@/components/FormField"; -import { api } from "@/lib/api-client"; -import { floorToThousand, formatChf } from "@/lib/format"; -import type { PhaseInput, TransitionDecision } from "@/lib/types"; -import type { PhaseComputed } from "@/lib/calculations"; - -interface ItemDraft { - positionType: "SECURITY" | "REAL_ESTATE"; - id: string; - name: string; - decision: TransitionDecision; - salePrice: number | null; - // 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({ - phase, - computed, - nextPhaseName, - onChanged, -}: { - phase: PhaseInput; - computed: PhaseComputed; - nextPhaseName: string; - onChanged: () => void; -}) { - const [items, setItems] = useState([]); - const [loaded, setLoaded] = useState(false); - const [expanded, setExpanded] = useState(false); - const [saving, setSaving] = useState(false); - const [saved, setSaved] = useState(false); - const [error, setError] = useState(null); - - useEffect(() => { - let cancelled = false; - async function load() { - const initial: ItemDraft[] = [ - ...phase.securities.map((s) => { - const c = computed.securities.find((cs) => cs.id === s.id); - return { - positionType: "SECURITY" as const, - id: s.id, - name: s.name, - decision: "CARRY_OVER" as TransitionDecision, - salePrice: null, - saleTaxRate: s.saleTaxRate, - carryOverValue: c?.endValue ?? 0, - originalValue: c?.startValue ?? 0, - 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.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; - saleTaxRate: number | null; - }[]; - } | null; - }>(`/api/phases/${phase.id}/transition`); - if (cancelled) return; - if (data.transition) { - for (const savedItem of data.transition.items) { - const target = initial.find( - (it) => it.id === (savedItem.securityId ?? savedItem.realEstateId) - ); - if (target) { - target.decision = savedItem.decision; - if (savedItem.salePrice != null) target.salePrice = savedItem.salePrice; - if (savedItem.saleTaxRate != null) target.saleTaxRate = savedItem.saleTaxRate; - } - } - } - setItems(initial); - setLoaded(true); - } catch { - setItems(initial); - setLoaded(true); - } - } - load(); - return () => { - cancelled = true; - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [phase.id]); - - if (!loaded) { - return ( -
- Uebergang wird geladen… -
- ); - } - - const totalAvailableCapital = floorToThousand( - items.reduce((sum, it) => { - if (it.decision === "CARRY_OVER") return sum; - if (it.positionType === "SECURITY") { - const gain = Math.max(0, it.carryOverValue - it.originalValue); - const tax = gain * (it.saleTaxRate / 100); - return sum + (it.carryOverValue - tax); - } - const salePrice = it.salePrice ?? 0; - const gain = Math.max(0, salePrice - it.originalValue); - const tax = gain * (it.saleTaxRate / 100); - return sum + (salePrice - it.remainingMortgage - tax); - }, 0) - ); - - async function handleSave() { - setSaving(true); - setError(null); - setSaved(false); - try { - await api.put(`/api/phases/${phase.id}/transition`, { - items: items.map((it) => ({ - positionType: it.positionType, - securityId: it.positionType === "SECURITY" ? it.id : null, - 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); - onChanged(); - } catch (e) { - setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen."); - } finally { - setSaving(false); - } - } - - if (items.length === 0) { - return null; - } - - return ( -
- - {expanded && ( -
- - - - - - - - - - - {items.map((it, i) => ( - - - - - - - ))} - -
PositionEntscheidungVerkaufspreisGrundstueckgewinnsteuer
{it.name} - - - {it.decision === "SELL" ? ( - it.positionType === "REAL_ESTATE" ? ( - - setItems((prev) => prev.map((x, idx) => (idx === i ? { ...x, salePrice: v } : x))) - } - /> - ) : ( - {formatChf(it.carryOverValue)} CHF - ) - ) : ( - {formatChf(it.carryOverValue)} CHF - )} - - {it.positionType === "REAL_ESTATE" && it.decision === "SELL" ? ( - - setItems((prev) => - prev.map((x, idx) => (idx === i ? { ...x, saleTaxRate: e.target.valueAsNumber || 0 } : x)) - ) - } - /> - ) : ( - - )} -
-
- Verfuegbares Startkapital fuer neue Phase (aus Verkaeufen): {formatChf(totalAvailableCapital)} CHF -

- Wird beim Speichern automatisch in "{nextPhaseName}" als verfuegbares Startkapital hinterlegt. - Gehaltene/uebernommene Positionen erscheinen dort automatisch mit ihrem Endwert (Wertschriften) bzw. - Kaufpreis/Resthypothek (Immobilien) als neue Ausgangswerte. -

-
- {error &&

{error}

} -
- - {saved && ( - - Gespeichert. - - )} -
-
- )} -
- ); -} diff --git a/src/components/WealthChart.tsx b/src/components/WealthChart.tsx index 52a956f..7eb13bc 100644 --- a/src/components/WealthChart.tsx +++ b/src/components/WealthChart.tsx @@ -5,7 +5,6 @@ import { Legend, Line, LineChart, - ReferenceLine, ResponsiveContainer, Tooltip, XAxis, @@ -20,59 +19,41 @@ export interface TimelineSeries { computed: PlanComputed; } -function buildTimeline(computed: PlanComputed) { - const points: { year: number; nominal: number; real: number }[] = [ - { year: 0, nominal: computed.phases[0]?.startWealthNominal ?? 0, real: computed.phases[0]?.startWealthNominal ?? 0 }, - ]; - const boundaries: { year: number; name: string }[] = []; - let year = 0; - for (const phase of computed.phases) { - boundaries.push({ year, name: phase.name }); - for (let y = 0; y < phase.durationYears; y++) { - year += 1; - points.push({ year, nominal: phase.yearlyNominal[y], real: phase.yearlyReal[y] }); - } - } - return { points, boundaries }; -} - -// Liniendiagramm ueber alle Phasen, nominal + real, mit Markierungen an den -// Phasengrenzen (TDD Kapitel 4.5 / 14). Unterstuetzt optional mehrere ueberlagerte -// Plaene fuer den Szenario-Vergleich. +// Liniendiagramm: Endvermoegen (nominal + real) je Lebensphase. Unterstuetzt mehrere +// ueberlagerte Plaene fuer den Szenario-Vergleich. export function WealthChart({ series }: { series: TimelineSeries[] }) { if (series.length === 0 || series[0].computed.phases.length === 0) { return

Noch keine Phasen vorhanden.

; } - const primary = buildTimeline(series[0].computed); - const maxYear = Math.max(...series.map((s) => buildTimeline(s.computed).points.length - 1)); - - const merged: Record> = {}; - for (const s of series) { - const tl = buildTimeline(s.computed); - for (const p of tl.points) { - merged[p.year] = merged[p.year] ?? { year: p.year }; - merged[p.year][`${s.label} (nominal)`] = p.nominal; - merged[p.year][`${s.label} (real)`] = p.real; + // Datenpunkte je Phasen-Index; X-Achse = Phasenname des Hauptplans. + const maxLen = Math.max(...series.map((s) => s.computed.phases.length)); + const data = Array.from({ length: maxLen }, (_, i) => { + const row: Record = { + phase: series[0].computed.phases[i]?.name ?? `Phase ${i + 1}`, + }; + for (const s of series) { + const p = s.computed.phases[i]; + if (p) { + row[`${s.label} (nominal)`] = Math.round(p.endWealthNominal); + row[`${s.label} (real)`] = Math.round(p.endWealthReal); + } } - } - const data = Array.from({ length: maxYear + 1 }, (_, y) => merged[y] ?? { year: y }); + return row; + }); return (
- + Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)} /> (typeof v === "number" ? formatChf(v) : v)} /> - {primary.boundaries.slice(1).map((b) => ( - - ))} {series.map((s) => ( in dieser Phase nicht mehr editierbar + startValue: number; // Netto-Wert zu Phasenbeginn (Aktiven +, Schulden -) + endValue: number; // Netto-Wert am Phasenende + incomeContribution: number; // Beitrag zum Phasen-Einkommen + expenseContribution: number; // Beitrag zu den Phasen-Ausgaben + quotaUse: number; // Betrag, der Spar-/Verzehrquote verbraucht (3a/Sonstiges Vermoegen) + capitalUse: number; // verbrauchtes verfuegbares Startkapital (Aufstockung/Neuinvestition) + summary: string; // Kennzahl fuer die eingeklappte Zelle + note: string | null; // z. B. "Verkauft", "Getilgt", "Vollstaendig bezogen" } export interface PhaseComputed { @@ -41,271 +43,515 @@ export interface PhaseComputed { name: string; sequenceNumber: number; durationYears: number; - incomeFromEntries: number; + type: PhaseType; + persons: PersonPhaseInfo[]; + maxDurationYears: number | null; // Kappung ans naechste Pensionsereignis (null = unbegrenzt) + incomeTotal: number; expenseTotal: number; - retirement: RetirementComputed | null; - effectiveIncome: number; // incomeFromEntries + retirement.totalPensionIncome - savingsQuota: number; // effectiveIncome - expenseTotal - allocatedSavings: number; // Summe der jaehrlichen Sparbeitraege auf Wertschriften - savingsWarning: boolean; - securities: SecurityComputed[]; - realEstates: RealEstateComputed[]; - oneTimeNet: number; + quota: number; // Einkommen - Ausgaben (Sparquote wenn >=0, Verzehrquote wenn <0) + isConsumption: boolean; + quotaAllocated: number; // verteilte Sparbeitraege bzw. gedeckte Verzehr-Bezuege + quotaComplete: boolean; + availableCapital: number | null; // null in der ersten Phase + availableCapitalUsed: number; + availableCapitalComplete: boolean; + incomplete: boolean; // roter Status (Quote/Kapital nicht vollstaendig verteilt) + elements: ElementPhaseComputed[]; startWealthNominal: number; endWealthNominal: number; - cumulativeInflationStart: number; cumulativeInflationEnd: number; - startWealthReal: number; endWealthReal: number; - yearlyNominal: number[]; // Laenge durationYears, Werte am Ende von Jahr 1..durationYears - yearlyReal: number[]; - // Alter der Personen zu Beginn und am Ende dieser Phase (Grundprofil-Alter + - // kumulierte Dauer der Vorphasen). - ages: PersonAgeRange[]; -} - -export interface PersonAgeRange { - personId: string; - role: string; // PERSON_A | PERSON_B - startAge: number; - endAge: number; } export interface PlanComputed { phases: PhaseComputed[]; nachlass: number; - totalSavingsWarnings: number; } -// Alle Zwischen- und Endwerte werden auf ein Vielfaches von 1'000 abgerundet (siehe -// lib/format.ts): nur so bleiben Betraege, die spaeter bei einem Verkauf oder Uebergang -// auf Wertschriften verteilt werden muessen, ueberhaupt vollstaendig verteilbar. -export function computeSecurityYearlyValues( - startValue: number, - expectedReturn: number, - annualContribution: number, - durationYears: number -): number[] { - const values = [floorToThousand(startValue)]; - for (let year = 1; year <= durationYears; year++) { - const previous = values[year - 1]; - values.push(floorToThousand(previous * (1 + expectedReturn / 100) + annualContribution)); +// Loest das effektive Pensionsalter einer Person auf (Plan-Override vor Profil-Default). +export function resolveRetirementAge( + role: PersonRole, + plan: { retirementAgeA: number | null; retirementAgeB: number | null }, + profileDefault: number +): number { + const override = role === "PERSON_A" ? plan.retirementAgeA : plan.retirementAgeB; + return override ?? profileDefault; +} + +// Maximale Dauer einer neuen Phase, die yearsBefore Jahre nach Planbeginn startet: +// bis zum naechsten Pensionsereignis einer noch erwerbenden Person (null = unbegrenzt). +export function maxPhaseDuration( + persons: { role: PersonRole; age: number; retirementAge: number }[], + plan: { retirementAgeA: number | null; retirementAgeB: number | null }, + yearsBefore: number +): number | null { + const caps: number[] = []; + for (const p of persons) { + const ra = resolveRetirementAge(p.role, plan, p.retirementAge); + const startAge = p.age + yearsBefore; + if (startAge < ra) caps.push(ra - startAge); } - return values; + return caps.length > 0 ? Math.min(...caps) : null; } -// Vereinfachtes Modell (keine Wertsteigerung): der Kaufpreis bleibt ueber die ganze -// Haltedauer fix, nur die Hypothek sinkt jaehrlich um die Amortisationsrate. -export function computeMortgageYearly( - mortgage: number, - amortization: number, - durationYears: number -): number[] { - const mortgages = [floorToThousand(mortgage)]; - for (let year = 1; year <= durationYears; year++) { - mortgages.push(floorToThousand(Math.max(0, mortgages[year - 1] - amortization))); +// Interner Zustand, der pro Element von Phase zu Phase weitergetragen wird. +interface Carry { + status: ElementStatus; + value: number; // Aktiven-Saldo (PK/3a/Sonstiges Vermoegen) am Ende der Vorphase + mortgage: number; // Immobilie: Resthypothek + owed: number; // Schulden: Restschuld (positiv) + pkPensionAnnual: number; // PK: jaehrliche Rente nach Verrentung + hasCarry: boolean; // gab es eine Vorphase mit diesem Element? +} + +function emptyCarry(): Carry { + return { status: "ACTIVE", value: 0, mortgage: 0, owed: 0, pkPensionAnnual: 0, hasCarry: false }; +} + +function growAsset(startValue: number, expectedReturn: number, annual: number, years: number): number { + let v = floorToThousand(startValue); + for (let y = 0; y < years; y++) { + v = floorToThousand(v * (1 + expectedReturn / 100) + annual); } - return mortgages; -} - -function computeRetirement( - household: HouseholdInput, - phase: PhaseInput -): RetirementComputed | null { - if (phase.retirementInfos.length === 0) return null; - - const perPerson = phase.retirementInfos.map((info) => ({ - personId: info.personId, - ahvAmount: info.ahvAmount, - pkPensionAmount: info.pkPensionAmount, - lumpSumAmount: info.lumpSumAmount, - lumpSumNet: floorToThousand(info.lumpSumAmount * (1 - info.lumpSumTaxRate / 100)), - })); - - const ahvSum = perPerson.reduce((sum, p) => sum + p.ahvAmount, 0); - const ahvCap = AHV_MAX_PENSION_PER_YEAR * AHV_COUPLE_CAP_FACTOR; - const isCoupleBothRetired = household.householdType === "COUPLE" && phase.retirementInfos.length === 2; - const combinedAhv = isCoupleBothRetired ? Math.min(ahvSum, ahvCap) : ahvSum; - const ahvCapped = isCoupleBothRetired && ahvSum > ahvCap; - - const pkTotal = perPerson.reduce((sum, p) => sum + p.pkPensionAmount, 0); - const lumpSumGrossTotal = perPerson.reduce((sum, p) => sum + p.lumpSumAmount, 0); - const lumpSumNetTotal = perPerson.reduce((sum, p) => sum + p.lumpSumNet, 0); - - return { - perPerson, - combinedAhv, - ahvCapped, - pkTotal, - totalPensionIncome: combinedAhv + pkTotal, - lumpSumGrossTotal, - lumpSumNetTotal, - }; -} - -function computePhase( - phase: PhaseInput, - household: HouseholdInput, - cumulativeInflationStart: number, - yearsBeforePhase: number -): PhaseComputed { - const ages: PersonAgeRange[] = household.persons.map((p) => ({ - personId: p.id, - role: p.role, - startAge: p.age + yearsBeforePhase, - endAge: p.age + yearsBeforePhase + phase.durationYears, - })); - - const incomeFromEntries = phase.incomeEntries.reduce((sum, e) => sum + e.amount, 0); - const expenseTotal = phase.expenseEntries.reduce((sum, e) => sum + e.amount, 0); - const retirement = computeRetirement(household, phase); - const effectiveIncome = incomeFromEntries + (retirement?.totalPensionIncome ?? 0); - const savingsQuota = effectiveIncome - expenseTotal; - // Amortisationsraten aller Immobilien zaehlen gleichwertig mit den Sparbeitraegen der - // Wertschriften gegen dieselbe verfuegbare Sparquote (ein gemeinsamer Topf). - const allocatedSavings = - phase.securities.reduce((sum, s) => sum + s.annualContribution, 0) + - phase.realEstates.reduce((sum, re) => sum + re.amortization, 0); - const savingsWarning = allocatedSavings > savingsQuota; - - const securities: SecurityComputed[] = phase.securities.map((s) => { - const yearly = computeSecurityYearlyValues( - s.startValue, - s.expectedReturn, - s.annualContribution, - phase.durationYears - ); - return { - id: s.id, - name: s.name, - ownerTag: s.ownerTag, - startValue: yearly[0], - endValue: yearly[phase.durationYears], - yearly, - }; - }); - - const realEstates: RealEstateComputed[] = phase.realEstates.map((re) => { - const mortgages = computeMortgageYearly(re.mortgage, re.amortization, phase.durationYears); - const purchasePrice = floorToThousand(re.purchasePrice); - return { - id: re.id, - name: re.name, - purchasePrice, - startNet: purchasePrice - mortgages[0], - endNet: purchasePrice - mortgages[phase.durationYears], - mortgages, - }; - }); - - const oneTimeNet = phase.oneTimeEvents.reduce( - (sum, e) => sum + (e.type === "INCOME" ? e.amount : -e.amount), - 0 - ); - - const startWealthNominal = - securities.reduce((sum, s) => sum + s.startValue, 0) + - realEstates.reduce((sum, re) => sum + re.startNet, 0); - - const endWealthNominal = - securities.reduce((sum, s) => sum + s.endValue, 0) + - realEstates.reduce((sum, re) => sum + re.endNet, 0) + - oneTimeNet + - (retirement?.lumpSumNetTotal ?? 0); - - const inflationRate = phase.inflationRate ?? household.inflationRateDefault; - // TDD Kapitel 3.5: kumulierte Inflation ist ein Produkt ueber die Phasen (ein Faktor - // pro Phase), nicht ueber einzelne Jahre. Bewusst woertlich gemaess Spezifikation umgesetzt. - const cumulativeInflationEnd = cumulativeInflationStart * (1 + inflationRate / 100); - - const yearlyNominal: number[] = []; - for (let year = 1; year <= phase.durationYears; year++) { - let value = - securities.reduce((sum, s) => sum + s.yearly[year], 0) + - realEstates.reduce((sum, re) => sum + (re.purchasePrice - re.mortgages[year]), 0); - if (year === phase.durationYears) { - // Einmalige Ereignisse und Kapitalbezuege schlagen erst am Ende der Phase zu - // Buche (siehe Phasenuebergang, TDD Kapitel 10). Immobilien-/Wertschriften- - // Verkaeufe wirken sich nur auf die naechste Phase aus (incomingCapital), nicht - // mehr auf das Endvermoegen dieser Phase selbst. - value += oneTimeNet + (retirement?.lumpSumNetTotal ?? 0); - } - yearlyNominal.push(value); - } - // Vereinfachung: innerhalb einer Phase wird fuer den Realwert durchgehend die am - // Phasenende gueltige kumulierte Inflation verwendet (siehe cumulativeInflationEnd oben). - const yearlyReal = yearlyNominal.map((v) => v / cumulativeInflationEnd); - - return { - id: phase.id, - name: phase.name, - sequenceNumber: phase.sequenceNumber, - durationYears: phase.durationYears, - incomeFromEntries, - expenseTotal, - retirement, - effectiveIncome, - savingsQuota, - allocatedSavings, - savingsWarning, - securities, - realEstates, - oneTimeNet, - startWealthNominal, - endWealthNominal, - cumulativeInflationStart, - cumulativeInflationEnd, - startWealthReal: startWealthNominal / cumulativeInflationStart, - endWealthReal: endWealthNominal / cumulativeInflationEnd, - yearlyNominal, - yearlyReal, - ages, - }; + return Math.max(0, v); } export function computePlan(plan: PlanInput, household: HouseholdInput): PlanComputed { - const orderedPhases = [...plan.phases].sort((a, b) => a.sequenceNumber - b.sequenceNumber); + const phases = [...plan.phases].sort((a, b) => a.sequenceNumber - b.sequenceNumber); + const persons = household.persons; - let cumulativeInflation = 1; - let yearsBefore = 0; - const phases: PhaseComputed[] = []; - for (const phase of orderedPhases) { - const computed = computePhase(phase, household, cumulativeInflation, yearsBefore); - cumulativeInflation = computed.cumulativeInflationEnd; - yearsBefore += phase.durationYears; - phases.push(computed); + // Pensionsalter je Person (aufgeloest). + const retirementAge = new Map(); + for (const p of persons) { + retirementAge.set(p.id, resolveRetirementAge(p.role, plan, p.retirementAge)); } - const nachlass = phases.length > 0 ? phases[phases.length - 1].endWealthNominal : 0; - const totalSavingsWarnings = phases.filter((p) => p.savingsWarning).length; + // Kumulierte AHV-Ausfalljahre je Person (ueber die Erwerbsphasen aufsummiert). + const gapYearsByPerson = new Map(); + // Carry-Zustand je Element. + const carries = new Map(); + for (const e of plan.elements) carries.set(e.id, emptyCarry()); - return { phases, nachlass, totalSavingsWarnings }; + const result: PhaseComputed[] = []; + let yearsBefore = 0; + let cumulativeInflation = 1; + let incomingCapital: number | null = null; // in die aktuelle Phase einfliessendes Startkapital + + for (let i = 0; i < phases.length; i++) { + const phase = phases[i]; + const nextPhase = phases[i + 1]; + + // --- Personen-Status in dieser Phase --- + const personInfos: PersonPhaseInfo[] = persons.map((p) => { + const ra = retirementAge.get(p.id)!; + const startAge = p.age + yearsBefore; + const working = startAge < ra; + return { + personId: p.id, + role: p.role, + startAge, + endAge: startAge + phase.durationYears, + working, + retiresAtStart: startAge === ra, + }; + }); + const anyWorking = personInfos.some((p) => p.working); + const anyRetired = personInfos.some((p) => !p.working); + const type: PhaseType = anyWorking && anyRetired ? "MIXED" : anyWorking ? "ERWERB" : "PENSION"; + + // Maximale Dauer: bis zum naechsten Pensionsereignis einer noch erwerbenden Person. + const capsFromWorking = personInfos + .filter((p) => p.working) + .map((p) => retirementAge.get(p.personId)! - p.startAge) + .filter((d) => d > 0); + const maxDurationYears = capsFromWorking.length > 0 ? Math.min(...capsFromWorking) : null; + + const workingByPerson = new Map(personInfos.map((p) => [p.personId, p.working])); + + // --- Ausfalljahre der Erwerbsphasen aufsummieren --- + for (const e of plan.elements) { + if (e.category !== "AHV" || !e.ownerRole) continue; + const owner = personByRole(persons, e.ownerRole); + if (!owner || !workingByPerson.get(owner.id)) continue; + const gy = Math.max(0, Math.round(num(e.phaseValues[phase.id]?.gapYears))); + gapYearsByPerson.set(owner.id, (gapYearsByPerson.get(owner.id) ?? 0) + gy); + } + + // --- AHV-Renten je pensionierter Person (mit Plafonierung) --- + const ahvUncapped = new Map(); + for (const e of plan.elements) { + if (e.category !== "AHV" || !e.ownerRole) continue; + const owner = personByRole(persons, e.ownerRole); + if (!owner || workingByPerson.get(owner.id)) continue; // nur pensionierte Personen + const gap = gapYearsByPerson.get(owner.id) ?? 0; + const factor = Math.max(0, (AHV_FULL_CONTRIBUTION_YEARS - gap) / AHV_FULL_CONTRIBUTION_YEARS); + ahvUncapped.set(owner.id, floorToThousand(AHV_MAX_ANNUAL_SINGLE * factor)); + } + const ahvFinal = new Map(ahvUncapped); + if (household.householdType === "COUPLE" && ahvUncapped.size === 2) { + const sum = [...ahvUncapped.values()].reduce((a, b) => a + b, 0); + const cap = AHV_MAX_ANNUAL_SINGLE * AHV_COUPLE_CAP_FACTOR; + if (sum > cap && sum > 0) { + for (const [pid, v] of ahvUncapped) ahvFinal.set(pid, floorToThousand(v * (cap / sum))); + } + } + + // --- Elemente dieser Phase berechnen --- + const elementsComputed: ElementPhaseComputed[] = []; + let incomeTotal = 0; + let expenseTotal = 0; + let quotaAllocated = 0; + let capitalUsed = 0; + + const orderedElements = [...plan.elements].sort((a, b) => a.orderIndex - b.orderIndex); + for (const e of orderedElements) { + const carry = carries.get(e.id)!; + const pd = e.phaseValues[phase.id] ?? {}; + const owner = e.ownerRole && e.ownerRole !== "HOUSEHOLD" ? personByRole(persons, e.ownerRole) : null; + const ownerWorking = owner ? workingByPerson.get(owner.id) ?? false : anyWorking; + + const ec: ElementPhaseComputed = { + elementId: e.id, + category: e.category, + name: e.name, + ownerRole: e.ownerRole, + status: carry.status, + locked: carry.status !== "ACTIVE", + startValue: 0, + endValue: 0, + incomeContribution: 0, + expenseContribution: 0, + quotaUse: 0, + capitalUse: 0, + summary: "", + note: null, + }; + + if (carry.status === "SOLD") { + ec.note = "Verkauft"; + ec.summary = "Verkauft"; + elementsComputed.push(ec); + continue; + } + if (carry.status === "SETTLED" && e.category === "OTHER_DEBT") { + ec.note = "Getilgt"; + ec.summary = "Getilgt"; + elementsComputed.push(ec); + continue; + } + + switch (e.category) { + case "INCOME": { + const amount = floorToThousand(num(pd.amount)); + ec.incomeContribution = amount; + incomeTotal += amount; + ec.summary = fmt(amount); + break; + } + case "EXPENSE": { + const amount = floorToThousand(num(pd.amount)); + ec.expenseContribution = amount; + expenseTotal += amount; + ec.summary = fmt(amount); + break; + } + case "AHV": { + if (owner && !ownerWorking) { + const pension = ahvFinal.get(owner.id) ?? 0; + ec.incomeContribution = pension; + incomeTotal += pension; + ec.summary = `Rente ${fmt(pension)}`; + } else { + const gap = Math.max(0, Math.round(num(pd.gapYears))); + ec.summary = gap > 0 ? `${gap} Ausfalljahre` : "Keine Ausfalljahre"; + } + break; + } + case "PENSION_FUND": { + if (!ownerWorking && carry.pkPensionAnnual > 0) { + // Verrentetes PK-Kapital: jaehrliche Rente als Einkommen. + const pension = carry.pkPensionAnnual; + ec.incomeContribution = pension; + incomeTotal += pension; + ec.summary = `Rente ${fmt(pension)}`; + } else if (!ownerWorking) { + ec.note = "Vollstaendig bezogen"; + ec.summary = "Bezogen"; + } else { + const start = floorToThousand(num(pd.currentValue)); + const contribution = floorToThousand(num(pd.annualContribution)); + const r = num(pd.expectedReturn); + ec.startValue = start; + ec.endValue = growAsset(start, r, contribution, phase.durationYears); + ec.capitalUse = Math.max(0, start - carry.value); + capitalUsed += ec.capitalUse; + // PK-Beitraege zaehlen NICHT gegen die Sparquote (in Ausgaben enthalten). + ec.summary = fmt(ec.endValue); + } + break; + } + case "PILLAR_3A": { + if (!ownerWorking) { + ec.note = "Vollstaendig bezogen"; + ec.summary = "Bezogen"; + } else { + const start = floorToThousand(num(pd.currentValue)); + const contribution = roundToHundred(num(pd.annualContribution)); + const r = num(pd.expectedReturn); + ec.startValue = start; + ec.endValue = growAsset(start, r, contribution, phase.durationYears); + ec.capitalUse = Math.max(0, start - carry.value); + capitalUsed += ec.capitalUse; + ec.quotaUse = contribution; // zaehlt gegen die Sparquote + quotaAllocated += contribution; + ec.summary = fmt(ec.endValue); + } + break; + } + case "OTHER_ASSET": { + const start = floorToThousand(num(pd.startValue)); + const contribution = floorToThousand(num(pd.annualContribution)); + const r = num(pd.expectedReturn); + ec.startValue = start; + ec.capitalUse = Math.max(0, start - carry.value); + capitalUsed += ec.capitalUse; + // In Erwerbsphasen (Sparen) wird eingezahlt, in Verzehrphasen bezogen -- das + // Vorzeichen ergibt sich aus der Phasenquote (siehe unten). Hier immer als + // Beitrag verbucht; die Verzehr-Logik nutzt denselben Betrag als Bezug. + ec.quotaUse = contribution; + quotaAllocated += contribution; + ec.endValue = growAsset(start, r, contribution, phase.durationYears); + ec.summary = fmt(ec.endValue); + break; + } + case "REAL_ESTATE": { + const purchase = floorToThousand(num(pd.purchasePrice)); + const mortgageStart = carry.hasCarry ? carry.mortgage : floorToThousand(num(pd.mortgage)); + const amort = floorToThousand(num(pd.amortization)); + const mortgageEnd = Math.max(0, mortgageStart - amort * phase.durationYears); + ec.startValue = purchase - mortgageStart; + ec.endValue = purchase - mortgageEnd; + if (!carry.hasCarry) { + ec.capitalUse = Math.max(0, purchase - mortgageStart); // Eigenkapital bei Neukauf + capitalUsed += ec.capitalUse; + } + carry.mortgage = mortgageEnd; // fuer Uebergang + ec.summary = fmt(ec.endValue); + break; + } + case "OTHER_DEBT": { + const owedStart = carry.hasCarry ? carry.owed : floorToThousand(num(pd.startValue)); + const repay = floorToThousand(num(pd.annualRepayment)); + const owedEnd = Math.max(0, owedStart - repay * phase.durationYears); + ec.startValue = -owedStart; + ec.endValue = -owedEnd; + carry.owed = owedEnd; + ec.summary = fmt(ec.endValue); + if (owedEnd === 0) ec.note = "Wird getilgt"; + break; + } + } + + elementsComputed.push(ec); + } + + // --- Kern-Kennzahlen --- + const quota = incomeTotal - expenseTotal; + const isConsumption = quota < 0; + // Sparphase: alles verteilt, wenn quotaAllocated == quota. Verzehrphase: gedeckt, + // wenn Bezuege (quotaAllocated) den Fehlbetrag decken. + const quotaTarget = Math.abs(quota); + const quotaComplete = Math.abs(quotaTarget - quotaAllocated) < 1; + + const availableCapital = incomingCapital; + const availableCapitalUsed = capitalUsed; + const availableCapitalComplete = + availableCapital === null || Math.abs(availableCapital - availableCapitalUsed) < 1; + + const incomplete = !quotaComplete || !availableCapitalComplete; + + const inflationRate = phase.inflationRate ?? household.inflationRateDefault; + cumulativeInflation = cumulativeInflation * (1 + inflationRate / 100); + + const startWealthNominal = elementsComputed.reduce((s, ec) => s + ec.startValue, 0); + const endWealthNominal = elementsComputed.reduce((s, ec) => s + ec.endValue, 0); + + result.push({ + id: phase.id, + name: phase.name, + sequenceNumber: phase.sequenceNumber, + durationYears: phase.durationYears, + type, + persons: personInfos, + maxDurationYears, + incomeTotal, + expenseTotal, + quota, + isConsumption, + quotaAllocated, + quotaComplete, + availableCapital, + availableCapitalUsed, + availableCapitalComplete, + incomplete, + elements: elementsComputed, + startWealthNominal, + endWealthNominal, + cumulativeInflationEnd: cumulativeInflation, + endWealthReal: endWealthNominal / cumulativeInflation, + }); + + // --- Uebergang zur naechsten Phase: Carry aktualisieren + Startkapital berechnen --- + let outgoing = 0; + for (const e of orderedElements) { + const carry = carries.get(e.id)!; + const ec = elementsComputed.find((x) => x.elementId === e.id)!; + const td = e.transitionValues[phase.id] ?? {}; + const owner = e.ownerRole && e.ownerRole !== "HOUSEHOLD" ? personByRole(persons, e.ownerRole) : null; + const ownerRetiresNext = + !!owner && !!nextPhase && workingByPerson.get(owner.id) === true && retiresInPhase(owner.id, nextPhase, persons, retirementAge, yearsBefore + phase.durationYears); + + if (carry.status !== "ACTIVE") { + carry.hasCarry = true; + continue; + } + + switch (e.category) { + case "PENSION_FUND": { + if (ownerRetiresNext) { + const value = ec.endValue; + const mode = td.payoutMode ?? "PENSION"; + if (mode === "CAPITAL") { + const net = floorToThousand(value * (1 - num(td.capitalTaxRate) / 100)); + outgoing += net; + carry.value = 0; + carry.pkPensionAnnual = 0; + } else if (mode === "PENSION") { + carry.pkPensionAnnual = floorToThousand((value * num(td.conversionRate)) / 100); + carry.value = 0; + } else { + const capital = Math.min(value, floorToThousand(num(td.capitalAmount))); + const net = floorToThousand(capital * (1 - num(td.capitalTaxRate) / 100)); + outgoing += net; + carry.pkPensionAnnual = floorToThousand(((value - capital) * num(td.conversionRate)) / 100); + carry.value = 0; + } + } else { + const withdrawal = Math.min(ec.endValue, floorToThousand(num(td.withdrawal))); + carry.value = ec.endValue - withdrawal; + outgoing += withdrawal; + } + break; + } + case "PILLAR_3A": { + if (ownerRetiresNext) { + const net = floorToThousand(ec.endValue * (1 - num(td.capitalTaxRate) / 100)); + outgoing += net; + carry.value = 0; + } else { + const withdrawal = Math.min(ec.endValue, floorToThousand(num(td.withdrawal))); + carry.value = ec.endValue - withdrawal; + outgoing += withdrawal; + } + break; + } + case "OTHER_ASSET": { + if (td.decision === "SELL") { + outgoing += ec.endValue; + carry.status = "SOLD"; + } else { + carry.value = ec.endValue; + } + break; + } + case "REAL_ESTATE": { + if (td.decision === "SELL") { + const purchase = floorToThousand(num(e.phaseValues[phase.id]?.purchasePrice)); + const salePrice = floorToThousand(num(td.salePrice)); + const gain = Math.max(0, salePrice - purchase); + const tax = gain * (num(td.saleTaxRate) / 100); + outgoing += floorToThousand(salePrice - carry.mortgage - tax); + carry.status = "SOLD"; + } + // HOLD: carry.mortgage bereits gesetzt. + break; + } + case "OTHER_DEBT": { + const immediate = Math.min(carry.owed, floorToThousand(num(td.immediateRepayment))); + if (immediate > 0) { + carry.owed = Math.max(0, carry.owed - immediate); + outgoing -= immediate; // sofortige Tilgung mindert das verfuegbare Kapital + } + if (carry.owed === 0) carry.status = "SETTLED"; + break; + } + default: + break; + } + carry.hasCarry = true; + } + + incomingCapital = nextPhase ? floorToThousand(outgoing) : null; + yearsBefore += phase.durationYears; + } + + const nachlass = result.length > 0 ? result[result.length - 1].endWealthNominal : 0; + return { phases: result, nachlass }; } -export function planToCsv(plan: PlanInput, planComputed: PlanComputed): string { +function personByRole(persons: { id: string; role: PersonRole }[], role: string) { + return persons.find((p) => p.role === role) ?? null; +} + +// Prueft, ob eine Person in der gegebenen Phase (mit gegebenem Jahres-Offset) pensioniert ist, +// obwohl sie in der Vorphase noch erwerbend war. +function retiresInPhase( + personId: string, + phase: { durationYears: number }, + persons: { id: string; role: PersonRole; age: number }[], + retirementAge: Map, + yearsBeforeNext: number +): boolean { + const p = persons.find((x) => x.id === personId); + if (!p) return false; + const ra = retirementAge.get(personId)!; + const startAgeNext = p.age + yearsBeforeNext; + return startAgeNext >= ra; +} + +function roundToHundred(v: number): number { + return Math.round((v || 0) / 100) * 100; +} + +function fmt(v: number): string { + const rounded = Math.round(v || 0); + const sign = rounded < 0 ? "-" : ""; + return sign + Math.abs(rounded).toString().replace(/\B(?=(\d{3})+(?!\d))/g, "'"); +} + +// CSV-Export (eine Zeile pro Lebensphase, Kernkennzahlen). +export function planToCsv(plan: PlanInput, computed: PlanComputed): string { const header = [ "Phase", - "Dauer (Jahre)", - "Startvermoegen (nominal)", - "Endvermoegen (nominal)", - "Endvermoegen (real)", + "Typ", + "Dauer", "Einkommen", "Ausgaben", - "Sparquote", - "Verplante Sparbeitraege", - "Einmalige Ereignisse (netto)", + "Spar-/Verzehrquote", + "Verfuegbares Kapital", + "Endvermoegen (nominal)", + "Endvermoegen (real)", ]; - const rows = planComputed.phases.map((p) => [ + const rows = computed.phases.map((p) => [ p.name, + p.type, String(p.durationYears), - p.startWealthNominal.toFixed(2), - p.endWealthNominal.toFixed(2), - p.endWealthReal.toFixed(2), - p.effectiveIncome.toFixed(2), - p.expenseTotal.toFixed(2), - p.savingsQuota.toFixed(2), - p.allocatedSavings.toFixed(2), - p.oneTimeNet.toFixed(2), + p.incomeTotal.toFixed(0), + p.expenseTotal.toFixed(0), + p.quota.toFixed(0), + p.availableCapital === null ? "n.a." : p.availableCapital.toFixed(0), + p.endWealthNominal.toFixed(0), + p.endWealthReal.toFixed(0), ]); return [header, ...rows].map((r) => r.join(";")).join("\n"); } diff --git a/src/lib/constants.ts b/src/lib/constants.ts index dee0876..4656b2d 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -1,7 +1,18 @@ -// AHV-Maximalrente (Einzelperson, CHF/Jahr). Aendert sich periodisch durch Anpassungen -// des Bundes -- deshalb hier als einzelner konfigurierbarer Systemparameter gefuehrt -// (TDD Kapitel 3.4), nicht hart im Code verteilt. -export const AHV_MAX_PENSION_PER_YEAR = 30240; +// Konfigurierbare Systemparameter (Stand 2026). Aendern sich periodisch durch +// Anpassungen des Bundes -- deshalb hier zentral gefuehrt, nicht im Code verteilt. -// Faktor fuer die Plafonierung der AHV-Rente bei Ehepaaren (TDD Kapitel 3.4). +// Maximale einfache AHV-Altersrente pro Jahr, inkl. 13. Rente (2'520/Monat × 13 = 32'760). +// Quelle: BSV / AHV-IV 2026. +export const AHV_MAX_ANNUAL_SINGLE = 32760; + +// Ehepaar-Plafonierung: die Summe beider Einzelrenten ist auf 150% der Einzel- +// Maximalrente begrenzt. Bei Ueberschreitung werden beide Renten proportional gekuerzt. export const AHV_COUPLE_CAP_FACTOR = 1.5; + +// Volle Beitragsdauer fuer eine ungekuerzte AHV-Rente (Rentenskala 44). Pro fehlendes +// Beitragsjahr (Ausfalljahr) wird die Rente um 1/44 gekuerzt. +export const AHV_FULL_CONTRIBUTION_YEARS = 44; + +// Maximaler jaehrlicher Saeule-3a-Beitrag fuer PK-Versicherte (2026). Wird in +// 100er-Schritten erfasst (nicht 1'000er wie andere Betraege). +export const PILLAR_3A_MAX_ANNUAL = 7258; diff --git a/src/lib/elements.ts b/src/lib/elements.ts new file mode 100644 index 0000000..00ab208 --- /dev/null +++ b/src/lib/elements.ts @@ -0,0 +1,125 @@ +// Typ- und Validierungs-Layer fuer die finanziellen Elemente. Die kategorie- und +// kontextspezifischen Felder liegen in der DB als JSON; hier werden sie typisiert und +// (an der API-Grenze) mit Zod validiert. + +import { z } from "zod"; + +export type ElementCategory = + | "INCOME" + | "EXPENSE" + | "AHV" + | "PENSION_FUND" + | "PILLAR_3A" + | "REAL_ESTATE" + | "OTHER_ASSET" + | "OTHER_DEBT"; + +export type OwnerRole = "PERSON_A" | "PERSON_B" | "HOUSEHOLD"; + +// Kategorien, deren Element zwingend genau einer Person zugeordnet ist. +export const PERSON_ONLY_CATEGORIES: ElementCategory[] = ["INCOME", "AHV", "PENSION_FUND", "PILLAR_3A"]; + +// Kategorien, die gemeinsam ODER pro Person erfasst werden koennen. +export const OWNER_OPTIONAL_CATEGORIES: ElementCategory[] = ["EXPENSE", "REAL_ESTATE", "OTHER_ASSET", "OTHER_DEBT"]; + +export const CATEGORY_LABELS: Record = { + INCOME: "Einkommen", + EXPENSE: "Ausgaben", + AHV: "AHV", + PENSION_FUND: "Pensionskasse", + PILLAR_3A: "Saeule 3a", + REAL_ESTATE: "Immobilie", + OTHER_ASSET: "Sonstiges Vermoegen", + OTHER_DEBT: "Sonstige Schulden", +}; + +// Reihenfolge der Kategorien in der Matrix (Gruppierung der Zeilen). +export const CATEGORY_ORDER: ElementCategory[] = [ + "INCOME", + "EXPENSE", + "AHV", + "PENSION_FUND", + "PILLAR_3A", + "REAL_ESTATE", + "OTHER_ASSET", + "OTHER_DEBT", +]; + +// --- Roh-Payloads (JSON in der DB) --- +// Bewusst tolerant getippt (alle Felder optional): die Berechnung liest defensiv mit +// Defaults, das UI zeigt je nach Kontext nur die relevanten Felder. + +export interface PhaseData { + // INCOME / EXPENSE + amount?: number; + // AHV + gapYears?: number; + // PENSION_FUND / PILLAR_3A / OTHER_ASSET + currentValue?: number; + startValue?: number; + expectedReturn?: number; + annualContribution?: number; + // REAL_ESTATE + purchasePrice?: number; + mortgage?: number; + amortization?: number; + // OTHER_DEBT + annualRepayment?: number; +} + +export type TransitionDecision = "HOLD" | "SELL"; +export type PkPayoutMode = "CAPITAL" | "PENSION" | "COMBI"; + +export interface TransitionData { + // PENSION_FUND / PILLAR_3A (normaler Uebergang) + withdrawal?: number; + // PENSION_FUND (Pensions-Uebergang) + payoutMode?: PkPayoutMode; + capitalAmount?: number; + conversionRate?: number; + // PENSION_FUND (Kapital) / PILLAR_3A (Pensions-Uebergang) / REAL_ESTATE + capitalTaxRate?: number; + saleTaxRate?: number; + // REAL_ESTATE / OTHER_ASSET + decision?: TransitionDecision; + salePrice?: number; + // OTHER_DEBT + immediateRepayment?: number; +} + +// --- Zod-Schemas (nachsichtig: unbekannte Felder werden verworfen) --- + +const nonNeg = z.number().min(0); + +export const phaseDataSchema = z + .object({ + amount: nonNeg.optional(), + gapYears: z.number().int().min(0).optional(), + currentValue: nonNeg.optional(), + startValue: nonNeg.optional(), + expectedReturn: z.number().min(-50).max(100).optional(), + annualContribution: nonNeg.optional(), + purchasePrice: nonNeg.optional(), + mortgage: nonNeg.optional(), + amortization: nonNeg.optional(), + annualRepayment: nonNeg.optional(), + }) + .strip(); + +export const transitionDataSchema = z + .object({ + withdrawal: nonNeg.optional(), + payoutMode: z.enum(["CAPITAL", "PENSION", "COMBI"]).optional(), + capitalAmount: nonNeg.optional(), + conversionRate: z.number().min(0).max(20).optional(), + capitalTaxRate: z.number().min(0).max(100).optional(), + saleTaxRate: z.number().min(0).max(100).optional(), + decision: z.enum(["HOLD", "SELL"]).optional(), + salePrice: nonNeg.optional(), + immediateRepayment: nonNeg.optional(), + }) + .strip(); + +export function num(value: number | undefined | null, fallback = 0): number { + return typeof value === "number" && Number.isFinite(value) ? value : fallback; +} diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 73fbee4..aef8a8a 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -1,25 +1,18 @@ import { Prisma } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; +import { phaseDataSchema, transitionDataSchema } from "@/lib/elements"; +import type { PhaseData, TransitionData } from "@/lib/elements"; import type { HouseholdInput, PlanInput } from "@/lib/types"; -export const phaseInclude = { - incomeEntries: true, - expenseEntries: true, - securities: true, - realEstates: true, - oneTimeEvents: true, - retirementInfos: true, -} satisfies Prisma.PhaseInclude; - export const planInclude = { - phases: { - include: phaseInclude, - orderBy: { sequenceNumber: "asc" }, + phases: { orderBy: { sequenceNumber: "asc" } }, + elements: { + orderBy: { orderIndex: "asc" }, + include: { phaseValues: true, transitionValues: true }, }, } satisfies Prisma.PlanInclude; export type PlanWithRelations = Prisma.PlanGetPayload<{ include: typeof planInclude }>; -export type PhaseWithRelations = Prisma.PhaseGetPayload<{ include: typeof phaseInclude }>; export type HouseholdWithPersons = Prisma.HouseholdGetPayload<{ include: { persons: true } }>; export function toHouseholdInput(household: HouseholdWithPersons): HouseholdInput { @@ -36,63 +29,44 @@ export function toHouseholdInput(household: HouseholdWithPersons): HouseholdInpu }; } +function parsePhaseData(raw: unknown): PhaseData { + const parsed = phaseDataSchema.safeParse(raw); + return parsed.success ? parsed.data : {}; +} + +function parseTransitionData(raw: unknown): TransitionData { + const parsed = transitionDataSchema.safeParse(raw); + return parsed.success ? parsed.data : {}; +} + export function toPlanInput(plan: PlanWithRelations): PlanInput { return { id: plan.id, name: plan.name, - parentPlanId: plan.parentPlanId, - branchFromPhaseId: plan.branchFromPhaseId, + retirementAgeA: plan.retirementAgeA, + retirementAgeB: plan.retirementAgeB, phases: plan.phases.map((phase) => ({ id: phase.id, sequenceNumber: phase.sequenceNumber, name: phase.name, durationYears: phase.durationYears, inflationRate: phase.inflationRate, - incomeMode: phase.incomeMode, - incomingCapital: phase.incomingCapital, - incomeEntries: phase.incomeEntries.map((e) => ({ - id: e.id, - personId: e.personId, - label: e.label, - amount: e.amount, - })), - expenseEntries: phase.expenseEntries.map((e) => ({ - id: e.id, - label: e.label, - amount: e.amount, - })), - securities: phase.securities.map((s) => ({ - id: s.id, - name: s.name, - startValue: s.startValue, - expectedReturn: s.expectedReturn, - annualContribution: s.annualContribution, - ownerTag: s.ownerTag, - saleTaxRate: s.saleTaxRate, - carriedBaseValue: s.carriedBaseValue, - })), - realEstates: phase.realEstates.map((re) => ({ - id: re.id, - name: re.name, - purchasePrice: re.purchasePrice, - mortgage: re.mortgage, - amortization: re.amortization, - })), - oneTimeEvents: phase.oneTimeEvents.map((e) => ({ - id: e.id, - type: e.type, - amount: e.amount, - description: e.description, - })), - retirementInfos: phase.retirementInfos.map((r) => ({ - id: r.id, - personId: r.personId, - ahvAmount: r.ahvAmount, - pkPensionAmount: r.pkPensionAmount, - lumpSumAmount: r.lumpSumAmount, - lumpSumTaxRate: r.lumpSumTaxRate, - })), })), + elements: plan.elements.map((e) => { + const phaseValues: Record = {}; + for (const pv of e.phaseValues) phaseValues[pv.phaseId] = parsePhaseData(pv.data); + const transitionValues: Record = {}; + for (const tv of e.transitionValues) transitionValues[tv.fromPhaseId] = parseTransitionData(tv.data); + return { + id: e.id, + category: e.category, + name: e.name, + ownerRole: e.ownerRole, + orderIndex: e.orderIndex, + phaseValues, + transitionValues, + }; + }), }; } @@ -101,7 +75,7 @@ export async function getHouseholdOrNull(userId: string): Promise; + transitionValues: Record; } export interface PlanInput { id: string; name: string; - parentPlanId: string | null; - branchFromPhaseId: string | null; + retirementAgeA: number | null; + retirementAgeB: number | null; phases: PhaseInput[]; + elements: ElementInput[]; }