d9ef980edf
Deploy App / deploy (push) Successful in 3m5s
Neuer Unterpunkt "Berichte" je Plan: Liste plus Assistent (Titel, Notiz, nominal ODER real, Plan-/Ist-Daten, bis zu drei Szenarien, gespeicherte Analysen). Layout immer gleich, Auswahl bestimmt nur die Bausteine. Die PDF-Datei wird ALS DATEI abgelegt (BYTEA in Postgres, nicht im Container-Dateisystem): Ein Bericht muss in drei Jahren byte-identisch wieder herunterladbar sein -- eine Neuerzeugung koennte das nach Aenderungen an Plan, Rechenkern oder Layout nicht garantieren. Kennzahlen je Szenario inkl. offener Entscheide. Deren Zaehlung liegt neu als reine Funktion in decisions.ts, die Matrix UND Bericht benutzen -- sonst nennen beide verschiedene Zahlen. Zu jeder Kennzahl ihre Grundlage als Verweis; die vollstaendigen Annahmen einmal je Szenario. Haftungsausschluss ist verpflichtend (per Test). Technik: pdfkit in der Node-Runtime statt Headless-Browser; @react-pdf/renderer bricht mit React 19. Als externes Paket deklariert, weil pdfkit Font-Metriken ueber Dateipfade laedt. Spezifikation 0.25 (3.11 neu), 9 Tests (212 -> 221). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
331 lines
11 KiB
Plaintext
331 lines
11 KiB
Plaintext
// 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
|
|
|
|
@@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<rootElementId, { value?, mortgage? }>
|
|
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(1)
|
|
|
|
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?
|
|
|
|
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])
|
|
}
|