Money: move /projects/money-tracker to /trackers/money, rename to Money, 308 redirects from old paths (bank-callback query kept)
All checks were successful
Deploy production / deploy (push) Successful in 2m49s
All checks were successful
Deploy production / deploy (push) Successful in 2m49s
This commit is contained in:
@@ -1,627 +0,0 @@
|
||||
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,
|
||||
accountNumberKey,
|
||||
cadence,
|
||||
dropInternalTransfers,
|
||||
cents,
|
||||
invalid,
|
||||
optionalText,
|
||||
parseTransactionImport,
|
||||
planImport,
|
||||
positiveId,
|
||||
textField,
|
||||
utilityKind,
|
||||
validDate
|
||||
} from '#lib/server/money-tracker.js';
|
||||
import { and, asc, desc, eq, gte, inArray, isNull, like, sql } 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 includeInTotal = form.get('includeInTotal') === 'on';
|
||||
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, includeInTotal, 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, includeInTotal, 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 files = form
|
||||
.getAll('file')
|
||||
.filter((file): file is File => file instanceof File && file.size > 0);
|
||||
if (!files.length || files.length > 10 || files.some((file) => file.size > 2_000_000))
|
||||
return invalid('Choose up to 10 CSV or TSV files, each 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.');
|
||||
const parsed: ImportedTransaction[] = [];
|
||||
try {
|
||||
for (const file of files)
|
||||
parsed.push(...parseTransactionImport(await file.text(), chosen?.name));
|
||||
} catch (cause) {
|
||||
return invalid(cause instanceof Error ? cause.message : 'Invalid CSV.');
|
||||
}
|
||||
parsed.sort((a, b) => a.date.localeCompare(b.date));
|
||||
// Bank statements that name their account go to the tracker account with that IBAN.
|
||||
const byNumber = new Map(
|
||||
accounts
|
||||
.filter((account) => account.iban)
|
||||
.map((account) => [accountNumberKey(account.iban), account])
|
||||
);
|
||||
if (!chosen)
|
||||
for (const row of parsed) {
|
||||
const match = row.sourceAccount && byNumber.get(accountNumberKey(row.sourceAccount));
|
||||
if (match) row.account = match.name;
|
||||
}
|
||||
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))];
|
||||
|
||||
// Several bank accounts can feed one tracker account (Knab current + savings): transfers
|
||||
// between them are internal and dropped on both sides. Interest and the rest stay.
|
||||
const statementRows = await tx
|
||||
.select({
|
||||
id: moneyTransactions.id,
|
||||
accountId: moneyTransactions.accountId,
|
||||
externalId: moneyTransactions.externalId,
|
||||
raw: moneyTransactions.raw
|
||||
})
|
||||
.from(moneyTransactions)
|
||||
.where(
|
||||
and(
|
||||
eq(moneyTransactions.userId, user.id),
|
||||
inArray(moneyTransactions.accountId, accountIds),
|
||||
like(moneyTransactions.externalId, 'knab:%')
|
||||
)
|
||||
);
|
||||
const own = new Map<number, Set<string>>();
|
||||
const addOwn = (accountId: number, number: string | null | undefined) => {
|
||||
if (!number) return;
|
||||
own.set(accountId, (own.get(accountId) ?? new Set()).add(accountNumberKey(number)));
|
||||
};
|
||||
for (const account of accounts)
|
||||
if (accountIds.includes(account.id)) addOwn(account.id, account.iban);
|
||||
for (const row of rows) addOwn(row.accountId, row.sourceAccount);
|
||||
for (const row of statementRows) addOwn(row.accountId, row.externalId?.split(':')[1]);
|
||||
const kept: typeof rows = [];
|
||||
let internal = 0;
|
||||
for (const accountId of accountIds) {
|
||||
const split = dropInternalTransfers(
|
||||
rows.filter((row) => row.accountId === accountId),
|
||||
own.get(accountId) ?? []
|
||||
);
|
||||
kept.push(...split.kept);
|
||||
internal += split.internal.length;
|
||||
}
|
||||
kept.sort((a, b) => a.date.localeCompare(b.date));
|
||||
// Legs imported before the other account's export was known.
|
||||
const stale = statementRows
|
||||
.filter((row) => {
|
||||
const other = accountNumberKey(
|
||||
(row.raw as { counterpartyAccount?: string } | null)?.counterpartyAccount
|
||||
);
|
||||
return other && own.get(row.accountId)?.has(other);
|
||||
})
|
||||
.map((row) => row.id);
|
||||
if (stale.length)
|
||||
await tx.delete(moneyTransactions).where(inArray(moneyTransactions.id, stale));
|
||||
internal += stale.length;
|
||||
if (!kept.length) return { imported: 0, duplicates: 0, internal, replaced: 0 };
|
||||
|
||||
const existing = await tx
|
||||
.select({
|
||||
id: moneyTransactions.id,
|
||||
accountId: moneyTransactions.accountId,
|
||||
date: moneyTransactions.date,
|
||||
amountCents: moneyTransactions.amountCents,
|
||||
externalId: moneyTransactions.externalId,
|
||||
description: moneyTransactions.description
|
||||
})
|
||||
.from(moneyTransactions)
|
||||
.where(
|
||||
and(
|
||||
eq(moneyTransactions.userId, user.id),
|
||||
inArray(moneyTransactions.accountId, accountIds),
|
||||
gte(moneyTransactions.date, addDays(kept[0].date, -1))
|
||||
)
|
||||
);
|
||||
const { insert, duplicates, attach, replace } = planImport(kept, existing);
|
||||
// Hand-entered twins keep their description but get the import id, so re-imports skip them.
|
||||
for (const { id, row } of attach)
|
||||
await tx
|
||||
.update(moneyTransactions)
|
||||
.set({
|
||||
externalId: row.externalId,
|
||||
counterparty: sql`coalesce(${moneyTransactions.counterparty}, ${row.counterparty ?? null})`
|
||||
})
|
||||
.where(and(eq(moneyTransactions.id, id), isNull(moneyTransactions.externalId)));
|
||||
// Hand entries that were the sum of several imported payments are replaced by them.
|
||||
if (replace.length)
|
||||
await tx.delete(moneyTransactions).where(
|
||||
and(
|
||||
inArray(
|
||||
moneyTransactions.id,
|
||||
replace.map((item) => item.id)
|
||||
),
|
||||
isNull(moneyTransactions.externalId)
|
||||
)
|
||||
);
|
||||
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,
|
||||
counterparty: row.counterparty ?? null,
|
||||
raw: row.sourceAccount
|
||||
? {
|
||||
account: row.sourceAccount,
|
||||
counterpartyAccount: row.counterpartyAccount ?? null
|
||||
}
|
||||
: null,
|
||||
source: 'csv'
|
||||
}))
|
||||
)
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
return { imported: insert.length, duplicates, internal, replaced: replace.length };
|
||||
});
|
||||
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);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
10
src/routes/projects/money-tracker/[...rest]/+server.ts
Normal file
10
src/routes/projects/money-tracker/[...rest]/+server.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
// Money moved from /projects/money-tracker to /trackers/money. Old links, and the Enable Banking
|
||||
// redirect URL registered as /projects/money-tracker/bank-callback, keep working: 308 keeps the
|
||||
// method, the subpath and the query string (?range=, ?code=&state=).
|
||||
export const fallback: RequestHandler = ({ params, url }) => {
|
||||
redirect(308, `/trackers/money${params.rest ? `/${params.rest}` : ''}${url.search}`);
|
||||
};
|
||||
export const GET = fallback;
|
||||
30
src/routes/projects/money-tracker/[...rest]/redirect.test.ts
Normal file
30
src/routes/projects/money-tracker/[...rest]/redirect.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { isRedirect } from '@sveltejs/kit';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { fallback } from './+server';
|
||||
|
||||
function target(path: string, rest: string) {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
fallback({ params: { rest }, url: new URL(`https://stanrunge.dev${path}`) } as any);
|
||||
} catch (thrown) {
|
||||
if (isRedirect(thrown)) return [thrown.status, thrown.location];
|
||||
throw thrown;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
describe('old money tracker URLs', () => {
|
||||
it('redirects to /trackers/money, keeping subpath and query', () => {
|
||||
expect(target('/projects/money-tracker?range=3y', '')).toEqual([
|
||||
308,
|
||||
'/trackers/money?range=3y'
|
||||
]);
|
||||
expect(target('/projects/money-tracker/transactions?page=2', 'transactions')).toEqual([
|
||||
308,
|
||||
'/trackers/money/transactions?page=2'
|
||||
]);
|
||||
expect(
|
||||
target('/projects/money-tracker/bank-callback?state=s&code=abc-123', 'bank-callback')
|
||||
).toEqual([308, '/trackers/money/bank-callback?state=s&code=abc-123']);
|
||||
});
|
||||
});
|
||||
@@ -1,18 +0,0 @@
|
||||
import { requireAdmin } from '#lib/server/authorization.js';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
// Enable Banking redirects here after Revolut consent. The code is single-use and only
|
||||
// shown to the admin, who pastes it into scripts/bank-link.ts on the server.
|
||||
export const load: PageServerLoad = async (event) => {
|
||||
if (!event.locals.user)
|
||||
redirect(303, '/login?returnTo=' + encodeURIComponent(event.url.pathname + event.url.search));
|
||||
requireAdmin(event);
|
||||
const params = event.url.searchParams;
|
||||
return {
|
||||
code: params.get('code')?.slice(0, 500) ?? '',
|
||||
error: params.get('error')?.slice(0, 200) ?? '',
|
||||
errorDescription: params.get('error_description')?.slice(0, 500) ?? '',
|
||||
fullUrl: event.url.href
|
||||
};
|
||||
};
|
||||
@@ -1,53 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import { Button } from '#lib/components/ui/button/index.js';
|
||||
import * as Card from '#lib/components/ui/card/index.js';
|
||||
import * as Alert from '#lib/components/ui/alert/index.js';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
let copied = $state(false);
|
||||
async function copy() {
|
||||
await navigator.clipboard.writeText(data.fullUrl);
|
||||
copied = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head
|
||||
><title>Bank connection · Money tracker</title><meta
|
||||
name="robots"
|
||||
content="noindex"
|
||||
/></svelte:head
|
||||
>
|
||||
|
||||
<section class="mx-auto max-w-xl space-y-6 p-6">
|
||||
<h1 class="text-2xl font-bold">Bank connection</h1>
|
||||
{#if data.error}
|
||||
<Alert.Root variant="destructive"
|
||||
><Alert.Title>Revolut did not grant access</Alert.Title><Alert.Description
|
||||
>{data.error}{data.errorDescription ? `: ${data.errorDescription}` : ''}. Run
|
||||
<code>scripts/bank-link.ts</code> again to retry.</Alert.Description
|
||||
></Alert.Root
|
||||
>
|
||||
{:else if data.code}
|
||||
<Card.Root
|
||||
><Card.Header
|
||||
><Card.Title>Almost done</Card.Title><Card.Description
|
||||
>Copy this address and paste it into the waiting <code>bank-link</code> prompt. The code is
|
||||
single-use and expires within minutes.</Card.Description
|
||||
></Card.Header
|
||||
><Card.Content class="space-y-3"
|
||||
><code class="block break-all rounded bg-muted p-3 text-xs">{data.fullUrl}</code><Button
|
||||
onclick={copy}>{copied ? 'Copied' : 'Copy address'}</Button
|
||||
></Card.Content
|
||||
></Card.Root
|
||||
>
|
||||
{:else}
|
||||
<p>
|
||||
No authorisation code in this link. Start a connection with <code>scripts/bank-link.ts</code>.
|
||||
</p>
|
||||
{/if}
|
||||
<p>
|
||||
<a class="underline" href={resolve('/projects/money-tracker')}>Back to the money tracker</a>
|
||||
</p>
|
||||
</section>
|
||||
@@ -1,568 +0,0 @@
|
||||
.money-app {
|
||||
min-height: 70vh;
|
||||
}
|
||||
.money-app button,
|
||||
.money-dialog button {
|
||||
cursor: pointer;
|
||||
}
|
||||
.money-app button:disabled,
|
||||
.money-dialog button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.45;
|
||||
}
|
||||
.money-app .button,
|
||||
.money-dialog .button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 7px;
|
||||
min-height: 38px;
|
||||
padding: 0 13px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.money-app .button.primary,
|
||||
.money-dialog .button.primary {
|
||||
color: var(--site-bg);
|
||||
background: var(--site-text);
|
||||
border-color: var(--site-text);
|
||||
}
|
||||
.money-app .button.primary:hover,
|
||||
.money-dialog .button.primary:hover {
|
||||
opacity: 0.82;
|
||||
}
|
||||
.money-app .button.secondary,
|
||||
.money-dialog .button.secondary {
|
||||
background: var(--site-surface);
|
||||
}
|
||||
.money-app .button.secondary:hover,
|
||||
.money-dialog .button.secondary:hover,
|
||||
.money-app .subtabs button:hover {
|
||||
background: var(--site-hover);
|
||||
}
|
||||
.money-dialog .button.danger {
|
||||
background: #b42318;
|
||||
border-color: #b42318;
|
||||
color: white;
|
||||
}
|
||||
.money-app .app-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: end;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.money-app h1 {
|
||||
margin: 2px 0 8px;
|
||||
font-size: clamp(2.25rem, 5vw, 3rem);
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.05em;
|
||||
line-height: 1.15;
|
||||
}
|
||||
.money-app .header-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.money-app .projection-note {
|
||||
margin-top: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.money-app .projection-totals {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
.money-app .projection-totals > div {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 7px;
|
||||
}
|
||||
.money-app .projection-totals strong {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.money-app .wish-tier-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(64px, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
.money-app .wish-tier-grid > div {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
padding: 9px 11px;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 7px;
|
||||
}
|
||||
.money-app .wish-tier-grid strong {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.money-app .wish-tier-grid small {
|
||||
color: var(--site-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
.money-app .capitalize {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
.money-app .eyebrow {
|
||||
font-size: 12px;
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.money-app .muted {
|
||||
color: var(--site-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
.money-app .panel {
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
min-width: 0;
|
||||
}
|
||||
.money-app .panel-heading {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.money-app .panel-heading h2 {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.035em;
|
||||
}
|
||||
.money-app .panel-heading p {
|
||||
color: var(--site-muted);
|
||||
font-size: 13px;
|
||||
margin-top: 3px;
|
||||
}
|
||||
.money-app .text-button {
|
||||
font-size: 13px;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.money-app .negative {
|
||||
color: #e5484d;
|
||||
}
|
||||
.money-app .positive {
|
||||
color: #46a758;
|
||||
}
|
||||
.money-app .empty {
|
||||
color: var(--site-muted);
|
||||
font-size: 13px;
|
||||
padding: 30px 0;
|
||||
}
|
||||
.money-app .filters {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.money-app .filters input {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.money-app .filters input,
|
||||
.money-app .filters select,
|
||||
.money-dialog .field input,
|
||||
.money-dialog .field select,
|
||||
.money-dialog .field textarea {
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 6px;
|
||||
background: var(--site-bg);
|
||||
color: var(--site-text);
|
||||
min-height: 38px;
|
||||
padding: 8px 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.money-app .table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
.money-app table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
.money-app th {
|
||||
color: var(--site-muted);
|
||||
text-align: left;
|
||||
font-weight: 500;
|
||||
border-bottom: 1px solid var(--site-border);
|
||||
padding: 8px 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.money-app td {
|
||||
border-bottom: 1px solid var(--site-border);
|
||||
padding: 8px 10px;
|
||||
vertical-align: top;
|
||||
}
|
||||
.money-app tbody tr:hover {
|
||||
background: var(--site-surface);
|
||||
}
|
||||
.money-app table .numeric {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.money-app table .strong {
|
||||
font-weight: 500;
|
||||
}
|
||||
.money-app .actions-heading {
|
||||
text-align: right;
|
||||
}
|
||||
.money-app .actions {
|
||||
white-space: nowrap;
|
||||
text-align: right;
|
||||
}
|
||||
.money-app .actions button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 5px;
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.money-app .actions button:hover {
|
||||
color: var(--site-text);
|
||||
background: var(--site-hover);
|
||||
}
|
||||
.money-app .pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
color: var(--site-muted);
|
||||
font-size: 13px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.money-app .pagination > div {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.money-app .subtabs {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.money-app .subtabs button {
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 6px;
|
||||
padding: 7px 12px;
|
||||
color: var(--site-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
.money-app .subtabs button.current {
|
||||
background: var(--site-text);
|
||||
color: var(--site-bg);
|
||||
border-color: var(--site-text);
|
||||
}
|
||||
.money-app .stat-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.money-app .stat {
|
||||
padding: 16px 18px;
|
||||
min-width: 0;
|
||||
}
|
||||
.money-app .stat + .stat {
|
||||
border-left: 1px solid var(--site-border);
|
||||
}
|
||||
.money-app .stat-value {
|
||||
margin-top: 4px;
|
||||
font-size: 1.35rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.04em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.money-app .stat.net .stat-value {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
.money-app .dashboard {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
.money-app .span-8 {
|
||||
grid-column: span 8;
|
||||
}
|
||||
.money-app .span-4 {
|
||||
grid-column: span 4;
|
||||
}
|
||||
.money-app .stack {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
.money-app .swatch {
|
||||
display: inline-block;
|
||||
flex: none;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 0 1px var(--site-border);
|
||||
}
|
||||
.money-app .account-cell {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
.money-app .nowrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.money-app tr.future {
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.money-app .chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.money-app .chips button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 999px;
|
||||
padding: 5px 11px;
|
||||
color: var(--site-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.money-app .chips button:hover {
|
||||
background: var(--site-hover);
|
||||
}
|
||||
.money-app .chips button.current {
|
||||
color: var(--site-text);
|
||||
border-color: var(--site-text);
|
||||
background: var(--site-surface);
|
||||
}
|
||||
.money-app .item-list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border-top: 1px solid var(--site-border);
|
||||
padding: 8px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
.money-app .item-list.scroll {
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.money-app .item-list li.selected {
|
||||
background: var(--site-surface);
|
||||
box-shadow: inset 2px 0 0 var(--site-text);
|
||||
}
|
||||
.money-app .item-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
padding-left: 4px;
|
||||
}
|
||||
.money-app .item-main > span:last-child {
|
||||
min-width: 0;
|
||||
}
|
||||
.money-app .item-main strong {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.money-app .item-main small {
|
||||
display: block;
|
||||
color: var(--site-muted);
|
||||
font-size: 11px;
|
||||
margin-top: 1px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.money-app .item-amount {
|
||||
font-weight: 500;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.money-app .item-list .actions button {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
}
|
||||
.money-app .tier {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: none;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 5px;
|
||||
color: var(--site-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
.money-app .mini-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.money-app .mini-stats > div {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
.money-app .mini-stats strong {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.money-app .mini-stats small {
|
||||
color: var(--site-muted);
|
||||
font-weight: 400;
|
||||
}
|
||||
.money-app .utility-settings {
|
||||
display: block;
|
||||
margin: 10px 0 6px;
|
||||
}
|
||||
.money-app .utility-note {
|
||||
margin-top: 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
@media (max-width: 1180px) {
|
||||
.money-app .span-8,
|
||||
.money-app .span-4 {
|
||||
grid-column: span 12;
|
||||
}
|
||||
.money-app .dashboard > .span-4:nth-last-child(-n + 3) {
|
||||
grid-column: span 6;
|
||||
}
|
||||
.money-app .dashboard > .span-4:last-child {
|
||||
grid-column: span 12;
|
||||
}
|
||||
.money-app .stat-strip {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
.money-app .stat:nth-child(4) {
|
||||
border-left: 0;
|
||||
}
|
||||
.money-app .stat:nth-child(n + 4) {
|
||||
border-top: 1px solid var(--site-border);
|
||||
}
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.money-app .dashboard > .span-4:nth-last-child(-n + 3) {
|
||||
grid-column: span 12;
|
||||
}
|
||||
.money-app .stat-strip {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.money-app .stat.net {
|
||||
grid-column: span 2;
|
||||
}
|
||||
.money-app .stat + .stat {
|
||||
border-left: 0;
|
||||
border-top: 1px solid var(--site-border);
|
||||
}
|
||||
.money-app .stat:nth-child(odd):not(.net) {
|
||||
border-left: 1px solid var(--site-border);
|
||||
}
|
||||
}
|
||||
.money-dialog .dialog-header {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
.money-dialog .dialog-header h2 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.035em;
|
||||
}
|
||||
.money-dialog .icon-button {
|
||||
display: inline-flex;
|
||||
padding: 4px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.money-dialog .icon-button:hover {
|
||||
background: var(--site-hover);
|
||||
}
|
||||
.money-dialog .dialog-copy {
|
||||
color: var(--site-muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.money-dialog .dialog-copy code {
|
||||
color: var(--site-text);
|
||||
}
|
||||
.money-dialog .field {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.money-dialog .field input,
|
||||
.money-dialog .field select,
|
||||
.money-dialog .field textarea {
|
||||
width: 100%;
|
||||
font-weight: 400;
|
||||
}
|
||||
.money-dialog .field small {
|
||||
color: var(--site-muted);
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
}
|
||||
.money-dialog .field.check-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-weight: 400;
|
||||
}
|
||||
.money-dialog .field.check-field input {
|
||||
width: auto;
|
||||
min-height: auto;
|
||||
}
|
||||
.money-dialog .dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
.money-dialog .form-error {
|
||||
color: #e5484d;
|
||||
font-size: 13px;
|
||||
}
|
||||
@media (max-width: 520px) {
|
||||
.money-app .projection-totals {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.money-app .panel {
|
||||
padding: 16px;
|
||||
}
|
||||
.money-app .filters {
|
||||
flex-direction: column;
|
||||
}
|
||||
.money-app .filters select {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
import { db } from '#lib/server/db/index.js';
|
||||
import { moneyAccounts, moneyTransactions } from '#lib/server/db/schema/index.js';
|
||||
import { requireAdmin } from '#lib/server/authorization.js';
|
||||
import { positiveId, validDate } from '#lib/server/money-tracker.js';
|
||||
import {
|
||||
and,
|
||||
asc,
|
||||
count,
|
||||
desc,
|
||||
eq,
|
||||
gt,
|
||||
gte,
|
||||
ilike,
|
||||
inArray,
|
||||
isNotNull,
|
||||
lt,
|
||||
lte,
|
||||
or,
|
||||
sql,
|
||||
sum,
|
||||
type SQL
|
||||
} from 'drizzle-orm';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
const sources = ['manual', 'csv', 'enablebanking', 'linked'] as const;
|
||||
const sorts = ['newest', 'oldest', 'largest', 'smallest'] as const;
|
||||
|
||||
export const load: PageServerLoad = async (event) => {
|
||||
if (!event.locals.user)
|
||||
redirect(303, '/login?returnTo=' + encodeURIComponent(event.url.pathname + event.url.search));
|
||||
const user = requireAdmin(event);
|
||||
const params = event.url.searchParams;
|
||||
const q = (params.get('q') ?? '').trim().slice(0, 100);
|
||||
const accountIds = params
|
||||
.getAll('account')
|
||||
.map(positiveId)
|
||||
.filter((id): id is number => id !== null);
|
||||
const source = sources.find((item) => item === params.get('source')) ?? '';
|
||||
const type = params.get('type') === 'in' ? 'in' : params.get('type') === 'out' ? 'out' : '';
|
||||
const from = validDate(params.get('from')) ?? '';
|
||||
const to = validDate(params.get('to')) ?? '';
|
||||
const sort = sorts.find((item) => item === params.get('sort')) ?? 'newest';
|
||||
const requestedPage = positiveId(params.get('page')) ?? 1;
|
||||
|
||||
const t = moneyTransactions;
|
||||
const filters: (SQL | undefined)[] = [eq(t.userId, user.id)];
|
||||
if (q) {
|
||||
const pattern = '%' + q.replace(/[\\%_]/g, (c) => '\\' + c) + '%';
|
||||
filters.push(or(ilike(t.description, pattern), ilike(t.counterparty, pattern)));
|
||||
}
|
||||
if (accountIds.length) filters.push(inArray(t.accountId, accountIds));
|
||||
if (source === 'linked') filters.push(and(eq(t.source, 'manual'), isNotNull(t.externalId)));
|
||||
else if (source) filters.push(eq(t.source, source));
|
||||
if (type === 'in') filters.push(gt(t.amountCents, 0));
|
||||
if (type === 'out') filters.push(lt(t.amountCents, 0));
|
||||
if (from) filters.push(gte(t.date, from));
|
||||
if (to) filters.push(lte(t.date, to));
|
||||
const where = and(...filters);
|
||||
|
||||
const [accounts, [totals]] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
id: moneyAccounts.id,
|
||||
name: moneyAccounts.name,
|
||||
color: moneyAccounts.color,
|
||||
scope: moneyAccounts.scope
|
||||
})
|
||||
.from(moneyAccounts)
|
||||
.where(eq(moneyAccounts.userId, user.id))
|
||||
.orderBy(asc(moneyAccounts.name)),
|
||||
db
|
||||
.select({ count: count(), sum: sum(t.amountCents) })
|
||||
.from(t)
|
||||
.where(where)
|
||||
]);
|
||||
const pages = Math.max(1, Math.ceil(totals.count / PAGE_SIZE));
|
||||
const page = Math.min(requestedPage, pages);
|
||||
const order =
|
||||
sort === 'oldest'
|
||||
? [asc(t.date), asc(t.id)]
|
||||
: sort === 'largest'
|
||||
? [desc(t.amountCents), desc(t.date)]
|
||||
: sort === 'smallest'
|
||||
? [asc(t.amountCents), desc(t.date)]
|
||||
: [desc(t.date), desc(t.id)];
|
||||
const rows = await db
|
||||
.select({
|
||||
id: t.id,
|
||||
date: t.date,
|
||||
description: t.description,
|
||||
counterparty: t.counterparty,
|
||||
amountCents: t.amountCents,
|
||||
accountId: t.accountId,
|
||||
source: t.source,
|
||||
linked: sql<boolean>`${t.externalId} is not null`
|
||||
})
|
||||
.from(t)
|
||||
.where(where)
|
||||
.orderBy(...order)
|
||||
.limit(PAGE_SIZE)
|
||||
.offset((page - 1) * PAGE_SIZE);
|
||||
|
||||
return {
|
||||
accounts,
|
||||
rows,
|
||||
total: totals.count,
|
||||
sumCents: Number(totals.sum ?? 0),
|
||||
page,
|
||||
pages,
|
||||
filters: { q, accountIds, source, type, from, to, sort }
|
||||
};
|
||||
};
|
||||
@@ -1,205 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { SvelteURLSearchParams } from 'svelte/reactivity';
|
||||
import { Badge } from '#lib/components/ui/badge/index.js';
|
||||
import { Button } from '#lib/components/ui/button/index.js';
|
||||
import { Input } from '#lib/components/ui/input/index.js';
|
||||
import { NativeSelect } from '#lib/components/ui/native-select/index.js';
|
||||
import * as Table from '#lib/components/ui/table/index.js';
|
||||
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
const euro = new Intl.NumberFormat('en-NL', { style: 'currency', currency: 'EUR' });
|
||||
const sourceLabels: Record<string, string> = {
|
||||
manual: 'Manual',
|
||||
csv: 'CSV',
|
||||
enablebanking: 'Bank'
|
||||
};
|
||||
let names = $derived(new Map(data.accounts.map((account) => [account.id, account.name])));
|
||||
|
||||
function dateLabel(value: string) {
|
||||
return new Date(value + 'T12:00:00Z').toLocaleDateString('en-NL', {
|
||||
weekday: 'short',
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
timeZone: 'UTC'
|
||||
});
|
||||
}
|
||||
function href(changes: Record<string, string | number | null>) {
|
||||
const params = new SvelteURLSearchParams(page.url.searchParams);
|
||||
for (const [key, value] of Object.entries(changes)) {
|
||||
if (value === null || value === '') params.delete(key);
|
||||
else params.set(key, String(value));
|
||||
}
|
||||
const query = params.toString();
|
||||
return resolve('/projects/money-tracker/transactions') + (query ? '?' + query : '');
|
||||
}
|
||||
function submit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
const form = new FormData(event.currentTarget as HTMLFormElement);
|
||||
const params = new SvelteURLSearchParams();
|
||||
for (const [key, value] of form)
|
||||
if (typeof value === 'string' && value) params.append(key, value);
|
||||
const query = params.toString();
|
||||
goto(resolve('/projects/money-tracker/transactions') + (query ? '?' + query : ''), {
|
||||
keepFocus: true,
|
||||
noScroll: true
|
||||
});
|
||||
}
|
||||
function autoSubmit(event: Event) {
|
||||
(event.currentTarget as HTMLElement).closest('form')?.requestSubmit();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Transactions · Money tracker</title></svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-screen-xl space-y-6 px-4 py-8 sm:px-6 sm:py-10">
|
||||
<header class="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<a
|
||||
class="inline-flex items-center gap-1 text-sm opacity-70 hover:opacity-100"
|
||||
href={resolve('/projects/money-tracker')}><ArrowLeft size={14} /> Money tracker</a
|
||||
>
|
||||
<h1 class="text-2xl font-bold">Transactions</h1>
|
||||
<p class="text-sm opacity-70">
|
||||
{data.total.toLocaleString('en-NL')} matching · net
|
||||
<span class:text-green-600={data.sumCents > 0} class:text-red-600={data.sumCents < 0}
|
||||
>{euro.format(data.sumCents / 100)}</span
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<form class="grid gap-3 sm:grid-cols-2 lg:grid-cols-6" method="GET" onsubmit={submit}>
|
||||
<label class="space-y-1 text-sm lg:col-span-2"
|
||||
><span>Search</span><Input
|
||||
type="search"
|
||||
name="q"
|
||||
placeholder="Description or counterparty"
|
||||
value={data.filters.q}
|
||||
/></label
|
||||
>
|
||||
<label class="space-y-1 text-sm"
|
||||
><span>Account</span><NativeSelect
|
||||
name="account"
|
||||
value={data.filters.accountIds.length === 1 ? String(data.filters.accountIds[0]) : ''}
|
||||
onchange={autoSubmit}
|
||||
><option value="">All accounts</option>{#each data.accounts as account (account.id)}<option
|
||||
value={String(account.id)}>{account.name}</option
|
||||
>{/each}</NativeSelect
|
||||
></label
|
||||
>
|
||||
<label class="space-y-1 text-sm"
|
||||
><span>Source</span><NativeSelect
|
||||
name="source"
|
||||
value={data.filters.source}
|
||||
onchange={autoSubmit}
|
||||
><option value="">Any source</option><option value="manual">Manual</option><option
|
||||
value="linked">Manual, matched to bank</option
|
||||
><option value="csv">CSV import</option><option value="enablebanking">Bank sync</option
|
||||
></NativeSelect
|
||||
></label
|
||||
>
|
||||
<label class="space-y-1 text-sm"
|
||||
><span>Type</span><NativeSelect name="type" value={data.filters.type} onchange={autoSubmit}
|
||||
><option value="">In and out</option><option value="out">Expenses</option><option value="in"
|
||||
>Income</option
|
||||
></NativeSelect
|
||||
></label
|
||||
>
|
||||
<label class="space-y-1 text-sm"
|
||||
><span>Sort</span><NativeSelect name="sort" value={data.filters.sort} onchange={autoSubmit}
|
||||
><option value="newest">Newest first</option><option value="oldest">Oldest first</option
|
||||
><option value="largest">Largest income</option><option value="smallest"
|
||||
>Largest expense</option
|
||||
></NativeSelect
|
||||
></label
|
||||
>
|
||||
<label class="space-y-1 text-sm"
|
||||
><span>From</span><Input
|
||||
type="date"
|
||||
name="from"
|
||||
value={data.filters.from}
|
||||
onchange={autoSubmit}
|
||||
/></label
|
||||
>
|
||||
<label class="space-y-1 text-sm"
|
||||
><span>To</span><Input
|
||||
type="date"
|
||||
name="to"
|
||||
value={data.filters.to}
|
||||
onchange={autoSubmit}
|
||||
/></label
|
||||
>
|
||||
<div class="flex items-end gap-2">
|
||||
<Button type="submit">Apply</Button><Button
|
||||
variant="ghost"
|
||||
href={resolve('/projects/money-tracker/transactions')}>Reset</Button
|
||||
>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<Table.Root>
|
||||
<Table.Header
|
||||
><Table.Row
|
||||
><Table.Head>Date</Table.Head><Table.Head>Description</Table.Head><Table.Head
|
||||
>Account</Table.Head
|
||||
><Table.Head>Source</Table.Head><Table.Head class="text-right">Amount</Table.Head
|
||||
></Table.Row
|
||||
></Table.Header
|
||||
>
|
||||
<Table.Body>
|
||||
{#each data.rows as row (row.id)}
|
||||
<Table.Row>
|
||||
<Table.Cell class="whitespace-nowrap">{dateLabel(row.date)}</Table.Cell>
|
||||
<Table.Cell
|
||||
><span class="font-medium">{row.description}</span
|
||||
>{#if row.counterparty && row.counterparty !== row.description}<span
|
||||
class="block text-xs opacity-60">{row.counterparty}</span
|
||||
>{/if}</Table.Cell
|
||||
>
|
||||
<Table.Cell class="whitespace-nowrap"
|
||||
>{names.get(row.accountId ?? 0) ?? 'Unassigned'}</Table.Cell
|
||||
>
|
||||
<Table.Cell
|
||||
><Badge variant={row.source === 'manual' ? 'outline' : 'secondary'}
|
||||
>{sourceLabels[row.source] ?? row.source}</Badge
|
||||
>{#if row.source === 'manual' && row.linked}<Badge
|
||||
variant="ghost"
|
||||
title="Matched to a bank transaction">✓ bank</Badge
|
||||
>{/if}</Table.Cell
|
||||
>
|
||||
<Table.Cell
|
||||
class={'text-right tabular-nums ' +
|
||||
(row.amountCents < 0 ? 'text-red-600' : 'text-green-600')}
|
||||
>{euro.format(row.amountCents / 100)}</Table.Cell
|
||||
>
|
||||
</Table.Row>
|
||||
{:else}
|
||||
<Table.Row
|
||||
><Table.Cell colspan={5} class="py-8 text-center opacity-70"
|
||||
>No matching transactions.</Table.Cell
|
||||
></Table.Row
|
||||
>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
|
||||
{#if data.pages > 1}
|
||||
<nav class="flex items-center justify-between text-sm" aria-label="Pagination">
|
||||
<span>Page {data.page} of {data.pages}</span>
|
||||
<div class="flex gap-2">
|
||||
{#if data.page > 1}<Button variant="outline" href={href({ page: data.page - 1 })}
|
||||
>Previous</Button
|
||||
>{/if}
|
||||
{#if data.page < data.pages}<Button variant="outline" href={href({ page: data.page + 1 })}
|
||||
>Next</Button
|
||||
>{/if}
|
||||
</div>
|
||||
</nav>
|
||||
{/if}
|
||||
</div>
|
||||
Reference in New Issue
Block a user