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">
|
<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 {
|
let {
|
||||||
series,
|
series: input,
|
||||||
title,
|
title,
|
||||||
unit = '€',
|
unit = '€',
|
||||||
marker,
|
marker,
|
||||||
@@ -24,6 +31,8 @@
|
|||||||
let compact = $derived(width < 520);
|
let compact = $derived(width < 520);
|
||||||
let height = $derived(compact ? 240 : 280);
|
let height = $derived(compact ? 240 : 280);
|
||||||
let plot = $derived({ left: compact ? 52 : 72, right: 14, top: 18, bottom: 30 });
|
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 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 times = $derived(series.flatMap((item) => item.points.map((point) => time(point.date))));
|
||||||
let scale = $derived(niceScale(Math.min(0, ...values), Math.max(0, ...values)));
|
let scale = $derived(niceScale(Math.min(0, ...values), Math.max(0, ...values)));
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
accountBalances,
|
accountBalances,
|
||||||
accountColor,
|
accountColor,
|
||||||
balanceSeries,
|
balanceSeries,
|
||||||
|
decimateSeries,
|
||||||
forecastSeries,
|
forecastSeries,
|
||||||
parseHistoryRange,
|
parseHistoryRange,
|
||||||
gridFor,
|
gridFor,
|
||||||
@@ -119,7 +120,18 @@ describe('money tracker calculations', () => {
|
|||||||
settings,
|
settings,
|
||||||
'2026-09-23'
|
'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[1].points.at(-1)?.value).toBe(100000);
|
||||||
expect(series[2].points.at(-1)?.value).toBe(110244);
|
expect(series[2].points.at(-1)?.value).toBe(110244);
|
||||||
});
|
});
|
||||||
@@ -151,9 +163,10 @@ describe('money tracker calculations', () => {
|
|||||||
},
|
},
|
||||||
'2026-09-23'
|
'2026-09-23'
|
||||||
);
|
);
|
||||||
expect(projection[0].points.map((point) => point.value)).toEqual([
|
const total = new Map(projection[0].points.map((point) => [point.date, point.value]));
|
||||||
40054, 40054, 167191, 294602
|
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[1].points.at(-1)?.value).toBe(100000);
|
||||||
expect(projection[2].points.at(-1)?.value).toBe(194602);
|
expect(projection[2].points.at(-1)?.value).toBe(194602);
|
||||||
});
|
});
|
||||||
@@ -373,4 +386,62 @@ describe('history range', () => {
|
|||||||
'2120'
|
'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 ChartPoint = { date: string; value: number };
|
||||||
export type ChartSeries = {
|
export type ChartSeries = {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -111,6 +122,7 @@ type ProjectionAccount = {
|
|||||||
};
|
};
|
||||||
type ProjectionMovement = { date: string; amountCents: number; accountId: number | null };
|
type ProjectionMovement = { date: string; amountCents: number; accountId: number | null };
|
||||||
type ProjectionRecurring = {
|
type ProjectionRecurring = {
|
||||||
|
name?: string;
|
||||||
amountCents: number;
|
amountCents: number;
|
||||||
cadence: string;
|
cadence: string;
|
||||||
accountId: number | null;
|
accountId: number | null;
|
||||||
@@ -127,6 +139,11 @@ type ProjectionSettings = {
|
|||||||
endDate?: string | null;
|
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(
|
export function wealthProjection(
|
||||||
accounts: ProjectionAccount[],
|
accounts: ProjectionAccount[],
|
||||||
balances: Map<number, number>,
|
balances: Map<number, number>,
|
||||||
@@ -136,11 +153,15 @@ export function wealthProjection(
|
|||||||
settings: ProjectionSettings | null,
|
settings: ProjectionSettings | null,
|
||||||
today: string,
|
today: string,
|
||||||
/** Overrides the settings' end date, e.g. with the chart's forecast horizon. */
|
/** 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[] {
|
): ChartSeries[] {
|
||||||
const excluded = excludedIds(accounts);
|
const excluded = excludedIds(accounts);
|
||||||
const counted = (accountId: number | null) => accountId === null || !excluded.has(accountId);
|
const counted = (accountId: number | null) => accountId === null || !excluded.has(accountId);
|
||||||
const accountKind = new Map(accounts.map((account) => [account.id, account.kind]));
|
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 investmentId = settings?.investmentAccountId ?? null;
|
||||||
const validInvestmentId =
|
const validInvestmentId =
|
||||||
investmentId !== null && accountKind.get(investmentId) === 'investment' ? investmentId : null;
|
investmentId !== null && accountKind.get(investmentId) === 'investment' ? investmentId : null;
|
||||||
@@ -156,13 +177,10 @@ export function wealthProjection(
|
|||||||
const totalPoints: ChartPoint[] = [{ date: today, value: spendable + invested }];
|
const totalPoints: ChartPoint[] = [{ date: today, value: spendable + invested }];
|
||||||
const spendablePoints: ChartPoint[] = [{ date: today, value: spendable }];
|
const spendablePoints: ChartPoint[] = [{ date: today, value: spendable }];
|
||||||
const investedPoints: ChartPoint[] = [{ date: today, value: invested }];
|
const investedPoints: ChartPoint[] = [{ date: today, value: invested }];
|
||||||
const movements = [...futureTransactions, ...forecasts].filter(
|
const movements = [...futureTransactions, ...forecasts]
|
||||||
(entry) => entry.date > today && counted(entry.accountId)
|
.filter((entry) => entry.date > today && counted(entry.accountId))
|
||||||
);
|
.sort((a, b) => a.date.localeCompare(b.date));
|
||||||
const latestMovement = movements.reduce(
|
const latestMovement = movements.at(-1)?.date ?? '';
|
||||||
(latest, entry) => (entry.date > latest ? entry.date : latest),
|
|
||||||
''
|
|
||||||
);
|
|
||||||
const fallback = new Date(`${today}T12:00:00Z`);
|
const fallback = new Date(`${today}T12:00:00Z`);
|
||||||
fallback.setUTCFullYear(fallback.getUTCFullYear() + 1);
|
fallback.setUTCFullYear(fallback.getUTCFullYear() + 1);
|
||||||
const horizon =
|
const horizon =
|
||||||
@@ -171,43 +189,71 @@ export function wealthProjection(
|
|||||||
: settings?.endDate && settings.endDate > today
|
: settings?.endDate && settings.endDate > today
|
||||||
? settings.endDate
|
? settings.endDate
|
||||||
: latestMovement || fallback.toISOString().slice(0, 10);
|
: latestMovement || fallback.toISOString().slice(0, 10);
|
||||||
const monthEnd = (year: number, month: number) =>
|
const todayNumber = dayNumber(today);
|
||||||
new Date(Date.UTC(year, month + 1, 0)).toISOString().slice(0, 10);
|
const endNumber = dayNumber(horizon);
|
||||||
const start = new Date(`${today}T12:00:00Z`);
|
const items =
|
||||||
let year = start.getUTCFullYear();
|
settings?.includeRecurring === false
|
||||||
let month = start.getUTCMonth();
|
? []
|
||||||
|
: 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 yearly = Number(settings?.annualReturnPercent ?? 5) / 100;
|
||||||
const monthlyGrowth = Math.pow(1 + yearly, 1 / 12) - 1;
|
const monthlyGrowth = Math.pow(1 + yearly, 1 / 12) - 1;
|
||||||
for (let index = 0; index < 1212; index++) {
|
const reserve = settings?.reserveCents ?? 100000;
|
||||||
const first = `${year}-${String(month + 1).padStart(2, '0')}-01`;
|
let year = Number(today.slice(0, 4));
|
||||||
const end = monthEnd(year, month);
|
let month = Number(today.slice(5, 7));
|
||||||
const date = end < horizon ? end : horizon;
|
let next = 0;
|
||||||
if (date > today) {
|
for (let guard = 0; guard < 1213; guard++) {
|
||||||
for (const entry of movements.filter(
|
const firstDay = dayOfMonth(year, month, 1);
|
||||||
(item) => (index === 0 ? item.date > today : item.date >= first) && item.date <= date
|
if (firstDay > endNumber) break;
|
||||||
)) {
|
const length = daysInMonth(year, month);
|
||||||
if (entry.accountId !== null && accountKind.get(entry.accountId) === 'investment') {
|
const toSpendable = new Float64Array(length);
|
||||||
invested += entry.amountCents;
|
const toInvested = new Float64Array(length);
|
||||||
if (entry.accountId === validInvestmentId) indexBalance += entry.amountCents;
|
const toIndex = new Float64Array(length);
|
||||||
} else spendable += entry.amountCents;
|
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);
|
||||||
}
|
}
|
||||||
if (settings?.includeRecurring !== false && index > 0) {
|
for (const { item, schedule } of items) {
|
||||||
for (const item of recurring) {
|
const flows = monthFlows(item, schedule, year, month, todayNumber);
|
||||||
if (!counted(item.accountId)) continue;
|
for (const [day, cents] of flows.events) add(day, cents, item.accountId);
|
||||||
if ((item.startsOn && item.startsOn > date) || (item.endsOn && item.endsOn < first))
|
for (let day = flows.spreadFrom; day <= flows.spreadTo; day++)
|
||||||
continue;
|
add(day, flows.spread, item.accountId);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
const paid =
|
||||||
if (index > 0) {
|
salary?.schedule.kind === 'monthly'
|
||||||
const growth = Math.round(indexBalance * monthlyGrowth);
|
? 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;
|
invested += growth;
|
||||||
indexBalance += growth;
|
indexBalance += growth;
|
||||||
const excess = Math.max(0, spendable - (settings?.reserveCents ?? 100000));
|
if (day === sweepDay) {
|
||||||
|
const excess = Math.max(0, spendable - reserve);
|
||||||
const contribution =
|
const contribution =
|
||||||
settings?.contributionMode === 'fixed'
|
settings?.contributionMode === 'fixed'
|
||||||
? Math.min(excess, settings.fixedMonthlyCents)
|
? Math.min(excess, settings.fixedMonthlyCents)
|
||||||
@@ -216,14 +262,14 @@ export function wealthProjection(
|
|||||||
invested += contribution;
|
invested += contribution;
|
||||||
indexBalance += contribution;
|
indexBalance += contribution;
|
||||||
}
|
}
|
||||||
totalPoints.push({ date, value: spendable + invested });
|
const date = isoDay(day);
|
||||||
spendablePoints.push({ date, value: spendable });
|
totalPoints.push({ date, value: Math.round(spendable + invested) });
|
||||||
investedPoints.push({ date, value: invested });
|
spendablePoints.push({ date, value: Math.round(spendable) });
|
||||||
|
investedPoints.push({ date, value: Math.round(invested) });
|
||||||
}
|
}
|
||||||
if (end >= horizon) break;
|
|
||||||
month++;
|
month++;
|
||||||
if (month === 12) {
|
if (month === 13) {
|
||||||
month = 0;
|
month = 1;
|
||||||
year++;
|
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';
|
export type TimelineView = 'total' | 'split' | 'accounts';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -76,29 +76,73 @@ describe('runScenario', () => {
|
|||||||
moveCostEuros: 0,
|
moveCostEuros: 0,
|
||||||
bonusPercentOfTarget: 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);
|
const result = runScenario(inputs, flat);
|
||||||
expect(result.points[0]).toMatchObject({ date: '2026-10-24', total: 350000 });
|
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.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]);
|
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', () => {
|
it('pays salary on payday, sweeps the excess the day after and counts recurring income and costs', () => {
|
||||||
const result = runScenario(inputs, flat);
|
const points = runScenario(inputs, flat).points;
|
||||||
const november = result.points[1];
|
// Without history the contract payday (the 25th) is used; €900 pre-move household is
|
||||||
expect(november.cash).toBe(500000);
|
// spread at €30 a day in November.
|
||||||
// 300k salary + 10k benefit + 28k one-off + 13th month 3/12 − 3k gym − 90k pre-move household.
|
expect(on(points, '2026-11-25').total - on(points, '2026-11-24').total).toBeCloseTo(
|
||||||
expect(november.total).toBeCloseTo(350000 + 300000 + 10000 + 28000 + 51250 - 3000 - 90000, 0);
|
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)', () => {
|
it('pays contract extras on payday 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;
|
const flatRun = runScenario(inputs, flat).points;
|
||||||
// Index 7 is May 2027: holiday pay 8/12 of €1,950.
|
const jump = (points: typeof flatRun, date: string, before: string) =>
|
||||||
expect(delta(flatRun, 7) - delta(flatRun, 6)).toBeCloseTo(130000, 0);
|
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;
|
const bonus = runScenario(inputs, { ...flat, bonusPercentOfTarget: 100 }).points;
|
||||||
// Index 10 is August 2027: the first bonus, €850.
|
expect(on(bonus, '2027-08-24').total - on(flatRun, '2027-08-24').total).toBeCloseTo(0, 0);
|
||||||
expect(delta(bonus, 10) - delta(flatRun, 10)).toBeCloseTo(85000, 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', () => {
|
it('orders the default scenarios from pessimistic to optimistic', () => {
|
||||||
const [pessimistic, realistic, optimistic] = (
|
const [pessimistic, realistic, optimistic] = (
|
||||||
@@ -148,7 +192,7 @@ describe('runScenario', () => {
|
|||||||
hopGapMonths: 4,
|
hopGapMonths: 4,
|
||||||
salaryCapEuros: 60000
|
salaryCapEuros: 60000
|
||||||
});
|
});
|
||||||
expect(result.points).toHaveLength(1201);
|
expect(result.points).toHaveLength(36525);
|
||||||
const byYear = new Map(result.years.map((row) => [row.year, row]));
|
const byYear = new Map(result.years.map((row) => [row.year, row]));
|
||||||
expect(byYear.get(2029)!.events).toEqual(['Between jobs (4 mo)']);
|
expect(byYear.get(2029)!.events).toEqual(['Between jobs (4 mo)']);
|
||||||
expect(byYear.get(2030)!.events).toEqual(['New job']);
|
expect(byYear.get(2030)!.events).toEqual(['New job']);
|
||||||
|
|||||||
@@ -9,6 +9,17 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ChartSeries } from './charts';
|
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 type ScenarioId = 'pessimistic' | 'realistic' | 'optimistic';
|
||||||
export const scenarioIds: ScenarioId[] = ['pessimistic', 'realistic', 'optimistic'];
|
export const scenarioIds: ScenarioId[] = ['pessimistic', 'realistic', 'optimistic'];
|
||||||
@@ -246,6 +257,8 @@ export type ScenarioRecurring = {
|
|||||||
endsOn?: string | null;
|
endsOn?: string | null;
|
||||||
};
|
};
|
||||||
export type ScenarioMovement = { date: string; amountCents: number; description: string };
|
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 = {
|
export type ScenarioInputs = {
|
||||||
today: string;
|
today: string;
|
||||||
@@ -254,10 +267,12 @@ export type ScenarioInputs = {
|
|||||||
investedCents: number;
|
investedCents: number;
|
||||||
/** Net salary per month from the recurring list. */
|
/** Net salary per month from the recurring list. */
|
||||||
netSalaryCents: number;
|
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. */
|
/** Recurring items other than salary and Joint income. */
|
||||||
personal: ScenarioRecurring[];
|
personal: ScheduledRecurring[];
|
||||||
/** Joint recurring costs, shared after the move. */
|
/** Joint recurring costs, shared after the move. */
|
||||||
household: ScenarioRecurring[];
|
household: ScheduledRecurring[];
|
||||||
/** Rent start: the move month. */
|
/** Rent start: the move month. */
|
||||||
moveDate: string | null;
|
moveDate: string | null;
|
||||||
oneOffs: ScenarioMovement[];
|
oneOffs: ScenarioMovement[];
|
||||||
@@ -300,18 +315,6 @@ const salaryPattern = /salary|salaris/i;
|
|||||||
/** Tracker estimates the scenarios model themselves (holiday pay, 13th month, bonus). */
|
/** Tracker estimates the scenarios model themselves (holiday pay, 13th month, bonus). */
|
||||||
const modelledPattern = /cargill|holiday|vakantiegeld|13th|dertiende|bonus/i;
|
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. */
|
/** Splits tracker data into the pieces the scenario model needs. */
|
||||||
export function scenarioInputs(input: {
|
export function scenarioInputs(input: {
|
||||||
accounts: { id: number; kind: string; name: string; includeInTotal?: boolean }[];
|
accounts: { id: number; kind: string; name: string; includeInTotal?: boolean }[];
|
||||||
@@ -326,6 +329,8 @@ export function scenarioInputs(input: {
|
|||||||
}[];
|
}[];
|
||||||
today: string;
|
today: string;
|
||||||
endDate?: string | null;
|
endDate?: string | null;
|
||||||
|
/** Bank transactions through today, to find the day each recurring item hits. */
|
||||||
|
history?: HistoryTransaction[];
|
||||||
}): ScenarioInputs {
|
}): ScenarioInputs {
|
||||||
const counted = input.accounts.filter((account) => account.includeInTotal !== false);
|
const counted = input.accounts.filter((account) => account.includeInTotal !== false);
|
||||||
const countedIds = new Set(counted.map((account) => account.id));
|
const countedIds = new Set(counted.map((account) => account.id));
|
||||||
@@ -354,15 +359,25 @@ export function scenarioInputs(input: {
|
|||||||
!modelledPattern.test(item.description)
|
!modelledPattern.test(item.description)
|
||||||
)
|
)
|
||||||
.map(({ date, amountCents, description }) => ({ date, amountCents, 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 {
|
return {
|
||||||
today: input.today,
|
today: input.today,
|
||||||
endDate: input.endDate && input.endDate > input.today ? input.endDate : '2030-12-31',
|
endDate: input.endDate && input.endDate > input.today ? input.endDate : '2030-12-31',
|
||||||
spendableCents: spendable,
|
spendableCents: spendable,
|
||||||
investedCents: invested,
|
investedCents: invested,
|
||||||
netSalaryCents: salary?.amountCents ?? 0,
|
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.
|
// Joint income (your partner's salary) pays her share of the household, so it stays out.
|
||||||
personal: recurring.filter((item) => item !== salary && !isJoint(item)),
|
personal: recurring.filter((item) => item !== salary && !isJoint(item)).map(scheduled),
|
||||||
household,
|
household: household.map(scheduled),
|
||||||
moveDate: rent?.startsOn ?? null,
|
moveDate: rent?.startsOn ?? null,
|
||||||
oneOffs
|
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 {
|
export function runScenario(inputs: ScenarioInputs, params: ScenarioParams): ScenarioResult {
|
||||||
const facts = scenarioFacts;
|
const facts = scenarioFacts;
|
||||||
const baseGross = facts.grossCents;
|
const baseGross = facts.grossCents;
|
||||||
@@ -425,6 +451,9 @@ export function runScenario(inputs: ScenarioInputs, params: ScenarioParams): Sce
|
|||||||
params.hopEveryYears > 0 &&
|
params.hopEveryYears > 0 &&
|
||||||
year >= params.firstHopYear &&
|
year >= params.firstHopYear &&
|
||||||
(year - params.firstHopYear) % Math.max(1, Math.round(params.hopEveryYears)) === 0;
|
(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 cash = inputs.spendableCents;
|
||||||
let invested = inputs.investedCents;
|
let invested = inputs.investedCents;
|
||||||
let basis = 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 years = new Map<number, ScenarioYear>();
|
||||||
const target = facts.targetMonthlyCents;
|
const target = facts.targetMonthlyCents;
|
||||||
|
let buffer = facts.bufferCents;
|
||||||
const milestones: { label: string; date: string | null; test: () => boolean }[] = [
|
const milestones: { label: string; date: string | null; test: () => boolean }[] = [
|
||||||
{
|
{
|
||||||
label: 'Cash buffer full',
|
label: 'Cash buffer full',
|
||||||
date: null,
|
date: null,
|
||||||
test: () => cash >= facts.bufferCents * deflator - 100
|
test: () => cash >= buffer - 100
|
||||||
},
|
},
|
||||||
...[10_000, 100_000, 250_000, 1_000_000].map((euros) => ({
|
...[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`,
|
label: `€${euros >= 1_000_000 ? `${euros / 1_000_000}M` : `${euros / 1000}k`} invested`,
|
||||||
@@ -475,14 +505,9 @@ export function runScenario(inputs: ScenarioInputs, params: ScenarioParams): Sce
|
|||||||
Number(firstMonth.slice(5, 7)) +
|
Number(firstMonth.slice(5, 7)) +
|
||||||
1;
|
1;
|
||||||
const moveDate = inputs.moveDate ?? '9999-12-31';
|
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 first = addMonths(firstMonth, index);
|
const moveDay = inputs.moveDate ? dayNumber(inputs.moveDate) : 0;
|
||||||
const last = monthEnd(first);
|
const yearRow = (year: number) =>
|
||||||
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.get(year) ??
|
||||||
years
|
years
|
||||||
.set(year, {
|
.set(year, {
|
||||||
@@ -502,12 +527,44 @@ export function runScenario(inputs: ScenarioInputs, params: ScenarioParams): Sce
|
|||||||
events: []
|
events: []
|
||||||
})
|
})
|
||||||
.get(year)!;
|
.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 year = Number(first.slice(0, 4));
|
||||||
|
const month = Number(first.slice(5, 7));
|
||||||
|
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) {
|
if (month === 1) {
|
||||||
investedAtYearStart = invested;
|
investedAtYearStart = invested;
|
||||||
contributedThisYear = 0;
|
contributedThisYear = 0;
|
||||||
realisedThisYear = 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;
|
const retired = first >= retireStart;
|
||||||
|
let income = 0;
|
||||||
|
let spending = 0;
|
||||||
|
let employed = false;
|
||||||
|
if (!partial) {
|
||||||
// Salary path: extension or next job after the contract (optional gap), raises, job hops
|
// 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.
|
// in October (optional gap before each), capped at the career plateau in today's euros.
|
||||||
const hopStart = isHopYear(year) ? `${year}-10-01` : null;
|
const hopStart = isHopYear(year) ? `${year}-10-01` : null;
|
||||||
@@ -519,7 +576,7 @@ export function runScenario(inputs: ScenarioInputs, params: ScenarioParams): Sce
|
|||||||
const inHopGap = hops.some((hop) => first >= hop.start && first < hop.end);
|
const inHopGap = hops.some((hop) => first >= hop.start && first < hop.end);
|
||||||
const hopEnd = hops.find((hop) => hop.end === first)?.end ?? null;
|
const hopEnd = hops.find((hop) => hop.end === first)?.end ?? null;
|
||||||
const inContractGap = params.gapMonths > 0 && first >= gapStart && first < gapEnd;
|
const inContractGap = params.gapMonths > 0 && first >= gapStart && first < gapEnd;
|
||||||
const employed = !retired && !inHopGap && !inContractGap;
|
employed = !retired && !inHopGap && !inContractGap;
|
||||||
if (first === gapStart) {
|
if (first === gapStart) {
|
||||||
gross = baseGross * (1 + pct(params.nextJobPercent));
|
gross = baseGross * (1 + pct(params.nextJobPercent));
|
||||||
row.events.push(
|
row.events.push(
|
||||||
@@ -548,83 +605,149 @@ export function runScenario(inputs: ScenarioInputs, params: ScenarioParams): Sce
|
|||||||
first === retireStart ||
|
first === retireStart ||
|
||||||
(hopStart !== null && first === hopStart) ||
|
(hopStart !== null && first === hopStart) ||
|
||||||
(params.gapMonths > 0 && first === gapStart);
|
(params.gapMonths > 0 && first === gapStart);
|
||||||
let income = 0;
|
// Pay, benefit or pension, and the contract extras: all on payday.
|
||||||
if (employed) income += netFromGross(gross);
|
let pay = 0;
|
||||||
else if (!retired) income += params.gapNetEuros * 100 * deflator;
|
if (employed) pay += netFromGross(gross);
|
||||||
|
else if (!retired) pay += params.gapNetEuros * 100 * deflator;
|
||||||
if (employed) {
|
if (employed) {
|
||||||
accruedHoliday += (facts.holidayNetYearCents / 12) * (gross / baseGross);
|
accruedHoliday += (facts.holidayNetYearCents / 12) * (gross / baseGross);
|
||||||
accrued13 += (facts.thirteenthNetYearCents / 12) * (gross / baseGross);
|
accrued13 += (facts.thirteenthNetYearCents / 12) * (gross / baseGross);
|
||||||
}
|
}
|
||||||
if (month === 5 || jobChange) {
|
if (month === 5 || jobChange) {
|
||||||
income += accruedHoliday;
|
pay += accruedHoliday;
|
||||||
accruedHoliday = 0;
|
accruedHoliday = 0;
|
||||||
}
|
}
|
||||||
if (month === 11 || jobChange) {
|
if (month === 11 || jobChange) {
|
||||||
income += accrued13;
|
pay += accrued13;
|
||||||
accrued13 = 0;
|
accrued13 = 0;
|
||||||
}
|
}
|
||||||
if (month === 8 && year >= facts.firstBonusYear && employed)
|
if (month === 8 && year >= facts.firstBonusYear && employed)
|
||||||
income +=
|
pay +=
|
||||||
pct(params.bonusPercentOfTarget) *
|
pct(params.bonusPercentOfTarget) *
|
||||||
(year === facts.firstBonusYear
|
(year === facts.firstBonusYear
|
||||||
? facts.firstBonusCents
|
? facts.firstBonusCents
|
||||||
: facts.bonusNetYearCents * (gross / baseGross));
|
: facts.bonusNetYearCents * (gross / baseGross));
|
||||||
if (retired) {
|
if (retired) {
|
||||||
income += params.pensionNetEuros * 100 * deflator;
|
pay += params.pensionNetEuros * 100 * deflator;
|
||||||
if (first === retireStart) row.events.push(`Retire at ${params.retireAge}`);
|
if (first === retireStart) row.events.push(`Retire at ${params.retireAge}`);
|
||||||
} else {
|
}
|
||||||
|
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 ramp = Math.min(1, Math.max(0, (index - 3) / 57));
|
||||||
const sideGross =
|
const sideGross =
|
||||||
params.sideStartEuros + (params.sideEndEuros - params.sideStartEuros) * ramp;
|
params.sideStartEuros + (params.sideEndEuros - params.sideStartEuros) * ramp;
|
||||||
income += sideGross * 100 * deflator * (1 - facts.sideTaxRate);
|
const side = sideGross * 100 * deflator * (1 - facts.sideTaxRate);
|
||||||
|
income += side;
|
||||||
|
spreadMonth(side);
|
||||||
}
|
}
|
||||||
// Recurring items from the tracker (positive ones such as benefits count as income).
|
|
||||||
if (index >= 3)
|
if (index >= 3)
|
||||||
spendGrowth *= Math.pow(
|
spendGrowth *= Math.pow(
|
||||||
1 + pct(index < 123 ? params.spendGrowthPercent : params.inflationPercent),
|
1 + pct(index < 123 ? params.spendGrowthPercent : params.inflationPercent),
|
||||||
1 / 12
|
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;
|
|
||||||
}
|
}
|
||||||
spending += params.otherSpendEuros * 100 * spendGrowth;
|
// Recurring items from the tracker (positive ones such as benefits count as income).
|
||||||
if (first >= '2027-01-01') spending += params.healthInsuranceEuros * 100 * spendGrowth;
|
const addItem = (item: ScheduledRecurring, scale: number, incomeAllowed: boolean) => {
|
||||||
if (first < moveDate.slice(0, 7) + '-01') spending += facts.preMoveHouseholdCents;
|
const flows = monthFlows(item, item.schedule, year, month, todayNumber);
|
||||||
else {
|
for (const [day, cents] of flows.events) {
|
||||||
let household = params.householdExtraEuros * 100;
|
if (cents >= 0) {
|
||||||
for (const item of inputs.household)
|
if (!incomeAllowed) continue;
|
||||||
if (activeIn(item, first, last)) household -= monthly(item);
|
income += cents;
|
||||||
spending += household * pct(params.householdSharePercent) * spendGrowth;
|
at(day, cents);
|
||||||
|
} else {
|
||||||
|
spending -= cents * scale;
|
||||||
|
at(day, cents * scale);
|
||||||
}
|
}
|
||||||
if (first === moveDate.slice(0, 7) + '-01') spending += params.moveCostEuros * 100;
|
}
|
||||||
if (year >= facts.duoFrom && year <= facts.duoUntil) spending += facts.duoCents;
|
if (flows.spread && flows.spreadTo >= flows.spreadFrom) {
|
||||||
for (const item of inputs.oneOffs)
|
const cents = flows.spread * (flows.spreadTo - flows.spreadFrom + 1);
|
||||||
if ((index === 0 ? item.date > inputs.today : item.date >= first) && item.date <= last) {
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 === 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;
|
if (item.amountCents >= 0) income += item.amountCents;
|
||||||
else spending -= item.amountCents;
|
else spending -= item.amountCents;
|
||||||
|
at(day, item.amountCents);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let tax = 0;
|
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;
|
tax = box3Due;
|
||||||
box3Due = 0;
|
box3Due = 0;
|
||||||
|
at(firstDay, -tax);
|
||||||
}
|
}
|
||||||
cash += income - spending - tax;
|
// Returns: crash and correction years spread their drop over March–October; daily
|
||||||
// Returns: crash and correction years spread their drop over March–October.
|
// compounding gives the same monthly return.
|
||||||
const market = markets.yearReturn(year);
|
const market = markets.yearReturn(year);
|
||||||
const rate =
|
const monthRate =
|
||||||
market.kind === 'normal'
|
market.kind === 'normal'
|
||||||
? Math.pow(1 + market.rate, 1 / 12) - 1
|
? Math.pow(1 + market.rate, 1 / 12) - 1
|
||||||
: month >= 3 && month <= 10
|
: month >= 3 && month <= 10
|
||||||
? Math.pow(1 + market.rate, 1 / 8) - 1
|
? Math.pow(1 + market.rate, 1 / 8) - 1
|
||||||
: 0;
|
: 0;
|
||||||
invested += invested * rate;
|
const dayGrowth = Math.pow(1 + monthRate, 1 / length);
|
||||||
cash *= Math.pow(1 + pct(params.cashRatePercent), 1 / 12);
|
const dayCash = Math.pow(1 + pct(params.cashRatePercent), 1 / 12 / length);
|
||||||
const buffer = facts.bufferCents * deflator;
|
const dayInflation = Math.pow(monthlyInflation, 1 / length);
|
||||||
if (cash > buffer) {
|
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;
|
const extra = cash - buffer;
|
||||||
invested += extra;
|
invested += extra;
|
||||||
basis += extra;
|
basis += extra;
|
||||||
@@ -640,6 +763,11 @@ export function runScenario(inputs: ScenarioInputs, params: ScenarioParams): Sce
|
|||||||
contributedThisYear -= sold;
|
contributedThisYear -= sold;
|
||||||
cash += 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) {
|
if (month === 12) {
|
||||||
const gain = invested - investedAtYearStart - contributedThisYear;
|
const gain = invested - investedAtYearStart - contributedThisYear;
|
||||||
if (year >= 2028) {
|
if (year >= 2028) {
|
||||||
@@ -659,21 +787,28 @@ export function runScenario(inputs: ScenarioInputs, params: ScenarioParams): Sce
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const total = cash + invested;
|
const total = cash + invested;
|
||||||
const real = total / deflator;
|
if (!partial) {
|
||||||
recentRealIncome.push(income / deflator);
|
recentRealIncome.push(income / deflator);
|
||||||
if (recentRealIncome.length > 12) recentRealIncome.shift();
|
if (recentRealIncome.length > 12) recentRealIncome.shift();
|
||||||
points.push({ date, cash, invested, total, real });
|
}
|
||||||
row.income += income;
|
row.income += income;
|
||||||
row.spending += spending + tax;
|
row.spending += spending + tax;
|
||||||
row.box3 += tax;
|
row.box3 += tax;
|
||||||
row.total = total;
|
row.total = total;
|
||||||
row.invested = invested;
|
row.invested = invested;
|
||||||
row.real = real;
|
row.real = total / deflator;
|
||||||
|
if (!partial) {
|
||||||
row.grossMonthly = employed ? gross : 0;
|
row.grossMonthly = employed ? gross : 0;
|
||||||
row.baseReal = retired ? 0 : (gross * 12) / deflator / 100;
|
row.baseReal = retired ? 0 : (gross * 12) / deflator / 100;
|
||||||
row.role = retired ? 'Retired' : roleFor(row.baseReal);
|
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)
|
for (const milestone of milestones)
|
||||||
if (milestone.date === null && milestone.test()) milestone.date = first.slice(0, 7);
|
if (milestone.date === null && milestone.test()) milestone.date = first.slice(0, 7);
|
||||||
|
if (last >= inputs.endDate) break;
|
||||||
}
|
}
|
||||||
for (const row of years.values()) {
|
for (const row of years.values()) {
|
||||||
row.saved = row.income - row.spending;
|
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 };
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
import FileText from 'lucide-svelte/icons/file-text';
|
import FileText from 'lucide-svelte/icons/file-text';
|
||||||
import MoneyChart from '#lib/money-tracker/MoneyChart.svelte';
|
import MoneyChart from '#lib/money-tracker/MoneyChart.svelte';
|
||||||
import QuickAdd from '#lib/money-tracker/QuickAdd.svelte';
|
import QuickAdd from '#lib/money-tracker/QuickAdd.svelte';
|
||||||
|
import { scheduleFor, scheduleLabel } from '#lib/money-tracker/schedule.js';
|
||||||
import ScenarioDetails from '#lib/money-tracker/ScenarioDetails.svelte';
|
import ScenarioDetails from '#lib/money-tracker/ScenarioDetails.svelte';
|
||||||
import {
|
import {
|
||||||
defaultScenarios,
|
defaultScenarios,
|
||||||
@@ -100,6 +101,16 @@
|
|||||||
let actualTransactions = $derived(data.transactions.filter((item) => item.date <= data.today));
|
let actualTransactions = $derived(data.transactions.filter((item) => item.date <= data.today));
|
||||||
let futureTransactions = $derived(data.transactions.filter((item) => item.date > data.today));
|
let futureTransactions = $derived(data.transactions.filter((item) => item.date > data.today));
|
||||||
let balances = $derived(accountBalances(data.accounts, actualTransactions));
|
let balances = $derived(accountBalances(data.accounts, actualTransactions));
|
||||||
|
// The last ~13 months of bank history: enough to find the day each recurring item hits.
|
||||||
|
let scheduleHistory = $derived.by(() => {
|
||||||
|
const cutoff = new Date(Date.parse(`${data.today}T12:00:00Z`) - 400 * 86_400_000)
|
||||||
|
.toISOString()
|
||||||
|
.slice(0, 10);
|
||||||
|
return actualTransactions.filter((item) => item.date >= cutoff);
|
||||||
|
});
|
||||||
|
let recurringTiming = $derived(
|
||||||
|
new Map(data.recurring.map((row) => [row.id, scheduleFor(row, scheduleHistory, data.today)]))
|
||||||
|
);
|
||||||
let totalAccounts = $derived(data.accounts.filter((account) => account.includeInTotal !== false));
|
let totalAccounts = $derived(data.accounts.filter((account) => account.includeInTotal !== false));
|
||||||
// Archived accounts (closed banks) stay in history, net worth and the charts, but not in the
|
// Archived accounts (closed banks) stay in history, net worth and the charts, but not in the
|
||||||
// quick balances or the account pickers.
|
// quick balances or the account pickers.
|
||||||
@@ -192,7 +203,8 @@
|
|||||||
data.recurring,
|
data.recurring,
|
||||||
data.projectionSettings,
|
data.projectionSettings,
|
||||||
data.today,
|
data.today,
|
||||||
forecastEnd
|
forecastEnd,
|
||||||
|
scheduleHistory
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
type ProjectionView = ScenarioId | 'compare' | 'simple';
|
type ProjectionView = ScenarioId | 'compare' | 'simple';
|
||||||
@@ -228,7 +240,8 @@
|
|||||||
forecasts: data.forecasts,
|
forecasts: data.forecasts,
|
||||||
futureTransactions,
|
futureTransactions,
|
||||||
today: data.today,
|
today: data.today,
|
||||||
endDate: forecastEnd
|
endDate: forecastEnd,
|
||||||
|
history: scheduleHistory
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
let scenarioResults = $derived(
|
let scenarioResults = $derived(
|
||||||
@@ -596,17 +609,20 @@
|
|||||||
onreset={() => (scenarioParams = structuredClone(defaultScenarios))}
|
onreset={() => (scenarioParams = structuredClone(defaultScenarios))}
|
||||||
/>
|
/>
|
||||||
<p class="muted projection-note">
|
<p class="muted projection-note">
|
||||||
Scenarios start from today’s balances and run monthly on the recurring list, one-off
|
Scenarios start from today’s balances and run day by day: salary and its extras on payday,
|
||||||
estimates (salary extras such as holiday pay, the 13th month and the bonus are modelled
|
each recurring item on the day the bank history (or its start date) puts it, budgets such
|
||||||
per scenario instead), and the assumptions above. Compare shows all three totals; Simple
|
as groceries spread over the month and vacation over July–August, investing the day after
|
||||||
is the setting-based projection. The history tabs set how far back the chart starts and
|
payday. They use the recurring list, one-off estimates (salary extras such as holiday pay,
|
||||||
Forecast how far ahead it runs (1–100 years); Today’s € divides projected values by the
|
the 13th month and the bonus are modelled per scenario instead), and the assumptions
|
||||||
scenario’s inflation so long horizons stay comparable.
|
above. Compare shows all three totals; Simple is the setting-based projection. The history
|
||||||
|
tabs set how far back the chart starts and Forecast how far ahead it runs (1–100 years);
|
||||||
|
Today’s € divides projected values by the scenario’s inflation so long horizons stay
|
||||||
|
comparable.
|
||||||
</p>
|
</p>
|
||||||
{:else}<p class="muted projection-note">
|
{:else}<p class="muted projection-note">
|
||||||
Includes future dated transactions and estimates{data.projectionSettings
|
Includes future dated transactions and estimates{data.projectionSettings
|
||||||
?.includeRecurring !== false
|
?.includeRecurring !== false
|
||||||
? ', plus recurring monthly equivalents from next month'
|
? ', plus recurring items on the days they hit'
|
||||||
: ''}. {data.projectionSettings?.investmentAccountId
|
: ''}. {data.projectionSettings?.investmentAccountId
|
||||||
? `The selected investment account and new contributions use a hypothetical ${data.projectionSettings.annualReturnPercent}% annual return.`
|
? `The selected investment account and new contributions use a hypothetical ${data.projectionSettings.annualReturnPercent}% annual return.`
|
||||||
: `Excess above ${amount(data.projectionSettings?.reserveCents ?? 100000)} moves into a projected investment balance with a hypothetical ${data.projectionSettings?.annualReturnPercent ?? '5.00'}% annual return.`}
|
: `Excess above ${amount(data.projectionSettings?.reserveCents ?? 100000)} moves into a projected investment balance with a hypothetical ${data.projectionSettings?.annualReturnPercent ?? '5.00'}% annual return.`}
|
||||||
@@ -778,11 +794,14 @@
|
|||||||
<span class="item-main"
|
<span class="item-main"
|
||||||
>{@render swatch(row.accountId)}<span
|
>{@render swatch(row.accountId)}<span
|
||||||
><strong>{row.name}</strong><small
|
><strong>{row.name}</strong><small
|
||||||
><span class="capitalize">{row.cadence}</span> · {row.accountId
|
><span class="capitalize">{row.cadence}</span
|
||||||
? accountName(row.accountId)
|
>{#if recurringTiming.get(row.id)}<span
|
||||||
: 'Any account'} · {row.startsOn ? dateLabel(row.startsOn) : 'Now'} – {row.endsOn
|
title={recurringTiming.get(row.id)!.source}
|
||||||
? dateLabel(row.endsOn)
|
>
|
||||||
: 'ongoing'}</small
|
· {scheduleLabel(recurringTiming.get(row.id)!)}</span
|
||||||
|
>{/if} · {row.accountId ? accountName(row.accountId) : 'Any account'} · {row.startsOn
|
||||||
|
? dateLabel(row.startsOn)
|
||||||
|
: 'Now'} – {row.endsOn ? dateLabel(row.endsOn) : 'ongoing'}</small
|
||||||
></span
|
></span
|
||||||
></span
|
></span
|
||||||
><strong class="item-amount">{@render signed(row.amountCents)}</strong
|
><strong class="item-amount">{@render signed(row.amountCents)}</strong
|
||||||
@@ -1254,8 +1273,8 @@
|
|||||||
>
|
>
|
||||||
{:else if mode === 'projectionSettings'}
|
{:else if mode === 'projectionSettings'}
|
||||||
<p class="dialog-copy">
|
<p class="dialog-copy">
|
||||||
A planning scenario. Returns are hypothetical and calculated monthly. Contributions
|
A planning scenario. Returns are hypothetical and compound daily. Contributions transfer
|
||||||
transfer money from spendable accounts into a projected investment balance.
|
money from spendable accounts into a projected investment balance.
|
||||||
</p>
|
</p>
|
||||||
<label class="field"
|
<label class="field"
|
||||||
>Existing investment account (optional)<NativeSelect name="investmentAccountId"
|
>Existing investment account (optional)<NativeSelect name="investmentAccountId"
|
||||||
|
|||||||
Reference in New Issue
Block a user