commit 1e8f1772d9d0e5c345faeea30fd73df9b441ed5d Author: Damian Kellenberger Date: Wed Jul 8 00:13:32 2026 +0200 First Commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0c8aed1 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +node_modules +.next +.git +.gitignore +.env +.env*.local +README.md +AICDS FPT Memo* +_fdd_extract.txt +docker-compose.dev.yml +*.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..90af50f --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +DATABASE_URL="postgresql://fpt:fpt_secret@localhost:5432/fpt?schema=public" +POSTGRES_PASSWORD=fpt_secret diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..3722418 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": ["next/core-web-vitals", "next/typescript"] +} diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..821bf74 --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,22 @@ +name: Deploy App + +on: + push: + branches: + - main + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Deploy to Server + run: | + APP_DIR="/opt/aicds/apps/${GITHUB_REPOSITORY#*/}" + mkdir -p $APP_DIR + cp -a . $APP_DIR/ + cd $APP_DIR + docker compose down || true + docker compose up -d --build \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b1839d5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +# dependencies +/node_modules +/.pnp +.pnp.js +.yarn/install-state.gz + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem +_fdd_extract.txt + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# env files +.env +.env*.local + +# typescript +*.tsbuildinfo +next-env.d.ts + +# prisma +prisma/*.db diff --git a/AICDS FPT Memo 2026070701 b/AICDS FPT Memo 2026070701 new file mode 100644 index 0000000..f75a2aa --- /dev/null +++ b/AICDS FPT Memo 2026070701 @@ -0,0 +1,134 @@ +AICDS FPT - Memo 26070701 +Sprecher 1 00:00:04 +Da ist meine erste Aufnahme zum gelernten FPT Finanzplanungstool. +Ich wäre in dieser ersten Aufnahme einfach mal ein bisschen frei darüber reden, Manni alles in diesem Finanzplanungstool erwarten. +Der Finanzplanungstooler soll letztendlich eine Webapplikation sein. +Die auf meiner eigenen Domain gekostet wird oder produziert wird. +Und zwar wird die Domain wird dann so etwas heißen wie FPT Punkt AICDS. +Punkt. CH. +Genau. +Das Tool wird eine Webapplikation sein und solltest ein indes wir am Ende benutzen. +Zur Planung von der finanziellen Zukunft helfen. +Ich will. +Dass sämtliche Daten eigentlich Manuel eingespeist werden. Also ich erwarte keine Anbindung an irgendwelche anderen Systeme oder Daten. +Sondern ich will wirklich mehr so als Planungstool nutzen, dass man auch ein bisschen mit Zahlen rumspielen kann. Verschiedene Prognosen machen, verschiedene Szenarien durchspielen. +Durch das solche Sachen. +Genau. +Ich denke da grundsätzlich sowieso vor, dass wir initial mal eine gewisse Grundparameter angeben. +ZB. +Ist man alleine? +Oder hat man Partner, wie zum Beispiel ihre Frau, wie auch Kinder? +Dass man dann nachher sagen kann, wie alt das vor allem natürlich v und und selber. +Ist also der Endbenutzer selber, wie alt das so ist? +Dass man etwas sagen will, dass man plant, mit Renten zu gehen, in welchem Alter, dass man angeben kann. +Ja. +Einfach so Sachen vielleicht so ein bisschen. +Geschätzt die Inflationsrate, die wir erwarten, dort über die nächsten Jahre. +Und und und. +Ich würde mir das so vorstellen, dass es so ein bisschen verschiedene Lebensabschnitte gibt. Und ich glaube, diese Lebensabschnitte werden ganz zentral. +In dieser Applikation. +Nämlich sollte man können. Aus meiner Perspektive heraus. +Selber definieren, wie lange der nächste Lebensabschnitt ist. Zum Beispiel könnte ich sagen, Hey, eben die nächsten 10 Jahre ist für mich der Lebensabschnitt 1. +Und zwar in diesem Lebensabschnitt sind alle Leben. +Sie bin ich noch für meine Kinder. Ich habe vielleicht ein mässiges Einkommen, relativ viele Ausgaben und so weiter. +Dann aber, nach 10 Jahren, wäre gewöhnlich meine Wanderfeuer also und auswandern. Aber ziehen meine Kinder aus, ich bin nicht mehr der nicht mehr für sie sorge. +Sie sind erwachsen. Und dann beginnt für mich die Lebensabschnittphase 2 und dort kann ich vielleicht mehr verdienen. +Weil er einfach älter geworden bin, ich erfahrener geworden bin. Ich habe geringere Ausgaben. +Dementsprechend auch mehr Budget zur Verfügung zum Sparen. +Ja, nicht so. +Da heißt aber, ich stelle mir dann vor, dass wir dann eine Lebensabschnittsphase definieren können, also eröffnen können quasi. +Und dann initial machen und sagen, wie lange dauert denn die nächste Lebensabschnittsphase? +Zum Beispiel 10 Jahre. +Dann würde ich erwarten, dass wir pro Lebensabschnitt Phase halt jene Sachen definieren. +Ein Punkt, sicher, ganz zentral ist das geschätzte Einkommen während dieser Zeit. Also man kann sagen, Hey. +Während dieser Zeit erwarte ich ein Einkommen vor. +100000 keine Ahnung was. +Vielleicht auch pro Person oder wenn man initial angegeben hat, dass man 2 Personen ist. Quasi dass man 2 Personen einen Haushalt hat bezüglich der Finanzen. Also die Kinder sind ja witzig, ignorieren für die ganze Finanzplanung, aber wenn man halt eben sagt, ja gut, ich habe 2 Personen Haushalt. +Wie es eine Frau für die Frau sagen kann, was sie für ein geschätztes Einkommen hat während dem nächsten Lebensabschnittsphase. +Dann vielleicht rentenbezüger, also dann erst später von der Lebensabschnittsphase. Relevant aber, dass man dort angeben kann oder was geschätzte Renteneinkommen sein wird. +Dass man vielleicht angeben kann, was sind die geschätzten Ausgaben während dieser Lebensabschnittsphase noch ganz zentral. +Dann würde ich erwarten, dass wir geschätzte Inflationsraten angehen können, um mal wirklich zu merken, wie die Kaufkraft vom. +Klar, also effektiv. Kaufkraft ist quasi vom starken Vermögen. +Ich würde erwarten, dass man. +Wieso eine Art Werteverzeichnisse zu pflegen? Also ich kann sagen, Hey, ich habe vor zum in deinem Lebensabschnitt halt. +Keine Ahnung. +Sie und die Wertpapiere zu behalten. Also man kann wie kann komplett allgemein sein, also muss man ja nicht irgendwie auf irgendeinem Value oder easy gehen oder irgendetwas einfach ganz allgemein das was man sagt, Hey, ich investiere zum Beispiel in einen globalen ETF. +Sehr geehrter Name, sei denn einfach globaler ETF. +Dann wird der erwartete Rendite angeben. +Dann kann ich noch angeben, wie viel, dass ich jährlich besparen tu oder dass ich sage, Hey, während der Lebensabschnittsphase gehe ich davon aus, dass ich auch 500 Franken pro Jahr dort quasi reinvestieren kann. +Und dann wird das so ein bisschen auf Basis von einem Zinseszins, Kalkulation Halt berechnet, was denn die geschätzte. +Sage jetzt mal, das Gesamtvermögen ist von dem und von dieser von dem Einzelnen. +Wertpapier quasi per Ende von von von einer Lebensabschnittsphase. +Da geht natürlich auch soweit. +Wir natürlich mehrere Sachen anziehen, sonst brauchte ich dann eben. +Nicht eine Wertschrift und eben ein Global ETF und eine zweite Wertschrift würde ich dann vielleicht bei ihnen mit. +Risikoreiche Aktien, keine Ahnung, wer und dort würde ich dann vielleicht erwarten, dass wir dort deutlich höher machen. Dafür hätte ich vielleicht ein geringeres Startguthaben. +Irgendwie so. +Immobilien, da werden auch neue Business machen. Hey. +Was sagen Sie momentan? Ja nicht einfach die Immobilie, dann gebe ich vielleicht auch wieder an, was ist. +Sage jetzt mal der aktuelle Wert. Was ist die aktuelle Hypothek? Meist die geschätzte Wertsteigerung während dem Lebensabschnitt und was ist die? +Geschätzt Amortisationsrate während dieser Lebens. +Phase. +Dann wir können noch relevant sein. +Bezüge und ausgehen also vielleicht so eine besondere einmalige Einkommen und besondere einmalige Ausgaben. +Ist auch pro Lebensabschnitt, falls es durchaus möglich ist, zum Beispiel eine spezielle Ausgabe, könnte ich mir vorstellen, dass man vielleicht sagt, Hey, in den nächsten 5 Jahren habe ich mal vor zu mir noch einen Pool zu bauen im Garten und der kostet halt einfach dann einmalig einen riesen Betrag. +Andersrum können wir nachher sagen, Hey, ich gehe vielleicht raus in einen gewissen Lebensabschnitt, dass ich dort vielleicht auch mal etwas erben werde und dementsprechend gehe ich dann auf den Fuß, dass ich einen gewissen wirr, also einen gewissen wert quasi. +Ja, mehr zur Verfügung habe. +Ja, irgendwie so. +Genau zentral wäre dann denke ich. +Dass man einfach. +Am Ende einer Lebensabschnittsphase aus den Ganzen. +Sagen Sie mal saldi wieso ein bisschen? Also wie das alles wie Saldiert wird oder dass man, wie kann sagen Hey, am Ende dieser Lebensabschnittsphase gehe ich davon aus, dass ich also auf Basis von diesen Berechnungen bin, also das ist schnell eigentlich, das Auto kommt von dieser Webapplikation, dass die mir dann sagt, Hey, am Ende von der vom ersten Lebensabschnitt wirst du eben ein Gesamtvermögen haben, von dem und dem. +Und dann könnte ich mit einem zweiten Lebensabschnitt eröffnen. +Könnten wir sagen, jetzt mal auf die Basis vom End Saldo vom ersten Lebensabschnitt könnte dann quasi mein Startsaldo einrichten. +Müssen eine www.bilanz die Schlussbilanz quasi. +Von der ersten Phase müsst ihr die Startbilanz von einer zweiten Phase. +Und in der zweiten Phase könnte ich dann vielleicht auch sagen, Hey, die haben diese Wertschrift übernehmen. +Die Wirtschaft würde vielleicht verkaufen zu diesem Zeitpunkt. +Das wäre so entscheidend und das berechnet sich vielleicht immer so ein bisschen. Ich sage jetzt mal verfügbares Startguthaben für den neuen Lebensabschnitt, wo ich drinnen wieder komme, wieder neu verteilen kann auf neue Wertschriften. +Bis andersherum. Gerade als synchron. +Weil man natürlich die erwartet, das Einkommen und erwarten Ausgabe in einem Lebensabschnitt anbieten kann. Sich daraus heraus sage ich jetzt mal die Sparkarte berechnen oder die mögliche Sparkarte berechnen. +Auch dort wiederum würden dann erwarten, dass man dann, wie kann die möglichst sparen, eben verteilen auf die einzelnen Wertschriften. +Also beispielsweise wenn ich hunderttausend verdiene, Fünfundneunzigtausend ausgebe, dann. +Habe ich draussen wie 5000 zum Sparen übrig und dann könnte er vielleicht wie sagen Hey wenn ich 5000 pro Jahr zum Sparen habe, kann ich die wie? +Wenn ich wie ein Datenfeld pro Wertpapiere, wo ich sagen kann, wie viele weisst du meine Sperre, meine meine meine jährliche Sparrate. +Und dann kann ich die 5000 vielleicht verteilen, dass ich sage, 4000 investiere in meinen globalen ETF pro Jahr und 1000. +Spare ich halt, also nutze ich für für meine Risiken meine risikoartigen Aktien. +Genau. +Man sicher auch noch cool wäre, ist. +Bei der Pensionierung dann anfängt. +Dann muss ich dann sicher kommen. +Ja, wie man sich also, wenn man es halt merkt, bei der bei der Memon pensioniert wird, dann gibt es ja da so eine AHV, Rente beispielsweise und dort gibt es ja zum Beispiel, wenn man ein Ehepaar beide pensioniert ist, dann tut man nur nicht das 1 Komma fünffache von einer AHV beziehen und nicht das Doppelte. +Vielleicht müsste man dann machen, dass sie dann. +Sagen, hey, ich bin Person 1, in die Rente geht. +Dann kommt Person 1 der vollbetrag, also wirklich das Einkommen halt auch quasi angeben. +Sie bezieht, dass sie voll AHV. +Person 2 hat aber ein normales Einkommen. +Und dann macht man vielleicht einen neuen Lebensabschnitt, wenn die Person 2 auch in die Rente geht. +Und dann müssen wir sagen, Hey, jetzt. +Haben wir Gemeinsames? +Einkommen, nämlich die 1,5 fache AHV. Also was ich damit sagen würde ist einfach. +Da die ganzen. +Sachen bezüglich wartenden Einkommen, erwarten, Ausgaben usw machen. Vielleicht weiß ich nicht wie man die kann pro Person angeben aber auch zusammen. +Quasi auf gemeinsamer Ebene kann ich sagen, er hat auf gemeinsamer in einem gewissen Wartezeit kommen. +Gerade auch bei den Ausgaben bei den Ausgaben ZB würde ich eher. +Auf gemeinsamer Ebene angeben, was wir insgesamt miteinander ausgeben, pro Jahr und weniger, was ich als Einzelpersonen gebe. +Aber das Einkommen wiederum ist einfacher zum Angreifen für mich pro Einzelperson. +Die Sparrate kann sich dann aber eigentlich, also ja, der Marktpunsch ist ja Sinn, wenn man das so machen würde und dann würde man einfach wirklich. +Bei der Gemeinde also, wir sagen. +Wenn keine gemeinsame, also das finale Einkommen für einen gewissen Lebensabschnitt berechnet sich dann einfach immer aus der Summe von allem, also Summe von. +Von gemeinsam. +Plus der einzelne. +Oder müssen wir entweder oder machen? Vielleicht ist das fast am Schlausten. +Entweder aus einer Summe von den Einzelnen. +Personen oder mehr definier zahlen auf gemeinsamer Ebene. +Und Sparrate wieder um. Sollte ich dann eben einfach. +Immer quasi auf das gemeinsame beziehen, oder? +Also mit pro Person. +Auch die ganzen Wertschriften würden dann alles. +Nicht die Person anziehen, sondern wirklich einfach für einen Lebensabschnitt, für den ganzen Haushalt, quasi für für beide Personen zusammen anziehen. Also man könnte ja dann unter Unternehmen eine Wertschrift anlegen mit mit globaler ETF Person A Global ETF Person B. +Haben Sie sich mal so getrennt? +Öffnen. +Genau. +Der Iversager da sind wir mal das Erste so ein bisschen in meinen Gedanken. +Genau. \ No newline at end of file diff --git a/AICDS FPT Memo 2026070702 b/AICDS FPT Memo 2026070702 new file mode 100644 index 0000000..7939925 --- /dev/null +++ b/AICDS FPT Memo 2026070702 @@ -0,0 +1,167 @@ +FPT Memo 2 +Sprecher 1 00:00:01 +Das ist mein zweites Memo zum Thema FPT Financial Planing Tool. +In diesem Memo gehe ich auf bar. +Unklarheiten, Widersprüche, die. +Claude Bro identifiziert hat in meinem ersten Memo. +Der eine Punkt war, dass Einzelpersonen versus Haushalt. +Herr schildhüter, du sagst selber, dass du nicht weisst, ob Einkommen pro Person aber auch zusammenlaufen soll und stellst dich entweder oder Fragezeichen Raum. +Genau. +Also ich sehe es so, ich wir müssten beides machen. +Also ich würde auch sagen Einkommen. +Würde also ich bin einfach immer pro Lebensabschnittsphase angeben dort muss. +Gemeinsam definieren oder nicht? Oder sprich wenn ich vielleicht ein gemeinsames Einkommen definiere. +Dann sollten wir die Werte in. +Weil pro Person Einkommen ausgeraubt werden, damit wir dort gar nicht mehr beitragen. +Das gleiche auch bei den Ausgaben. Es geht ja grundsätzlich aus meiner Sicht geht es nur wieder um die Ausgaben und und das Einkommen. +Alles andere quasi auf gemeinsamer Ebene dargestellt und aufgezeigt, aber. +Eine Einkommensausgaben würde erwarten, dass man es pro Person angeben kann. +Oder aber wie man will, kann man es auch auf gemeinsamer Ebene angeben. +Wie man es auf gemeinsamer Ebene angibt, denke ich ist klar oder? Dann dann brechen sich die Sparkarten einfach aus dem gemeinsam heraus und dann hat man alle gemeinsam sparrate. +Wenn man die Sparrate also, wie man es, wie man es auf einzelner Ebene angegeben hat, die Eingaben ein Einkommen und Ausgaben, dann würde ich erwarten, dass sie wie aufsummiert werden. Und dann wird eine gemeinsame. +Aufsummierte Sparquote quasi berechnet. Aufpassen von den Aufsummierten einzelnen Einkommen und auf das wir die einzelnen Ausgaben. +Ich hoffe das beantwortet. +Der erste, der erste Punkt. +Zweiter Punkt, der genannt worden ist, ist wegen der Wertschrift, wie man die pro Haushalt oder pro Person angeben soll. +Dort bin ich der Meinung, ganz klar pro Haushalt, wenn ich hier ein Beispiel in meinem ersten Memo gesagt habe, wegen globaler ETF für Person A globale Person B. +Da meint einfach, da wäre der von manuelle Benennungen. Also ich würde sagen ein Lebensabschnitt drin kann man haben oder Lebensphase drin. +Dann kann ich einfach dann mehrere Wertschriften anlegen, zum Beispiel 3 Wertschriften anlegen und jede Wertschrift kann ich einfach so benennen, wie ich möchte. +Und sie sind aber immer auf gemeinsamer Ebene da. Die Sparquote, die da immer auf gemeinsamer Ebene berechnet wird. Es geht immer um den gesamt Finanzplanung vom Gesamthaushalt. +Und nicht von den einzelnen Personen. +Und den Wertschriften kann ich auf jeden Namen geben. Und wenn ich es will, für mich quasi aus meiner Endbenutzersicht einfach trennen. +Dann würde ich halt einen Namen geben, eben global Personal. +Person B. +Aber ich muss nicht, oder? Also ich habe auch einfach eine Wertschrift anlegen, die einfach globalere TFH ist der Punkt. +Claude Bru hat mir dann aber da noch vorgeschlagen, vielleicht aus dem Personen Tag von ihm. Das ist natürlich. +Auch ganz, ganz cool. Da war doch ganz, ganz lässig. +Wie man sie einfach angeben. +Auf dem Wertschriftenverzeichnis pro Lebensschritt ab Phase Wem gehört wahr? Das wäre natürlich fast die schönste Lösung. +Sagen. +Der erste ETF quasi, der ist der Personal, die andere Person B und das andere gehört beiden. +Dann wird ein Punkt A. +Ist der Punkt 3 drauf von Cloud Promo hinterfragt wird mit der Sparrate Verteilungslogik. +Da wird Fragen, wie man quasi verteilen will auf die einzelnen Wertschriften für das Sparen. +Hier sehe ich einfach eine manuelle. +Eingabe. Also ich würde sagen, dass wir pro Wertschöpfung AG wieviel vom. Also wir haben mit der mehr Sparquote pro. +Pro Lebensabschnitt. +UA gibt quasi wieviel Geld kann man pro Jahr sparen während dieser Lebensphase? +Und. +Dann würde ich davon ausgehen, dass man einfach kann, dass man sagen 5000 wären, dass ich einfach 5000 zur Verfügung habe, um auf die einzelnen Wertschriften quasi zu verteilen. Dass ich kann sagen, Hey, bei der einen tue ich 1000 pro Jahr reinerstiere, bei den anderen 4000 das sind aber manuelle Eingaben. +Dann, beim Punkt 4 wird wegen dem Phase die Bank gefragt, ob es ein bisschen eine Art Default gibt für die einzelnen Wertschriften. +Dort würde ich wirklich sagen. +Der letzte wäre wirklich, aber ich stelle mir halt die ganze Applikation so ein bisschen vor, dass man den besser runterscrollen kann. +Und ich habe wirklich erwartet, dass zwischen 2 einzelnen Lebensphasen, wieso eine Zwischenphase kommt, wieso ein Zwischending, wieso ein bisschen eben so eine Schlussbilanz quasi von der vorhergehenden Lebensphase. +Und dass man dort ihnen wirklich sagen kann, Hey wertschrift, welche Wertschrift tu ich übernehmen? +Ja. +Etwas zu diesem Stil. +Also ich würde keine Vorräte machen. +Ich bin wirklich einfach Excel Seite User auffordern und sagen Hey. +Die Wertschriften. +1234 übernehme ich. +05:00 Uhr auszahlen. +Also es war rasch. +Was spüre ich eigentlich zu Claude von sie? So gerne auch mal einen alternativen Vorschlag. +Jedes Mal, wenn ich das sonst könnte, machen mir das auch wichtig, dass man die Wertschriften. +Selber benachteiligt einen passiven Anleger und ich tue es relativ lange, behalten die Wertschriften, oder? +Aber nichtsdestotrotz gibt es viele Momente, in denen ich sage, jetzt würde ich mal wieder umlagern. Jetzt habe ich mal neu adoptiert, also nachher. +10 Jahren oder so. +Dann kommt es durchaus vor, dass ich mal wieder etwas verkaufen tu. +Und dafür etwas Neues investieren. +Und da würde ich auch noch von ihrer einfachen und coolen Art und Weise in dieser Webapplikation machen, dass ich kann sagen, Hey, jetzt plane ich mal damit, dass ich eben zu diesem Zeitpunkt da und da verkaufen würde und eben neu. +Investieren tu ich da und da Objekte. +In Punkt 5 spricht von Immobilien Zinskosten. +Das geht mich auch von der generischen Ausgabe an. +Also ich würde nicht es ihm wirklich einen Ausgabemanager machen. Also ich will nicht, dass man anfangen. +Also damit das Tool wirklich nicht brauchen. Durch meine Ausgaben als Austracken oder wie meine Ausgaben sonst sagen ja quasi kategorisieren und sagen was ich und eben hypothekarzinsen was sind Alltag, essen, kleiderkosten quasi oder Auto oder wo auch immer. +Aber in diesem Tool, in dem FPT Tool will ich wirklich einfach nur generische Gesamtausgaben pro Ansage, also die Fliesen dort rein. +Der Punkt 6 dort bin ich klar der Meinung beides. Also ich möchte zu einer quasi den nominalen Wert sehen. +Also. +Und ja, wirklich, der effektive Wert vom Gesamtvermögen ganz am Ende gesehen. +Vielleicht auch dann auch bei der. +Ja. +Vielleicht immer am Ende einer Lebensphase, dass man immer am Ende einer Lebensphase immer sieht, was ist jetzt dein? +Wirklich nominale Vermögen? +Und was für Kaufkraft hat es quasi mit Best, also mit dem also mit einer inflationsbereinigt, oder wie, wie sieht es aus inflationsbereinigt? +Das war über beide Werte zu sehen. Im Moment einer Lebensphase. +Immer am Ende von einer Bilanzkrise. +Übrigens, jetzt fällt mir gerade noch der Punkt ein. Du hast ja gesagt, dass du vielleicht auch unübersichtlich bist mit, weil wir zu viele Lebensphasen haben. +Aufbauen. +Dort würde ich erwarten, dass wir ein und ausklappen kann oder dass wir, wie kann sagen. +Ja, halt. Wie es zu weit zurückgängig ist in den modernen Webapplikationen, dass man das auch wissen kann, den Reiter beschließen kann. +Ist alles sehr kompakt zusammen. Dort sieht man dann vielleicht einfach nur so Kernkindzahlen pro Phase vielleicht mal wieder eine Anzahl, also wie lange ist die Phase eben? Das ist der nominale Endwert fährst du wirklich ein Start oder ein Startwert, hat man jetzt eine vorherige und Lebensphase. +Sehen wir etwas und wie Aufklappen und dann sieht man ja gerade Details. +Bei Punkt 7 geht es um Kinder. +Erfasst, aber nicht genutzt. +Da, da habe ich mir, glaube ich, selber ein bisschen widersprochen oder selber ein bisschen falsch. +Denkt bitte ignorieren. Also die Kinder sollen nicht erfasst werden, sind aus meiner Sicht absolut irrelevant für das ganze FPT. +Letzten Endes, wenn ich 10 Kinder habe und dann einfach mehr kinderzulagen bekomme, ja dann, dann habe ich einfach ein höheres geschätztes Einkommen. +Für gewisse Lebensphasen. +Aber die Kinder sollen nicht erfasst werden. Für Leute bringt nichts. +Punkt 8 ist ein sehr ein guter Punkt. +Das ist natürlich schon relevant. +Aber. +Dort würde ich vielleicht einfach. +Vielleicht muss man, wenn die Lebensphase. +Oder Pensionierung quasi startet. +Die muss vielleicht ein bisschen anders aussehen und dann muss man vielleicht einfach ein bisschen sagen, hey, was willst du zu diesem Zeitpunkt, dein Plan und gesagt. +Meist ist erwartet deine erwartete Rente. +Zusammengesetzt aus AHV plus PK. +Und was ist dein erwarteter Kapitalbezug? +Und der sollte sich Zusammenhalt aus 3 A plus PK. +Ganz simpel, aber ich finde es gut, wenn man es namentlich erwähnt hat, dann würde ich sagen, Hey. +Bei der bei der Lebensphase der Pensionierung steht. +Erwartete Rente und dann vielleicht mit so einem Info Bubble oder so etwas angibt, AHV plus Bezüge oder Rentenbezüge als PK. +Und dann? +Gleichzeitig kann man sagen, was sind erwartete Rentenbeziehungen oder Kapitalbezüge. +An denen wir da eine Info Bubble quasi 3 a. +Plus Kapitalbeziehung aus der PK. +Bei der Bezeugung von Kapital. +Vielleicht auch noch ein kleiner Steuerrechner. +Einziehen in mit ihnen schreiben. +Macht ja Sinn, weil der Kapitalbezugsstörteil. +Also er hat ja auch ja. +Ja, da macht schon Sinn. Also wenn man eingibt, dass man wirklich an den Bruttowert eingeben. Also ich gehe davon aus, dass ich habe 1000000 PK Gelder beziehe. +Und dann wird aber automatisch quasi Kapital bezugsteuert abgezogen und ich sehe dann wirklich, was dann eigentlich netto. +Mir zur Verfügung steht. +Das Gleiche geht da gerade ein bisschen in Punkt 9 rein, mit all den Steuern. +Da muss man halt ein bisschen. +Durch die Einkommenssteuer und Vermögenssteuer wäre. +Ausklammern. +Und wenn dann irgendwas irgendwo als Kommentar oder als als infofabel AG bei der bei der Ausgabe oder dass man wirklich sagt, bei den Ausgaben, bei der geschätzten Ausgaben von einer Lebensphase, es ist inklusive allem. +Also die geplanten Ausgaben beinhaltet wirklich die jährlich geplanten Ausgaben werden dann Lebensabschnitt. +Die Bestehen aus Alltagskosten als Ferien, aus Hypothekarzinsen, aus Einkommenssteuer, aus Vermögenssteuer und und und. +Die grösseren Steuern, die auf grössere Kapital sich quasi beziehen, wie zum Beispiel. +Stück Gewinnsteuer. +Dann müsste man vielleicht nie sagen. +Wie man sich am Ende einer Lebensphase entscheidet, um die Immobilien zu verkaufen. +Dass wir die dort auch. +Das muss Aurorschön der erwartete Verkaufspreis wird quasi und auf Basis dessen wird dann einfach die Grundstücke gewinnsteuer berechnet. +Das wäre cool. +Frage 10. +Ist extrem spannend. +Da weiß ich ehrlich gesagt nicht. Aber da. +Da absolut recht, oder? Also ich werde einmal den Plan machen und dann würde ich ein bisschen Szenarien durchspielen. +Vielleicht kann man nicht mehr sagen, vielleicht wird es ein. So ein Plan ist vielleicht wieso ein Tab. +Und eben besteht aus mehreren. Also es ist wieso ein Nein wie? +Was haben wir? +Es nicht wäre der richtige Fachbegriff herrschte aber, wie man Obendran so ein so ein Register quasi hat für einen Plan. +Und innerhalb dieses Plans hat es daneben mit den mit den ausklappbaren Lebensphasen sieht man alle Lebensphasen aufgelistet und alle die Berechnungen und so weiter. +Und ich habe mir wirklich. +Ein zweites Szenario anziehen und wieso eine Kopie macht oder so. +Und dann gibt es ein zweites Register. +Und dort kann man dann einzelne Zahlen einfach ein bisschen anpassen. +Und ich muss mich da auf ja smarte Art und Weise ein bisschen vergleichen. +Die 11. +Da muss ich sehr gerne mit dürfen die mir ein bisschen einen Vorschlag? +Höre also als 10 und 11 habe ich gerne Vorschläge, wäre das etwas best practice? +Und habe einfach sinnvoll wäre. +Ist ihnen doch klar, irgendwie durch. Würde dann irgendwie hinten raus schon geben, wo ein paar coole Grafiken haben und sauber Übersicht haben um zu sehen. +War ich, wenn ich zu einem gewissen Zeitpunkt die Ausgabe ertönte, meine Frage meiner riskantere anlege? +Was ist, wenn ich ein Haus verkaufe, um dem und dem Zeitpunkt und und und? +Es wäre schon schön, wenn man da etwas kann, auch visuell rausnehmen. +Export. +Also wirklich so ein. +Tabelle Export also braucht es nicht. +Was ginge da? Was mir das auf der einen aus der Art Dashboard. +Visualisierung. Grafiken. +Genau. \ No newline at end of file diff --git a/AICDS FPT Memo 2026070703 b/AICDS FPT Memo 2026070703 new file mode 100644 index 0000000..008abda --- /dev/null +++ b/AICDS FPT Memo 2026070703 @@ -0,0 +1,70 @@ +Sprecher 1 00:00:01 +Das ist meine dritte Memo zum Thema F. +Pt Financial planing Tool. +Ich gehe da direkt wieder auf die Unschärfe ein, die Claude identifiziert hat. Während dem Review von den ersten 2 Memos und am Anfang vom 2. November. +Das eine geht zu viel auf die Steuerlogik und zwar, dass wir. +Einkommens und Vermögenssteuer in der generischen Ausgaben Einrechnet Grundstück, Gewinnsteuer und auch Kapitalbezugssteuer und so weiter. +Aber nicht. +Wenn ich mache, dann würde ich bei den. +Würde ich bei der bei den Ausgaben würde ich wirklich so ein Info. Babel machen und sagen, dass wir dort eben gewünscht Vermögenssteuer und Kapitalsteuer einfach. +Die jährlichen Steuern und gesagt, dass man die solle einfach dort drin berücksichtigen. +Dann würde bei den. +Einem Verkauf eines Objekts, wenn sie sich entscheiden, ob das Haus zu verkaufen oder entscheidet zum. +Zu einem Wertschätzung zu verkaufen oder auch, wenn man sich entscheidet um. +PK Gelder beziehen. +Das sind aus meiner Sicht die 3 Punkte, die es im Leben gibt, die wirklich auf einen Schlag sehr viel Geld bekommt und die halt eben Steuern anfallen. +Dass man den. +Wieso repauschal Prozent steuern kann quasi angeben, dass man, wie kann sagen Hey. +Ja, ich rechne, dass ich im Moment quasi den und den Betrag quasi wie bekomme. +Aber ich ziehe halt eben 10% ab, weil da meine Annahme ist, dass so hoch die Steuern sind. +Und weil ich gar keine Ahnung habe von diesen Steuern, würde ich einfach bitten, dass Sie sich die mal ein bisschen. +Grob analysiert also. +Ist mir klar, dass da von Kanton zu Kanton unterschiedlich ist und dass wir uns genau berechnen wollen, dass wir dann Dienst in Parameter eingeben müssen. Aber mir geht es ja da nur um eine. +Ja, eine grobe Abschätzung mit grob abschätzen, was ist denn so ein Grundstück Grundstück günstiger, ungefähr prozentual. +Spitalbesuch in Syrien ungefähr prozentual also bei der Bezirk von Pkglla. +Und weißt du auch da der Kapitalbezugsteuer bei. +Beim Auszahlen von von von Aktien. +Dass man einfach da mal so ein bisschen. +Coop High Level. +Recherchieren. +Und dann kannst du vielleicht auch wieder einen Info Bubble vorsehen. Im im Tool drin. +Wo denn, wie könnte bei denen, also bei diesem Datenfeld, wo ich dann mit ihm geschätzt. +Zinszahlungen, dass man dort mehr, also mit Mausoper oder so, quasi wie eben so Info Bubble aufpoppt oder so und dort vielleicht gesehen, dass da typischerweise, also dass eben das vom Kanton zu Kanton, und das ist auch so und dass es typischerweise irgendwo im Bereich zwischen +Sagen sie, irgendetwas zwischen 520% liegt. +Und ich habe den. +Benutze mich selber entscheiden was in wie viel Prozent dazu eingeben was steuern. +Beziehungsweise kann ich auch dann selber auf eigene Faust anfangen, recherchieren und sie einen genauen Wert sonst selber irgendwie ausrechnen. +Aber es muss kein Steuer rechnen. In der ersten Phase in das Tool reinkommen. Es ist wirklich rein einfach so. +Wie würde es berücksichtigt werden? Aber sie sollen auf manueller Ebene definiert werden können. +Der zweite Punkt wegen dem Phasenübergang als eigener UA Schritt. Also ich muss dann den Felsschritt bin ich voll bei dir, also absolut das muss das ist wirklich ein relativ ein zentraler Punkt, wirklich schauen, dass da wirklich ein user Interface super irgendwie. +Dargestellt wird und schwer verwaltet werden. +Das dritte noch die Sparratenverteilung das zu müssen. +Bin ich auch froh bei dir mal so sein unbedingt zu validieren, quasi im Hintergrund. +Ich habe nicht. +Über 5 verschiedene Wertschriften. +1000 Franken verteilen, wenn ich eine Sparquote von nur 3000 Gramm pro Jahr. +Deine Vorschläge bezüglich Punkt 10 und Punkt 11 gefallen mir extrem gut. Das bitte 1 zu 1 sowohl im Konzept berücksichtigen. +Einen letzten Punkt habe ich noch. +Wo wir uns gerade noch in synchronisieren. +Und zwar. +Will ich, dass wir. +Vielleicht auch zuoberst oben, ganz am Anfang, dort, wo Ma seriös aktuelle Alter von sich angibt und so, dass man dort vielleicht auch am AG noch was. +Ist. +Sage jetzt mal der geplante Hinterlass. +Ich weiss nicht, was das richtige Wort dafür ist, aber einfach was ich nenne das mal seinen Kindern vererben. +Also. +Das man wie vielleicht auch sieht, irgendwie. +Also tönt jetzt ein bisschen makaber, aber ich meine, die allerletzte Lebensabschnittsphase geht auch dann bis zum Tod. +Und dort fände ich es manchmal cool, wenn man sagen könnte, hey, bin ich dann mal geblieben. +Dann würde ich gerne halt eben, dass der und der. +Die haben die Geldmenge quasi bei. +Zurückbleibt und vererbt werden. +Und dann die. +Da quasi gegenübergestellt wird von dem, was ich eben zuoberst oben mal initiale. +Nein, weisst du was? Nein, sorry, vergisst den Punkt also oben, also in die Zahl muss man das nicht angeben, aber. +Am Ende der letzten Phase dann wollen wir eigentlich schauen. Stirbt tot ist. +Dann soll er ihn dort bitte noch rausgegeben werden. Was ist der? +Der Endbetrag quasi, der wie übrig bleibt zum Zeitpunkt vom Tod. +Und dann kann man sich selber daraus ableiten. +Wieviel man quasi jetzt hätte, um zu vererben. +Genau. \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1410c3a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,42 @@ +FROM node:22-alpine AS base + +FROM base AS deps +RUN apk add --no-cache libc6-compat openssl +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm install + +FROM base AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +RUN npx prisma generate +ENV NEXT_TELEMETRY_DISABLED=1 +RUN npm run build + +FROM base AS runner +WORKDIR /app +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 +RUN apk add --no-cache openssl +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +COPY --from=builder /app/public ./public +COPY --from=builder /app/prisma ./prisma +COPY --from=builder /app/package.json ./package.json +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static +COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma +COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma +COPY --from=builder /app/node_modules/prisma ./node_modules/prisma + +COPY docker-entrypoint.sh ./ +RUN chmod +x docker-entrypoint.sh + +EXPOSE 3000 +ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" + +ENTRYPOINT ["./docker-entrypoint.sh"] +CMD ["node", "server.js"] diff --git a/FDD_TDD_FPT.docx b/FDD_TDD_FPT.docx new file mode 100644 index 0000000..f8c05f2 Binary files /dev/null and b/FDD_TDD_FPT.docx differ diff --git a/README.md b/README.md new file mode 100644 index 0000000..dc0cd95 --- /dev/null +++ b/README.md @@ -0,0 +1,61 @@ +# Financial Planning Tool (FPT) + +Webapplikation zur langfristigen Finanzplanung mit Lebensphasen, Wertschriften, Immobilien und Szenarien. + +## Features + +- **Haushaltsplanung** – Einzelperson oder Paar +- **Lebensphasen** – Einkommen, Ausgaben, Wertschriften, Immobilien pro Phase +- **Phasenübergänge** – Wertschriften übernehmen/auszahlen, Immobilien verkaufen +- **Szenarien** – Varianten eines Plans kopieren und vergleichen +- **Dashboard** – Vermögensverlauf (nominal & inflationsbereinigt) +- **Steuern** – Pauschale Abschätzung für Kapitalbezug und Immobilienverkauf + +## Tech Stack + +- Next.js 15 (App Router) + TypeScript +- PostgreSQL + Prisma +- Tailwind CSS + Recharts +- Docker + Traefik + +## Lokale Entwicklung + +```bash +# Mit Docker (empfohlen) +docker compose -f docker-compose.dev.yml up --build + +# App: http://localhost:3000 +``` + +## Produktion (fpt.aicds.ch) + +```bash +# .env anlegen +cp .env.example .env +# POSTGRES_PASSWORD setzen + +# Traefik-Netzwerk muss existieren +docker network create traefik 2>/dev/null || true + +docker compose up -d --build +``` + +Traefik-Labels sind in `docker-compose.yml` vorkonfiguriert für `fpt.aicds.ch`. + +## Umgebungsvariablen + +| Variable | Beschreibung | +|----------|-------------| +| `DATABASE_URL` | PostgreSQL-Verbindungsstring | +| `POSTGRES_PASSWORD` | DB-Passwort (docker-compose) | + +## Projektstruktur + +``` +src/ +├── app/ # Next.js Pages & API Routes +├── components/ # UI-Komponenten +└── lib/ # DB, Berechnungen, Services +prisma/ +└── schema.prisma # Datenmodell +``` diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..e46a38c --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,41 @@ +services: + app: + build: . + ports: + - "3000:3000" + environment: + DATABASE_URL: postgresql://fpt:${POSTGRES_PASSWORD:-fpt_secret}@db:5432/fpt?schema=public + NODE_ENV: development + depends_on: + db: + condition: service_healthy + volumes: + - .:/app + - /app/node_modules + - /app/.next + networks: + - internal + + db: + image: postgres:16-alpine + ports: + - "5432:5432" + environment: + POSTGRES_DB: fpt + POSTGRES_USER: fpt + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-fpt_secret} + volumes: + - fpt_pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U fpt -d fpt"] + interval: 5s + timeout: 5s + retries: 10 + networks: + - internal + +volumes: + fpt_pgdata: + +networks: + internal: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..88c939d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,44 @@ +services: + app: + build: . + restart: unless-stopped + environment: + DATABASE_URL: postgresql://fpt:${POSTGRES_PASSWORD:-fpt_secret}@db:5432/fpt?schema=public + NODE_ENV: production + depends_on: + db: + condition: service_healthy + networks: + - traefik + - internal + labels: + - "traefik.enable=true" + - "traefik.http.routers.fpt.rule=Host(`fpt.aicds.ch`)" + - "traefik.http.routers.fpt.entrypoints=websecure" + - "traefik.http.routers.fpt.tls.certresolver=letsencrypt" + - "traefik.http.services.fpt.loadbalancer.server.port=3000" + + db: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_DB: fpt + POSTGRES_USER: fpt + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-fpt_secret} + volumes: + - fpt_pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U fpt -d fpt"] + interval: 5s + timeout: 5s + retries: 10 + networks: + - internal + +volumes: + fpt_pgdata: + +networks: + traefik: + external: true + internal: diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 0000000..7be76a3 --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,23 @@ +#!/bin/sh +set -e + +echo "Waiting for database..." +until node -e " +const { PrismaClient } = require('@prisma/client'); +const prisma = new PrismaClient(); +prisma.\$connect() + .then(() => { prisma.\$disconnect(); process.exit(0); }) + .catch(() => process.exit(1)); +" 2>/dev/null; do + sleep 2 +done + +echo "Running database migrations..." +npx prisma db push --skip-generate + +echo "Starting application..." +if [ "$(id -u)" = "0" ]; then + exec su -s /bin/sh nextjs -c 'exec "$@"' sh "$@" +else + exec "$@" +fi diff --git a/next.config.ts b/next.config.ts new file mode 100644 index 0000000..68a6c64 --- /dev/null +++ b/next.config.ts @@ -0,0 +1,7 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + output: "standalone", +}; + +export default nextConfig; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..c50c3fa --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "fpt", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..6058304 --- /dev/null +++ b/package.json @@ -0,0 +1,35 @@ +{ + "name": "fpt", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "prisma generate && next build", + "start": "next start", + "lint": "next lint", + "db:generate": "prisma generate", + "db:migrate": "prisma migrate deploy", + "db:push": "prisma db push", + "postinstall": "prisma generate" + }, + "dependencies": { + "@prisma/client": "^6.9.0", + "next": "^15.3.3", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "recharts": "^2.15.3", + "zod": "^3.25.51" + }, + "devDependencies": { + "@types/node": "^22.15.21", + "@types/react": "^19.1.6", + "@types/react-dom": "^19.1.5", + "autoprefixer": "^10.4.21", + "eslint": "^9.27.0", + "eslint-config-next": "^15.3.3", + "postcss": "^8.5.4", + "prisma": "^6.9.0", + "tailwindcss": "^3.4.17", + "typescript": "^5.8.3" + } +} diff --git a/postcss.config.mjs b/postcss.config.mjs new file mode 100644 index 0000000..2ef30fc --- /dev/null +++ b/postcss.config.mjs @@ -0,0 +1,9 @@ +/** @type {import('postcss-load-config').Config} */ +const config = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; + +export default config; diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..e0ec058 --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,146 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +enum HouseholdType { + SINGLE + COUPLE +} + +enum IncomeMode { + PER_PERSON + HOUSEHOLD +} + +enum OneTimeEventType { + INCOME + EXPENSE +} + +enum TransitionAction { + CARRY_OVER + PAYOUT + SELL +} + +model Plan { + id String @id @default(cuid()) + name String + householdType HouseholdType @default(SINGLE) + person1Name String @default("Person 1") + person1CurrentAge Int + person2Name String? + person2CurrentAge Int? + plannedRetirementAge Int @default(65) + defaultInflationRate Float @default(2.0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + scenarios Scenario[] +} + +model Scenario { + id String @id @default(cuid()) + planId String + name String + isBaseline Boolean @default(false) + sortOrder Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + plan Plan @relation(fields: [planId], references: [id], onDelete: Cascade) + phases LifePhase[] + + @@index([planId]) +} + +model LifePhase { + id String @id @default(cuid()) + scenarioId String + name String + sortOrder Int + durationYears Int + isRetirementPhase Boolean @default(false) + incomeMode IncomeMode @default(PER_PERSON) + householdIncome Float? + person1Income Float? + person2Income Float? + householdExpenses Float @default(0) + inflationRate Float? + expectedPensionIncome Float? + expectedPensionCapital Float? + pensionCapitalTaxRate Float? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + scenario Scenario @relation(fields: [scenarioId], references: [id], onDelete: Cascade) + securities Security[] + properties Property[] + oneTimeEvents OneTimeEvent[] + transitionFrom PhaseTransition? @relation("TransitionFrom") + transitionTo PhaseTransition? @relation("TransitionTo") + + @@index([scenarioId]) +} + +model Security { + id String @id @default(cuid()) + lifePhaseId String + name String + ownerTag String? + startBalance Float @default(0) + expectedReturnRate Float @default(5.0) + annualSavingsAllocation Float @default(0) + sortOrder Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + lifePhase LifePhase @relation(fields: [lifePhaseId], references: [id], onDelete: Cascade) + + @@index([lifePhaseId]) +} + +model Property { + id String @id @default(cuid()) + lifePhaseId String + name String + currentValue Float @default(0) + mortgageBalance Float @default(0) + valueGrowthRate Float @default(2.0) + annualAmortization Float @default(0) + saleTaxRatePercent Float? + sortOrder Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + lifePhase LifePhase @relation(fields: [lifePhaseId], references: [id], onDelete: Cascade) + + @@index([lifePhaseId]) +} + +model OneTimeEvent { + id String @id @default(cuid()) + lifePhaseId String + name String + yearOffset Int + amount Float + type OneTimeEventType + taxRatePercent Float? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + lifePhase LifePhase @relation(fields: [lifePhaseId], references: [id], onDelete: Cascade) + + @@index([lifePhaseId]) +} + +model PhaseTransition { + id String @id @default(cuid()) + fromPhaseId String @unique + toPhaseId String @unique + securityActions Json @default("[]") + propertyActions Json @default("[]") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + fromPhase LifePhase @relation("TransitionFrom", fields: [fromPhaseId], references: [id], onDelete: Cascade) + toPhase LifePhase @relation("TransitionTo", fields: [toPhaseId], references: [id], onDelete: Cascade) +} diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..bba7954 --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,5 @@ + + + + F + diff --git a/src/app/api/phases/[phaseId]/route.ts b/src/app/api/phases/[phaseId]/route.ts new file mode 100644 index 0000000..0971c46 --- /dev/null +++ b/src/app/api/phases/[phaseId]/route.ts @@ -0,0 +1,122 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/db"; + +type Params = { params: Promise<{ phaseId: string }> }; + +export async function GET(_request: Request, { params }: Params) { + const { phaseId } = await params; + const phase = await prisma.lifePhase.findUnique({ + where: { id: phaseId }, + include: { + securities: { orderBy: { sortOrder: "asc" } }, + properties: { orderBy: { sortOrder: "asc" } }, + oneTimeEvents: true, + scenario: { include: { plan: true } }, + }, + }); + + if (!phase) { + return NextResponse.json({ error: "Phase nicht gefunden" }, { status: 404 }); + } + + return NextResponse.json(phase); +} + +export async function PUT(request: Request, { params }: Params) { + const { phaseId } = await params; + const body = await request.json(); + + await prisma.security.deleteMany({ where: { lifePhaseId: phaseId } }); + await prisma.property.deleteMany({ where: { lifePhaseId: phaseId } }); + await prisma.oneTimeEvent.deleteMany({ where: { lifePhaseId: phaseId } }); + + const phase = await prisma.lifePhase.update({ + where: { id: phaseId }, + data: { + name: body.name, + durationYears: Number(body.durationYears), + isRetirementPhase: Boolean(body.isRetirementPhase), + incomeMode: body.incomeMode, + householdIncome: body.householdIncome, + person1Income: body.person1Income, + person2Income: body.person2Income, + householdExpenses: Number(body.householdExpenses ?? 0), + inflationRate: body.inflationRate, + expectedPensionIncome: body.expectedPensionIncome, + expectedPensionCapital: body.expectedPensionCapital, + pensionCapitalTaxRate: body.pensionCapitalTaxRate, + securities: { + create: (body.securities ?? []).map( + ( + s: { + name: string; + ownerTag?: string; + startBalance?: number; + expectedReturnRate?: number; + annualSavingsAllocation?: number; + }, + idx: number + ) => ({ + name: s.name, + ownerTag: s.ownerTag || null, + startBalance: Number(s.startBalance ?? 0), + expectedReturnRate: Number(s.expectedReturnRate ?? 5), + annualSavingsAllocation: Number(s.annualSavingsAllocation ?? 0), + sortOrder: idx, + }) + ), + }, + properties: { + create: (body.properties ?? []).map( + ( + p: { + name: string; + currentValue?: number; + mortgageBalance?: number; + valueGrowthRate?: number; + annualAmortization?: number; + }, + idx: number + ) => ({ + name: p.name, + currentValue: Number(p.currentValue ?? 0), + mortgageBalance: Number(p.mortgageBalance ?? 0), + valueGrowthRate: Number(p.valueGrowthRate ?? 2), + annualAmortization: Number(p.annualAmortization ?? 0), + sortOrder: idx, + }) + ), + }, + oneTimeEvents: { + create: (body.oneTimeEvents ?? []).map( + (e: { + name: string; + yearOffset: number; + amount: number; + type: string; + taxRatePercent?: number; + }) => ({ + name: e.name, + yearOffset: Number(e.yearOffset), + amount: Number(e.amount), + type: e.type, + taxRatePercent: e.taxRatePercent ?? null, + }) + ), + }, + }, + include: { + securities: true, + properties: true, + oneTimeEvents: true, + }, + }); + + return NextResponse.json(phase); +} + +export async function DELETE(_request: Request, { params }: Params) { + const { phaseId } = await params; + await prisma.lifePhase.delete({ where: { id: phaseId } }); + return NextResponse.json({ ok: true }); +} diff --git a/src/app/api/phases/route.ts b/src/app/api/phases/route.ts new file mode 100644 index 0000000..62887a4 --- /dev/null +++ b/src/app/api/phases/route.ts @@ -0,0 +1,103 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/db"; + +export async function POST(request: Request) { + const body = await request.json(); + + if (!body.scenarioId || !body.name || !body.durationYears) { + return NextResponse.json( + { error: "Szenario, Name und Dauer sind erforderlich" }, + { status: 400 } + ); + } + + const maxOrder = await prisma.lifePhase.aggregate({ + where: { scenarioId: body.scenarioId }, + _max: { sortOrder: true }, + }); + + const phase = await prisma.lifePhase.create({ + data: { + scenarioId: body.scenarioId, + name: body.name, + sortOrder: (maxOrder._max.sortOrder ?? -1) + 1, + durationYears: Number(body.durationYears), + isRetirementPhase: Boolean(body.isRetirementPhase), + incomeMode: body.incomeMode ?? "PER_PERSON", + householdIncome: body.householdIncome, + person1Income: body.person1Income, + person2Income: body.person2Income, + householdExpenses: Number(body.householdExpenses ?? 0), + inflationRate: body.inflationRate, + expectedPensionIncome: body.expectedPensionIncome, + expectedPensionCapital: body.expectedPensionCapital, + pensionCapitalTaxRate: body.pensionCapitalTaxRate, + securities: { + create: (body.securities ?? []).map( + ( + s: { + name: string; + ownerTag?: string; + startBalance?: number; + expectedReturnRate?: number; + annualSavingsAllocation?: number; + }, + idx: number + ) => ({ + name: s.name, + ownerTag: s.ownerTag || null, + startBalance: Number(s.startBalance ?? 0), + expectedReturnRate: Number(s.expectedReturnRate ?? 5), + annualSavingsAllocation: Number(s.annualSavingsAllocation ?? 0), + sortOrder: idx, + }) + ), + }, + properties: { + create: (body.properties ?? []).map( + ( + p: { + name: string; + currentValue?: number; + mortgageBalance?: number; + valueGrowthRate?: number; + annualAmortization?: number; + }, + idx: number + ) => ({ + name: p.name, + currentValue: Number(p.currentValue ?? 0), + mortgageBalance: Number(p.mortgageBalance ?? 0), + valueGrowthRate: Number(p.valueGrowthRate ?? 2), + annualAmortization: Number(p.annualAmortization ?? 0), + sortOrder: idx, + }) + ), + }, + oneTimeEvents: { + create: (body.oneTimeEvents ?? []).map( + (e: { + name: string; + yearOffset: number; + amount: number; + type: string; + taxRatePercent?: number; + }) => ({ + name: e.name, + yearOffset: Number(e.yearOffset), + amount: Number(e.amount), + type: e.type, + taxRatePercent: e.taxRatePercent ?? null, + }) + ), + }, + }, + include: { + securities: true, + properties: true, + oneTimeEvents: true, + }, + }); + + return NextResponse.json(phase, { status: 201 }); +} diff --git a/src/app/api/plans/[planId]/route.ts b/src/app/api/plans/[planId]/route.ts new file mode 100644 index 0000000..14ac268 --- /dev/null +++ b/src/app/api/plans/[planId]/route.ts @@ -0,0 +1,53 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/db"; +import { scenarioInclude } from "@/lib/scenario-service"; + +type Params = { params: Promise<{ planId: string }> }; + +export async function GET(_request: Request, { params }: Params) { + const { planId } = await params; + const plan = await prisma.plan.findUnique({ + where: { id: planId }, + include: { + scenarios: { + orderBy: { sortOrder: "asc" }, + include: scenarioInclude, + }, + }, + }); + + if (!plan) { + return NextResponse.json({ error: "Plan nicht gefunden" }, { status: 404 }); + } + + return NextResponse.json(plan); +} + +export async function PUT(request: Request, { params }: Params) { + const { planId } = await params; + const body = await request.json(); + + const plan = await prisma.plan.update({ + where: { id: planId }, + data: { + name: body.name, + householdType: body.householdType, + person1Name: body.person1Name, + person1CurrentAge: Number(body.person1CurrentAge), + person2Name: body.person2Name ?? null, + person2CurrentAge: body.person2CurrentAge + ? Number(body.person2CurrentAge) + : null, + plannedRetirementAge: Number(body.plannedRetirementAge), + defaultInflationRate: Number(body.defaultInflationRate), + }, + }); + + return NextResponse.json(plan); +} + +export async function DELETE(_request: Request, { params }: Params) { + const { planId } = await params; + await prisma.plan.delete({ where: { id: planId } }); + return NextResponse.json({ ok: true }); +} diff --git a/src/app/api/plans/[planId]/scenarios/route.ts b/src/app/api/plans/[planId]/scenarios/route.ts new file mode 100644 index 0000000..c94afcf --- /dev/null +++ b/src/app/api/plans/[planId]/scenarios/route.ts @@ -0,0 +1,99 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/db"; +import { scenarioInclude } from "@/lib/scenario-service"; + +type Params = { params: Promise<{ planId: string }> }; + +export async function POST(request: Request, { params }: Params) { + const { planId } = await params; + const body = await request.json().catch(() => ({})); + + const plan = await prisma.plan.findUnique({ + where: { id: planId }, + include: { + scenarios: { + orderBy: { sortOrder: "desc" }, + take: 1, + include: scenarioInclude, + }, + }, + }); + + if (!plan) { + return NextResponse.json({ error: "Plan nicht gefunden" }, { status: 404 }); + } + + const source = body.copyFromScenarioId + ? await prisma.scenario.findUnique({ + where: { id: body.copyFromScenarioId }, + include: scenarioInclude, + }) + : plan.scenarios[0]; + + const nextOrder = (plan.scenarios[0]?.sortOrder ?? 0) + 1; + + const scenario = await prisma.scenario.create({ + data: { + planId, + name: body.name ?? `Szenario ${nextOrder + 1}`, + isBaseline: false, + sortOrder: nextOrder, + }, + }); + + if (source && source.phases.length > 0) { + for (const phase of source.phases) { + const newPhase = await prisma.lifePhase.create({ + data: { + scenarioId: scenario.id, + name: phase.name, + sortOrder: phase.sortOrder, + durationYears: phase.durationYears, + isRetirementPhase: phase.isRetirementPhase, + incomeMode: phase.incomeMode, + householdIncome: phase.householdIncome, + person1Income: phase.person1Income, + person2Income: phase.person2Income, + householdExpenses: phase.householdExpenses, + inflationRate: phase.inflationRate, + expectedPensionIncome: phase.expectedPensionIncome, + expectedPensionCapital: phase.expectedPensionCapital, + pensionCapitalTaxRate: phase.pensionCapitalTaxRate, + securities: { + create: phase.securities.map((s) => ({ + name: s.name, + ownerTag: s.ownerTag, + startBalance: s.startBalance, + expectedReturnRate: s.expectedReturnRate, + annualSavingsAllocation: s.annualSavingsAllocation, + sortOrder: s.sortOrder, + })), + }, + properties: { + create: phase.properties.map((p) => ({ + name: p.name, + currentValue: p.currentValue, + mortgageBalance: p.mortgageBalance, + valueGrowthRate: p.valueGrowthRate, + annualAmortization: p.annualAmortization, + saleTaxRatePercent: p.saleTaxRatePercent, + sortOrder: p.sortOrder, + })), + }, + oneTimeEvents: { + create: phase.oneTimeEvents.map((e) => ({ + name: e.name, + yearOffset: e.yearOffset, + amount: e.amount, + type: e.type, + taxRatePercent: e.taxRatePercent, + })), + }, + }, + }); + void newPhase; + } + } + + return NextResponse.json(scenario, { status: 201 }); +} diff --git a/src/app/api/plans/route.ts b/src/app/api/plans/route.ts new file mode 100644 index 0000000..caa9473 --- /dev/null +++ b/src/app/api/plans/route.ts @@ -0,0 +1,51 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/db"; + +export async function GET() { + const plans = await prisma.plan.findMany({ + orderBy: { updatedAt: "desc" }, + include: { + scenarios: { + orderBy: { sortOrder: "asc" }, + take: 1, + }, + _count: { select: { scenarios: true } }, + }, + }); + return NextResponse.json(plans); +} + +export async function POST(request: Request) { + const body = await request.json(); + + if (!body.name || !body.person1CurrentAge) { + return NextResponse.json( + { error: "Name und Alter Person 1 sind erforderlich" }, + { status: 400 } + ); + } + + const plan = await prisma.plan.create({ + data: { + name: body.name, + householdType: body.householdType ?? "SINGLE", + person1Name: body.person1Name ?? "Person 1", + person1CurrentAge: Number(body.person1CurrentAge), + person2Name: body.person2Name ?? null, + person2CurrentAge: body.person2CurrentAge + ? Number(body.person2CurrentAge) + : null, + plannedRetirementAge: Number(body.plannedRetirementAge ?? 65), + defaultInflationRate: Number(body.defaultInflationRate ?? 2), + scenarios: { + create: { + name: "Basisszenario", + isBaseline: true, + sortOrder: 0, + }, + }, + }, + }); + + return NextResponse.json(plan, { status: 201 }); +} diff --git a/src/app/api/scenarios/[scenarioId]/route.ts b/src/app/api/scenarios/[scenarioId]/route.ts new file mode 100644 index 0000000..788e277 --- /dev/null +++ b/src/app/api/scenarios/[scenarioId]/route.ts @@ -0,0 +1,56 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/db"; +import { calculateForScenario, scenarioInclude } from "@/lib/scenario-service"; + +type Params = { params: Promise<{ scenarioId: string }> }; + +export async function GET(_request: Request, { params }: Params) { + const { scenarioId } = await params; + + const scenario = await prisma.scenario.findUnique({ + where: { id: scenarioId }, + include: { + plan: true, + ...scenarioInclude, + }, + }); + + if (!scenario) { + return NextResponse.json( + { error: "Szenario nicht gefunden" }, + { status: 404 } + ); + } + + const result = calculateForScenario(scenario.plan, scenario); + + return NextResponse.json({ + scenario, + calculation: result, + }); +} + +export async function DELETE(_request: Request, { params }: Params) { + const { scenarioId } = await params; + + const scenario = await prisma.scenario.findUnique({ + where: { id: scenarioId }, + }); + + if (!scenario) { + return NextResponse.json( + { error: "Szenario nicht gefunden" }, + { status: 404 } + ); + } + + if (scenario.isBaseline) { + return NextResponse.json( + { error: "Basisszenario kann nicht gelöscht werden" }, + { status: 400 } + ); + } + + await prisma.scenario.delete({ where: { id: scenarioId } }); + return NextResponse.json({ ok: true }); +} diff --git a/src/app/api/transitions/route.ts b/src/app/api/transitions/route.ts new file mode 100644 index 0000000..2049bdc --- /dev/null +++ b/src/app/api/transitions/route.ts @@ -0,0 +1,29 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/db"; + +export async function POST(request: Request) { + const body = await request.json(); + + if (!body.fromPhaseId || !body.toPhaseId) { + return NextResponse.json( + { error: "fromPhaseId und toPhaseId sind erforderlich" }, + { status: 400 } + ); + } + + const transition = await prisma.phaseTransition.upsert({ + where: { fromPhaseId: body.fromPhaseId }, + create: { + fromPhaseId: body.fromPhaseId, + toPhaseId: body.toPhaseId, + securityActions: body.securityActions ?? [], + propertyActions: body.propertyActions ?? [], + }, + update: { + securityActions: body.securityActions ?? [], + propertyActions: body.propertyActions ?? [], + }, + }); + + return NextResponse.json(transition); +} diff --git a/src/app/globals.css b/src/app/globals.css new file mode 100644 index 0000000..56552c3 --- /dev/null +++ b/src/app/globals.css @@ -0,0 +1,64 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + --background: #f8fafc; + --foreground: #0f172a; + --card: #ffffff; + --border: #e2e8f0; + --muted: #64748b; + --accent: #2563eb; +} + +body { + color: var(--foreground); + background: var(--background); + font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +input, +select, +textarea { + @apply rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm outline-none transition focus:border-brand-500 focus:ring-2 focus:ring-brand-100; +} + +label { + @apply text-sm font-medium text-slate-700; +} + +.card { + @apply rounded-xl border border-slate-200 bg-white p-5 shadow-sm; +} + +.btn { + @apply inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium transition; +} + +.btn-primary { + @apply btn bg-brand-600 text-white hover:bg-brand-700; +} + +.btn-secondary { + @apply btn border border-slate-300 bg-white text-slate-700 hover:bg-slate-50; +} + +.btn-danger { + @apply btn bg-red-600 text-white hover:bg-red-700; +} + +.badge { + @apply inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium; +} + +.badge-success { + @apply badge bg-emerald-100 text-emerald-800; +} + +.badge-warning { + @apply badge bg-amber-100 text-amber-800; +} + +.badge-info { + @apply badge bg-blue-100 text-blue-800; +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx new file mode 100644 index 0000000..acc3727 --- /dev/null +++ b/src/app/layout.tsx @@ -0,0 +1,37 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import "./globals.css"; + +export const metadata: Metadata = { + title: "FPT – Financial Planning Tool", + description: "Finanzplanung mit Lebensphasen und Szenarien", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + +
+
+ + FPT + + Financial Planning Tool + + + +
+
+
{children}
+ + + ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx new file mode 100644 index 0000000..6b76397 --- /dev/null +++ b/src/app/page.tsx @@ -0,0 +1,51 @@ +import Link from "next/link"; + +export default function HomePage() { + return ( +
+
+

+ Finanzplanung für die Zukunft +

+

+ Planen Sie Ihre finanzielle Zukunft mit Lebensphasen, Wertschriften, + Immobilien und Szenarien. Alle Daten werden manuell erfasst – ideal + zum Durchspielen verschiedener Annahmen. +

+
+ + Pläne verwalten + + + Neuen Plan erstellen + +
+
+ +
+
+

Lebensphasen

+

+ Definieren Sie Phasen mit Einkommen, Ausgaben, Wertschriften und + Immobilien. Am Ende jeder Phase sehen Sie nominalen und realen + Endwert. +

+
+
+

Phasenübergänge

+

+ Übernehmen oder bezahlen Sie Wertschriften aus und verkaufen Sie + Immobilien mit pauschaler Steuerabschätzung. +

+
+
+

Szenarien

+

+ Erstellen Sie Varianten eines Plans und vergleichen Sie + Vermögensverläufe im Dashboard. +

+
+
+
+ ); +} diff --git a/src/app/plans/[planId]/page.tsx b/src/app/plans/[planId]/page.tsx new file mode 100644 index 0000000..21c65d1 --- /dev/null +++ b/src/app/plans/[planId]/page.tsx @@ -0,0 +1,72 @@ +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { PlanForm } from "@/components/PlanForm"; +import { ScenarioActions } from "@/components/ScenarioActions"; +import { prisma } from "@/lib/db"; + +export const dynamic = "force-dynamic"; + +type Props = { params: Promise<{ planId: string }> }; + +export default async function PlanDetailPage({ params }: Props) { + const { planId } = await params; + const plan = await prisma.plan.findUnique({ + where: { id: planId }, + include: { + scenarios: { orderBy: { sortOrder: "asc" } }, + }, + }); + + if (!plan) notFound(); + + return ( +
+
+
+ + ← Alle Pläne + +

{plan.name}

+
+
+ +
+

Szenarien

+
+ {plan.scenarios.map((scenario) => ( + + {scenario.name} + {scenario.isBaseline && " (Basis)"} + + ))} +
+ +
+ +
+

Plan bearbeiten

+ +
+
+ ); +} diff --git a/src/app/plans/[planId]/scenarios/[scenarioId]/page.tsx b/src/app/plans/[planId]/scenarios/[scenarioId]/page.tsx new file mode 100644 index 0000000..e8fbf38 --- /dev/null +++ b/src/app/plans/[planId]/scenarios/[scenarioId]/page.tsx @@ -0,0 +1,139 @@ +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { PhaseSummaryCard } from "@/components/PhaseSummaryCard"; +import { TransitionForm } from "@/components/TransitionForm"; +import { WealthChart } from "@/components/WealthChart"; +import { formatChf } from "@/lib/calculations"; +import { prisma } from "@/lib/db"; +import { calculateForScenario, scenarioInclude } from "@/lib/scenario-service"; + +export const dynamic = "force-dynamic"; + +type Props = { + params: Promise<{ planId: string; scenarioId: string }>; +}; + +export default async function ScenarioPage({ params }: Props) { + const { planId, scenarioId } = await params; + + const scenario = await prisma.scenario.findUnique({ + where: { id: scenarioId }, + include: { + plan: true, + ...scenarioInclude, + }, + }); + + if (!scenario || scenario.planId !== planId) notFound(); + + const calculation = calculateForScenario(scenario.plan, scenario); + const phases = scenario.phases; + + return ( +
+
+ + ← {scenario.plan.name} + +

+ {scenario.name} +

+

+ {phases.length} Lebensphase{phases.length !== 1 ? "n" : ""} +

+
+ +
+

Vermögensverlauf

+ +
+ + {calculation.finalInheritanceNominal > 0 && ( +
+

+ Verbleibendes Vermögen (Ende letzte Phase) +

+

+ {formatChf(calculation.finalInheritanceNominal)}{" "} + + (real: {formatChf(calculation.finalInheritanceReal)}) + +

+
+ )} + +
+
+

Lebensphasen

+ + + Lebensphase + +
+ + {calculation.phases.map((result, idx) => ( +
+
+ +
+ + Bearbeiten + +
+
+ + {idx < phases.length - 1 && ( + ({ + id: s.id, + name: s.name, + }))} + properties={phases[idx].properties.map((p) => ({ + id: p.id, + name: p.name, + }))} + initial={ + phases[idx].transitionFrom + ? { + securityActions: phases[idx].transitionFrom + .securityActions as Array<{ + securityId: string; + action: "carry_over" | "payout"; + payoutAmount?: number; + }>, + propertyActions: phases[idx].transitionFrom + .propertyActions as Array<{ + propertyId: string; + action: "carry_over" | "sell"; + saleProceeds?: number; + saleTaxRatePercent?: number; + }>, + } + : undefined + } + /> + )} +
+ ))} + + {phases.length === 0 && ( +
+ Noch keine Lebensphasen. Erstellen Sie die erste Phase. +
+ )} +
+
+ ); +} diff --git a/src/app/plans/[planId]/scenarios/[scenarioId]/phases/[phaseId]/edit/page.tsx b/src/app/plans/[planId]/scenarios/[scenarioId]/phases/[phaseId]/edit/page.tsx new file mode 100644 index 0000000..8293226 --- /dev/null +++ b/src/app/plans/[planId]/scenarios/[scenarioId]/phases/[phaseId]/edit/page.tsx @@ -0,0 +1,80 @@ +import { notFound } from "next/navigation"; +import { LifePhaseForm } from "@/components/LifePhaseForm"; +import { prisma } from "@/lib/db"; + +export const dynamic = "force-dynamic"; + +type Props = { + params: Promise<{ + planId: string; + scenarioId: string; + phaseId: string; + }>; +}; + +export default async function EditPhasePage({ params }: Props) { + const { planId, scenarioId, phaseId } = await params; + + const phase = await prisma.lifePhase.findUnique({ + where: { id: phaseId }, + include: { + securities: { orderBy: { sortOrder: "asc" } }, + properties: { orderBy: { sortOrder: "asc" } }, + oneTimeEvents: true, + scenario: { include: { plan: true } }, + }, + }); + + if (!phase || phase.scenarioId !== scenarioId || phase.scenario.planId !== planId) { + notFound(); + } + + return ( +
+

+ Lebensphase bearbeiten – {phase.name} +

+ ({ + name: s.name, + ownerTag: s.ownerTag ?? "", + startBalance: s.startBalance, + expectedReturnRate: s.expectedReturnRate, + annualSavingsAllocation: s.annualSavingsAllocation, + })), + properties: phase.properties.map((p) => ({ + name: p.name, + currentValue: p.currentValue, + mortgageBalance: p.mortgageBalance, + valueGrowthRate: p.valueGrowthRate, + annualAmortization: p.annualAmortization, + })), + oneTimeEvents: phase.oneTimeEvents.map((e) => ({ + name: e.name, + yearOffset: e.yearOffset, + amount: e.amount, + type: e.type, + taxRatePercent: e.taxRatePercent ?? 0, + })), + }} + /> +
+ ); +} diff --git a/src/app/plans/[planId]/scenarios/[scenarioId]/phases/new/page.tsx b/src/app/plans/[planId]/scenarios/[scenarioId]/phases/new/page.tsx new file mode 100644 index 0000000..eca9a48 --- /dev/null +++ b/src/app/plans/[planId]/scenarios/[scenarioId]/phases/new/page.tsx @@ -0,0 +1,33 @@ +import { notFound } from "next/navigation"; +import { LifePhaseForm } from "@/components/LifePhaseForm"; +import { prisma } from "@/lib/db"; + +export const dynamic = "force-dynamic"; + +type Props = { + params: Promise<{ planId: string; scenarioId: string }>; +}; + +export default async function NewPhasePage({ params }: Props) { + const { planId, scenarioId } = await params; + + const scenario = await prisma.scenario.findUnique({ + where: { id: scenarioId }, + include: { plan: true }, + }); + + if (!scenario || scenario.planId !== planId) notFound(); + + return ( +
+

+ Neue Lebensphase – {scenario.name} +

+ +
+ ); +} diff --git a/src/app/plans/new/page.tsx b/src/app/plans/new/page.tsx new file mode 100644 index 0000000..fdae5df --- /dev/null +++ b/src/app/plans/new/page.tsx @@ -0,0 +1,10 @@ +import { PlanForm } from "@/components/PlanForm"; + +export default function NewPlanPage() { + return ( +
+

Neuen Plan erstellen

+ +
+ ); +} diff --git a/src/app/plans/page.tsx b/src/app/plans/page.tsx new file mode 100644 index 0000000..4538d65 --- /dev/null +++ b/src/app/plans/page.tsx @@ -0,0 +1,62 @@ +import Link from "next/link"; +import { prisma } from "@/lib/db"; + +export const dynamic = "force-dynamic"; + +export default async function PlansPage() { + const plans = await prisma.plan.findMany({ + orderBy: { updatedAt: "desc" }, + include: { _count: { select: { scenarios: true } } }, + }); + + return ( +
+
+

Finanzpläne

+ + Neuer Plan + +
+ + {plans.length === 0 ? ( +
+

Noch keine Pläne vorhanden.

+ + Ersten Plan erstellen + +
+ ) : ( +
+ {plans.map((plan) => ( + +
+
+

+ {plan.name} +

+

+ {plan.householdType === "COUPLE" ? "Paar" : "Einzelperson"} ·{" "} + {plan.person1Name} + {plan.person2Name ? ` & ${plan.person2Name}` : ""} · Alter{" "} + {plan.person1CurrentAge} + {plan.person2CurrentAge + ? ` / ${plan.person2CurrentAge}` + : ""} +

+
+ + {plan._count.scenarios} Szenario + {plan._count.scenarios !== 1 ? "s" : ""} + +
+ + ))} +
+ )} +
+ ); +} diff --git a/src/components/InfoBubble.tsx b/src/components/InfoBubble.tsx new file mode 100644 index 0000000..c99a909 --- /dev/null +++ b/src/components/InfoBubble.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { useState } from "react"; + +type InfoBubbleProps = { + title: string; + children: React.ReactNode; +}; + +export function InfoBubble({ title, children }: InfoBubbleProps) { + const [open, setOpen] = useState(false); + + return ( + + + {open && ( +
+

{title}

+ {children} +
+ )} +
+ ); +} + +export const TAX_INFO = { + expenses: + "Geschätzte Ausgaben beinhalten alle jährlichen Kosten inkl. Alltag, Hypothekarzinsen, Einkommens- und Vermögenssteuer. Keine detaillierte Steuerberechnung.", + capitalWithdrawal: + "Kapitalbezugssteuer variiert kantonal stark. Typischerweise ca. 5–20% auf PK/3a-Kapitalbezüge. Geben Sie einen pauschalen Prozentsatz ein.", + propertySale: + "Grundstückgewinnsteuer variiert kantonal. Typischerweise ca. 5–30% auf den Gewinn. Geben Sie einen geschätzten Prozentsatz auf den Verkaufserlös ein.", +}; diff --git a/src/components/LifePhaseForm.tsx b/src/components/LifePhaseForm.tsx new file mode 100644 index 0000000..a39a485 --- /dev/null +++ b/src/components/LifePhaseForm.tsx @@ -0,0 +1,637 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { InfoBubble, TAX_INFO } from "./InfoBubble"; + +type SecurityInput = { + name: string; + ownerTag: string; + startBalance: number; + expectedReturnRate: number; + annualSavingsAllocation: number; +}; + +type PropertyInput = { + name: string; + currentValue: number; + mortgageBalance: number; + valueGrowthRate: number; + annualAmortization: number; +}; + +type OneTimeEventInput = { + name: string; + yearOffset: number; + amount: number; + type: "INCOME" | "EXPENSE"; + taxRatePercent: number; +}; + +type LifePhaseFormProps = { + scenarioId: string; + planId: string; + initial?: { + id?: string; + name?: string; + durationYears?: number; + isRetirementPhase?: boolean; + incomeMode?: "PER_PERSON" | "HOUSEHOLD"; + householdIncome?: number; + person1Income?: number; + person2Income?: number; + householdExpenses?: number; + inflationRate?: number; + expectedPensionIncome?: number; + expectedPensionCapital?: number; + pensionCapitalTaxRate?: number; + securities?: SecurityInput[]; + properties?: PropertyInput[]; + oneTimeEvents?: OneTimeEventInput[]; + }; + isCouple?: boolean; +}; + +const defaultSecurity = (): SecurityInput => ({ + name: "", + ownerTag: "", + startBalance: 0, + expectedReturnRate: 5, + annualSavingsAllocation: 0, +}); + +const defaultProperty = (): PropertyInput => ({ + name: "", + currentValue: 0, + mortgageBalance: 0, + valueGrowthRate: 2, + annualAmortization: 0, +}); + +export function LifePhaseForm({ + scenarioId, + planId, + initial, + isCouple, +}: LifePhaseFormProps) { + const router = useRouter(); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [incomeMode, setIncomeMode] = useState<"PER_PERSON" | "HOUSEHOLD">( + initial?.incomeMode ?? "PER_PERSON" + ); + const [isRetirement, setIsRetirement] = useState( + initial?.isRetirementPhase ?? false + ); + const [securities, setSecurities] = useState( + initial?.securities?.length ? initial.securities : [defaultSecurity()] + ); + const [properties, setProperties] = useState( + initial?.properties ?? [] + ); + const [events, setEvents] = useState( + initial?.oneTimeEvents ?? [] + ); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setLoading(true); + setError(null); + + const form = new FormData(e.currentTarget); + const payload = { + scenarioId, + name: form.get("name"), + durationYears: Number(form.get("durationYears")), + isRetirementPhase: isRetirement, + incomeMode, + householdIncome: + incomeMode === "HOUSEHOLD" ? Number(form.get("householdIncome")) : null, + person1Income: + incomeMode === "PER_PERSON" ? Number(form.get("person1Income")) : null, + person2Income: + incomeMode === "PER_PERSON" && isCouple + ? Number(form.get("person2Income")) + : null, + householdExpenses: Number(form.get("householdExpenses")), + inflationRate: form.get("inflationRate") + ? Number(form.get("inflationRate")) + : null, + expectedPensionIncome: isRetirement + ? Number(form.get("expectedPensionIncome") || 0) + : null, + expectedPensionCapital: isRetirement + ? Number(form.get("expectedPensionCapital") || 0) + : null, + pensionCapitalTaxRate: isRetirement + ? Number(form.get("pensionCapitalTaxRate") || 0) + : null, + securities: securities.filter((s) => s.name.trim()), + properties: properties.filter((p) => p.name.trim()), + oneTimeEvents: events.filter((ev) => ev.name.trim()), + }; + + const url = initial?.id + ? `/api/phases/${initial.id}` + : "/api/phases"; + const method = initial?.id ? "PUT" : "POST"; + + const res = await fetch(url, { + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + + setLoading(false); + + if (!res.ok) { + const data = await res.json().catch(() => ({})); + setError(data.error ?? "Speichern fehlgeschlagen"); + return; + } + + router.push(`/plans/${planId}/scenarios/${scenarioId}`); + router.refresh(); + } + + return ( +
+ {error && ( +

+ {error} +

+ )} + +
+

Grunddaten

+
+
+ + +
+
+ + +
+
+ + + +
+ + +
+
+ +
+

Einkommen & Ausgaben

+ +
+ + +
+ + {incomeMode === "HOUSEHOLD" ? ( +
+ + +
+ ) : ( +
+
+ + +
+ {isCouple && ( +
+ + +
+ )} +
+ )} + + {isRetirement && ( +
+
+ + +
+
+ + +
+
+ + +
+
+ )} + +
+ + +
+
+ +
+
+

Wertschriften

+ +
+ + {securities.map((sec, idx) => ( +
+ { + const next = [...securities]; + next[idx] = { ...next[idx], name: e.target.value }; + setSecurities(next); + }} + /> + { + const next = [...securities]; + next[idx] = { ...next[idx], ownerTag: e.target.value }; + setSecurities(next); + }} + /> + { + const next = [...securities]; + next[idx] = { + ...next[idx], + startBalance: Number(e.target.value), + }; + setSecurities(next); + }} + /> + { + const next = [...securities]; + next[idx] = { + ...next[idx], + expectedReturnRate: Number(e.target.value), + }; + setSecurities(next); + }} + /> + { + const next = [...securities]; + next[idx] = { + ...next[idx], + annualSavingsAllocation: Number(e.target.value), + }; + setSecurities(next); + }} + /> + +
+ ))} +
+ +
+
+

Immobilien

+ +
+ + {properties.length === 0 && ( +

Keine Immobilien erfasst.

+ )} + + {properties.map((prop, idx) => ( +
+ { + const next = [...properties]; + next[idx] = { ...next[idx], name: e.target.value }; + setProperties(next); + }} + /> + { + const next = [...properties]; + next[idx] = { + ...next[idx], + currentValue: Number(e.target.value), + }; + setProperties(next); + }} + /> + { + const next = [...properties]; + next[idx] = { + ...next[idx], + mortgageBalance: Number(e.target.value), + }; + setProperties(next); + }} + /> + { + const next = [...properties]; + next[idx] = { + ...next[idx], + valueGrowthRate: Number(e.target.value), + }; + setProperties(next); + }} + /> + { + const next = [...properties]; + next[idx] = { + ...next[idx], + annualAmortization: Number(e.target.value), + }; + setProperties(next); + }} + /> + +
+ ))} +
+ +
+
+

Einmalige Ereignisse

+ +
+ + {events.map((ev, idx) => ( +
+ { + const next = [...events]; + next[idx] = { ...next[idx], name: e.target.value }; + setEvents(next); + }} + /> + { + const next = [...events]; + next[idx] = { + ...next[idx], + yearOffset: Number(e.target.value), + }; + setEvents(next); + }} + /> + { + const next = [...events]; + next[idx] = { ...next[idx], amount: Number(e.target.value) }; + setEvents(next); + }} + /> + + { + const next = [...events]; + next[idx] = { + ...next[idx], + taxRatePercent: Number(e.target.value), + }; + setEvents(next); + }} + /> + +
+ ))} +
+ + +
+ ); +} diff --git a/src/components/PhaseSummaryCard.tsx b/src/components/PhaseSummaryCard.tsx new file mode 100644 index 0000000..f321cf2 --- /dev/null +++ b/src/components/PhaseSummaryCard.tsx @@ -0,0 +1,71 @@ +import { formatChf } from "@/lib/calculations"; +import type { PhaseResult } from "@/lib/types"; + +type PhaseSummaryCardProps = { + result: PhaseResult; + defaultOpen?: boolean; +}; + +export function PhaseSummaryCard({ + result, + defaultOpen = false, +}: PhaseSummaryCardProps) { + return ( +
+ +
+
+

+ {result.phaseName} +

+

+ {result.durationYears} Jahre +

+
+
+ + Endwert nominal: {formatChf(result.endNominal)} + + + Endwert real: {formatChf(result.endReal)} + + {!result.savingsAllocationValid && ( + + Sparverteilung überschreitet Sparquote + + )} +
+
+
+ +
+ + + + 0 + ? formatChf(result.inheritanceNominal) + : "–" + } + /> +
+
+ ); +} + +function Stat({ label, value }: { label: string; value: string }) { + return ( +
+

{label}

+

{value}

+
+ ); +} diff --git a/src/components/PlanForm.tsx b/src/components/PlanForm.tsx new file mode 100644 index 0000000..19fefd4 --- /dev/null +++ b/src/components/PlanForm.tsx @@ -0,0 +1,201 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useState } from "react"; + +type PlanFormProps = { + initial?: { + name?: string; + householdType?: "SINGLE" | "COUPLE"; + person1Name?: string; + person1CurrentAge?: number; + person2Name?: string; + person2CurrentAge?: number; + plannedRetirementAge?: number; + defaultInflationRate?: number; + }; + planId?: string; +}; + +export function PlanForm({ initial, planId }: PlanFormProps) { + const router = useRouter(); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [householdType, setHouseholdType] = useState<"SINGLE" | "COUPLE">( + initial?.householdType ?? "SINGLE" + ); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setLoading(true); + setError(null); + + const form = new FormData(e.currentTarget); + const payload = { + name: form.get("name"), + householdType, + person1Name: form.get("person1Name"), + person1CurrentAge: Number(form.get("person1CurrentAge")), + person2Name: householdType === "COUPLE" ? form.get("person2Name") : null, + person2CurrentAge: + householdType === "COUPLE" + ? Number(form.get("person2CurrentAge")) + : null, + plannedRetirementAge: Number(form.get("plannedRetirementAge")), + defaultInflationRate: Number(form.get("defaultInflationRate")), + }; + + const url = planId ? `/api/plans/${planId}` : "/api/plans"; + const method = planId ? "PUT" : "POST"; + + const res = await fetch(url, { + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + + setLoading(false); + + if (!res.ok) { + const data = await res.json().catch(() => ({})); + setError(data.error ?? "Speichern fehlgeschlagen"); + return; + } + + const data = await res.json(); + router.push(`/plans/${data.id}`); + router.refresh(); + } + + return ( +
+ {error && ( +

+ {error} +

+ )} + +
+ + +
+ +
+ +
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + {householdType === "COUPLE" && ( +
+
+ + +
+
+ + +
+
+ )} + +
+
+ + +
+
+ + +
+
+ + +
+ ); +} diff --git a/src/components/ScenarioActions.tsx b/src/components/ScenarioActions.tsx new file mode 100644 index 0000000..e4c60bf --- /dev/null +++ b/src/components/ScenarioActions.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useState } from "react"; + +type Scenario = { + id: string; + name: string; + isBaseline: boolean; +}; + +type ScenarioActionsProps = { + planId: string; + scenarios: Scenario[]; +}; + +export function ScenarioActions({ planId, scenarios }: ScenarioActionsProps) { + const router = useRouter(); + const [loading, setLoading] = useState(false); + const baseline = scenarios.find((s) => s.isBaseline) ?? scenarios[0]; + + async function createScenario() { + const name = prompt("Name des neuen Szenarios:", "Alternativszenario"); + if (!name) return; + + setLoading(true); + const res = await fetch(`/api/plans/${planId}/scenarios`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name, + copyFromScenarioId: baseline?.id, + }), + }); + setLoading(false); + + if (res.ok) { + const data = await res.json(); + router.push(`/plans/${planId}/scenarios/${data.id}`); + router.refresh(); + } + } + + return ( + + ); +} diff --git a/src/components/TransitionForm.tsx b/src/components/TransitionForm.tsx new file mode 100644 index 0000000..dda3271 --- /dev/null +++ b/src/components/TransitionForm.tsx @@ -0,0 +1,239 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { InfoBubble, TAX_INFO } from "./InfoBubble"; + +type TransitionFormProps = { + fromPhaseId: string; + toPhaseId: string; + planId: string; + scenarioId: string; + securities: Array<{ id: string; name: string }>; + properties: Array<{ id: string; name: string }>; + initial?: { + securityActions: Array<{ + securityId: string; + action: "carry_over" | "payout"; + payoutAmount?: number; + }>; + propertyActions: Array<{ + propertyId: string; + action: "carry_over" | "sell"; + saleProceeds?: number; + saleTaxRatePercent?: number; + }>; + }; +}; + +export function TransitionForm({ + fromPhaseId, + toPhaseId, + planId, + scenarioId, + securities, + properties, + initial, +}: TransitionFormProps) { + const router = useRouter(); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const [securityActions, setSecurityActions] = useState( + securities.map((s) => { + const existing = initial?.securityActions.find( + (a) => a.securityId === s.id + ); + return { + securityId: s.id, + name: s.name, + action: existing?.action ?? ("carry_over" as const), + payoutAmount: existing?.payoutAmount ?? 0, + }; + }) + ); + + const [propertyActions, setPropertyActions] = useState( + properties.map((p) => { + const existing = initial?.propertyActions.find( + (a) => a.propertyId === p.id + ); + return { + propertyId: p.id, + name: p.name, + action: existing?.action ?? ("carry_over" as const), + saleProceeds: existing?.saleProceeds ?? 0, + saleTaxRatePercent: existing?.saleTaxRatePercent ?? 15, + }; + }) + ); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setLoading(true); + setError(null); + + const res = await fetch("/api/transitions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + fromPhaseId, + toPhaseId, + securityActions: securityActions.map( + ({ securityId, action, payoutAmount }) => ({ + securityId, + action, + payoutAmount: action === "payout" ? payoutAmount : undefined, + }) + ), + propertyActions: propertyActions.map( + ({ propertyId, action, saleProceeds, saleTaxRatePercent }) => ({ + propertyId, + action, + saleProceeds: action === "sell" ? saleProceeds : undefined, + saleTaxRatePercent: + action === "sell" ? saleTaxRatePercent : undefined, + }) + ), + }), + }); + + setLoading(false); + + if (!res.ok) { + const data = await res.json().catch(() => ({})); + setError(data.error ?? "Speichern fehlgeschlagen"); + return; + } + + router.push(`/plans/${planId}/scenarios/${scenarioId}`); + router.refresh(); + } + + return ( +
+
+

Phasenübergang

+

+ Legen Sie fest, welche Wertschriften übernommen oder ausbezahlt werden + und ob Immobilien verkauft werden. +

+
+ + {error && ( +

+ {error} +

+ )} + + {securities.length > 0 && ( +
+

Wertschriften

+ {securityActions.map((item, idx) => ( +
+ {item.name} + + {item.action === "payout" && ( + { + const next = [...securityActions]; + next[idx] = { + ...next[idx], + payoutAmount: Number(e.target.value), + }; + setSecurityActions(next); + }} + /> + )} +
+ ))} +
+ )} + + {properties.length > 0 && ( +
+

+ Immobilien + {TAX_INFO.propertySale} +

+ {propertyActions.map((item, idx) => ( +
+ {item.name} + + {item.action === "sell" && ( + <> + { + const next = [...propertyActions]; + next[idx] = { + ...next[idx], + saleProceeds: Number(e.target.value), + }; + setPropertyActions(next); + }} + /> + { + const next = [...propertyActions]; + next[idx] = { + ...next[idx], + saleTaxRatePercent: Number(e.target.value), + }; + setPropertyActions(next); + }} + /> + + )} +
+ ))} +
+ )} + + +
+ ); +} diff --git a/src/components/WealthChart.tsx b/src/components/WealthChart.tsx new file mode 100644 index 0000000..3118e6f --- /dev/null +++ b/src/components/WealthChart.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { + CartesianGrid, + Legend, + Line, + LineChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import { formatChf } from "@/lib/calculations"; + +type WealthChartProps = { + data: Array<{ + globalYear: number; + nominal: number; + real: number; + phaseName: string; + }>; +}; + +export function WealthChart({ data }: WealthChartProps) { + if (data.length === 0) { + return ( +

+ Noch keine Daten – fügen Sie Lebensphasen hinzu. +

+ ); + } + + return ( +
+ + + + + + new Intl.NumberFormat("de-CH", { + notation: "compact", + compactDisplay: "short", + }).format(v) + } + /> + formatChf(value)} + labelFormatter={(label) => `Jahr ${label}`} + /> + + + + + +
+ ); +} diff --git a/src/lib/calculations.ts b/src/lib/calculations.ts new file mode 100644 index 0000000..9f8943f --- /dev/null +++ b/src/lib/calculations.ts @@ -0,0 +1,308 @@ +import type { + CalcPhase, + CalcPlanContext, + CalcProperty, + PhaseResult, + PropertyAction, + ScenarioResult, + SecurityAction, + YearSnapshot, +} from "./types"; + +function phaseIncome(phase: CalcPhase): number { + if (phase.isRetirementPhase) { + const pension = phase.expectedPensionIncome ?? 0; + if (phase.incomeMode === "HOUSEHOLD") { + return pension + (phase.householdIncome ?? 0); + } + return ( + pension + + (phase.person1Income ?? 0) + + (phase.person2Income ?? 0) + + (phase.householdIncome ?? 0) + ); + } + + if (phase.incomeMode === "HOUSEHOLD") { + return phase.householdIncome ?? 0; + } + return (phase.person1Income ?? 0) + (phase.person2Income ?? 0); +} + +function applyTax(amount: number, taxRatePercent?: number | null): number { + if (!taxRatePercent || taxRatePercent <= 0) return amount; + return amount * (1 - taxRatePercent / 100); +} + +function applyTransitionToNextPhase( + phase: CalcPhase, + nextPhase: CalcPhase, + securityEndBalances: Map, + propertyEndStates: CalcProperty[] +): void { + const transition = phase.transition; + if (!transition) return; + + const securityMap = new Map( + phase.securities.map((s, idx) => [s.id, { sec: s, idx }]) + ); + const nextSecurityByName = new Map( + nextPhase.securities.map((s, idx) => [s.name, idx]) + ); + + for (const action of transition.securityActions as SecurityAction[]) { + const source = securityMap.get(action.securityId); + if (!source) continue; + const endBalance = securityEndBalances.get(action.securityId) ?? 0; + + if (action.action === "payout") { + const payout = action.payoutAmount ?? endBalance; + securityEndBalances.set(action.securityId, Math.max(0, endBalance - payout)); + const cashIdx = nextPhase.securities.findIndex((s) => + s.name.toLowerCase().includes("liquid") + ); + if (cashIdx >= 0) { + nextPhase.securities[cashIdx].startBalance += payout; + } else if (nextPhase.securities.length > 0) { + nextPhase.securities[0].startBalance += payout; + } + } else if (action.action === "carry_over") { + const carryAmount = endBalance; + const targetIdx = nextSecurityByName.get(source.sec.name); + if (targetIdx !== undefined) { + nextPhase.securities[targetIdx].startBalance += carryAmount; + } + securityEndBalances.set(action.securityId, 0); + } + } + + const propertyMap = new Map( + phase.properties.map((p) => [p.id, p]) + ); + + for (const action of transition.propertyActions as PropertyAction[]) { + const source = propertyEndStates.find((p) => p.id === action.propertyId); + const original = propertyMap.get(action.propertyId); + if (!source || !original) continue; + + if (action.action === "sell") { + const gross = action.saleProceeds ?? source.currentValue; + const net = applyTax(gross, action.saleTaxRatePercent); + const cashIdx = nextPhase.securities.findIndex((s) => + s.name.toLowerCase().includes("liquid") + ); + if (cashIdx >= 0) { + nextPhase.securities[cashIdx].startBalance += net; + } else if (nextPhase.securities.length > 0) { + nextPhase.securities[0].startBalance += net; + } + source.currentValue = 0; + source.mortgageBalance = 0; + } else if (action.action === "carry_over") { + const existing = nextPhase.properties.find((p) => p.name === original.name); + if (existing) { + existing.currentValue = source.currentValue; + existing.mortgageBalance = source.mortgageBalance; + } + } + } +} + +export function calculateScenario( + scenarioId: string, + phasesInput: CalcPhase[], + context: CalcPlanContext +): ScenarioResult { + const phases = phasesInput.map((p) => ({ + ...p, + securities: p.securities.map((s) => ({ ...s })), + properties: p.properties.map((prop) => ({ ...prop })), + oneTimeEvents: [...p.oneTimeEvents], + })); + + const phaseResults: PhaseResult[] = []; + const chartData: ScenarioResult["chartData"] = []; + let globalYear = 0; + let cumulativeInflation = 1; + let liquidCarryover = 0; + + for (let phaseIndex = 0; phaseIndex < phases.length; phaseIndex++) { + const phase = phases[phaseIndex]; + const inflationRate = phase.inflationRate ?? context.defaultInflationRate; + const income = phaseIncome(phase); + const expenses = phase.householdExpenses; + let savingsRate = income - expenses; + + if (phase.isRetirementPhase && phase.expectedPensionCapital) { + const netCapital = applyTax( + phase.expectedPensionCapital, + phase.pensionCapitalTaxRate + ); + if (phase.securities.length > 0) { + phase.securities[0].startBalance += netCapital; + } + } + + if (liquidCarryover > 0) { + if (phase.securities.length > 0) { + phase.securities[0].startBalance += liquidCarryover; + } + liquidCarryover = 0; + } + + const allocatedSavings = phase.securities.reduce( + (sum, s) => sum + s.annualSavingsAllocation, + 0 + ); + const savingsAllocationValid = allocatedSavings <= savingsRate + 0.01; + + const years: YearSnapshot[] = []; + const securityBalances = phase.securities.map((s) => s.startBalance); + const propertyStates = phase.properties.map((p) => ({ ...p })); + + let startNominal = 0; + if (phaseIndex === 0) { + startNominal = + securityBalances.reduce((a, b) => a + b, 0) + + propertyStates.reduce( + (sum, p) => sum + p.currentValue - p.mortgageBalance, + 0 + ); + } else { + const prev = phaseResults[phaseResults.length - 1]; + startNominal = prev.endNominal; + } + + for (let y = 0; y < phase.durationYears; y++) { + globalYear += 1; + cumulativeInflation *= 1 + inflationRate / 100; + + for (let i = 0; i < phase.securities.length; i++) { + const sec = phase.securities[i]; + securityBalances[i] = + securityBalances[i] * (1 + sec.expectedReturnRate / 100) + + sec.annualSavingsAllocation; + } + + for (const prop of propertyStates) { + prop.currentValue *= 1 + prop.valueGrowthRate / 100; + prop.mortgageBalance = Math.max( + 0, + prop.mortgageBalance - prop.annualAmortization + ); + } + + const eventsThisYear = phase.oneTimeEvents.filter( + (e) => e.yearOffset === y + 1 + ); + let eventAdjustment = 0; + for (const event of eventsThisYear) { + const net = applyTax(event.amount, event.taxRatePercent); + if (event.type === "INCOME") { + eventAdjustment += net; + if (phase.securities.length > 0) { + securityBalances[0] += net; + } + } else { + eventAdjustment -= net; + if (phase.securities.length > 0) { + securityBalances[0] = Math.max(0, securityBalances[0] - net); + } + } + } + + const securitiesTotal = securityBalances.reduce((a, b) => a + b, 0); + const propertiesTotal = propertyStates.reduce( + (sum, p) => sum + p.currentValue - p.mortgageBalance, + 0 + ); + const netWorthNominal = securitiesTotal + propertiesTotal; + const netWorthReal = netWorthNominal / cumulativeInflation; + + const agePerson1 = context.person1CurrentAge + globalYear - 1; + const agePerson2 = context.person2CurrentAge + ? context.person2CurrentAge + globalYear - 1 + : null; + + years.push({ + year: y + 1, + globalYear, + agePerson1, + agePerson2, + income, + expenses, + savingsRate, + securitiesTotal, + propertiesTotal, + netWorthNominal, + netWorthReal, + cumulativeInflationFactor: cumulativeInflation, + }); + + chartData.push({ + globalYear, + nominal: Math.round(netWorthNominal), + real: Math.round(netWorthReal), + phaseName: phase.name, + }); + } + + const endNominal = years[years.length - 1]?.netWorthNominal ?? startNominal; + const endReal = years[years.length - 1]?.netWorthReal ?? startNominal; + + const securityEndBalances = new Map(); + phase.securities.forEach((s, i) => { + securityEndBalances.set(s.id, securityBalances[i]); + }); + + if (phaseIndex < phases.length - 1) { + applyTransitionToNextPhase( + phase, + phases[phaseIndex + 1], + securityEndBalances, + propertyStates + ); + } else { + liquidCarryover = 0; + } + + const isLastPhase = phaseIndex === phases.length - 1; + + phaseResults.push({ + phaseId: phase.id, + phaseName: phase.name, + durationYears: phase.durationYears, + startNominal, + endNominal, + endReal, + availableSavingsRate: savingsRate, + allocatedSavings, + savingsAllocationValid, + years, + inheritanceNominal: isLastPhase ? endNominal : 0, + inheritanceReal: isLastPhase ? endReal : 0, + }); + } + + const lastPhase = phaseResults[phaseResults.length - 1]; + + return { + scenarioId, + phases: phaseResults, + chartData, + finalInheritanceNominal: lastPhase?.inheritanceNominal ?? 0, + finalInheritanceReal: lastPhase?.inheritanceReal ?? 0, + }; +} + +export function formatChf(value: number): string { + return new Intl.NumberFormat("de-CH", { + style: "currency", + currency: "CHF", + maximumFractionDigits: 0, + }).format(value); +} + +export function formatPercent(value: number): string { + return `${value.toFixed(1)}%`; +} diff --git a/src/lib/db.ts b/src/lib/db.ts new file mode 100644 index 0000000..bd05edf --- /dev/null +++ b/src/lib/db.ts @@ -0,0 +1,15 @@ +import { PrismaClient } from "@prisma/client"; + +const globalForPrisma = globalThis as unknown as { + prisma: PrismaClient | undefined; +}; + +export const prisma = + globalForPrisma.prisma ?? + new PrismaClient({ + log: process.env.NODE_ENV === "development" ? ["error", "warn"] : ["error"], + }); + +if (process.env.NODE_ENV !== "production") { + globalForPrisma.prisma = prisma; +} diff --git a/src/lib/scenario-service.ts b/src/lib/scenario-service.ts new file mode 100644 index 0000000..20ec87a --- /dev/null +++ b/src/lib/scenario-service.ts @@ -0,0 +1,100 @@ +import type { + LifePhase, + OneTimeEvent, + Plan, + Property, + Scenario, + Security, +} from "@prisma/client"; +import { calculateScenario } from "./calculations"; +import type { CalcPhase, ScenarioResult } from "./types"; + +type PhaseWithRelations = LifePhase & { + securities: Security[]; + properties: Property[]; + oneTimeEvents: OneTimeEvent[]; + transitionFrom: { + securityActions: unknown; + propertyActions: unknown; + } | null; +}; + +type ScenarioWithRelations = Scenario & { + phases: PhaseWithRelations[]; +}; + +export function buildCalcPhases(phases: PhaseWithRelations[]): CalcPhase[] { + return phases + .sort((a, b) => a.sortOrder - b.sortOrder) + .map((phase) => ({ + id: phase.id, + name: phase.name, + durationYears: phase.durationYears, + isRetirementPhase: phase.isRetirementPhase, + incomeMode: phase.incomeMode, + householdIncome: phase.householdIncome, + person1Income: phase.person1Income, + person2Income: phase.person2Income, + householdExpenses: phase.householdExpenses, + inflationRate: phase.inflationRate ?? undefined, + expectedPensionIncome: phase.expectedPensionIncome, + expectedPensionCapital: phase.expectedPensionCapital, + pensionCapitalTaxRate: phase.pensionCapitalTaxRate, + securities: phase.securities + .sort((a, b) => a.sortOrder - b.sortOrder) + .map((s) => ({ + id: s.id, + name: s.name, + startBalance: s.startBalance, + expectedReturnRate: s.expectedReturnRate, + annualSavingsAllocation: s.annualSavingsAllocation, + })), + properties: phase.properties + .sort((a, b) => a.sortOrder - b.sortOrder) + .map((p) => ({ + id: p.id, + name: p.name, + currentValue: p.currentValue, + mortgageBalance: p.mortgageBalance, + valueGrowthRate: p.valueGrowthRate, + annualAmortization: p.annualAmortization, + })), + oneTimeEvents: phase.oneTimeEvents.map((e) => ({ + yearOffset: e.yearOffset, + amount: e.amount, + type: e.type, + taxRatePercent: e.taxRatePercent, + })), + transition: phase.transitionFrom + ? { + securityActions: (phase.transitionFrom.securityActions ?? + []) as import("./types").SecurityAction[], + propertyActions: (phase.transitionFrom.propertyActions ?? + []) as import("./types").PropertyAction[], + } + : null, + })); +} + +export function calculateForScenario( + plan: Plan, + scenario: ScenarioWithRelations +): ScenarioResult { + return calculateScenario(scenario.id, buildCalcPhases(scenario.phases), { + person1CurrentAge: plan.person1CurrentAge, + person2CurrentAge: plan.person2CurrentAge, + defaultInflationRate: plan.defaultInflationRate, + }); +} + +export const scenarioInclude = { + phases: { + orderBy: { sortOrder: "asc" as const }, + include: { + securities: { orderBy: { sortOrder: "asc" as const } }, + properties: { orderBy: { sortOrder: "asc" as const } }, + oneTimeEvents: true, + transitionFrom: true, + }, + }, +}; diff --git a/src/lib/types.ts b/src/lib/types.ts new file mode 100644 index 0000000..9930ee2 --- /dev/null +++ b/src/lib/types.ts @@ -0,0 +1,108 @@ +export type SecurityAction = { + securityId: string; + action: "carry_over" | "payout"; + payoutAmount?: number; +}; + +export type PropertyAction = { + propertyId: string; + action: "carry_over" | "sell"; + saleProceeds?: number; + saleTaxRatePercent?: number; +}; + +export type YearSnapshot = { + year: number; + globalYear: number; + agePerson1: number; + agePerson2: number | null; + income: number; + expenses: number; + savingsRate: number; + securitiesTotal: number; + propertiesTotal: number; + netWorthNominal: number; + netWorthReal: number; + cumulativeInflationFactor: number; +}; + +export type PhaseResult = { + phaseId: string; + phaseName: string; + durationYears: number; + startNominal: number; + endNominal: number; + endReal: number; + availableSavingsRate: number; + allocatedSavings: number; + savingsAllocationValid: boolean; + years: YearSnapshot[]; + inheritanceNominal: number; + inheritanceReal: number; +}; + +export type ScenarioResult = { + scenarioId: string; + phases: PhaseResult[]; + chartData: Array<{ + globalYear: number; + nominal: number; + real: number; + phaseName: string; + }>; + finalInheritanceNominal: number; + finalInheritanceReal: number; +}; + +export type CalcSecurity = { + id: string; + name: string; + startBalance: number; + expectedReturnRate: number; + annualSavingsAllocation: number; +}; + +export type CalcProperty = { + id: string; + name: string; + currentValue: number; + mortgageBalance: number; + valueGrowthRate: number; + annualAmortization: number; +}; + +export type CalcOneTimeEvent = { + yearOffset: number; + amount: number; + type: "INCOME" | "EXPENSE"; + taxRatePercent?: number | null; +}; + +export type CalcPhase = { + id: string; + name: string; + durationYears: number; + isRetirementPhase: boolean; + incomeMode: "PER_PERSON" | "HOUSEHOLD"; + householdIncome?: number | null; + person1Income?: number | null; + person2Income?: number | null; + householdExpenses: number; + inflationRate: number; + expectedPensionIncome?: number | null; + expectedPensionCapital?: number | null; + pensionCapitalTaxRate?: number | null; + securities: CalcSecurity[]; + properties: CalcProperty[]; + oneTimeEvents: CalcOneTimeEvent[]; + transition?: { + securityActions: SecurityAction[]; + propertyActions: PropertyAction[]; + } | null; +}; + +export type CalcPlanContext = { + person1CurrentAge: number; + person2CurrentAge?: number | null; + defaultInflationRate: number; +}; diff --git a/tailwind.config.ts b/tailwind.config.ts new file mode 100644 index 0000000..b45a185 --- /dev/null +++ b/tailwind.config.ts @@ -0,0 +1,25 @@ +import type { Config } from "tailwindcss"; + +const config: Config = { + content: [ + "./src/pages/**/*.{js,ts,jsx,tsx,mdx}", + "./src/components/**/*.{js,ts,jsx,tsx,mdx}", + "./src/app/**/*.{js,ts,jsx,tsx,mdx}", + ], + theme: { + extend: { + colors: { + brand: { + 50: "#f0f7ff", + 100: "#e0effe", + 500: "#2563eb", + 600: "#1d4ed8", + 700: "#1e40af", + }, + }, + }, + }, + plugins: [], +}; + +export default config; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..b8cdcc7 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [{ "name": "next" }], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +}