Money: move /projects/money-tracker to /trackers/money, rename to Money, 308 redirects from old paths (bank-callback query kept)
All checks were successful
Deploy production / deploy (push) Successful in 2m49s

This commit is contained in:
2026-10-24 02:37:07 +00:00
parent 2af279e8a9
commit 988c6c31a9
17 changed files with 67 additions and 29 deletions

View File

@@ -0,0 +1,627 @@
import { db } from '#lib/server/db/index.js';
import {
moneyAccounts,
moneyTransactions,
moneyRecurring,
moneyForecasts,
moneyUtilitySettings,
moneyUtilityReadings,
moneyWishlist,
moneyInsurances,
moneyProjectionSettings
} from '#lib/server/db/schema/index.js';
import { requireAdmin } from '#lib/server/authorization.js';
import {
type ImportedTransaction,
accountNumberKey,
cadence,
dropInternalTransfers,
cents,
invalid,
optionalText,
parseTransactionImport,
planImport,
positiveId,
textField,
utilityKind,
validDate
} from '#lib/server/money-tracker.js';
import { and, asc, desc, eq, gte, inArray, isNull, like, sql } from 'drizzle-orm';
import { error, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async (event) => {
if (!event.locals.user) redirect(303, '/login?returnTo=/trackers/money');
const user = requireAdmin(event);
const userId = user.id;
const [
accounts,
transactions,
recurring,
forecasts,
utilitySettings,
utilityReadings,
wishlist,
insurances,
projectionSettings
] = await Promise.all([
db
.select()
.from(moneyAccounts)
.where(eq(moneyAccounts.userId, userId))
.orderBy(asc(moneyAccounts.name)),
db
.select({
id: moneyTransactions.id,
accountId: moneyTransactions.accountId,
date: moneyTransactions.date,
description: moneyTransactions.description,
amountCents: moneyTransactions.amountCents,
source: moneyTransactions.source
})
.from(moneyTransactions)
.where(eq(moneyTransactions.userId, userId))
.orderBy(asc(moneyTransactions.date), asc(moneyTransactions.id)),
db
.select()
.from(moneyRecurring)
.where(eq(moneyRecurring.userId, userId))
.orderBy(asc(moneyRecurring.name)),
db
.select()
.from(moneyForecasts)
.where(eq(moneyForecasts.userId, userId))
.orderBy(asc(moneyForecasts.date)),
db.select().from(moneyUtilitySettings).where(eq(moneyUtilitySettings.userId, userId)),
db
.select()
.from(moneyUtilityReadings)
.where(eq(moneyUtilityReadings.userId, userId))
.orderBy(asc(moneyUtilityReadings.date)),
db
.select()
.from(moneyWishlist)
.where(eq(moneyWishlist.userId, userId))
.orderBy(desc(moneyWishlist.createdAt)),
db
.select()
.from(moneyInsurances)
.where(eq(moneyInsurances.userId, userId))
.orderBy(asc(moneyInsurances.name)),
db.select().from(moneyProjectionSettings).where(eq(moneyProjectionSettings.userId, userId))
]);
return {
accounts,
transactions,
recurring,
forecasts,
utilitySettings,
utilityReadings,
wishlist,
insurances,
projectionSettings: projectionSettings[0] ?? null,
today: new Date().toISOString().slice(0, 10)
};
};
async function ownedAccount(userId: string, value: FormDataEntryValue | null, optional = false) {
if (optional && !value) return null;
const id = positiveId(value);
if (!id) error(400, 'Choose an account.');
const [account] = await db
.select({ id: moneyAccounts.id })
.from(moneyAccounts)
.where(and(eq(moneyAccounts.id, id), eq(moneyAccounts.userId, userId)));
if (!account) error(400, 'Choose an account you own.');
return account.id;
}
function recordId(form: FormData) {
return positiveId(form.get('id'));
}
export const actions: Actions = {
saveAccount: async (event) => {
const user = requireAdmin(event);
const form = await event.request.formData();
const name = textField(form, 'name', 80);
const openingBalanceCents = cents(form.get('openingBalance'));
const kind = String(form.get('kind') ?? 'spendable');
const includeInTotal = form.get('includeInTotal') === 'on';
const colorRaw = String(form.get('color') ?? '').trim();
const color = colorRaw ? colorRaw.toLowerCase() : null;
if (!name || openingBalanceCents == null || !['spendable', 'investment'].includes(kind))
return invalid('Enter an account name and a valid opening balance.');
if (color && !/^#[0-9a-f]{6}$/.test(color)) return invalid('Choose a valid account color.');
const id = recordId(form);
try {
if (id) {
const [row] = await db
.update(moneyAccounts)
.set({ name, openingBalanceCents, kind, includeInTotal, color })
.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, kind, includeInTotal, color });
} catch (cause) {
if (typeof cause === 'object' && cause && 'code' in cause && cause.code === '23505')
return invalid('An account with that name already exists.');
throw cause;
}
return { saved: true };
},
deleteAccount: async (event) => {
const user = requireAdmin(event);
const id = recordId(await event.request.formData());
if (!id) return invalid('Invalid account.');
try {
await db
.delete(moneyAccounts)
.where(and(eq(moneyAccounts.id, id), eq(moneyAccounts.userId, user.id)));
} catch (cause) {
if (typeof cause === 'object' && cause && 'code' in cause && cause.code === '23503')
return invalid('Remove or move this account’s transactions before deleting it.');
throw cause;
}
return { saved: true };
},
saveTransaction: async (event) => {
const user = requireAdmin(event);
const form = await event.request.formData();
const date = validDate(form.get('date'));
const description = textField(form, 'description', 500);
const amountCents = cents(form.get('amount'));
if (!date || !description || amountCents == null)
return invalid('Enter a date, description and valid signed amount.');
const accountId = await ownedAccount(user.id, form.get('accountId'));
if (!accountId) return invalid('Choose an account.');
const values = { date, description, amountCents, accountId };
const id = recordId(form);
if (id) {
const [row] = await db
.update(moneyTransactions)
.set(values)
.where(and(eq(moneyTransactions.id, id), eq(moneyTransactions.userId, user.id)))
.returning({ id: moneyTransactions.id });
if (!row) error(404, 'Transaction not found');
} else await db.insert(moneyTransactions).values({ ...values, userId: user.id });
return { saved: true };
},
deleteTransaction: async (event) => {
const user = requireAdmin(event);
const id = recordId(await event.request.formData());
if (!id) return invalid('Invalid transaction.');
await db
.delete(moneyTransactions)
.where(and(eq(moneyTransactions.id, id), eq(moneyTransactions.userId, user.id)));
return { saved: true };
},
saveRecurring: async (event) => {
const user = requireAdmin(event);
const form = await event.request.formData();
const name = textField(form, 'name', 200);
const amountCents = cents(form.get('amount'));
const period = cadence(form.get('cadence'));
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, startsOn, endsOn };
const id = recordId(form);
if (id) {
const [row] = await db
.update(moneyRecurring)
.set(values)
.where(and(eq(moneyRecurring.id, id), eq(moneyRecurring.userId, user.id)))
.returning({ id: moneyRecurring.id });
if (!row) error(404, 'Recurring item not found');
} else await db.insert(moneyRecurring).values({ ...values, userId: user.id });
return { saved: true };
},
deleteRecurring: async (event) => {
const user = requireAdmin(event);
const id = recordId(await event.request.formData());
if (!id) return invalid('Invalid recurring item.');
await db
.delete(moneyRecurring)
.where(and(eq(moneyRecurring.id, id), eq(moneyRecurring.userId, user.id)));
return { saved: true };
},
saveForecast: async (event) => {
const user = requireAdmin(event);
const form = await event.request.formData();
const date = validDate(form.get('date'));
const description = textField(form, 'description', 500);
const amountCents = cents(form.get('amount'));
if (!date || !description || amountCents == null)
return invalid('Enter a date, description and valid signed amount.');
const accountId = await ownedAccount(user.id, form.get('accountId'), true);
const values = { date, description, amountCents, accountId };
const id = recordId(form);
if (id) {
const [row] = await db
.update(moneyForecasts)
.set(values)
.where(and(eq(moneyForecasts.id, id), eq(moneyForecasts.userId, user.id)))
.returning({ id: moneyForecasts.id });
if (!row) error(404, 'Forecast not found');
} else await db.insert(moneyForecasts).values({ ...values, userId: user.id });
return { saved: true };
},
deleteForecast: async (event) => {
const user = requireAdmin(event);
const id = recordId(await event.request.formData());
if (!id) return invalid('Invalid forecast.');
await db
.delete(moneyForecasts)
.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',
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();
const kind = utilityKind(form.get('kind'));
const rate = String(form.get('ratePerUnit') ?? '')
.trim()
.replace(',', '.');
const monthlyAdvanceCents = cents(form.get('monthlyAdvance'));
const billingStartRaw = String(form.get('billingStart') ?? '').trim();
const billingStart = billingStartRaw ? validDate(billingStartRaw) : null;
if (
!kind ||
!/^\d{1,7}(?:\.\d{1,4})?$/.test(rate) ||
monthlyAdvanceCents == null ||
monthlyAdvanceCents < 0 ||
(billingStartRaw && !billingStart)
)
return invalid('Enter a non-negative unit rate and monthly advance.');
await db
.insert(moneyUtilitySettings)
.values({ userId: user.id, kind, ratePerUnit: rate, monthlyAdvanceCents, billingStart })
.onConflictDoUpdate({
target: [moneyUtilitySettings.userId, moneyUtilitySettings.kind],
set: { ratePerUnit: rate, monthlyAdvanceCents, billingStart }
});
return { saved: true };
},
saveReading: async (event) => {
const user = requireAdmin(event);
const form = await event.request.formData();
const kind = utilityKind(form.get('kind'));
const date = validDate(form.get('date'));
const reading = String(form.get('reading') ?? '')
.trim()
.replace(',', '.');
if (!kind || !date || !/^\d{1,9}(?:\.\d{1,3})?$/.test(reading))
return invalid('Enter a valid meter reading and date.');
const id = recordId(form);
const values = { kind, date, reading };
if (id) {
const [row] = await db
.update(moneyUtilityReadings)
.set(values)
.where(and(eq(moneyUtilityReadings.id, id), eq(moneyUtilityReadings.userId, user.id)))
.returning({ id: moneyUtilityReadings.id });
if (!row) error(404, 'Reading not found');
} else await db.insert(moneyUtilityReadings).values({ ...values, userId: user.id });
return { saved: true };
},
deleteReading: async (event) => {
const user = requireAdmin(event);
const id = recordId(await event.request.formData());
if (!id) return invalid('Invalid reading.');
await db
.delete(moneyUtilityReadings)
.where(and(eq(moneyUtilityReadings.id, id), eq(moneyUtilityReadings.userId, user.id)));
return { saved: true };
},
saveWishlist: async (event) => {
const user = requireAdmin(event);
const form = await event.request.formData();
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) ||
!Number.isInteger(priority) ||
priority < 1 ||
priority > 5
)
return invalid('Enter a name and a valid estimated cost.');
const values = { name, estimatedCents, notes, priority };
const id = recordId(form);
if (id) {
const [row] = await db
.update(moneyWishlist)
.set(values)
.where(and(eq(moneyWishlist.id, id), eq(moneyWishlist.userId, user.id)))
.returning({ id: moneyWishlist.id });
if (!row) error(404, 'Wish list item not found');
} else await db.insert(moneyWishlist).values({ ...values, userId: user.id });
return { saved: true };
},
deleteWishlist: async (event) => {
const user = requireAdmin(event);
const id = recordId(await event.request.formData());
if (!id) return invalid('Invalid wish list item.');
await db
.delete(moneyWishlist)
.where(and(eq(moneyWishlist.id, id), eq(moneyWishlist.userId, user.id)));
return { saved: true };
},
saveInsurance: async (event) => {
const user = requireAdmin(event);
const form = await event.request.formData();
const name = textField(form, 'name', 200);
const provider = optionalText(form, 'provider', 200);
const coverage = optionalText(form, 'coverage');
const premiumCents = cents(form.get('premium'), true);
const period = cadence(form.get('cadence'));
if (
!name ||
provider === undefined ||
coverage === undefined ||
premiumCents === undefined ||
(premiumCents !== null && premiumCents < 0) ||
!period
)
return invalid('Enter an insurance name and valid premium.');
const values = { name, provider, coverage, premiumCents, cadence: period };
const id = recordId(form);
if (id) {
const [row] = await db
.update(moneyInsurances)
.set(values)
.where(and(eq(moneyInsurances.id, id), eq(moneyInsurances.userId, user.id)))
.returning({ id: moneyInsurances.id });
if (!row) error(404, 'Insurance not found');
} else await db.insert(moneyInsurances).values({ ...values, userId: user.id });
return { saved: true };
},
deleteInsurance: async (event) => {
const user = requireAdmin(event);
const id = recordId(await event.request.formData());
if (!id) return invalid('Invalid insurance.');
await db
.delete(moneyInsurances)
.where(and(eq(moneyInsurances.id, id), eq(moneyInsurances.userId, user.id)));
return { saved: true };
},
importTransactions: async (event) => {
const user = requireAdmin(event);
const form = await event.request.formData();
const files = form
.getAll('file')
.filter((file): file is File => file instanceof File && file.size > 0);
if (!files.length || files.length > 10 || files.some((file) => file.size > 2_000_000))
return invalid('Choose up to 10 CSV or TSV files, each under 2 MB.');
const chosenId = positiveId(form.get('accountId'));
const accounts = await db.select().from(moneyAccounts).where(eq(moneyAccounts.userId, user.id));
const chosen = chosenId ? accounts.find((account) => account.id === chosenId) : undefined;
if (chosenId && !chosen) return invalid('Choose an account.');
const parsed: ImportedTransaction[] = [];
try {
for (const file of files)
parsed.push(...parseTransactionImport(await file.text(), chosen?.name));
} catch (cause) {
return invalid(cause instanceof Error ? cause.message : 'Invalid CSV.');
}
parsed.sort((a, b) => a.date.localeCompare(b.date));
// Bank statements that name their account go to the tracker account with that IBAN.
const byNumber = new Map(
accounts
.filter((account) => account.iban)
.map((account) => [accountNumberKey(account.iban), account])
);
if (!chosen)
for (const row of parsed) {
const match = row.sourceAccount && byNumber.get(accountNumberKey(row.sourceAccount));
if (match) row.account = match.name;
}
const result = await db.transaction(async (tx) => {
const ids = new Map(accounts.map((account) => [account.name.toLowerCase(), account.id]));
for (const name of [...new Set(parsed.map((row) => row.account))]) {
if (ids.has(name.toLowerCase())) continue;
const [account] = await tx
.insert(moneyAccounts)
.values({ userId: user.id, name })
.returning({ id: moneyAccounts.id });
ids.set(name.toLowerCase(), account.id);
}
const rows = parsed.map((row) => ({
...row,
accountId: ids.get(row.account.toLowerCase())!
}));
const accountIds = [...new Set(rows.map((row) => row.accountId))];
// Several bank accounts can feed one tracker account (Knab current + savings): transfers
// between them are internal and dropped on both sides. Interest and the rest stay.
const statementRows = await tx
.select({
id: moneyTransactions.id,
accountId: moneyTransactions.accountId,
externalId: moneyTransactions.externalId,
raw: moneyTransactions.raw
})
.from(moneyTransactions)
.where(
and(
eq(moneyTransactions.userId, user.id),
inArray(moneyTransactions.accountId, accountIds),
like(moneyTransactions.externalId, 'knab:%')
)
);
const own = new Map<number, Set<string>>();
const addOwn = (accountId: number, number: string | null | undefined) => {
if (!number) return;
own.set(accountId, (own.get(accountId) ?? new Set()).add(accountNumberKey(number)));
};
for (const account of accounts)
if (accountIds.includes(account.id)) addOwn(account.id, account.iban);
for (const row of rows) addOwn(row.accountId, row.sourceAccount);
for (const row of statementRows) addOwn(row.accountId, row.externalId?.split(':')[1]);
const kept: typeof rows = [];
let internal = 0;
for (const accountId of accountIds) {
const split = dropInternalTransfers(
rows.filter((row) => row.accountId === accountId),
own.get(accountId) ?? []
);
kept.push(...split.kept);
internal += split.internal.length;
}
kept.sort((a, b) => a.date.localeCompare(b.date));
// Legs imported before the other account's export was known.
const stale = statementRows
.filter((row) => {
const other = accountNumberKey(
(row.raw as { counterpartyAccount?: string } | null)?.counterpartyAccount
);
return other && own.get(row.accountId)?.has(other);
})
.map((row) => row.id);
if (stale.length)
await tx.delete(moneyTransactions).where(inArray(moneyTransactions.id, stale));
internal += stale.length;
if (!kept.length) return { imported: 0, duplicates: 0, internal, replaced: 0 };
const existing = await tx
.select({
id: moneyTransactions.id,
accountId: moneyTransactions.accountId,
date: moneyTransactions.date,
amountCents: moneyTransactions.amountCents,
externalId: moneyTransactions.externalId,
description: moneyTransactions.description
})
.from(moneyTransactions)
.where(
and(
eq(moneyTransactions.userId, user.id),
inArray(moneyTransactions.accountId, accountIds),
gte(moneyTransactions.date, addDays(kept[0].date, -1))
)
);
const { insert, duplicates, attach, replace } = planImport(kept, existing);
// Hand-entered twins keep their description but get the import id, so re-imports skip them.
for (const { id, row } of attach)
await tx
.update(moneyTransactions)
.set({
externalId: row.externalId,
counterparty: sql`coalesce(${moneyTransactions.counterparty}, ${row.counterparty ?? null})`
})
.where(and(eq(moneyTransactions.id, id), isNull(moneyTransactions.externalId)));
// Hand entries that were the sum of several imported payments are replaced by them.
if (replace.length)
await tx.delete(moneyTransactions).where(
and(
inArray(
moneyTransactions.id,
replace.map((item) => item.id)
),
isNull(moneyTransactions.externalId)
)
);
for (let i = 0; i < insert.length; i += 500) {
await tx
.insert(moneyTransactions)
.values(
insert.slice(i, i + 500).map((row) => ({
userId: user.id,
accountId: row.accountId,
date: row.date,
description: row.description,
amountCents: row.amountCents,
externalId: row.externalId,
counterparty: row.counterparty ?? null,
raw: row.sourceAccount
? {
account: row.sourceAccount,
counterpartyAccount: row.counterpartyAccount ?? null
}
: null,
source: 'csv'
}))
)
.onConflictDoNothing();
}
return { imported: insert.length, duplicates, internal, replaced: replace.length };
});
return result;
}
};
function addDays(date: string, days: number) {
return new Date(Date.parse(date + 'T00:00:00Z') + days * 86_400_000).toISOString().slice(0, 10);
}