diff --git a/src/lib/money-tracker/QuickAdd.svelte b/src/lib/money-tracker/QuickAdd.svelte new file mode 100644 index 0000000..27ed1e7 --- /dev/null +++ b/src/lib/money-tracker/QuickAdd.svelte @@ -0,0 +1,269 @@ + + +
{ + saving = true; + const saved = { description, signedAmount }; + return async ({ result, update }) => { + saving = false; + if (result.type === 'success') { + localStorage.setItem(storageKey, accountId); + failed = false; + message = `Saved ${saved.description} · ${euro.format(Number(saved.signedAmount))}`; + description = ''; + amount = ''; + await update({ reset: false }); + descriptionInput?.focus(); + } else { + failed = true; + message = + result.type === 'failure' && typeof result.data?.message === 'string' + ? result.data.message + : 'Could not save this transaction.'; + } + }; + }} +> +
+ +
+
+ + 0} + aria-controls="quick-suggestions" + aria-autocomplete="list" + aria-activedescendant={highlighted >= 0 ? `quick-suggestion-${highlighted}` : undefined} + bind:ref={descriptionInput} + bind:value={description} + oninput={() => { + listOpen = true; + highlighted = -1; + }} + onkeydown={keydown} + onfocus={() => (listOpen = true)} + onblur={() => setTimeout(() => (listOpen = false), 120)} + /> + {#if listOpen && matches.length} + + {/if} +
+ + + + + {#each accounts as account (account.id)}{/each} + + + + {#if message}

{message}

{/if} +
+ + diff --git a/src/lib/money-tracker/quick-add.test.ts b/src/lib/money-tracker/quick-add.test.ts new file mode 100644 index 0000000..ebdd8e7 --- /dev/null +++ b/src/lib/money-tracker/quick-add.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { descriptionSuggestions } from './quick-add'; + +describe('quick-add suggestions', () => { + it('ranks by use and fills the usual amount and latest account', () => { + const rows = [ + { description: 'PLUS', amountCents: -1200, accountId: 1, date: '2026-10-01' }, + { description: 'plus', amountCents: -1200, accountId: 1, date: '2026-10-03' }, + { description: 'Plus', amountCents: -800, accountId: 4, date: '2026-10-05' }, + { description: 'From Juhee', amountCents: 5000, accountId: 4, date: '2026-10-02' }, + { description: 'Future', amountCents: -1, accountId: 1, date: '2026-12-01' } + ]; + const [first, second, ...rest] = descriptionSuggestions(rows, '2026-10-24'); + expect(first).toMatchObject({ + key: 'plus', + description: 'Plus', + amountCents: -1200, + accountId: 4, + count: 3 + }); + expect(second).toMatchObject({ description: 'From Juhee', amountCents: 5000 }); + expect(rest).toEqual([]); + }); +}); diff --git a/src/lib/money-tracker/quick-add.ts b/src/lib/money-tracker/quick-add.ts new file mode 100644 index 0000000..5e137ef --- /dev/null +++ b/src/lib/money-tracker/quick-add.ts @@ -0,0 +1,73 @@ +export type SuggestionSource = { + description: string; + amountCents: number; + accountId: number | null; + date: string; +}; + +export type Suggestion = { + key: string; + description: string; + /** Most common amount for this description (latest wins ties). */ + amountCents: number; + /** Account used most recently with this description. */ + accountId: number | null; + count: number; + lastDate: string; +}; + +/** Past descriptions ranked by use, each with its usual amount and latest account. */ +export function descriptionSuggestions(rows: SuggestionSource[], today: string): Suggestion[] { + const groups = new Map< + string, + { + description: string; + amounts: Map; + accountId: number | null; + count: number; + lastDate: string; + lastAmount: number; + } + >(); + for (const row of rows) { + if (row.date > today) continue; + const key = row.description.trim().toLowerCase(); + if (!key) continue; + const group = groups.get(key) ?? { + description: row.description.trim(), + amounts: new Map(), + accountId: row.accountId, + count: 0, + lastDate: '', + lastAmount: row.amountCents + }; + group.count++; + group.amounts.set(row.amountCents, (group.amounts.get(row.amountCents) ?? 0) + 1); + if (row.date >= group.lastDate) { + group.lastDate = row.date; + group.description = row.description.trim(); + group.accountId = row.accountId; + group.lastAmount = row.amountCents; + } + groups.set(key, group); + } + return [...groups.entries()] + .map(([key, group]) => { + let amountCents = group.lastAmount; + let best = group.amounts.get(amountCents) ?? 0; + for (const [value, uses] of group.amounts) + if (uses > best) { + best = uses; + amountCents = value; + } + return { + key, + description: group.description, + amountCents, + accountId: group.accountId, + count: group.count, + lastDate: group.lastDate + }; + }) + .sort((a, b) => b.count - a.count || b.lastDate.localeCompare(a.lastDate)); +} diff --git a/src/lib/server/money-tracker.test.ts b/src/lib/server/money-tracker.test.ts index c2ed2d5..22ab1ea 100644 --- a/src/lib/server/money-tracker.test.ts +++ b/src/lib/server/money-tracker.test.ts @@ -1,5 +1,12 @@ import { describe, expect, it } from 'vitest'; -import { cents, csvDate, importCents, parseCsv, parseTransactionImport } from './money-tracker'; +import { + cents, + csvDate, + importCents, + parseCsv, + parseTransactionImport, + planImport +} from './money-tracker'; describe('transaction import parsing', () => { it('handles quoted commas and semicolon-separated European amounts', () => { @@ -63,3 +70,50 @@ describe('transaction import parsing', () => { ).toThrow('Quote values containing commas'); }); }); + +describe('revolut statements and import dedupe', () => { + const statement = + 'Type,Product,Started Date,Completed Date,Description,Amount,Fee,Currency,State,Balance\n' + + 'CARD_PAYMENT,Current,2026-10-01 10:00:00,2026-10-02 09:00:00,Albert Heijn,-12.34,0.00,EUR,COMPLETED,100.00\n' + + 'TOPUP,Current,2026-10-03 10:00:00,,Pending top-up,50.00,0.00,EUR,PENDING,\n' + + 'TRANSFER,Current,2026-10-04 10:00:00,2026-10-04 10:00:01,To Joint,-100.00,0.50,EUR,COMPLETED,0.00\n' + + 'CARD_PAYMENT,Current,2026-10-05 10:00:00,2026-10-05 11:00:00,Coffee,-3.00,0.00,USD,COMPLETED,0.00\n'; + + it('keeps completed EUR rows, uses the completed date and deducts fees', () => { + const rows = parseTransactionImport(statement, 'Revolut'); + expect(rows.map((row) => [row.date, row.account, row.description, row.amountCents])).toEqual([ + ['2026-10-02', 'Revolut', 'Albert Heijn', -1234], + ['2026-10-04', 'Revolut', 'To Joint', -10050] + ]); + expect(rows[0].externalId).toMatch(/^csv:[0-9a-f]{32}$/); + expect(parseTransactionImport(statement, 'Revolut')[0].externalId).toBe(rows[0].externalId); + expect(() => parseTransactionImport(statement)).toThrow('Choose which account'); + }); + + it('gives identical rows in one file distinct, stable ids', () => { + const file = + 'date,account,description,amount\n2026-10-01,Cash,Kiosk,-2\n2026-10-01,Cash,Kiosk,-2\n'; + const [a, b] = parseTransactionImport(file); + expect(a.externalId).not.toBe(b.externalId); + expect(parseTransactionImport(file).map((row) => row.externalId)).toEqual([ + a.externalId, + b.externalId + ]); + }); + + it('skips rows already imported or already entered by hand (±1 day)', () => { + const rows = [ + { accountId: 1, date: '2026-10-02', amountCents: -1234, externalId: 'csv:a' }, + { accountId: 1, date: '2026-10-02', amountCents: -1234, externalId: 'csv:b' }, + { accountId: 1, date: '2026-10-04', amountCents: -500, externalId: 'csv:c' }, + { accountId: 2, date: '2026-10-04', amountCents: -500, externalId: 'csv:d' } + ]; + const existing = [ + { id: 10, accountId: 1, date: '2026-10-01', amountCents: -1234, externalId: null }, + { id: 11, accountId: 1, date: '2026-10-04', amountCents: -500, externalId: 'csv:c' } + ]; + const { insert, duplicates } = planImport(rows, existing); + expect(insert.map((row) => row.externalId)).toEqual(['csv:b', 'csv:d']); + expect(duplicates).toBe(2); + }); +}); diff --git a/src/lib/server/money-tracker.ts b/src/lib/server/money-tracker.ts index 7868cfc..114db48 100644 --- a/src/lib/server/money-tracker.ts +++ b/src/lib/server/money-tracker.ts @@ -1,4 +1,5 @@ import { fail } from '@sveltejs/kit'; +import { createHash } from 'node:crypto'; export const utilityKinds = ['electricity', 'gas', 'water'] as const; export const cadences = ['weekly', 'monthly', 'yearly'] as const; @@ -130,21 +131,93 @@ export type ImportedTransaction = { account: string; description: string; amountCents: number; + /** Stable per-row id ("csv:") so importing the same file twice adds nothing. */ + externalId: string; }; -export function parseTransactionImport(input: string): ImportedTransaction[] { - const rows = parseCsv(input); - if (rows.length < 2 || rows.length > 20_001) - throw new Error('The file must have a header and 1–20,000 rows.'); - const header = rows.shift()!.map((value) => +export type ImportFormat = 'tracker' | 'revolut'; + +function headerOf(rows: string[][]) { + return rows[0].map((value) => value .replace(/^\uFEFF/, '') .trim() .toLowerCase() ); +} + +/** Revolut app statement export: Type, Product, Started Date, Completed Date, Description, Amount, Fee, Currency, State, Balance. */ +export function isRevolutHeader(header: string[]) { + return ['type', 'started date', 'description', 'amount', 'currency', 'state'].every((key) => + header.includes(key) + ); +} + +function importId(parts: (string | number)[], seen: Map) { + const base = parts.join('\u001f'); + // Identical rows in one file (two coffees, same day) stay distinct but stable. + const occurrence = (seen.get(base) ?? 0) + 1; + seen.set(base, occurrence); + return ( + 'csv:' + createHash('sha256').update(`${base}\u001f${occurrence}`).digest('hex').slice(0, 32) + ); +} + +/** + * Parses either the tracker's own export (date, account, description, amount) or a Revolut + * statement. Revolut files have no account column, so `defaultAccount` names the tracker + * account they belong to; only COMPLETED EUR rows are kept and the fee is deducted. + */ +export function parseTransactionImport( + input: string, + defaultAccount?: string +): ImportedTransaction[] { + const rows = parseCsv(input); + if (rows.length < 2 || rows.length > 20_001) + throw new Error('The file must have a header and 1–20,000 rows.'); + const header = headerOf(rows); + rows.shift(); + const seen = new Map(); + if (isRevolutHeader(header)) { + const account = defaultAccount?.trim(); + if (!account) throw new Error('Choose which account this Revolut statement belongs to.'); + const col = (key: string) => header.indexOf(key); + const parsed: ImportedTransaction[] = []; + for (const [index, row] of rows.entries()) { + const state = (row[col('state')] ?? '').trim().toUpperCase(); + if (state && state !== 'COMPLETED') continue; + const currency = (row[col('currency')] ?? 'EUR').trim().toUpperCase(); + if (currency && currency !== 'EUR') continue; + const started = (row[col('started date')] ?? '').trim(); + const completed = col('completed date') >= 0 ? (row[col('completed date')] ?? '').trim() : ''; + const date = csvDate((completed || started).slice(0, 10)); + const amount = importCents(row[col('amount')]); + const fee = col('fee') >= 0 ? (importCents(row[col('fee')] || '0') ?? undefined) : 0; + const description = (row[col('description')] ?? '').trim().slice(0, 500) || 'Revolut'; + if (!date || amount == null || fee == null) + throw new Error(`Invalid data in statement row ${index + 1}. No rows were imported.`); + const amountCents = amount - fee; + if (!amountCents) continue; + parsed.push({ + date, + account, + description, + amountCents, + externalId: importId( + ['revolut', account.toLowerCase(), started, amount, fee, description], + seen + ) + }); + } + if (!parsed.length) throw new Error('No completed EUR transactions found in this statement.'); + return parsed.sort((a, b) => a.date.localeCompare(b.date)); + } const columns = ['date', 'account', 'description', 'amount'].map((key) => header.indexOf(key)); - if (columns.some((index) => index < 0)) - throw new Error('Headers must include date, account, description and amount.'); + const accountColumn = columns[1]; + if (columns[0] < 0 || columns[2] < 0 || columns[3] < 0 || (accountColumn < 0 && !defaultAccount)) + throw new Error( + 'Headers must include date, account, description and amount (or choose an account), or be a Revolut statement.' + ); const parsed: ImportedTransaction[] = []; for (const [index, row] of rows.entries()) { if (row.length !== header.length) @@ -152,7 +225,8 @@ export function parseTransactionImport(input: string): ImportedTransaction[] { `Data row ${index + 1} has ${row.length} columns; expected ${header.length}. Quote values containing commas. No rows were imported.` ); const date = csvDate(row[columns[0]] ?? ''); - const account = (row[columns[1]] ?? '').trim(); + const account = + (accountColumn >= 0 ? (row[accountColumn] ?? '') : '').trim() || defaultAccount?.trim() || ''; const description = (row[columns[2]] ?? '').trim(); const amountCents = importCents(row[columns[3]]); if ( @@ -164,7 +238,64 @@ export function parseTransactionImport(input: string): ImportedTransaction[] { amountCents == null ) throw new Error(`Invalid data in transaction ${index + 1}. No rows were imported.`); - parsed.push({ date, account, description, amountCents }); + parsed.push({ + date, + account, + description, + amountCents, + externalId: importId([date, account.toLowerCase(), description, amountCents], seen) + }); } return parsed.sort((a, b) => a.date.localeCompare(b.date)); } + +export type ExistingImportRow = { + id: number; + accountId: number; + date: string; + amountCents: number; + externalId: string | null; +}; + +function daysApart(a: string, b: string) { + return Math.abs(Date.parse(a + 'T00:00:00Z') - Date.parse(b + 'T00:00:00Z')) / 86_400_000; +} + +/** + * Splits imported rows into new ones and duplicates: same import id already stored, or an + * existing row on the same account with the same amount within ±1 day (each existing row + * can only absorb one imported row). + */ +export function planImport< + T extends { accountId: number; date: string; amountCents: number; externalId: string } +>(rows: T[], existing: ExistingImportRow[]) { + const ids = new Set( + existing.flatMap((row) => (row.externalId ? [`${row.accountId}:${row.externalId}`] : [])) + ); + const byKey = new Map(); + for (const row of existing) { + const key = `${row.accountId}:${row.amountCents}`; + byKey.set(key, [...(byKey.get(key) ?? []), row]); + } + const claimed = new Set(); + 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

- +
+ +
{#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} 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.

{ 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.`; + } }} > +