Rework core model: financial elements as plan-wide entities across phases; derived phase types (Erwerb/Pension/Misch) with retirement-capped durations and per-plan retirement age; AHV (gap years + couple ceiling), PK payout/annuity, 3a, real estate, other assets/debts; horizontal timeline with retirement markers; phase x element matrix with detail panel; savings/consumption quota + available-capital key figures with red status
Deploy App / deploy (push) Successful in 1m57s

This commit is contained in:
2026-07-13 07:55:58 +02:00
parent b3a3b565ac
commit b775ab77cb
27 changed files with 2407 additions and 2166 deletions
@@ -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;
+70 -166
View File
@@ -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])
}