update money tracker
All checks were successful
Test and deploy staging / staging (push) Successful in 30s
All checks were successful
Test and deploy staging / staging (push) Successful in 30s
This commit is contained in:
@@ -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'
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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',
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user