// FPT (Financial Planning Tool) — Datenmodell. // // Kernkonzept: finanzielle Elemente leben auf SZENARIO-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). // // Rework 07/2026 (V6, Szenario-Hierarchie): Der PLAN ist neu ein schlanker Behaelter und // traegt selbst keine Finanzdaten. Die berechenbare Einheit ist das SZENARIO -- es traegt // das Grundprofil (Haushaltsform, Personen, Inflation, Cash-Anfangswert), die Phasenkette // und die Elemente. Jeder Plan hat genau ein Basisszenario (isBase) und beliebig viele // weitere Szenarien, die als Baum (parentScenarioId) darunter haengen. Kopierte Phasen und // Elemente tragen einen Herkunfts-Verweis (sourcePhaseId / sourceElementId) auf ihr // Gegenstueck im Eltern-Szenario -- darauf beruht die Abweichungs-Markierung im UI. // // Namens-Hinweis: In der Berechnungsschicht heisst die berechenbare Einheit weiterhin // `PlanInput` / `computePlan` (sie beschreibt eine Finanzplanung -- fachlich ein Szenario). generator client { provider = "prisma-client" output = "../src/generated/prisma" } datasource db { provider = "postgresql" } model User { id String @id @default(cuid()) username String @unique passwordHash String createdAt DateTime @default(now()) plans Plan[] versions ScenarioVersion[] actuals ActualsSet[] analyses SavedAnalysis[] reports Report[] } enum HouseholdType { SINGLE COUPLE } enum PersonRole { PERSON_A PERSON_B } enum OwnerRole { PERSON_A PERSON_B HOUSEHOLD } enum ElementCategory { INCOME EXPENSE AHV PENSION_FUND PILLAR_3A REAL_ESTATE OTHER_ASSET OTHER_DEBT } // Einzelperson eines Szenarios. retirementAge ist das (szenario-eigene) Pensionsalter -- // dadurch sind Frueh-/Spaetpensionierungs-Szenarien moeglich. // Szenario-EIGENE Angabe zu einer Person. Seit V7 nur noch das Pensionsalter -- es ist der // Kern jedes Frueh-/Spaetpensionierungs-Szenarios. Name und Alter beschreiben den Haushalt // und liegen deshalb am Plan (PlanPerson). model Person { id String @id @default(cuid()) scenarioId String scenario Scenario @relation(fields: [scenarioId], references: [id], onDelete: Cascade) role PersonRole retirementAge Int // Bis zu welchem Alter gerechnet wird. Bisher ergab sich das Planende stillschweigend aus // der Summe der Phasendauern -- zwei Szenarien konnten dadurch unbemerkt verschieden weit // rechnen und waren nicht vergleichbar. planningHorizonAge Int? @@unique([scenarioId, role]) } // Eine Person des HAUSHALTS. Gilt fuer alle Szenarien des Plans. model PlanPerson { id String @id @default(cuid()) planId String plan Plan @relation(fields: [planId], references: [id], onDelete: Cascade) role PersonRole name String? age Int @@unique([planId, role]) } // Behaelter einer Planung. Traegt selbst KEINE Finanzdaten, nur den Namen und die Szenarien. model Plan { id String @id @default(cuid()) userId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) name String // Seit V7 am Plan: Diese Angaben beschreiben den HAUSHALT, nicht eine Planungsvariante. // Unterscheiden sie sich, ist es ein anderer Plan -- kein anderes Szenario. householdType HouseholdType // Kalenderjahr, in dem die Planung beginnt (Jahr 1). Rein fuer die Darstellung -- // die Berechnung rechnet weiterhin in relativen Jahren ab Planbeginn. startYear Int? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt scenarios Scenario[] actuals ActualsSet[] persons PlanPerson[] analyses SavedAnalysis[] reports Report[] } // Ein erzeugter PDF-Bericht (Roadmap Nr. 11). Die FERTIGE DATEI wird abgelegt, nicht nur // ihre Definition: Ein Bericht, den man heute verschickt, muss in drei Jahren unveraendert // wieder herunterladbar sein -- eine Neuerzeugung koennte das nach Aenderungen an Plan, // Rechenkern oder Layout nicht garantieren. model Report { id String @id @default(cuid()) planId String plan Plan @relation(fields: [planId], references: [id], onDelete: Cascade) title String // Gewaehlte Parameter und das eingefrorene Berichtsmodell (alle Zahlen). config Json @default("{}") model Json @default("{}") pdf Bytes pdfBytes Int @default(0) createdById String createdBy User @relation(fields: [createdById], references: [id], onDelete: Cascade) createdAt DateTime @default(now()) @@index([planId, createdAt]) } // Eine festgehaltene Analyse (Roadmap Nr. 5 / Analysen-Ansicht). Haelt EINGABEN und ERGEBNIS // als Zahlen fest -- read-only, es wird nichts neu gerechnet. Zahlen statt Bild, damit der // spaetere PDF-Bericht daraus vektoriell zeichnen kann. model SavedAnalysis { id String @id @default(cuid()) planId String plan Plan @relation(fields: [planId], references: [id], onDelete: Cascade) name String type String // CHART | MONTE_CARLO | SENSITIVITY // Denormalisierte Kerndaten fuer die Liste. scenarioName String? versionLabel String? metric String @default("nominal") source String @default("PLAN") summary String? inputs Json @default("{}") result Json @default("{}") createdById String createdBy User @relation(fields: [createdById], references: [id], onDelete: Cascade) createdAt DateTime @default(now()) @@index([planId, createdAt]) } // Ein erfasster Stand der WIRKLICHKEIT zu einem Stichtag (Roadmap Nr. 5). // // Haengt am PLAN, nicht am Szenario: Das tatsaechliche PK-Guthaben am 18.8.2026 ist eine // Zahl, unabhaengig davon, gegen welches Szenario man sie haelt. Die Zuordnung auf die // szenario-eigenen Element-IDs erfolgt ueber die Herkunfts-Kette (lib/actuals.ts). model ActualsSet { id String @id @default(cuid()) planId String plan Plan @relation(fields: [planId], references: [id], onDelete: Cascade) // Exaktes Datum fuer Liste und Zeitachse; fuer die Rechnung zaehlt nur `year`. recordedOn DateTime @db.Date year Int comment String? // Effektiver Cash-Bestand (Cash ist kein FinancialElement). cash Float? // Werte je Wurzel-Element: Record values Json @default("{}") createdById String createdBy User @relation(fields: [createdById], references: [id], onDelete: Cascade) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([planId, year]) } // Die berechenbare Einheit: Grundprofil + Phasenkette + Elemente. Genau ein Szenario je // Plan ist das Basisszenario (isBase); weitere haengen als Baum darunter (parentScenarioId). model Scenario { id String @id @default(cuid()) planId String plan Plan @relation(fields: [planId], references: [id], onDelete: Cascade) name String isBase Boolean @default(false) // Szenario, aus dem dieses kopiert wurde. Zugleich die Vergleichsbasis fuer den Diff // (Basisszenario: null). Beim Loeschen des Elternteils bleibt der Baum flach erhalten. parentScenarioId String? parentScenario Scenario? @relation("ScenarioTree", fields: [parentScenarioId], references: [id], onDelete: SetNull) children Scenario[] @relation("ScenarioTree") // Szenario-eigene Annahmen. Haushaltsform, Personen und Startjahr liegen seit V7 am Plan. inflationRateDefault Float initialCash Float @default(0) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt // Laufende Hauptversion. Die Nebenversionen zaehlen in ScenarioVersion. currentMajor Int @default(0) persons Person[] phases Phase[] elements FinancialElement[] versions ScenarioVersion[] } // Ein festgehaltener Stand eines Szenarios (Version A.B). Haelt den VOLLSTAENDIGEN Zustand // als JSON in der Form PlanInput -- nicht eine Differenz. Dadurch laesst sich jeder alte // Stand ohne Umbau rechnen (computePlan), anzeigen und wiederherstellen. model ScenarioVersion { id String @id @default(cuid()) scenarioId String scenario Scenario @relation(fields: [scenarioId], references: [id], onDelete: Cascade) major Int minor Int // Pflicht bei Hauptversionen; Wiederherstellungen tragen hier ihre Herkunft. comment String? isMajor Boolean @default(false) createdById String createdBy User @relation(fields: [createdById], references: [id], onDelete: Cascade) snapshot Json createdAt DateTime @default(now()) // Wird bei Zusammenfassung innerhalb einer Bearbeitungssitzung mitgezogen. updatedAt DateTime @updatedAt @@unique([scenarioId, major, minor]) @@index([scenarioId, createdAt]) } // Ein Lebensabschnitt innerhalb eines Plans. Der Phasentyp (Erwerb/Pension/Mischung) // wird NICHT gespeichert, sondern aus Alter + Pensionsalter abgeleitet (lib/calculations). // Die Inflation liegt seit V5 plan-weit am Plan (inflationRateDefault), nicht mehr hier. model Phase { id String @id @default(cuid()) scenarioId String scenario Scenario @relation(fields: [scenarioId], references: [id], onDelete: Cascade) sequenceNumber Int name String durationYears Int // Gegenstueck im Eltern-Szenario (lose Referenz, kein FK) -- Grundlage des Diffs. sourcePhaseId String? // Entscheid fuer das Cash-Konto beim UEBERGANG NACH dieser Phase (JSON, validiert in // lib/elements.ts): 1:1 uebernehmen oder einmaliger Zufluss/einmalige Kosten. Liegt hier // und nicht in ElementTransitionValue, weil Cash kein FinancialElement ist. cashTransition Json? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt phaseValues ElementPhaseValue[] transitionValues ElementTransitionValue[] @relation("TransitionFromPhase") @@unique([scenarioId, sequenceNumber]) } // Ein finanzielles Element (szenario-weit): Kategorie + optionale Personenzuordnung. model FinancialElement { id String @id @default(cuid()) scenarioId String scenario Scenario @relation(fields: [scenarioId], references: [id], onDelete: Cascade) category ElementCategory name String ownerRole OwnerRole? orderIndex Int @default(0) createdAt DateTime @default(now()) // Gegenstueck im Eltern-Szenario (lose Referenz, kein FK) -- Grundlage des Diffs. sourceElementId String? // Pensionierungs-Entscheid (AHV/PK/3a). Bewusst OHNE Phasen-ID im Schluessel: So ueberlebt // er jede Verschiebung der Zeitachse. Siehe src/lib/retirement-decision.ts. retirementDecision Json? phaseValues ElementPhaseValue[] transitionValues ElementTransitionValue[] } // 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]) } // Entscheid/Werte eines Elements beim UEBERGANG nach der Phase fromPhase (JSON). model ElementTransitionValue { id String @id @default(cuid()) 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]) }