update money tracker
All checks were successful
Test and deploy staging / staging (push) Successful in 30s

This commit is contained in:
2026-09-23 18:06:20 +00:00
parent 2466db74cd
commit 499dd94cbb
9 changed files with 641 additions and 77 deletions

View File

@@ -35,10 +35,16 @@ try {
(select count(*) from information_schema.tables (select count(*) from information_schema.tables
where table_schema = 'public' and table_name in ( where table_schema = 'public' and table_name in (
'money_accounts', 'money_transactions', 'money_recurring', 'money_forecasts', 'money_accounts', 'money_transactions', 'money_recurring', 'money_forecasts',
'money_utility_settings', 'money_utility_readings', 'money_wishlist', 'money_insurances' 'money_utility_settings', 'money_utility_readings', 'money_wishlist', 'money_insurances',
'money_projection_settings'
)) as money_tables, )) as money_tables,
(select count(*) from information_schema.columns (select count(*) from information_schema.columns
where table_name = 'money_utility_settings' and column_name = 'billing_start') as money_billing_start where table_name = 'money_utility_settings' and column_name = 'billing_start') as money_billing_start,
(select count(*) from information_schema.columns
where (table_name = 'money_accounts' and column_name = 'kind')
or (table_name = 'money_recurring' and column_name in ('starts_on', 'ends_on'))
or (table_name = 'money_wishlist' and column_name = 'priority')
or (table_name = 'money_projection_settings' and column_name = 'include_recurring')) as money_projection_columns
`; `;
if ( if (
!Number(schema.user_role) || !Number(schema.user_role) ||
@@ -49,8 +55,9 @@ try {
!schema.list_completions || !schema.list_completions ||
!Number(schema.topic_link_id) || !Number(schema.topic_link_id) ||
Number(schema.animal_serial_ids) !== 3 || Number(schema.animal_serial_ids) !== 3 ||
Number(schema.money_tables) !== 8 || Number(schema.money_tables) !== 9 ||
!Number(schema.money_billing_start) !Number(schema.money_billing_start) ||
Number(schema.money_projection_columns) !== 5
) { ) {
throw new Error( throw new Error(
'Required migrations or Animaldex ID conversion have not been applied to this database' 'Required migrations or Animaldex ID conversion have not been applied to this database'

View File

@@ -3,11 +3,13 @@ CREATE TABLE IF NOT EXISTS money_accounts (
user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
name text NOT NULL, name text NOT NULL,
opening_balance_cents integer NOT NULL DEFAULT 0, opening_balance_cents integer NOT NULL DEFAULT 0,
kind text NOT NULL DEFAULT 'spendable',
created_at timestamptz NOT NULL DEFAULT now(), created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (user_id, name) UNIQUE (user_id, name)
); );
CREATE INDEX IF NOT EXISTS money_accounts_user_id_idx ON money_accounts (user_id); CREATE INDEX IF NOT EXISTS money_accounts_user_id_idx ON money_accounts (user_id);
ALTER TABLE money_accounts ADD COLUMN IF NOT EXISTS kind text NOT NULL DEFAULT 'spendable';
CREATE TABLE IF NOT EXISTS money_transactions ( CREATE TABLE IF NOT EXISTS money_transactions (
id serial PRIMARY KEY, id serial PRIMARY KEY,
@@ -28,11 +30,15 @@ CREATE TABLE IF NOT EXISTS money_recurring (
name text NOT NULL, name text NOT NULL,
amount_cents integer NOT NULL, amount_cents integer NOT NULL,
cadence text NOT NULL, cadence text NOT NULL,
starts_on date,
ends_on date,
account_id integer REFERENCES money_accounts(id) ON DELETE SET NULL, account_id integer REFERENCES money_accounts(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(), created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now() updated_at timestamptz NOT NULL DEFAULT now()
); );
CREATE INDEX IF NOT EXISTS money_recurring_user_id_idx ON money_recurring (user_id); CREATE INDEX IF NOT EXISTS money_recurring_user_id_idx ON money_recurring (user_id);
ALTER TABLE money_recurring ADD COLUMN IF NOT EXISTS starts_on date;
ALTER TABLE money_recurring ADD COLUMN IF NOT EXISTS ends_on date;
CREATE TABLE IF NOT EXISTS money_forecasts ( CREATE TABLE IF NOT EXISTS money_forecasts (
id serial PRIMARY KEY, id serial PRIMARY KEY,
@@ -75,11 +81,28 @@ CREATE TABLE IF NOT EXISTS money_wishlist (
user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
name text NOT NULL, name text NOT NULL,
estimated_cents integer, estimated_cents integer,
priority integer NOT NULL DEFAULT 3,
notes text, notes text,
created_at timestamptz NOT NULL DEFAULT now(), created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now() updated_at timestamptz NOT NULL DEFAULT now()
); );
CREATE INDEX IF NOT EXISTS money_wishlist_user_id_idx ON money_wishlist (user_id); CREATE INDEX IF NOT EXISTS money_wishlist_user_id_idx ON money_wishlist (user_id);
ALTER TABLE money_wishlist ADD COLUMN IF NOT EXISTS priority integer NOT NULL DEFAULT 3;
CREATE TABLE IF NOT EXISTS money_projection_settings (
id serial PRIMARY KEY,
user_id text NOT NULL UNIQUE REFERENCES "user"(id) ON DELETE CASCADE,
investment_account_id integer REFERENCES money_accounts(id) ON DELETE SET NULL,
reserve_cents integer NOT NULL DEFAULT 100000,
annual_return_percent numeric(5, 2) NOT NULL DEFAULT 5.00,
contribution_mode text NOT NULL DEFAULT 'excess',
include_recurring integer NOT NULL DEFAULT 1,
fixed_monthly_cents integer NOT NULL DEFAULT 0,
end_date date,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE money_projection_settings ADD COLUMN IF NOT EXISTS include_recurring integer NOT NULL DEFAULT 1;
CREATE TABLE IF NOT EXISTS money_insurances ( CREATE TABLE IF NOT EXISTS money_insurances (
id serial PRIMARY KEY, id serial PRIMARY KEY,

View File

@@ -48,8 +48,7 @@
<figure class="chart"> <figure class="chart">
{#if values.length} {#if values.length}
<!-- svelte-ignore a11y_no_noninteractive_tabindex: the chart viewport scrolls horizontally on narrow screens --> <div class="chart-scroll" role="region" aria-label={title}>
<div class="chart-scroll" role="region" tabindex="0" aria-label={title}>
<svg <svg
viewBox={`0 0 ${width} ${height}`} viewBox={`0 0 ${width} ${height}`}
role="img" role="img"
@@ -80,11 +79,19 @@
d={path(item.points)} d={path(item.points)}
fill="none" fill="none"
stroke={item.color} stroke={item.color}
stroke-width="2" stroke-width={item.emphasis ? 3.5 : 2}
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
vector-effect="non-scaling-stroke" vector-effect="non-scaling-stroke"
/> />
{#if item.points.length === 1}
<circle
cx={x(item.points[0].date)}
cy={y(item.points[0].value)}
r={item.emphasis ? 4 : 3}
fill={item.color}
/>
{/if}
{/if} {/if}
{/each} {/each}
<text x={plot.left} y={height - 8} fill="var(--site-muted)" font-size="11" <text x={plot.left} y={height - 8} fill="var(--site-muted)" font-size="11"
@@ -102,7 +109,9 @@
{#if series.length > 1} {#if series.length > 1}
<figcaption class="legend"> <figcaption class="legend">
{#each series as item (item.label)} {#each series as item (item.label)}
<span><span class="swatch" style={`background:${item.color}`}></span>{item.label}</span> <span class:emphasis={item.emphasis}
><span class="swatch" style={`background:${item.color}`}></span>{item.label}</span
>
{/each} {/each}
</figcaption> </figcaption>
{/if} {/if}
@@ -137,6 +146,12 @@
align-items: center; align-items: center;
gap: 7px; gap: 7px;
} }
.legend > span.emphasis {
order: -1;
flex-basis: 100%;
color: var(--site-text);
font-weight: 600;
}
.swatch { .swatch {
display: inline-block; display: inline-block;
width: 8px; width: 8px;

View File

@@ -1,5 +1,11 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { accountBalances, balanceSeries, forecastSeries, utilityEstimate } from './charts'; import {
accountBalances,
balanceSeries,
forecastSeries,
utilityEstimate,
wealthProjection
} from './charts';
describe('money tracker calculations', () => { describe('money tracker calculations', () => {
it('keeps balances separate across accounts and preserves transaction order', () => { it('keeps balances separate across accounts and preserves transaction order', () => {
@@ -14,8 +20,43 @@ describe('money tracker calculations', () => {
]; ];
expect([...accountBalances(accounts, transactions).values()]).toEqual([8500, 25000]); expect([...accountBalances(accounts, transactions).values()]).toEqual([8500, 25000]);
expect(balanceSeries(accounts, transactions)[0].points.map((point) => point.value)).toEqual([ expect(balanceSeries(accounts, transactions)[0].points.map((point) => point.value)).toEqual([
10000, 7500, 8500 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('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', () => { it('projects only future estimates from the current balance', () => {
@@ -32,6 +73,46 @@ describe('money tracker calculations', () => {
).toEqual([10000, 7500, 12500]); ).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('compares estimated usage cost with prorated advances', () => { it('compares estimated usage cost with prorated advances', () => {
const result = utilityEstimate( const result = utilityEstimate(
[ [

View File

@@ -1,25 +1,44 @@
export type ChartPoint = { date: string; value: number }; export type ChartPoint = { date: string; value: number };
export type ChartSeries = { label: string; color: string; points: ChartPoint[] }; export type ChartSeries = {
label: string;
color: string;
points: ChartPoint[];
emphasis?: boolean;
};
export function balanceSeries( export function balanceSeries(
accounts: { id: number; name: string; openingBalanceCents: number }[], accounts: { id: number; name: string; openingBalanceCents: number }[],
transactions: { accountId: number; date: string; amountCents: number }[] transactions: { accountId: number; date: string; amountCents: number }[]
): ChartSeries[] { ): ChartSeries[] {
const palette = ['#0070f3', '#e5484d', '#46a758', '#ad7f00', '#8e4ec6', '#0091ad', '#e56910']; const palette = ['#0070f3', '#e5484d', '#46a758', '#ad7f00', '#8e4ec6', '#0091ad', '#e56910'];
const lastDate = transactions.at(-1)?.date; const ordered = [...transactions].sort((a, b) => a.date.localeCompare(b.date));
return accounts.map((account, index) => { const balances = new Map(accounts.map((account) => [account.id, account.openingBalanceCents]));
let balance = account.openingBalanceCents; const accountPoints = new Map(accounts.map((account) => [account.id, [] as ChartPoint[]]));
const entries = transactions.filter((transaction) => transaction.accountId === account.id); const combinedPoints: ChartPoint[] = [];
const points: ChartPoint[] = []; for (let index = 0; index < ordered.length;) {
if (entries.length) points.push({ date: entries[0].date, value: balance }); const date = ordered[index].date;
for (const entry of entries) { while (index < ordered.length && ordered[index].date === date) {
balance += entry.amountCents; const transaction = ordered[index++];
points.push({ date: entry.date, value: balance }); balances.set(
transaction.accountId,
(balances.get(transaction.accountId) ?? 0) + transaction.amountCents
);
} }
if (lastDate && entries.length && lastDate > entries.at(-1)!.date) for (const account of accounts)
points.push({ date: lastDate, value: balance }); accountPoints.get(account.id)!.push({ date, value: balances.get(account.id)! });
return { label: account.name, color: palette[index % palette.length], points }; combinedPoints.push({
}); date,
value: [...balances.values()].reduce((sum, balance) => sum + balance, 0)
});
}
return [
...accounts.map((account, index) => ({
label: account.name,
color: palette[index % palette.length],
points: accountPoints.get(account.id)!
})),
{ label: 'All accounts', color: 'var(--site-muted)', points: combinedPoints }
];
} }
export function accountBalances( export function accountBalances(
@@ -59,6 +78,122 @@ export function forecastSeries(
return [{ label: 'Projected total', color: '#0070f3', points }]; return [{ label: 'Projected total', color: '#0070f3', points }];
} }
type ProjectionAccount = { id: number; kind: string; openingBalanceCents: number };
type ProjectionMovement = { date: string; amountCents: number; accountId: number | null };
type ProjectionRecurring = {
amountCents: number;
cadence: string;
accountId: number | null;
startsOn?: string | null;
endsOn?: string | null;
};
type ProjectionSettings = {
investmentAccountId?: number | null;
reserveCents: number;
annualReturnPercent: string | number;
contributionMode: string;
fixedMonthlyCents: number;
includeRecurring: number;
endDate?: string | null;
};
export function wealthProjection(
accounts: ProjectionAccount[],
balances: Map<number, number>,
futureTransactions: ProjectionMovement[],
forecasts: ProjectionMovement[],
recurring: ProjectionRecurring[],
settings: ProjectionSettings | null,
today: string
): ChartSeries[] {
const accountKind = new Map(accounts.map((account) => [account.id, account.kind]));
const investmentId = settings?.investmentAccountId ?? null;
const validInvestmentId =
investmentId !== null && accountKind.get(investmentId) === 'investment' ? investmentId : null;
let spendable = 0;
let invested = 0;
for (const account of accounts) {
const value = balances.get(account.id) ?? 0;
if (account.kind === 'investment') invested += value;
else spendable += value;
}
let indexBalance = validInvestmentId === null ? 0 : (balances.get(validInvestmentId) ?? 0);
const totalPoints: ChartPoint[] = [{ date: today, value: spendable + invested }];
const spendablePoints: ChartPoint[] = [{ date: today, value: spendable }];
const investedPoints: ChartPoint[] = [{ date: today, value: invested }];
const movements = [...futureTransactions, ...forecasts].filter((entry) => entry.date > today);
const latestMovement = movements.reduce(
(latest, entry) => (entry.date > latest ? entry.date : latest),
''
);
const fallback = new Date(`${today}T12:00:00Z`);
fallback.setUTCFullYear(fallback.getUTCFullYear() + 1);
const horizon =
settings?.endDate && settings.endDate > today
? settings.endDate
: latestMovement || fallback.toISOString().slice(0, 10);
const monthEnd = (year: number, month: number) =>
new Date(Date.UTC(year, month + 1, 0)).toISOString().slice(0, 10);
const start = new Date(`${today}T12:00:00Z`);
let year = start.getUTCFullYear();
let month = start.getUTCMonth();
const yearly = Number(settings?.annualReturnPercent ?? 5) / 100;
const monthlyGrowth = Math.pow(1 + yearly, 1 / 12) - 1;
for (let index = 0; index < 240; index++) {
const first = `${year}-${String(month + 1).padStart(2, '0')}-01`;
const end = monthEnd(year, month);
const date = end < horizon ? end : horizon;
if (date > today) {
for (const entry of movements.filter(
(item) => (index === 0 ? item.date > today : item.date >= first) && item.date <= date
)) {
if (entry.accountId !== null && accountKind.get(entry.accountId) === 'investment') {
invested += entry.amountCents;
if (entry.accountId === validInvestmentId) indexBalance += entry.amountCents;
} else spendable += entry.amountCents;
}
if (settings?.includeRecurring !== 0 && index > 0) {
for (const item of recurring) {
if ((item.startsOn && item.startsOn > date) || (item.endsOn && item.endsOn < first))
continue;
const value = Math.round(monthlyCents(item.amountCents, item.cadence));
if (item.accountId !== null && accountKind.get(item.accountId) === 'investment') {
invested += value;
if (item.accountId === validInvestmentId) indexBalance += value;
} else spendable += value;
}
}
if (validInvestmentId !== null && index > 0) {
const growth = Math.round(indexBalance * monthlyGrowth);
invested += growth;
indexBalance += growth;
const excess = Math.max(0, spendable - (settings?.reserveCents ?? 100000));
const contribution =
settings?.contributionMode === 'fixed'
? Math.min(excess, settings.fixedMonthlyCents)
: excess;
spendable -= contribution;
invested += contribution;
indexBalance += contribution;
}
totalPoints.push({ date, value: spendable + invested });
spendablePoints.push({ date, value: spendable });
investedPoints.push({ date, value: invested });
}
if (end >= horizon) break;
month++;
if (month === 12) {
month = 0;
year++;
}
}
return [
{ label: 'Total wealth', color: '#0070f3', points: totalPoints, emphasis: true },
{ label: 'Spendable', color: '#46a758', points: spendablePoints },
{ label: 'Invested', color: '#8e4ec6', points: investedPoints }
];
}
export function utilityEstimate( export function utilityEstimate(
readings: { date: string; reading: string; kind: string }[], readings: { date: string; reading: string; kind: string }[],
kind: string, kind: string,

View File

@@ -11,6 +11,7 @@ export const moneyAccounts = pgTable(
.references(() => user.id, { onDelete: 'cascade' }), .references(() => user.id, { onDelete: 'cascade' }),
name: text('name').notNull(), name: text('name').notNull(),
openingBalanceCents: integer('opening_balance_cents').notNull().default(0), openingBalanceCents: integer('opening_balance_cents').notNull().default(0),
kind: text('kind').notNull().default('spendable'),
...timestamps ...timestamps
}, },
(table) => [unique().on(table.userId, table.name), index().on(table.userId)] (table) => [unique().on(table.userId, table.name), index().on(table.userId)]
@@ -44,6 +45,8 @@ export const moneyRecurring = pgTable(
name: text('name').notNull(), name: text('name').notNull(),
amountCents: integer('amount_cents').notNull(), amountCents: integer('amount_cents').notNull(),
cadence: text('cadence').notNull(), cadence: text('cadence').notNull(),
startsOn: date('starts_on', { mode: 'string' }),
endsOn: date('ends_on', { mode: 'string' }),
accountId: integer('account_id').references(() => moneyAccounts.id, { onDelete: 'set null' }), accountId: integer('account_id').references(() => moneyAccounts.id, { onDelete: 'set null' }),
...timestamps ...timestamps
}, },
@@ -106,12 +109,36 @@ export const moneyWishlist = pgTable(
.references(() => user.id, { onDelete: 'cascade' }), .references(() => user.id, { onDelete: 'cascade' }),
name: text('name').notNull(), name: text('name').notNull(),
estimatedCents: integer('estimated_cents'), estimatedCents: integer('estimated_cents'),
priority: integer('priority').notNull().default(3),
notes: text('notes'), notes: text('notes'),
...timestamps ...timestamps
}, },
(table) => [index().on(table.userId)] (table) => [index().on(table.userId)]
); );
export const moneyProjectionSettings = pgTable(
'money_projection_settings',
{
id: id(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
investmentAccountId: integer('investment_account_id').references(() => moneyAccounts.id, {
onDelete: 'set null'
}),
reserveCents: integer('reserve_cents').notNull().default(100000),
annualReturnPercent: numeric('annual_return_percent', { precision: 5, scale: 2 })
.notNull()
.default('5.00'),
contributionMode: text('contribution_mode').notNull().default('excess'),
includeRecurring: integer('include_recurring').notNull().default(1),
fixedMonthlyCents: integer('fixed_monthly_cents').notNull().default(0),
endDate: date('end_date', { mode: 'string' }),
...timestamps
},
(table) => [unique().on(table.userId)]
);
export const moneyInsurances = pgTable( export const moneyInsurances = pgTable(
'money_insurances', 'money_insurances',
{ {

View File

@@ -7,7 +7,8 @@ import {
moneyUtilitySettings, moneyUtilitySettings,
moneyUtilityReadings, moneyUtilityReadings,
moneyWishlist, moneyWishlist,
moneyInsurances moneyInsurances,
moneyProjectionSettings
} from '$lib/server/db/schema'; } from '$lib/server/db/schema';
import { requireAdmin } from '$lib/server/authorization'; import { requireAdmin } from '$lib/server/authorization';
import { import {
@@ -38,7 +39,8 @@ export const load: PageServerLoad = async (event) => {
utilitySettings, utilitySettings,
utilityReadings, utilityReadings,
wishlist, wishlist,
insurances insurances,
projectionSettings
] = await Promise.all([ ] = await Promise.all([
db db
.select() .select()
@@ -75,7 +77,8 @@ export const load: PageServerLoad = async (event) => {
.select() .select()
.from(moneyInsurances) .from(moneyInsurances)
.where(eq(moneyInsurances.userId, userId)) .where(eq(moneyInsurances.userId, userId))
.orderBy(asc(moneyInsurances.name)) .orderBy(asc(moneyInsurances.name)),
db.select().from(moneyProjectionSettings).where(eq(moneyProjectionSettings.userId, userId))
]); ]);
return { return {
accounts, accounts,
@@ -85,7 +88,9 @@ export const load: PageServerLoad = async (event) => {
utilitySettings, utilitySettings,
utilityReadings, utilityReadings,
wishlist, wishlist,
insurances insurances,
projectionSettings: projectionSettings[0] ?? null,
today: new Date().toISOString().slice(0, 10)
}; };
}; };
@@ -111,18 +116,20 @@ export const actions: Actions = {
const form = await event.request.formData(); const form = await event.request.formData();
const name = textField(form, 'name', 80); const name = textField(form, 'name', 80);
const openingBalanceCents = cents(form.get('openingBalance')); const openingBalanceCents = cents(form.get('openingBalance'));
if (!name || openingBalanceCents == null) const kind = String(form.get('kind') ?? 'spendable');
if (!name || openingBalanceCents == null || !['spendable', 'investment'].includes(kind))
return invalid('Enter an account name and a valid opening balance.'); return invalid('Enter an account name and a valid opening balance.');
const id = recordId(form); const id = recordId(form);
try { try {
if (id) { if (id) {
const [row] = await db const [row] = await db
.update(moneyAccounts) .update(moneyAccounts)
.set({ name, openingBalanceCents }) .set({ name, openingBalanceCents, kind })
.where(and(eq(moneyAccounts.id, id), eq(moneyAccounts.userId, user.id))) .where(and(eq(moneyAccounts.id, id), eq(moneyAccounts.userId, user.id)))
.returning({ id: moneyAccounts.id }); .returning({ id: moneyAccounts.id });
if (!row) error(404, 'Account not found'); if (!row) error(404, 'Account not found');
} else await db.insert(moneyAccounts).values({ userId: user.id, name, openingBalanceCents }); } else
await db.insert(moneyAccounts).values({ userId: user.id, name, openingBalanceCents, kind });
} catch (cause) { } catch (cause) {
if (typeof cause === 'object' && cause && 'code' in cause && cause.code === '23505') if (typeof cause === 'object' && cause && 'code' in cause && cause.code === '23505')
return invalid('An account with that name already exists.'); return invalid('An account with that name already exists.');
@@ -182,10 +189,21 @@ export const actions: Actions = {
const name = textField(form, 'name', 200); const name = textField(form, 'name', 200);
const amountCents = cents(form.get('amount')); const amountCents = cents(form.get('amount'));
const period = cadence(form.get('cadence')); const period = cadence(form.get('cadence'));
if (!name || amountCents == null || !period) const startsOnRaw = String(form.get('startsOn') ?? '').trim();
const endsOnRaw = String(form.get('endsOn') ?? '').trim();
const startsOn = startsOnRaw ? validDate(startsOnRaw) : null;
const endsOn = endsOnRaw ? validDate(endsOnRaw) : null;
if (
!name ||
amountCents == null ||
!period ||
(startsOnRaw && !startsOn) ||
(endsOnRaw && !endsOn) ||
(startsOn && endsOn && endsOn < startsOn)
)
return invalid('Enter a name, valid signed amount and frequency.'); return invalid('Enter a name, valid signed amount and frequency.');
const accountId = await ownedAccount(user.id, form.get('accountId'), true); const accountId = await ownedAccount(user.id, form.get('accountId'), true);
const values = { name, amountCents, cadence: period, accountId }; const values = { name, amountCents, cadence: period, accountId, startsOn, endsOn };
const id = recordId(form); const id = recordId(form);
if (id) { if (id) {
const [row] = await db const [row] = await db
@@ -236,6 +254,59 @@ export const actions: Actions = {
.where(and(eq(moneyForecasts.id, id), eq(moneyForecasts.userId, user.id))); .where(and(eq(moneyForecasts.id, id), eq(moneyForecasts.userId, user.id)));
return { saved: true }; return { saved: true };
}, },
saveProjectionSettings: async (event) => {
const user = requireAdmin(event);
const form = await event.request.formData();
const investmentAccountId = await ownedAccount(user.id, form.get('investmentAccountId'), true);
if (investmentAccountId !== null) {
const [account] = await db
.select({ kind: moneyAccounts.kind })
.from(moneyAccounts)
.where(and(eq(moneyAccounts.id, investmentAccountId), eq(moneyAccounts.userId, user.id)));
if (account?.kind !== 'investment') return invalid('Choose an investment account.');
}
const reserveCents = cents(form.get('reserve'));
const fixedMonthlyCents = cents(form.get('fixedMonthly'));
const annualReturnPercent = String(form.get('annualReturnPercent') ?? '')
.trim()
.replace(',', '.');
const contributionMode = String(form.get('contributionMode') ?? 'excess');
const endDateRaw = String(form.get('endDate') ?? '').trim();
const endDate = endDateRaw ? validDate(endDateRaw) : null;
const maxEndDate = new Date();
maxEndDate.setUTCFullYear(maxEndDate.getUTCFullYear() + 20);
if (
reserveCents == null ||
reserveCents < 0 ||
fixedMonthlyCents == null ||
fixedMonthlyCents < 0 ||
!/^(?:\d{1,2}|100)(?:\.\d{1,2})?$/.test(annualReturnPercent) ||
Number(annualReturnPercent) > 100 ||
!['excess', 'fixed'].includes(contributionMode) ||
(endDateRaw && !endDate) ||
(endDate &&
(endDate < new Date().toISOString().slice(0, 10) ||
endDate > maxEndDate.toISOString().slice(0, 10)))
)
return invalid('Enter valid projection settings.');
const values = {
investmentAccountId,
reserveCents,
fixedMonthlyCents,
annualReturnPercent,
contributionMode,
includeRecurring: form.get('includeRecurring') === 'on' ? 1 : 0,
endDate
};
await db
.insert(moneyProjectionSettings)
.values({ userId: user.id, ...values })
.onConflictDoUpdate({
target: moneyProjectionSettings.userId,
set: values
});
return { saved: true };
},
saveUtilitySettings: async (event) => { saveUtilitySettings: async (event) => {
const user = requireAdmin(event); const user = requireAdmin(event);
const form = await event.request.formData(); const form = await event.request.formData();
@@ -300,14 +371,18 @@ export const actions: Actions = {
const name = textField(form, 'name', 200); const name = textField(form, 'name', 200);
const estimatedCents = cents(form.get('estimated'), true); const estimatedCents = cents(form.get('estimated'), true);
const notes = optionalText(form, 'notes'); const notes = optionalText(form, 'notes');
const priority = Number(form.get('priority') ?? 3);
if ( if (
!name || !name ||
estimatedCents === undefined || estimatedCents === undefined ||
notes === undefined || notes === undefined ||
(estimatedCents !== null && estimatedCents < 0) (estimatedCents !== null && estimatedCents < 0) ||
!Number.isInteger(priority) ||
priority < 1 ||
priority > 5
) )
return invalid('Enter a name and a valid estimated cost.'); return invalid('Enter a name and a valid estimated cost.');
const values = { name, estimatedCents, notes }; const values = { name, estimatedCents, notes, priority };
const id = recordId(form); const id = recordId(form);
if (id) { if (id) {
const [row] = await db const [row] = await db

View File

@@ -11,9 +11,9 @@
import { import {
accountBalances, accountBalances,
balanceSeries, balanceSeries,
forecastSeries,
monthlyCents, monthlyCents,
utilityEstimate utilityEstimate,
wealthProjection
} from '$lib/money-tracker/charts'; } from '$lib/money-tracker/charts';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
@@ -26,6 +26,7 @@
| 'recurring' | 'recurring'
| 'forecast' | 'forecast'
| 'settings' | 'settings'
| 'projectionSettings'
| 'reading' | 'reading'
| 'wish' | 'wish'
| 'insurance' | 'insurance'
@@ -57,10 +58,12 @@
let pageNumber = $state(1); let pageNumber = $state(1);
let selectedUtility = $state<(typeof kinds)[number]['id']>('electricity'); let selectedUtility = $state<(typeof kinds)[number]['id']>('electricity');
let dialog: HTMLDialogElement; let dialog: HTMLDialogElement;
let balances = $derived(accountBalances(data.accounts, data.transactions)); let actualTransactions = $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 total = $derived([...balances.values()].reduce((sum, value) => sum + value, 0)); let total = $derived([...balances.values()].reduce((sum, value) => sum + value, 0));
let series = $derived(balanceSeries(data.accounts, data.transactions)); let series = $derived(balanceSeries(data.accounts, actualTransactions));
let recent = $derived([...data.transactions].reverse().slice(0, 8)); let recent = $derived([...actualTransactions].reverse().slice(0, 8));
let filtered = $derived( let filtered = $derived(
(sortOrder === 'newest' ? [...data.transactions].reverse() : data.transactions).filter( (sortOrder === 'newest' ? [...data.transactions].reverse() : data.transactions).filter(
(row) => (row) =>
@@ -75,7 +78,27 @@
data.recurring.reduce((sum, item) => sum + monthlyCents(item.amountCents, item.cadence), 0) data.recurring.reduce((sum, item) => sum + monthlyCents(item.amountCents, item.cadence), 0)
); );
let projection = $derived( let projection = $derived(
forecastSeries(total, data.forecasts, new Date().toISOString().slice(0, 10)) wealthProjection(
data.accounts,
balances,
futureTransactions,
data.forecasts,
data.recurring,
data.projectionSettings,
data.today
)
);
let wishTiers = $derived(
[5, 4, 3, 2, 1].map((tier) => ({
tier,
value: data.wishlist
.filter((item) => item.priority >= tier)
.reduce((sum, item) => sum + (item.estimatedCents ?? 0), 0),
count: data.wishlist.filter((item) => item.priority >= tier).length
}))
);
let unpricedWishes = $derived(
data.wishlist.filter((item) => item.estimatedCents === null).length
); );
let utility = $derived(kinds.find((item) => item.id === selectedUtility)!); let utility = $derived(kinds.find((item) => item.id === selectedUtility)!);
let settings = $derived(data.utilitySettings.find((item) => item.kind === selectedUtility)); let settings = $derived(data.utilitySettings.find((item) => item.kind === selectedUtility));
@@ -206,7 +229,7 @@
<div class="net-worth"> <div class="net-worth">
<p class="eyebrow">Across {data.accounts.length} accounts</p> <p class="eyebrow">Across {data.accounts.length} accounts</p>
<p class="net-value">{amount(total)}</p> <p class="net-value">{amount(total)}</p>
<p class="muted">Current combined balance</p> <p class="muted">Current combined balance through {dateLabel(data.today)}</p>
</div> </div>
<div class="overview-side"> <div class="overview-side">
<div> <div>
@@ -223,10 +246,55 @@
<div class="panel-heading"> <div class="panel-heading">
<div> <div>
<h2>Account history</h2> <h2>Account history</h2>
<p>Balances calculated from opening balances and dated transactions.</p> <p>
End-of-day balances through today. Future dated transactions appear in the projection.
</p>
</div> </div>
</div> </div>
<MoneyChart {series} title="Account balances over time" /> <MoneyChart {series} title="Individual and combined account balances over time" />
</section>
<section class="panel">
<div class="panel-heading">
<div>
<h2>Expected wealth</h2>
<p>From today through {dateLabel(projection[0].points.at(-1)?.date ?? data.today)}.</p>
</div>
<button
class="button secondary"
onclick={() => open('projectionSettings', data.projectionSettings ?? {})}
>Edit projection</button
>
</div>
<MoneyChart series={projection} title="Expected total, spendable and invested wealth" />
<p class="muted projection-note">
Includes future dated transactions and estimates{data.projectionSettings
?.includeRecurring !== 0
? ', plus recurring monthly equivalents from next month'
: ''}. {data.projectionSettings?.investmentAccountId
? `The selected investment account has a hypothetical ${data.projectionSettings.annualReturnPercent}% annual return; contributions move money between accounts.`
: 'Choose an investment account to model contributions and growth.'} Enter an item only once
across transactions, estimates, and recurring items to avoid counting it twice.
</p>
</section>
<section class="panel">
<div class="panel-heading">
<div>
<h2>Wish list total</h2>
<p>Cumulative estimated cost by priority, from tier 5 down.</p>
</div>
<button class="text-button" onclick={() => (tab = 'wishlist')}>View wish list</button>
</div>
<div class="wish-tier-grid">
{#each wishTiers as row (row.tier)}<div>
<span class="eyebrow">{row.tier === 5 ? 'Tier 5' : `Tiers 5–${row.tier}`}</span><strong
>{amount(row.value)}</strong
><small>{row.count} {row.count === 1 ? 'item' : 'items'}</small>
</div>{/each}
</div>
{#if unpricedWishes}<p class="muted projection-note">
{unpricedWishes} unpriced {unpricedWishes === 1 ? 'item is' : 'items are'} excluded from these
totals.
</p>{/if}
</section> </section>
<div class="two-columns"> <div class="two-columns">
<section class="panel"> <section class="panel">
@@ -348,23 +416,10 @@
<p class="muted">Signed total of recurring income and costs</p> <p class="muted">Signed total of recurring income and costs</p>
</div> </div>
<p class="muted"> <p class="muted">
Recurring items are a reference list. Dated forecasts do not change actual balances. Recurring items feed the Overview projection when enabled. Dated forecasts do not change
actual balances.
</p> </p>
</div> </div>
{#if data.forecasts.some((item) => item.date >= new Date().toISOString().slice(0, 10))}
<section class="panel">
<div class="panel-heading">
<div>
<h2>Projected total</h2>
<p>
Current combined balance plus dated future estimates. Recurring items are not
automatically included.
</p>
</div>
</div>
<MoneyChart series={projection} title="Projected combined account balance" />
</section>
{/if}
<section class="panel"> <section class="panel">
<div class="panel-heading"> <div class="panel-heading">
<div> <div>
@@ -379,17 +434,20 @@
<table> <table>
<thead <thead
><tr ><tr
><th>Name</th><th>Account</th><th>Frequency</th><th class="numeric">Amount</th><th ><th>Name</th><th>Account</th><th>Frequency</th><th>Dates</th><th class="numeric"
class="actions-heading">Actions</th >Amount</th
></tr ><th class="actions-heading">Actions</th></tr
></thead ></thead
><tbody ><tbody
>{#each data.recurring as row (row.id)}<tr >{#each data.recurring as row (row.id)}<tr
><td class="strong">{row.name}</td><td ><td class="strong">{row.name}</td><td
>{row.accountId ? accountName(row.accountId) : 'Any'}</td >{row.accountId ? accountName(row.accountId) : 'Any'}</td
><td class="capitalize">{row.cadence}</td><td ><td class="capitalize">{row.cadence}</td><td
class="numeric" >{row.startsOn ? dateLabel(row.startsOn) : 'Now'} – {row.endsOn
class:negative={row.amountCents < 0}>{amount(row.amountCents)}</td ? dateLabel(row.endsOn)
: 'ongoing'}</td
><td class="numeric" class:negative={row.amountCents < 0}
>{amount(row.amountCents)}</td
><td class="actions" ><td class="actions"
><button aria-label={'Edit ' + row.name} onclick={() => open('recurring', row)} ><button aria-label={'Edit ' + row.name} onclick={() => open('recurring', row)}
><Pencil size={15} /></button ><Pencil size={15} /></button
@@ -552,13 +610,14 @@
<table> <table>
<thead <thead
><tr ><tr
><th>Item</th><th>Notes</th><th class="numeric">Estimated cost</th><th ><th>Item</th><th>Tier</th><th>Notes</th><th class="numeric">Estimated cost</th><th
class="actions-heading">Actions</th class="actions-heading">Actions</th
></tr ></tr
></thead ></thead
><tbody ><tbody
>{#each data.wishlist as row (row.id)}<tr >{#each data.wishlist as row (row.id)}<tr
><td class="strong">{row.name}</td><td>{row.notes ?? '—'}</td><td class="numeric" ><td class="strong">{row.name}</td><td>{row.priority}</td><td>{row.notes ?? '—'}</td
><td class="numeric"
>{row.estimatedCents == null ? '—' : amount(row.estimatedCents)}</td >{row.estimatedCents == null ? '—' : amount(row.estimatedCents)}</td
><td class="actions" ><td class="actions"
><button aria-label={'Edit ' + row.name} onclick={() => open('wish', row)} ><button aria-label={'Edit ' + row.name} onclick={() => open('wish', row)}
@@ -628,14 +687,14 @@
<table> <table>
<thead <thead
><tr ><tr
><th>Account</th><th class="numeric">Opening balance</th><th class="numeric" ><th>Account</th><th>Type</th><th class="numeric">Opening balance</th><th
>Current balance</th class="numeric">Current balance</th
><th class="actions-heading">Actions</th></tr ><th class="actions-heading">Actions</th></tr
></thead ></thead
><tbody ><tbody
>{#each data.accounts as row (row.id)}<tr >{#each data.accounts as row (row.id)}<tr
><td class="strong">{row.name}</td><td class="numeric" ><td class="strong">{row.name}</td><td class="capitalize">{row.kind}</td><td
>{amount(row.openingBalanceCents)}</td class="numeric">{amount(row.openingBalanceCents)}</td
><td class="numeric strong">{amount(balances.get(row.id) ?? 0)}</td><td ><td class="numeric strong">{amount(balances.get(row.id) ?? 0)}</td><td
class="actions" class="actions"
><button aria-label={'Edit ' + row.name} onclick={() => open('account', row)} ><button aria-label={'Edit ' + row.name} onclick={() => open('account', row)}
@@ -667,8 +726,10 @@
? 'Import transactions' ? 'Import transactions'
: mode === 'settings' : mode === 'settings'
? 'Utility settings' ? 'Utility settings'
: (active.id ? 'Edit ' : 'Add ') + : mode === 'projectionSettings'
(mode === 'wish' ? 'wish list item' : mode === 'reading' ? 'meter reading' : mode)} ? 'Wealth projection'
: (active.id ? 'Edit ' : 'Add ') +
(mode === 'wish' ? 'wish list item' : mode === 'reading' ? 'meter reading' : mode)}
</h2> </h2>
<button class="icon-button" aria-label="Close dialog" onclick={() => dialog.close()} <button class="icon-button" aria-label="Close dialog" onclick={() => dialog.close()}
><X size={18} /></button ><X size={18} /></button
@@ -738,11 +799,13 @@
? 'saveForecast' ? 'saveForecast'
: mode === 'settings' : mode === 'settings'
? 'saveUtilitySettings' ? 'saveUtilitySettings'
: mode === 'reading' : mode === 'projectionSettings'
? 'saveReading' ? 'saveProjectionSettings'
: mode === 'wish' : mode === 'reading'
? 'saveWishlist' ? 'saveReading'
: 'saveInsurance')} : mode === 'wish'
? 'saveWishlist'
: 'saveInsurance')}
use:enhance={() => use:enhance={() =>
async ({ result, update }) => { async ({ result, update }) => {
await update({ reset: false }); await update({ reset: false });
@@ -770,6 +833,15 @@
value={inputAmount(active.openingBalanceCents)} value={inputAmount(active.openingBalanceCents)}
/><small>Balance before the first transaction you record.</small></label /><small>Balance before the first transaction you record.</small></label
> >
<label class="field"
>Account type<select name="kind"
><option value="spendable" selected={active.kind !== 'investment'}>Spendable</option
><option value="investment" selected={active.kind === 'investment'}>Investment</option
></select
><small
>Investment accounts are included in total wealth but excluded from the cash reserve.</small
></label
>
{:else if mode === 'transaction' || mode === 'forecast'} {:else if mode === 'transaction' || mode === 'forecast'}
<label class="field" <label class="field"
>Date<input >Date<input
@@ -844,6 +916,78 @@
>{/each}</select >{/each}</select
></label ></label
> >
<label class="field"
>Starts on<input name="startsOn" type="date" value={active.startsOn ?? ''} /><small
>Optional. Leave blank for an ongoing item.</small
></label
>
<label class="field"
>Ends on<input name="endsOn" type="date" value={active.endsOn ?? ''} /></label
>
{:else if mode === 'projectionSettings'}
<p class="dialog-copy">
A planning scenario. Returns are hypothetical and calculated monthly. Contributions
transfer money from spendable accounts into the selected investment account.
</p>
<label class="field"
>Investment account<select name="investmentAccountId"
><option value="">No investment model</option
>{#each data.accounts.filter((account) => account.kind === 'investment') as account (account.id)}<option
value={account.id}
selected={active.investmentAccountId === account.id}>{account.name}</option
>{/each}</select
><small>Mark an account as Investment in Accounts to select it here.</small></label
>
<label class="field"
>Spendable reserve (€)<input
name="reserve"
type="number"
min="0"
step="0.01"
required
value={inputAmount(active.reserveCents ?? 100000)}
/></label
>
<label class="field"
>Hypothetical annual return (%)<input
name="annualReturnPercent"
type="number"
min="0"
max="100"
step="0.01"
required
value={active.annualReturnPercent ?? '5.00'}
/></label
>
<label class="field"
>Contribution rule<select name="contributionMode"
><option value="excess" selected={active.contributionMode !== 'fixed'}
>Invest all above reserve</option
><option value="fixed" selected={active.contributionMode === 'fixed'}
>Fixed monthly amount, up to excess</option
></select
></label
>
<label class="field"
>Fixed monthly amount (€)<input
name="fixedMonthly"
type="number"
min="0"
step="0.01"
required
value={inputAmount(active.fixedMonthlyCents ?? 0)}
/></label
>
<label class="field"
>Project through<input name="endDate" type="date" value={active.endDate ?? ''} /><small
>Optional, up to 20 years ahead. By default, ends at the latest future entry, or one
year from today.</small
></label
>
<label class="field check-field"
><input name="includeRecurring" type="checkbox" checked={active.includeRecurring !== 0} /> Include
recurring items from next month</label
>
{:else if mode === 'settings'} {:else if mode === 'settings'}
<input type="hidden" name="kind" value={active.kind ?? selectedUtility} /> <input type="hidden" name="kind" value={active.kind ?? selectedUtility} />
<label class="field" <label class="field"
@@ -915,6 +1059,15 @@
value={inputAmount(active.estimatedCents)} value={inputAmount(active.estimatedCents)}
/></label /></label
> >
<label class="field"
>Priority tier<select name="priority"
>{#each [5, 4, 3, 2, 1] as tier (tier)}<option
value={tier}
selected={(active.priority ?? 3) === tier}
>Tier {tier}{tier === 5 ? ' · highest' : tier === 1 ? ' · lowest' : ''}</option
>{/each}</select
></label
>
<label class="field" <label class="field"
>Notes<textarea name="notes" maxlength="2000" rows="3">{active.notes ?? ''}</textarea >Notes<textarea name="notes" maxlength="2000" rows="3">{active.notes ?? ''}</textarea
></label ></label

View File

@@ -126,6 +126,38 @@
letter-spacing: -0.04em; letter-spacing: -0.04em;
margin-top: 7px; margin-top: 7px;
} }
.money-app .projection-note {
margin-top: 14px;
line-height: 1.5;
}
.money-app .wish-tier-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
border: 1px solid var(--site-border);
border-radius: 8px;
overflow: hidden;
}
.money-app .wish-tier-grid > div {
display: grid;
gap: 5px;
padding: 15px;
min-width: 0;
}
.money-app .wish-tier-grid > div + div {
border-left: 1px solid var(--site-border);
}
.money-app .wish-tier-grid strong {
font-size: 17px;
font-weight: 600;
overflow-wrap: anywhere;
}
.money-app .wish-tier-grid small {
color: var(--site-muted);
font-size: 11px;
}
.money-app .capitalize {
text-transform: capitalize;
}
.money-app .eyebrow { .money-app .eyebrow {
font-size: 12px; font-size: 12px;
color: var(--site-muted); color: var(--site-muted);
@@ -401,6 +433,15 @@
font-size: 11px; font-size: 11px;
font-weight: 400; font-weight: 400;
} }
.money-dialog .field.check-field {
display: flex;
align-items: center;
font-weight: 400;
}
.money-dialog .field.check-field input {
width: auto;
min-height: auto;
}
.money-dialog .dialog-footer { .money-dialog .dialog-footer {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
@@ -430,6 +471,13 @@
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 8px; gap: 8px;
} }
.money-app .wish-tier-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.money-app .wish-tier-grid > div {
border-left: 0 !important;
border-top: 1px solid var(--site-border);
}
.money-app .planning-intro { .money-app .planning-intro {
display: block; display: block;
} }