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:
2026-10-26 01:13:43 +00:00
parent 5d732fc750
commit 807a6b3104
8 changed files with 1163 additions and 248 deletions

View File

@@ -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']);