From 2466db74cd7ab22de1a2b87cf06ed893d9203f32 Mon Sep 17 00:00:00 2001 From: Stan Runge Date: Wed, 23 Sep 2026 17:23:14 +0000 Subject: [PATCH] fix csv import for money tracker --- src/lib/server/money-tracker.test.ts | 47 +++++++++- src/lib/server/money-tracker.ts | 91 ++++++++++++++++++- .../projects/money-tracker/+page.server.ts | 39 +------- .../projects/money-tracker/+page.svelte | 41 ++++++--- 4 files changed, 165 insertions(+), 53 deletions(-) diff --git a/src/lib/server/money-tracker.test.ts b/src/lib/server/money-tracker.test.ts index f2b98eb..c2ed2d5 100644 --- a/src/lib/server/money-tracker.test.ts +++ b/src/lib/server/money-tracker.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { cents, csvDate, parseCsv } from './money-tracker'; +import { cents, csvDate, importCents, parseCsv, parseTransactionImport } from './money-tracker'; describe('transaction import parsing', () => { it('handles quoted commas and semicolon-separated European amounts', () => { @@ -15,6 +15,51 @@ describe('transaction import parsing', () => { it('rejects invalid dates and amounts rather than altering them', () => { expect(csvDate('31/02/2026')).toBeNull(); + expect(csvDate('Sat 1 Aug 2025')).toBeNull(); expect(cents('1.234,56')).toBeUndefined(); }); + + it('accepts the spreadsheet export with tabs, spacer rows, named dates and euro amounts', () => { + const rows = parseCsv( + 'date\taccount\tdescription\tamount\n' + + 'Fri 1 Aug 2025\tDaily\tStart balance\t€123.45\n' + + 'Fri 1 Aug 2025\tBusiness\tStart balance\t-€12.34\n' + + 'Fri 1 Aug 2025\tShared\tStart balance\t€1,234.56\n' + + '\t\t\t\n' + + 'Sat 2 Aug 2025\tShared\tExample bill\t-€1,234.50\n' + ); + expect(rows).toHaveLength(5); + expect(rows[4]).toEqual(['Sat 2 Aug 2025', 'Shared', 'Example bill', '-€1,234.50']); + expect(csvDate(rows[1][0])).toBe('2025-08-01'); + expect(csvDate(rows[4][0])).toBe('2025-08-02'); + expect(rows.slice(1).map((row) => importCents(row[3]))).toEqual([ + 12345, -1234, 123456, -123450 + ]); + expect( + parseCsv( + 'date,account,description,amount\n"Fri 1 Aug 2025",Shared,Start balance,"€1,234.56"' + )[1] + ).toEqual(['Fri 1 Aug 2025', 'Shared', 'Start balance', '€1,234.56']); + expect( + parseCsv('date,account,description,amount\nFri 1 Aug 2025,Shared,Start balance,€1,234.56')[1] + ).toHaveLength(5); + expect( + parseTransactionImport( + 'date\taccount\tdescription\tamount\n' + + 'Sat 2 Aug 2025\tShared\tExample bill\t-€1,234.50\n' + + '\t\t\t\n' + + 'Fri 1 Aug 2025\tDaily\tStart balance\t€123.45\n' + + 'Fri 1 Aug 2025\tBusiness\tStart balance\t-€12.34\n' + ).map((row) => [row.date, row.account, row.amountCents]) + ).toEqual([ + ['2025-08-01', 'Daily', 12345], + ['2025-08-01', 'Business', -1234], + ['2025-08-02', 'Shared', -123450] + ]); + expect(() => + parseTransactionImport( + 'date,account,description,amount\nFri 1 Aug 2025,Shared,Start balance,€1,234.56' + ) + ).toThrow('Quote values containing commas'); + }); }); diff --git a/src/lib/server/money-tracker.ts b/src/lib/server/money-tracker.ts index 3c032d6..7868cfc 100644 --- a/src/lib/server/money-tracker.ts +++ b/src/lib/server/money-tracker.ts @@ -23,6 +23,17 @@ export function cents(value: unknown, optional = false): number | null | undefin return Number.isSafeInteger(amount) && Math.abs(amount) <= 1_000_000_000 ? amount : undefined; } +export function importCents(value: unknown): number | undefined { + const raw = String(value ?? '') + .replace(/[€\s\u00a0]/g, '') + .trim(); + if (/^-?\d{1,3}(?:,\d{3})+(?:\.\d{1,2})?$/.test(raw)) + return cents(raw.replaceAll(',', '')) ?? undefined; + if (/^-?\d{1,3}(?:\.\d{3})+(?:,\d{1,2})?$/.test(raw)) + return cents(raw.replaceAll('.', '')) ?? undefined; + return cents(raw) ?? undefined; +} + export function validDate(value: unknown): string | null { const raw = String(value ?? '').trim(); if (!/^\d{4}-\d{2}-\d{2}$/.test(raw)) return null; @@ -49,7 +60,8 @@ export function invalid(message: string) { export function parseCsv(input: string): string[][] { const rows: string[][] = []; - const delimiter = input.split(/\r?\n/, 1)[0].includes(';') ? ';' : ','; + const header = input.split(/\r?\n/, 1)[0]; + const delimiter = header.includes('\t') ? '\t' : header.includes(';') ? ';' : ','; let row: string[] = []; let field = ''; let quoted = false; @@ -81,7 +93,78 @@ export function csvDate(value: string): string | null { const raw = value.trim(); if (validDate(raw)) return raw; const match = /^(\d{1,2})[/-](\d{1,2})[/-](\d{4})$/.exec(raw); - return match - ? validDate(`${match[3]}-${match[2].padStart(2, '0')}-${match[1].padStart(2, '0')}`) - : null; + if (match) + return validDate(`${match[3]}-${match[2].padStart(2, '0')}-${match[1].padStart(2, '0')}`); + const named = + /^(?:(Mon|Tue|Wed|Thu|Fri|Sat|Sun)\s+)?(\d{1,2})\s+([A-Za-z]{3,9})\s+(\d{4})$/i.exec(raw); + if (!named) return null; + const months = [ + 'jan', + 'feb', + 'mar', + 'apr', + 'may', + 'jun', + 'jul', + 'aug', + 'sep', + 'oct', + 'nov', + 'dec' + ]; + const month = months.indexOf(named[3].slice(0, 3).toLowerCase()) + 1; + if (!month) return null; + const iso = validDate( + `${named[4]}-${String(month).padStart(2, '0')}-${named[2].padStart(2, '0')}` + ); + if (!iso) return null; + if (named[1]) { + const weekdays = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat']; + if (weekdays[new Date(iso + 'T12:00:00Z').getUTCDay()] !== named[1].toLowerCase()) return null; + } + return iso; +} + +export type ImportedTransaction = { + date: string; + account: string; + description: string; + amountCents: number; +}; + +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) => + value + .replace(/^\uFEFF/, '') + .trim() + .toLowerCase() + ); + 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 parsed: ImportedTransaction[] = []; + for (const [index, row] of rows.entries()) { + if (row.length !== header.length) + throw new Error( + `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 description = (row[columns[2]] ?? '').trim(); + const amountCents = importCents(row[columns[3]]); + if ( + !date || + !account || + account.length > 80 || + !description || + description.length > 500 || + amountCents == null + ) + throw new Error(`Invalid data in transaction ${index + 1}. No rows were imported.`); + parsed.push({ date, account, description, amountCents }); + } + return parsed.sort((a, b) => a.date.localeCompare(b.date)); } diff --git a/src/routes/projects/money-tracker/+page.server.ts b/src/routes/projects/money-tracker/+page.server.ts index 1c96c35..33d4857 100644 --- a/src/routes/projects/money-tracker/+page.server.ts +++ b/src/routes/projects/money-tracker/+page.server.ts @@ -11,12 +11,12 @@ import { } from '$lib/server/db/schema'; import { requireAdmin } from '$lib/server/authorization'; import { + type ImportedTransaction, cadence, cents, - csvDate, invalid, optionalText, - parseCsv, + parseTransactionImport, positiveId, textField, utilityKind, @@ -371,42 +371,13 @@ export const actions: Actions = { const form = await event.request.formData(); const file = form.get('file'); if (!(file instanceof File) || !file.size || file.size > 2_000_000) - return invalid('Choose a CSV file under 2 MB.'); - let rows: string[][]; + return invalid('Choose a CSV or TSV file under 2 MB.'); + let parsed: ImportedTransaction[]; try { - rows = parseCsv(await file.text()); + parsed = parseTransactionImport(await file.text()); } catch (cause) { return invalid(cause instanceof Error ? cause.message : 'Invalid CSV.'); } - if (rows.length < 2 || rows.length > 20_001) - return invalid('The CSV must have a header and 1–20,000 rows.'); - const header = rows.shift()!.map((value) => - value - .replace(/^\uFEFF/, '') - .trim() - .toLowerCase() - ); - const columns = ['date', 'account', 'description', 'amount'].map((key) => header.indexOf(key)); - if (columns.some((index) => index < 0)) - return invalid('CSV headers must include date, account, description and amount.'); - const parsed: { date: string; account: string; description: string; amountCents: number }[] = - []; - for (const [index, row] of rows.entries()) { - const date = csvDate(row[columns[0]] ?? ''); - const account = (row[columns[1]] ?? '').trim(); - const description = (row[columns[2]] ?? '').trim(); - const amountCents = cents(row[columns[3]]); - if ( - !date || - !account || - account.length > 80 || - !description || - description.length > 500 || - amountCents == null - ) - return invalid(`Invalid data in CSV row ${index + 2}. No rows were imported.`); - parsed.push({ date, account, description, amountCents }); - } await db.transaction(async (tx) => { const existing = await tx .select() diff --git a/src/routes/projects/money-tracker/+page.svelte b/src/routes/projects/money-tracker/+page.svelte index 29bb276..c53c011 100644 --- a/src/routes/projects/money-tracker/+page.svelte +++ b/src/routes/projects/money-tracker/+page.svelte @@ -53,6 +53,7 @@ let showActionError = $state(false); let search = $state(''); let accountFilter = $state('all'); + let sortOrder = $state<'newest' | 'oldest'>('newest'); let pageNumber = $state(1); let selectedUtility = $state<(typeof kinds)[number]['id']>('electricity'); let dialog: HTMLDialogElement; @@ -61,15 +62,13 @@ let series = $derived(balanceSeries(data.accounts, data.transactions)); let recent = $derived([...data.transactions].reverse().slice(0, 8)); let filtered = $derived( - [...data.transactions] - .reverse() - .filter( - (row) => - (accountFilter === 'all' || String(row.accountId) === accountFilter) && - (!search || - row.description.toLowerCase().includes(search.toLowerCase()) || - accountName(row.accountId).toLowerCase().includes(search.toLowerCase())) - ) + (sortOrder === 'newest' ? [...data.transactions].reverse() : data.transactions).filter( + (row) => + (accountFilter === 'all' || String(row.accountId) === accountFilter) && + (!search || + row.description.toLowerCase().includes(search.toLowerCase()) || + accountName(row.accountId).toLowerCase().includes(search.toLowerCase())) + ) ); let shown = $derived(filtered.slice((pageNumber - 1) * 25, pageNumber * 25)); let monthlyRecurring = $derived( @@ -288,6 +287,13 @@ >{account.name}{/each} +
@@ -688,9 +694,11 @@ {:else if mode === 'import'}

- Upload a CSV with date,account,description,amount columns. Dates can be YYYY-MM-DD - or DD/MM/YYYY. Amounts are signed euros. New account names are created automatically. Importing - a file twice adds duplicate transactions. + 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. + 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.

CSV or TSV file