From 5e17ce4afbde42494c4936571deae118d3d0b1ff Mon Sep 17 00:00:00 2001 From: kelle Date: Wed, 15 Jul 2026 09:24:13 +0200 Subject: [PATCH] V4-Rechenmodell: Flow-Indexierung, Cash-Ausgleichstopf, Ruin-Erkennung Behebt die Nominal/Real-Inkonsistenz (Bug #1): Einkommen/Ausgaben werden neu Jahr fuer Jahr indexiert (eigenes Feld teuerungsausgleich je Element, Default = Phaseninflation; Renten nominal fix = 0%). Vermoegen verzinst weiterhin nominal. Cash: neues systemseitiges, immer sichtbares Element (0% Verzinsung, kein DB-Row - synthetisch in computePlan). Ist der Ausgleichstopf = "verfuegbares Kapital fuer Investments": cash_delta(t) = quote(t) - geplante flache Jahresraten (3a/Vermoegen/Amort./ Tilgung); Cash laeuft ueber Phasen fort, darf negativ werden (rot). Der Verteilzwang und die harten Sparraten-Caps entfallen. Ruin: Gesamtvermoegen (inkl. Cash) je Jahr; erstes Unterschreiten von 0 -> Ruin-Alter (Person A), Anzeige als Banner + Zeitachsen-Marker. Phasenkopf neu: Einkommen/Ausgaben Start->Ende, Quote Beginn/Ende, Cash Start->Ende, Vermoegen inkl. Cash, Realwert. Inflation-Deflator neu pro Jahr (Math.pow ^Dauer). Golden Tests (vitest) 1-4 + Renten-0% + Fortschreibung gruen (Test1 761'654/565'928, Test2 Ruin Alter 94). Co-Authored-By: Claude Opus 4.8 --- package-lock.json | 829 ++++++++++++++++++++- package.json | 4 +- src/app/api/plans/[planId]/phases/route.ts | 3 +- src/components/ElementDetail.tsx | 73 +- src/components/PlanView.tsx | 90 ++- src/components/Timeline.tsx | 24 +- src/lib/calculations.test.ts | 137 ++++ src/lib/calculations.ts | 468 ++++++------ src/lib/elements.ts | 8 + vitest.config.ts | 12 + 10 files changed, 1349 insertions(+), 299 deletions(-) create mode 100644 src/lib/calculations.test.ts create mode 100644 vitest.config.ts diff --git a/package-lock.json b/package-lock.json index a856585..e131424 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,7 +32,8 @@ "eslint": "^9", "eslint-config-next": "16.2.10", "tailwindcss": "^4", - "typescript": "^5" + "typescript": "^5", + "vitest": "^4.1.10" } }, "node_modules/@alloc/quick-lru": { @@ -1363,6 +1364,16 @@ "node": ">=12.4.0" } }, + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, "node_modules/@prisma/adapter-pg": { "version": "7.8.0", "resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.8.0.tgz", @@ -1748,6 +1759,322 @@ } } }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -2070,6 +2397,17 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/d3-array": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", @@ -2133,6 +2471,13 @@ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "license": "MIT" }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -2848,6 +3193,119 @@ "win32" ] }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.17.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", @@ -3081,6 +3539,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/ast-types-flow": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", @@ -3343,6 +3811,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -3986,6 +4464,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", @@ -4478,6 +4963,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -4494,6 +4989,16 @@ "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", "license": "MIT" }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/exsolve": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.0.tgz", @@ -4694,6 +5199,21 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -6491,6 +7011,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/ohash": { "version": "2.0.11", "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", @@ -7204,6 +7738,40 @@ "node": ">=0.10.0" } }, + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -7514,6 +8082,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -7560,6 +8135,13 @@ "dev": true, "license": "MIT" }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/std-env": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", @@ -7793,6 +8375,23 @@ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -7841,6 +8440,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -8177,6 +8786,207 @@ "d3-timer": "^3.0.1" } }, + "node_modules/vite": { + "version": "8.1.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz", + "integrity": "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.16", + "rolldown": "~1.1.4", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest/node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -8281,6 +9091,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/package.json b/package.json index 1c3be1d..ff01337 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "build": "next build", "start": "next start", "lint": "eslint", + "test": "vitest run", "postinstall": "prisma generate" }, "dependencies": { @@ -33,7 +34,8 @@ "eslint": "^9", "eslint-config-next": "16.2.10", "tailwindcss": "^4", - "typescript": "^5" + "typescript": "^5", + "vitest": "^4.1.10" }, "allowScripts": { "@prisma/engines@7.8.0": true, diff --git a/src/app/api/plans/[planId]/phases/route.ts b/src/app/api/plans/[planId]/phases/route.ts index 65c31b7..aaeac7a 100644 --- a/src/app/api/plans/[planId]/phases/route.ts +++ b/src/app/api/plans/[planId]/phases/route.ts @@ -86,7 +86,8 @@ function buildCarryData(category: string, prev: PhaseData): PhaseData { switch (category) { case "INCOME": case "EXPENSE": - return { amount: num(prev.amount) }; + // Basis wird live indexiert fortgeschrieben; nur der Teuerungsausgleich wird uebernommen. + return prev.teuerungsausgleich != null ? { teuerungsausgleich: prev.teuerungsausgleich } : {}; case "AHV": return { gapYears: 0 }; case "PENSION_FUND": diff --git a/src/components/ElementDetail.tsx b/src/components/ElementDetail.tsx index 830a766..ee38915 100644 --- a/src/components/ElementDetail.tsx +++ b/src/components/ElementDetail.tsx @@ -22,10 +22,9 @@ export interface CellContext { durationYears: number; isRetirementTransition: boolean; carriedEndValue: number; // Endwert des Elements in der (Vor-)Phase, fuer Bezugs-Maxima - carried: boolean; // Phase >= 2: Startwert wird aus der Vorphase fortgeschrieben - derivedStart: number; // fortgeschriebener Basis-Startwert (read-only Anzeige) - quotaRateMax: number; // Max fuer eine Sparrate/Verzehrrate dieses Elements - capitalMax?: number; // Max fuer Startkapital/Neuinvestition (undefined = kein Cap) + carried: boolean; // Phase >= 2: Basiswert wird aus der Vorphase fortgeschrieben + derivedStart: number; // fortgeschriebener Basiswert (read-only Anzeige) + phaseInflation: number; // Default fuer Teuerungsausgleich } interface Props { @@ -135,9 +134,38 @@ export function ElementPhaseFields({ const carried = context.carried; switch (element.category) { case "INCOME": - return setP({ amount: v })} />; - case "EXPENSE": - return setP({ amount: v })} />; + case "EXPENSE": { + const isIncome = element.category === "INCOME"; + const teuerung = ( + setP({ teuerungsausgleich: v })} + /> + ); + if (carried) { + return ( + <> + + setP({ amountOverride: v })} + /> + {teuerung} + + ); + } + return ( + <> + setP({ amount: v })} /> + {teuerung} + + ); + } case "AHV": if (!context.ownerWorking) { return ( @@ -175,12 +203,12 @@ export function ElementPhaseFields({ label="Zusatzeinlage aus Kapital (CHF)" help="Aufstockung aus dem verfuegbaren Kapital dieser Phase." value={num(pd.additionalInvestment)} - max={context.capitalMax} + onChange={(v) => setP({ additionalInvestment: v })} /> ) : ( - setP({ currentValue: v })} /> + setP({ currentValue: v })} /> )} setP({ additionalInvestment: v })} /> ) : ( - setP({ currentValue: v })} /> + setP({ currentValue: v })} /> )} setP({ annualContribution: v })} /> setP({ expectedReturn: v })} /> @@ -230,7 +258,7 @@ export function ElementPhaseFields({ label="Amortisation (CHF/Jahr)" help="Jaehrliche Reduktion der Hypothek. Zaehlt gegen die Sparquote." value={num(pd.amortization)} - max={context.quotaRateMax} + onChange={(v) => setP({ amortization: v })} /> @@ -238,13 +266,13 @@ export function ElementPhaseFields({ } return ( <> - setP({ purchasePrice: v })} /> + setP({ purchasePrice: v })} /> setP({ mortgage: v })} /> setP({ amortization: v })} /> @@ -259,23 +287,18 @@ export function ElementPhaseFields({ label="Zusatzinvestition aus Kapital (CHF)" help="Neuinvestition aus dem verfuegbaren Kapital dieser Phase." value={num(pd.additionalInvestment)} - max={context.capitalMax} + onChange={(v) => setP({ additionalInvestment: v })} /> ) : ( - setP({ startValue: v })} /> + setP({ startValue: v })} /> )} setP({ expectedReturn: v })} /> setP({ annualContribution: v })} /> @@ -292,7 +315,7 @@ export function ElementPhaseFields({ label="Jaehrliche Tilgung (CHF)" help="Zaehlt gegen die Sparquote (max. die Sparquote)." value={num(pd.annualRepayment)} - max={context.quotaRateMax} + onChange={(v) => setP({ annualRepayment: v })} /> diff --git a/src/components/PlanView.tsx b/src/components/PlanView.tsx index bb6e3f9..39eb0f0 100644 --- a/src/components/PlanView.tsx +++ b/src/components/PlanView.tsx @@ -141,20 +141,17 @@ export function PlanView({ return !!before?.working && !!after && !after.working; } - // Baut den Kontext (inkl. Live-Caps) fuer eine Phasenzelle. + function phaseInflationFor(phaseId: string): number { + return plan.phases.find((p) => p.id === phaseId)?.inflationRate ?? plan.inflationRateDefault; + } + + // Baut den Kontext fuer eine Phasenzelle. function buildPhaseContext(phase: PhaseComputed, element: ElementInput): CellContext { const ce = computedElement(phase.id, element.id); const ownerWorking = element.ownerRole && element.ownerRole !== "HOUSEHOLD" ? phase.persons.find((p) => p.role === element.ownerRole)?.working ?? false : phase.type !== "PENSION"; - const otherQuota = phase.quotaAllocated - (ce?.quotaUse ?? 0); - const quotaRateMax = Math.max(0, Math.abs(phase.quota) - otherQuota); - const capitalMax = - phase.availableCapital == null - ? undefined - : Math.max(0, phase.availableCapital - (phase.availableCapitalUsed - (ce?.capitalUse ?? 0))); - const derivedStart = (ce?.startValue ?? 0) - (ce?.capitalUse ?? 0); return { kind: "phase", phaseId: phase.id, @@ -164,9 +161,8 @@ export function PlanView({ isRetirementTransition: false, carriedEndValue: ce?.endValue ?? 0, carried: ce?.carried ?? false, - derivedStart, - quotaRateMax, - capitalMax, + derivedStart: ce?.baseValue ?? 0, + phaseInflation: phaseInflationFor(phase.id), }; } @@ -182,8 +178,7 @@ export function PlanView({ carriedEndValue: ce?.endValue ?? 0, carried: ce?.carried ?? false, derivedStart: 0, - quotaRateMax: 0, - capitalMax: undefined, + phaseInflation: phaseInflationFor(fromPhase.id), }; } @@ -242,7 +237,7 @@ export function PlanView({ return (
- + {/* Plan-Profil */}
@@ -294,6 +289,12 @@ export function PlanView({
)} + {computed.ruinAge !== null && ( +
+ Kapital aufgebraucht mit Alter {computed.ruinAge} – das Gesamtvermoegen (inkl. Cash) faellt danach unter 0. +
+ )} + {/* Matrix */} {hasPhases && (
@@ -323,6 +324,33 @@ export function PlanView({ + {/* Cash-Zeile (systemseitig, immer sichtbar, read-only). */} + + + + Cash + + verfuegbares Kapital + + {columns.map((col) => + col.kind === "phase" ? ( + + + {formatChf(col.phase.cashStart)} {formatChf(col.phase.cashEnd)} + + + ) : ( + + → + + ) + )} + {CATEGORY_ORDER.map((cat) => { const els = elementsByCategory.get(cat)!; if (els.length === 0) return null; @@ -584,10 +612,12 @@ export function PlanView({ } // Zellinhalt: Start- UND Zielwert fuer wertbehaftete Elemente, sonst die Kennzahl. +const START_END_CATEGORIES: ElementCategory[] = [...VALUE_CATEGORIES, "INCOME", "EXPENSE"]; + function phaseCellContent(ce: ReturnType | undefined): React.ReactNode { if (!ce) return "–"; if (ce.note) return ce.note; - if (VALUE_CATEGORIES.includes(ce.category) && (ce.startValue !== 0 || ce.endValue !== 0)) { + if (START_END_CATEGORIES.includes(ce.category) && (ce.startValue !== 0 || ce.endValue !== 0)) { return ( {formatChf(ce.startValue)} {formatChf(ce.endValue)} @@ -608,20 +638,17 @@ function PhaseHeader({ onClick: () => void; active: boolean; }) { - const quotaLabel = phase.isConsumption ? "Verzehr" : "Sparquote"; - const quotaTarget = Math.abs(phase.quota); - const quotaPct = quotaTarget > 0 ? Math.round((phase.quotaAllocated / quotaTarget) * 100) : 100; - const distributedWord = phase.isConsumption ? "gedeckt" : "verteilt"; + const quotaLabel = phase.isConsumption ? "Verzehr" : "Quote"; return (
{phase.name} - {phase.incomplete ? ( + {phase.cashNegative ? ( ) : ( @@ -639,20 +666,18 @@ function PhaseHeader({ {personLabel(p.role)} {p.startAge} → {p.endAge}
))} -
Einkommen {formatChf(phase.incomeTotal)}
-
Ausgaben {formatChf(phase.expenseTotal)}
-
- {quotaLabel} {formatChf(quotaTarget)} ({quotaPct}% {distributedWord}) +
Einkommen {formatChf(phase.incomeStart)} → {formatChf(phase.incomeEnd)}
+
Ausgaben {formatChf(phase.expenseStart)} → {formatChf(phase.expenseEnd)}
+
+ {quotaLabel} {formatChf(phase.quotaStart)} → {formatChf(phase.quotaEnd)} +
+
+ Cash {formatChf(phase.cashStart)} → {formatChf(phase.cashEnd)}
Vermoegen {formatChf(phase.startWealthNominal)} → {formatChf(phase.endWealthNominal)}
-
- Kapital {phase.availableCapital === null ? "n.a." : formatChf(phase.availableCapital)} - {phase.availableCapital !== null && !phase.availableCapitalComplete && ( - · offen {formatChf(Math.max(0, phase.availableCapitalRemaining))} - )} -
+
real {formatChf(phase.endWealthReal)}
); @@ -745,8 +770,7 @@ function AddElementDialog({ carriedEndValue: 0, carried: false, derivedStart: 0, - quotaRateMax: Math.max(0, Math.abs(firstPhase.quota) - firstPhase.quotaAllocated), - capitalMax: firstPhase.availableCapital == null ? undefined : Math.max(0, firstPhase.availableCapital - firstPhase.availableCapitalUsed), + phaseInflation: plan.phases.find((p) => p.id === firstPhase.id)?.inflationRate ?? plan.inflationRateDefault, }; async function create() { diff --git a/src/components/Timeline.tsx b/src/components/Timeline.tsx index 9339343..c616002 100644 --- a/src/components/Timeline.tsx +++ b/src/components/Timeline.tsx @@ -13,7 +13,15 @@ interface PersonAxis { // Horizontale Zeitachse: Alter von links nach rechts, mit deutlich markiertem // Pensionsalter je Person und Trennlinien an den Phasengrenzen. -export function Timeline({ phases, persons }: { phases: PhaseComputed[]; persons: PersonAxis[] }) { +export function Timeline({ + phases, + persons, + ruinAge, +}: { + phases: PhaseComputed[]; + persons: PersonAxis[]; + ruinAge?: number | null; +}) { if (phases.length === 0 || persons.length === 0) return null; const totalYears = phases.reduce((s, p) => s + p.durationYears, 0); @@ -64,6 +72,20 @@ export function Timeline({ phases, persons }: { phases: PhaseComputed[]; persons ) : null )} + {/* Ruin-Marker (Person A) */} + {ruinAge != null && ruinAge > minAge && ruinAge <= maxAge && ( +
+ + Ruin {ruinAge} + +
+
+ )} + {/* Achse */}
{boundaries.slice(1).map((b) => ( diff --git a/src/lib/calculations.test.ts b/src/lib/calculations.test.ts new file mode 100644 index 0000000..587d118 --- /dev/null +++ b/src/lib/calculations.test.ts @@ -0,0 +1,137 @@ +import { describe, it, expect } from "vitest"; +import { computePlan } from "@/lib/calculations"; +import type { ElementCategory, PhaseData, TransitionData } from "@/lib/elements"; +import type { PlanInput } from "@/lib/types"; + +// --- kleine Bau-Helfer --- +let idc = 0; +const nid = () => `id${idc++}`; + +function el( + category: ElementCategory, + ownerRole: string | null, + phaseValues: Record, + transitionValues: Record = {} +) { + return { id: nid(), category, name: category, ownerRole: ownerRole as never, orderIndex: idc, phaseValues, transitionValues }; +} + +function plan(opts: { + age: number; + retirementAge: number; + inflation?: number; + phases: { id: string; durationYears: number }[]; + elements: ReturnType[]; + household?: "SINGLE" | "COUPLE"; +}): PlanInput { + return { + id: "plan", + name: "T", + householdType: opts.household ?? "SINGLE", + inflationRateDefault: opts.inflation ?? 2, + persons: [{ id: "A", role: "PERSON_A", name: null, age: opts.age, retirementAge: opts.retirementAge }], + phases: opts.phases.map((p, i) => ({ id: p.id, sequenceNumber: i + 1, name: p.id, durationYears: p.durationYears, inflationRate: null })), + elements: opts.elements, + }; +} + +const within = (actual: number, expected: number, pct: number) => Math.abs(actual - expected) <= Math.abs(expected) * pct; + +describe("V4 Golden Tests", () => { + it("Test 1 – Ansparphase, Flows wachsen (Cash 0%)", () => { + const p = plan({ + age: 40, + retirementAge: 60, + phases: [{ id: "p1", durationYears: 15 }], + elements: [ + el("INCOME", "PERSON_A", { p1: { amount: 120000, teuerungsausgleich: 2 } }), + el("EXPENSE", "HOUSEHOLD", { p1: { amount: 100000, teuerungsausgleich: 2 } }), + el("OTHER_ASSET", "HOUSEHOLD", { p1: { startValue: 200000, expectedReturn: 5, annualContribution: 0 } }), + ], + }); + const r = computePlan(p); + const ph = r.phases[0]; + expect(within(ph.endWealthNominal, 761654, 0.01)).toBe(true); + expect(within(ph.endWealthReal, 565928, 0.01)).toBe(true); + expect(ph.cashNegative).toBe(false); + }); + + it("Test 2 – Verzehr/Ruin: Gesamtvermoegen kippt, Ruin Alter 94", () => { + const p = plan({ + age: 65, + retirementAge: 65, + phases: [{ id: "p1", durationYears: 35 }], + elements: [ + el("INCOME", "PERSON_A", { p1: { amount: 60000, teuerungsausgleich: 0 } }), // Rente nominal fix + el("EXPENSE", "HOUSEHOLD", { p1: { amount: 100000, teuerungsausgleich: 2 } }), + el("OTHER_ASSET", "HOUSEHOLD", { p1: { startValue: 900000, expectedReturn: 3, annualContribution: 0 } }), + ], + }); + const r = computePlan(p); + expect(r.ruinAge).toBe(94); + }); + + it("Test 3 – Cash-Ausgleich, Rate 6'364 (unter Anfangsquote), Cash nie negativ", () => { + const p = plan({ + age: 40, + retirementAge: 60, + phases: [{ id: "p1", durationYears: 3 }], + elements: [ + el("INCOME", "PERSON_A", { p1: { amount: 100000, teuerungsausgleich: 0 } }), + el("EXPENSE", "HOUSEHOLD", { p1: { amount: 90000, teuerungsausgleich: 2 } }), + el("OTHER_ASSET", "HOUSEHOLD", { p1: { startValue: 0, expectedReturn: 0, annualContribution: 6364 } }), + ], + }); + const ph = computePlan(p).phases[0]; + expect(ph.cashEnd).toBe(5472); + expect(ph.cashNegative).toBe(false); + }); + + it("Test 4 – Cash-Ausgleich, Rate 10'000 (ueber Endquote), Cash wird negativ", () => { + const p = plan({ + age: 40, + retirementAge: 60, + phases: [{ id: "p1", durationYears: 3 }], + elements: [ + el("INCOME", "PERSON_A", { p1: { amount: 100000, teuerungsausgleich: 0 } }), + el("EXPENSE", "HOUSEHOLD", { p1: { amount: 90000, teuerungsausgleich: 2 } }), + el("OTHER_ASSET", "HOUSEHOLD", { p1: { startValue: 0, expectedReturn: 0, annualContribution: 10000 } }), + ], + }); + const ph = computePlan(p).phases[0]; + expect(ph.cashEnd).toBe(-5436); + expect(ph.cashNegative).toBe(true); + }); + + it("indexRate 0 -> Einkommen bleibt nominal flach", () => { + const p = plan({ + age: 40, + retirementAge: 60, + phases: [{ id: "p1", durationYears: 10 }], + elements: [el("INCOME", "PERSON_A", { p1: { amount: 80000, teuerungsausgleich: 0 } })], + }); + const ph = computePlan(p).phases[0]; + expect(ph.incomeStart).toBe(80000); + expect(ph.incomeEnd).toBe(80000); + }); + + it("Phasen-Fortschreibung: indexierter Endwert Phase 1 = Startwert Phase 2; Cash laeuft fort", () => { + const p = plan({ + age: 40, + retirementAge: 70, + phases: [ + { id: "p1", durationYears: 5 }, + { id: "p2", durationYears: 5 }, + ], + elements: [ + el("INCOME", "PERSON_A", { p1: { amount: 100000, teuerungsausgleich: 2 }, p2: {} }), + el("EXPENSE", "HOUSEHOLD", { p1: { amount: 80000, teuerungsausgleich: 2 }, p2: {} }), + ], + }); + const r = computePlan(p); + const expectedBasis = Math.round(100000 * Math.pow(1.02, 5)); + const incomeP2 = r.phases[1].elements.find((e) => e.category === "INCOME")!; + expect(incomeP2.startValue).toBe(expectedBasis); + expect(r.phases[1].cashStart).toBe(r.phases[0].cashEnd); + }); +}); diff --git a/src/lib/calculations.ts b/src/lib/calculations.ts index 2ea96f3..4b35589 100644 --- a/src/lib/calculations.ts +++ b/src/lib/calculations.ts @@ -19,7 +19,6 @@ export interface PersonPhaseInfo { startAge: number; endAge: number; working: boolean; - // Wird diese Person genau zu Beginn dieser Phase pensioniert (erste Pensionsphase)? retiresAtStart: boolean; } @@ -29,16 +28,13 @@ export interface ElementPhaseComputed { name: string; ownerRole: string | null; status: ElementStatus; - locked: boolean; // verkauft/getilgt -> in dieser Phase nicht mehr editierbar - carried: boolean; // Startwert wird aus der Vorphase fortgeschrieben (Phase >= 2) - startValue: number; // Netto-Wert zu Phasenbeginn (Aktiven +, Schulden -) - endValue: number; // Netto-Wert am Phasenende - incomeContribution: number; // Beitrag zum Phasen-Einkommen - expenseContribution: number; // Beitrag zu den Phasen-Ausgaben - quotaUse: number; // Betrag, der Spar-/Verzehrquote verbraucht (3a/Vermoegen/Amort./Tilgung) - capitalUse: number; // verbrauchtes verfuegbares Startkapital (Aufstockung/Neuinvestition) - summary: string; // Kennzahl fuer die eingeklappte Zelle - note: string | null; // z. B. "Verkauft", "Getilgt", "Vollstaendig bezogen" + locked: boolean; + carried: boolean; // Phase >= 2: Start-/Basiswert wird aus der Vorphase fortgeschrieben + baseValue: number; // fortgeschriebener Basiswert (read-only Anzeige ab Phase 2; ohne Zusatzeinlage) + startValue: number; // Wert/Flow zu Phasenbeginn (Aktiven +, Schulden -, Einkommen/Ausgaben = Flow Jahr 1) + endValue: number; // Wert/Flow am Phasenende (letztes Jahr) + summary: string; + note: string | null; } export interface PhaseComputed { @@ -48,22 +44,24 @@ export interface PhaseComputed { durationYears: number; type: PhaseType; persons: PersonPhaseInfo[]; - maxDurationYears: number | null; // Kappung ans naechste Pensionsereignis (null = unbegrenzt) - incomeTotal: number; - expenseTotal: number; - quota: number; // Einkommen - Ausgaben (Sparquote wenn >=0, Verzehrquote wenn <0) + maxDurationYears: number | null; + // Einkommen/Ausgaben als indexierte Flows: Wert im ersten und im letzten Phasenjahr. + incomeStart: number; + incomeEnd: number; + expenseStart: number; + expenseEnd: number; + // Spar-/Verzehrquote zu Phasenbeginn (Jahr 1) und Phasenende (letztes Jahr). + quotaStart: number; + quotaEnd: number; isConsumption: boolean; - quotaAllocated: number; // verteilte Sparbeitraege bzw. gedeckte Verzehr-Bezuege - quotaRemaining: number; // |quota| - quotaAllocated (offener Rest, kann negativ = ueberzogen) - quotaComplete: boolean; - availableCapital: number | null; // null in der ersten Phase - availableCapitalUsed: number; - availableCapitalRemaining: number; // 0 in der ersten Phase - availableCapitalComplete: boolean; - incomplete: boolean; // roter Status (Quote/Kapital nicht vollstaendig verteilt) + plannedRatesTotal: number; // Summe der geplanten flachen Jahresraten (3a, Vermoegen, Amort., Tilgung) + cashStart: number; + cashEnd: number; + cashNegative: boolean; // Cash faellt in dieser Phase (irgendwann) unter 0 -> Liquiditaetsluecke + incomplete: boolean; // roter Status = Liquiditaetsluecke elements: ElementPhaseComputed[]; - startWealthNominal: number; - endWealthNominal: number; + startWealthNominal: number; // inkl. Cash + endWealthNominal: number; // inkl. Cash cumulativeInflationEnd: number; endWealthReal: number; } @@ -71,10 +69,10 @@ export interface PhaseComputed { export interface PlanComputed { phases: PhaseComputed[]; nachlass: number; + ruinAge: number | null; // Alter (Person A), in dem das Gesamtvermoegen (inkl. Cash) erstmals < 0 faellt } -// Maximale Dauer einer neuen Phase, die yearsBefore Jahre nach Planbeginn startet: -// bis zum naechsten Pensionsereignis einer noch erwerbenden Person (null = unbegrenzt). +// Maximale Dauer einer neuen Phase bis zum naechsten Pensionsereignis (null = unbegrenzt). export function maxPhaseDuration( persons: { role: PersonRole; age: number; retirementAge: number }[], yearsBefore: number @@ -87,81 +85,78 @@ export function maxPhaseDuration( return caps.length > 0 ? Math.min(...caps) : null; } -// Interner Zustand, der pro Element von Phase zu Phase weitergetragen wird. interface Carry { status: ElementStatus; value: number; // Aktiven-Saldo (PK/3a/Sonstiges Vermoegen) am Ende der Vorphase mortgage: number; // Immobilie: Resthypothek owed: number; // Schulden: Restschuld (positiv) pkPensionAnnual: number; // PK: jaehrliche Rente nach Verrentung - hasCarry: boolean; // gab es eine Vorphase mit diesem Element? + flowBasis: number; // Einkommen/Ausgaben: indexierter Basiswert der naechsten Phase + hasCarry: boolean; } function emptyCarry(): Carry { - return { status: "ACTIVE", value: 0, mortgage: 0, owed: 0, pkPensionAnnual: 0, hasCarry: false }; + return { status: "ACTIVE", value: 0, mortgage: 0, owed: 0, pkPensionAnnual: 0, flowBasis: 0, hasCarry: false }; } -// Zinseszins mit jaehrlichem Beitrag; kein Zwischen-Runden mehr (1'000er-Konzept entfernt), -// nur das Endresultat wird auf ganze Franken gerundet. -function growAsset(startValue: number, expectedReturn: number, annual: number, years: number): number { - let v = startValue; - for (let y = 0; y < years; y++) { - v = v * (1 + expectedReturn / 100) + annual; - } - return Math.max(0, Math.round(v)); +function fmt(v: number): string { + const rounded = Math.round(v || 0); + const sign = rounded < 0 ? "-" : ""; + return sign + Math.abs(rounded).toString().replace(/\B(?=(\d{3})+(?!\d))/g, "'"); +} + +function personByRole(persons: { id: string; role: PersonRole }[], role: string) { + return persons.find((p) => p.role === role) ?? null; } export function computePlan(plan: PlanInput): PlanComputed { const phases = [...plan.phases].sort((a, b) => a.sequenceNumber - b.sequenceNumber); const persons = plan.persons; + const personA = persons.find((p) => p.role === "PERSON_A") ?? persons[0]; - // Pensionsalter je Person (liegt direkt am plan-eigenen Personensatz). const retirementAge = new Map(); for (const p of persons) retirementAge.set(p.id, p.retirementAge); - // Kumulierte AHV-Ausfalljahre je Person (ueber die Erwerbsphasen aufsummiert). const gapYearsByPerson = new Map(); - // Carry-Zustand je Element. const carries = new Map(); for (const e of plan.elements) carries.set(e.id, emptyCarry()); const result: PhaseComputed[] = []; let yearsBefore = 0; let cumulativeInflation = 1; - let incomingCapital: number | null = null; // in die aktuelle Phase einfliessendes Startkapital + let cashCarryIn = 0; + let ruinAge: number | null = null; for (let i = 0; i < phases.length; i++) { const phase = phases[i]; const nextPhase = phases[i + 1]; + const isFirstPhase = i === 0; + const duration = Math.max(1, phase.durationYears); + const phaseInflation = phase.inflationRate ?? plan.inflationRateDefault; - // --- Personen-Status in dieser Phase --- const personInfos: PersonPhaseInfo[] = persons.map((p) => { const ra = retirementAge.get(p.id)!; const startAge = p.age + yearsBefore; - const working = startAge < ra; return { personId: p.id, role: p.role, startAge, - endAge: startAge + phase.durationYears, - working, + endAge: startAge + duration, + working: startAge < ra, retiresAtStart: startAge === ra, }; }); const anyWorking = personInfos.some((p) => p.working); const anyRetired = personInfos.some((p) => !p.working); const type: PhaseType = anyWorking && anyRetired ? "MIXED" : anyWorking ? "ERWERB" : "PENSION"; - - // Maximale Dauer: bis zum naechsten Pensionsereignis einer noch erwerbenden Person. const capsFromWorking = personInfos .filter((p) => p.working) .map((p) => retirementAge.get(p.personId)! - p.startAge) .filter((d) => d > 0); const maxDurationYears = capsFromWorking.length > 0 ? Math.min(...capsFromWorking) : null; - const workingByPerson = new Map(personInfos.map((p) => [p.personId, p.working])); - // --- Ausfalljahre der Erwerbsphasen aufsummieren --- + // Ausfalljahre kumulieren + AHV-Renten (mit Plafonierung). for (const e of plan.elements) { if (e.category !== "AHV" || !e.ownerRole) continue; const owner = personByRole(persons, e.ownerRole); @@ -169,13 +164,11 @@ export function computePlan(plan: PlanInput): PlanComputed { const gy = Math.max(0, Math.round(num(e.phaseValues[phase.id]?.gapYears))); gapYearsByPerson.set(owner.id, (gapYearsByPerson.get(owner.id) ?? 0) + gy); } - - // --- AHV-Renten je pensionierter Person (mit Plafonierung) --- const ahvUncapped = new Map(); for (const e of plan.elements) { if (e.category !== "AHV" || !e.ownerRole) continue; const owner = personByRole(persons, e.ownerRole); - if (!owner || workingByPerson.get(owner.id)) continue; // nur pensionierte Personen + if (!owner || workingByPerson.get(owner.id)) continue; const gap = gapYearsByPerson.get(owner.id) ?? 0; const factor = Math.max(0, (AHV_FULL_CONTRIBUTION_YEARS - gap) / AHV_FULL_CONTRIBUTION_YEARS); ahvUncapped.set(owner.id, Math.round(AHV_MAX_ANNUAL_SINGLE * factor)); @@ -184,47 +177,22 @@ export function computePlan(plan: PlanInput): PlanComputed { if (plan.householdType === "COUPLE" && ahvUncapped.size === 2) { const sum = [...ahvUncapped.values()].reduce((a, b) => a + b, 0); const cap = AHV_MAX_ANNUAL_SINGLE * AHV_COUPLE_CAP_FACTOR; - if (sum > cap && sum > 0) { - for (const [pid, v] of ahvUncapped) ahvFinal.set(pid, Math.round(v * (cap / sum))); - } + if (sum > cap && sum > 0) for (const [pid, v] of ahvUncapped) ahvFinal.set(pid, Math.round(v * (cap / sum))); } - // --- Elemente dieser Phase berechnen --- + // --- Element-Laufzeitzustaende aufbauen --- const orderedElements = [...plan.elements].sort((a, b) => a.orderIndex - b.orderIndex); - - // --- Vorlauf: Quoten-Vorzeichen bestimmen (Einkommen inkl. Renten minus Ausgaben) --- - // Wird VOR der Element-Schleife gebraucht, damit sonstiges Vermoegen in Verzehrphasen - // die Rate abzieht (Bezug) statt sie zu addieren (Sparen). - let preIncome = 0; - let preExpense = 0; - for (const e of orderedElements) { - const carry = carries.get(e.id)!; - if (carry.status !== "ACTIVE") continue; - const pd = e.phaseValues[phase.id] ?? {}; - const owner = e.ownerRole && e.ownerRole !== "HOUSEHOLD" ? personByRole(persons, e.ownerRole) : null; - const ownerWorking = owner ? workingByPerson.get(owner.id) ?? false : anyWorking; - switch (e.category) { - case "INCOME": - preIncome += Math.round(num(pd.amount)); - break; - case "EXPENSE": - preExpense += Math.round(num(pd.amount)); - break; - case "AHV": - if (owner && !ownerWorking) preIncome += ahvFinal.get(owner.id) ?? 0; - break; - case "PENSION_FUND": - if (!ownerWorking && carry.pkPensionAnnual > 0) preIncome += carry.pkPensionAnnual; - break; - } - } - const isConsumptionPhase = preIncome - preExpense < 0; - - const elementsComputed: ElementPhaseComputed[] = []; - let incomeTotal = 0; - let expenseTotal = 0; - let quotaAllocated = 0; - let capitalUsed = 0; + const ecById = new Map(); + const incomes: { basis: number; idx: number; ec: ElementPhaseComputed }[] = []; + const expenses: { basis: number; idx: number; ec: ElementPhaseComputed }[] = []; + let renteTotal = 0; // AHV + PK-Renten (nominal fix) + const assets: { value: number; rate: number; r: number; ec: ElementPhaseComputed }[] = []; + const realEstates: { purchase: number; mortgageStart: number; amort: number; ec: ElementPhaseComputed }[] = []; + const debts: { owedStart: number; repay: number; ec: ElementPhaseComputed }[] = []; + let plannedRatesTotal = 0; // R: 3a + Sonstiges Vermoegen + Amortisation + Tilgung + let investmentsFromCash = 0; // Neuinvestitionen/Aufstockungen (ab Phase 2, aus Cash) + let wealthStart = 0; + let wealthEnd = 0; // wird nach der Jahresschleife gefuellt for (const e of orderedElements) { const carry = carries.get(e.id)!; @@ -240,50 +208,44 @@ export function computePlan(plan: PlanInput): PlanComputed { status: carry.status, locked: carry.status !== "ACTIVE", carried: carry.hasCarry, + baseValue: 0, startValue: 0, endValue: 0, - incomeContribution: 0, - expenseContribution: 0, - quotaUse: 0, - capitalUse: 0, summary: "", note: null, }; + ecById.set(e.id, ec); if (carry.status === "SOLD") { ec.note = "Verkauft"; ec.summary = "Verkauft"; - elementsComputed.push(ec); continue; } if (carry.status === "SETTLED" && e.category === "OTHER_DEBT") { ec.note = "Getilgt"; ec.summary = "Getilgt"; - elementsComputed.push(ec); continue; } switch (e.category) { - case "INCOME": { - const amount = Math.round(num(pd.amount)); - ec.incomeContribution = amount; - incomeTotal += amount; - ec.summary = fmt(amount); - break; - } + case "INCOME": case "EXPENSE": { - const amount = Math.round(num(pd.amount)); - ec.expenseContribution = amount; - expenseTotal += amount; - ec.summary = fmt(amount); + const idx = num(pd.teuerungsausgleich, phaseInflation); + ec.baseValue = carry.hasCarry ? Math.round(carry.flowBasis) : Math.round(num(pd.amount)); + let basis: number; + if (!carry.hasCarry) basis = Math.round(num(pd.amount)); + else if (typeof pd.amountOverride === "number") basis = Math.round(pd.amountOverride); + else basis = ec.baseValue; + (e.category === "INCOME" ? incomes : expenses).push({ basis, idx, ec }); break; } case "AHV": { if (owner && !ownerWorking) { - const pension = ahvFinal.get(owner.id) ?? 0; - ec.incomeContribution = pension; - incomeTotal += pension; - ec.summary = `Rente ${fmt(pension)}`; + const rente = ahvFinal.get(owner.id) ?? 0; + renteTotal += rente; + ec.startValue = rente; + ec.endValue = rente; + ec.summary = `Rente ${fmt(rente)}`; } else { const gap = Math.max(0, Math.round(num(pd.gapYears))); ec.summary = gap > 0 ? `${gap} Ausfalljahre` : "Keine Ausfalljahre"; @@ -292,11 +254,10 @@ export function computePlan(plan: PlanInput): PlanComputed { } case "PENSION_FUND": { if (!ownerWorking && carry.pkPensionAnnual > 0) { - // Verrentetes PK-Kapital: jaehrliche Rente als Einkommen. - const pension = carry.pkPensionAnnual; - ec.incomeContribution = pension; - incomeTotal += pension; - ec.summary = `Rente ${fmt(pension)}`; + renteTotal += carry.pkPensionAnnual; + ec.startValue = carry.pkPensionAnnual; + ec.endValue = carry.pkPensionAnnual; + ec.summary = `Rente ${fmt(carry.pkPensionAnnual)}`; } else if (!ownerWorking) { ec.note = "Vollstaendig bezogen"; ec.summary = "Bezogen"; @@ -304,14 +265,12 @@ export function computePlan(plan: PlanInput): PlanComputed { const base = carry.hasCarry ? carry.value : Math.round(num(pd.currentValue)); const topUp = carry.hasCarry ? Math.round(num(pd.additionalInvestment)) : 0; const start = base + topUp; - const contribution = Math.round(num(pd.annualContribution)); - const r = num(pd.expectedReturn); + const rate = Math.round(num(pd.annualContribution)); // PK-Beitrag zaehlt NICHT zur Quote + if (!isFirstPhase) investmentsFromCash += topUp; + ec.baseValue = base; ec.startValue = start; - ec.endValue = growAsset(start, r, contribution, phase.durationYears); - ec.capitalUse = topUp; - capitalUsed += topUp; - // PK-Beitraege zaehlen NICHT gegen die Sparquote (in Ausgaben enthalten). - ec.summary = fmt(ec.endValue); + wealthStart += start; + assets.push({ value: start, rate, r: num(pd.expectedReturn), ec }); } break; } @@ -323,15 +282,13 @@ export function computePlan(plan: PlanInput): PlanComputed { const base = carry.hasCarry ? carry.value : Math.round(num(pd.currentValue)); const topUp = carry.hasCarry ? Math.round(num(pd.additionalInvestment)) : 0; const start = base + topUp; - const contribution = Math.round(num(pd.annualContribution)); - const r = num(pd.expectedReturn); + const rate = Math.round(num(pd.annualContribution)); + plannedRatesTotal += rate; + if (!isFirstPhase) investmentsFromCash += topUp; + ec.baseValue = base; ec.startValue = start; - ec.endValue = growAsset(start, r, contribution, phase.durationYears); - ec.capitalUse = topUp; - capitalUsed += topUp; - ec.quotaUse = contribution; // zaehlt gegen die Sparquote - quotaAllocated += contribution; - ec.summary = fmt(ec.endValue); + wealthStart += start; + assets.push({ value: start, rate, r: num(pd.expectedReturn), ec }); } break; } @@ -339,117 +296,163 @@ export function computePlan(plan: PlanInput): PlanComputed { const base = carry.hasCarry ? carry.value : Math.round(num(pd.startValue)); const topUp = carry.hasCarry ? Math.round(num(pd.additionalInvestment)) : 0; const start = base + topUp; - const contribution = Math.round(num(pd.annualContribution)); - const r = num(pd.expectedReturn); + const rate = Math.round(num(pd.annualContribution)); + plannedRatesTotal += rate; + if (!isFirstPhase) investmentsFromCash += topUp; + ec.baseValue = base; ec.startValue = start; - ec.capitalUse = topUp; - capitalUsed += topUp; - // In Erwerbsphasen Sparbeitrag (wird eingezahlt/waechst), in Verzehrphasen - // Bezugsrate (wird entnommen/mindert das Vermoegen). Beides zaehlt betragsmaessig - // gegen die Quote. - ec.quotaUse = contribution; - quotaAllocated += contribution; - const annual = isConsumptionPhase ? -contribution : contribution; - ec.endValue = growAsset(start, r, annual, phase.durationYears); - ec.summary = fmt(ec.endValue); + wealthStart += start; + assets.push({ value: start, rate, r: num(pd.expectedReturn), ec }); break; } case "REAL_ESTATE": { const purchase = Math.round(num(pd.purchasePrice)); const mortgageStart = carry.hasCarry ? carry.mortgage : Math.round(num(pd.mortgage)); const amort = Math.round(num(pd.amortization)); - const mortgageEnd = Math.max(0, mortgageStart - amort * phase.durationYears); - ec.startValue = purchase - mortgageStart; - ec.endValue = purchase - mortgageEnd; - if (!carry.hasCarry) { - ec.capitalUse = Math.max(0, purchase - mortgageStart); // Eigenkapital bei Neukauf - capitalUsed += ec.capitalUse; - } - // Amortisation ist quotenwirksam (jaehrlicher Budgetbetrag). - ec.quotaUse = amort; - quotaAllocated += amort; - carry.mortgage = mortgageEnd; // fuer Uebergang - ec.summary = fmt(ec.endValue); + plannedRatesTotal += amort; + const equity = purchase - mortgageStart; + if (!carry.hasCarry && !isFirstPhase) investmentsFromCash += Math.max(0, equity); + ec.baseValue = equity; + ec.startValue = equity; + wealthStart += equity; + realEstates.push({ purchase, mortgageStart, amort, ec }); break; } case "OTHER_DEBT": { const owedStart = carry.hasCarry ? carry.owed : Math.round(num(pd.startValue)); const repay = Math.round(num(pd.annualRepayment)); - const owedEnd = Math.max(0, owedStart - repay * phase.durationYears); + plannedRatesTotal += repay; + ec.baseValue = -owedStart; ec.startValue = -owedStart; - ec.endValue = -owedEnd; - carry.owed = owedEnd; - // Tilgung ist quotenwirksam (jaehrlicher Budgetbetrag). - ec.quotaUse = repay; - quotaAllocated += repay; - ec.summary = fmt(ec.endValue); - if (owedEnd === 0) ec.note = "Wird getilgt"; + wealthStart += -owedStart; + debts.push({ owedStart, repay, ec }); break; } } - - elementsComputed.push(ec); } - // --- Kern-Kennzahlen --- - const quota = incomeTotal - expenseTotal; - const isConsumption = quota < 0; - // Sparphase: alles verteilt, wenn quotaAllocated == quota. Verzehrphase: gedeckt, - // wenn Bezuege (quotaAllocated) den Fehlbetrag decken. - const quotaTarget = Math.abs(quota); - const quotaRemaining = quotaTarget - quotaAllocated; - const quotaComplete = Math.abs(quotaRemaining) < 1; + // --- Jahr-fuer-Jahr: indexierte Flows, Cash-Ausgleich, Verzinsung, Ruin --- + const cashStart = cashCarryIn; + let cash = cashCarryIn - (isFirstPhase ? 0 : investmentsFromCash); + let cashNegative = cash < 0; + let incomeStart = 0; + let incomeEnd = 0; + let expenseStart = 0; + let expenseEnd = 0; + let quotaStart = 0; + let quotaEnd = 0; - const availableCapital = incomingCapital; - const availableCapitalUsed = capitalUsed; - const availableCapitalRemaining = availableCapital === null ? 0 : availableCapital - availableCapitalUsed; - const availableCapitalComplete = - availableCapital === null || Math.abs(availableCapitalRemaining) < 1; + for (let t = 1; t <= duration; t++) { + let incomeFlow = renteTotal; + for (const inc of incomes) incomeFlow += inc.basis * Math.pow(1 + inc.idx / 100, t - 1); + let expenseFlow = 0; + for (const exp of expenses) expenseFlow += exp.basis * Math.pow(1 + exp.idx / 100, t - 1); + const quote = incomeFlow - expenseFlow; - const incomplete = !quotaComplete || !availableCapitalComplete; + if (t === 1) { + incomeStart = incomeFlow; + expenseStart = expenseFlow; + quotaStart = quote; + } + if (t === duration) { + incomeEnd = incomeFlow; + expenseEnd = expenseFlow; + quotaEnd = quote; + } - const inflationRate = phase.inflationRate ?? plan.inflationRateDefault; - cumulativeInflation = cumulativeInflation * (1 + inflationRate / 100); + cash += quote - plannedRatesTotal; + for (const a of assets) a.value = a.value * (1 + a.r / 100) + a.rate; + if (cash < 0) cashNegative = true; - const startWealthNominal = elementsComputed.reduce((s, ec) => s + ec.startValue, 0); - const endWealthNominal = elementsComputed.reduce((s, ec) => s + ec.endValue, 0); + // Gesamtvermoegen zum Jahresende t (fuer Ruin-Erkennung). + let total = cash; + for (const a of assets) total += a.value; + for (const re of realEstates) total += re.purchase - Math.max(0, re.mortgageStart - re.amort * t); + for (const d of debts) total += -Math.max(0, d.owedStart - d.repay * t); + if (ruinAge === null && total < 0) ruinAge = personA.age + yearsBefore + t; + } + + // Endwerte je Element setzen + Endvermoegen bilden. + for (const inc of incomes) { + inc.ec.startValue = Math.round(inc.basis); + inc.ec.endValue = Math.round(inc.basis * Math.pow(1 + inc.idx / 100, duration - 1)); + inc.ec.summary = fmt(inc.ec.startValue); + } + for (const exp of expenses) { + exp.ec.startValue = Math.round(exp.basis); + exp.ec.endValue = Math.round(exp.basis * Math.pow(1 + exp.idx / 100, duration - 1)); + exp.ec.summary = fmt(exp.ec.startValue); + } + for (const a of assets) { + a.ec.endValue = Math.round(a.value); + a.ec.summary = fmt(a.ec.endValue); + wealthEnd += a.ec.endValue; + } + for (const re of realEstates) { + const mortgageEnd = Math.max(0, re.mortgageStart - re.amort * duration); + re.ec.endValue = re.purchase - mortgageEnd; + re.ec.summary = fmt(re.ec.endValue); + wealthEnd += re.ec.endValue; + } + for (const d of debts) { + const owedEnd = Math.max(0, d.owedStart - d.repay * duration); + d.ec.endValue = -owedEnd; + d.ec.summary = fmt(d.ec.endValue); + wealthEnd += d.ec.endValue; + if (owedEnd === 0) d.ec.note = "Wird getilgt"; + } + + const cashEnd = Math.round(cash); + const startWealthNominal = Math.round(wealthStart + cashStart); + const endWealthNominal = Math.round(wealthEnd + cashEnd); + cumulativeInflation = cumulativeInflation * Math.pow(1 + phaseInflation / 100, duration); result.push({ id: phase.id, name: phase.name, sequenceNumber: phase.sequenceNumber, - durationYears: phase.durationYears, + durationYears: duration, type, persons: personInfos, maxDurationYears, - incomeTotal, - expenseTotal, - quota, - isConsumption, - quotaAllocated, - quotaRemaining, - quotaComplete, - availableCapital, - availableCapitalUsed, - availableCapitalRemaining, - availableCapitalComplete, - incomplete, - elements: elementsComputed, + incomeStart: Math.round(incomeStart), + incomeEnd: Math.round(incomeEnd), + expenseStart: Math.round(expenseStart), + expenseEnd: Math.round(expenseEnd), + quotaStart: Math.round(quotaStart), + quotaEnd: Math.round(quotaEnd), + isConsumption: quotaStart < 0, + plannedRatesTotal, + cashStart: Math.round(cashStart), + cashEnd, + cashNegative, + incomplete: cashNegative, + elements: orderedElements.map((e) => ecById.get(e.id)!), startWealthNominal, endWealthNominal, cumulativeInflationEnd: cumulativeInflation, endWealthReal: endWealthNominal / cumulativeInflation, }); - // --- Uebergang zur naechsten Phase: Carry aktualisieren + Startkapital berechnen --- + // --- Uebergang: Carry aktualisieren, Cash der Folgephase bilden --- let outgoing = 0; for (const e of orderedElements) { const carry = carries.get(e.id)!; - const ec = elementsComputed.find((x) => x.elementId === e.id)!; + const ec = ecById.get(e.id)!; + const pd = e.phaseValues[phase.id] ?? {}; const td = e.transitionValues[phase.id] ?? {}; const owner = e.ownerRole && e.ownerRole !== "HOUSEHOLD" ? personByRole(persons, e.ownerRole) : null; const ownerRetiresNext = - !!owner && !!nextPhase && workingByPerson.get(owner.id) === true && retiresInPhase(owner.id, persons, retirementAge, yearsBefore + phase.durationYears); + !!owner && !!nextPhase && workingByPerson.get(owner.id) === true && + retiresInPhase(owner.id, persons, retirementAge, yearsBefore + duration); + + // Einkommen/Ausgaben: indexierten Basiswert fortschreiben. + if (e.category === "INCOME" || e.category === "EXPENSE") { + const idx = num(pd.teuerungsausgleich, phaseInflation); + carry.flowBasis = ec.startValue * Math.pow(1 + idx / 100, duration); + carry.hasCarry = true; + continue; + } if (carry.status !== "ACTIVE") { carry.hasCarry = true; @@ -462,8 +465,7 @@ export function computePlan(plan: PlanInput): PlanComputed { const value = ec.endValue; const mode = td.payoutMode ?? "PENSION"; if (mode === "CAPITAL") { - const net = Math.round(value * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100)); - outgoing += net; + outgoing += Math.round(value * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100)); carry.value = 0; carry.pkPensionAnnual = 0; } else if (mode === "PENSION") { @@ -471,8 +473,7 @@ export function computePlan(plan: PlanInput): PlanComputed { carry.value = 0; } else { const capital = Math.min(value, Math.round(num(td.capitalAmount))); - const net = Math.round(capital * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100)); - outgoing += net; + outgoing += Math.round(capital * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100)); carry.pkPensionAnnual = Math.round(((value - capital) * num(td.conversionRate, DEFAULT_PK_CONVERSION_RATE)) / 100); carry.value = 0; } @@ -485,8 +486,7 @@ export function computePlan(plan: PlanInput): PlanComputed { } case "PILLAR_3A": { if (ownerRetiresNext) { - const net = Math.round(ec.endValue * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100)); - outgoing += net; + outgoing += Math.round(ec.endValue * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100)); carry.value = 0; } else { const withdrawal = Math.min(ec.endValue, Math.round(num(td.withdrawal))); @@ -505,22 +505,27 @@ export function computePlan(plan: PlanInput): PlanComputed { break; } case "REAL_ESTATE": { + // ec.endValue = Kaufpreis - Resthypothek am Phasenende -> Resthypothek zurueckrechnen. + const purchase = Math.round(num(pd.purchasePrice)); + const restMortgage = purchase - ec.endValue; if (td.decision === "SELL") { - const purchase = Math.round(num(e.phaseValues[phase.id]?.purchasePrice)); const salePrice = Math.round(num(td.salePrice)); const gain = Math.max(0, salePrice - purchase); const tax = gain * (num(td.saleTaxRate, DEFAULT_PROPERTY_GAINS_TAX_RATE) / 100); - outgoing += Math.round(salePrice - carry.mortgage - tax); + outgoing += Math.round(salePrice - restMortgage - tax); carry.status = "SOLD"; + } else { + carry.mortgage = restMortgage; } - // HOLD: carry.mortgage bereits gesetzt. break; } case "OTHER_DEBT": { + const owedEnd = -ec.endValue; + carry.owed = owedEnd; const immediate = Math.min(carry.owed, Math.round(num(td.immediateRepayment))); if (immediate > 0) { carry.owed = Math.max(0, carry.owed - immediate); - outgoing -= immediate; // sofortige Tilgung mindert das verfuegbare Kapital + outgoing -= immediate; } if (carry.owed === 0) carry.status = "SETTLED"; break; @@ -531,20 +536,14 @@ export function computePlan(plan: PlanInput): PlanComputed { carry.hasCarry = true; } - incomingCapital = nextPhase ? Math.round(outgoing) : null; - yearsBefore += phase.durationYears; + cashCarryIn = cashEnd + outgoing; + yearsBefore += duration; } const nachlass = result.length > 0 ? result[result.length - 1].endWealthNominal : 0; - return { phases: result, nachlass }; + return { phases: result, nachlass, ruinAge }; } -function personByRole(persons: { id: string; role: PersonRole }[], role: string) { - return persons.find((p) => p.role === role) ?? null; -} - -// Prueft, ob eine Person mit dem gegebenen Jahres-Offset (zu Beginn der Folgephase) pensioniert -// ist, obwohl sie in der Vorphase noch erwerbend war. function retiresInPhase( personId: string, persons: { id: string; role: PersonRole; age: number }[], @@ -553,15 +552,7 @@ function retiresInPhase( ): boolean { const p = persons.find((x) => x.id === personId); if (!p) return false; - const ra = retirementAge.get(personId)!; - const startAgeNext = p.age + yearsBeforeNext; - return startAgeNext >= ra; -} - -function fmt(v: number): string { - const rounded = Math.round(v || 0); - const sign = rounded < 0 ? "-" : ""; - return sign + Math.abs(rounded).toString().replace(/\B(?=(\d{3})+(?!\d))/g, "'"); + return p.age + yearsBeforeNext >= retirementAge.get(personId)!; } // CSV-Export (eine Zeile pro Lebensphase, Kernkennzahlen). @@ -570,10 +561,11 @@ export function planToCsv(plan: PlanInput, computed: PlanComputed): string { "Phase", "Typ", "Dauer", - "Einkommen", - "Ausgaben", - "Spar-/Verzehrquote", - "Verfuegbares Kapital", + "Einkommen (Beginn)", + "Ausgaben (Beginn)", + "Quote (Beginn)", + "Quote (Ende)", + "Cash (Ende)", "Endvermoegen (nominal)", "Endvermoegen (real)", ]; @@ -581,12 +573,14 @@ export function planToCsv(plan: PlanInput, computed: PlanComputed): string { p.name, p.type, String(p.durationYears), - p.incomeTotal.toFixed(0), - p.expenseTotal.toFixed(0), - p.quota.toFixed(0), - p.availableCapital === null ? "n.a." : p.availableCapital.toFixed(0), + p.incomeStart.toFixed(0), + p.expenseStart.toFixed(0), + p.quotaStart.toFixed(0), + p.quotaEnd.toFixed(0), + p.cashEnd.toFixed(0), p.endWealthNominal.toFixed(0), p.endWealthReal.toFixed(0), ]); + if (computed.ruinAge !== null) rows.push([`Ruin: Kapital aufgebraucht mit Alter ${computed.ruinAge}`]); return [header, ...rows].map((r) => r.join(";")).join("\n"); } diff --git a/src/lib/elements.ts b/src/lib/elements.ts index d149e58..306cbcb 100644 --- a/src/lib/elements.ts +++ b/src/lib/elements.ts @@ -52,6 +52,12 @@ export const CATEGORY_ORDER: ElementCategory[] = [ export interface PhaseData { // INCOME / EXPENSE amount?: number; + // INCOME / EXPENSE: jaehrlicher Teuerungsausgleich (%). Indexiert den Flow ueber die + // Phasenjahre (Jahr t = Basis x (1+idx)^(t-1)). Eigenes Feld je Element (Einkommen und + // Ausgaben unabhaengig). Default = Phaseninflation. + teuerungsausgleich?: number; + // INCOME / EXPENSE ab Phase 2: uebersteuert den fortgeschriebenen Basiswert dieser Phase. + amountOverride?: number; // AHV gapYears?: number; // PENSION_FUND / PILLAR_3A / OTHER_ASSET @@ -98,6 +104,8 @@ const nonNeg = z.number().min(0); export const phaseDataSchema = z .object({ amount: nonNeg.optional(), + teuerungsausgleich: z.number().min(-20).max(50).optional(), + amountOverride: nonNeg.optional(), gapYears: z.number().int().min(0).optional(), currentValue: nonNeg.optional(), startValue: nonNeg.optional(), diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..170e2c8 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "vitest/config"; +import path from "node:path"; + +export default defineConfig({ + resolve: { + alias: { "@": path.resolve(__dirname, "src") }, + }, + test: { + environment: "node", + include: ["src/**/*.test.ts"], + }, +});