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); }); });