feat(expenses): CSV import with per-row validation

- lib/server/csv-parse.ts: RFC 4180 parser (quoted fields, embedded commas
  and quotes, CRLF/LF, BOM), no extra dep
- services/expenses.ts: importExpenses() is transactional + all-or-nothing —
  if any row fails Zod-style validation, nothing is inserted
- /properties/[id]/expenses/import: upload page with per-line error list,
  sample CSV download at /template.csv, 5 MB cap
- 'Import CSV' button wired on the expenses tab
This commit is contained in:
2026-04-23 15:51:26 +07:00
parent f8478f5019
commit 911898507a
6 changed files with 466 additions and 5 deletions
@@ -71,10 +71,16 @@
</div>
{/if}
</div>
<button type="button" onclick={() => { showForm = !showForm; editingId = null; }}
class="rounded-md bg-primary-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-primary-700">
{showForm ? 'Cancel' : '+ New expense'}
</button>
<div class="flex shrink-0 items-center gap-2">
<a href="expenses/import"
class="rounded-md border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700">
Import CSV
</a>
<button type="button" onclick={() => { showForm = !showForm; editingId = null; }}
class="rounded-md bg-primary-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-primary-700">
{showForm ? 'Cancel' : '+ New expense'}
</button>
</div>
</section>
{#if form?.error}
@@ -0,0 +1,98 @@
import { fail } from '@sveltejs/kit';
import { and, eq } from 'drizzle-orm';
import { requireCompany } from '$lib/server/auth/guards';
import { db } from '$lib/server/db/client';
import { companies } from '$lib/server/db/schema/tenancy';
import { parseCsvDict } from '$lib/server/csv-parse';
import { importExpenses, type ImportRow } from '$lib/server/services/expenses';
import type { Actions, PageServerLoad } from './$types';
const MAX_BYTES = 5 * 1024 * 1024; // 5 MB
interface CompanySettings {
default_currency?: string;
}
function parseSettings(raw: string | null | undefined): CompanySettings {
if (!raw) return {};
try {
return JSON.parse(raw) as CompanySettings;
} catch {
return {};
}
}
export const load: PageServerLoad = async ({ locals }) => {
const { company } = requireCompany(locals);
const [row] = await db
.select({ settings: companies.settings })
.from(companies)
.where(eq(companies.id, company.id))
.limit(1);
return {
defaultCurrency: parseSettings(row?.settings ?? null).default_currency ?? 'USD'
};
};
export const actions: Actions = {
default: async ({ request, locals, params }) => {
const { user, company } = requireCompany(locals);
const form = await request.formData();
const file = form.get('file');
if (!(file instanceof File) || file.size === 0) {
return fail(400, { error: 'Pick a CSV file to upload.', rowErrors: [] });
}
if (file.size > MAX_BYTES) {
return fail(413, {
error: `File too large (max ${MAX_BYTES / 1024 / 1024} MB).`,
rowErrors: []
});
}
const text = await file.text();
let records: Record<string, string>[];
try {
records = parseCsvDict(text);
} catch (e) {
return fail(400, {
error: `CSV parse failed: ${(e as Error).message}`,
rowErrors: []
});
}
if (records.length === 0) {
return fail(400, {
error: 'No data rows found (is the file just a header?).',
rowErrors: []
});
}
const rows: ImportRow[] = records.map((values, idx) => ({
// +2 because: human 1-based line numbers, plus header row.
line: idx + 2,
values
}));
const [companyRow] = await db
.select({ settings: companies.settings })
.from(companies)
.where(eq(companies.id, company.id))
.limit(1);
const defCur =
parseSettings(companyRow?.settings ?? null).default_currency ?? 'USD';
try {
const result = await importExpenses(company.id, params.id, user.id, rows, defCur);
if (result.errors.length > 0) {
return fail(400, {
error: `${result.errors.length} row${result.errors.length === 1 ? '' : 's'} rejected — nothing imported. Fix the CSV and retry.`,
rowErrors: result.errors
});
}
return { ok: true, imported: result.imported };
} catch (e) {
return fail(400, { error: (e as Error).message, rowErrors: [] });
}
}
};
// silence unused helper
void and;
@@ -0,0 +1,84 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { PageData, ActionData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
let uploading = $state(false);
</script>
<div class="mx-auto max-w-3xl space-y-6">
<div>
<a href="../" class="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">← back to expenses</a>
<h1 class="mt-1 text-2xl font-semibold text-gray-900 dark:text-gray-100">Import expenses from CSV</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Upload a CSV exported from your bank, accounting tool, or a hand-typed sheet.
Validation is all-or-nothing — if any row is bad, nothing is imported and
you'll get a list of line numbers to fix.
</p>
</div>
<div class="rounded-lg border border-gray-200 bg-white p-4 text-sm dark:border-gray-700 dark:bg-gray-800">
<p class="font-medium text-gray-900 dark:text-gray-100">Expected format</p>
<p class="mt-1 text-gray-600 dark:text-gray-300">
First line is the header. Required columns:
<code class="font-mono">date</code>,
<code class="font-mono">kind</code>,
<code class="font-mono">amount</code>.
Optional:
<code class="font-mono">currency</code> (falls back to <code class="font-mono">{data.defaultCurrency}</code>),
<code class="font-mono">period_start</code>,
<code class="font-mono">period_end</code>,
<code class="font-mono">vendor</code>,
<code class="font-mono">reference</code>,
<code class="font-mono">notes</code>.
</p>
<p class="mt-2 text-gray-600 dark:text-gray-300">
Dates are <code class="font-mono">YYYY-MM-DD</code>. Valid kinds:
<code class="font-mono">water, electricity, gas, internet, phone, cable, waste, maintenance, repair, cleaning, insurance, tax, rent, other</code>.
</p>
<a href="template.csv" download
class="mt-3 inline-block text-sm text-primary-600 hover:underline dark:text-primary-400">
Download template CSV
</a>
</div>
<form method="post" enctype="multipart/form-data"
use:enhance={() => {
uploading = true;
return ({ update }) => update().finally(() => (uploading = false));
}}
class="space-y-4 rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800">
{#if form?.error}
<div class="rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-700/50 dark:bg-red-900/20 dark:text-red-300">
<p class="font-medium">{form.error}</p>
{#if form.rowErrors && form.rowErrors.length > 0}
<ul class="mt-2 list-disc space-y-0.5 pl-5 text-xs">
{#each form.rowErrors as err}
<li><span class="font-mono">line {err.line}</span>{err.message}</li>
{/each}
</ul>
{/if}
</div>
{:else if form?.ok}
<div class="rounded-md border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-700 dark:border-emerald-700/50 dark:bg-emerald-900/20 dark:text-emerald-300">
Imported {form.imported} expense{form.imported === 1 ? '' : 's'}.
<a href="../" class="ml-2 underline">View them →</a>
</div>
{/if}
<label class="block">
<span class="block text-sm font-medium text-gray-700 dark:text-gray-300">CSV file <span class="text-red-500">*</span></span>
<input name="file" type="file" accept=".csv,text/csv" required
class="mt-1 block w-full text-sm text-gray-700 file:mr-3 file:rounded-md file:border-0 file:bg-primary-50 file:px-3 file:py-1.5 file:text-sm file:font-medium file:text-primary-700 hover:file:bg-primary-100 dark:text-gray-300 dark:file:bg-primary-900/30 dark:file:text-primary-300" />
<p class="mt-1 text-xs text-gray-400">Max 5 MB.</p>
</label>
<div class="flex justify-end gap-3 border-t border-gray-200 pt-4 dark:border-gray-700">
<a href="../" class="text-sm text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100">Cancel</a>
<button type="submit" disabled={uploading}
class="rounded-md bg-primary-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-primary-700 disabled:opacity-60">
{uploading ? 'Uploading…' : 'Import'}
</button>
</div>
</form>
</div>
@@ -0,0 +1,48 @@
import { requireCompany } from '$lib/server/auth/guards';
import { csvResponse, toCsv } from '$lib/server/csv';
import type { RequestHandler } from './$types';
// Tiny sample CSV so the user has a well-formed example to edit rather than
// guessing the column names and date format.
export const GET: RequestHandler = async ({ locals }) => {
requireCompany(locals);
const today = new Date().toISOString().slice(0, 10);
const body = toCsv(
[
{
date: today,
kind: 'electricity',
amount: '1234.56',
currency: 'THB',
period_start: '',
period_end: '',
vendor: 'MEA',
reference: 'INV-2026-0001',
notes: ''
},
{
date: today,
kind: 'water',
amount: '345.00',
currency: 'THB',
period_start: '',
period_end: '',
vendor: 'MWA',
reference: '',
notes: ''
}
],
[
'date',
'kind',
'amount',
'currency',
'period_start',
'period_end',
'vendor',
'reference',
'notes'
]
);
return csvResponse('expenses-template.csv', body);
};