Files
website/src/lib/money-tracker/scenarios.ts
Stan Runge d5e2ec346b
Some checks failed
Deploy production / deploy (push) Failing after 1m6s
money: rent detection matches whole words ("living with parents" is not the rent)
The scenario move date came from the first Joint item matching /rent|huur/,
which since the "Groceries (living with parents)" item matched that one, so
the scenarios never moved out (no rent, the pre-move contribution forever).
2026-10-26 01:20:39 +00:00

871 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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);
// Whole words: "Groceries (living with parents)" is not the rent.
const rent = household.find((item) => /\b(rent|huur)\b/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)
}))
}));
}