Files
website/src/routes/projects/money-tracker/+page.server.ts
Stan Runge 2466db74cd
All checks were successful
Test and deploy staging / staging (push) Successful in 1m44s
fix csv import for money tracker
2026-09-23 17:23:14 +00:00

410 lines
14 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';
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 };
}
};