money: day-by-day forecast with real payment dates
The wealth forecast now runs per day instead of per month, so it shows the same sawtooth as the actual history: - recurring items hit on the day the bank history shows (most common day of the last four months, clamped to month end), else their start date - "every N weeks" items (Basic-Fit, haircut) run on an exact N-day cadence from the last charge; yearly items on their renewal date - salary and its extras (holiday pay in May, 13th month in November, bonus, settlement) land on the payday from history (contract 25th as fallback), moved to the Friday before a weekend; toeslagen move to the Monday after - variable budgets are spread per day, the vacation budget over Jul-Aug - excess above the buffer is invested the day after payday; returns compound daily - long horizons stay fast (~35 ms for 100 years): the chart thins each pixel bucket to its low and high
This commit is contained in:
@@ -76,29 +76,73 @@ describe('runScenario', () => {
|
||||
moveCostEuros: 0,
|
||||
bonusPercentOfTarget: 0
|
||||
};
|
||||
it('runs monthly to the end date and starts from today', () => {
|
||||
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(51);
|
||||
expect(result.points).toHaveLength(1530);
|
||||
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 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; €900 pre-move household is
|
||||
// spread at €30 a day in November.
|
||||
expect(on(points, '2026-11-25').total - on(points, '2026-11-24').total).toBeCloseTo(
|
||||
300000 + 51250 - 3000,
|
||||
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 - 3000,
|
||||
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 €900 pre-move household. November: 300k salary + 10k benefit
|
||||
// + 28k one-off + 13th month 3/12 − 3k gym − 90k pre-move household.
|
||||
expect(on(points, '2026-11-30').total).toBeCloseTo(
|
||||
350000 - (90000 * 7) / 31 + 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;
|
||||
it('pays contract extras on payday in May (holiday pay) and August (bonus)', () => {
|
||||
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 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;
|
||||
// Index 10 is August 2027: the first bonus, €850.
|
||||
expect(delta(bonus, 10) - delta(flatRun, 10)).toBeCloseTo(85000, 0);
|
||||
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 - 90000 / 31,
|
||||
0
|
||||
);
|
||||
});
|
||||
it('orders the default scenarios from pessimistic to optimistic', () => {
|
||||
const [pessimistic, realistic, optimistic] = (
|
||||
@@ -148,7 +192,7 @@ describe('runScenario', () => {
|
||||
hopGapMonths: 4,
|
||||
salaryCapEuros: 60000
|
||||
});
|
||||
expect(result.points).toHaveLength(1201);
|
||||
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']);
|
||||
|
||||
Reference in New Issue
Block a user