Money tracker: accounts can be left out of total wealth (include_in_total)
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:
2026-10-24 02:14:03 +00:00
parent 6f82d1350e
commit a022c45499
7 changed files with 13822 additions and 14 deletions

View File

@@ -280,4 +280,32 @@ describe('history range', () => {
]);
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);
});
});

View File

@@ -15,10 +15,24 @@ export function accountColor(account: { color?: string | null }, index: number):
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(
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 }[]
): ChartSeries[] {
const excluded = excludedIds(accounts);
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[]]));
@@ -36,7 +50,10 @@ export function balanceSeries(
accountPoints.get(account.id)!.push({ date, value: balances.get(account.id)! });
combinedPoints.push({
date,
value: [...balances.values()].reduce((sum, balance) => sum + balance, 0)
value: [...balances].reduce(
(sum, [id, balance]) => (excluded.has(id) ? sum : sum + balance),
0
)
});
}
return [
@@ -86,7 +103,12 @@ export function forecastSeries(
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 ProjectionRecurring = {
amountCents: number;
@@ -114,6 +136,8 @@ export function wealthProjection(
settings: ProjectionSettings | null,
today: string
): 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 investmentId = settings?.investmentAccountId ?? null;
const validInvestmentId =
@@ -121,6 +145,7 @@ export function wealthProjection(
let spendable = 0;
let invested = 0;
for (const account of accounts) {
if (excluded.has(account.id)) continue;
const value = balances.get(account.id) ?? 0;
if (account.kind === 'investment') invested += value;
else spendable += value;
@@ -129,7 +154,9 @@ export function wealthProjection(
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 movements = [...futureTransactions, ...forecasts].filter(
(entry) => entry.date > today && counted(entry.accountId)
);
const latestMovement = movements.reduce(
(latest, entry) => (entry.date > latest ? entry.date : latest),
''
@@ -162,6 +189,7 @@ export function wealthProjection(
}
if (settings?.includeRecurring !== false && index > 0) {
for (const item of recurring) {
if (!counted(item.accountId)) continue;
if ((item.startsOn && item.startsOn > date) || (item.endsOn && item.endsOn < first))
continue;
const value = Math.round(monthlyCents(item.amountCents, item.cadence));
@@ -215,6 +243,7 @@ export function wealthTimeline(
kind: string;
openingBalanceCents: number;
color?: string | null;
includeInTotal?: boolean;
}[],
actualTransactions: { accountId: number; date: string; amountCents: number }[],
projection: ChartSeries[],
@@ -229,7 +258,7 @@ export function wealthTimeline(
date,
value: accounts.reduce(
(sum, account, accountIndex) =>
(account.kind === 'investment') === investment
account.includeInTotal !== false && (account.kind === 'investment') === investment
? sum + accountLines[accountIndex].points[index].value
: sum,
0

View File

@@ -24,6 +24,9 @@ export const moneyAccounts = moneySchema.table(
name: text('name').notNull(),
openingBalanceCents: integer('opening_balance_cents').notNull().default(0),
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'),
// Household scope: Stan's own money vs. the joint account with Juhee.
scope: text('scope').notNull().default('personal'),