Money tracker: Knab CSV import, multi-bank linking, sum reconcile, history range
Some checks failed
Deploy production / deploy (push) Failing after 23s

- Knab statement exports: per-account ids (knab:<account>:<Referentie>), routed by IBAN,
  transfers between own accounts merged into one tracker account are dropped (interest kept)
- Import: multiple files, hand-entered twins get the import id, hand entries equal to the
  sum of 2-3 payments within a day are replaced by them
- bank-link --bank revolut|abn|knab with per-bank default account; ASPSP notes printed
- bank-sync: longest history on an account first sync (--full to force), sum reconcile,
  per-session status so one bank failing does not mark the others
- Timeline: 1 year / 3 years / All history range
This commit is contained in:
2026-10-24 01:37:35 +00:00
parent 2cada618bf
commit 763d7881fb
13 changed files with 729 additions and 67 deletions

View File

@@ -13,7 +13,9 @@ import {
import { requireAdmin } from '#lib/server/authorization.js';
import {
type ImportedTransaction,
accountNumberKey,
cadence,
dropInternalTransfers,
cents,
invalid,
optionalText,
@@ -24,7 +26,7 @@ import {
utilityKind,
validDate
} from '#lib/server/money-tracker.js';
import { and, asc, desc, eq, gte, inArray } from 'drizzle-orm';
import { and, asc, desc, eq, gte, inArray, isNull, like, sql } from 'drizzle-orm';
import { error, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
@@ -457,19 +459,34 @@ export const actions: Actions = {
importTransactions: async (event) => {
const user = requireAdmin(event);
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 or TSV file under 2 MB.');
const files = form
.getAll('file')
.filter((file): file is File => file instanceof File && file.size > 0);
if (!files.length || files.length > 10 || files.some((file) => file.size > 2_000_000))
return invalid('Choose up to 10 CSV or TSV files, each 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[];
const parsed: ImportedTransaction[] = [];
try {
parsed = parseTransactionImport(await file.text(), chosen?.name);
for (const file of files)
parsed.push(...parseTransactionImport(await file.text(), chosen?.name));
} catch (cause) {
return invalid(cause instanceof Error ? cause.message : 'Invalid CSV.');
}
parsed.sort((a, b) => a.date.localeCompare(b.date));
// Bank statements that name their account go to the tracker account with that IBAN.
const byNumber = new Map(
accounts
.filter((account) => account.iban)
.map((account) => [accountNumberKey(account.iban), account])
);
if (!chosen)
for (const row of parsed) {
const match = row.sourceAccount && byNumber.get(accountNumberKey(row.sourceAccount));
if (match) row.account = match.name;
}
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))]) {
@@ -485,23 +502,96 @@ export const actions: Actions = {
accountId: ids.get(row.account.toLowerCase())!
}));
const accountIds = [...new Set(rows.map((row) => row.accountId))];
const existing = await tx
// Several bank accounts can feed one tracker account (Knab current + savings): transfers
// between them are internal and dropped on both sides. Interest and the rest stay.
const statementRows = await tx
.select({
id: moneyTransactions.id,
accountId: moneyTransactions.accountId,
date: moneyTransactions.date,
amountCents: moneyTransactions.amountCents,
externalId: moneyTransactions.externalId
externalId: moneyTransactions.externalId,
raw: moneyTransactions.raw
})
.from(moneyTransactions)
.where(
and(
eq(moneyTransactions.userId, user.id),
inArray(moneyTransactions.accountId, accountIds),
gte(moneyTransactions.date, addDays(rows[0].date, -1))
like(moneyTransactions.externalId, 'knab:%')
)
);
const own = new Map<number, Set<string>>();
const addOwn = (accountId: number, number: string | null | undefined) => {
if (!number) return;
own.set(accountId, (own.get(accountId) ?? new Set()).add(accountNumberKey(number)));
};
for (const account of accounts)
if (accountIds.includes(account.id)) addOwn(account.id, account.iban);
for (const row of rows) addOwn(row.accountId, row.sourceAccount);
for (const row of statementRows) addOwn(row.accountId, row.externalId?.split(':')[1]);
const kept: typeof rows = [];
let internal = 0;
for (const accountId of accountIds) {
const split = dropInternalTransfers(
rows.filter((row) => row.accountId === accountId),
own.get(accountId) ?? []
);
kept.push(...split.kept);
internal += split.internal.length;
}
kept.sort((a, b) => a.date.localeCompare(b.date));
// Legs imported before the other account's export was known.
const stale = statementRows
.filter((row) => {
const other = accountNumberKey(
(row.raw as { counterpartyAccount?: string } | null)?.counterpartyAccount
);
return other && own.get(row.accountId)?.has(other);
})
.map((row) => row.id);
if (stale.length)
await tx.delete(moneyTransactions).where(inArray(moneyTransactions.id, stale));
internal += stale.length;
if (!kept.length) return { imported: 0, duplicates: 0, internal, replaced: 0 };
const existing = await tx
.select({
id: moneyTransactions.id,
accountId: moneyTransactions.accountId,
date: moneyTransactions.date,
amountCents: moneyTransactions.amountCents,
externalId: moneyTransactions.externalId,
description: moneyTransactions.description
})
.from(moneyTransactions)
.where(
and(
eq(moneyTransactions.userId, user.id),
inArray(moneyTransactions.accountId, accountIds),
gte(moneyTransactions.date, addDays(kept[0].date, -1))
)
);
const { insert, duplicates, attach, replace } = planImport(kept, existing);
// Hand-entered twins keep their description but get the import id, so re-imports skip them.
for (const { id, row } of attach)
await tx
.update(moneyTransactions)
.set({
externalId: row.externalId,
counterparty: sql`coalesce(${moneyTransactions.counterparty}, ${row.counterparty ?? null})`
})
.where(and(eq(moneyTransactions.id, id), isNull(moneyTransactions.externalId)));
// Hand entries that were the sum of several imported payments are replaced by them.
if (replace.length)
await tx.delete(moneyTransactions).where(
and(
inArray(
moneyTransactions.id,
replace.map((item) => item.id)
),
isNull(moneyTransactions.externalId)
)
);
const { insert, duplicates } = planImport(rows, existing);
for (let i = 0; i < insert.length; i += 500) {
await tx
.insert(moneyTransactions)
@@ -513,12 +603,19 @@ export const actions: Actions = {
description: row.description,
amountCents: row.amountCents,
externalId: row.externalId,
counterparty: row.counterparty ?? null,
raw: row.sourceAccount
? {
account: row.sourceAccount,
counterpartyAccount: row.counterpartyAccount ?? null
}
: null,
source: 'csv'
}))
)
.onConflictDoNothing();
}
return { imported: insert.length, duplicates };
return { imported: insert.length, duplicates, internal, replaced: replace.length };
});
return result;
}

View File

@@ -25,6 +25,9 @@
import {
accountBalances,
accountColor,
clipSeries,
type HistoryRange,
historyStart,
monthlyCents,
type TimelineView,
utilityEstimate,
@@ -132,8 +135,17 @@
{ id: 'accounts', label: 'By account' }
];
let timelineView = $state<TimelineView>('total');
const historyRanges: { id: HistoryRange; label: string }[] = [
{ id: '1y', label: '1 year' },
{ id: '3y', label: '3 years' },
{ id: 'all', label: 'All' }
];
let historyRange = $state<HistoryRange>('1y');
let timeline = $derived(
wealthTimeline(data.accounts, actualTransactions, projection, data.today, timelineView)
clipSeries(
wealthTimeline(data.accounts, actualTransactions, projection, data.today, timelineView),
historyStart(historyRange, data.today)
)
);
let projectionEnd = $derived(projection.map((line) => line.points.at(-1)?.value ?? 0));
let wishTiers = $derived(
@@ -301,13 +313,22 @@
>Edit projection</Button
>
</div>
<Tabs.Root bind:value={timelineView} class="mb-3"
><Tabs.List
>{#each timelineViews as item (item.id)}<Tabs.Trigger value={item.id}
>{item.label}</Tabs.Trigger
>{/each}</Tabs.List
></Tabs.Root
>
<div class="mb-3 flex flex-wrap gap-2">
<Tabs.Root bind:value={timelineView}
><Tabs.List
>{#each timelineViews as item (item.id)}<Tabs.Trigger value={item.id}
>{item.label}</Tabs.Trigger
>{/each}</Tabs.List
></Tabs.Root
>
<Tabs.Root bind:value={historyRange} aria-label="History range"
><Tabs.List
>{#each historyRanges as item (item.id)}<Tabs.Trigger value={item.id}
>{item.label}</Tabs.Trigger
>{/each}</Tabs.List
></Tabs.Root
>
</div>
<MoneyChart
series={timeline}
title="Actual and projected wealth over time"
@@ -769,11 +790,14 @@
{:else if mode === 'import'}
<p class="dialog-copy">
Upload your CSV or tab-separated export with <code>date, account, description, amount</code>
columns, or a Revolut statement export (only completed EUR rows are imported, fees included).
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.
Rows already imported, or already entered with the same amount within a day, are skipped.
columns, a Revolut statement export (only completed EUR rows are imported, fees included), or
Knab exports. Knab files go to the account with that IBAN; pick an account to merge several Knab
accounts (current + savings) into one, and transfers between them are left out. 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. Rows already imported, or already entered
with the same amount within a day, are skipped.
</p>
<form
method="POST"
@@ -785,23 +809,31 @@
showActionError = result.type === 'failure';
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.`;
const done = result.data as
| { imported?: number; duplicates?: number; internal?: number; replaced?: number }
| undefined;
importNotice =
`Imported ${done?.imported ?? 0} transactions; skipped ${done?.duplicates ?? 0} duplicates` +
(done?.internal ? `, ${done.internal} transfers between your own accounts` : '') +
(done?.replaced
? `; replaced ${done.replaced} hand entries that were sums of payments`
: '') +
'.';
}
}}
>
<label class="field"
>CSV or TSV file<Input
>CSV or TSV files<Input
type="file"
name="file"
multiple
accept=".csv,.tsv,text/csv,text/tab-separated-values"
required
/></label
>
<label class="field"
>Account (for files without an account column, like Revolut statements)<NativeSelect
name="accountId"
bind:value={importAccountId}
>Account (for files without an account column, like Revolut statements, or to merge Knab
accounts)<NativeSelect name="accountId" bind:value={importAccountId}
><option value="">From the file</option
>{#each data.accounts as account (account.id)}<option value={String(account.id)}
>{account.name}</option