money: pessimistic/realistic/optimistic scenarios on the wealth chart (realistic by default), editable assumptions, yearly table and milestones
All checks were successful
Deploy production / deploy (push) Successful in 2m30s

This commit is contained in:
2026-10-24 14:39:25 +00:00
parent 35f78ec829
commit 8a29d5ad59
4 changed files with 994 additions and 14 deletions

View File

@@ -0,0 +1,103 @@
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 − 3k gym − 90k pre-move household.
expect(november.total).toBeCloseTo(350000 + 300000 + 10000 + 28000 - 3000 - 90000, 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);
});
});