Files
website/src/lib/money-tracker/charts.test.ts
Stan Runge c757d0882a
All checks were successful
Test and deploy staging / staging (push) Successful in 2m30s
add icons to lists and solve money tracker bugs
2026-09-24 19:24:09 +00:00

260 lines
8.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, expect, it } from 'vitest';
import {
accountBalances,
accountColor,
balanceSeries,
forecastSeries,
utilityEstimate,
wealthProjection,
wealthTimeline
} from './charts';
describe('money tracker calculations', () => {
it('keeps balances separate across accounts and preserves transaction order', () => {
const accounts = [
{ id: 1, name: 'Cash', openingBalanceCents: 10000 },
{ id: 2, name: 'Bank', openingBalanceCents: 20000 }
];
const transactions = [
{ accountId: 1, date: '2026-01-01', amountCents: -2500 },
{ accountId: 2, date: '2026-01-02', amountCents: 5000 },
{ accountId: 1, date: '2026-01-03', amountCents: 1000 }
];
expect([...accountBalances(accounts, transactions).values()]).toEqual([8500, 25000]);
expect(balanceSeries(accounts, transactions)[0].points.map((point) => point.value)).toEqual([
7500, 7500, 8500
]);
const combined = balanceSeries(accounts, transactions).at(-1)!;
expect(combined.label).toBe('All accounts');
expect(combined.emphasis).toBeFalsy();
expect(combined.points.map((point) => point.value)).toEqual([27500, 32500, 33500]);
expect(combined.points.at(-1)?.value).toBe(
[...accountBalances(accounts, transactions).values()].reduce((sum, value) => sum + value, 0)
);
});
it("uses an account's chosen color and falls back to the palette", () => {
const series = balanceSeries(
[
{ id: 1, name: 'Revolut', openingBalanceCents: 0, color: '#000000' },
{ id: 2, name: 'Knab', openingBalanceCents: 0, color: null }
],
[{ accountId: 1, date: '2026-01-01', amountCents: 100 }]
);
expect(series[0].color).toBe('#000000');
expect(series[1].color).toBe(accountColor({ color: null }, 1));
});
it('plots combined transactions in date order even if the input is unordered', () => {
const combined = balanceSeries(
[{ id: 1, name: 'Cash', openingBalanceCents: 1000 }],
[
{ accountId: 1, date: '2026-02-02', amountCents: 200 },
{ accountId: 1, date: '2026-01-01', amountCents: -100 }
]
).at(-1)!;
expect(combined.points).toEqual([
{ date: '2026-01-01', value: 900 },
{ date: '2026-02-02', value: 1100 }
]);
});
it('does not show a temporary loss when money moves between accounts on the same date', () => {
const combined = balanceSeries(
[
{ id: 1, name: 'Daily', openingBalanceCents: 1000 },
{ id: 2, name: 'Shared', openingBalanceCents: 0 }
],
[
{ accountId: 1, date: '2026-01-01', amountCents: -200 },
{ accountId: 2, date: '2026-01-01', amountCents: 200 }
]
).at(-1)!;
expect(combined.points).toEqual([{ date: '2026-01-01', value: 1000 }]);
});
it('projects only future estimates from the current balance', () => {
expect(
forecastSeries(
10000,
[
{ date: '2026-01-01', amountCents: -9999 },
{ date: '2026-10-01', amountCents: -2500 },
{ date: '2026-11-01', amountCents: 5000 }
],
'2026-09-23'
)[0].points.map((point) => point.value)
).toEqual([10000, 7500, 12500]);
});
it('keeps the reserve, transfers excess into investments, and adds growth only to invested money', () => {
const settings = {
investmentAccountId: 2,
reserveCents: 100000,
annualReturnPercent: '5.00',
contributionMode: 'excess',
fixedMonthlyCents: 0,
includeRecurring: 1,
endDate: '2026-11-30'
};
const accounts = [
{ id: 1, kind: 'spendable', openingBalanceCents: 100000 },
{ id: 2, kind: 'investment', openingBalanceCents: 0 }
];
const series = wealthProjection(
accounts,
new Map([
[1, 100000],
[2, 0]
]),
[{ date: '2026-10-01', accountId: 1, amountCents: -10000 }],
[{ date: '2026-10-15', accountId: 1, amountCents: 20000 }],
[{ amountCents: 50000, cadence: 'monthly', accountId: 1 }],
settings,
'2026-09-23'
);
expect(series[0].points.map((point) => point.value)).toEqual([100000, 100000, 160000, 210244]);
expect(series[1].points.at(-1)?.value).toBe(100000);
expect(series[2].points.at(-1)?.value).toBe(110244);
});
it('does not add a future dated transaction to today’s starting balance', () => {
const accounts = [{ id: 1, kind: 'spendable', openingBalanceCents: 10000 }];
const future = [{ date: '2026-10-01', accountId: 1, amountCents: 5000 }];
const balances = accountBalances(accounts, []);
const series = wealthProjection(accounts, balances, future, [], [], null, '2026-09-23');
expect(series[0].points[0].value).toBe(10000);
expect(series[0].points.at(-1)?.value).toBe(15000);
});
it('invests excess each month without requiring an existing investment account', () => {
const projection = wealthProjection(
[{ id: 1, kind: 'spendable', openingBalanceCents: 40054 }],
new Map([[1, 40054]]),
[],
[],
[{ amountCents: 127137, cadence: 'monthly', accountId: 1 }],
{
investmentAccountId: null,
reserveCents: 100000,
annualReturnPercent: '5.00',
contributionMode: 'excess',
fixedMonthlyCents: 0,
includeRecurring: 1,
endDate: '2026-11-30'
},
'2026-09-23'
);
expect(projection[0].points.map((point) => point.value)).toEqual([
40054, 40054, 167191, 294602
]);
expect(projection[1].points.at(-1)?.value).toBe(100000);
expect(projection[2].points.at(-1)?.value).toBe(194602);
});
it('continues actual history into the projection as one dashed line', () => {
const accounts = [
{ id: 1, name: 'Bank', kind: 'spendable', openingBalanceCents: 0 },
{ id: 2, name: 'Broker', kind: 'investment', openingBalanceCents: 0 }
];
const actual = [
{ accountId: 1, date: '2026-09-01', amountCents: 50000 },
{ accountId: 2, date: '2026-09-10', amountCents: 20000 }
];
const projection = [
{
label: 'Total wealth',
color: '',
points: [
{ date: '2026-09-23', value: 70000 },
{ date: '2026-10-31', value: 90000 }
]
},
{
label: 'Spendable',
color: '',
points: [
{ date: '2026-09-23', value: 50000 },
{ date: '2026-10-31', value: 60000 }
]
},
{
label: 'Invested',
color: '',
points: [
{ date: '2026-09-23', value: 20000 },
{ date: '2026-10-31', value: 30000 }
]
}
];
const [total] = wealthTimeline(accounts, actual, projection, '2026-09-23', 'total');
expect(total.dashedFrom).toBe('2026-09-23');
expect(total.points).toEqual([
{ date: '2026-09-01', value: 50000 },
{ date: '2026-09-10', value: 70000 },
{ date: '2026-09-23', value: 70000 },
{ date: '2026-10-31', value: 90000 }
]);
const [, spendable, invested] = wealthTimeline(
accounts,
actual,
projection,
'2026-09-23',
'split'
);
expect(spendable.points.map((point) => point.value)).toEqual([50000, 50000, 50000, 60000]);
expect(invested.points.map((point) => point.value)).toEqual([0, 20000, 20000, 30000]);
const byAccount = wealthTimeline(accounts, actual, projection, '2026-09-23', 'accounts');
expect(byAccount.map((line) => line.label)).toEqual(['Bank', 'Broker', 'Total']);
expect(byAccount[0].points.at(-1)).toEqual({ date: '2026-09-23', value: 50000 });
});
it('starts the timeline at today when there is no history', () => {
const [total] = wealthTimeline(
[],
[],
[
{
label: 'Total wealth',
color: '',
points: [
{ date: '2026-09-23', value: 0 },
{ date: '2026-10-31', value: 100 }
]
}
],
'2026-09-23',
'total'
);
expect(total.points.map((point) => point.date)).toEqual(['2026-09-23', '2026-10-31']);
});
it('compares estimated usage cost with prorated advances', () => {
const result = utilityEstimate(
[
{ kind: 'gas', date: '2026-01-01', reading: '100' },
{ kind: 'gas', date: '2026-04-01', reading: '130' }
],
'gas',
'1.2500',
2000
);
expect(result?.usage).toBe(30);
expect(result?.estimatedCents).toBe(3750);
expect(result?.advancesCents).toBeGreaterThan(5800);
expect(
utilityEstimate(
[
{ kind: 'gas', date: '2025-01-01', reading: '0' },
{ kind: 'gas', date: '2026-01-01', reading: '100' },
{ kind: 'gas', date: '2026-04-01', reading: '130' }
],
'gas',
'1.2500',
2000,
'2026-01-01'
)?.usage
).toBe(30);
});
});