money: pessimistic/realistic/optimistic scenarios on the wealth chart (realistic by default), editable assumptions, yearly table and milestones
All checks were successful
Deploy production / deploy (push) Successful in 2m30s
All checks were successful
Deploy production / deploy (push) Successful in 2m30s
This commit is contained in:
506
src/lib/money-tracker/scenarios.ts
Normal file
506
src/lib/money-tracker/scenarios.ts
Normal file
@@ -0,0 +1,506 @@
|
||||
/**
|
||||
* Pessimistic / realistic / optimistic wealth scenarios to the projection end date.
|
||||
*
|
||||
* Money in the tracker drives the start balances, recurring costs and one-off estimates; each
|
||||
* scenario adds labelled assumptions (returns, salary path, spending growth, Box 3, inflation)
|
||||
* that the page lists and lets you tweak. Amounts are in cents, except the editable assumptions,
|
||||
* which are whole euros or percentages so they read like the inputs on the page.
|
||||
*/
|
||||
|
||||
import type { ChartSeries } from './charts';
|
||||
|
||||
export type ScenarioId = 'pessimistic' | 'realistic' | 'optimistic';
|
||||
export const scenarioIds: ScenarioId[] = ['pessimistic', 'realistic', 'optimistic'];
|
||||
|
||||
export type ScenarioParams = {
|
||||
label: string;
|
||||
/** Yearly investment return before any crash, in percent. */
|
||||
returnPercent: number;
|
||||
/** Market drop in the crash year (percent, e.g. -28); 0 means no crash. */
|
||||
crashPercent: number;
|
||||
crashYear: number;
|
||||
/** Yearly return in the years after the crash, in percent. */
|
||||
recoveryPercent: number;
|
||||
/** Interest on the cash buffer, percent per year. */
|
||||
cashRatePercent: number;
|
||||
/** Months without a job after the contract ends (unemployment benefit instead). */
|
||||
gapMonths: number;
|
||||
/** Net unemployment benefit per month during the gap, euros. */
|
||||
gapNetEuros: number;
|
||||
/** Gross salary change at the extension or next job, percent. */
|
||||
nextJobPercent: number;
|
||||
/** Yearly raise in percent, applied each raiseMonth from raiseFromYear. */
|
||||
raisePercent: number;
|
||||
raiseMonth: number;
|
||||
raiseFromYear: number;
|
||||
/** Share of the 5% target bonus actually paid each September, 0–100. */
|
||||
bonusPercentOfTarget: number;
|
||||
/** Side income, gross euros per month, ramping linearly to the end value. */
|
||||
sideStartEuros: number;
|
||||
sideEndEuros: number;
|
||||
/** Spending growth per year (lifestyle creep plus prices), percent. */
|
||||
spendGrowthPercent: number;
|
||||
/** Inflation used to show the end value in today's euros, percent. */
|
||||
inflationPercent: number;
|
||||
/** Your share of Joint household costs after the move, percent. */
|
||||
householdSharePercent: number;
|
||||
/** Personal spending not in the recurring list (going out, transport, shopping), euros/month. */
|
||||
otherSpendEuros: number;
|
||||
/** Household costs on top of the Joint recurring items after the move, euros/month. */
|
||||
householdExtraEuros: number;
|
||||
/** Health insurance (premium unknown yet), euros/month from January 2027. */
|
||||
healthInsuranceEuros: number;
|
||||
/** One-off moving costs in the move month, euros. */
|
||||
moveCostEuros: number;
|
||||
/** Box 3: tax on yearly gains from 2028 ('accrual'), or only when sold ('realisation'). */
|
||||
box3: 'accrual' | 'realisation';
|
||||
};
|
||||
|
||||
export const defaultScenarios: Record<ScenarioId, ScenarioParams> = {
|
||||
pessimistic: {
|
||||
label: 'Pessimistic',
|
||||
returnPercent: 0,
|
||||
crashPercent: -28,
|
||||
crashYear: 2027,
|
||||
recoveryPercent: 15,
|
||||
cashRatePercent: 0,
|
||||
gapMonths: 3,
|
||||
gapNetEuros: 2300,
|
||||
nextJobPercent: 0,
|
||||
raisePercent: 2,
|
||||
raiseMonth: 1,
|
||||
raiseFromYear: 2029,
|
||||
bonusPercentOfTarget: 0,
|
||||
sideStartEuros: 100,
|
||||
sideEndEuros: 100,
|
||||
spendGrowthPercent: 3.5,
|
||||
inflationPercent: 2.5,
|
||||
householdSharePercent: 57,
|
||||
otherSpendEuros: 400,
|
||||
householdExtraEuros: 200,
|
||||
healthInsuranceEuros: 165,
|
||||
moveCostEuros: 2000,
|
||||
box3: 'accrual'
|
||||
},
|
||||
realistic: {
|
||||
label: 'Realistic',
|
||||
returnPercent: 6.5,
|
||||
crashPercent: 0,
|
||||
crashYear: 2027,
|
||||
recoveryPercent: 6.5,
|
||||
cashRatePercent: 1.5,
|
||||
gapMonths: 0,
|
||||
gapNetEuros: 2300,
|
||||
nextJobPercent: 3,
|
||||
raisePercent: 3.5,
|
||||
raiseMonth: 10,
|
||||
raiseFromYear: 2028,
|
||||
bonusPercentOfTarget: 50,
|
||||
sideStartEuros: 250,
|
||||
sideEndEuros: 500,
|
||||
spendGrowthPercent: 2.5,
|
||||
inflationPercent: 2.5,
|
||||
householdSharePercent: 50,
|
||||
otherSpendEuros: 330,
|
||||
householdExtraEuros: 65,
|
||||
healthInsuranceEuros: 165,
|
||||
moveCostEuros: 1000,
|
||||
box3: 'accrual'
|
||||
},
|
||||
optimistic: {
|
||||
label: 'Optimistic',
|
||||
returnPercent: 9,
|
||||
crashPercent: 0,
|
||||
crashYear: 2027,
|
||||
recoveryPercent: 9,
|
||||
cashRatePercent: 1.5,
|
||||
gapMonths: 0,
|
||||
gapNetEuros: 2300,
|
||||
nextJobPercent: 6,
|
||||
raisePercent: 5,
|
||||
raiseMonth: 10,
|
||||
raiseFromYear: 2028,
|
||||
bonusPercentOfTarget: 100,
|
||||
sideStartEuros: 300,
|
||||
sideEndEuros: 2500,
|
||||
spendGrowthPercent: 2,
|
||||
inflationPercent: 2.5,
|
||||
householdSharePercent: 45,
|
||||
otherSpendEuros: 280,
|
||||
householdExtraEuros: 0,
|
||||
healthInsuranceEuros: 165,
|
||||
moveCostEuros: 500,
|
||||
box3: 'realisation'
|
||||
}
|
||||
};
|
||||
|
||||
/** Fixed facts shared by every scenario (contract, tax rules, buffer). */
|
||||
export const scenarioFacts = {
|
||||
/** Contract gross per month and the date the current contract ends. */
|
||||
grossCents: 372493,
|
||||
contractEnd: '2027-09-30',
|
||||
/** Extra net per extra gross euro: about 50% marginal tax after 2.65% pension. */
|
||||
marginalNet: (1 - 0.0265) * 0.5,
|
||||
/** Holiday pay (8%) and 13th month accrue monthly; paid half net in May and December. */
|
||||
holidayPayRate: 0.08,
|
||||
/** Target bonus as a share of yearly gross (12.96 months), paid half net in September. */
|
||||
bonusTarget: 0.05,
|
||||
sideTaxRate: 0.4,
|
||||
bufferCents: 500000,
|
||||
/** Joint household contribution before the move, per month (Apr–Oct 2026 average). */
|
||||
preMoveHouseholdCents: 90000,
|
||||
/** DUO study-debt repayment from 2029, per month. */
|
||||
duoFrom: 2029,
|
||||
duoCents: 10000
|
||||
};
|
||||
|
||||
export type ScenarioRecurring = {
|
||||
name: string;
|
||||
amountCents: number;
|
||||
cadence: string;
|
||||
accountId: number | null;
|
||||
startsOn?: string | null;
|
||||
endsOn?: string | null;
|
||||
};
|
||||
export type ScenarioMovement = { date: string; amountCents: number; description: string };
|
||||
|
||||
export type ScenarioInputs = {
|
||||
today: string;
|
||||
endDate: string;
|
||||
spendableCents: number;
|
||||
investedCents: number;
|
||||
/** Net salary per month from the recurring list. */
|
||||
netSalaryCents: number;
|
||||
/** Recurring items other than salary and Joint income. */
|
||||
personal: ScenarioRecurring[];
|
||||
/** Joint recurring costs, shared after the move. */
|
||||
household: ScenarioRecurring[];
|
||||
/** Rent start: the move month. */
|
||||
moveDate: string | null;
|
||||
oneOffs: ScenarioMovement[];
|
||||
};
|
||||
|
||||
export type ScenarioPoint = {
|
||||
date: string;
|
||||
cash: number;
|
||||
invested: number;
|
||||
total: number;
|
||||
real: number;
|
||||
};
|
||||
export type ScenarioYear = {
|
||||
year: number;
|
||||
income: number;
|
||||
spending: number;
|
||||
saved: number;
|
||||
savingsRate: number;
|
||||
total: number;
|
||||
invested: number;
|
||||
real: number;
|
||||
box3: number;
|
||||
};
|
||||
export type ScenarioResult = {
|
||||
points: ScenarioPoint[];
|
||||
years: ScenarioYear[];
|
||||
milestones: { label: string; date: string | null }[];
|
||||
end: ScenarioPoint;
|
||||
grossEndCents: number;
|
||||
};
|
||||
|
||||
const salaryPattern = /salary|salaris/i;
|
||||
/** Tracker estimates the scenarios model themselves (holiday pay, 13th month, bonus). */
|
||||
const modelledPattern = /cargill|holiday|vakantiegeld|13th|dertiende|bonus/i;
|
||||
|
||||
function monthly(item: ScenarioRecurring) {
|
||||
return item.cadence === 'weekly'
|
||||
? (item.amountCents * 52) / 12
|
||||
: item.cadence === 'yearly'
|
||||
? item.amountCents / 12
|
||||
: item.amountCents;
|
||||
}
|
||||
|
||||
function activeIn(item: ScenarioRecurring, first: string, last: string) {
|
||||
return !(item.startsOn && item.startsOn > last) && !(item.endsOn && item.endsOn < first);
|
||||
}
|
||||
|
||||
/** Splits tracker data into the pieces the scenario model needs. */
|
||||
export function scenarioInputs(input: {
|
||||
accounts: { id: number; kind: string; name: string; includeInTotal?: boolean }[];
|
||||
balances: Map<number, number>;
|
||||
recurring: ScenarioRecurring[];
|
||||
forecasts: { date: string; amountCents: number; description: string; accountId: number | null }[];
|
||||
futureTransactions: {
|
||||
date: string;
|
||||
amountCents: number;
|
||||
description: string;
|
||||
accountId: number | null;
|
||||
}[];
|
||||
today: string;
|
||||
endDate?: string | null;
|
||||
}): ScenarioInputs {
|
||||
const counted = input.accounts.filter((account) => account.includeInTotal !== false);
|
||||
const countedIds = new Set(counted.map((account) => account.id));
|
||||
const jointIds = new Set(
|
||||
input.accounts.filter((account) => /joint/i.test(account.name)).map((account) => account.id)
|
||||
);
|
||||
let spendable = 0;
|
||||
let invested = 0;
|
||||
for (const account of counted) {
|
||||
const value = input.balances.get(account.id) ?? 0;
|
||||
if (account.kind === 'investment') invested += value;
|
||||
else spendable += value;
|
||||
}
|
||||
const inTotal = (accountId: number | null) => accountId === null || countedIds.has(accountId);
|
||||
const recurring = input.recurring.filter((item) => inTotal(item.accountId));
|
||||
const salary = recurring.find((item) => item.amountCents > 0 && salaryPattern.test(item.name));
|
||||
const isJoint = (item: ScenarioRecurring) =>
|
||||
item.accountId !== null && jointIds.has(item.accountId);
|
||||
const household = recurring.filter((item) => isJoint(item) && item.amountCents < 0);
|
||||
const rent = household.find((item) => /rent|huur/i.test(item.name));
|
||||
const oneOffs = [...input.forecasts, ...input.futureTransactions]
|
||||
.filter(
|
||||
(item) =>
|
||||
item.date > input.today &&
|
||||
inTotal(item.accountId) &&
|
||||
!modelledPattern.test(item.description)
|
||||
)
|
||||
.map(({ date, amountCents, description }) => ({ date, amountCents, description }));
|
||||
return {
|
||||
today: input.today,
|
||||
endDate: input.endDate && input.endDate > input.today ? input.endDate : '2030-12-31',
|
||||
spendableCents: spendable,
|
||||
investedCents: invested,
|
||||
netSalaryCents: salary?.amountCents ?? 0,
|
||||
// Joint income (your partner's salary) pays her share of the household, so it stays out.
|
||||
personal: recurring.filter((item) => item !== salary && !isJoint(item)),
|
||||
household,
|
||||
moveDate: rent?.startsOn ?? null,
|
||||
oneOffs
|
||||
};
|
||||
}
|
||||
|
||||
function addMonths(date: string, months: number) {
|
||||
const value = new Date(`${date.slice(0, 7)}-01T12:00:00Z`);
|
||||
value.setUTCMonth(value.getUTCMonth() + months);
|
||||
return value.toISOString().slice(0, 10);
|
||||
}
|
||||
function monthEnd(first: string) {
|
||||
const value = new Date(`${first}T12:00:00Z`);
|
||||
return new Date(Date.UTC(value.getUTCFullYear(), value.getUTCMonth() + 1, 0))
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
}
|
||||
|
||||
export function runScenario(inputs: ScenarioInputs, params: ScenarioParams): ScenarioResult {
|
||||
const facts = scenarioFacts;
|
||||
const baseGross = facts.grossCents;
|
||||
const netFromGross = (gross: number) =>
|
||||
inputs.netSalaryCents + (gross - baseGross) * facts.marginalNet;
|
||||
const gapStart = addMonths(facts.contractEnd, 1);
|
||||
const gapEnd = params.gapMonths > 0 ? addMonths(gapStart, params.gapMonths) : gapStart;
|
||||
const pct = (value: number) => value / 100;
|
||||
let cash = inputs.spendableCents;
|
||||
let invested = inputs.investedCents;
|
||||
let gross = baseGross;
|
||||
let accruedHoliday = baseGross * facts.holidayPayRate;
|
||||
let accrued13 = baseGross / 12;
|
||||
let investedAtYearStart = invested;
|
||||
let contributedThisYear = 0;
|
||||
let box3Due = 0;
|
||||
const points: ScenarioPoint[] = [
|
||||
{ date: inputs.today, cash, invested, total: cash + invested, real: cash + invested }
|
||||
];
|
||||
const years = new Map<number, ScenarioYear>();
|
||||
const milestones: { label: string; date: string | null; test: () => boolean }[] = [
|
||||
{ label: 'Cash buffer full', date: null, test: () => cash >= facts.bufferCents - 100 },
|
||||
...[10_000, 25_000, 50_000, 100_000].map((euros) => ({
|
||||
label: `€${euros / 1000}k invested`,
|
||||
date: null,
|
||||
test: () => invested >= euros * 100
|
||||
}))
|
||||
];
|
||||
const firstMonth = addMonths(inputs.today, 1);
|
||||
const totalMonths =
|
||||
(Number(inputs.endDate.slice(0, 4)) - Number(firstMonth.slice(0, 4))) * 12 +
|
||||
Number(inputs.endDate.slice(5, 7)) -
|
||||
Number(firstMonth.slice(5, 7)) +
|
||||
1;
|
||||
const moveDate = inputs.moveDate ?? '9999-12-31';
|
||||
for (let index = 0; index < Math.min(totalMonths, 600); index++) {
|
||||
const first = addMonths(firstMonth, index);
|
||||
const last = monthEnd(first);
|
||||
const date = last < inputs.endDate ? last : inputs.endDate;
|
||||
const year = Number(first.slice(0, 4));
|
||||
const month = Number(first.slice(5, 7));
|
||||
const row =
|
||||
years.get(year) ??
|
||||
years
|
||||
.set(year, {
|
||||
year,
|
||||
income: 0,
|
||||
spending: 0,
|
||||
saved: 0,
|
||||
savingsRate: 0,
|
||||
total: 0,
|
||||
invested: 0,
|
||||
real: 0,
|
||||
box3: 0
|
||||
})
|
||||
.get(year)!;
|
||||
if (month === 1) {
|
||||
investedAtYearStart = invested;
|
||||
contributedThisYear = 0;
|
||||
}
|
||||
// Salary path: extension or next job after the contract, with an optional gap.
|
||||
const employed = !(params.gapMonths > 0 && first >= gapStart && first < gapEnd);
|
||||
if (first === gapStart) gross = baseGross * (1 + pct(params.nextJobPercent));
|
||||
if (first > gapStart && year >= params.raiseFromYear && month === params.raiseMonth)
|
||||
gross *= 1 + pct(params.raisePercent);
|
||||
let income = employed ? netFromGross(gross) : params.gapNetEuros * 100;
|
||||
if (employed) {
|
||||
accruedHoliday += gross * facts.holidayPayRate;
|
||||
accrued13 += gross / 12;
|
||||
}
|
||||
if (month === 5) {
|
||||
income += accruedHoliday * 0.5;
|
||||
accruedHoliday = 0;
|
||||
}
|
||||
if (month === 12) {
|
||||
income += accrued13 * 0.5;
|
||||
accrued13 = 0;
|
||||
}
|
||||
// A job change pays out what is still accrued when the contract ends.
|
||||
if (params.gapMonths > 0 && first === addMonths(facts.contractEnd, 0)) {
|
||||
income += (accruedHoliday + accrued13) * 0.5;
|
||||
accruedHoliday = 0;
|
||||
accrued13 = 0;
|
||||
}
|
||||
if (month === 9 && first >= addMonths(facts.contractEnd, 0) && employed)
|
||||
income += pct(params.bonusPercentOfTarget) * facts.bonusTarget * gross * 12.96 * 0.5;
|
||||
const ramp = Math.min(1, Math.max(0, (index - 3) / Math.max(1, totalMonths - 4)));
|
||||
const sideGross = params.sideStartEuros + (params.sideEndEuros - params.sideStartEuros) * ramp;
|
||||
income += sideGross * 100 * (1 - facts.sideTaxRate);
|
||||
// Recurring items from the tracker (positive ones such as benefits count as income).
|
||||
const growth = Math.pow(1 + pct(params.spendGrowthPercent), Math.max(0, (index - 3) / 12));
|
||||
let spending = 0;
|
||||
for (const item of inputs.personal) {
|
||||
if (!activeIn(item, first, last)) continue;
|
||||
const value = monthly(item);
|
||||
if (value >= 0) income += value;
|
||||
else spending -= value * growth;
|
||||
}
|
||||
spending += params.otherSpendEuros * 100 * growth;
|
||||
if (first >= '2027-01-01') spending += params.healthInsuranceEuros * 100 * growth;
|
||||
if (first < moveDate.slice(0, 7) + '-01') spending += facts.preMoveHouseholdCents;
|
||||
else {
|
||||
let household = params.householdExtraEuros * 100;
|
||||
for (const item of inputs.household)
|
||||
if (activeIn(item, first, last)) household -= monthly(item);
|
||||
spending += household * pct(params.householdSharePercent) * growth;
|
||||
}
|
||||
if (first === moveDate.slice(0, 7) + '-01') spending += params.moveCostEuros * 100;
|
||||
if (year >= facts.duoFrom) spending += facts.duoCents;
|
||||
for (const item of inputs.oneOffs)
|
||||
if ((index === 0 ? item.date > inputs.today : item.date >= first) && item.date <= last) {
|
||||
if (item.amountCents >= 0) income += item.amountCents;
|
||||
else spending -= item.amountCents;
|
||||
}
|
||||
let tax = 0;
|
||||
if (month === 5) {
|
||||
tax = box3Due;
|
||||
box3Due = 0;
|
||||
}
|
||||
cash += income - spending - tax;
|
||||
// Returns: a crash spreads over March–October of its year.
|
||||
const crash = params.crashPercent !== 0 && year === params.crashYear;
|
||||
const yearly =
|
||||
params.crashPercent !== 0 && year > params.crashYear
|
||||
? pct(params.recoveryPercent)
|
||||
: pct(params.returnPercent);
|
||||
const rate = crash
|
||||
? month >= 3 && month <= 10
|
||||
? Math.pow(1 + pct(params.crashPercent), 1 / 8) - 1
|
||||
: 0
|
||||
: Math.pow(1 + yearly, 1 / 12) - 1;
|
||||
invested += invested * rate;
|
||||
cash *= Math.pow(1 + pct(params.cashRatePercent), 1 / 12);
|
||||
if (cash > facts.bufferCents) {
|
||||
const extra = cash - facts.bufferCents;
|
||||
invested += extra;
|
||||
contributedThisYear += extra;
|
||||
cash = facts.bufferCents;
|
||||
} else if (cash < 0 && invested > 0) {
|
||||
const sold = Math.min(invested, -cash);
|
||||
invested -= sold;
|
||||
contributedThisYear -= sold;
|
||||
cash += sold;
|
||||
}
|
||||
if (month === 12) {
|
||||
const gain = invested - investedAtYearStart - contributedThisYear;
|
||||
if (year >= 2028) box3Due = params.box3 === 'accrual' ? Math.max(0, gain - 100000) * 0.36 : 0;
|
||||
else {
|
||||
const exempt = year === 2026 ? 5_935_700 : 3_084_600;
|
||||
const deemed = year === 2026 ? 0.06 : 0.0787;
|
||||
box3Due = Math.max(0, invested - Math.max(0, exempt - cash)) * deemed * 0.36;
|
||||
}
|
||||
}
|
||||
const total = cash + invested;
|
||||
const elapsed = (index + 1) / 12;
|
||||
const real = total / Math.pow(1 + pct(params.inflationPercent), elapsed);
|
||||
points.push({ date, cash, invested, total, real });
|
||||
row.income += income;
|
||||
row.spending += spending + tax;
|
||||
row.box3 += tax;
|
||||
row.total = total;
|
||||
row.invested = invested;
|
||||
row.real = real;
|
||||
for (const milestone of milestones)
|
||||
if (milestone.date === null && milestone.test()) milestone.date = first.slice(0, 7);
|
||||
}
|
||||
for (const row of years.values()) {
|
||||
row.saved = row.income - row.spending;
|
||||
row.savingsRate = row.income ? row.saved / row.income : 0;
|
||||
}
|
||||
return {
|
||||
points,
|
||||
years: [...years.values()],
|
||||
milestones: milestones.map(({ label, date }) => ({ label, date })),
|
||||
end: points.at(-1)!,
|
||||
grossEndCents: gross
|
||||
};
|
||||
}
|
||||
|
||||
const scenarioColors: Record<ScenarioId, string> = {
|
||||
pessimistic: '#e5484d',
|
||||
realistic: '#0070f3',
|
||||
optimistic: '#46a758'
|
||||
};
|
||||
export function scenarioColor(id: ScenarioId) {
|
||||
return scenarioColors[id];
|
||||
}
|
||||
|
||||
/** Total, spendable and invested lines in the shape `wealthTimeline` expects for a projection. */
|
||||
export function scenarioProjection(result: ScenarioResult): ChartSeries[] {
|
||||
const line = (key: 'total' | 'cash' | 'invested') =>
|
||||
result.points.map((point) => ({ date: point.date, value: Math.round(point[key]) }));
|
||||
return [
|
||||
{ label: 'Total wealth', color: '#0070f3', points: line('total'), emphasis: true },
|
||||
{ label: 'Spendable', color: '#46a758', points: line('cash') },
|
||||
{ label: 'Invested', color: '#8e4ec6', points: line('invested') }
|
||||
];
|
||||
}
|
||||
|
||||
/** One dashed total line per scenario, for comparing them on the wealth chart. */
|
||||
export function scenarioCompareLines(
|
||||
results: Record<ScenarioId, ScenarioResult>,
|
||||
params: Record<ScenarioId, ScenarioParams>,
|
||||
today: string
|
||||
): ChartSeries[] {
|
||||
return scenarioIds.map((id) => ({
|
||||
label: params[id].label,
|
||||
color: scenarioColors[id],
|
||||
dashedFrom: today,
|
||||
points: results[id].points
|
||||
.filter((point) => point.date > today)
|
||||
.map((point) => ({ date: point.date, value: Math.round(point.total) }))
|
||||
}));
|
||||
}
|
||||
Reference in New Issue
Block a user