V4-Rechenmodell: Flow-Indexierung, Cash-Ausgleichstopf, Ruin-Erkennung
Deploy App / deploy (push) Successful in 3m6s

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 09:24:13 +02:00
parent 9506f43b67
commit 5e17ce4afb
10 changed files with 1349 additions and 299 deletions
+828 -1
View File
@@ -32,7 +32,8 @@
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.2.10", "eslint-config-next": "16.2.10",
"tailwindcss": "^4", "tailwindcss": "^4",
"typescript": "^5" "typescript": "^5",
"vitest": "^4.1.10"
} }
}, },
"node_modules/@alloc/quick-lru": { "node_modules/@alloc/quick-lru": {
@@ -1363,6 +1364,16 @@
"node": ">=12.4.0" "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": { "node_modules/@prisma/adapter-pg": {
"version": "7.8.0", "version": "7.8.0",
"resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.8.0.tgz", "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": { "node_modules/@rtsao/scc": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@@ -2070,6 +2397,17 @@
"tslib": "^2.4.0" "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": { "node_modules/@types/d3-array": {
"version": "3.2.2", "version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
@@ -2133,6 +2471,13 @@
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT" "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": { "node_modules/@types/estree": {
"version": "1.0.9", "version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@@ -2848,6 +3193,119 @@
"win32" "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": { "node_modules/acorn": {
"version": "8.17.0", "version": "8.17.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
@@ -3081,6 +3539,16 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/ast-types-flow": {
"version": "0.0.8", "version": "0.0.8",
"resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz",
@@ -3343,6 +3811,16 @@
], ],
"license": "CC-BY-4.0" "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": { "node_modules/chalk": {
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -3986,6 +4464,13 @@
"node": ">= 0.4" "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": { "node_modules/es-object-atoms": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
@@ -4478,6 +4963,16 @@
"node": ">=4.0" "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": { "node_modules/esutils": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
@@ -4494,6 +4989,16 @@
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT" "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": { "node_modules/exsolve": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.0.tgz", "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.0.tgz",
@@ -4694,6 +5199,21 @@
"url": "https://github.com/sponsors/isaacs" "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": { "node_modules/function-bind": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -6491,6 +7011,20 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/ohash": {
"version": "2.0.11", "version": "2.0.11",
"resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz",
@@ -7204,6 +7738,40 @@
"node": ">=0.10.0" "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": { "node_modules/run-parallel": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@@ -7514,6 +8082,13 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/signal-exit": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
@@ -7560,6 +8135,13 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/std-env": {
"version": "3.10.0", "version": "3.10.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", "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==", "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT" "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": { "node_modules/tinyglobby": {
"version": "0.2.17", "version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -7841,6 +8440,16 @@
"url": "https://github.com/sponsors/jonschlinkert" "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": { "node_modules/to-regex-range": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -8177,6 +8786,207 @@
"d3-timer": "^3.0.1" "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": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -8281,6 +9091,23 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/word-wrap": {
"version": "1.2.5", "version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
+3 -1
View File
@@ -7,6 +7,7 @@
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "eslint", "lint": "eslint",
"test": "vitest run",
"postinstall": "prisma generate" "postinstall": "prisma generate"
}, },
"dependencies": { "dependencies": {
@@ -33,7 +34,8 @@
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.2.10", "eslint-config-next": "16.2.10",
"tailwindcss": "^4", "tailwindcss": "^4",
"typescript": "^5" "typescript": "^5",
"vitest": "^4.1.10"
}, },
"allowScripts": { "allowScripts": {
"@prisma/engines@7.8.0": true, "@prisma/engines@7.8.0": true,
+2 -1
View File
@@ -86,7 +86,8 @@ function buildCarryData(category: string, prev: PhaseData): PhaseData {
switch (category) { switch (category) {
case "INCOME": case "INCOME":
case "EXPENSE": 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": case "AHV":
return { gapYears: 0 }; return { gapYears: 0 };
case "PENSION_FUND": case "PENSION_FUND":
+48 -25
View File
@@ -22,10 +22,9 @@ export interface CellContext {
durationYears: number; durationYears: number;
isRetirementTransition: boolean; isRetirementTransition: boolean;
carriedEndValue: number; // Endwert des Elements in der (Vor-)Phase, fuer Bezugs-Maxima carriedEndValue: number; // Endwert des Elements in der (Vor-)Phase, fuer Bezugs-Maxima
carried: boolean; // Phase >= 2: Startwert wird aus der Vorphase fortgeschrieben carried: boolean; // Phase >= 2: Basiswert wird aus der Vorphase fortgeschrieben
derivedStart: number; // fortgeschriebener Basis-Startwert (read-only Anzeige) derivedStart: number; // fortgeschriebener Basiswert (read-only Anzeige)
quotaRateMax: number; // Max fuer eine Sparrate/Verzehrrate dieses Elements phaseInflation: number; // Default fuer Teuerungsausgleich
capitalMax?: number; // Max fuer Startkapital/Neuinvestition (undefined = kein Cap)
} }
interface Props { interface Props {
@@ -135,9 +134,38 @@ export function ElementPhaseFields({
const carried = context.carried; const carried = context.carried;
switch (element.category) { switch (element.category) {
case "INCOME": case "INCOME":
return <MoneyField label="Jahreseinkommen (CHF)" value={num(pd.amount)} onChange={(v) => setP({ amount: v })} />; case "EXPENSE": {
case "EXPENSE": const isIncome = element.category === "INCOME";
return <MoneyField label="Jahresausgaben (CHF)" value={num(pd.amount)} onChange={(v) => setP({ amount: v })} />; const teuerung = (
<NumberField
label="Teuerungsausgleich (%/Jahr)"
help="Indexiert den Betrag ueber die Phasenjahre (Jahr t = Basis x (1+Satz)^(t-1)). Default = Phaseninflation. 0% = nominal flach (real sinkend)."
step={0.1}
value={num(pd.teuerungsausgleich, context.phaseInflation)}
onChange={(v) => setP({ teuerungsausgleich: v })}
/>
);
if (carried) {
return (
<>
<DerivedField label="Basiswert (fortgeschrieben)" value={context.derivedStart} help="Indexierter Wert aus der Vorphase (Jahr 1 dieser Phase)." />
<MoneyField
label="Uebersteuern (optional, CHF)"
help="Setzt den Basiswert dieser Phase neu (z. B. Einkommensknick bei Teilzeit/Fruehpension). Leer = fortgeschrieben."
value={num(pd.amountOverride)}
onChange={(v) => setP({ amountOverride: v })}
/>
{teuerung}
</>
);
}
return (
<>
<MoneyField label={isIncome ? "Jahreseinkommen (CHF, erstes Jahr)" : "Jahresausgaben (CHF, erstes Jahr)"} value={num(pd.amount)} onChange={(v) => setP({ amount: v })} />
{teuerung}
</>
);
}
case "AHV": case "AHV":
if (!context.ownerWorking) { if (!context.ownerWorking) {
return ( return (
@@ -175,12 +203,12 @@ export function ElementPhaseFields({
label="Zusatzeinlage aus Kapital (CHF)" label="Zusatzeinlage aus Kapital (CHF)"
help="Aufstockung aus dem verfuegbaren Kapital dieser Phase." help="Aufstockung aus dem verfuegbaren Kapital dieser Phase."
value={num(pd.additionalInvestment)} value={num(pd.additionalInvestment)}
max={context.capitalMax}
onChange={(v) => setP({ additionalInvestment: v })} onChange={(v) => setP({ additionalInvestment: v })}
/> />
</> </>
) : ( ) : (
<MoneyField label="Aktueller PK-Wert (CHF)" value={num(pd.currentValue)} max={context.capitalMax} onChange={(v) => setP({ currentValue: v })} /> <MoneyField label="Aktueller PK-Wert (CHF)" value={num(pd.currentValue)} onChange={(v) => setP({ currentValue: v })} />
)} )}
<MoneyField <MoneyField
label="Jaehrliche Einzahlung (CHF)" label="Jaehrliche Einzahlung (CHF)"
@@ -204,18 +232,18 @@ export function ElementPhaseFields({
label="Zusatzeinlage aus Kapital (CHF)" label="Zusatzeinlage aus Kapital (CHF)"
help="Aufstockung aus dem verfuegbaren Kapital dieser Phase." help="Aufstockung aus dem verfuegbaren Kapital dieser Phase."
value={num(pd.additionalInvestment)} value={num(pd.additionalInvestment)}
max={context.capitalMax}
onChange={(v) => setP({ additionalInvestment: v })} onChange={(v) => setP({ additionalInvestment: v })}
/> />
</> </>
) : ( ) : (
<MoneyField label="Aktueller 3a-Wert (CHF)" value={num(pd.currentValue)} max={context.capitalMax} onChange={(v) => setP({ currentValue: v })} /> <MoneyField label="Aktueller 3a-Wert (CHF)" value={num(pd.currentValue)} onChange={(v) => setP({ currentValue: v })} />
)} )}
<MoneyField <MoneyField
label="Jaehrliche Einzahlung (CHF)" label="Jaehrliche Einzahlung (CHF)"
help={`Maximal CHF ${PILLAR_3A_MAX_ANNUAL.toLocaleString("de-CH")} (2026, mit PK) und hoechstens die Sparquote. Wird von der Sparquote abgezogen.`} help={`Maximal CHF ${PILLAR_3A_MAX_ANNUAL.toLocaleString("de-CH")} (2026, mit PK) und hoechstens die Sparquote. Wird von der Sparquote abgezogen.`}
value={num(pd.annualContribution)} value={num(pd.annualContribution)}
max={Math.min(PILLAR_3A_MAX_ANNUAL, context.quotaRateMax)} max={PILLAR_3A_MAX_ANNUAL}
onChange={(v) => setP({ annualContribution: v })} onChange={(v) => setP({ annualContribution: v })}
/> />
<NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={num(pd.expectedReturn)} onChange={(v) => setP({ expectedReturn: v })} /> <NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={num(pd.expectedReturn)} onChange={(v) => setP({ expectedReturn: v })} />
@@ -230,7 +258,7 @@ export function ElementPhaseFields({
label="Amortisation (CHF/Jahr)" label="Amortisation (CHF/Jahr)"
help="Jaehrliche Reduktion der Hypothek. Zaehlt gegen die Sparquote." help="Jaehrliche Reduktion der Hypothek. Zaehlt gegen die Sparquote."
value={num(pd.amortization)} value={num(pd.amortization)}
max={context.quotaRateMax}
onChange={(v) => setP({ amortization: v })} onChange={(v) => setP({ amortization: v })}
/> />
</> </>
@@ -238,13 +266,13 @@ export function ElementPhaseFields({
} }
return ( return (
<> <>
<MoneyField label="Kaufpreis (CHF)" value={num(pd.purchasePrice)} max={context.capitalMax != null ? context.capitalMax + num(pd.mortgage) : undefined} onChange={(v) => setP({ purchasePrice: v })} /> <MoneyField label="Kaufpreis (CHF)" value={num(pd.purchasePrice)} onChange={(v) => setP({ purchasePrice: v })} />
<MoneyField label="Hypothek (CHF)" value={num(pd.mortgage)} onChange={(v) => setP({ mortgage: v })} /> <MoneyField label="Hypothek (CHF)" value={num(pd.mortgage)} onChange={(v) => setP({ mortgage: v })} />
<MoneyField <MoneyField
label="Amortisation (CHF/Jahr)" label="Amortisation (CHF/Jahr)"
help="Jaehrliche Reduktion der Hypothek. Zaehlt gegen die Sparquote." help="Jaehrliche Reduktion der Hypothek. Zaehlt gegen die Sparquote."
value={num(pd.amortization)} value={num(pd.amortization)}
max={context.quotaRateMax}
onChange={(v) => setP({ amortization: v })} onChange={(v) => setP({ amortization: v })}
/> />
</> </>
@@ -259,23 +287,18 @@ export function ElementPhaseFields({
label="Zusatzinvestition aus Kapital (CHF)" label="Zusatzinvestition aus Kapital (CHF)"
help="Neuinvestition aus dem verfuegbaren Kapital dieser Phase." help="Neuinvestition aus dem verfuegbaren Kapital dieser Phase."
value={num(pd.additionalInvestment)} value={num(pd.additionalInvestment)}
max={context.capitalMax}
onChange={(v) => setP({ additionalInvestment: v })} onChange={(v) => setP({ additionalInvestment: v })}
/> />
</> </>
) : ( ) : (
<MoneyField label="Startwert (CHF)" value={num(pd.startValue)} max={context.capitalMax} onChange={(v) => setP({ startValue: v })} /> <MoneyField label="Startwert (CHF)" value={num(pd.startValue)} onChange={(v) => setP({ startValue: v })} />
)} )}
<NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={num(pd.expectedReturn)} onChange={(v) => setP({ expectedReturn: v })} /> <NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={num(pd.expectedReturn)} onChange={(v) => setP({ expectedReturn: v })} />
<MoneyField <MoneyField
label={context.isConsumption ? "Jaehrliche Bezugsrate (CHF)" : "Jaehrlicher Sparbeitrag (CHF)"} label="Jaehrlicher Sparbeitrag (CHF)"
help={ help="Flacher Jahresbetrag, der ins Vermoegen fliesst und vom Cash abgezogen wird. Zum Entnehmen im Alter das Element im Uebergang verkaufen."
context.isConsumption
? "In dieser Verzehrphase deckt dieser Betrag die Verzehrquote (max. die Verzehrquote)."
: "Wird von der Sparquote abgezogen (max. die Sparquote)."
}
value={num(pd.annualContribution)} value={num(pd.annualContribution)}
max={context.quotaRateMax}
onChange={(v) => setP({ annualContribution: v })} onChange={(v) => setP({ annualContribution: v })}
/> />
</> </>
@@ -292,7 +315,7 @@ export function ElementPhaseFields({
label="Jaehrliche Tilgung (CHF)" label="Jaehrliche Tilgung (CHF)"
help="Zaehlt gegen die Sparquote (max. die Sparquote)." help="Zaehlt gegen die Sparquote (max. die Sparquote)."
value={num(pd.annualRepayment)} value={num(pd.annualRepayment)}
max={context.quotaRateMax}
onChange={(v) => setP({ annualRepayment: v })} onChange={(v) => setP({ annualRepayment: v })}
/> />
</> </>
+57 -33
View File
@@ -141,20 +141,17 @@ export function PlanView({
return !!before?.working && !!after && !after.working; 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 { function buildPhaseContext(phase: PhaseComputed, element: ElementInput): CellContext {
const ce = computedElement(phase.id, element.id); const ce = computedElement(phase.id, element.id);
const ownerWorking = const ownerWorking =
element.ownerRole && element.ownerRole !== "HOUSEHOLD" element.ownerRole && element.ownerRole !== "HOUSEHOLD"
? phase.persons.find((p) => p.role === element.ownerRole)?.working ?? false ? phase.persons.find((p) => p.role === element.ownerRole)?.working ?? false
: phase.type !== "PENSION"; : 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 { return {
kind: "phase", kind: "phase",
phaseId: phase.id, phaseId: phase.id,
@@ -164,9 +161,8 @@ export function PlanView({
isRetirementTransition: false, isRetirementTransition: false,
carriedEndValue: ce?.endValue ?? 0, carriedEndValue: ce?.endValue ?? 0,
carried: ce?.carried ?? false, carried: ce?.carried ?? false,
derivedStart, derivedStart: ce?.baseValue ?? 0,
quotaRateMax, phaseInflation: phaseInflationFor(phase.id),
capitalMax,
}; };
} }
@@ -182,8 +178,7 @@ export function PlanView({
carriedEndValue: ce?.endValue ?? 0, carriedEndValue: ce?.endValue ?? 0,
carried: ce?.carried ?? false, carried: ce?.carried ?? false,
derivedStart: 0, derivedStart: 0,
quotaRateMax: 0, phaseInflation: phaseInflationFor(fromPhase.id),
capitalMax: undefined,
}; };
} }
@@ -242,7 +237,7 @@ export function PlanView({
return ( return (
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5">
<Timeline phases={computed.phases} persons={personAxes} /> <Timeline phases={computed.phases} persons={personAxes} ruinAge={computed.ruinAge} />
{/* Plan-Profil */} {/* Plan-Profil */}
<div className="flex flex-wrap items-center gap-3 rounded-xl border border-border bg-surface px-4 py-3 text-sm shadow-sm"> <div className="flex flex-wrap items-center gap-3 rounded-xl border border-border bg-surface px-4 py-3 text-sm shadow-sm">
@@ -294,6 +289,12 @@ export function PlanView({
</div> </div>
)} )}
{computed.ruinAge !== null && (
<div className="flex items-center gap-2 rounded-xl border border-danger bg-danger-soft px-4 py-2 text-sm font-medium text-danger">
<AlertCircle className="h-4 w-4 shrink-0" /> Kapital aufgebraucht mit Alter {computed.ruinAge} das Gesamtvermoegen (inkl. Cash) faellt danach unter 0.
</div>
)}
{/* Matrix */} {/* Matrix */}
{hasPhases && ( {hasPhases && (
<div className="overflow-x-auto rounded-xl border border-border bg-surface shadow-sm"> <div className="overflow-x-auto rounded-xl border border-border bg-surface shadow-sm">
@@ -323,6 +324,33 @@ export function PlanView({
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{/* Cash-Zeile (systemseitig, immer sichtbar, read-only). */}
<tr className="bg-surface-2/50">
<td className="sticky left-0 z-10 border-b border-r border-border bg-surface px-3 py-1.5">
<span className="flex items-center gap-1.5 text-xs font-semibold text-fg">
<Wallet className="h-4 w-4 text-accent" /> Cash
</span>
<span className="text-[10px] text-faint">verfuegbares Kapital</span>
</td>
{columns.map((col) =>
col.kind === "phase" ? (
<td
key={`cash-${col.phase.id}`}
className={`border-b border-r border-border px-2 py-1.5 text-center text-xs ${
col.phase.cashNegative ? "font-semibold text-danger" : "text-fg"
}`}
>
<span className="whitespace-nowrap">
{formatChf(col.phase.cashStart)} <span className="text-faint"></span> {formatChf(col.phase.cashEnd)}
</span>
</td>
) : (
<td key={`cash-t-${col.fromPhase.id}`} className="border-b border-r border-border px-2 py-1.5 text-center text-[11px] text-faint">
</td>
)
)}
</tr>
{CATEGORY_ORDER.map((cat) => { {CATEGORY_ORDER.map((cat) => {
const els = elementsByCategory.get(cat)!; const els = elementsByCategory.get(cat)!;
if (els.length === 0) return null; if (els.length === 0) return null;
@@ -584,10 +612,12 @@ export function PlanView({
} }
// Zellinhalt: Start- UND Zielwert fuer wertbehaftete Elemente, sonst die Kennzahl. // Zellinhalt: Start- UND Zielwert fuer wertbehaftete Elemente, sonst die Kennzahl.
const START_END_CATEGORIES: ElementCategory[] = [...VALUE_CATEGORIES, "INCOME", "EXPENSE"];
function phaseCellContent(ce: ReturnType<PhaseComputed["elements"]["find"]> | undefined): React.ReactNode { function phaseCellContent(ce: ReturnType<PhaseComputed["elements"]["find"]> | undefined): React.ReactNode {
if (!ce) return ""; if (!ce) return "";
if (ce.note) return ce.note; 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 ( return (
<span className="whitespace-nowrap"> <span className="whitespace-nowrap">
{formatChf(ce.startValue)} <span className="text-faint"></span> {formatChf(ce.endValue)} {formatChf(ce.startValue)} <span className="text-faint"></span> {formatChf(ce.endValue)}
@@ -608,20 +638,17 @@ function PhaseHeader({
onClick: () => void; onClick: () => void;
active: boolean; active: boolean;
}) { }) {
const quotaLabel = phase.isConsumption ? "Verzehr" : "Sparquote"; const quotaLabel = phase.isConsumption ? "Verzehr" : "Quote";
const quotaTarget = Math.abs(phase.quota);
const quotaPct = quotaTarget > 0 ? Math.round((phase.quotaAllocated / quotaTarget) * 100) : 100;
const distributedWord = phase.isConsumption ? "gedeckt" : "verteilt";
return ( return (
<th <th
onClick={onClick} onClick={onClick}
className={`min-w-40 cursor-pointer border-b border-r border-border px-2 py-2 text-left align-top ${ className={`min-w-44 cursor-pointer border-b border-r border-border px-2 py-2 text-left align-top ${
active ? "bg-accent-soft" : "bg-surface" active ? "bg-accent-soft" : "bg-surface"
}`} }`}
> >
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<span className="truncate text-xs font-semibold text-fg">{phase.name}</span> <span className="truncate text-xs font-semibold text-fg">{phase.name}</span>
{phase.incomplete ? ( {phase.cashNegative ? (
<AlertCircle className="h-3.5 w-3.5 shrink-0 text-danger" /> <AlertCircle className="h-3.5 w-3.5 shrink-0 text-danger" />
) : ( ) : (
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-success" /> <CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-success" />
@@ -639,20 +666,18 @@ function PhaseHeader({
{personLabel(p.role)} {p.startAge} {p.endAge} {personLabel(p.role)} {p.startAge} {p.endAge}
</div> </div>
))} ))}
<div>Einkommen {formatChf(phase.incomeTotal)}</div> <div>Einkommen {formatChf(phase.incomeStart)} {formatChf(phase.incomeEnd)}</div>
<div>Ausgaben {formatChf(phase.expenseTotal)}</div> <div>Ausgaben {formatChf(phase.expenseStart)} {formatChf(phase.expenseEnd)}</div>
<div className={phase.quotaComplete ? "text-success" : "text-danger"}> <div>
{quotaLabel} {formatChf(quotaTarget)} ({quotaPct}% {distributedWord}) {quotaLabel} {formatChf(phase.quotaStart)} {formatChf(phase.quotaEnd)}
</div>
<div className={phase.cashNegative ? "text-danger font-medium" : ""}>
Cash {formatChf(phase.cashStart)} {formatChf(phase.cashEnd)}
</div> </div>
<div> <div>
Vermoegen {formatChf(phase.startWealthNominal)} {formatChf(phase.endWealthNominal)} Vermoegen {formatChf(phase.startWealthNominal)} {formatChf(phase.endWealthNominal)}
</div> </div>
<div className={phase.availableCapitalComplete ? "" : "text-danger"}> <div className="text-faint">real {formatChf(phase.endWealthReal)}</div>
Kapital {phase.availableCapital === null ? "n.a." : formatChf(phase.availableCapital)}
{phase.availableCapital !== null && !phase.availableCapitalComplete && (
<span> · offen {formatChf(Math.max(0, phase.availableCapitalRemaining))}</span>
)}
</div>
</div> </div>
</th> </th>
); );
@@ -745,8 +770,7 @@ function AddElementDialog({
carriedEndValue: 0, carriedEndValue: 0,
carried: false, carried: false,
derivedStart: 0, derivedStart: 0,
quotaRateMax: Math.max(0, Math.abs(firstPhase.quota) - firstPhase.quotaAllocated), phaseInflation: plan.phases.find((p) => p.id === firstPhase.id)?.inflationRate ?? plan.inflationRateDefault,
capitalMax: firstPhase.availableCapital == null ? undefined : Math.max(0, firstPhase.availableCapital - firstPhase.availableCapitalUsed),
}; };
async function create() { async function create() {
+23 -1
View File
@@ -13,7 +13,15 @@ interface PersonAxis {
// Horizontale Zeitachse: Alter von links nach rechts, mit deutlich markiertem // Horizontale Zeitachse: Alter von links nach rechts, mit deutlich markiertem
// Pensionsalter je Person und Trennlinien an den Phasengrenzen. // 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; if (phases.length === 0 || persons.length === 0) return null;
const totalYears = phases.reduce((s, p) => s + p.durationYears, 0); const totalYears = phases.reduce((s, p) => s + p.durationYears, 0);
@@ -64,6 +72,20 @@ export function Timeline({ phases, persons }: { phases: PhaseComputed[]; persons
) : null ) : null
)} )}
{/* Ruin-Marker (Person A) */}
{ruinAge != null && ruinAge > minAge && ruinAge <= maxAge && (
<div
className="absolute top-0 flex -translate-x-1/2 flex-col items-center"
style={{ left: pct(ruinAge) }}
title={`Kapital aufgebraucht mit Alter ${ruinAge}`}
>
<span className="text-[10px] font-semibold" style={{ color: "var(--danger)" }}>
Ruin {ruinAge}
</span>
<div className="mt-0.5 h-4 w-px" style={{ backgroundColor: "var(--danger)" }} />
</div>
)}
{/* Achse */} {/* Achse */}
<div className="relative h-2 w-full rounded-full bg-gradient-to-r from-accent-soft to-accent"> <div className="relative h-2 w-full rounded-full bg-gradient-to-r from-accent-soft to-accent">
{boundaries.slice(1).map((b) => ( {boundaries.slice(1).map((b) => (
+137
View File
@@ -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<string, PhaseData>,
transitionValues: Record<string, TransitionData> = {}
) {
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<typeof el>[];
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);
});
});
+230 -236
View File
@@ -19,7 +19,6 @@ export interface PersonPhaseInfo {
startAge: number; startAge: number;
endAge: number; endAge: number;
working: boolean; working: boolean;
// Wird diese Person genau zu Beginn dieser Phase pensioniert (erste Pensionsphase)?
retiresAtStart: boolean; retiresAtStart: boolean;
} }
@@ -29,16 +28,13 @@ export interface ElementPhaseComputed {
name: string; name: string;
ownerRole: string | null; ownerRole: string | null;
status: ElementStatus; status: ElementStatus;
locked: boolean; // verkauft/getilgt -> in dieser Phase nicht mehr editierbar locked: boolean;
carried: boolean; // Startwert wird aus der Vorphase fortgeschrieben (Phase >= 2) carried: boolean; // Phase >= 2: Start-/Basiswert wird aus der Vorphase fortgeschrieben
startValue: number; // Netto-Wert zu Phasenbeginn (Aktiven +, Schulden -) baseValue: number; // fortgeschriebener Basiswert (read-only Anzeige ab Phase 2; ohne Zusatzeinlage)
endValue: number; // Netto-Wert am Phasenende startValue: number; // Wert/Flow zu Phasenbeginn (Aktiven +, Schulden -, Einkommen/Ausgaben = Flow Jahr 1)
incomeContribution: number; // Beitrag zum Phasen-Einkommen endValue: number; // Wert/Flow am Phasenende (letztes Jahr)
expenseContribution: number; // Beitrag zu den Phasen-Ausgaben summary: string;
quotaUse: number; // Betrag, der Spar-/Verzehrquote verbraucht (3a/Vermoegen/Amort./Tilgung) note: string | null;
capitalUse: number; // verbrauchtes verfuegbares Startkapital (Aufstockung/Neuinvestition)
summary: string; // Kennzahl fuer die eingeklappte Zelle
note: string | null; // z. B. "Verkauft", "Getilgt", "Vollstaendig bezogen"
} }
export interface PhaseComputed { export interface PhaseComputed {
@@ -48,22 +44,24 @@ export interface PhaseComputed {
durationYears: number; durationYears: number;
type: PhaseType; type: PhaseType;
persons: PersonPhaseInfo[]; persons: PersonPhaseInfo[];
maxDurationYears: number | null; // Kappung ans naechste Pensionsereignis (null = unbegrenzt) maxDurationYears: number | null;
incomeTotal: number; // Einkommen/Ausgaben als indexierte Flows: Wert im ersten und im letzten Phasenjahr.
expenseTotal: number; incomeStart: number;
quota: number; // Einkommen - Ausgaben (Sparquote wenn >=0, Verzehrquote wenn <0) incomeEnd: number;
expenseStart: number;
expenseEnd: number;
// Spar-/Verzehrquote zu Phasenbeginn (Jahr 1) und Phasenende (letztes Jahr).
quotaStart: number;
quotaEnd: number;
isConsumption: boolean; isConsumption: boolean;
quotaAllocated: number; // verteilte Sparbeitraege bzw. gedeckte Verzehr-Bezuege plannedRatesTotal: number; // Summe der geplanten flachen Jahresraten (3a, Vermoegen, Amort., Tilgung)
quotaRemaining: number; // |quota| - quotaAllocated (offener Rest, kann negativ = ueberzogen) cashStart: number;
quotaComplete: boolean; cashEnd: number;
availableCapital: number | null; // null in der ersten Phase cashNegative: boolean; // Cash faellt in dieser Phase (irgendwann) unter 0 -> Liquiditaetsluecke
availableCapitalUsed: number; incomplete: boolean; // roter Status = Liquiditaetsluecke
availableCapitalRemaining: number; // 0 in der ersten Phase
availableCapitalComplete: boolean;
incomplete: boolean; // roter Status (Quote/Kapital nicht vollstaendig verteilt)
elements: ElementPhaseComputed[]; elements: ElementPhaseComputed[];
startWealthNominal: number; startWealthNominal: number; // inkl. Cash
endWealthNominal: number; endWealthNominal: number; // inkl. Cash
cumulativeInflationEnd: number; cumulativeInflationEnd: number;
endWealthReal: number; endWealthReal: number;
} }
@@ -71,10 +69,10 @@ export interface PhaseComputed {
export interface PlanComputed { export interface PlanComputed {
phases: PhaseComputed[]; phases: PhaseComputed[];
nachlass: number; 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: // Maximale Dauer einer neuen Phase bis zum naechsten Pensionsereignis (null = unbegrenzt).
// bis zum naechsten Pensionsereignis einer noch erwerbenden Person (null = unbegrenzt).
export function maxPhaseDuration( export function maxPhaseDuration(
persons: { role: PersonRole; age: number; retirementAge: number }[], persons: { role: PersonRole; age: number; retirementAge: number }[],
yearsBefore: number yearsBefore: number
@@ -87,81 +85,78 @@ export function maxPhaseDuration(
return caps.length > 0 ? Math.min(...caps) : null; return caps.length > 0 ? Math.min(...caps) : null;
} }
// Interner Zustand, der pro Element von Phase zu Phase weitergetragen wird.
interface Carry { interface Carry {
status: ElementStatus; status: ElementStatus;
value: number; // Aktiven-Saldo (PK/3a/Sonstiges Vermoegen) am Ende der Vorphase value: number; // Aktiven-Saldo (PK/3a/Sonstiges Vermoegen) am Ende der Vorphase
mortgage: number; // Immobilie: Resthypothek mortgage: number; // Immobilie: Resthypothek
owed: number; // Schulden: Restschuld (positiv) owed: number; // Schulden: Restschuld (positiv)
pkPensionAnnual: number; // PK: jaehrliche Rente nach Verrentung 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 { 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), function fmt(v: number): string {
// nur das Endresultat wird auf ganze Franken gerundet. const rounded = Math.round(v || 0);
function growAsset(startValue: number, expectedReturn: number, annual: number, years: number): number { const sign = rounded < 0 ? "-" : "";
let v = startValue; return sign + Math.abs(rounded).toString().replace(/\B(?=(\d{3})+(?!\d))/g, "'");
for (let y = 0; y < years; y++) {
v = v * (1 + expectedReturn / 100) + annual;
} }
return Math.max(0, Math.round(v));
function personByRole(persons: { id: string; role: PersonRole }[], role: string) {
return persons.find((p) => p.role === role) ?? null;
} }
export function computePlan(plan: PlanInput): PlanComputed { export function computePlan(plan: PlanInput): PlanComputed {
const phases = [...plan.phases].sort((a, b) => a.sequenceNumber - b.sequenceNumber); const phases = [...plan.phases].sort((a, b) => a.sequenceNumber - b.sequenceNumber);
const persons = plan.persons; 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<string, number>(); const retirementAge = new Map<string, number>();
for (const p of persons) retirementAge.set(p.id, p.retirementAge); for (const p of persons) retirementAge.set(p.id, p.retirementAge);
// Kumulierte AHV-Ausfalljahre je Person (ueber die Erwerbsphasen aufsummiert).
const gapYearsByPerson = new Map<string, number>(); const gapYearsByPerson = new Map<string, number>();
// Carry-Zustand je Element.
const carries = new Map<string, Carry>(); const carries = new Map<string, Carry>();
for (const e of plan.elements) carries.set(e.id, emptyCarry()); for (const e of plan.elements) carries.set(e.id, emptyCarry());
const result: PhaseComputed[] = []; const result: PhaseComputed[] = [];
let yearsBefore = 0; let yearsBefore = 0;
let cumulativeInflation = 1; 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++) { for (let i = 0; i < phases.length; i++) {
const phase = phases[i]; const phase = phases[i];
const nextPhase = phases[i + 1]; 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 personInfos: PersonPhaseInfo[] = persons.map((p) => {
const ra = retirementAge.get(p.id)!; const ra = retirementAge.get(p.id)!;
const startAge = p.age + yearsBefore; const startAge = p.age + yearsBefore;
const working = startAge < ra;
return { return {
personId: p.id, personId: p.id,
role: p.role, role: p.role,
startAge, startAge,
endAge: startAge + phase.durationYears, endAge: startAge + duration,
working, working: startAge < ra,
retiresAtStart: startAge === ra, retiresAtStart: startAge === ra,
}; };
}); });
const anyWorking = personInfos.some((p) => p.working); const anyWorking = personInfos.some((p) => p.working);
const anyRetired = personInfos.some((p) => !p.working); const anyRetired = personInfos.some((p) => !p.working);
const type: PhaseType = anyWorking && anyRetired ? "MIXED" : anyWorking ? "ERWERB" : "PENSION"; const type: PhaseType = anyWorking && anyRetired ? "MIXED" : anyWorking ? "ERWERB" : "PENSION";
// Maximale Dauer: bis zum naechsten Pensionsereignis einer noch erwerbenden Person.
const capsFromWorking = personInfos const capsFromWorking = personInfos
.filter((p) => p.working) .filter((p) => p.working)
.map((p) => retirementAge.get(p.personId)! - p.startAge) .map((p) => retirementAge.get(p.personId)! - p.startAge)
.filter((d) => d > 0); .filter((d) => d > 0);
const maxDurationYears = capsFromWorking.length > 0 ? Math.min(...capsFromWorking) : null; const maxDurationYears = capsFromWorking.length > 0 ? Math.min(...capsFromWorking) : null;
const workingByPerson = new Map(personInfos.map((p) => [p.personId, p.working])); 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) { for (const e of plan.elements) {
if (e.category !== "AHV" || !e.ownerRole) continue; if (e.category !== "AHV" || !e.ownerRole) continue;
const owner = personByRole(persons, e.ownerRole); 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))); const gy = Math.max(0, Math.round(num(e.phaseValues[phase.id]?.gapYears)));
gapYearsByPerson.set(owner.id, (gapYearsByPerson.get(owner.id) ?? 0) + gy); gapYearsByPerson.set(owner.id, (gapYearsByPerson.get(owner.id) ?? 0) + gy);
} }
// --- AHV-Renten je pensionierter Person (mit Plafonierung) ---
const ahvUncapped = new Map<string, number>(); const ahvUncapped = new Map<string, number>();
for (const e of plan.elements) { for (const e of plan.elements) {
if (e.category !== "AHV" || !e.ownerRole) continue; if (e.category !== "AHV" || !e.ownerRole) continue;
const owner = personByRole(persons, e.ownerRole); 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 gap = gapYearsByPerson.get(owner.id) ?? 0;
const factor = Math.max(0, (AHV_FULL_CONTRIBUTION_YEARS - gap) / AHV_FULL_CONTRIBUTION_YEARS); 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)); 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) { if (plan.householdType === "COUPLE" && ahvUncapped.size === 2) {
const sum = [...ahvUncapped.values()].reduce((a, b) => a + b, 0); const sum = [...ahvUncapped.values()].reduce((a, b) => a + b, 0);
const cap = AHV_MAX_ANNUAL_SINGLE * AHV_COUPLE_CAP_FACTOR; const cap = AHV_MAX_ANNUAL_SINGLE * AHV_COUPLE_CAP_FACTOR;
if (sum > cap && sum > 0) { if (sum > cap && sum > 0) for (const [pid, v] of ahvUncapped) ahvFinal.set(pid, Math.round(v * (cap / sum)));
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); const orderedElements = [...plan.elements].sort((a, b) => a.orderIndex - b.orderIndex);
const ecById = new Map<string, ElementPhaseComputed>();
// --- Vorlauf: Quoten-Vorzeichen bestimmen (Einkommen inkl. Renten minus Ausgaben) --- const incomes: { basis: number; idx: number; ec: ElementPhaseComputed }[] = [];
// Wird VOR der Element-Schleife gebraucht, damit sonstiges Vermoegen in Verzehrphasen const expenses: { basis: number; idx: number; ec: ElementPhaseComputed }[] = [];
// die Rate abzieht (Bezug) statt sie zu addieren (Sparen). let renteTotal = 0; // AHV + PK-Renten (nominal fix)
let preIncome = 0; const assets: { value: number; rate: number; r: number; ec: ElementPhaseComputed }[] = [];
let preExpense = 0; const realEstates: { purchase: number; mortgageStart: number; amort: number; ec: ElementPhaseComputed }[] = [];
for (const e of orderedElements) { const debts: { owedStart: number; repay: number; ec: ElementPhaseComputed }[] = [];
const carry = carries.get(e.id)!; let plannedRatesTotal = 0; // R: 3a + Sonstiges Vermoegen + Amortisation + Tilgung
if (carry.status !== "ACTIVE") continue; let investmentsFromCash = 0; // Neuinvestitionen/Aufstockungen (ab Phase 2, aus Cash)
const pd = e.phaseValues[phase.id] ?? {}; let wealthStart = 0;
const owner = e.ownerRole && e.ownerRole !== "HOUSEHOLD" ? personByRole(persons, e.ownerRole) : null; let wealthEnd = 0; // wird nach der Jahresschleife gefuellt
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;
for (const e of orderedElements) { for (const e of orderedElements) {
const carry = carries.get(e.id)!; const carry = carries.get(e.id)!;
@@ -240,50 +208,44 @@ export function computePlan(plan: PlanInput): PlanComputed {
status: carry.status, status: carry.status,
locked: carry.status !== "ACTIVE", locked: carry.status !== "ACTIVE",
carried: carry.hasCarry, carried: carry.hasCarry,
baseValue: 0,
startValue: 0, startValue: 0,
endValue: 0, endValue: 0,
incomeContribution: 0,
expenseContribution: 0,
quotaUse: 0,
capitalUse: 0,
summary: "", summary: "",
note: null, note: null,
}; };
ecById.set(e.id, ec);
if (carry.status === "SOLD") { if (carry.status === "SOLD") {
ec.note = "Verkauft"; ec.note = "Verkauft";
ec.summary = "Verkauft"; ec.summary = "Verkauft";
elementsComputed.push(ec);
continue; continue;
} }
if (carry.status === "SETTLED" && e.category === "OTHER_DEBT") { if (carry.status === "SETTLED" && e.category === "OTHER_DEBT") {
ec.note = "Getilgt"; ec.note = "Getilgt";
ec.summary = "Getilgt"; ec.summary = "Getilgt";
elementsComputed.push(ec);
continue; continue;
} }
switch (e.category) { switch (e.category) {
case "INCOME": { case "INCOME":
const amount = Math.round(num(pd.amount));
ec.incomeContribution = amount;
incomeTotal += amount;
ec.summary = fmt(amount);
break;
}
case "EXPENSE": { case "EXPENSE": {
const amount = Math.round(num(pd.amount)); const idx = num(pd.teuerungsausgleich, phaseInflation);
ec.expenseContribution = amount; ec.baseValue = carry.hasCarry ? Math.round(carry.flowBasis) : Math.round(num(pd.amount));
expenseTotal += amount; let basis: number;
ec.summary = fmt(amount); 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; break;
} }
case "AHV": { case "AHV": {
if (owner && !ownerWorking) { if (owner && !ownerWorking) {
const pension = ahvFinal.get(owner.id) ?? 0; const rente = ahvFinal.get(owner.id) ?? 0;
ec.incomeContribution = pension; renteTotal += rente;
incomeTotal += pension; ec.startValue = rente;
ec.summary = `Rente ${fmt(pension)}`; ec.endValue = rente;
ec.summary = `Rente ${fmt(rente)}`;
} else { } else {
const gap = Math.max(0, Math.round(num(pd.gapYears))); const gap = Math.max(0, Math.round(num(pd.gapYears)));
ec.summary = gap > 0 ? `${gap} Ausfalljahre` : "Keine Ausfalljahre"; ec.summary = gap > 0 ? `${gap} Ausfalljahre` : "Keine Ausfalljahre";
@@ -292,11 +254,10 @@ export function computePlan(plan: PlanInput): PlanComputed {
} }
case "PENSION_FUND": { case "PENSION_FUND": {
if (!ownerWorking && carry.pkPensionAnnual > 0) { if (!ownerWorking && carry.pkPensionAnnual > 0) {
// Verrentetes PK-Kapital: jaehrliche Rente als Einkommen. renteTotal += carry.pkPensionAnnual;
const pension = carry.pkPensionAnnual; ec.startValue = carry.pkPensionAnnual;
ec.incomeContribution = pension; ec.endValue = carry.pkPensionAnnual;
incomeTotal += pension; ec.summary = `Rente ${fmt(carry.pkPensionAnnual)}`;
ec.summary = `Rente ${fmt(pension)}`;
} else if (!ownerWorking) { } else if (!ownerWorking) {
ec.note = "Vollstaendig bezogen"; ec.note = "Vollstaendig bezogen";
ec.summary = "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 base = carry.hasCarry ? carry.value : Math.round(num(pd.currentValue));
const topUp = carry.hasCarry ? Math.round(num(pd.additionalInvestment)) : 0; const topUp = carry.hasCarry ? Math.round(num(pd.additionalInvestment)) : 0;
const start = base + topUp; const start = base + topUp;
const contribution = Math.round(num(pd.annualContribution)); const rate = Math.round(num(pd.annualContribution)); // PK-Beitrag zaehlt NICHT zur Quote
const r = num(pd.expectedReturn); if (!isFirstPhase) investmentsFromCash += topUp;
ec.baseValue = base;
ec.startValue = start; ec.startValue = start;
ec.endValue = growAsset(start, r, contribution, phase.durationYears); wealthStart += start;
ec.capitalUse = topUp; assets.push({ value: start, rate, r: num(pd.expectedReturn), ec });
capitalUsed += topUp;
// PK-Beitraege zaehlen NICHT gegen die Sparquote (in Ausgaben enthalten).
ec.summary = fmt(ec.endValue);
} }
break; break;
} }
@@ -323,15 +282,13 @@ export function computePlan(plan: PlanInput): PlanComputed {
const base = carry.hasCarry ? carry.value : Math.round(num(pd.currentValue)); const base = carry.hasCarry ? carry.value : Math.round(num(pd.currentValue));
const topUp = carry.hasCarry ? Math.round(num(pd.additionalInvestment)) : 0; const topUp = carry.hasCarry ? Math.round(num(pd.additionalInvestment)) : 0;
const start = base + topUp; const start = base + topUp;
const contribution = Math.round(num(pd.annualContribution)); const rate = Math.round(num(pd.annualContribution));
const r = num(pd.expectedReturn); plannedRatesTotal += rate;
if (!isFirstPhase) investmentsFromCash += topUp;
ec.baseValue = base;
ec.startValue = start; ec.startValue = start;
ec.endValue = growAsset(start, r, contribution, phase.durationYears); wealthStart += start;
ec.capitalUse = topUp; assets.push({ value: start, rate, r: num(pd.expectedReturn), ec });
capitalUsed += topUp;
ec.quotaUse = contribution; // zaehlt gegen die Sparquote
quotaAllocated += contribution;
ec.summary = fmt(ec.endValue);
} }
break; break;
} }
@@ -339,117 +296,163 @@ export function computePlan(plan: PlanInput): PlanComputed {
const base = carry.hasCarry ? carry.value : Math.round(num(pd.startValue)); const base = carry.hasCarry ? carry.value : Math.round(num(pd.startValue));
const topUp = carry.hasCarry ? Math.round(num(pd.additionalInvestment)) : 0; const topUp = carry.hasCarry ? Math.round(num(pd.additionalInvestment)) : 0;
const start = base + topUp; const start = base + topUp;
const contribution = Math.round(num(pd.annualContribution)); const rate = Math.round(num(pd.annualContribution));
const r = num(pd.expectedReturn); plannedRatesTotal += rate;
if (!isFirstPhase) investmentsFromCash += topUp;
ec.baseValue = base;
ec.startValue = start; ec.startValue = start;
ec.capitalUse = topUp; wealthStart += start;
capitalUsed += topUp; assets.push({ value: start, rate, r: num(pd.expectedReturn), ec });
// 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);
break; break;
} }
case "REAL_ESTATE": { case "REAL_ESTATE": {
const purchase = Math.round(num(pd.purchasePrice)); const purchase = Math.round(num(pd.purchasePrice));
const mortgageStart = carry.hasCarry ? carry.mortgage : Math.round(num(pd.mortgage)); const mortgageStart = carry.hasCarry ? carry.mortgage : Math.round(num(pd.mortgage));
const amort = Math.round(num(pd.amortization)); const amort = Math.round(num(pd.amortization));
const mortgageEnd = Math.max(0, mortgageStart - amort * phase.durationYears); plannedRatesTotal += amort;
ec.startValue = purchase - mortgageStart; const equity = purchase - mortgageStart;
ec.endValue = purchase - mortgageEnd; if (!carry.hasCarry && !isFirstPhase) investmentsFromCash += Math.max(0, equity);
if (!carry.hasCarry) { ec.baseValue = equity;
ec.capitalUse = Math.max(0, purchase - mortgageStart); // Eigenkapital bei Neukauf ec.startValue = equity;
capitalUsed += ec.capitalUse; wealthStart += equity;
} realEstates.push({ purchase, mortgageStart, amort, ec });
// Amortisation ist quotenwirksam (jaehrlicher Budgetbetrag).
ec.quotaUse = amort;
quotaAllocated += amort;
carry.mortgage = mortgageEnd; // fuer Uebergang
ec.summary = fmt(ec.endValue);
break; break;
} }
case "OTHER_DEBT": { case "OTHER_DEBT": {
const owedStart = carry.hasCarry ? carry.owed : Math.round(num(pd.startValue)); const owedStart = carry.hasCarry ? carry.owed : Math.round(num(pd.startValue));
const repay = Math.round(num(pd.annualRepayment)); 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.startValue = -owedStart;
ec.endValue = -owedEnd; wealthStart += -owedStart;
carry.owed = owedEnd; debts.push({ owedStart, repay, ec });
// Tilgung ist quotenwirksam (jaehrlicher Budgetbetrag).
ec.quotaUse = repay;
quotaAllocated += repay;
ec.summary = fmt(ec.endValue);
if (owedEnd === 0) ec.note = "Wird getilgt";
break; break;
} }
} }
elementsComputed.push(ec);
} }
// --- Kern-Kennzahlen --- // --- Jahr-fuer-Jahr: indexierte Flows, Cash-Ausgleich, Verzinsung, Ruin ---
const quota = incomeTotal - expenseTotal; const cashStart = cashCarryIn;
const isConsumption = quota < 0; let cash = cashCarryIn - (isFirstPhase ? 0 : investmentsFromCash);
// Sparphase: alles verteilt, wenn quotaAllocated == quota. Verzehrphase: gedeckt, let cashNegative = cash < 0;
// wenn Bezuege (quotaAllocated) den Fehlbetrag decken. let incomeStart = 0;
const quotaTarget = Math.abs(quota); let incomeEnd = 0;
const quotaRemaining = quotaTarget - quotaAllocated; let expenseStart = 0;
const quotaComplete = Math.abs(quotaRemaining) < 1; let expenseEnd = 0;
let quotaStart = 0;
let quotaEnd = 0;
const availableCapital = incomingCapital; for (let t = 1; t <= duration; t++) {
const availableCapitalUsed = capitalUsed; let incomeFlow = renteTotal;
const availableCapitalRemaining = availableCapital === null ? 0 : availableCapital - availableCapitalUsed; for (const inc of incomes) incomeFlow += inc.basis * Math.pow(1 + inc.idx / 100, t - 1);
const availableCapitalComplete = let expenseFlow = 0;
availableCapital === null || Math.abs(availableCapitalRemaining) < 1; 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; cash += quote - plannedRatesTotal;
cumulativeInflation = cumulativeInflation * (1 + inflationRate / 100); 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); // Gesamtvermoegen zum Jahresende t (fuer Ruin-Erkennung).
const endWealthNominal = elementsComputed.reduce((s, ec) => s + ec.endValue, 0); 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({ result.push({
id: phase.id, id: phase.id,
name: phase.name, name: phase.name,
sequenceNumber: phase.sequenceNumber, sequenceNumber: phase.sequenceNumber,
durationYears: phase.durationYears, durationYears: duration,
type, type,
persons: personInfos, persons: personInfos,
maxDurationYears, maxDurationYears,
incomeTotal, incomeStart: Math.round(incomeStart),
expenseTotal, incomeEnd: Math.round(incomeEnd),
quota, expenseStart: Math.round(expenseStart),
isConsumption, expenseEnd: Math.round(expenseEnd),
quotaAllocated, quotaStart: Math.round(quotaStart),
quotaRemaining, quotaEnd: Math.round(quotaEnd),
quotaComplete, isConsumption: quotaStart < 0,
availableCapital, plannedRatesTotal,
availableCapitalUsed, cashStart: Math.round(cashStart),
availableCapitalRemaining, cashEnd,
availableCapitalComplete, cashNegative,
incomplete, incomplete: cashNegative,
elements: elementsComputed, elements: orderedElements.map((e) => ecById.get(e.id)!),
startWealthNominal, startWealthNominal,
endWealthNominal, endWealthNominal,
cumulativeInflationEnd: cumulativeInflation, cumulativeInflationEnd: cumulativeInflation,
endWealthReal: endWealthNominal / cumulativeInflation, endWealthReal: endWealthNominal / cumulativeInflation,
}); });
// --- Uebergang zur naechsten Phase: Carry aktualisieren + Startkapital berechnen --- // --- Uebergang: Carry aktualisieren, Cash der Folgephase bilden ---
let outgoing = 0; let outgoing = 0;
for (const e of orderedElements) { for (const e of orderedElements) {
const carry = carries.get(e.id)!; 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 td = e.transitionValues[phase.id] ?? {};
const owner = e.ownerRole && e.ownerRole !== "HOUSEHOLD" ? personByRole(persons, e.ownerRole) : null; const owner = e.ownerRole && e.ownerRole !== "HOUSEHOLD" ? personByRole(persons, e.ownerRole) : null;
const ownerRetiresNext = 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") { if (carry.status !== "ACTIVE") {
carry.hasCarry = true; carry.hasCarry = true;
@@ -462,8 +465,7 @@ export function computePlan(plan: PlanInput): PlanComputed {
const value = ec.endValue; const value = ec.endValue;
const mode = td.payoutMode ?? "PENSION"; const mode = td.payoutMode ?? "PENSION";
if (mode === "CAPITAL") { if (mode === "CAPITAL") {
const net = Math.round(value * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100)); outgoing += Math.round(value * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100));
outgoing += net;
carry.value = 0; carry.value = 0;
carry.pkPensionAnnual = 0; carry.pkPensionAnnual = 0;
} else if (mode === "PENSION") { } else if (mode === "PENSION") {
@@ -471,8 +473,7 @@ export function computePlan(plan: PlanInput): PlanComputed {
carry.value = 0; carry.value = 0;
} else { } else {
const capital = Math.min(value, Math.round(num(td.capitalAmount))); 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 += Math.round(capital * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100));
outgoing += net;
carry.pkPensionAnnual = Math.round(((value - capital) * num(td.conversionRate, DEFAULT_PK_CONVERSION_RATE)) / 100); carry.pkPensionAnnual = Math.round(((value - capital) * num(td.conversionRate, DEFAULT_PK_CONVERSION_RATE)) / 100);
carry.value = 0; carry.value = 0;
} }
@@ -485,8 +486,7 @@ export function computePlan(plan: PlanInput): PlanComputed {
} }
case "PILLAR_3A": { case "PILLAR_3A": {
if (ownerRetiresNext) { if (ownerRetiresNext) {
const net = Math.round(ec.endValue * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100)); outgoing += Math.round(ec.endValue * (1 - num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE) / 100));
outgoing += net;
carry.value = 0; carry.value = 0;
} else { } else {
const withdrawal = Math.min(ec.endValue, Math.round(num(td.withdrawal))); const withdrawal = Math.min(ec.endValue, Math.round(num(td.withdrawal)));
@@ -505,22 +505,27 @@ export function computePlan(plan: PlanInput): PlanComputed {
break; break;
} }
case "REAL_ESTATE": { 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") { if (td.decision === "SELL") {
const purchase = Math.round(num(e.phaseValues[phase.id]?.purchasePrice));
const salePrice = Math.round(num(td.salePrice)); const salePrice = Math.round(num(td.salePrice));
const gain = Math.max(0, salePrice - purchase); const gain = Math.max(0, salePrice - purchase);
const tax = gain * (num(td.saleTaxRate, DEFAULT_PROPERTY_GAINS_TAX_RATE) / 100); 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"; carry.status = "SOLD";
} else {
carry.mortgage = restMortgage;
} }
// HOLD: carry.mortgage bereits gesetzt.
break; break;
} }
case "OTHER_DEBT": { case "OTHER_DEBT": {
const owedEnd = -ec.endValue;
carry.owed = owedEnd;
const immediate = Math.min(carry.owed, Math.round(num(td.immediateRepayment))); const immediate = Math.min(carry.owed, Math.round(num(td.immediateRepayment)));
if (immediate > 0) { if (immediate > 0) {
carry.owed = Math.max(0, carry.owed - immediate); 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"; if (carry.owed === 0) carry.status = "SETTLED";
break; break;
@@ -531,20 +536,14 @@ export function computePlan(plan: PlanInput): PlanComputed {
carry.hasCarry = true; carry.hasCarry = true;
} }
incomingCapital = nextPhase ? Math.round(outgoing) : null; cashCarryIn = cashEnd + outgoing;
yearsBefore += phase.durationYears; yearsBefore += duration;
} }
const nachlass = result.length > 0 ? result[result.length - 1].endWealthNominal : 0; 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( function retiresInPhase(
personId: string, personId: string,
persons: { id: string; role: PersonRole; age: number }[], persons: { id: string; role: PersonRole; age: number }[],
@@ -553,15 +552,7 @@ function retiresInPhase(
): boolean { ): boolean {
const p = persons.find((x) => x.id === personId); const p = persons.find((x) => x.id === personId);
if (!p) return false; if (!p) return false;
const ra = retirementAge.get(personId)!; return p.age + yearsBeforeNext >= 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, "'");
} }
// CSV-Export (eine Zeile pro Lebensphase, Kernkennzahlen). // CSV-Export (eine Zeile pro Lebensphase, Kernkennzahlen).
@@ -570,10 +561,11 @@ export function planToCsv(plan: PlanInput, computed: PlanComputed): string {
"Phase", "Phase",
"Typ", "Typ",
"Dauer", "Dauer",
"Einkommen", "Einkommen (Beginn)",
"Ausgaben", "Ausgaben (Beginn)",
"Spar-/Verzehrquote", "Quote (Beginn)",
"Verfuegbares Kapital", "Quote (Ende)",
"Cash (Ende)",
"Endvermoegen (nominal)", "Endvermoegen (nominal)",
"Endvermoegen (real)", "Endvermoegen (real)",
]; ];
@@ -581,12 +573,14 @@ export function planToCsv(plan: PlanInput, computed: PlanComputed): string {
p.name, p.name,
p.type, p.type,
String(p.durationYears), String(p.durationYears),
p.incomeTotal.toFixed(0), p.incomeStart.toFixed(0),
p.expenseTotal.toFixed(0), p.expenseStart.toFixed(0),
p.quota.toFixed(0), p.quotaStart.toFixed(0),
p.availableCapital === null ? "n.a." : p.availableCapital.toFixed(0), p.quotaEnd.toFixed(0),
p.cashEnd.toFixed(0),
p.endWealthNominal.toFixed(0), p.endWealthNominal.toFixed(0),
p.endWealthReal.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"); return [header, ...rows].map((r) => r.join(";")).join("\n");
} }
+8
View File
@@ -52,6 +52,12 @@ export const CATEGORY_ORDER: ElementCategory[] = [
export interface PhaseData { export interface PhaseData {
// INCOME / EXPENSE // INCOME / EXPENSE
amount?: number; 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 // AHV
gapYears?: number; gapYears?: number;
// PENSION_FUND / PILLAR_3A / OTHER_ASSET // PENSION_FUND / PILLAR_3A / OTHER_ASSET
@@ -98,6 +104,8 @@ const nonNeg = z.number().min(0);
export const phaseDataSchema = z export const phaseDataSchema = z
.object({ .object({
amount: nonNeg.optional(), amount: nonNeg.optional(),
teuerungsausgleich: z.number().min(-20).max(50).optional(),
amountOverride: nonNeg.optional(),
gapYears: z.number().int().min(0).optional(), gapYears: z.number().int().min(0).optional(),
currentValue: nonNeg.optional(), currentValue: nonNeg.optional(),
startValue: nonNeg.optional(), startValue: nonNeg.optional(),
+12
View File
@@ -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"],
},
});