All checks were successful
Deploy production / deploy (push) Successful in 2m32s
114 lines
4.5 KiB
TypeScript
114 lines
4.5 KiB
TypeScript
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);
|
||
});
|
||
});
|