import { db } from '$lib/server/db'; import { moneyAccounts, moneyTransactions, moneyRecurring, moneyForecasts, moneyUtilitySettings, moneyUtilityReadings, moneyWishlist, moneyInsurances } from '$lib/server/db/schema'; import { requireAdmin } from '$lib/server/authorization'; import { type ImportedTransaction, cadence, cents, invalid, optionalText, parseTransactionImport, positiveId, textField, utilityKind, validDate } from '$lib/server/money-tracker'; import { and, asc, desc, eq } 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 ] = await Promise.all([ db .select() .from(moneyAccounts) .where(eq(moneyAccounts.userId, userId)) .orderBy(asc(moneyAccounts.name)), db .select() .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)) ]); return { accounts, transactions, recurring, forecasts, utilitySettings, utilityReadings, wishlist, insurances }; }; 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')); if (!name || openingBalanceCents == null) return invalid('Enter an account name and a valid opening balance.'); const id = recordId(form); try { if (id) { const [row] = await db .update(moneyAccounts) .set({ name, openingBalanceCents }) .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 }); } 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')); if (!name || amountCents == null || !period) 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 }; 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 }; }, 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'); if ( !name || estimatedCents === undefined || notes === undefined || (estimatedCents !== null && estimatedCents < 0) ) return invalid('Enter a name and a valid estimated cost.'); const values = { name, estimatedCents, notes }; 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.'); let parsed: ImportedTransaction[]; try { parsed = parseTransactionImport(await file.text()); } catch (cause) { return invalid(cause instanceof Error ? cause.message : 'Invalid CSV.'); } await db.transaction(async (tx) => { const existing = await tx .select() .from(moneyAccounts) .where(eq(moneyAccounts.userId, user.id)); const ids = new Map(existing.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); } for (let i = 0; i < parsed.length; i += 500) { await tx.insert(moneyTransactions).values( parsed.slice(i, i + 500).map((row) => ({ userId: user.id, accountId: ids.get(row.account.toLowerCase())!, date: row.date, description: row.description, amountCents: row.amountCents })) ); } }); return { imported: parsed.length }; } };