Files
website/src/lib/money-tracker/scenarios.test.ts
2026-10-24 14:46:32 +00:00

114 lines
4.5 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, 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: '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(['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,
recoveryPercent: 0,
cashRatePercent: 0,
sideStartEuros: 0,
sideEndEuros: 0,
spendGrowthPercent: 0,
otherSpendEuros: 0,
householdExtraEuros: 0,
healthInsuranceEuros: 0,
moveCostEuros: 0,
bonusPercentOfTarget: 0
};
it('runs monthly 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.end.date).toBe('2030-12-31');
expect(result.points).toHaveLength(51);
expect(result.years.map((row) => row.year)).toEqual([2026, 2027, 2028, 2029, 2030]);
});
it('fills the buffer before investing and counts recurring income and costs', () => {
const result = runScenario(inputs, flat);
const november = result.points[1];
expect(november.cash).toBe(500000);
// 300k salary + 10k benefit + 28k one-off + 13th month 3/12 − 3k gym − 90k pre-move household.
expect(november.total).toBeCloseTo(350000 + 300000 + 10000 + 28000 + 51250 - 3000 - 90000, 0);
});
it('pays contract extras in May (holiday pay) and August (bonus)', () => {
const delta = (points: { total: number }[], index: number) =>
points[index].total - points[index - 1].total;
const flatRun = runScenario(inputs, flat).points;
// Index 7 is May 2027: holiday pay 8/12 of €1,950.
expect(delta(flatRun, 7) - delta(flatRun, 6)).toBeCloseTo(130000, 0);
const bonus = runScenario(inputs, { ...flat, bonusPercentOfTarget: 100 }).points;
// Index 10 is August 2027: the first bonus, €850.
expect(delta(bonus, 10) - delta(flatRun, 10)).toBeCloseTo(85000, 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 a crash in its year and a job gap after the contract', () => {
const calm = runScenario(inputs, { ...flat, returnPercent: 5, recoveryPercent: 5 });
const crash = runScenario(inputs, {
...flat,
returnPercent: 5,
recoveryPercent: 5,
crashPercent: -30,
crashYear: 2027
});
expect(crash.end.total).toBeLessThan(calm.end.total);
const gap = runScenario(inputs, { ...flat, gapMonths: 3, gapNetEuros: 1000 });
expect(gap.end.total).toBeLessThan(runScenario(inputs, flat).end.total);
});
});