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

@@ -257,3 +257,27 @@ describe('money tracker calculations', () => {
).toBe(30);
});
});
describe('history range', () => {
it('starts lines at the range start with the balance they had then', async () => {
const { clipSeries, historyStart } = await import('./charts');
expect(historyStart('1y', '2026-10-24')).toBe('2025-10-24');
expect(historyStart('all', '2026-10-24')).toBeNull();
const series = [
{
label: 'Knab',
color: 'x',
points: [
{ date: '2021-08-24', value: 100 },
{ date: '2025-01-01', value: 50 },
{ date: '2026-01-01', value: 70 }
]
}
];
expect(clipSeries(series, '2025-10-24')[0].points).toEqual([
{ date: '2025-10-24', value: 50 },
{ date: '2026-01-01', value: 70 }
]);
expect(clipSeries(series, null)).toBe(series);
});
});

View File

@@ -306,3 +306,28 @@ export function utilityEstimate(
differenceCents: advancesCents - estimatedCents
};
}
export type HistoryRange = '1y' | '3y' | 'all';
/** First date shown for a history range (null = everything). */
export function historyStart(range: HistoryRange, today: string): string | null {
if (range === 'all') return null;
const years = range === '1y' ? 1 : 3;
return `${Number(today.slice(0, 4)) - years}${today.slice(4)}`;
}
/**
* Hides history before `from`, starting each line at `from` with the balance it had then,
* so a long bank history doesn't squash the recent months.
*/
export function clipSeries(series: ChartSeries[], from: string | null): ChartSeries[] {
if (!from) return series;
return series.map((line) => {
const before = line.points.filter((point) => point.date < from);
const after = line.points.filter((point) => point.date >= from);
const carried = before.at(-1);
const points =
carried && after[0]?.date !== from ? [{ date: from, value: carried.value }, ...after] : after;
return { ...line, points };
});
}

View File

@@ -120,6 +120,22 @@ describe('account mapping', () => {
['b', 'Revolut']
]);
});
it('maps another bank to its own tracker account without the joint heuristic', () => {
const plan = planAccountMapping(
[
{
uid: 'n',
account_id: { iban: 'NL01ABNA0123456789' },
name: 'S Runge & J Kim',
currency: 'EUR'
}
],
[...tracker, { id: 7, name: 'ABN Amro', scope: 'personal', iban: null, external_uid: null }],
{},
{ defaultAccount: 'ABN Amro', joint: false }
);
expect(plan.map((p) => [p.bank.uid, p.tracker?.name ?? null])).toEqual([['n', 'ABN Amro']]);
});
});
describe('enable banking client', () => {

View File

@@ -11,6 +11,7 @@ import type {
SessionResponse
} from './enable-banking';
import { EnableBankingError } from './enable-banking';
import { findSumMatch } from './reconcile';
export type MappedTransaction = {
externalId: string;
@@ -86,6 +87,9 @@ function dayDiff(a: string, b: string) {
return Math.abs(Date.parse(a + 'T00:00:00Z') - Date.parse(b + 'T00:00:00Z')) / 86_400_000;
}
export const isOpeningBalance = (description: string) =>
/^(start|opening) balance/i.test(description.trim());
export type ExistingRow = { id: number; date: string; amountCents: number; description: string };
/**
@@ -143,13 +147,22 @@ type TrackerAccount = {
/**
* Decides which tracker account each bank account feeds. Order: explicit override
* (uid/IBAN -> tracker account name), same IBAN, joint heuristic -> the joint-scoped
* account, first remaining EUR current account -> "Revolut". Others are skipped.
* account (Revolut only), first remaining EUR current account -> `defaultAccount`
* ("Revolut" unless given). Others are skipped.
*/
export function planAccountMapping(
bankAccounts: BankAccount[],
tracker: TrackerAccount[],
overrides: Record<string, string> = {}
overrides: Record<string, string> = {},
options: {
/** Tracker account for the first personal EUR current account (default "Revolut"). */
defaultAccount?: string;
/** Route joint-looking accounts to the joint-scoped tracker account (Revolut only). */
joint?: boolean;
} = {}
) {
const defaultAccount = (options.defaultAccount ?? 'Revolut').toLowerCase();
const joint = options.joint ?? defaultAccount === 'revolut';
const byName = new Map(tracker.map((a) => [a.name.toLowerCase(), a]));
const used = new Set<number>();
const plan: { bank: BankAccount; tracker: TrackerAccount | null; reason: string }[] = [];
@@ -188,6 +201,7 @@ export function planAccountMapping(
const rest = pending.filter(
(bank) =>
!(
joint &&
eur(bank) &&
looksJoint(bank) &&
take(
@@ -201,7 +215,7 @@ export function planAccountMapping(
if (
eur(bank) &&
bank.cash_account_type !== 'SVGS' &&
take(bank, byName.get('revolut'), 'personal EUR account')
take(bank, byName.get(defaultAccount), 'personal EUR account')
)
continue;
plan.push({ bank, tracker: null, reason: 'no match (use --map uid=Account)' });
@@ -274,6 +288,8 @@ export type SyncOptions = {
/** Match against the whole history of hand-entered rows (one-time), not just the window. */
reconcile?: boolean;
from?: string;
/** Fetch the longest history the bank serves (automatic on an account's first sync). */
full?: boolean;
accountName?: string;
log?: (line: string) => void;
today?: string;
@@ -285,7 +301,11 @@ export type AccountSyncResult = {
booked: number;
inserted: number;
matched: number;
/** Hand-entered rows replaced because they equal the sum of 2-3 bank payments. */
absorbed: number;
alreadyKnown: number;
/** Oldest booked date the bank returned. */
historyFrom?: string;
balance?: string;
/** Bank balance minus the tracker's own rows, absorbed by the adjustment row. */
adjustmentCents?: number;
@@ -318,6 +338,7 @@ export async function syncBankAccounts(
booked: 0,
inserted: 0,
matched: 0,
absorbed: 0,
alreadyKnown: 0
};
results.push(result);
@@ -325,22 +346,31 @@ export async function syncBankAccounts(
const [{ last }] = await sql<{ last: string | null }[]>`
select max(date)::text as last from money.transactions
where account_id = ${account.id} and source = 'enablebanking'`;
const [{ first }] = await sql<{ first: string | null }[]>`
select min(date)::text as first from money.transactions where account_id = ${account.id}`;
const dateFrom = options.from ?? (last ? addDays(last, -7) : (first ?? addDays(today, -89)));
// First sync of an account (or --full): ask for the longest history the bank serves.
const longest = options.full || (!last && !options.from);
const dateFrom = options.from ?? (last ? addDays(last, -7) : undefined);
let raw: BankTransaction[];
try {
raw = await client.transactions(account.external_uid, { dateFrom });
raw = await client.transactions(
account.external_uid,
longest ? { strategy: 'longest', dateFrom: options.from } : { dateFrom }
);
} catch (error) {
// Many banks only serve ~90 days of history after the first hour of consent.
if (error instanceof EnableBankingError && error.code === 'WRONG_TRANSACTIONS_PERIOD') {
log(`${account.name}: ${dateFrom} is too far back; retrying from 89 days ago`);
log(
`${account.name}: ${dateFrom ?? 'full history'} is not available; retrying from 89 days ago`
);
raw = await client.transactions(account.external_uid, { dateFrom: addDays(today, -89) });
} else throw error;
}
result.fetched = raw.length;
const mapped = raw.map(mapBankTransaction).filter((t): t is MappedTransaction => t !== null);
result.booked = mapped.length;
result.historyFrom = mapped.reduce<string | undefined>(
(min, t) => (!min || t.date < min ? t.date : min),
undefined
);
if (!mapped.length) {
await syncBalance(sql, client, account, result, options, today, log);
continue;
@@ -355,11 +385,14 @@ export async function syncBankAccounts(
).map((r) => r.external_id)
);
const minDate = mapped.reduce((m, t) => (t.date < m ? t.date : m), mapped[0].date);
const candidates = await sql<ExistingRow[]>`
select id, date::text as date, amount_cents as "amountCents", description
from money.transactions
where account_id = ${account.id} and external_id is null
and date >= ${options.reconcile ? '0001-01-01' : addDays(minDate, -1)}`;
// Opening-balance rows are not payments; never match or replace them.
const candidates = (
await sql<ExistingRow[]>`
select id, date::text as date, amount_cents as "amountCents", description
from money.transactions
where account_id = ${account.id} and external_id is null
and date >= ${options.reconcile ? '0001-01-01' : addDays(minDate, -1)}`
).filter((row) => !isOpeningBalance(row.description));
const used = new Set<number>();
const inserts: MappedTransaction[] = [];
const updates: { id: number; tx: MappedTransaction }[] = [];
@@ -376,10 +409,31 @@ export async function syncBankAccounts(
updates.push({ id: match.id, tx });
} else inserts.push(tx);
}
// A hand entry can stand for several bank payments ("To Joint 209" = 200 + 3 + 6):
// replace it with the real payments instead of keeping both.
const absorbed: { row: ExistingRow; parts: MappedTransaction[] }[] = [];
const takenInserts = new Set<number>();
for (const row of candidates) {
if (used.has(row.id)) continue;
const parts = findSumMatch(row, inserts, takenInserts);
if (!parts) continue;
parts.forEach((index) => takenInserts.add(index));
used.add(row.id);
absorbed.push({ row, parts: parts.map((index) => inserts[index]) });
}
for (const { row, parts } of absorbed)
log(
`${account.name}: hand entry #${row.id} ${row.date} "${row.description}" ${row.amountCents} = ` +
parts.map((p) => `${p.date} ${p.description} ${p.amountCents}`).join(' + ') +
(options.dryRun ? ' (would replace)' : ' (replaced)')
);
result.matched = updates.length;
result.absorbed = absorbed.length;
result.inserted = inserts.length;
if (!options.dryRun) {
await sql.begin(async (q) => {
for (const { row } of absorbed)
await q`delete from money.transactions where id = ${row.id} and external_id is null`;
for (const { id, tx } of updates) {
// Keep Stan's own description/category; attach the bank identity.
await q`
@@ -407,14 +461,22 @@ export async function syncBankAccounts(
}
}
if (!options.dryRun) {
const failed = results.filter((r) => r.error).map((r) => `${r.account}: ${r.error}`);
const expired = failed.some((f) => /EXPIRED_SESSION|REVOKED_SESSION|CLOSED_SESSION/.test(f));
await sql`
update money.bank_sessions set last_synced_at = now(),
last_error = ${failed.length ? failed.join('\n').slice(0, 2000) : null},
status = case when ${expired} then 'expired'
when valid_until is not null and valid_until < now() then 'expired' else status end
where status = 'authorized'`;
// Each bank has its own session; only touch the sessions whose accounts were synced.
const sessions = await sql<{ id: number; accounts: { uid: string | null }[] }[]>`
select id, accounts from money.bank_sessions where status = 'authorized'`;
for (const session of sessions) {
const uids = new Set(session.accounts.map((a) => a.uid).filter(Boolean));
const mine = results.filter((_, index) => uids.has(selected[index].external_uid));
if (!mine.length) continue;
const failed = mine.filter((r) => r.error).map((r) => `${r.account}: ${r.error}`);
const expired = failed.some((f) => /EXPIRED_SESSION|REVOKED_SESSION|CLOSED_SESSION/.test(f));
await sql`
update money.bank_sessions set last_synced_at = now(),
last_error = ${failed.length ? failed.join('\n').slice(0, 2000) : null},
status = case when ${expired} then 'expired'
when valid_until is not null and valid_until < now() then 'expired' else status end
where id = ${session.id}`;
}
}
return results;
}

View File

@@ -172,6 +172,9 @@ export function createEnableBankingClient(config: EnableBankingConfig) {
country: string;
maximum_consent_validity: number;
psu_types: string[];
bic?: string;
beta?: boolean;
required_psu_headers?: string[];
}[];
}>('GET', `/aspsps?${new URLSearchParams({ country, psu_type: psuType, service: 'AIS' })}`),
startAuth: (input: {
@@ -211,6 +214,8 @@ export function createEnableBankingClient(config: EnableBankingConfig) {
dateFrom?: string;
dateTo?: string;
status?: 'BOOK' | 'PDNG';
/** 'longest' asks Enable Banking to find the longest period the bank serves. */
strategy?: 'default' | 'longest';
maxPages?: number;
} = {}
) {
@@ -221,6 +226,7 @@ export function createEnableBankingClient(config: EnableBankingConfig) {
if (options.dateFrom) query.set('date_from', options.dateFrom);
if (options.dateTo) query.set('date_to', options.dateTo);
if (options.status) query.set('transaction_status', options.status);
if (options.strategy) query.set('strategy', options.strategy);
if (continuationKey) query.set('continuation_key', continuationKey);
const result = await request<{
transactions: BankTransaction[];

View File

@@ -1,7 +1,9 @@
import { describe, expect, it } from 'vitest';
import {
accountNumberKey,
cents,
csvDate,
dropInternalTransfers,
importCents,
parseCsv,
parseTransactionImport,
@@ -117,3 +119,150 @@ describe('revolut statements and import dedupe', () => {
expect(duplicates).toBe(2);
});
});
describe('knab exports', () => {
const header =
'Rekeningnummer;Transactiedatum;Valutacode;CreditDebet;Bedrag;Tegenrekeningnummer;Tegenrekeninghouder;Valutadatum;Betaalwijze;Omschrijving;Type betaling;Machtigingsnummer;Incassant ID;Referentie;Boekdatum;\n';
const line = (
account: string,
date: string,
cd: string,
amount: string,
other: string,
name: string,
method: string,
note: string,
ref: string
) =>
`"${account}";"${date}";"EUR";"${cd}";"${amount}";"${other}";"${name}";"${date}";"${method}";"${note}";"";"";"";"${ref}";"${date}";\n`;
const current =
header +
line(
'NL98KNAB0408449136',
'01-10-2026',
'Afschrijvingen',
'1',
'80000208',
'Knab',
'Pakketkosten',
'Pakketkosten',
'R1'
) +
line(
'NL98KNAB0408449136',
'02-10-2026',
'Bijschrijvingen',
'1250,5',
'30581727',
'Spaar',
'Ontvangen betaling',
'',
'SHARED'
) +
line(
'NL98KNAB0408449136',
'03-10-2026',
'Afschrijvingen',
'2,35',
'NL11INGB0000000001',
'NS',
'iDEAL',
'Treinkaartje',
'R3'
);
const savings =
header +
line(
'30581727',
'02-10-2026',
'Afschrijvingen',
'1250,5',
'NL98KNAB0408449136',
'S Runge',
'Overboeking',
'',
'SHARED'
) +
line('30581727', '30-09-2026', 'Bijschrijvingen', '0,42', '', '', 'Rente', 'Rente', 'R5');
it('parses signs, decimal commas and descriptions', () => {
const rows = parseTransactionImport(current, 'Knab');
expect(
rows.map((row) => [row.date, row.description, row.amountCents, row.counterparty])
).toEqual([
['2026-10-01', 'Knab: Pakketkosten', -100, 'Knab'],
['2026-10-02', 'Spaar', 125050, 'Spaar'],
['2026-10-03', 'NS: Treinkaartje', -235, 'NS']
]);
expect(parseTransactionImport(current)[0].account).toBe('NL98KNAB0408449136');
});
it('scopes ids to the booked account because Referentie repeats across accounts', () => {
const a = parseTransactionImport(current, 'Knab').find((row) => row.amountCents === 125050)!;
const b = parseTransactionImport(savings, 'Knab').find((row) => row.amountCents === -125050)!;
expect(a.externalId).toBe('knab:408449136:SHARED');
expect(b.externalId).toBe('knab:30581727:SHARED');
});
it('drops transfers between own accounts but keeps interest', () => {
const rows = [
...parseTransactionImport(current, 'Knab'),
...parseTransactionImport(savings, 'Knab')
];
const { kept, internal } = dropInternalTransfers(rows, ['NL98KNAB0408449136', '30581727']);
expect(internal.map((row) => row.amountCents)).toEqual([125050, -125050]);
expect(kept.map((row) => row.amountCents)).toEqual([-100, -235, 42]);
expect(accountNumberKey('NL98 KNAB 0408 4491 36')).toBe(accountNumberKey('408449136'));
});
it('rejects malformed amounts', () => {
const bad =
header +
line('NL98KNAB0408449136', '01-10-2026', 'Afschrijvingen', '1.234,00', '', '', '', '', 'X');
expect(() => parseTransactionImport(bad, 'Knab')).toThrow('Invalid data in Knab row 1');
});
});
describe('import reconcile', () => {
it('attaches hand-entered twins and replaces hand sums of several payments', () => {
const rows = [
{ accountId: 2, date: '2026-03-01', amountCents: -20000, externalId: 'k:1' },
{ accountId: 2, date: '2026-03-01', amountCents: -300, externalId: 'k:2' },
{ accountId: 2, date: '2026-03-02', amountCents: -600, externalId: 'k:3' },
{ accountId: 2, date: '2026-03-03', amountCents: 5000, externalId: 'k:4' }
];
const existing = [
{
id: 1,
accountId: 2,
date: '2026-03-01',
amountCents: -20900,
externalId: null,
description: 'To Joint'
},
{
id: 2,
accountId: 2,
date: '2026-03-03',
amountCents: 5000,
externalId: null,
description: 'From Revolut'
},
{
id: 3,
accountId: 2,
date: '2026-03-01',
amountCents: -20300,
externalId: null,
description: 'Start balance'
}
];
const { insert, duplicates, attach, replace } = planImport(rows, existing);
expect(duplicates).toBe(1);
expect(attach.map((item) => [item.id, item.row.externalId])).toEqual([[2, 'k:4']]);
expect(insert.map((row) => row.externalId)).toEqual(['k:1', 'k:2', 'k:3']);
expect(
replace.map((item) => [item.id, item.parts.map((row) => row.externalId).sort()])
).toEqual([[1, ['k:1', 'k:2', 'k:3']]]);
});
});

View File

@@ -1,5 +1,6 @@
import { fail } from '@sveltejs/kit';
import { createHash } from 'node:crypto';
import { findSumMatch } from './reconcile';
export const utilityKinds = ['electricity', 'gas', 'water'] as const;
export const cadences = ['weekly', 'monthly', 'yearly'] as const;
@@ -131,11 +132,48 @@ export type ImportedTransaction = {
account: string;
description: string;
amountCents: number;
/** Stable per-row id ("csv:<hash>") so importing the same file twice adds nothing. */
/** Stable per-row id ("csv:<hash>", "knab:<account>:<ref>") so re-imports add nothing. */
externalId: string;
counterparty?: string | null;
/** Bank account the row was booked on (bank statements that say so, e.g. Knab). */
sourceAccount?: string;
/** The other side's account number, used to recognise transfers between own accounts. */
counterpartyAccount?: string;
};
export type ImportFormat = 'tracker' | 'revolut';
export type ImportFormat = 'tracker' | 'revolut' | 'knab';
/**
* Canonical account number: Dutch IBANs and the bare numbers Knab uses for its savings
* accounts compare equal (NL98KNAB0408449136 ~ 408449136).
*/
export function accountNumberKey(value: string | null | undefined) {
const raw = (value ?? '').replace(/[\s.-]/g, '').toUpperCase();
const iban = /^[A-Z]{2}\d{2}[A-Z]{4}(\d{6,})$/.exec(raw);
return (iban ? iban[1] : raw).replace(/^0+/, '');
}
/** Knab "Exporteer transacties" CSV (semicolon separated, Dutch headers). */
export function isKnabHeader(header: string[]) {
return ['rekeningnummer', 'transactiedatum', 'creditdebet', 'bedrag', 'referentie'].every((key) =>
header.includes(key)
);
}
/** Drops transfers between the given own accounts (both legs), keeping everything else. */
export function dropInternalTransfers<T extends ImportedTransaction>(
rows: T[],
own: Iterable<string>
) {
const keys = new Set([...own].map(accountNumberKey).filter(Boolean));
const kept: T[] = [];
const internal: T[] = [];
for (const row of rows) {
const other = accountNumberKey(row.counterpartyAccount);
(other && keys.has(other) ? internal : kept).push(row);
}
return { kept, internal };
}
function headerOf(rows: string[][]) {
return rows[0].map((value) =>
@@ -178,6 +216,7 @@ export function parseTransactionImport(
const header = headerOf(rows);
rows.shift();
const seen = new Map<string, number>();
if (isKnabHeader(header)) return parseKnab(header, rows, defaultAccount, seen);
if (isRevolutHeader(header)) {
const account = defaultAccount?.trim();
if (!account) throw new Error('Choose which account this Revolut statement belongs to.');
@@ -249,14 +288,74 @@ export function parseTransactionImport(
return parsed.sort((a, b) => a.date.localeCompare(b.date));
}
function parseKnab(
header: string[],
rows: string[][],
defaultAccount: string | undefined,
seen: Map<string, number>
) {
const col = (key: string) => header.indexOf(key);
const cell = (row: string[], key: string) => (col(key) >= 0 ? (row[col(key)] ?? '').trim() : '');
const parsed: ImportedTransaction[] = [];
for (const [index, row] of rows.entries()) {
if (row.every((value) => !value.trim())) continue;
const sourceAccount = cell(row, 'rekeningnummer').replace(/\s/g, '').toUpperCase();
const currency = cell(row, 'valutacode').toUpperCase();
if (currency && currency !== 'EUR') continue;
const date = csvDate(cell(row, 'transactiedatum'));
// Knab writes plain amounts with a decimal comma and never a thousands separator.
const amount = /^\d{1,9}(?:,\d{1,2})?$/.test(cell(row, 'bedrag'))
? cents(cell(row, 'bedrag'))
: undefined;
const direction = cell(row, 'creditdebet').toLowerCase();
const debit = direction.startsWith('af') || direction === 'd' || direction === 'debet';
const credit = direction.startsWith('bij') || direction === 'c' || direction === 'credit';
if (!sourceAccount || !date || amount == null || (!debit && !credit))
throw new Error(`Invalid data in Knab row ${index + 1}. No rows were imported.`);
if (!amount) continue;
const counterparty = cell(row, 'tegenrekeninghouder');
const note = cell(row, 'omschrijving');
const method = cell(row, 'betaalwijze');
const description = (
counterparty && note && note.toLowerCase() !== counterparty.toLowerCase()
? `${counterparty}: ${note}`
: counterparty || note || method || 'Knab'
).slice(0, 500);
const reference = cell(row, 'referentie');
const account = defaultAccount?.trim() || sourceAccount;
// Referentie repeats across a customer's accounts (both legs of a transfer), so the
// id is scoped to the account the row was booked on.
const externalId = reference
? `knab:${accountNumberKey(sourceAccount)}:${reference}`
: importId(['knab', sourceAccount, date, amount, description], seen);
parsed.push({
date,
account,
description,
amountCents: debit ? -amount : amount,
externalId,
counterparty: counterparty || null,
sourceAccount,
counterpartyAccount: cell(row, 'tegenrekeningnummer') || undefined
});
}
if (!parsed.length) throw new Error('No EUR transactions found in this Knab export.');
return parsed.sort((a, b) => a.date.localeCompare(b.date));
}
export type ExistingImportRow = {
id: number;
accountId: number;
date: string;
amountCents: number;
externalId: string | null;
/** Hand-entered rows (no external id) can be replaced; opening balances never. */
description?: string;
};
const isOpeningBalanceRow = (description: string | undefined) =>
/^(start|opening) balance/i.test((description ?? '').trim());
function daysApart(a: string, b: string) {
return Math.abs(Date.parse(a + 'T00:00:00Z') - Date.parse(b + 'T00:00:00Z')) / 86_400_000;
}
@@ -264,7 +363,10 @@ function daysApart(a: string, b: string) {
/**
* Splits imported rows into new ones and duplicates: same import id already stored, or an
* existing row on the same account with the same amount within ±1 day (each existing row
* can only absorb one imported row).
* can only absorb one imported row). Hand-entered matches are returned in `attach` so the
* caller can give them the import id. A hand-entered row equal to the sum of 2-3 new rows
* within a day ("To Joint 209" = 200 + 3 + 6) is returned in `replace`: the new rows go in
* and the hand row should be deleted.
*/
export function planImport<
T extends { accountId: number; date: string; amountCents: number; externalId: string }
@@ -274,11 +376,13 @@ export function planImport<
);
const byKey = new Map<string, ExistingImportRow[]>();
for (const row of existing) {
if (!row.externalId && isOpeningBalanceRow(row.description)) continue;
const key = `${row.accountId}:${row.amountCents}`;
byKey.set(key, [...(byKey.get(key) ?? []), row]);
}
const claimed = new Set<number>();
const insert: T[] = [];
const attach: { id: number; row: T }[] = [];
let duplicates = 0;
for (const row of rows) {
const idKey = `${row.accountId}:${row.externalId}`;
@@ -292,10 +396,24 @@ export function planImport<
if (match) {
claimed.add(match.id);
duplicates++;
if (!match.externalId) attach.push({ id: match.id, row });
continue;
}
ids.add(idKey);
insert.push(row);
}
return { insert, duplicates };
const replace: { id: number; parts: T[] }[] = [];
const taken = new Set<number>();
for (const hand of existing) {
if (hand.externalId || claimed.has(hand.id) || isOpeningBalanceRow(hand.description)) continue;
const pool = insert.map((row) =>
row.accountId === hand.accountId ? row : { ...row, amountCents: 0 }
);
const parts = findSumMatch(hand, pool, taken);
if (!parts) continue;
parts.forEach((index) => taken.add(index));
claimed.add(hand.id);
replace.push({ id: hand.id, parts: parts.map((index) => insert[index]) });
}
return { insert, duplicates, attach, replace };
}

View File

@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest';
import { findSumMatch } from './reconcile';
describe('sum matching', () => {
const pool = [
{ date: '2026-03-01', amountCents: -20000 },
{ date: '2026-03-01', amountCents: -300 },
{ date: '2026-03-02', amountCents: -600 },
{ date: '2026-03-05', amountCents: -900 },
{ date: '2026-03-01', amountCents: 900 }
];
it('finds the payments a hand entry stands for', () => {
expect(
findSumMatch({ date: '2026-03-01', amountCents: -20900 }, pool, new Set())?.sort()
).toEqual([0, 1, 2]);
expect(findSumMatch({ date: '2026-03-01', amountCents: -20300 }, pool, new Set())).toEqual([
0, 1
]);
});
it('ignores used rows, other days and opposite signs', () => {
expect(
findSumMatch({ date: '2026-03-01', amountCents: -20900 }, pool, new Set([2]))
).toBeNull();
expect(findSumMatch({ date: '2026-03-05', amountCents: -1200 }, pool, new Set())).toBeNull();
expect(findSumMatch({ date: '2026-03-01', amountCents: -19100 }, pool, new Set())).toBeNull();
});
it('needs at least two parts', () => {
expect(findSumMatch({ date: '2026-03-01', amountCents: -300 }, pool, new Set())).toBeNull();
});
});

View File

@@ -0,0 +1,49 @@
// Shared reconcile helpers for bank sync and CSV import: a hand-entered row often stands
// for several bank payments (e.g. "To Joint 209" = 200 + 3 + 6 on the same day).
export type DatedAmount = { date: string; amountCents: number };
export function daysBetween(a: string, b: string) {
return Math.abs(Date.parse(a + 'T00:00:00Z') - Date.parse(b + 'T00:00:00Z')) / 86_400_000;
}
/**
* Finds 2..maxParts unused pool entries, each within ±maxDays of `target.date`, whose
* amounts add up exactly to `target.amountCents`. Prefers fewer parts, then the smallest
* total date distance. Returns pool indexes, or null. Every part must have the same sign as
* the target, which keeps coincidental matches rare.
*/
export function findSumMatch(
target: DatedAmount,
pool: DatedAmount[],
used: Set<number>,
maxParts = 3,
maxDays = 1
): number[] | null {
const near = pool
.map((row, index) => ({ row, index, distance: daysBetween(row.date, target.date) }))
.filter(
({ row, index, distance }) =>
!used.has(index) &&
Math.sign(row.amountCents) === Math.sign(target.amountCents) &&
row.amountCents !== 0 &&
distance <= maxDays
);
// Same-day batches are small; cap the search so a busy day can't explode combinatorially.
if (near.length > 40) near.length = 40;
let best: { indexes: number[]; distance: number } | null = null;
const search = (size: number, start: number, chosen: typeof near, sum: number) => {
if (chosen.length === size) {
if (sum !== target.amountCents) return;
const distance = chosen.reduce((total, item) => total + item.distance, 0);
if (!best || distance < best.distance)
best = { indexes: chosen.map((item) => item.index), distance };
return;
}
for (let i = start; i < near.length; i++)
search(size, i + 1, [...chosen, near[i]], sum + near[i].row.amountCents);
};
for (let size = 2; size <= maxParts && !best; size++) search(size, 0, [], 0);
const found = best as { indexes: number[] } | null;
return found ? found.indexes : null;
}

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