The wealth forecast now runs per day instead of per month, so it shows the same sawtooth as the actual history: - recurring items hit on the day the bank history shows (most common day of the last four months, clamped to month end), else their start date - "every N weeks" items (Basic-Fit, haircut) run on an exact N-day cadence from the last charge; yearly items on their renewal date - salary and its extras (holiday pay in May, 13th month in November, bonus, settlement) land on the payday from history (contract 25th as fallback), moved to the Friday before a weekend; toeslagen move to the Monday after - variable budgets are spread per day, the vacation budget over Jul-Aug - excess above the buffer is invested the day after payday; returns compound daily - long horizons stay fast (~35 ms for 100 years): the chart thins each pixel bucket to its low and high
870 lines
31 KiB
TypeScript
870 lines
31 KiB
TypeScript
/**
|
||
* Pessimistic / realistic / optimistic wealth scenarios to the chosen horizon (up to 100 years).
|
||
*
|
||
* Money in the tracker drives the start balances, recurring costs and one-off estimates; each
|
||
* scenario adds labelled assumptions (portfolio and crashes, career path, retirement, 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';
|
||
import {
|
||
businessDay,
|
||
dayNumber,
|
||
dayOfMonth,
|
||
daysInMonth,
|
||
isoDay,
|
||
monthFlows,
|
||
scheduleFor,
|
||
type HistoryTransaction,
|
||
type ItemSchedule
|
||
} from './schedule';
|
||
|
||
export type ScenarioId = 'pessimistic' | 'realistic' | 'optimistic';
|
||
export const scenarioIds: ScenarioId[] = ['pessimistic', 'realistic', 'optimistic'];
|
||
|
||
export type ScenarioParams = {
|
||
label: string;
|
||
/** Portfolio mix, shown with the assumptions. */
|
||
portfolio: string;
|
||
/** Long-run average yearly return of the portfolio including crashes, in percent. */
|
||
returnPercent: number;
|
||
/** Typical yearly swing (standard deviation), percent; shown for context. */
|
||
volatilityPercent: number;
|
||
/** Market drop in a crash year (percent, e.g. -30); 0 means no crashes. */
|
||
crashPercent: number;
|
||
/** Years between crashes, counted from the first crash year. */
|
||
crashEveryYears: number;
|
||
firstCrashYear: number;
|
||
/** Smaller correction halfway between crashes, percent (0 = none). */
|
||
correctionPercent: number;
|
||
/** Interest on the cash buffer, percent per year. */
|
||
cashRatePercent: number;
|
||
/** Inflation: prices, the salary cap, pension and benefits grow with it; percent. */
|
||
inflationPercent: number;
|
||
/** Months without a job after the contract ends on 30 Sep 2027 (benefit instead). */
|
||
gapMonths: number;
|
||
/** Net unemployment benefit per month during a gap, today's euros. */
|
||
gapNetEuros: number;
|
||
/** Gross salary change at the extension or next job in October 2027, percent. */
|
||
nextJobPercent: number;
|
||
/** Yearly raise in percent, applied each raiseMonth from raiseFromYear. */
|
||
raisePercent: number;
|
||
raiseMonth: number;
|
||
raiseFromYear: number;
|
||
/** Job hops: in October of the first year, then every N years (0 = never). */
|
||
firstHopYear: number;
|
||
hopEveryYears: number;
|
||
/** Gross raise at each hop, percent. */
|
||
hopRaisePercent: number;
|
||
/** Months without a job before each hop starts (unemployment spells). */
|
||
hopGapMonths: number;
|
||
/** Career plateau: base salary cap per year in today's euros (excl. holiday pay). */
|
||
salaryCapEuros: number;
|
||
/** Share of the contract's bonus estimate actually paid each August, 0–100. */
|
||
bonusPercentOfTarget: number;
|
||
/** Side income, gross today's euros per month, ramping over five years to the end value. */
|
||
sideStartEuros: number;
|
||
sideEndEuros: number;
|
||
/** Retirement age: salary and side income stop, pension starts, spending comes out of savings. */
|
||
retireAge: number;
|
||
/** Net pension per month (AOW plus workplace pension), today's euros. */
|
||
pensionNetEuros: number;
|
||
/** Spending growth per year for the first ten years (prices plus lifestyle), then inflation. */
|
||
spendGrowthPercent: 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',
|
||
portfolio: '60% global equity ETF, 40% euro bonds',
|
||
returnPercent: 4,
|
||
volatilityPercent: 10,
|
||
crashPercent: -25,
|
||
crashEveryYears: 8,
|
||
firstCrashYear: 2027,
|
||
correctionPercent: -8,
|
||
cashRatePercent: 0,
|
||
inflationPercent: 2.5,
|
||
gapMonths: 3,
|
||
gapNetEuros: 2300,
|
||
nextJobPercent: 0,
|
||
raisePercent: 2,
|
||
raiseMonth: 1,
|
||
raiseFromYear: 2029,
|
||
firstHopYear: 2031,
|
||
hopEveryYears: 5,
|
||
hopRaisePercent: 15,
|
||
hopGapMonths: 3,
|
||
salaryCapEuros: 75000,
|
||
bonusPercentOfTarget: 0,
|
||
sideStartEuros: 100,
|
||
sideEndEuros: 100,
|
||
retireAge: 68,
|
||
pensionNetEuros: 1700,
|
||
spendGrowthPercent: 3.5,
|
||
householdSharePercent: 57,
|
||
otherSpendEuros: 400,
|
||
householdExtraEuros: 200,
|
||
healthInsuranceEuros: 165,
|
||
moveCostEuros: 2000,
|
||
box3: 'accrual'
|
||
},
|
||
realistic: {
|
||
label: 'Realistic',
|
||
portfolio: '100% global equity ETF (all-world index)',
|
||
returnPercent: 7,
|
||
volatilityPercent: 15,
|
||
crashPercent: -30,
|
||
crashEveryYears: 10,
|
||
firstCrashYear: 2029,
|
||
correctionPercent: -10,
|
||
cashRatePercent: 1.5,
|
||
inflationPercent: 2.5,
|
||
gapMonths: 0,
|
||
gapNetEuros: 2300,
|
||
nextJobPercent: 6,
|
||
raisePercent: 4,
|
||
raiseMonth: 10,
|
||
raiseFromYear: 2028,
|
||
firstHopYear: 2029,
|
||
hopEveryYears: 3,
|
||
hopRaisePercent: 20,
|
||
hopGapMonths: 0,
|
||
salaryCapEuros: 95000,
|
||
bonusPercentOfTarget: 100,
|
||
sideStartEuros: 250,
|
||
sideEndEuros: 500,
|
||
retireAge: 68,
|
||
pensionNetEuros: 2400,
|
||
spendGrowthPercent: 3,
|
||
householdSharePercent: 50,
|
||
otherSpendEuros: 330,
|
||
householdExtraEuros: 65,
|
||
healthInsuranceEuros: 165,
|
||
moveCostEuros: 1000,
|
||
box3: 'accrual'
|
||
},
|
||
optimistic: {
|
||
label: 'Optimistic',
|
||
portfolio: '80% global equity ETF, 20% tech, small-cap and factor tilts',
|
||
returnPercent: 9.5,
|
||
volatilityPercent: 19,
|
||
crashPercent: -38,
|
||
crashEveryYears: 10,
|
||
firstCrashYear: 2030,
|
||
correctionPercent: -15,
|
||
cashRatePercent: 1.5,
|
||
inflationPercent: 2.5,
|
||
gapMonths: 0,
|
||
gapNetEuros: 2300,
|
||
nextJobPercent: 8,
|
||
raisePercent: 6,
|
||
raiseMonth: 10,
|
||
raiseFromYear: 2028,
|
||
firstHopYear: 2029,
|
||
hopEveryYears: 3,
|
||
hopRaisePercent: 22,
|
||
hopGapMonths: 0,
|
||
salaryCapEuros: 150000,
|
||
bonusPercentOfTarget: 100,
|
||
sideStartEuros: 300,
|
||
sideEndEuros: 2500,
|
||
retireAge: 68,
|
||
pensionNetEuros: 3200,
|
||
spendGrowthPercent: 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',
|
||
/** Born 2003: 23 now, so 68 is 2071 (the expected AOW age for that year is about 68–70). */
|
||
birthYear: 2003,
|
||
/** Extra net per extra gross euro: about 50% marginal tax after 2.65% pension. */
|
||
marginalNet: (1 - 0.0265) * 0.5,
|
||
/**
|
||
* Contract extras, net per full year at the contract gross (scaled with later pay): holiday
|
||
* pay paid in May and the 13th month in November, both accrued per month worked. The first
|
||
* ones are pro rata (13th month 3/12 in Nov 2026, holiday pay 8/12 in May 2027). The bonus
|
||
* is paid in August after the fiscal year, only while employed: once €850 (FY27), then €1,300.
|
||
*/
|
||
holidayNetYearCents: 195000,
|
||
thirteenthNetYearCents: 205000,
|
||
holidayMonthsAccrued: 1,
|
||
thirteenthMonthsAccrued: 2,
|
||
firstBonusCents: 85000,
|
||
firstBonusYear: 2027,
|
||
bonusNetYearCents: 130000,
|
||
sideTaxRate: 0.4,
|
||
/** Cash buffer in today's euros (grows with inflation); everything above it is invested. */
|
||
bufferCents: 500000,
|
||
/** Joint household contribution before the move, per month (Apr–Oct 2026 average). */
|
||
preMoveHouseholdCents: 90000,
|
||
/** DUO study-debt repayment, per month, for at most 35 years from 2029. */
|
||
duoFrom: 2029,
|
||
duoUntil: 2063,
|
||
duoCents: 10000,
|
||
/** Box 3 from 2028: 36% on gains above €1,000 a year, losses carried forward. */
|
||
box3Rate: 0.36,
|
||
box3FreeCents: 100000,
|
||
/** Withdrawal rate for the "passive income" milestone. */
|
||
withdrawalRate: 0.035,
|
||
/** Monthly amount for the income milestones, today's euros. */
|
||
targetMonthlyCents: 1_000_000
|
||
};
|
||
|
||
/** Base salary bands per year in today's euros (lower ends), for the role column. */
|
||
export const salaryBands: { fromEuros: number; role: string }[] = [
|
||
{ fromEuros: 0, role: 'Junior' },
|
||
{ fromEuros: 52000, role: 'Medior' },
|
||
{ fromEuros: 65000, role: 'Senior' },
|
||
{ fromEuros: 85000, role: 'Lead / senior II' },
|
||
{ fromEuros: 105000, role: 'Staff / eng. manager' },
|
||
{ fromEuros: 135000, role: 'Principal / big-tech staff' }
|
||
];
|
||
export function roleFor(baseRealEuros: number) {
|
||
return salaryBands.findLast((band) => baseRealEuros >= band.fromEuros)?.role ?? 'Junior';
|
||
}
|
||
|
||
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 };
|
||
/** A recurring item with the days it hits (see schedule.ts). */
|
||
export type ScheduledRecurring = ScenarioRecurring & { schedule: ItemSchedule };
|
||
|
||
export type ScenarioInputs = {
|
||
today: string;
|
||
endDate: string;
|
||
spendableCents: number;
|
||
investedCents: number;
|
||
/** Net salary per month from the recurring list. */
|
||
netSalaryCents: number;
|
||
/** Salary payday from the bank history (null: the contract's 25th). */
|
||
payday: { day: number; shift: 'none' | 'before' | 'after'; source: string } | null;
|
||
/** Recurring items other than salary and Joint income. */
|
||
personal: ScheduledRecurring[];
|
||
/** Joint recurring costs, shared after the move. */
|
||
household: ScheduledRecurring[];
|
||
/** 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;
|
||
age: number;
|
||
/** Gross per month at year end (nominal, 0 when retired). */
|
||
grossMonthly: number;
|
||
/** Base salary per year in today's euros, for the role. */
|
||
baseReal: number;
|
||
role: string;
|
||
events: string[];
|
||
};
|
||
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;
|
||
|
||
/** 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;
|
||
/** Bank transactions through today, to find the day each recurring item hits. */
|
||
history?: HistoryTransaction[];
|
||
}): 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 }));
|
||
const history = input.history ?? [];
|
||
const scheduled = (item: ScenarioRecurring): ScheduledRecurring => ({
|
||
...item,
|
||
schedule: scheduleFor(item, history, input.today)
|
||
});
|
||
const salarySchedule = salary ? scheduleFor(salary, history, input.today) : null;
|
||
return {
|
||
today: input.today,
|
||
endDate: input.endDate && input.endDate > input.today ? input.endDate : '2030-12-31',
|
||
spendableCents: spendable,
|
||
investedCents: invested,
|
||
netSalaryCents: salary?.amountCents ?? 0,
|
||
payday:
|
||
salarySchedule?.kind === 'monthly' && salarySchedule.last
|
||
? { day: salarySchedule.day, shift: 'before', source: salarySchedule.source }
|
||
: null,
|
||
// Joint income (your partner's salary) pays her share of the household, so it stays out.
|
||
personal: recurring.filter((item) => item !== salary && !isJoint(item)).map(scheduled),
|
||
household: household.map(scheduled),
|
||
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);
|
||
}
|
||
|
||
/**
|
||
* Years of a crash cycle: a crash in the first year, a correction halfway, and normal years whose
|
||
* return makes the whole cycle average out at the expected return.
|
||
*/
|
||
export function marketReturns(params: ScenarioParams) {
|
||
const pct = (value: number) => value / 100;
|
||
const every = Math.max(2, Math.round(params.crashEveryYears));
|
||
const crashes = params.crashPercent !== 0;
|
||
const corrections = crashes && params.correctionPercent !== 0 && every >= 3;
|
||
const shocks =
|
||
(1 + (crashes ? pct(params.crashPercent) : 0)) *
|
||
(1 + (corrections ? pct(params.correctionPercent) : 0));
|
||
const normalYears = every - (crashes ? 1 : 0) - (corrections ? 1 : 0);
|
||
const normal = crashes
|
||
? Math.pow(Math.pow(1 + pct(params.returnPercent), every) / shocks, 1 / normalYears) - 1
|
||
: pct(params.returnPercent);
|
||
return {
|
||
normal,
|
||
yearReturn(year: number) {
|
||
if (!crashes || year < params.firstCrashYear)
|
||
return { kind: 'normal' as const, rate: normal };
|
||
const offset = (year - params.firstCrashYear) % every;
|
||
if (offset === 0) return { kind: 'crash' as const, rate: pct(params.crashPercent) };
|
||
if (corrections && offset === Math.floor(every / 2))
|
||
return { kind: 'correction' as const, rate: pct(params.correctionPercent) };
|
||
return { kind: 'normal' as const, rate: normal };
|
||
}
|
||
};
|
||
}
|
||
|
||
/** Contract payday (the 25th) when the bank history has no salary yet. */
|
||
const contractPayday = 25;
|
||
|
||
/**
|
||
* Runs one scenario day by day from tomorrow to the end date. Every flow lands on its own day:
|
||
* salary and its extras on payday, recurring items on the days the bank history (or their start
|
||
* date) puts them, variable budgets spread over the days of the month, one-offs on their date.
|
||
* Investing happens the day after payday (everything above the buffer), returns compound daily,
|
||
* and investments are sold on any day the cash would go below zero. Amounts per month are the
|
||
* same as a monthly model's; only the timing within the month differs.
|
||
*/
|
||
export function runScenario(inputs: ScenarioInputs, params: ScenarioParams): ScenarioResult {
|
||
const facts = scenarioFacts;
|
||
const baseGross = facts.grossCents;
|
||
const pct = (value: number) => value / 100;
|
||
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 retireStart = `${facts.birthYear + Math.round(params.retireAge)}-01-01`;
|
||
const markets = marketReturns(params);
|
||
const monthlyInflation = Math.pow(1 + pct(params.inflationPercent), 1 / 12);
|
||
const isHopYear = (year: number) =>
|
||
params.hopEveryYears > 0 &&
|
||
year >= params.firstHopYear &&
|
||
(year - params.firstHopYear) % Math.max(1, Math.round(params.hopEveryYears)) === 0;
|
||
const payday = inputs.payday ?? { day: contractPayday, shift: 'before' as const };
|
||
const todayNumber = dayNumber(inputs.today);
|
||
const endNumber = dayNumber(inputs.endDate);
|
||
let cash = inputs.spendableCents;
|
||
let invested = inputs.investedCents;
|
||
let basis = inputs.investedCents;
|
||
let gross = baseGross;
|
||
let accruedHoliday = (facts.holidayNetYearCents / 12) * facts.holidayMonthsAccrued;
|
||
let accrued13 = (facts.thirteenthNetYearCents / 12) * facts.thirteenthMonthsAccrued;
|
||
let investedAtYearStart = invested;
|
||
let contributedThisYear = 0;
|
||
let realisedThisYear = 0;
|
||
let lossCarried = 0;
|
||
let box3Due = 0;
|
||
let deflator = 1;
|
||
let spendGrowth = 1;
|
||
const recentRealIncome: number[] = [];
|
||
const points: ScenarioPoint[] = [
|
||
{ date: inputs.today, cash, invested, total: cash + invested, real: cash + invested }
|
||
];
|
||
const years = new Map<number, ScenarioYear>();
|
||
const target = facts.targetMonthlyCents;
|
||
let buffer = facts.bufferCents;
|
||
const milestones: { label: string; date: string | null; test: () => boolean }[] = [
|
||
{
|
||
label: 'Cash buffer full',
|
||
date: null,
|
||
test: () => cash >= buffer - 100
|
||
},
|
||
...[10_000, 100_000, 250_000, 1_000_000].map((euros) => ({
|
||
label: `€${euros >= 1_000_000 ? `${euros / 1_000_000}M` : `${euros / 1000}k`} invested`,
|
||
date: null,
|
||
test: () => invested >= euros * 100
|
||
})),
|
||
{
|
||
label: '€10k/mo net income (today’s €)',
|
||
date: null,
|
||
test: () =>
|
||
recentRealIncome.length === 12 &&
|
||
recentRealIncome.reduce((sum, value) => sum + value, 0) / 12 >= target
|
||
},
|
||
{
|
||
label: '€10k/mo passive at 3.5% (today’s €)',
|
||
date: null,
|
||
test: () => ((invested / deflator) * facts.withdrawalRate) / 12 >= target
|
||
}
|
||
];
|
||
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';
|
||
const moveMonth = moveDate.slice(0, 7) + '-01';
|
||
const moveDay = inputs.moveDate ? dayNumber(inputs.moveDate) : 0;
|
||
const yearRow = (year: number) =>
|
||
years.get(year) ??
|
||
years
|
||
.set(year, {
|
||
year,
|
||
income: 0,
|
||
spending: 0,
|
||
saved: 0,
|
||
savingsRate: 0,
|
||
total: 0,
|
||
invested: 0,
|
||
real: 0,
|
||
box3: 0,
|
||
age: year - facts.birthYear,
|
||
grossMonthly: 0,
|
||
baseReal: 0,
|
||
role: '',
|
||
events: []
|
||
})
|
||
.get(year)!;
|
||
// Index -1 is the rest of the current month (only recurring items, budgets and one-offs; the
|
||
// salary for it has been paid), then one index per calendar month.
|
||
for (let index = -1; index < Math.min(totalMonths, 1212); index++) {
|
||
const first = addMonths(firstMonth, index);
|
||
const last = monthEnd(first);
|
||
const year = Number(first.slice(0, 4));
|
||
const month = Number(first.slice(5, 7));
|
||
const firstDay = dayNumber(first);
|
||
const length = daysInMonth(year, month);
|
||
const partial = index < 0;
|
||
const row = yearRow(year);
|
||
const startDeflator = deflator;
|
||
// The current month only inflates over its remaining days.
|
||
const monthInflation = partial
|
||
? Math.pow(monthlyInflation, (firstDay + length - 1 - todayNumber) / length)
|
||
: monthlyInflation;
|
||
deflator *= monthInflation;
|
||
if (month === 1) {
|
||
investedAtYearStart = invested;
|
||
contributedThisYear = 0;
|
||
realisedThisYear = 0;
|
||
}
|
||
const flow = new Float64Array(length);
|
||
const at = (day: number, cents: number) => {
|
||
const offset = day - firstDay;
|
||
if (offset >= 0 && offset < length) flow[offset] += cents;
|
||
};
|
||
/** Spreads a month's amount over its days, from tomorrow in the current month. */
|
||
const spreadMonth = (cents: number, from = firstDay, to = firstDay + length - 1) => {
|
||
const perDay = cents / length;
|
||
for (let day = Math.max(from, todayNumber + 1); day <= to; day++) at(day, perDay);
|
||
};
|
||
const paid = businessDay(dayOfMonth(year, month, payday.day), payday.shift);
|
||
const retired = first >= retireStart;
|
||
let income = 0;
|
||
let spending = 0;
|
||
let employed = false;
|
||
if (!partial) {
|
||
// Salary path: extension or next job after the contract (optional gap), raises, job hops
|
||
// in October (optional gap before each), capped at the career plateau in today's euros.
|
||
const hopStart = isHopYear(year) ? `${year}-10-01` : null;
|
||
// A gap that starts in October can run into the next year.
|
||
const hops = [year - 1, year].filter(isHopYear).map((hopYear) => {
|
||
const start = `${hopYear}-10-01`;
|
||
return { start, end: addMonths(start, Math.max(0, params.hopGapMonths)) };
|
||
});
|
||
const inHopGap = hops.some((hop) => first >= hop.start && first < hop.end);
|
||
const hopEnd = hops.find((hop) => hop.end === first)?.end ?? null;
|
||
const inContractGap = params.gapMonths > 0 && first >= gapStart && first < gapEnd;
|
||
employed = !retired && !inHopGap && !inContractGap;
|
||
if (first === gapStart) {
|
||
gross = baseGross * (1 + pct(params.nextJobPercent));
|
||
row.events.push(
|
||
params.gapMonths > 0 ? `Contract ends, ${params.gapMonths} mo gap` : 'Contract renewed'
|
||
);
|
||
}
|
||
if (
|
||
first > gapStart &&
|
||
year >= params.raiseFromYear &&
|
||
month === params.raiseMonth &&
|
||
!(hopStart && month === 10)
|
||
)
|
||
gross *= 1 + pct(params.raisePercent);
|
||
const cap = ((params.salaryCapEuros * 100) / 12) * deflator;
|
||
if (hopStart && first === hopStart && params.hopGapMonths > 0 && !retired)
|
||
row.events.push(`Between jobs (${params.hopGapMonths} mo)`);
|
||
if (hopEnd && first === hopEnd && !retired) {
|
||
const plateau = gross >= cap * 0.99;
|
||
gross *= 1 + pct(params.hopRaisePercent);
|
||
row.events.push(
|
||
(params.hopGapMonths > 0 ? 'New job' : 'Job hop') + (plateau ? ' (at plateau)' : '')
|
||
);
|
||
}
|
||
if (gross > cap) gross = cap;
|
||
const jobChange =
|
||
first === retireStart ||
|
||
(hopStart !== null && first === hopStart) ||
|
||
(params.gapMonths > 0 && first === gapStart);
|
||
// Pay, benefit or pension, and the contract extras: all on payday.
|
||
let pay = 0;
|
||
if (employed) pay += netFromGross(gross);
|
||
else if (!retired) pay += params.gapNetEuros * 100 * deflator;
|
||
if (employed) {
|
||
accruedHoliday += (facts.holidayNetYearCents / 12) * (gross / baseGross);
|
||
accrued13 += (facts.thirteenthNetYearCents / 12) * (gross / baseGross);
|
||
}
|
||
if (month === 5 || jobChange) {
|
||
pay += accruedHoliday;
|
||
accruedHoliday = 0;
|
||
}
|
||
if (month === 11 || jobChange) {
|
||
pay += accrued13;
|
||
accrued13 = 0;
|
||
}
|
||
if (month === 8 && year >= facts.firstBonusYear && employed)
|
||
pay +=
|
||
pct(params.bonusPercentOfTarget) *
|
||
(year === facts.firstBonusYear
|
||
? facts.firstBonusCents
|
||
: facts.bonusNetYearCents * (gross / baseGross));
|
||
if (retired) {
|
||
pay += params.pensionNetEuros * 100 * deflator;
|
||
if (first === retireStart) row.events.push(`Retire at ${params.retireAge}`);
|
||
}
|
||
income += pay;
|
||
at(paid, pay);
|
||
if (!retired) {
|
||
// Side income: invoices come in through the month, so it is spread.
|
||
const ramp = Math.min(1, Math.max(0, (index - 3) / 57));
|
||
const sideGross =
|
||
params.sideStartEuros + (params.sideEndEuros - params.sideStartEuros) * ramp;
|
||
const side = sideGross * 100 * deflator * (1 - facts.sideTaxRate);
|
||
income += side;
|
||
spreadMonth(side);
|
||
}
|
||
if (index >= 3)
|
||
spendGrowth *= Math.pow(
|
||
1 + pct(index < 123 ? params.spendGrowthPercent : params.inflationPercent),
|
||
1 / 12
|
||
);
|
||
}
|
||
// Recurring items from the tracker (positive ones such as benefits count as income).
|
||
const addItem = (item: ScheduledRecurring, scale: number, incomeAllowed: boolean) => {
|
||
const flows = monthFlows(item, item.schedule, year, month, todayNumber);
|
||
for (const [day, cents] of flows.events) {
|
||
if (cents >= 0) {
|
||
if (!incomeAllowed) continue;
|
||
income += cents;
|
||
at(day, cents);
|
||
} else {
|
||
spending -= cents * scale;
|
||
at(day, cents * scale);
|
||
}
|
||
}
|
||
if (flows.spread && flows.spreadTo >= flows.spreadFrom) {
|
||
const cents = flows.spread * (flows.spreadTo - flows.spreadFrom + 1);
|
||
if (cents >= 0) {
|
||
if (!incomeAllowed) return;
|
||
income += cents;
|
||
for (let day = flows.spreadFrom; day <= flows.spreadTo; day++) at(day, flows.spread);
|
||
} else {
|
||
spending -= cents * scale;
|
||
for (let day = flows.spreadFrom; day <= flows.spreadTo; day++)
|
||
at(day, flows.spread * scale);
|
||
}
|
||
}
|
||
};
|
||
for (const item of inputs.personal) addItem(item, spendGrowth, !retired);
|
||
// Spending not in the recurring list: spread over the month.
|
||
const remaining = partial ? (firstDay + length - 1 - todayNumber) / length : 1;
|
||
const other = params.otherSpendEuros * 100 * spendGrowth;
|
||
spending += other * remaining;
|
||
spreadMonth(-other);
|
||
if (first >= '2027-01-01') {
|
||
// Health insurance premium: collected on the 1st.
|
||
const premium = params.healthInsuranceEuros * 100 * spendGrowth;
|
||
const day = dayOfMonth(year, month, 1);
|
||
if (day > todayNumber) {
|
||
spending += premium;
|
||
at(day, -premium);
|
||
}
|
||
}
|
||
if (first < moveMonth) {
|
||
spending += facts.preMoveHouseholdCents * remaining;
|
||
spreadMonth(-facts.preMoveHouseholdCents);
|
||
} else {
|
||
const share = pct(params.householdSharePercent) * spendGrowth;
|
||
const extra = params.householdExtraEuros * 100 * share;
|
||
spending += extra * remaining;
|
||
spreadMonth(-extra);
|
||
for (const item of inputs.household) addItem(item, share, false);
|
||
}
|
||
if (first === moveMonth && moveDay > todayNumber) {
|
||
spending += params.moveCostEuros * 100;
|
||
at(moveDay, -params.moveCostEuros * 100);
|
||
}
|
||
if (!partial && year >= facts.duoFrom && year <= facts.duoUntil) {
|
||
// DUO collects the repayment around the 24th.
|
||
spending += facts.duoCents;
|
||
at(dayOfMonth(year, month, 24), -facts.duoCents);
|
||
}
|
||
for (const item of inputs.oneOffs) {
|
||
const day = dayNumber(item.date);
|
||
if (day > todayNumber && day >= firstDay && day < firstDay + length) {
|
||
if (item.amountCents >= 0) income += item.amountCents;
|
||
else spending -= item.amountCents;
|
||
at(day, item.amountCents);
|
||
}
|
||
}
|
||
let tax = 0;
|
||
if (month === 5 && !partial) {
|
||
// Box 3 assessment for the previous year, paid at the start of May.
|
||
tax = box3Due;
|
||
box3Due = 0;
|
||
at(firstDay, -tax);
|
||
}
|
||
// Returns: crash and correction years spread their drop over March–October; daily
|
||
// compounding gives the same monthly return.
|
||
const market = markets.yearReturn(year);
|
||
const monthRate =
|
||
market.kind === 'normal'
|
||
? Math.pow(1 + market.rate, 1 / 12) - 1
|
||
: month >= 3 && month <= 10
|
||
? Math.pow(1 + market.rate, 1 / 8) - 1
|
||
: 0;
|
||
const dayGrowth = Math.pow(1 + monthRate, 1 / length);
|
||
const dayCash = Math.pow(1 + pct(params.cashRatePercent), 1 / 12 / length);
|
||
const dayInflation = Math.pow(monthlyInflation, 1 / length);
|
||
const sweepDay = Math.min(paid + 1, firstDay + length - 1);
|
||
let dayDeflator = partial ? 1 : startDeflator;
|
||
buffer = facts.bufferCents * deflator;
|
||
for (let offset = 0; offset < length; offset++) {
|
||
const day = firstDay + offset;
|
||
if (day <= todayNumber) continue;
|
||
if (day > endNumber) break;
|
||
dayDeflator *= dayInflation;
|
||
cash += flow[offset];
|
||
invested *= dayGrowth;
|
||
cash *= dayCash;
|
||
if (day === sweepDay && cash > buffer) {
|
||
// Investing right after payday: everything above the buffer.
|
||
const extra = cash - buffer;
|
||
invested += extra;
|
||
basis += extra;
|
||
contributedThisYear += extra;
|
||
cash = buffer;
|
||
} else if (cash < 0 && invested > 0) {
|
||
// Withdrawals: sell investments to cover the shortfall.
|
||
const sold = Math.min(invested, -cash);
|
||
const soldBasis = invested > 0 ? basis * (sold / invested) : 0;
|
||
realisedThisYear += sold - soldBasis;
|
||
basis -= soldBasis;
|
||
invested -= sold;
|
||
contributedThisYear -= sold;
|
||
cash += sold;
|
||
}
|
||
const total = cash + invested;
|
||
points.push({ date: isoDay(day), cash, invested, total, real: total / dayDeflator });
|
||
for (const milestone of milestones)
|
||
if (milestone.date === null && milestone.test()) milestone.date = first.slice(0, 7);
|
||
}
|
||
if (month === 12) {
|
||
const gain = invested - investedAtYearStart - contributedThisYear;
|
||
if (year >= 2028) {
|
||
const taxable = params.box3 === 'accrual' ? gain : realisedThisYear;
|
||
const net = taxable - lossCarried;
|
||
if (net < 0) {
|
||
lossCarried = -net;
|
||
box3Due = 0;
|
||
} else {
|
||
lossCarried = 0;
|
||
box3Due = Math.max(0, net - facts.box3FreeCents) * facts.box3Rate;
|
||
}
|
||
} 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 * facts.box3Rate;
|
||
}
|
||
}
|
||
const total = cash + invested;
|
||
if (!partial) {
|
||
recentRealIncome.push(income / deflator);
|
||
if (recentRealIncome.length > 12) recentRealIncome.shift();
|
||
}
|
||
row.income += income;
|
||
row.spending += spending + tax;
|
||
row.box3 += tax;
|
||
row.total = total;
|
||
row.invested = invested;
|
||
row.real = total / deflator;
|
||
if (!partial) {
|
||
row.grossMonthly = employed ? gross : 0;
|
||
row.baseReal = retired ? 0 : (gross * 12) / deflator / 100;
|
||
row.role = retired ? 'Retired' : roleFor(row.baseReal);
|
||
} else {
|
||
row.grossMonthly = gross;
|
||
row.baseReal = (gross * 12) / 100;
|
||
row.role = roleFor(row.baseReal);
|
||
}
|
||
for (const milestone of milestones)
|
||
if (milestone.date === null && milestone.test()) milestone.date = first.slice(0, 7);
|
||
if (last >= inputs.endDate) break;
|
||
}
|
||
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, todaysEuros = false): ChartSeries[] {
|
||
const line = (key: 'total' | 'cash' | 'invested') =>
|
||
result.points.map((point) => ({
|
||
date: point.date,
|
||
value: Math.round(
|
||
todaysEuros && point.total ? point[key] * (point.real / point.total) : 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,
|
||
todaysEuros = false
|
||
): 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(todaysEuros ? point.real : point.total)
|
||
}))
|
||
}));
|
||
}
|