money: quick-add bar, server-filtered transactions page, CSV import dedupe + Revolut statements

This commit is contained in:
2026-10-24 00:50:46 +00:00
parent 9a1b21ec39
commit 00c1e8b1e7
9 changed files with 976 additions and 36 deletions

View File

@@ -18,12 +18,13 @@ import {
invalid,
optionalText,
parseTransactionImport,
planImport,
positiveId,
textField,
utilityKind,
validDate
} from '#lib/server/money-tracker.js';
import { and, asc, desc, eq } from 'drizzle-orm';
import { and, asc, desc, eq, gte, inArray } from 'drizzle-orm';
import { error, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
@@ -48,7 +49,14 @@ export const load: PageServerLoad = async (event) => {
.where(eq(moneyAccounts.userId, userId))
.orderBy(asc(moneyAccounts.name)),
db
.select()
.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)),
@@ -452,18 +460,18 @@ export const actions: Actions = {
const file = form.get('file');
if (!(file instanceof File) || !file.size || file.size > 2_000_000)
return invalid('Choose a CSV or TSV file under 2 MB.');
const chosenId = positiveId(form.get('accountId'));
const accounts = await db.select().from(moneyAccounts).where(eq(moneyAccounts.userId, user.id));
const chosen = chosenId ? accounts.find((account) => account.id === chosenId) : undefined;
if (chosenId && !chosen) return invalid('Choose an account.');
let parsed: ImportedTransaction[];
try {
parsed = parseTransactionImport(await file.text());
parsed = parseTransactionImport(await file.text(), chosen?.name);
} 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]));
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
@@ -472,18 +480,50 @@ export const actions: Actions = {
.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
}))
const rows = parsed.map((row) => ({
...row,
accountId: ids.get(row.account.toLowerCase())!
}));
const accountIds = [...new Set(rows.map((row) => row.accountId))];
const existing = await tx
.select({
id: moneyTransactions.id,
accountId: moneyTransactions.accountId,
date: moneyTransactions.date,
amountCents: moneyTransactions.amountCents,
externalId: moneyTransactions.externalId
})
.from(moneyTransactions)
.where(
and(
eq(moneyTransactions.userId, user.id),
inArray(moneyTransactions.accountId, accountIds),
gte(moneyTransactions.date, addDays(rows[0].date, -1))
)
);
const { insert, duplicates } = planImport(rows, existing);
for (let i = 0; i < insert.length; i += 500) {
await tx
.insert(moneyTransactions)
.values(
insert.slice(i, i + 500).map((row) => ({
userId: user.id,
accountId: row.accountId,
date: row.date,
description: row.description,
amountCents: row.amountCents,
externalId: row.externalId,
source: 'csv'
}))
)
.onConflictDoNothing();
}
return { imported: insert.length, duplicates };
});
return { imported: parsed.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);
}

View File

@@ -16,6 +16,9 @@
import Upload from 'lucide-svelte/icons/upload';
import Download from 'lucide-svelte/icons/download';
import MoneyChart from '#lib/money-tracker/MoneyChart.svelte';
import QuickAdd from '#lib/money-tracker/QuickAdd.svelte';
import { Badge } from '#lib/components/ui/badge/index.js';
import { resolve } from '$app/paths';
import { Button } from '#lib/components/ui/button/index.js';
import { Input } from '#lib/components/ui/input/index.js';
import * as Tabs from '#lib/components/ui/tabs/index.js';
@@ -68,6 +71,8 @@
let pageNumber = $state(1);
let selectedUtility = $state<(typeof kinds)[number]['id']>('electricity');
let dialogOpen = $state(false);
let importAccountId = $state('');
let importNotice = $state('');
let actualTransactions = $derived(data.transactions.filter((item) => item.date <= data.today));
let futureTransactions = $derived(data.transactions.filter((item) => item.date > data.today));
let balances = $derived(accountBalances(data.accounts, actualTransactions));
@@ -280,6 +285,9 @@
</div>
</section>
<QuickAdd accounts={data.accounts} transactions={data.transactions} today={data.today} />
{#if importNotice}<p class="mb-4 text-sm" role="status">{importNotice}</p>{/if}
<div class="dashboard">
<section class="panel span-8">
<div class="panel-heading">
@@ -380,9 +388,13 @@
<h2>Transactions</h2>
<p>{filtered.length} of {data.transactions.length} entries</p>
</div>
<Button variant="outline" onclick={exportCsv} disabled={!data.transactions.length}
><Download size={16} /> Export CSV</Button
>
<div class="header-actions">
<Button variant="outline" href={resolve('/projects/money-tracker/transactions')}
>All transactions</Button
><Button variant="outline" onclick={exportCsv} disabled={!data.transactions.length}
><Download size={16} /> Export CSV</Button
>
</div>
</div>
<div class="filters">
<label class="sr-only" for="transaction-search">Search transactions</label><Input
@@ -430,7 +442,10 @@
><tbody
>{#each shown as row (row.id)}<tr class:future={row.date > data.today}
><td class="nowrap">{dateLabel(row.date)}</td><td class="strong"
>{row.description}</td
>{row.description}{#if row.source !== 'manual'}
<Badge variant="secondary" class="ml-2"
>{row.source === 'enablebanking' ? 'bank' : row.source}</Badge
>{/if}</td
><td class="nowrap"
><span class="account-cell"
>{@render swatch(row.accountId)}{accountName(row.accountId)}</span
@@ -743,10 +758,11 @@
{:else if mode === 'import'}
<p class="dialog-copy">
Upload your CSV or tab-separated export with <code>date, account, description, amount</code>
columns. Dates like <code>Fri 1 Aug 2025</code> and amounts like <code>-€1,234.50</code> are supported.
columns, or a Revolut statement export (only completed EUR rows are imported, fees included).
Dates like <code>Fri 1 Aug 2025</code> and amounts like <code>-€1,234.50</code> are supported.
Empty rows are skipped, and entries appear in date order. “Start balance” rows import as transactions,
so leave those accounts’ opening balances at zero. New account names are created automatically.
Importing a file twice adds duplicate transactions.
Rows already imported, or already entered with the same amount within a day, are skipped.
</p>
<form
method="POST"
@@ -756,7 +772,11 @@
async ({ result, update }) => {
await update();
showActionError = result.type === 'failure';
if (result.type === 'success') dialogOpen = false;
if (result.type === 'success') {
dialogOpen = false;
const done = result.data as { imported?: number; duplicates?: number } | undefined;
importNotice = `Imported ${done?.imported ?? 0} transactions; skipped ${done?.duplicates ?? 0} duplicates.`;
}
}}
>
<label class="field"
@@ -767,6 +787,16 @@
required
/></label
>
<label class="field"
>Account (for files without an account column, like Revolut statements)<NativeSelect
name="accountId"
bind:value={importAccountId}
><option value="">From the file</option
>{#each data.accounts as account (account.id)}<option value={String(account.id)}
>{account.name}</option
>{/each}</NativeSelect
></label
>
<div class="dialog-footer">
<Button type="button" class="button secondary" onclick={() => (dialogOpen = false)}
>Cancel</Button

View File

@@ -0,0 +1,114 @@
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 }
};
};

View File

@@ -0,0 +1,205 @@
<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>