Files
website/src/lib/money-tracker/scenarios.test.ts
Stan Runge 78b69c7d8f
All checks were successful
Deploy production / deploy (push) Successful in 1m13s
Money forecast: calm optimistic start, rising plateau, thin solid projected lines
- Optimistic: no crash or correction through 2031 (5 years from today), earning the
  9.5% average; cycle resumes after (correction 2035, crash 2040). Salary plateau
  grows 2%/yr real so pay never stalls.
- Realistic keeps its single -30% crash in 2029; pessimistic unchanged.
- Projected lines are now thin, slightly lighter solid lines in every view; legend updated.
- Explanation text lists the crash/correction years and recovery time.
2026-10-26 08:25:52 +00:00

261 lines
11 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.

import { describe, expect, it } from 'vitest';
import {
defaultScenarios,
marketReturns,
roleFor,
runScenario,
scenarioInputs,
type ScenarioParams
} from './scenarios';
const accounts = [
{ id: 1, name: 'Revolut', kind: 'checking' },
{ id: 4, name: 'Joint', kind: 'checking' },
{ id: 5, name: 'Broker', kind: 'investment' },
{ id: 9, name: 'Savings', kind: 'savings', includeInTotal: false }
];
const inputs = scenarioInputs({
accounts,
balances: new Map([
[1, 200000],
[4, 100000],
[5, 50000],
[9, 999999]
]),
recurring: [
{ name: 'Cargill salary', amountCents: 300000, cadence: 'monthly', accountId: 1 },
{ name: 'Gym', amountCents: -3000, cadence: 'monthly', accountId: 1 },
{ name: 'Benefit', amountCents: 10000, cadence: 'monthly', accountId: 1, endsOn: '2026-12-31' },
{
name: 'Groceries (living with parents)',
amountCents: -5000,
cadence: 'monthly',
accountId: 4,
endsOn: '2026-12-31'
},
{
name: 'Rent + utilities (estimate)',
amountCents: -180000,
cadence: 'monthly',
accountId: 4,
startsOn: '2027-03-01'
},
{ name: 'Partner income', amountCents: 250000, cadence: 'monthly', accountId: 4 },
{ name: 'Hidden', amountCents: -5000, cadence: 'monthly', accountId: 9 }
],
forecasts: [
{ date: '2026-11-15', amountCents: 28000, description: 'Side job', accountId: 1 },
{ date: '2027-09-30', amountCents: 250000, description: 'Bonus Cargill', accountId: 1 }
],
futureTransactions: [],
today: '2026-10-24',
endDate: '2030-12-31'
});
describe('scenarioInputs', () => {
it('splits tracker data into balances, salary, personal and household items', () => {
expect(inputs.spendableCents).toBe(300000);
expect(inputs.investedCents).toBe(50000);
expect(inputs.netSalaryCents).toBe(300000);
expect(inputs.personal.map((item) => item.name)).toEqual(['Gym', 'Benefit']);
expect(inputs.household.map((item) => item.name)).toEqual([
'Groceries (living with parents)',
'Rent + utilities (estimate)'
]);
expect(inputs.moveDate).toBe('2027-03-01');
// Salary extras are modelled by the scenarios, other one-offs pass through.
expect(inputs.oneOffs.map((item) => item.description)).toEqual(['Side job']);
});
});
describe('runScenario', () => {
const flat: ScenarioParams = {
...defaultScenarios.realistic,
returnPercent: 0,
crashPercent: 0,
correctionPercent: 0,
cashRatePercent: 0,
inflationPercent: 0,
hopEveryYears: 0,
sideStartEuros: 0,
sideEndEuros: 0,
spendGrowthPercent: 0,
otherSpendEuros: 0,
householdExtraEuros: 0,
healthInsuranceEuros: 0,
moveCostEuros: 0,
bonusPercentOfTarget: 0
};
type Point = { date: string; cash: number; invested: number; total: number };
const on = (points: Point[], date: string) => points.find((point) => point.date === date)!;
it('runs day by day to the end date and starts from today', () => {
const result = runScenario(inputs, flat);
expect(result.points[0]).toMatchObject({ date: '2026-10-24', total: 350000 });
expect(result.points[1].date).toBe('2026-10-25');
expect(result.end.date).toBe('2030-12-31');
expect(result.points).toHaveLength(1530);
expect(result.years.map((row) => row.year)).toEqual([2026, 2027, 2028, 2029, 2030]);
});
it('pays salary on payday, sweeps the excess the day after and counts recurring income and costs', () => {
const points = runScenario(inputs, flat).points;
// Without history the contract payday (the 25th) is used. Before the move only the Joint
// recurring items count: €50 groceries (living with parents), spread at €1.67 a day.
expect(on(points, '2026-11-25').total - on(points, '2026-11-24').total).toBeCloseTo(
300000 + 51250 - 5000 / 30,
0
);
// The benefit (paid on the 1st, a Sunday) moves to Monday.
expect(on(points, '2026-11-02').total - on(points, '2026-11-01').total).toBeCloseTo(
10000 - 5000 / 30,
0
);
expect(on(points, '2026-11-25').cash).toBeGreaterThan(500000);
expect(on(points, '2026-11-26').cash).toBe(500000);
// Rest of October: 7/31 of the €50 groceries. November: 300k salary + 10k benefit
// + 28k one-off + 13th month 3/12 − 3k gym − 5k groceries. No flat Joint contribution.
expect(on(points, '2026-11-30').total).toBeCloseTo(
350000 - (5000 * 7) / 31 + 300000 + 10000 + 28000 + 51250 - 3000 - 5000,
0
);
});
it('pays contract extras on payday in May (holiday pay) and August (bonus)', () => {
const flatRun = runScenario(inputs, flat).points;
const jump = (points: typeof flatRun, date: string, before: string) =>
on(points, date).total - on(points, before).total;
// Holiday pay 8/12 of €1,950 on top of the salary.
expect(jump(flatRun, '2027-05-25', '2027-05-24')).toBeCloseTo(300000 + 130000, 0);
expect(jump(flatRun, '2027-05-24', '2027-05-23')).toBeCloseTo(0, 0);
const bonus = runScenario(inputs, { ...flat, bonusPercentOfTarget: 100 }).points;
expect(on(bonus, '2027-08-24').total - on(flatRun, '2027-08-24').total).toBeCloseTo(0, 0);
expect(on(bonus, '2027-08-25').total - on(flatRun, '2027-08-25').total).toBeCloseTo(85000, 0);
});
it('uses the payday from history and moves it before a weekend', () => {
const withHistory = scenarioInputs({
accounts,
balances: new Map([[1, 200000]]),
recurring: [
{ name: 'Cargill salary', amountCents: 300000, cadence: 'monthly', accountId: 1 }
],
forecasts: [],
futureTransactions: [],
history: [
{ date: '2026-09-21', amountCents: 298000, description: 'Salaris Cargill' },
{ date: '2026-10-21', amountCents: 298653, description: 'Salaris Cargill' }
],
today: '2026-10-24',
endDate: '2027-01-31'
});
expect(withHistory.payday?.day).toBe(21);
const points = runScenario(withHistory, flat).points;
// 21 November 2026 is a Saturday, so the salary lands on Friday the 20th.
expect(on(points, '2026-11-20').total - on(points, '2026-11-19').total).toBeGreaterThan(300000);
expect(on(points, '2026-12-21').total - on(points, '2026-12-20').total).toBeCloseTo(300000, 0);
});
it('orders the default scenarios from pessimistic to optimistic', () => {
const [pessimistic, realistic, optimistic] = (
['pessimistic', 'realistic', 'optimistic'] as const
).map((id) => runScenario(inputs, defaultScenarios[id]).end.total);
expect(pessimistic).toBeLessThan(realistic);
expect(realistic).toBeLessThan(optimistic);
});
it('applies crashes and job gaps', () => {
const calm = runScenario(inputs, { ...flat, returnPercent: 5 });
const crash = runScenario(inputs, {
...flat,
returnPercent: 5,
crashPercent: -30,
crashEveryYears: 10,
firstCrashYear: 2027
});
// The crash year itself ends lower; later years catch up to the same long-run average.
const at2027 = (result: typeof calm) => result.years.find((row) => row.year === 2027)!.total;
expect(at2027(crash)).toBeLessThan(at2027(calm));
const gap = runScenario(inputs, { ...flat, gapMonths: 3, gapNetEuros: 1000 });
expect(gap.end.total).toBeLessThan(runScenario(inputs, flat).end.total);
});
it('averages the crash cycle out at the expected return', () => {
const markets = marketReturns({
...flat,
returnPercent: 7,
crashPercent: -30,
crashEveryYears: 10,
firstCrashYear: 2029,
correctionPercent: -10
});
let growth = 1;
for (let year = 2029; year < 2039; year++) growth *= 1 + markets.yearReturn(year).rate;
expect(growth).toBeCloseTo(Math.pow(1.07, 10), 6);
expect(markets.yearReturn(2029).kind).toBe('crash');
expect(markets.yearReturn(2034).kind).toBe('correction');
expect(markets.yearReturn(2028).kind).toBe('normal');
});
it('keeps the optimistic start calm and its cycle after that', () => {
const markets = marketReturns(defaultScenarios.optimistic, 2026);
for (let year = 2026; year <= 2031; year++)
expect(markets.yearReturn(year)).toEqual({ kind: 'calm', rate: 0.095 });
expect(markets.yearReturn(2032).kind).toBe('normal');
expect(markets.yearReturn(2035)).toMatchObject({ kind: 'correction', rate: -0.15 });
expect(markets.yearReturn(2040)).toMatchObject({ kind: 'crash', rate: -0.38 });
// No down year at all in the first five years of the run.
const run = runScenario(inputs, defaultScenarios.optimistic);
const growth = run.years.filter((row) => row.year <= 2031).map((row) => row.invested);
growth.slice(1).forEach((value, index) => expect(value).toBeGreaterThan(growth[index]));
});
it('gives realistic one crash in its first ten years and keeps pessimistic as it was', () => {
const shocks = (id: 'realistic' | 'pessimistic') => {
const markets = marketReturns(defaultScenarios[id], 2026);
return Array.from({ length: 10 }, (_, offset) => 2026 + offset)
.map((year) => ({ year, ...markets.yearReturn(year) }))
.filter((row) => row.kind === 'crash');
};
expect(shocks('realistic')).toEqual([{ year: 2029, kind: 'crash', rate: -0.3 }]);
expect(shocks('pessimistic').map((row) => row.year)).toEqual([2027, 2035]);
expect(defaultScenarios.realistic.calmYears).toBe(0);
expect(defaultScenarios.pessimistic.calmYears).toBe(0);
// Without a start year the calm start never applies.
expect(marketReturns(defaultScenarios.optimistic).yearReturn(2030).kind).toBe('crash');
});
it('keeps the optimistic salary rising every working year', () => {
const long = { ...inputs, endDate: '2076-10-24' };
const years = runScenario(long, defaultScenarios.optimistic).years.filter(
(row) => row.year >= 2027 && row.role !== 'Retired'
);
expect(years.length).toBeGreaterThan(40);
years
.slice(1)
.forEach((row, index) => expect(row.baseReal).toBeGreaterThan(years[index].baseReal));
// A flat plateau stalls once it is reached.
const flatCap = runScenario(long, {
...defaultScenarios.optimistic,
salaryCapGrowthPercent: 0
});
const late = flatCap.years.filter((row) => row.year >= 2050 && row.role !== 'Retired');
expect(late.at(-1)!.baseReal).toBeCloseTo(late[0].baseReal, -2);
});
it('hops jobs, caps the salary and retires at the chosen age', () => {
const long = { ...inputs, endDate: '2126-10-24' };
const result = runScenario(long, {
...flat,
firstHopYear: 2029,
hopEveryYears: 3,
hopRaisePercent: 20,
hopGapMonths: 4,
salaryCapEuros: 60000
});
expect(result.points).toHaveLength(36525);
const byYear = new Map(result.years.map((row) => [row.year, row]));
expect(byYear.get(2029)!.events).toEqual(['Between jobs (4 mo)']);
expect(byYear.get(2030)!.events).toEqual(['New job']);
// The gap runs into 2030, when the raise lands, then the plateau caps it.
expect(byYear.get(2030)!.grossMonthly).toBeCloseTo(500000, -1);
expect(byYear.get(2071)!.role).toBe('Retired');
expect(byYear.get(2071)!.events).toContain('Retire at 68');
expect(byYear.get(2070)!.age).toBe(67);
});
it('labels roles by base salary in today’s euros', () => {
expect(roleFor(44700)).toBe('Junior');
expect(roleFor(70000)).toBe('Senior');
expect(roleFor(150000)).toBe('Principal / big-tech staff');
});
});