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:
@@ -1,8 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { timeGrid, yearLabels, type ChartPoint, type ChartSeries, type GridMode } from './charts';
|
||||
import {
|
||||
decimateSeries,
|
||||
timeGrid,
|
||||
yearLabels,
|
||||
type ChartPoint,
|
||||
type ChartSeries,
|
||||
type GridMode
|
||||
} from './charts';
|
||||
|
||||
let {
|
||||
series,
|
||||
series: input,
|
||||
title,
|
||||
unit = '€',
|
||||
marker,
|
||||
@@ -24,6 +31,8 @@
|
||||
let compact = $derived(width < 520);
|
||||
let height = $derived(compact ? 240 : 280);
|
||||
let plot = $derived({ left: compact ? 52 : 72, right: 14, top: 18, bottom: 30 });
|
||||
// Daily projections over decades: keep each pixel column's low and high so spikes stay.
|
||||
let series = $derived(decimateSeries(input, width - plot.left - plot.right));
|
||||
let values = $derived(series.flatMap((item) => item.points.map((point) => point.value)));
|
||||
let times = $derived(series.flatMap((item) => item.points.map((point) => time(point.date))));
|
||||
let scale = $derived(niceScale(Math.min(0, ...values), Math.max(0, ...values)));
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
accountBalances,
|
||||
accountColor,
|
||||
balanceSeries,
|
||||
decimateSeries,
|
||||
forecastSeries,
|
||||
parseHistoryRange,
|
||||
gridFor,
|
||||
@@ -119,7 +120,18 @@ describe('money tracker calculations', () => {
|
||||
settings,
|
||||
'2026-09-23'
|
||||
);
|
||||
expect(series[0].points.map((point) => point.value)).toEqual([100000, 100000, 160000, 210244]);
|
||||
const on = (index: number, date: string) =>
|
||||
series[index].points.find((point) => point.date === date)?.value;
|
||||
expect(series[0].points).toHaveLength(69);
|
||||
expect(
|
||||
['2026-09-23', '2026-09-30', '2026-10-31', '2026-11-30'].map((date) => on(0, date))
|
||||
).toEqual([100000, 100000, 160000, 210244]);
|
||||
// Day by day: the monthly item lands on the 1st and the estimate on the 15th.
|
||||
expect(on(1, '2026-10-01')).toBe(140000);
|
||||
expect(on(1, '2026-10-15')).toBe(160000);
|
||||
// Excess above the reserve moves into investments at the end of the month without a payday.
|
||||
expect(on(1, '2026-10-31')).toBe(100000);
|
||||
expect(on(2, '2026-10-31')).toBe(60000);
|
||||
expect(series[1].points.at(-1)?.value).toBe(100000);
|
||||
expect(series[2].points.at(-1)?.value).toBe(110244);
|
||||
});
|
||||
@@ -151,9 +163,10 @@ describe('money tracker calculations', () => {
|
||||
},
|
||||
'2026-09-23'
|
||||
);
|
||||
expect(projection[0].points.map((point) => point.value)).toEqual([
|
||||
40054, 40054, 167191, 294602
|
||||
]);
|
||||
const total = new Map(projection[0].points.map((point) => [point.date, point.value]));
|
||||
expect(
|
||||
['2026-09-23', '2026-09-30', '2026-10-31', '2026-11-30'].map((date) => total.get(date))
|
||||
).toEqual([40054, 40054, 167191, 294602]);
|
||||
expect(projection[1].points.at(-1)?.value).toBe(100000);
|
||||
expect(projection[2].points.at(-1)?.value).toBe(194602);
|
||||
});
|
||||
@@ -373,4 +386,62 @@ describe('history range', () => {
|
||||
'2120'
|
||||
]);
|
||||
});
|
||||
|
||||
it('draws a day-by-day sawtooth: salary on payday, costs on their days, invest the day after', () => {
|
||||
const projection = wealthProjection(
|
||||
[
|
||||
{ id: 1, kind: 'spendable', openingBalanceCents: 0 },
|
||||
{ id: 2, kind: 'investment', openingBalanceCents: 0 }
|
||||
],
|
||||
new Map([
|
||||
[1, 150000],
|
||||
[2, 0]
|
||||
]),
|
||||
[],
|
||||
[],
|
||||
[
|
||||
{ name: 'Cargill salary', amountCents: 300000, cadence: 'monthly', accountId: 1 },
|
||||
{ name: 'Spotify', amountCents: -1199, cadence: 'monthly', accountId: 1 }
|
||||
],
|
||||
{
|
||||
investmentAccountId: 2,
|
||||
reserveCents: 100000,
|
||||
annualReturnPercent: '0.00',
|
||||
contributionMode: 'excess',
|
||||
fixedMonthlyCents: 0,
|
||||
includeRecurring: true,
|
||||
endDate: '2026-12-31'
|
||||
},
|
||||
'2026-10-26',
|
||||
null,
|
||||
[
|
||||
{ date: '2026-10-21', amountCents: 298653, description: 'Salaris Cargill' },
|
||||
{ date: '2026-10-24', amountCents: -1199, description: 'Spotify AB' }
|
||||
]
|
||||
);
|
||||
const cash = new Map(projection[1].points.map((point) => [point.date, point.value]));
|
||||
const invested = new Map(projection[2].points.map((point) => [point.date, point.value]));
|
||||
expect(cash.get('2026-11-19')).toBe(150000);
|
||||
// 21 Nov is a Saturday: paid on Friday the 20th, invested above the reserve on the 21st.
|
||||
expect(cash.get('2026-11-20')).toBe(450000);
|
||||
expect(cash.get('2026-11-21')).toBe(100000);
|
||||
expect(invested.get('2026-11-21')).toBe(350000);
|
||||
expect(cash.get('2026-11-24')).toBe(100000 - 1199);
|
||||
expect(cash.get('2026-12-21')).toBe(100000 - 1199 + 300000);
|
||||
});
|
||||
|
||||
it('thins long daily series to each bucket’s low and high, keeping both ends', () => {
|
||||
const points = Array.from({ length: 3650 }, (_, index) => ({
|
||||
date: new Date(Date.UTC(2026, 0, 1 + index)).toISOString().slice(0, 10),
|
||||
value: index % 30 === 0 ? 1000 : index % 30 === 15 ? -1000 : 0
|
||||
}));
|
||||
const series = [{ label: 'Total', color: '#000', points }];
|
||||
const [thin] = decimateSeries(series, 100);
|
||||
expect(thin.points.length).toBeLessThanOrEqual(202);
|
||||
expect(thin.points[0]).toEqual(points[0]);
|
||||
expect(thin.points.at(-1)).toEqual(points.at(-1));
|
||||
expect(Math.max(...thin.points.map((point) => point.value))).toBe(1000);
|
||||
expect(Math.min(...thin.points.map((point) => point.value))).toBe(-1000);
|
||||
expect(decimateSeries(series, 5000)).toBe(series);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
import {
|
||||
businessDay,
|
||||
dayNumber,
|
||||
dayOfMonth,
|
||||
daysInMonth,
|
||||
isoDay,
|
||||
monthFlows,
|
||||
scheduleFor,
|
||||
type HistoryTransaction
|
||||
} from './schedule';
|
||||
|
||||
export type ChartPoint = { date: string; value: number };
|
||||
export type ChartSeries = {
|
||||
label: string;
|
||||
@@ -111,6 +122,7 @@ type ProjectionAccount = {
|
||||
};
|
||||
type ProjectionMovement = { date: string; amountCents: number; accountId: number | null };
|
||||
type ProjectionRecurring = {
|
||||
name?: string;
|
||||
amountCents: number;
|
||||
cadence: string;
|
||||
accountId: number | null;
|
||||
@@ -127,6 +139,11 @@ type ProjectionSettings = {
|
||||
endDate?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* The setting-based ("Simple") projection, day by day: dated transactions and estimates on their
|
||||
* date, recurring items on the days they hit (see schedule.ts), returns compounding daily, and the
|
||||
* excess above the reserve invested the day after payday (month end when there is no salary).
|
||||
*/
|
||||
export function wealthProjection(
|
||||
accounts: ProjectionAccount[],
|
||||
balances: Map<number, number>,
|
||||
@@ -136,11 +153,15 @@ export function wealthProjection(
|
||||
settings: ProjectionSettings | null,
|
||||
today: string,
|
||||
/** Overrides the settings' end date, e.g. with the chart's forecast horizon. */
|
||||
endDate?: string | null
|
||||
endDate?: string | null,
|
||||
/** Bank transactions through today, to find the day each recurring item hits. */
|
||||
history: HistoryTransaction[] = []
|
||||
): ChartSeries[] {
|
||||
const excluded = excludedIds(accounts);
|
||||
const counted = (accountId: number | null) => accountId === null || !excluded.has(accountId);
|
||||
const accountKind = new Map(accounts.map((account) => [account.id, account.kind]));
|
||||
const isInvestment = (accountId: number | null) =>
|
||||
accountId !== null && accountKind.get(accountId) === 'investment';
|
||||
const investmentId = settings?.investmentAccountId ?? null;
|
||||
const validInvestmentId =
|
||||
investmentId !== null && accountKind.get(investmentId) === 'investment' ? investmentId : null;
|
||||
@@ -156,13 +177,10 @@ export function wealthProjection(
|
||||
const totalPoints: ChartPoint[] = [{ date: today, value: spendable + invested }];
|
||||
const spendablePoints: ChartPoint[] = [{ date: today, value: spendable }];
|
||||
const investedPoints: ChartPoint[] = [{ date: today, value: invested }];
|
||||
const movements = [...futureTransactions, ...forecasts].filter(
|
||||
(entry) => entry.date > today && counted(entry.accountId)
|
||||
);
|
||||
const latestMovement = movements.reduce(
|
||||
(latest, entry) => (entry.date > latest ? entry.date : latest),
|
||||
''
|
||||
);
|
||||
const movements = [...futureTransactions, ...forecasts]
|
||||
.filter((entry) => entry.date > today && counted(entry.accountId))
|
||||
.sort((a, b) => a.date.localeCompare(b.date));
|
||||
const latestMovement = movements.at(-1)?.date ?? '';
|
||||
const fallback = new Date(`${today}T12:00:00Z`);
|
||||
fallback.setUTCFullYear(fallback.getUTCFullYear() + 1);
|
||||
const horizon =
|
||||
@@ -171,43 +189,71 @@ export function wealthProjection(
|
||||
: settings?.endDate && settings.endDate > today
|
||||
? settings.endDate
|
||||
: latestMovement || fallback.toISOString().slice(0, 10);
|
||||
const monthEnd = (year: number, month: number) =>
|
||||
new Date(Date.UTC(year, month + 1, 0)).toISOString().slice(0, 10);
|
||||
const start = new Date(`${today}T12:00:00Z`);
|
||||
let year = start.getUTCFullYear();
|
||||
let month = start.getUTCMonth();
|
||||
const todayNumber = dayNumber(today);
|
||||
const endNumber = dayNumber(horizon);
|
||||
const items =
|
||||
settings?.includeRecurring === false
|
||||
? []
|
||||
: recurring
|
||||
.filter((item) => counted(item.accountId))
|
||||
.map((item) => {
|
||||
const named = { ...item, name: item.name ?? '' };
|
||||
return { item: named, schedule: scheduleFor(named, history, today) };
|
||||
});
|
||||
const salary = items.find(
|
||||
({ item, schedule }) =>
|
||||
item.amountCents > 0 && /salary|salaris/i.test(item.name) && schedule.kind === 'monthly'
|
||||
);
|
||||
const yearly = Number(settings?.annualReturnPercent ?? 5) / 100;
|
||||
const monthlyGrowth = Math.pow(1 + yearly, 1 / 12) - 1;
|
||||
for (let index = 0; index < 1212; index++) {
|
||||
const first = `${year}-${String(month + 1).padStart(2, '0')}-01`;
|
||||
const end = monthEnd(year, month);
|
||||
const date = end < horizon ? end : horizon;
|
||||
if (date > today) {
|
||||
for (const entry of movements.filter(
|
||||
(item) => (index === 0 ? item.date > today : item.date >= first) && item.date <= date
|
||||
)) {
|
||||
if (entry.accountId !== null && accountKind.get(entry.accountId) === 'investment') {
|
||||
invested += entry.amountCents;
|
||||
if (entry.accountId === validInvestmentId) indexBalance += entry.amountCents;
|
||||
} else spendable += entry.amountCents;
|
||||
}
|
||||
if (settings?.includeRecurring !== false && index > 0) {
|
||||
for (const item of recurring) {
|
||||
if (!counted(item.accountId)) continue;
|
||||
if ((item.startsOn && item.startsOn > date) || (item.endsOn && item.endsOn < first))
|
||||
continue;
|
||||
const value = Math.round(monthlyCents(item.amountCents, item.cadence));
|
||||
if (item.accountId !== null && accountKind.get(item.accountId) === 'investment') {
|
||||
invested += value;
|
||||
if (item.accountId === validInvestmentId) indexBalance += value;
|
||||
} else spendable += value;
|
||||
}
|
||||
}
|
||||
if (index > 0) {
|
||||
const growth = Math.round(indexBalance * monthlyGrowth);
|
||||
invested += growth;
|
||||
indexBalance += growth;
|
||||
const excess = Math.max(0, spendable - (settings?.reserveCents ?? 100000));
|
||||
const reserve = settings?.reserveCents ?? 100000;
|
||||
let year = Number(today.slice(0, 4));
|
||||
let month = Number(today.slice(5, 7));
|
||||
let next = 0;
|
||||
for (let guard = 0; guard < 1213; guard++) {
|
||||
const firstDay = dayOfMonth(year, month, 1);
|
||||
if (firstDay > endNumber) break;
|
||||
const length = daysInMonth(year, month);
|
||||
const toSpendable = new Float64Array(length);
|
||||
const toInvested = new Float64Array(length);
|
||||
const toIndex = new Float64Array(length);
|
||||
const add = (day: number, cents: number, accountId: number | null) => {
|
||||
const offset = day - firstDay;
|
||||
if (offset < 0 || offset >= length) return;
|
||||
if (isInvestment(accountId)) {
|
||||
toInvested[offset] += cents;
|
||||
if (accountId === validInvestmentId) toIndex[offset] += cents;
|
||||
} else toSpendable[offset] += cents;
|
||||
};
|
||||
while (next < movements.length && dayNumber(movements[next].date) < firstDay + length) {
|
||||
const entry = movements[next++];
|
||||
add(dayNumber(entry.date), entry.amountCents, entry.accountId);
|
||||
}
|
||||
for (const { item, schedule } of items) {
|
||||
const flows = monthFlows(item, schedule, year, month, todayNumber);
|
||||
for (const [day, cents] of flows.events) add(day, cents, item.accountId);
|
||||
for (let day = flows.spreadFrom; day <= flows.spreadTo; day++)
|
||||
add(day, flows.spread, item.accountId);
|
||||
}
|
||||
const paid =
|
||||
salary?.schedule.kind === 'monthly'
|
||||
? businessDay(dayOfMonth(year, month, salary.schedule.day), salary.schedule.shift)
|
||||
: null;
|
||||
const sweepDay =
|
||||
paid === null ? firstDay + length - 1 : Math.min(paid + 1, firstDay + length - 1);
|
||||
const dayGrowth = Math.pow(1 + monthlyGrowth, 1 / length);
|
||||
for (let offset = 0; offset < length; offset++) {
|
||||
const day = firstDay + offset;
|
||||
if (day <= todayNumber) continue;
|
||||
if (day > endNumber) break;
|
||||
spendable += toSpendable[offset];
|
||||
invested += toInvested[offset];
|
||||
indexBalance += toIndex[offset];
|
||||
const growth = indexBalance * (dayGrowth - 1);
|
||||
invested += growth;
|
||||
indexBalance += growth;
|
||||
if (day === sweepDay) {
|
||||
const excess = Math.max(0, spendable - reserve);
|
||||
const contribution =
|
||||
settings?.contributionMode === 'fixed'
|
||||
? Math.min(excess, settings.fixedMonthlyCents)
|
||||
@@ -216,14 +262,14 @@ export function wealthProjection(
|
||||
invested += contribution;
|
||||
indexBalance += contribution;
|
||||
}
|
||||
totalPoints.push({ date, value: spendable + invested });
|
||||
spendablePoints.push({ date, value: spendable });
|
||||
investedPoints.push({ date, value: invested });
|
||||
const date = isoDay(day);
|
||||
totalPoints.push({ date, value: Math.round(spendable + invested) });
|
||||
spendablePoints.push({ date, value: Math.round(spendable) });
|
||||
investedPoints.push({ date, value: Math.round(invested) });
|
||||
}
|
||||
if (end >= horizon) break;
|
||||
month++;
|
||||
if (month === 12) {
|
||||
month = 0;
|
||||
if (month === 13) {
|
||||
month = 1;
|
||||
year++;
|
||||
}
|
||||
}
|
||||
@@ -234,6 +280,57 @@ export function wealthProjection(
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Thins long series for drawing: per time bucket only the lowest and highest point stay (in
|
||||
* date order), so a daily projection over decades keeps its spikes without plotting every day.
|
||||
* The first and last points and the solid/dashed join are always kept.
|
||||
*/
|
||||
export function decimateSeries(series: ChartSeries[], buckets: number): ChartSeries[] {
|
||||
const limit = Math.max(2, Math.floor(buckets)) * 2;
|
||||
if (!series.some((line) => line.points.length > limit)) return series;
|
||||
let from = Infinity;
|
||||
let until = -Infinity;
|
||||
for (const line of series)
|
||||
if (line.points.length) {
|
||||
from = Math.min(from, Date.parse(line.points[0].date));
|
||||
until = Math.max(until, Date.parse(line.points.at(-1)!.date));
|
||||
}
|
||||
const span = until - from || 1;
|
||||
return series.map((line) => {
|
||||
if (line.points.length <= limit) return line;
|
||||
const keep = new Uint8Array(line.points.length);
|
||||
keep[0] = 1;
|
||||
keep[line.points.length - 1] = 1;
|
||||
if (line.dashedFrom) {
|
||||
const join = line.points.findLastIndex((point) => point.date <= line.dashedFrom!);
|
||||
if (join >= 0) keep[join] = 1;
|
||||
}
|
||||
let bucket = -1;
|
||||
let low = -1;
|
||||
let high = -1;
|
||||
const flush = () => {
|
||||
if (low >= 0) keep[low] = 1;
|
||||
if (high >= 0) keep[high] = 1;
|
||||
};
|
||||
line.points.forEach((point, index) => {
|
||||
const at = Math.min(
|
||||
buckets - 1,
|
||||
Math.floor(((Date.parse(point.date) - from) / span) * buckets)
|
||||
);
|
||||
if (at !== bucket) {
|
||||
flush();
|
||||
bucket = at;
|
||||
low = high = index;
|
||||
return;
|
||||
}
|
||||
if (point.value < line.points[low].value) low = index;
|
||||
if (point.value > line.points[high].value) high = index;
|
||||
});
|
||||
flush();
|
||||
return { ...line, points: line.points.filter((_, index) => keep[index]) };
|
||||
});
|
||||
}
|
||||
|
||||
export type TimelineView = 'total' | 'split' | 'accounts';
|
||||
|
||||
/**
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -9,6 +9,17 @@
|
||||
*/
|
||||
|
||||
import type { ChartSeries } from './charts';
|
||||
import {
|
||||
businessDay,
|
||||
dayNumber,
|
||||
dayOfMonth,
|
||||
daysInMonth,
|
||||
isoDay,
|
||||
monthFlows,
|
||||
scheduleFor,
|
||||
type HistoryTransaction,
|
||||
type ItemSchedule
|
||||
} from './schedule';
|
||||
|
||||
export type ScenarioId = 'pessimistic' | 'realistic' | 'optimistic';
|
||||
export const scenarioIds: ScenarioId[] = ['pessimistic', 'realistic', 'optimistic'];
|
||||
@@ -246,6 +257,8 @@ export type ScenarioRecurring = {
|
||||
endsOn?: string | null;
|
||||
};
|
||||
export type ScenarioMovement = { date: string; amountCents: number; description: string };
|
||||
/** A recurring item with the days it hits (see schedule.ts). */
|
||||
export type ScheduledRecurring = ScenarioRecurring & { schedule: ItemSchedule };
|
||||
|
||||
export type ScenarioInputs = {
|
||||
today: string;
|
||||
@@ -254,10 +267,12 @@ export type ScenarioInputs = {
|
||||
investedCents: number;
|
||||
/** Net salary per month from the recurring list. */
|
||||
netSalaryCents: number;
|
||||
/** Salary payday from the bank history (null: the contract's 25th). */
|
||||
payday: { day: number; shift: 'none' | 'before' | 'after'; source: string } | null;
|
||||
/** Recurring items other than salary and Joint income. */
|
||||
personal: ScenarioRecurring[];
|
||||
personal: ScheduledRecurring[];
|
||||
/** Joint recurring costs, shared after the move. */
|
||||
household: ScenarioRecurring[];
|
||||
household: ScheduledRecurring[];
|
||||
/** Rent start: the move month. */
|
||||
moveDate: string | null;
|
||||
oneOffs: ScenarioMovement[];
|
||||
@@ -300,18 +315,6 @@ const salaryPattern = /salary|salaris/i;
|
||||
/** Tracker estimates the scenarios model themselves (holiday pay, 13th month, bonus). */
|
||||
const modelledPattern = /cargill|holiday|vakantiegeld|13th|dertiende|bonus/i;
|
||||
|
||||
function monthly(item: ScenarioRecurring) {
|
||||
return item.cadence === 'weekly'
|
||||
? (item.amountCents * 52) / 12
|
||||
: item.cadence === 'yearly'
|
||||
? item.amountCents / 12
|
||||
: item.amountCents;
|
||||
}
|
||||
|
||||
function activeIn(item: ScenarioRecurring, first: string, last: string) {
|
||||
return !(item.startsOn && item.startsOn > last) && !(item.endsOn && item.endsOn < first);
|
||||
}
|
||||
|
||||
/** Splits tracker data into the pieces the scenario model needs. */
|
||||
export function scenarioInputs(input: {
|
||||
accounts: { id: number; kind: string; name: string; includeInTotal?: boolean }[];
|
||||
@@ -326,6 +329,8 @@ export function scenarioInputs(input: {
|
||||
}[];
|
||||
today: string;
|
||||
endDate?: string | null;
|
||||
/** Bank transactions through today, to find the day each recurring item hits. */
|
||||
history?: HistoryTransaction[];
|
||||
}): ScenarioInputs {
|
||||
const counted = input.accounts.filter((account) => account.includeInTotal !== false);
|
||||
const countedIds = new Set(counted.map((account) => account.id));
|
||||
@@ -354,15 +359,25 @@ export function scenarioInputs(input: {
|
||||
!modelledPattern.test(item.description)
|
||||
)
|
||||
.map(({ date, amountCents, description }) => ({ date, amountCents, description }));
|
||||
const history = input.history ?? [];
|
||||
const scheduled = (item: ScenarioRecurring): ScheduledRecurring => ({
|
||||
...item,
|
||||
schedule: scheduleFor(item, history, input.today)
|
||||
});
|
||||
const salarySchedule = salary ? scheduleFor(salary, history, input.today) : null;
|
||||
return {
|
||||
today: input.today,
|
||||
endDate: input.endDate && input.endDate > input.today ? input.endDate : '2030-12-31',
|
||||
spendableCents: spendable,
|
||||
investedCents: invested,
|
||||
netSalaryCents: salary?.amountCents ?? 0,
|
||||
payday:
|
||||
salarySchedule?.kind === 'monthly' && salarySchedule.last
|
||||
? { day: salarySchedule.day, shift: 'before', source: salarySchedule.source }
|
||||
: null,
|
||||
// Joint income (your partner's salary) pays her share of the household, so it stays out.
|
||||
personal: recurring.filter((item) => item !== salary && !isJoint(item)),
|
||||
household,
|
||||
personal: recurring.filter((item) => item !== salary && !isJoint(item)).map(scheduled),
|
||||
household: household.map(scheduled),
|
||||
moveDate: rent?.startsOn ?? null,
|
||||
oneOffs
|
||||
};
|
||||
@@ -410,6 +425,17 @@ export function marketReturns(params: ScenarioParams) {
|
||||
};
|
||||
}
|
||||
|
||||
/** Contract payday (the 25th) when the bank history has no salary yet. */
|
||||
const contractPayday = 25;
|
||||
|
||||
/**
|
||||
* Runs one scenario day by day from tomorrow to the end date. Every flow lands on its own day:
|
||||
* salary and its extras on payday, recurring items on the days the bank history (or their start
|
||||
* date) puts them, variable budgets spread over the days of the month, one-offs on their date.
|
||||
* Investing happens the day after payday (everything above the buffer), returns compound daily,
|
||||
* and investments are sold on any day the cash would go below zero. Amounts per month are the
|
||||
* same as a monthly model's; only the timing within the month differs.
|
||||
*/
|
||||
export function runScenario(inputs: ScenarioInputs, params: ScenarioParams): ScenarioResult {
|
||||
const facts = scenarioFacts;
|
||||
const baseGross = facts.grossCents;
|
||||
@@ -425,6 +451,9 @@ export function runScenario(inputs: ScenarioInputs, params: ScenarioParams): Sce
|
||||
params.hopEveryYears > 0 &&
|
||||
year >= params.firstHopYear &&
|
||||
(year - params.firstHopYear) % Math.max(1, Math.round(params.hopEveryYears)) === 0;
|
||||
const payday = inputs.payday ?? { day: contractPayday, shift: 'before' as const };
|
||||
const todayNumber = dayNumber(inputs.today);
|
||||
const endNumber = dayNumber(inputs.endDate);
|
||||
let cash = inputs.spendableCents;
|
||||
let invested = inputs.investedCents;
|
||||
let basis = inputs.investedCents;
|
||||
@@ -444,11 +473,12 @@ export function runScenario(inputs: ScenarioInputs, params: ScenarioParams): Sce
|
||||
];
|
||||
const years = new Map<number, ScenarioYear>();
|
||||
const target = facts.targetMonthlyCents;
|
||||
let buffer = facts.bufferCents;
|
||||
const milestones: { label: string; date: string | null; test: () => boolean }[] = [
|
||||
{
|
||||
label: 'Cash buffer full',
|
||||
date: null,
|
||||
test: () => cash >= facts.bufferCents * deflator - 100
|
||||
test: () => cash >= buffer - 100
|
||||
},
|
||||
...[10_000, 100_000, 250_000, 1_000_000].map((euros) => ({
|
||||
label: `€${euros >= 1_000_000 ? `${euros / 1_000_000}M` : `${euros / 1000}k`} invested`,
|
||||
@@ -475,170 +505,268 @@ export function runScenario(inputs: ScenarioInputs, params: ScenarioParams): Sce
|
||||
Number(firstMonth.slice(5, 7)) +
|
||||
1;
|
||||
const moveDate = inputs.moveDate ?? '9999-12-31';
|
||||
for (let index = 0; index < Math.min(totalMonths, 1212); index++) {
|
||||
const moveMonth = moveDate.slice(0, 7) + '-01';
|
||||
const moveDay = inputs.moveDate ? dayNumber(inputs.moveDate) : 0;
|
||||
const yearRow = (year: number) =>
|
||||
years.get(year) ??
|
||||
years
|
||||
.set(year, {
|
||||
year,
|
||||
income: 0,
|
||||
spending: 0,
|
||||
saved: 0,
|
||||
savingsRate: 0,
|
||||
total: 0,
|
||||
invested: 0,
|
||||
real: 0,
|
||||
box3: 0,
|
||||
age: year - facts.birthYear,
|
||||
grossMonthly: 0,
|
||||
baseReal: 0,
|
||||
role: '',
|
||||
events: []
|
||||
})
|
||||
.get(year)!;
|
||||
// Index -1 is the rest of the current month (only recurring items, budgets and one-offs; the
|
||||
// salary for it has been paid), then one index per calendar month.
|
||||
for (let index = -1; index < Math.min(totalMonths, 1212); index++) {
|
||||
const first = addMonths(firstMonth, index);
|
||||
const last = monthEnd(first);
|
||||
const date = last < inputs.endDate ? last : inputs.endDate;
|
||||
const year = Number(first.slice(0, 4));
|
||||
const month = Number(first.slice(5, 7));
|
||||
deflator *= monthlyInflation;
|
||||
const row =
|
||||
years.get(year) ??
|
||||
years
|
||||
.set(year, {
|
||||
year,
|
||||
income: 0,
|
||||
spending: 0,
|
||||
saved: 0,
|
||||
savingsRate: 0,
|
||||
total: 0,
|
||||
invested: 0,
|
||||
real: 0,
|
||||
box3: 0,
|
||||
age: year - facts.birthYear,
|
||||
grossMonthly: 0,
|
||||
baseReal: 0,
|
||||
role: '',
|
||||
events: []
|
||||
})
|
||||
.get(year)!;
|
||||
const firstDay = dayNumber(first);
|
||||
const length = daysInMonth(year, month);
|
||||
const partial = index < 0;
|
||||
const row = yearRow(year);
|
||||
const startDeflator = deflator;
|
||||
// The current month only inflates over its remaining days.
|
||||
const monthInflation = partial
|
||||
? Math.pow(monthlyInflation, (firstDay + length - 1 - todayNumber) / length)
|
||||
: monthlyInflation;
|
||||
deflator *= monthInflation;
|
||||
if (month === 1) {
|
||||
investedAtYearStart = invested;
|
||||
contributedThisYear = 0;
|
||||
realisedThisYear = 0;
|
||||
}
|
||||
const flow = new Float64Array(length);
|
||||
const at = (day: number, cents: number) => {
|
||||
const offset = day - firstDay;
|
||||
if (offset >= 0 && offset < length) flow[offset] += cents;
|
||||
};
|
||||
/** Spreads a month's amount over its days, from tomorrow in the current month. */
|
||||
const spreadMonth = (cents: number, from = firstDay, to = firstDay + length - 1) => {
|
||||
const perDay = cents / length;
|
||||
for (let day = Math.max(from, todayNumber + 1); day <= to; day++) at(day, perDay);
|
||||
};
|
||||
const paid = businessDay(dayOfMonth(year, month, payday.day), payday.shift);
|
||||
const retired = first >= retireStart;
|
||||
// Salary path: extension or next job after the contract (optional gap), raises, job hops
|
||||
// in October (optional gap before each), capped at the career plateau in today's euros.
|
||||
const hopStart = isHopYear(year) ? `${year}-10-01` : null;
|
||||
// A gap that starts in October can run into the next year.
|
||||
const hops = [year - 1, year].filter(isHopYear).map((hopYear) => {
|
||||
const start = `${hopYear}-10-01`;
|
||||
return { start, end: addMonths(start, Math.max(0, params.hopGapMonths)) };
|
||||
});
|
||||
const inHopGap = hops.some((hop) => first >= hop.start && first < hop.end);
|
||||
const hopEnd = hops.find((hop) => hop.end === first)?.end ?? null;
|
||||
const inContractGap = params.gapMonths > 0 && first >= gapStart && first < gapEnd;
|
||||
const employed = !retired && !inHopGap && !inContractGap;
|
||||
if (first === gapStart) {
|
||||
gross = baseGross * (1 + pct(params.nextJobPercent));
|
||||
row.events.push(
|
||||
params.gapMonths > 0 ? `Contract ends, ${params.gapMonths} mo gap` : 'Contract renewed'
|
||||
);
|
||||
}
|
||||
if (
|
||||
first > gapStart &&
|
||||
year >= params.raiseFromYear &&
|
||||
month === params.raiseMonth &&
|
||||
!(hopStart && month === 10)
|
||||
)
|
||||
gross *= 1 + pct(params.raisePercent);
|
||||
const cap = ((params.salaryCapEuros * 100) / 12) * deflator;
|
||||
if (hopStart && first === hopStart && params.hopGapMonths > 0 && !retired)
|
||||
row.events.push(`Between jobs (${params.hopGapMonths} mo)`);
|
||||
if (hopEnd && first === hopEnd && !retired) {
|
||||
const plateau = gross >= cap * 0.99;
|
||||
gross *= 1 + pct(params.hopRaisePercent);
|
||||
row.events.push(
|
||||
(params.hopGapMonths > 0 ? 'New job' : 'Job hop') + (plateau ? ' (at plateau)' : '')
|
||||
);
|
||||
}
|
||||
if (gross > cap) gross = cap;
|
||||
const jobChange =
|
||||
first === retireStart ||
|
||||
(hopStart !== null && first === hopStart) ||
|
||||
(params.gapMonths > 0 && first === gapStart);
|
||||
let income = 0;
|
||||
if (employed) income += netFromGross(gross);
|
||||
else if (!retired) income += params.gapNetEuros * 100 * deflator;
|
||||
if (employed) {
|
||||
accruedHoliday += (facts.holidayNetYearCents / 12) * (gross / baseGross);
|
||||
accrued13 += (facts.thirteenthNetYearCents / 12) * (gross / baseGross);
|
||||
}
|
||||
if (month === 5 || jobChange) {
|
||||
income += accruedHoliday;
|
||||
accruedHoliday = 0;
|
||||
}
|
||||
if (month === 11 || jobChange) {
|
||||
income += accrued13;
|
||||
accrued13 = 0;
|
||||
}
|
||||
if (month === 8 && year >= facts.firstBonusYear && employed)
|
||||
income +=
|
||||
pct(params.bonusPercentOfTarget) *
|
||||
(year === facts.firstBonusYear
|
||||
? facts.firstBonusCents
|
||||
: facts.bonusNetYearCents * (gross / baseGross));
|
||||
if (retired) {
|
||||
income += params.pensionNetEuros * 100 * deflator;
|
||||
if (first === retireStart) row.events.push(`Retire at ${params.retireAge}`);
|
||||
} else {
|
||||
const ramp = Math.min(1, Math.max(0, (index - 3) / 57));
|
||||
const sideGross =
|
||||
params.sideStartEuros + (params.sideEndEuros - params.sideStartEuros) * ramp;
|
||||
income += sideGross * 100 * deflator * (1 - facts.sideTaxRate);
|
||||
let spending = 0;
|
||||
let employed = false;
|
||||
if (!partial) {
|
||||
// Salary path: extension or next job after the contract (optional gap), raises, job hops
|
||||
// in October (optional gap before each), capped at the career plateau in today's euros.
|
||||
const hopStart = isHopYear(year) ? `${year}-10-01` : null;
|
||||
// A gap that starts in October can run into the next year.
|
||||
const hops = [year - 1, year].filter(isHopYear).map((hopYear) => {
|
||||
const start = `${hopYear}-10-01`;
|
||||
return { start, end: addMonths(start, Math.max(0, params.hopGapMonths)) };
|
||||
});
|
||||
const inHopGap = hops.some((hop) => first >= hop.start && first < hop.end);
|
||||
const hopEnd = hops.find((hop) => hop.end === first)?.end ?? null;
|
||||
const inContractGap = params.gapMonths > 0 && first >= gapStart && first < gapEnd;
|
||||
employed = !retired && !inHopGap && !inContractGap;
|
||||
if (first === gapStart) {
|
||||
gross = baseGross * (1 + pct(params.nextJobPercent));
|
||||
row.events.push(
|
||||
params.gapMonths > 0 ? `Contract ends, ${params.gapMonths} mo gap` : 'Contract renewed'
|
||||
);
|
||||
}
|
||||
if (
|
||||
first > gapStart &&
|
||||
year >= params.raiseFromYear &&
|
||||
month === params.raiseMonth &&
|
||||
!(hopStart && month === 10)
|
||||
)
|
||||
gross *= 1 + pct(params.raisePercent);
|
||||
const cap = ((params.salaryCapEuros * 100) / 12) * deflator;
|
||||
if (hopStart && first === hopStart && params.hopGapMonths > 0 && !retired)
|
||||
row.events.push(`Between jobs (${params.hopGapMonths} mo)`);
|
||||
if (hopEnd && first === hopEnd && !retired) {
|
||||
const plateau = gross >= cap * 0.99;
|
||||
gross *= 1 + pct(params.hopRaisePercent);
|
||||
row.events.push(
|
||||
(params.hopGapMonths > 0 ? 'New job' : 'Job hop') + (plateau ? ' (at plateau)' : '')
|
||||
);
|
||||
}
|
||||
if (gross > cap) gross = cap;
|
||||
const jobChange =
|
||||
first === retireStart ||
|
||||
(hopStart !== null && first === hopStart) ||
|
||||
(params.gapMonths > 0 && first === gapStart);
|
||||
// Pay, benefit or pension, and the contract extras: all on payday.
|
||||
let pay = 0;
|
||||
if (employed) pay += netFromGross(gross);
|
||||
else if (!retired) pay += params.gapNetEuros * 100 * deflator;
|
||||
if (employed) {
|
||||
accruedHoliday += (facts.holidayNetYearCents / 12) * (gross / baseGross);
|
||||
accrued13 += (facts.thirteenthNetYearCents / 12) * (gross / baseGross);
|
||||
}
|
||||
if (month === 5 || jobChange) {
|
||||
pay += accruedHoliday;
|
||||
accruedHoliday = 0;
|
||||
}
|
||||
if (month === 11 || jobChange) {
|
||||
pay += accrued13;
|
||||
accrued13 = 0;
|
||||
}
|
||||
if (month === 8 && year >= facts.firstBonusYear && employed)
|
||||
pay +=
|
||||
pct(params.bonusPercentOfTarget) *
|
||||
(year === facts.firstBonusYear
|
||||
? facts.firstBonusCents
|
||||
: facts.bonusNetYearCents * (gross / baseGross));
|
||||
if (retired) {
|
||||
pay += params.pensionNetEuros * 100 * deflator;
|
||||
if (first === retireStart) row.events.push(`Retire at ${params.retireAge}`);
|
||||
}
|
||||
income += pay;
|
||||
at(paid, pay);
|
||||
if (!retired) {
|
||||
// Side income: invoices come in through the month, so it is spread.
|
||||
const ramp = Math.min(1, Math.max(0, (index - 3) / 57));
|
||||
const sideGross =
|
||||
params.sideStartEuros + (params.sideEndEuros - params.sideStartEuros) * ramp;
|
||||
const side = sideGross * 100 * deflator * (1 - facts.sideTaxRate);
|
||||
income += side;
|
||||
spreadMonth(side);
|
||||
}
|
||||
if (index >= 3)
|
||||
spendGrowth *= Math.pow(
|
||||
1 + pct(index < 123 ? params.spendGrowthPercent : params.inflationPercent),
|
||||
1 / 12
|
||||
);
|
||||
}
|
||||
// Recurring items from the tracker (positive ones such as benefits count as income).
|
||||
if (index >= 3)
|
||||
spendGrowth *= Math.pow(
|
||||
1 + pct(index < 123 ? params.spendGrowthPercent : params.inflationPercent),
|
||||
1 / 12
|
||||
);
|
||||
let spending = 0;
|
||||
for (const item of inputs.personal) {
|
||||
if (!activeIn(item, first, last)) continue;
|
||||
const value = monthly(item);
|
||||
if (value >= 0) income += retired ? 0 : value;
|
||||
else spending -= value * spendGrowth;
|
||||
const addItem = (item: ScheduledRecurring, scale: number, incomeAllowed: boolean) => {
|
||||
const flows = monthFlows(item, item.schedule, year, month, todayNumber);
|
||||
for (const [day, cents] of flows.events) {
|
||||
if (cents >= 0) {
|
||||
if (!incomeAllowed) continue;
|
||||
income += cents;
|
||||
at(day, cents);
|
||||
} else {
|
||||
spending -= cents * scale;
|
||||
at(day, cents * scale);
|
||||
}
|
||||
}
|
||||
if (flows.spread && flows.spreadTo >= flows.spreadFrom) {
|
||||
const cents = flows.spread * (flows.spreadTo - flows.spreadFrom + 1);
|
||||
if (cents >= 0) {
|
||||
if (!incomeAllowed) return;
|
||||
income += cents;
|
||||
for (let day = flows.spreadFrom; day <= flows.spreadTo; day++) at(day, flows.spread);
|
||||
} else {
|
||||
spending -= cents * scale;
|
||||
for (let day = flows.spreadFrom; day <= flows.spreadTo; day++)
|
||||
at(day, flows.spread * scale);
|
||||
}
|
||||
}
|
||||
};
|
||||
for (const item of inputs.personal) addItem(item, spendGrowth, !retired);
|
||||
// Spending not in the recurring list: spread over the month.
|
||||
const remaining = partial ? (firstDay + length - 1 - todayNumber) / length : 1;
|
||||
const other = params.otherSpendEuros * 100 * spendGrowth;
|
||||
spending += other * remaining;
|
||||
spreadMonth(-other);
|
||||
if (first >= '2027-01-01') {
|
||||
// Health insurance premium: collected on the 1st.
|
||||
const premium = params.healthInsuranceEuros * 100 * spendGrowth;
|
||||
const day = dayOfMonth(year, month, 1);
|
||||
if (day > todayNumber) {
|
||||
spending += premium;
|
||||
at(day, -premium);
|
||||
}
|
||||
}
|
||||
spending += params.otherSpendEuros * 100 * spendGrowth;
|
||||
if (first >= '2027-01-01') spending += params.healthInsuranceEuros * 100 * spendGrowth;
|
||||
if (first < moveDate.slice(0, 7) + '-01') spending += facts.preMoveHouseholdCents;
|
||||
else {
|
||||
let household = params.householdExtraEuros * 100;
|
||||
for (const item of inputs.household)
|
||||
if (activeIn(item, first, last)) household -= monthly(item);
|
||||
spending += household * pct(params.householdSharePercent) * spendGrowth;
|
||||
if (first < moveMonth) {
|
||||
spending += facts.preMoveHouseholdCents * remaining;
|
||||
spreadMonth(-facts.preMoveHouseholdCents);
|
||||
} else {
|
||||
const share = pct(params.householdSharePercent) * spendGrowth;
|
||||
const extra = params.householdExtraEuros * 100 * share;
|
||||
spending += extra * remaining;
|
||||
spreadMonth(-extra);
|
||||
for (const item of inputs.household) addItem(item, share, false);
|
||||
}
|
||||
if (first === moveDate.slice(0, 7) + '-01') spending += params.moveCostEuros * 100;
|
||||
if (year >= facts.duoFrom && year <= facts.duoUntil) spending += facts.duoCents;
|
||||
for (const item of inputs.oneOffs)
|
||||
if ((index === 0 ? item.date > inputs.today : item.date >= first) && item.date <= last) {
|
||||
if (first === moveMonth && moveDay > todayNumber) {
|
||||
spending += params.moveCostEuros * 100;
|
||||
at(moveDay, -params.moveCostEuros * 100);
|
||||
}
|
||||
if (!partial && year >= facts.duoFrom && year <= facts.duoUntil) {
|
||||
// DUO collects the repayment around the 24th.
|
||||
spending += facts.duoCents;
|
||||
at(dayOfMonth(year, month, 24), -facts.duoCents);
|
||||
}
|
||||
for (const item of inputs.oneOffs) {
|
||||
const day = dayNumber(item.date);
|
||||
if (day > todayNumber && day >= firstDay && day < firstDay + length) {
|
||||
if (item.amountCents >= 0) income += item.amountCents;
|
||||
else spending -= item.amountCents;
|
||||
at(day, item.amountCents);
|
||||
}
|
||||
}
|
||||
let tax = 0;
|
||||
if (month === 5) {
|
||||
if (month === 5 && !partial) {
|
||||
// Box 3 assessment for the previous year, paid at the start of May.
|
||||
tax = box3Due;
|
||||
box3Due = 0;
|
||||
at(firstDay, -tax);
|
||||
}
|
||||
cash += income - spending - tax;
|
||||
// Returns: crash and correction years spread their drop over March–October.
|
||||
// Returns: crash and correction years spread their drop over March–October; daily
|
||||
// compounding gives the same monthly return.
|
||||
const market = markets.yearReturn(year);
|
||||
const rate =
|
||||
const monthRate =
|
||||
market.kind === 'normal'
|
||||
? Math.pow(1 + market.rate, 1 / 12) - 1
|
||||
: month >= 3 && month <= 10
|
||||
? Math.pow(1 + market.rate, 1 / 8) - 1
|
||||
: 0;
|
||||
invested += invested * rate;
|
||||
cash *= Math.pow(1 + pct(params.cashRatePercent), 1 / 12);
|
||||
const buffer = facts.bufferCents * deflator;
|
||||
if (cash > buffer) {
|
||||
const extra = cash - buffer;
|
||||
invested += extra;
|
||||
basis += extra;
|
||||
contributedThisYear += extra;
|
||||
cash = buffer;
|
||||
} else if (cash < 0 && invested > 0) {
|
||||
// Withdrawals: sell investments to cover the shortfall.
|
||||
const sold = Math.min(invested, -cash);
|
||||
const soldBasis = invested > 0 ? basis * (sold / invested) : 0;
|
||||
realisedThisYear += sold - soldBasis;
|
||||
basis -= soldBasis;
|
||||
invested -= sold;
|
||||
contributedThisYear -= sold;
|
||||
cash += sold;
|
||||
const dayGrowth = Math.pow(1 + monthRate, 1 / length);
|
||||
const dayCash = Math.pow(1 + pct(params.cashRatePercent), 1 / 12 / length);
|
||||
const dayInflation = Math.pow(monthlyInflation, 1 / length);
|
||||
const sweepDay = Math.min(paid + 1, firstDay + length - 1);
|
||||
let dayDeflator = partial ? 1 : startDeflator;
|
||||
buffer = facts.bufferCents * deflator;
|
||||
for (let offset = 0; offset < length; offset++) {
|
||||
const day = firstDay + offset;
|
||||
if (day <= todayNumber) continue;
|
||||
if (day > endNumber) break;
|
||||
dayDeflator *= dayInflation;
|
||||
cash += flow[offset];
|
||||
invested *= dayGrowth;
|
||||
cash *= dayCash;
|
||||
if (day === sweepDay && cash > buffer) {
|
||||
// Investing right after payday: everything above the buffer.
|
||||
const extra = cash - buffer;
|
||||
invested += extra;
|
||||
basis += extra;
|
||||
contributedThisYear += extra;
|
||||
cash = buffer;
|
||||
} else if (cash < 0 && invested > 0) {
|
||||
// Withdrawals: sell investments to cover the shortfall.
|
||||
const sold = Math.min(invested, -cash);
|
||||
const soldBasis = invested > 0 ? basis * (sold / invested) : 0;
|
||||
realisedThisYear += sold - soldBasis;
|
||||
basis -= soldBasis;
|
||||
invested -= sold;
|
||||
contributedThisYear -= sold;
|
||||
cash += sold;
|
||||
}
|
||||
const total = cash + invested;
|
||||
points.push({ date: isoDay(day), cash, invested, total, real: total / dayDeflator });
|
||||
for (const milestone of milestones)
|
||||
if (milestone.date === null && milestone.test()) milestone.date = first.slice(0, 7);
|
||||
}
|
||||
if (month === 12) {
|
||||
const gain = invested - investedAtYearStart - contributedThisYear;
|
||||
@@ -659,21 +787,28 @@ export function runScenario(inputs: ScenarioInputs, params: ScenarioParams): Sce
|
||||
}
|
||||
}
|
||||
const total = cash + invested;
|
||||
const real = total / deflator;
|
||||
recentRealIncome.push(income / deflator);
|
||||
if (recentRealIncome.length > 12) recentRealIncome.shift();
|
||||
points.push({ date, cash, invested, total, real });
|
||||
if (!partial) {
|
||||
recentRealIncome.push(income / deflator);
|
||||
if (recentRealIncome.length > 12) recentRealIncome.shift();
|
||||
}
|
||||
row.income += income;
|
||||
row.spending += spending + tax;
|
||||
row.box3 += tax;
|
||||
row.total = total;
|
||||
row.invested = invested;
|
||||
row.real = real;
|
||||
row.grossMonthly = employed ? gross : 0;
|
||||
row.baseReal = retired ? 0 : (gross * 12) / deflator / 100;
|
||||
row.role = retired ? 'Retired' : roleFor(row.baseReal);
|
||||
row.real = total / deflator;
|
||||
if (!partial) {
|
||||
row.grossMonthly = employed ? gross : 0;
|
||||
row.baseReal = retired ? 0 : (gross * 12) / deflator / 100;
|
||||
row.role = retired ? 'Retired' : roleFor(row.baseReal);
|
||||
} else {
|
||||
row.grossMonthly = gross;
|
||||
row.baseReal = (gross * 12) / 100;
|
||||
row.role = roleFor(row.baseReal);
|
||||
}
|
||||
for (const milestone of milestones)
|
||||
if (milestone.date === null && milestone.test()) milestone.date = first.slice(0, 7);
|
||||
if (last >= inputs.endDate) break;
|
||||
}
|
||||
for (const row of years.values()) {
|
||||
row.saved = row.income - row.spending;
|
||||
|
||||
152
src/lib/money-tracker/schedule.test.ts
Normal file
152
src/lib/money-tracker/schedule.test.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
businessDay,
|
||||
dayNumber,
|
||||
dayOfMonth,
|
||||
isoDay,
|
||||
monthFlows,
|
||||
parseInterval,
|
||||
scheduleFor,
|
||||
scheduleLabel,
|
||||
type HistoryTransaction
|
||||
} from './schedule';
|
||||
|
||||
const today = '2026-10-26';
|
||||
const at = (date: string) => dayNumber(date);
|
||||
const dates = (events: [number, number][]) => events.map(([day]) => isoDay(day));
|
||||
|
||||
describe('day helpers', () => {
|
||||
it('clamps a day of the month to the month end', () => {
|
||||
expect(isoDay(dayOfMonth(2027, 2, 31))).toBe('2027-02-28');
|
||||
expect(isoDay(dayOfMonth(2028, 2, 30))).toBe('2028-02-29');
|
||||
expect(isoDay(dayOfMonth(2027, 4, 31))).toBe('2027-04-30');
|
||||
});
|
||||
it('moves weekend days to the Friday before or the Monday after', () => {
|
||||
// 21 Nov 2026 is a Saturday, 22 Nov a Sunday.
|
||||
expect(isoDay(businessDay(at('2026-11-21'), 'before'))).toBe('2026-11-20');
|
||||
expect(isoDay(businessDay(at('2026-11-22'), 'before'))).toBe('2026-11-20');
|
||||
expect(isoDay(businessDay(at('2026-11-21'), 'after'))).toBe('2026-11-23');
|
||||
expect(isoDay(businessDay(at('2026-11-21'), 'none'))).toBe('2026-11-21');
|
||||
expect(isoDay(businessDay(at('2026-11-23'), 'before'))).toBe('2026-11-23');
|
||||
});
|
||||
it('reads "every N weeks" from a name', () => {
|
||||
expect(parseInterval('Basic-Fit (€29.99 every 4 weeks)')).toEqual({
|
||||
everyDays: 28,
|
||||
perChargeCents: 2999
|
||||
});
|
||||
expect(parseInterval('Haircut every 6 weeks')).toEqual({ everyDays: 42, perChargeCents: null });
|
||||
expect(parseInterval('Spotify')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('scheduleFor', () => {
|
||||
const history: HistoryTransaction[] = [
|
||||
{ date: '2026-07-18', amountCents: -899, description: 'Amazon Prime' },
|
||||
{ date: '2026-08-18', amountCents: -899, description: 'Amazon Prime' },
|
||||
{ date: '2026-09-17', amountCents: -899, description: 'AMAZON PRIME NL' },
|
||||
{ date: '2026-10-18', amountCents: -899, description: 'Amazon Prime' },
|
||||
{ date: '2026-10-18', amountCents: -12000, description: 'Amazon order' },
|
||||
{ date: '2026-10-19', amountCents: -2999, description: 'Basic-Fit Nederland' },
|
||||
{ date: '2025-10-09', amountCents: -1849, description: 'Apple.com/bill Authenticator' },
|
||||
{ date: '2026-10-09', amountCents: -1849, description: 'APPLE.COM/BILL' },
|
||||
{ date: '2026-10-21', amountCents: 298653, description: 'Salaris Cargill BV' }
|
||||
];
|
||||
it('uses the most common recent day of the month for monthly charges', () => {
|
||||
const schedule = scheduleFor(
|
||||
{ name: 'Amazon Prime', amountCents: -899, cadence: 'monthly' },
|
||||
history,
|
||||
today
|
||||
);
|
||||
expect(schedule).toMatchObject({ kind: 'monthly', day: 18, shift: 'none', last: '2026-10-18' });
|
||||
expect(scheduleLabel(schedule)).toBe('on the 18th');
|
||||
});
|
||||
it('moves salary before weekends', () => {
|
||||
expect(
|
||||
scheduleFor(
|
||||
{ name: 'Cargill salary', amountCents: 298653, cadence: 'monthly' },
|
||||
history,
|
||||
today
|
||||
)
|
||||
).toMatchObject({ kind: 'monthly', day: 21, shift: 'before' });
|
||||
});
|
||||
it('runs N-weekly items on an exact cadence from the last charge', () => {
|
||||
const item = {
|
||||
name: 'Basic-Fit (€29.99 every 4 weeks)',
|
||||
amountCents: -3249,
|
||||
cadence: 'monthly'
|
||||
};
|
||||
const schedule = scheduleFor(item, history, today);
|
||||
expect(schedule).toMatchObject({
|
||||
kind: 'interval',
|
||||
everyDays: 28,
|
||||
anchor: '2026-10-19',
|
||||
perChargeCents: -2999
|
||||
});
|
||||
expect(dates(monthFlows(item, schedule, 2026, 11, at(today)).events)).toEqual(['2026-11-16']);
|
||||
expect(dates(monthFlows(item, schedule, 2026, 12, at(today)).events)).toEqual(['2026-12-14']);
|
||||
// 13 charges a year.
|
||||
let count = 0;
|
||||
for (let month = 1; month <= 12; month++)
|
||||
count += monthFlows(item, schedule, 2027, month, at(today)).events.length;
|
||||
expect(count).toBe(13);
|
||||
});
|
||||
it('puts yearly items on their renewal date and skips the one just charged', () => {
|
||||
const item = { name: 'Authenticator (Apple)', amountCents: -1849, cadence: 'yearly' };
|
||||
const schedule = scheduleFor(item, history, today);
|
||||
expect(schedule).toMatchObject({ kind: 'yearly', month: 10, day: 9 });
|
||||
expect(monthFlows(item, schedule, 2026, 10, at('2026-10-01')).events).toEqual([]);
|
||||
expect(dates(monthFlows(item, schedule, 2027, 10, at(today)).events)).toEqual(['2027-10-09']);
|
||||
const future = {
|
||||
name: 'Cloudflare vash.software',
|
||||
amountCents: -2920,
|
||||
cadence: 'yearly',
|
||||
startsOn: '2028-01-30'
|
||||
};
|
||||
const futureSchedule = scheduleFor(future, history, today);
|
||||
expect(futureSchedule).toMatchObject({ kind: 'yearly', month: 1, day: 30 });
|
||||
expect(monthFlows(future, futureSchedule, 2027, 1, at(today)).events).toEqual([]);
|
||||
expect(dates(monthFlows(future, futureSchedule, 2028, 1, at(today)).events)).toEqual([
|
||||
'2028-01-30'
|
||||
]);
|
||||
});
|
||||
it('uses a future start date and respects the end date', () => {
|
||||
const item = {
|
||||
name: 'Grok',
|
||||
amountCents: -3000,
|
||||
cadence: 'monthly',
|
||||
startsOn: '2026-11-24',
|
||||
endsOn: '2027-01-31'
|
||||
};
|
||||
const schedule = scheduleFor(item, [], today);
|
||||
expect(schedule).toMatchObject({ kind: 'monthly', day: 24 });
|
||||
expect(dates(monthFlows(item, schedule, 2026, 11, at(today)).events)).toEqual(['2026-11-24']);
|
||||
expect(monthFlows(item, schedule, 2027, 2, at(today)).events).toEqual([]);
|
||||
});
|
||||
it('skips a monthly charge that already happened this month', () => {
|
||||
const item = { name: 'Amazon Prime', amountCents: -899, cadence: 'monthly' };
|
||||
const schedule = scheduleFor(item, history, today);
|
||||
expect(monthFlows(item, schedule, 2026, 10, at('2026-10-10')).events).toEqual([]);
|
||||
expect(dates(monthFlows(item, schedule, 2026, 11, at(today)).events)).toEqual(['2026-11-18']);
|
||||
});
|
||||
it('spreads budgets over the month and the vacation over July and August', () => {
|
||||
const groceries = {
|
||||
name: 'Groceries',
|
||||
amountCents: -25000,
|
||||
cadence: 'monthly',
|
||||
startsOn: '2027-01-01'
|
||||
};
|
||||
const schedule = scheduleFor(groceries, [], today);
|
||||
expect(schedule.kind).toBe('spread');
|
||||
const january = monthFlows(groceries, schedule, 2027, 1, at(today));
|
||||
expect(january.events).toEqual([]);
|
||||
expect(january.spread * (january.spreadTo - january.spreadFrom + 1)).toBeCloseTo(-25000, 6);
|
||||
const rest = monthFlows({ ...groceries, startsOn: null }, schedule, 2026, 10, at(today));
|
||||
expect(rest.spread * (rest.spreadTo - rest.spreadFrom + 1)).toBeCloseTo((-25000 * 5) / 31, 6);
|
||||
const vacation = { name: 'Vacation', amountCents: -500000, cadence: 'yearly' };
|
||||
const summer = scheduleFor(vacation, [], today);
|
||||
expect(scheduleLabel(summer)).toBe('Jul–Aug');
|
||||
expect(monthFlows(vacation, summer, 2027, 6, at(today)).spread).toBe(0);
|
||||
const july = monthFlows(vacation, summer, 2027, 7, at(today));
|
||||
expect(july.spread * 31).toBeCloseTo(-250000, 6);
|
||||
});
|
||||
});
|
||||
388
src/lib/money-tracker/schedule.ts
Normal file
388
src/lib/money-tracker/schedule.ts
Normal file
@@ -0,0 +1,388 @@
|
||||
/**
|
||||
* Day-accurate timing for recurring items: on which days a recurring cash flow actually hits.
|
||||
*
|
||||
* Nothing here needs extra columns. Each item's timing is derived from what the tracker already
|
||||
* has: the bank history (the most common day of the month among recent matching charges, or the
|
||||
* last charge for N-weekly and yearly items), the item's start date, and its name ("€29.99 every
|
||||
* 4 weeks"). Variable budgets (groceries, takeout, Club Animo, …) are spread evenly over the days
|
||||
* of the month, and the vacation budget over July and August. Dates are ISO strings; internally
|
||||
* days are counted from 1970-01-01 (UTC) so the daily simulations stay fast.
|
||||
*/
|
||||
|
||||
export type ScheduleItem = {
|
||||
name: string;
|
||||
amountCents: number;
|
||||
cadence: string;
|
||||
startsOn?: string | null;
|
||||
endsOn?: string | null;
|
||||
};
|
||||
export type HistoryTransaction = { date: string; amountCents: number; description: string };
|
||||
|
||||
export type ItemSchedule =
|
||||
| {
|
||||
kind: 'monthly';
|
||||
day: number;
|
||||
shift: 'none' | 'before' | 'after';
|
||||
source: string;
|
||||
last: string | null;
|
||||
}
|
||||
| {
|
||||
kind: 'interval';
|
||||
everyDays: number;
|
||||
anchor: string;
|
||||
perChargeCents: number;
|
||||
source: string;
|
||||
last: string | null;
|
||||
}
|
||||
| { kind: 'yearly'; month: number; day: number; source: string; last: string | null }
|
||||
| { kind: 'spread'; source: string; last: null }
|
||||
| { kind: 'summer'; source: string; last: null };
|
||||
|
||||
const DAY = 86_400_000;
|
||||
export function dayNumber(date: string) {
|
||||
return Math.round(Date.parse(`${date}T00:00:00Z`) / DAY);
|
||||
}
|
||||
export function isoDay(day: number) {
|
||||
return new Date(day * DAY).toISOString().slice(0, 10);
|
||||
}
|
||||
export function daysInMonth(year: number, month: number) {
|
||||
return new Date(Date.UTC(year, month, 0)).getUTCDate();
|
||||
}
|
||||
/** Day number of a day of the month, clamped to the month end (31st → 30 Apr, 28/29 Feb). */
|
||||
export function dayOfMonth(year: number, month: number, day: number) {
|
||||
return Math.round(Date.UTC(year, month - 1, Math.min(day, daysInMonth(year, month))) / DAY);
|
||||
}
|
||||
/** 0 = Sunday … 6 = Saturday. */
|
||||
function weekday(day: number) {
|
||||
return (day + 4) % 7;
|
||||
}
|
||||
/** Moves a weekend date to the Friday before or the Monday after. */
|
||||
export function businessDay(day: number, shift: 'none' | 'before' | 'after') {
|
||||
const wd = weekday(day);
|
||||
if (shift === 'none' || (wd !== 0 && wd !== 6)) return day;
|
||||
if (shift === 'before') return day - (wd === 6 ? 1 : 2);
|
||||
return day + (wd === 6 ? 2 : 1);
|
||||
}
|
||||
|
||||
/** Variable budgets: spread over the month instead of a single charge. */
|
||||
const budgetPattern =
|
||||
/grocer|boodschap|uber ?eats|club animo|takeout|take-?out|thuisbezorgd|eating out|restaurant|going out|fuel|petrol/i;
|
||||
const vacationPattern = /vacation|vakantie(?!geld)|holiday trip|travel budget/i;
|
||||
/** Income paid on a fixed payday: moved to the Friday before when it falls on a weekend. */
|
||||
const salaryPattern = /salary|salaris|wage|loon\b/i;
|
||||
/** Benefits from the Belastingdienst are paid on the 20th, or the next working day. */
|
||||
const benefitPattern = /toeslag|allowance|benefit|uitkering/i;
|
||||
|
||||
const stopwords = new Set([
|
||||
'estimate',
|
||||
'estimates',
|
||||
'subscription',
|
||||
'insurance',
|
||||
'server',
|
||||
'domains',
|
||||
'domain',
|
||||
'app',
|
||||
'plus',
|
||||
'the',
|
||||
'and',
|
||||
'for',
|
||||
'revolut',
|
||||
'living',
|
||||
'with',
|
||||
'parents',
|
||||
'monthly',
|
||||
'yearly',
|
||||
'every',
|
||||
'weeks'
|
||||
]);
|
||||
/** Other names a merchant shows up under on the bank statement. */
|
||||
const aliases: Record<string, string[]> = {
|
||||
icloud: ['icloud', 'apple'],
|
||||
authenticator: ['authenticator', 'apple'],
|
||||
chatgpt: ['chatgpt', 'openai'],
|
||||
anthropic: ['anthropic', 'claude'],
|
||||
claude: ['anthropic', 'claude'],
|
||||
haircut: ['haircut', 'haarvisie', 'kapsalon', 'kapper'],
|
||||
grok: ['grok', 'xai'],
|
||||
salary: ['salary', 'salaris', 'cargill'],
|
||||
zorgtoeslag: ['zorgtoeslag', 'belastingdienst']
|
||||
};
|
||||
|
||||
function normalise(text: string) {
|
||||
return ` ${text.toLowerCase().replace(/[^a-z0-9]+/g, ' ')} `;
|
||||
}
|
||||
|
||||
/** Word groups an item's bank description must contain (each group: any of its words). */
|
||||
export function matchTokens(name: string): string[][] {
|
||||
const words = normalise(name.replace(/\([^)]*\)/g, ' '))
|
||||
.split(' ')
|
||||
.filter((word) => word.length >= 3 && !stopwords.has(word) && !/^\d+$/.test(word));
|
||||
return [...new Set(words)].map((word) => aliases[word] ?? [word]);
|
||||
}
|
||||
|
||||
/** "€29.99 every 4 weeks" → 28 days at €29.99 a charge. */
|
||||
export function parseInterval(
|
||||
name: string
|
||||
): { everyDays: number; perChargeCents: number | null } | null {
|
||||
const match = /(?:€\s*([\d.,]+)\s*)?every\s+(\d+)\s*(?:weeks?|wk)/i.exec(name);
|
||||
if (!match) return null;
|
||||
const euros = match[1] ? Number(match[1].replace(',', '.')) : NaN;
|
||||
return {
|
||||
everyDays: Number(match[2]) * 7,
|
||||
perChargeCents: Number.isFinite(euros) ? Math.round(euros * 100) : null
|
||||
};
|
||||
}
|
||||
|
||||
/** Bank transactions that look like charges of this item: same merchant words, similar amount. */
|
||||
export function matchingCharges(
|
||||
item: ScheduleItem,
|
||||
history: HistoryTransaction[],
|
||||
perChargeCents: number
|
||||
): HistoryTransaction[] {
|
||||
const tokens = matchTokens(item.name);
|
||||
if (!tokens.length || !perChargeCents) return [];
|
||||
const expected = Math.abs(perChargeCents);
|
||||
return history.filter((row) => {
|
||||
if (Math.sign(row.amountCents) !== Math.sign(perChargeCents)) return false;
|
||||
const ratio = Math.abs(row.amountCents) / expected;
|
||||
if (ratio < 0.6 || ratio > 1.6) return false;
|
||||
const text = normalise(row.description);
|
||||
return tokens.every((group) => group.some((word) => text.includes(word)));
|
||||
});
|
||||
}
|
||||
|
||||
const ordinal = (day: number) =>
|
||||
`${day}${day % 10 === 1 && day !== 11 ? 'st' : day % 10 === 2 && day !== 12 ? 'nd' : day % 10 === 3 && day !== 13 ? 'rd' : 'th'}`;
|
||||
const monthNames = [
|
||||
'Jan',
|
||||
'Feb',
|
||||
'Mar',
|
||||
'Apr',
|
||||
'May',
|
||||
'Jun',
|
||||
'Jul',
|
||||
'Aug',
|
||||
'Sep',
|
||||
'Oct',
|
||||
'Nov',
|
||||
'Dec'
|
||||
];
|
||||
|
||||
/**
|
||||
* When an item hits. Monthly: the most common day among the charges of the last four months
|
||||
* (ties → the latest), else the start date's day, else the 1st. N-weekly: every N×7 days from
|
||||
* the last charge. Yearly: a future start date, else the last charge's date, else the start date.
|
||||
*/
|
||||
export function scheduleFor(
|
||||
item: ScheduleItem,
|
||||
history: HistoryTransaction[],
|
||||
today: string
|
||||
): ItemSchedule {
|
||||
if (vacationPattern.test(item.name))
|
||||
return { kind: 'summer', source: 'budget: spread over Jul–Aug', last: null };
|
||||
if (budgetPattern.test(item.name))
|
||||
return { kind: 'spread', source: 'budget: spread over the month', last: null };
|
||||
const interval =
|
||||
parseInterval(item.name) ??
|
||||
(item.cadence === 'weekly' ? { everyDays: 7, perChargeCents: null } : null);
|
||||
const perCharge =
|
||||
interval?.perChargeCents != null
|
||||
? Math.sign(item.amountCents || -1) * interval.perChargeCents
|
||||
: interval
|
||||
? Math.round((item.amountCents * 12 * interval.everyDays) / 365.25)
|
||||
: item.amountCents;
|
||||
const charges = matchingCharges(
|
||||
item,
|
||||
history,
|
||||
item.cadence === 'weekly' ? item.amountCents : perCharge
|
||||
)
|
||||
.filter((row) => row.date <= today)
|
||||
.sort((a, b) => a.date.localeCompare(b.date));
|
||||
const last = charges.at(-1)?.date ?? null;
|
||||
const futureStart = item.startsOn && item.startsOn > today ? item.startsOn : null;
|
||||
if (interval) {
|
||||
const anchor = futureStart ?? last ?? item.startsOn ?? today;
|
||||
const source = futureStart
|
||||
? `every ${interval.everyDays / 7} weeks from the start date`
|
||||
: last
|
||||
? `every ${interval.everyDays / 7} weeks from the last charge (${last})`
|
||||
: `every ${interval.everyDays / 7} weeks from ${item.startsOn ? 'the start date' : 'today'} (no charge found)`;
|
||||
return {
|
||||
kind: 'interval',
|
||||
everyDays: interval.everyDays,
|
||||
anchor,
|
||||
perChargeCents: item.cadence === 'weekly' ? item.amountCents : perCharge,
|
||||
source,
|
||||
last
|
||||
};
|
||||
}
|
||||
if (item.cadence === 'yearly') {
|
||||
const from = futureStart ?? last ?? item.startsOn ?? null;
|
||||
if (!from)
|
||||
return { kind: 'yearly', month: 1, day: 1, source: 'yearly on 1 Jan (no date known)', last };
|
||||
const day = Number(from.slice(8, 10));
|
||||
const month = Number(from.slice(5, 7));
|
||||
return {
|
||||
kind: 'yearly',
|
||||
month,
|
||||
day,
|
||||
source: `yearly on ${day} ${monthNames[month - 1]} (${futureStart ? 'start date' : last ? 'last charge' : 'start date'})`,
|
||||
last
|
||||
};
|
||||
}
|
||||
const shift =
|
||||
salaryPattern.test(item.name) && item.amountCents > 0
|
||||
? 'before'
|
||||
: benefitPattern.test(item.name) && item.amountCents > 0
|
||||
? 'after'
|
||||
: 'none';
|
||||
if (futureStart) {
|
||||
const day = Number(futureStart.slice(8, 10));
|
||||
return { kind: 'monthly', day, shift, source: `the ${ordinal(day)} (start date)`, last };
|
||||
}
|
||||
const cutoff = isoDay(dayNumber(today) - 122);
|
||||
const recent = charges.filter((row) => row.date >= cutoff);
|
||||
const pool = recent.length ? recent : charges.slice(-4);
|
||||
if (pool.length) {
|
||||
const counts = new Map<number, number>();
|
||||
for (const row of pool) {
|
||||
const day = Number(row.date.slice(8, 10));
|
||||
counts.set(day, (counts.get(day) ?? 0) + 1);
|
||||
}
|
||||
let best = 0;
|
||||
let bestCount = 0;
|
||||
// Latest first, so a tie goes to the most recent charge day.
|
||||
for (const row of [...pool].reverse()) {
|
||||
const day = Number(row.date.slice(8, 10));
|
||||
if (counts.get(day)! > bestCount) {
|
||||
best = day;
|
||||
bestCount = counts.get(day)!;
|
||||
}
|
||||
}
|
||||
// A weekend-shifted payment (e.g. paid Monday the 22nd for the 20th) shouldn't move the day.
|
||||
return {
|
||||
kind: 'monthly',
|
||||
day: best,
|
||||
shift,
|
||||
source: `the ${ordinal(best)} (${pool.length} recent charge${pool.length === 1 ? '' : 's'})`,
|
||||
last
|
||||
};
|
||||
}
|
||||
if (item.startsOn) {
|
||||
const day = Number(item.startsOn.slice(8, 10));
|
||||
return {
|
||||
kind: 'monthly',
|
||||
day,
|
||||
shift,
|
||||
source: `the ${ordinal(day)} (start date, no charge found)`,
|
||||
last
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: 'monthly',
|
||||
day: 1,
|
||||
shift,
|
||||
source: 'the 1st (no charge or start date found)',
|
||||
last
|
||||
};
|
||||
}
|
||||
|
||||
/** Short label for the recurring list, e.g. "18th" or "every 4 wk". */
|
||||
export function scheduleLabel(schedule: ItemSchedule) {
|
||||
switch (schedule.kind) {
|
||||
case 'monthly':
|
||||
return `on the ${ordinal(schedule.day)}`;
|
||||
case 'interval':
|
||||
return `every ${schedule.everyDays / 7} wk`;
|
||||
case 'yearly':
|
||||
return `${schedule.day} ${monthNames[schedule.month - 1]}`;
|
||||
case 'spread':
|
||||
return 'spread over the month';
|
||||
case 'summer':
|
||||
return 'Jul–Aug';
|
||||
}
|
||||
}
|
||||
|
||||
export type MonthFlows = {
|
||||
/** Dated amounts in this month: [day number, cents]. */
|
||||
events: [number, number][];
|
||||
/** Cents per day, spread evenly over the days spreadFrom…spreadTo (day numbers). */
|
||||
spread: number;
|
||||
spreadFrom: number;
|
||||
spreadTo: number;
|
||||
};
|
||||
|
||||
function monthlyOf(item: ScheduleItem) {
|
||||
return item.cadence === 'weekly'
|
||||
? (item.amountCents * 52) / 12
|
||||
: item.cadence === 'yearly'
|
||||
? item.amountCents / 12
|
||||
: item.amountCents;
|
||||
}
|
||||
|
||||
function active(item: ScheduleItem, day: number) {
|
||||
const date = isoDay(day);
|
||||
return !(item.startsOn && item.startsOn > date) && !(item.endsOn && item.endsOn < date);
|
||||
}
|
||||
|
||||
/**
|
||||
* The item's cash flows within one calendar month (year, 1–12), only on days after `after` (day
|
||||
* number, usually today). Amounts are unscaled cents; callers scale for spending growth.
|
||||
*/
|
||||
export function monthFlows(
|
||||
item: ScheduleItem,
|
||||
schedule: ItemSchedule,
|
||||
year: number,
|
||||
month: number,
|
||||
after: number
|
||||
): MonthFlows {
|
||||
const first = dayOfMonth(year, month, 1);
|
||||
const length = daysInMonth(year, month);
|
||||
const lastDay = first + length - 1;
|
||||
const events: [number, number][] = [];
|
||||
let spread = 0;
|
||||
let spreadFrom = 0;
|
||||
let spreadTo = -1;
|
||||
const lastCharge = schedule.last ? dayNumber(schedule.last) : -Infinity;
|
||||
switch (schedule.kind) {
|
||||
case 'monthly': {
|
||||
const day = businessDay(dayOfMonth(year, month, schedule.day), schedule.shift);
|
||||
// Skip a charge that already happened this month (e.g. taken a few days early).
|
||||
const chargedThisMonth = lastCharge >= first && lastCharge <= lastDay;
|
||||
if (day > after && active(item, day) && !chargedThisMonth)
|
||||
events.push([day, item.amountCents]);
|
||||
break;
|
||||
}
|
||||
case 'interval': {
|
||||
const anchor = dayNumber(schedule.anchor);
|
||||
const startsInFuture = item.startsOn && dayNumber(item.startsOn) === anchor && anchor > after;
|
||||
let k = Math.ceil((first - anchor) / schedule.everyDays);
|
||||
if (k < (startsInFuture ? 0 : 1)) k = startsInFuture ? 0 : 1;
|
||||
for (let day = anchor + k * schedule.everyDays; day <= lastDay; day += schedule.everyDays)
|
||||
if (day >= first && day > after && active(item, day))
|
||||
events.push([day, schedule.perChargeCents]);
|
||||
break;
|
||||
}
|
||||
case 'yearly': {
|
||||
if (month !== schedule.month) break;
|
||||
const day = dayOfMonth(year, month, schedule.day);
|
||||
if (day > after && active(item, day) && day - lastCharge > 300)
|
||||
events.push([day, item.amountCents]);
|
||||
break;
|
||||
}
|
||||
case 'spread':
|
||||
case 'summer': {
|
||||
if (schedule.kind === 'summer' && month !== 7 && month !== 8) break;
|
||||
const monthly =
|
||||
schedule.kind === 'summer'
|
||||
? (item.cadence === 'yearly' ? item.amountCents : monthlyOf(item) * 12) / 2 // a year's budget over two months
|
||||
: monthlyOf(item);
|
||||
spreadFrom = Math.max(first, after + 1, item.startsOn ? dayNumber(item.startsOn) : first);
|
||||
spreadTo = Math.min(lastDay, item.endsOn ? dayNumber(item.endsOn) : lastDay);
|
||||
// The month's budget, per calendar day; a part month (today, start or end) gets its share.
|
||||
spread = spreadTo >= spreadFrom ? monthly / length : 0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return { events, spread, spreadFrom, spreadTo };
|
||||
}
|
||||
Reference in New Issue
Block a user