Money tracker: accounts can be left out of total wealth (include_in_total)
All checks were successful
Deploy production / deploy (push) Successful in 2m32s
All checks were successful
Deploy production / deploy (push) Successful in 2m32s
Adds money.accounts.include_in_total (default true). Excluded accounts keep their own balance and line, but are left out of net worth, invested, this month, the projection and the total/All accounts lines.
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "money"."accounts" ADD COLUMN "include_in_total" boolean DEFAULT true NOT NULL;
|
||||||
13735
drizzle/20261024021157_money_include_in_total/snapshot.json
Normal file
13735
drizzle/20261024021157_money_include_in_total/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -280,4 +280,32 @@ describe('history range', () => {
|
|||||||
]);
|
]);
|
||||||
expect(clipSeries(series, null)).toBe(series);
|
expect(clipSeries(series, null)).toBe(series);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('leaves accounts with includeInTotal false out of the totals but keeps their own line', () => {
|
||||||
|
const accounts = [
|
||||||
|
{ id: 1, name: 'Bank', kind: 'spendable', openingBalanceCents: 1000 },
|
||||||
|
{ id: 2, name: 'Parents', kind: 'spendable', openingBalanceCents: 0, includeInTotal: false }
|
||||||
|
];
|
||||||
|
const actual = [
|
||||||
|
{ accountId: 2, date: '2019-03-01', amountCents: 1_000_000 },
|
||||||
|
{ accountId: 2, date: '2019-04-01', amountCents: -1_000_000 },
|
||||||
|
{ accountId: 1, date: '2019-04-01', amountCents: 500 }
|
||||||
|
];
|
||||||
|
const series = balanceSeries(accounts, actual);
|
||||||
|
expect(series[1].points.map((point) => point.value)).toEqual([1_000_000, 0]);
|
||||||
|
expect(series.at(-1)!.points.map((point) => point.value)).toEqual([1000, 1500]);
|
||||||
|
const balances = accountBalances(accounts, actual);
|
||||||
|
const future = [
|
||||||
|
{ accountId: 2, date: '2026-10-01', amountCents: 9999 },
|
||||||
|
{ accountId: 1, date: '2026-10-01', amountCents: 100 }
|
||||||
|
];
|
||||||
|
const projection = wealthProjection(accounts, balances, future, [], [], null, '2026-09-23');
|
||||||
|
expect(projection[0].points[0].value).toBe(1500);
|
||||||
|
expect(projection[0].points.at(-1)!.value).toBe(1600);
|
||||||
|
const [, spendable] = wealthTimeline(accounts, actual, projection, '2026-09-23', 'split');
|
||||||
|
expect(spendable.points.slice(0, 2).map((point) => point.value)).toEqual([1000, 1500]);
|
||||||
|
const byAccount = wealthTimeline(accounts, actual, projection, '2026-09-23', 'accounts');
|
||||||
|
expect(byAccount.map((line) => line.label)).toEqual(['Bank', 'Parents', 'Total']);
|
||||||
|
expect(byAccount.at(-1)!.points[0].value).toBe(1000);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,10 +15,24 @@ export function accountColor(account: { color?: string | null }, index: number):
|
|||||||
return account.color || palette[index % palette.length];
|
return account.color || palette[index % palette.length];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Accounts kept out of net worth and total wealth (`includeInTotal: false`). */
|
||||||
|
export function excludedIds(accounts: { id: number; includeInTotal?: boolean }[]): Set<number> {
|
||||||
|
return new Set(
|
||||||
|
accounts.filter((account) => account.includeInTotal === false).map((account) => account.id)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function balanceSeries(
|
export function balanceSeries(
|
||||||
accounts: { id: number; name: string; openingBalanceCents: number; color?: string | null }[],
|
accounts: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
openingBalanceCents: number;
|
||||||
|
color?: string | null;
|
||||||
|
includeInTotal?: boolean;
|
||||||
|
}[],
|
||||||
transactions: { accountId: number; date: string; amountCents: number }[]
|
transactions: { accountId: number; date: string; amountCents: number }[]
|
||||||
): ChartSeries[] {
|
): ChartSeries[] {
|
||||||
|
const excluded = excludedIds(accounts);
|
||||||
const ordered = [...transactions].sort((a, b) => a.date.localeCompare(b.date));
|
const ordered = [...transactions].sort((a, b) => a.date.localeCompare(b.date));
|
||||||
const balances = new Map(accounts.map((account) => [account.id, account.openingBalanceCents]));
|
const balances = new Map(accounts.map((account) => [account.id, account.openingBalanceCents]));
|
||||||
const accountPoints = new Map(accounts.map((account) => [account.id, [] as ChartPoint[]]));
|
const accountPoints = new Map(accounts.map((account) => [account.id, [] as ChartPoint[]]));
|
||||||
@@ -36,7 +50,10 @@ export function balanceSeries(
|
|||||||
accountPoints.get(account.id)!.push({ date, value: balances.get(account.id)! });
|
accountPoints.get(account.id)!.push({ date, value: balances.get(account.id)! });
|
||||||
combinedPoints.push({
|
combinedPoints.push({
|
||||||
date,
|
date,
|
||||||
value: [...balances.values()].reduce((sum, balance) => sum + balance, 0)
|
value: [...balances].reduce(
|
||||||
|
(sum, [id, balance]) => (excluded.has(id) ? sum : sum + balance),
|
||||||
|
0
|
||||||
|
)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return [
|
return [
|
||||||
@@ -86,7 +103,12 @@ 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 ProjectionAccount = {
|
||||||
|
id: number;
|
||||||
|
kind: string;
|
||||||
|
openingBalanceCents: number;
|
||||||
|
includeInTotal?: boolean;
|
||||||
|
};
|
||||||
type ProjectionMovement = { date: string; amountCents: number; accountId: number | null };
|
type ProjectionMovement = { date: string; amountCents: number; accountId: number | null };
|
||||||
type ProjectionRecurring = {
|
type ProjectionRecurring = {
|
||||||
amountCents: number;
|
amountCents: number;
|
||||||
@@ -114,6 +136,8 @@ export function wealthProjection(
|
|||||||
settings: ProjectionSettings | null,
|
settings: ProjectionSettings | null,
|
||||||
today: string
|
today: string
|
||||||
): ChartSeries[] {
|
): ChartSeries[] {
|
||||||
|
const excluded = excludedIds(accounts);
|
||||||
|
const counted = (accountId: number | null) => accountId === null || !excluded.has(accountId);
|
||||||
const accountKind = new Map(accounts.map((account) => [account.id, account.kind]));
|
const accountKind = new Map(accounts.map((account) => [account.id, account.kind]));
|
||||||
const investmentId = settings?.investmentAccountId ?? null;
|
const investmentId = settings?.investmentAccountId ?? null;
|
||||||
const validInvestmentId =
|
const validInvestmentId =
|
||||||
@@ -121,6 +145,7 @@ export function wealthProjection(
|
|||||||
let spendable = 0;
|
let spendable = 0;
|
||||||
let invested = 0;
|
let invested = 0;
|
||||||
for (const account of accounts) {
|
for (const account of accounts) {
|
||||||
|
if (excluded.has(account.id)) continue;
|
||||||
const value = balances.get(account.id) ?? 0;
|
const value = balances.get(account.id) ?? 0;
|
||||||
if (account.kind === 'investment') invested += value;
|
if (account.kind === 'investment') invested += value;
|
||||||
else spendable += value;
|
else spendable += value;
|
||||||
@@ -129,7 +154,9 @@ export function wealthProjection(
|
|||||||
const totalPoints: ChartPoint[] = [{ date: today, value: spendable + invested }];
|
const totalPoints: ChartPoint[] = [{ date: today, value: spendable + invested }];
|
||||||
const spendablePoints: ChartPoint[] = [{ date: today, value: spendable }];
|
const spendablePoints: ChartPoint[] = [{ date: today, value: spendable }];
|
||||||
const investedPoints: ChartPoint[] = [{ date: today, value: invested }];
|
const investedPoints: ChartPoint[] = [{ date: today, value: invested }];
|
||||||
const movements = [...futureTransactions, ...forecasts].filter((entry) => entry.date > today);
|
const movements = [...futureTransactions, ...forecasts].filter(
|
||||||
|
(entry) => entry.date > today && counted(entry.accountId)
|
||||||
|
);
|
||||||
const latestMovement = movements.reduce(
|
const latestMovement = movements.reduce(
|
||||||
(latest, entry) => (entry.date > latest ? entry.date : latest),
|
(latest, entry) => (entry.date > latest ? entry.date : latest),
|
||||||
''
|
''
|
||||||
@@ -162,6 +189,7 @@ export function wealthProjection(
|
|||||||
}
|
}
|
||||||
if (settings?.includeRecurring !== false && index > 0) {
|
if (settings?.includeRecurring !== false && index > 0) {
|
||||||
for (const item of recurring) {
|
for (const item of recurring) {
|
||||||
|
if (!counted(item.accountId)) continue;
|
||||||
if ((item.startsOn && item.startsOn > date) || (item.endsOn && item.endsOn < first))
|
if ((item.startsOn && item.startsOn > date) || (item.endsOn && item.endsOn < first))
|
||||||
continue;
|
continue;
|
||||||
const value = Math.round(monthlyCents(item.amountCents, item.cadence));
|
const value = Math.round(monthlyCents(item.amountCents, item.cadence));
|
||||||
@@ -215,6 +243,7 @@ export function wealthTimeline(
|
|||||||
kind: string;
|
kind: string;
|
||||||
openingBalanceCents: number;
|
openingBalanceCents: number;
|
||||||
color?: string | null;
|
color?: string | null;
|
||||||
|
includeInTotal?: boolean;
|
||||||
}[],
|
}[],
|
||||||
actualTransactions: { accountId: number; date: string; amountCents: number }[],
|
actualTransactions: { accountId: number; date: string; amountCents: number }[],
|
||||||
projection: ChartSeries[],
|
projection: ChartSeries[],
|
||||||
@@ -229,7 +258,7 @@ export function wealthTimeline(
|
|||||||
date,
|
date,
|
||||||
value: accounts.reduce(
|
value: accounts.reduce(
|
||||||
(sum, account, accountIndex) =>
|
(sum, account, accountIndex) =>
|
||||||
(account.kind === 'investment') === investment
|
account.includeInTotal !== false && (account.kind === 'investment') === investment
|
||||||
? sum + accountLines[accountIndex].points[index].value
|
? sum + accountLines[accountIndex].points[index].value
|
||||||
: sum,
|
: sum,
|
||||||
0
|
0
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ export const moneyAccounts = moneySchema.table(
|
|||||||
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'),
|
kind: text('kind').notNull().default('spendable'),
|
||||||
|
// False for money tracked but not owned (e.g. a savings account parents controlled):
|
||||||
|
// still listed with its own balance, left out of net worth and the total wealth chart.
|
||||||
|
includeInTotal: boolean('include_in_total').notNull().default(true),
|
||||||
color: text('color'),
|
color: text('color'),
|
||||||
// Household scope: Stan's own money vs. the joint account with Juhee.
|
// Household scope: Stan's own money vs. the joint account with Juhee.
|
||||||
scope: text('scope').notNull().default('personal'),
|
scope: text('scope').notNull().default('personal'),
|
||||||
|
|||||||
@@ -127,6 +127,7 @@ export const actions: Actions = {
|
|||||||
const name = textField(form, 'name', 80);
|
const name = textField(form, 'name', 80);
|
||||||
const openingBalanceCents = cents(form.get('openingBalance'));
|
const openingBalanceCents = cents(form.get('openingBalance'));
|
||||||
const kind = String(form.get('kind') ?? 'spendable');
|
const kind = String(form.get('kind') ?? 'spendable');
|
||||||
|
const includeInTotal = form.get('includeInTotal') === 'on';
|
||||||
const colorRaw = String(form.get('color') ?? '').trim();
|
const colorRaw = String(form.get('color') ?? '').trim();
|
||||||
const color = colorRaw ? colorRaw.toLowerCase() : null;
|
const color = colorRaw ? colorRaw.toLowerCase() : null;
|
||||||
if (!name || openingBalanceCents == null || !['spendable', 'investment'].includes(kind))
|
if (!name || openingBalanceCents == null || !['spendable', 'investment'].includes(kind))
|
||||||
@@ -137,14 +138,14 @@ export const actions: Actions = {
|
|||||||
if (id) {
|
if (id) {
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
.update(moneyAccounts)
|
.update(moneyAccounts)
|
||||||
.set({ name, openingBalanceCents, kind, color })
|
.set({ name, openingBalanceCents, kind, includeInTotal, color })
|
||||||
.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
|
} else
|
||||||
await db
|
await db
|
||||||
.insert(moneyAccounts)
|
.insert(moneyAccounts)
|
||||||
.values({ userId: user.id, name, openingBalanceCents, kind, color });
|
.values({ userId: user.id, name, openingBalanceCents, kind, includeInTotal, color });
|
||||||
} 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.');
|
||||||
|
|||||||
@@ -79,9 +79,13 @@
|
|||||||
let actualTransactions = $derived(data.transactions.filter((item) => item.date <= data.today));
|
let actualTransactions = $derived(data.transactions.filter((item) => item.date <= data.today));
|
||||||
let futureTransactions = $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 balances = $derived(accountBalances(data.accounts, actualTransactions));
|
||||||
let total = $derived([...balances.values()].reduce((sum, value) => sum + value, 0));
|
let totalAccounts = $derived(data.accounts.filter((account) => account.includeInTotal !== false));
|
||||||
|
let totalIds = $derived(new Set(totalAccounts.map((account) => account.id)));
|
||||||
|
let total = $derived(
|
||||||
|
totalAccounts.reduce((sum, account) => sum + (balances.get(account.id) ?? 0), 0)
|
||||||
|
);
|
||||||
let invested = $derived(
|
let invested = $derived(
|
||||||
data.accounts
|
totalAccounts
|
||||||
.filter((account) => account.kind === 'investment')
|
.filter((account) => account.kind === 'investment')
|
||||||
.reduce((sum, account) => sum + (balances.get(account.id) ?? 0), 0)
|
.reduce((sum, account) => sum + (balances.get(account.id) ?? 0), 0)
|
||||||
);
|
);
|
||||||
@@ -90,7 +94,9 @@
|
|||||||
);
|
);
|
||||||
let monthNet = $derived(
|
let monthNet = $derived(
|
||||||
actualTransactions
|
actualTransactions
|
||||||
.filter((item) => item.date.slice(0, 7) === data.today.slice(0, 7))
|
.filter(
|
||||||
|
(item) => item.date.slice(0, 7) === data.today.slice(0, 7) && totalIds.has(item.accountId)
|
||||||
|
)
|
||||||
.reduce((sum, item) => sum + item.amountCents, 0)
|
.reduce((sum, item) => sum + item.amountCents, 0)
|
||||||
);
|
);
|
||||||
let monthlyInsurance = $derived(
|
let monthlyInsurance = $derived(
|
||||||
@@ -270,7 +276,7 @@
|
|||||||
|
|
||||||
<section class="stat-strip" aria-label="Summary">
|
<section class="stat-strip" aria-label="Summary">
|
||||||
<div class="stat net">
|
<div class="stat net">
|
||||||
<p class="eyebrow">Net worth · {data.accounts.length} accounts</p>
|
<p class="eyebrow">Net worth · {totalAccounts.length} accounts</p>
|
||||||
<p class="stat-value">{@render signed(total)}</p>
|
<p class="stat-value">{@render signed(total)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
@@ -388,9 +394,10 @@
|
|||||||
onclick={() => toggleAccount(account.id)}
|
onclick={() => toggleAccount(account.id)}
|
||||||
>{@render swatch(account.id)}<span
|
>{@render swatch(account.id)}<span
|
||||||
><strong>{account.name}</strong><small class="capitalize"
|
><strong>{account.name}</strong><small class="capitalize"
|
||||||
>{account.kind} · {#if account.bankBalanceAt}bank {amount(
|
>{account.kind}{#if account.includeInTotal === false} · not in total{/if} · {#if account.bankBalanceAt}bank
|
||||||
account.bankBalanceCents ?? 0
|
{amount(account.bankBalanceCents ?? 0)}, {new Date(
|
||||||
)}, {new Date(account.bankBalanceAt).toLocaleDateString('en-GB', {
|
account.bankBalanceAt
|
||||||
|
).toLocaleDateString('en-GB', {
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
@@ -903,6 +910,10 @@
|
|||||||
>Investment accounts are included in total wealth but excluded from the cash reserve.</small
|
>Investment accounts are included in total wealth but excluded from the cash reserve.</small
|
||||||
></label
|
></label
|
||||||
>
|
>
|
||||||
|
<label class="field check-field"
|
||||||
|
><Checkbox name="includeInTotal" value="on" checked={active.includeInTotal !== false} /> Count
|
||||||
|
toward total wealth</label
|
||||||
|
>
|
||||||
<label class="field"
|
<label class="field"
|
||||||
>Color<Input
|
>Color<Input
|
||||||
name="color"
|
name="color"
|
||||||
|
|||||||
Reference in New Issue
Block a user