();
+ const insert: T[] = [];
+ let duplicates = 0;
+ for (const row of rows) {
+ const idKey = `${row.accountId}:${row.externalId}`;
+ if (ids.has(idKey)) {
+ duplicates++;
+ continue;
+ }
+ const match = (byKey.get(`${row.accountId}:${row.amountCents}`) ?? [])
+ .filter((candidate) => !claimed.has(candidate.id) && daysApart(candidate.date, row.date) <= 1)
+ .sort((a, b) => daysApart(a.date, row.date) - daysApart(b.date, row.date))[0];
+ if (match) {
+ claimed.add(match.id);
+ duplicates++;
+ continue;
+ }
+ ids.add(idKey);
+ insert.push(row);
+ }
+ return { insert, duplicates };
+}
diff --git a/src/routes/projects/money-tracker/+page.server.ts b/src/routes/projects/money-tracker/+page.server.ts
index 74f14e7..9545bb9 100644
--- a/src/routes/projects/money-tracker/+page.server.ts
+++ b/src/routes/projects/money-tracker/+page.server.ts
@@ -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);
+}
diff --git a/src/routes/projects/money-tracker/+page.svelte b/src/routes/projects/money-tracker/+page.svelte
index db276a5..a5e7c7c 100644
--- a/src/routes/projects/money-tracker/+page.svelte
+++ b/src/routes/projects/money-tracker/+page.svelte
@@ -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 @@
+
+ {#if importNotice}{importNotice}
{/if}
+
@@ -380,9 +388,13 @@
Transactions
{filtered.length} of {data.transactions.length} entries
- Export CSV
+
Search transactions
{#each shown as row (row.id)} data.today}
>{dateLabel(row.date)} {row.description} {row.description}{#if row.source !== 'manual'}
+ {row.source === 'enablebanking' ? 'bank' : row.source} {/if}{@render swatch(row.accountId)}{accountName(row.accountId)}
Upload your CSV or tab-separated export with date, account, description, amount
- columns. Dates like Fri 1 Aug 2025 and amounts like -€1,234.50 are supported.
+ columns, or a Revolut statement export (only completed EUR rows are imported, fees included).
+ Dates like Fri 1 Aug 2025 and amounts like -€1,234.50 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.