From 499dd94cbb212f0493df285f6c1d80c5ea4a9a78 Mon Sep 17 00:00:00 2001 From: Stan Runge Date: Wed, 23 Sep 2026 18:06:20 +0000 Subject: [PATCH] update money tracker --- scripts/deploy-preflight.ts | 15 +- scripts/money-tracker-migration.sql | 23 ++ src/lib/money-tracker/MoneyChart.svelte | 23 +- src/lib/money-tracker/charts.test.ts | 85 ++++++- src/lib/money-tracker/charts.ts | 163 ++++++++++-- src/lib/server/db/schema/money-tracker.ts | 27 ++ .../projects/money-tracker/+page.server.ts | 97 ++++++- .../projects/money-tracker/+page.svelte | 237 ++++++++++++++---- .../projects/money-tracker/money-tracker.css | 48 ++++ 9 files changed, 641 insertions(+), 77 deletions(-) diff --git a/scripts/deploy-preflight.ts b/scripts/deploy-preflight.ts index f57f27a..4fa7bfa 100644 --- a/scripts/deploy-preflight.ts +++ b/scripts/deploy-preflight.ts @@ -35,10 +35,16 @@ try { (select count(*) from information_schema.tables where table_schema = 'public' and table_name in ( '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, (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 ( !Number(schema.user_role) || @@ -49,8 +55,9 @@ try { !schema.list_completions || !Number(schema.topic_link_id) || Number(schema.animal_serial_ids) !== 3 || - Number(schema.money_tables) !== 8 || - !Number(schema.money_billing_start) + Number(schema.money_tables) !== 9 || + !Number(schema.money_billing_start) || + Number(schema.money_projection_columns) !== 5 ) { throw new Error( 'Required migrations or Animaldex ID conversion have not been applied to this database' diff --git a/scripts/money-tracker-migration.sql b/scripts/money-tracker-migration.sql index ddcc73b..f43992c 100644 --- a/scripts/money-tracker-migration.sql +++ b/scripts/money-tracker-migration.sql @@ -3,11 +3,13 @@ CREATE TABLE IF NOT EXISTS money_accounts ( user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, name text NOT NULL, opening_balance_cents integer NOT NULL DEFAULT 0, + kind text NOT NULL DEFAULT 'spendable', created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), UNIQUE (user_id, name) ); 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 ( id serial PRIMARY KEY, @@ -28,11 +30,15 @@ CREATE TABLE IF NOT EXISTS money_recurring ( name text NOT NULL, amount_cents integer NOT NULL, cadence text NOT NULL, + starts_on date, + ends_on date, account_id integer REFERENCES money_accounts(id) ON DELETE SET NULL, created_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); +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 ( 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, name text NOT NULL, estimated_cents integer, + priority integer NOT NULL DEFAULT 3, notes text, created_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); +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 ( id serial PRIMARY KEY, diff --git a/src/lib/money-tracker/MoneyChart.svelte b/src/lib/money-tracker/MoneyChart.svelte index dfd3a7d..7861ac5 100644 --- a/src/lib/money-tracker/MoneyChart.svelte +++ b/src/lib/money-tracker/MoneyChart.svelte @@ -48,8 +48,7 @@
{#if values.length} - -
+
+ {#if item.points.length === 1} + + {/if} {/if} {/each} 1}
{#each series as item (item.label)} - {item.label} + {item.label} {/each}
{/if} @@ -137,6 +146,12 @@ align-items: center; gap: 7px; } + .legend > span.emphasis { + order: -1; + flex-basis: 100%; + color: var(--site-text); + font-weight: 600; + } .swatch { display: inline-block; width: 8px; diff --git a/src/lib/money-tracker/charts.test.ts b/src/lib/money-tracker/charts.test.ts index 10bedbc..656b357 100644 --- a/src/lib/money-tracker/charts.test.ts +++ b/src/lib/money-tracker/charts.test.ts @@ -1,5 +1,11 @@ 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', () => { 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(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', () => { @@ -32,6 +73,46 @@ describe('money tracker calculations', () => { ).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', () => { const result = utilityEstimate( [ diff --git a/src/lib/money-tracker/charts.ts b/src/lib/money-tracker/charts.ts index 5ef670f..bfe902a 100644 --- a/src/lib/money-tracker/charts.ts +++ b/src/lib/money-tracker/charts.ts @@ -1,25 +1,44 @@ 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( accounts: { id: number; name: string; openingBalanceCents: number }[], transactions: { accountId: number; date: string; amountCents: number }[] ): ChartSeries[] { const palette = ['#0070f3', '#e5484d', '#46a758', '#ad7f00', '#8e4ec6', '#0091ad', '#e56910']; - const lastDate = transactions.at(-1)?.date; - return accounts.map((account, index) => { - let balance = account.openingBalanceCents; - const entries = transactions.filter((transaction) => transaction.accountId === account.id); - const points: ChartPoint[] = []; - if (entries.length) points.push({ date: entries[0].date, value: balance }); - for (const entry of entries) { - balance += entry.amountCents; - points.push({ date: entry.date, value: balance }); + const ordered = [...transactions].sort((a, b) => a.date.localeCompare(b.date)); + const balances = new Map(accounts.map((account) => [account.id, account.openingBalanceCents])); + const accountPoints = new Map(accounts.map((account) => [account.id, [] as ChartPoint[]])); + const combinedPoints: ChartPoint[] = []; + for (let index = 0; index < ordered.length;) { + const date = ordered[index].date; + while (index < ordered.length && ordered[index].date === date) { + const transaction = ordered[index++]; + balances.set( + transaction.accountId, + (balances.get(transaction.accountId) ?? 0) + transaction.amountCents + ); } - if (lastDate && entries.length && lastDate > entries.at(-1)!.date) - points.push({ date: lastDate, value: balance }); - return { label: account.name, color: palette[index % palette.length], points }; - }); + for (const account of accounts) + accountPoints.get(account.id)!.push({ date, value: balances.get(account.id)! }); + 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( @@ -59,6 +78,122 @@ export function forecastSeries( 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, + 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( readings: { date: string; reading: string; kind: string }[], kind: string, diff --git a/src/lib/server/db/schema/money-tracker.ts b/src/lib/server/db/schema/money-tracker.ts index 7f4d13c..eacdc5b 100644 --- a/src/lib/server/db/schema/money-tracker.ts +++ b/src/lib/server/db/schema/money-tracker.ts @@ -11,6 +11,7 @@ export const moneyAccounts = pgTable( .references(() => user.id, { onDelete: 'cascade' }), name: text('name').notNull(), openingBalanceCents: integer('opening_balance_cents').notNull().default(0), + kind: text('kind').notNull().default('spendable'), ...timestamps }, (table) => [unique().on(table.userId, table.name), index().on(table.userId)] @@ -44,6 +45,8 @@ export const moneyRecurring = pgTable( name: text('name').notNull(), amountCents: integer('amount_cents').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' }), ...timestamps }, @@ -106,12 +109,36 @@ export const moneyWishlist = pgTable( .references(() => user.id, { onDelete: 'cascade' }), name: text('name').notNull(), estimatedCents: integer('estimated_cents'), + priority: integer('priority').notNull().default(3), notes: text('notes'), ...timestamps }, (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( 'money_insurances', { diff --git a/src/routes/projects/money-tracker/+page.server.ts b/src/routes/projects/money-tracker/+page.server.ts index 33d4857..5088a80 100644 --- a/src/routes/projects/money-tracker/+page.server.ts +++ b/src/routes/projects/money-tracker/+page.server.ts @@ -7,7 +7,8 @@ import { moneyUtilitySettings, moneyUtilityReadings, moneyWishlist, - moneyInsurances + moneyInsurances, + moneyProjectionSettings } from '$lib/server/db/schema'; import { requireAdmin } from '$lib/server/authorization'; import { @@ -38,7 +39,8 @@ export const load: PageServerLoad = async (event) => { utilitySettings, utilityReadings, wishlist, - insurances + insurances, + projectionSettings ] = await Promise.all([ db .select() @@ -75,7 +77,8 @@ export const load: PageServerLoad = async (event) => { .select() .from(moneyInsurances) .where(eq(moneyInsurances.userId, userId)) - .orderBy(asc(moneyInsurances.name)) + .orderBy(asc(moneyInsurances.name)), + db.select().from(moneyProjectionSettings).where(eq(moneyProjectionSettings.userId, userId)) ]); return { accounts, @@ -85,7 +88,9 @@ export const load: PageServerLoad = async (event) => { utilitySettings, utilityReadings, 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 name = textField(form, 'name', 80); 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.'); const id = recordId(form); try { if (id) { const [row] = await db .update(moneyAccounts) - .set({ name, openingBalanceCents }) + .set({ name, openingBalanceCents, kind }) .where(and(eq(moneyAccounts.id, id), eq(moneyAccounts.userId, user.id))) .returning({ id: moneyAccounts.id }); 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) { if (typeof cause === 'object' && cause && 'code' in cause && cause.code === '23505') return invalid('An account with that name already exists.'); @@ -182,10 +189,21 @@ export const actions: Actions = { const name = textField(form, 'name', 200); const amountCents = cents(form.get('amount')); 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.'); 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); if (id) { const [row] = await db @@ -236,6 +254,59 @@ export const actions: Actions = { .where(and(eq(moneyForecasts.id, id), eq(moneyForecasts.userId, user.id))); 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) => { const user = requireAdmin(event); const form = await event.request.formData(); @@ -300,14 +371,18 @@ export const actions: Actions = { const name = textField(form, 'name', 200); const estimatedCents = cents(form.get('estimated'), true); const notes = optionalText(form, 'notes'); + const priority = Number(form.get('priority') ?? 3); if ( !name || estimatedCents === 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.'); - const values = { name, estimatedCents, notes }; + const values = { name, estimatedCents, notes, priority }; const id = recordId(form); if (id) { const [row] = await db diff --git a/src/routes/projects/money-tracker/+page.svelte b/src/routes/projects/money-tracker/+page.svelte index c53c011..1960c99 100644 --- a/src/routes/projects/money-tracker/+page.svelte +++ b/src/routes/projects/money-tracker/+page.svelte @@ -11,9 +11,9 @@ import { accountBalances, balanceSeries, - forecastSeries, monthlyCents, - utilityEstimate + utilityEstimate, + wealthProjection } from '$lib/money-tracker/charts'; import type { PageProps } from './$types'; @@ -26,6 +26,7 @@ | 'recurring' | 'forecast' | 'settings' + | 'projectionSettings' | 'reading' | 'wish' | 'insurance' @@ -57,10 +58,12 @@ let pageNumber = $state(1); let selectedUtility = $state<(typeof kinds)[number]['id']>('electricity'); 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 series = $derived(balanceSeries(data.accounts, data.transactions)); - let recent = $derived([...data.transactions].reverse().slice(0, 8)); + let series = $derived(balanceSeries(data.accounts, actualTransactions)); + let recent = $derived([...actualTransactions].reverse().slice(0, 8)); let filtered = $derived( (sortOrder === 'newest' ? [...data.transactions].reverse() : data.transactions).filter( (row) => @@ -75,7 +78,27 @@ data.recurring.reduce((sum, item) => sum + monthlyCents(item.amountCents, item.cadence), 0) ); 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 settings = $derived(data.utilitySettings.find((item) => item.kind === selectedUtility)); @@ -206,7 +229,7 @@

Across {data.accounts.length} accounts

{amount(total)}

-

Current combined balance

+

Current combined balance through {dateLabel(data.today)}

@@ -223,10 +246,55 @@

Account history

-

Balances calculated from opening balances and dated transactions.

+

+ End-of-day balances through today. Future dated transactions appear in the projection. +

- + + +
+
+
+

Expected wealth

+

From today through {dateLabel(projection[0].points.at(-1)?.date ?? data.today)}.

+
+ +
+ +

+ 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. +

+
+
+
+
+

Wish list total

+

Cumulative estimated cost by priority, from tier 5 down.

+
+ +
+
+ {#each wishTiers as row (row.tier)}
+ {row.tier === 5 ? 'Tier 5' : `Tiers 5–${row.tier}`}{amount(row.value)}{row.count} {row.count === 1 ? 'item' : 'items'} +
{/each} +
+ {#if unpricedWishes}

+ {unpricedWishes} unpriced {unpricedWishes === 1 ? 'item is' : 'items are'} excluded from these + totals. +

{/if}
@@ -348,23 +416,10 @@

Signed total of recurring income and costs

- 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.

- {#if data.forecasts.some((item) => item.date >= new Date().toISOString().slice(0, 10))} -
-
-
-

Projected total

-

- Current combined balance plus dated future estimates. Recurring items are not - automatically included. -

-
-
- -
- {/if}
@@ -379,17 +434,20 @@ {#each data.recurring as row (row.id)}{row.startsOn ? dateLabel(row.startsOn) : 'Now'} – {row.endsOn + ? dateLabel(row.endsOn) + : 'ongoing'}{#each data.wishlist as row (row.id)}
NameAccountFrequencyAmountActions
NameAccountFrequencyDatesAmountActions
{row.name}{row.accountId ? accountName(row.accountId) : 'Any'}{row.cadence}{amount(row.amountCents)}{amount(row.amountCents)}
ItemNotesEstimated costItemTierNotesEstimated costActions
{row.name}{row.notes ?? '—'}{row.name}{row.priority}{row.notes ?? '—'}{row.estimatedCents == null ? '—' : amount(row.estimatedCents)}