All checks were successful
Test and deploy staging / staging (push) Successful in 1m44s
410 lines
14 KiB
TypeScript
410 lines
14 KiB
TypeScript
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 };
|
||
}
|
||
};
|