Files
website/src/routes/projects/money-tracker/+page.server.ts

530 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
cadence,
cents,
invalid,
optionalText,
parseTransactionImport,
planImport,
positiveId,
textField,
utilityKind,
validDate
} from '#lib/server/money-tracker.js';
import { and, asc, desc, eq, gte, inArray } 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=/projects/money-tracker');
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 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, 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, 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 file = form.get('file');
if (!(file instanceof File) || !file.size || file.size > 2_000_000)
return invalid('Choose a CSV or TSV file 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.');
let parsed: ImportedTransaction[];
try {
parsed = parseTransactionImport(await file.text(), chosen?.name);
} catch (cause) {
return invalid(cause instanceof Error ? cause.message : 'Invalid CSV.');
}
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))];
const existing = await tx
.select({
id: moneyTransactions.id,
accountId: moneyTransactions.accountId,
date: moneyTransactions.date,
amountCents: moneyTransactions.amountCents,
externalId: moneyTransactions.externalId
})
.from(moneyTransactions)
.where(
and(
eq(moneyTransactions.userId, user.id),
inArray(moneyTransactions.accountId, accountIds),
gte(moneyTransactions.date, addDays(rows[0].date, -1))
)
);
const { insert, duplicates } = planImport(rows, existing);
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,
source: 'csv'
}))
)
.onConflictDoNothing();
}
return { imported: insert.length, duplicates };
});
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);
}