All checks were successful
Deploy production / deploy (push) Successful in 1m13s
- 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.
261 lines
11 KiB
TypeScript
261 lines
11 KiB
TypeScript
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');
|
||
});
|
||
});
|