fix csv import for money tracker
All checks were successful
Test and deploy staging / staging (push) Successful in 1m44s
All checks were successful
Test and deploy staging / staging (push) Successful in 1m44s
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user