fix csv import for money tracker
All checks were successful
Test and deploy staging / staging (push) Successful in 1m44s

This commit is contained in:
2026-09-23 17:23:14 +00:00
parent 0638ead87f
commit 2466db74cd
4 changed files with 165 additions and 53 deletions

View File

@@ -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()

View File

@@ -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}</option
>{/each}</select
>
<label class="sr-only" for="transaction-order">Transaction order</label><select
id="transaction-order"
bind:value={sortOrder}
onchange={() => (pageNumber = 1)}
><option value="newest">Newest first</option><option value="oldest">Oldest first</option
></select
>
</div>
<div class="table-wrap">
<table>
@@ -688,9 +694,11 @@
</form>
{:else if mode === 'import'}
<p class="dialog-copy">
Upload a CSV with <code>date,account,description,amount</code> 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 <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.
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.
</p>
<form
method="POST"
@@ -704,7 +712,12 @@
}}
>
<label class="field"
>CSV file<input type="file" name="file" accept=".csv,text/csv" required /></label
>CSV or TSV file<input
type="file"
name="file"
accept=".csv,.tsv,text/csv,text/tab-separated-values"
required
/></label
>
<div class="dialog-footer">
<button type="button" class="button secondary" onclick={() => dialog.close()}>Cancel</button
@@ -745,7 +758,7 @@
required
maxlength="80"
value={active.name ?? ''}
placeholder="Revolut"
placeholder="Daily"
/></label
>
<label class="field"