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); }