Money tracker: Knab CSV import, multi-bank linking, sum reconcile, history range
Some checks failed
Deploy production / deploy (push) Failing after 23s
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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user