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;
}