money: quick-add bar, server-filtered transactions page, CSV import dedupe + Revolut statements
This commit is contained in:
@@ -1,5 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { cents, csvDate, importCents, parseCsv, parseTransactionImport } from './money-tracker';
|
||||
import {
|
||||
cents,
|
||||
csvDate,
|
||||
importCents,
|
||||
parseCsv,
|
||||
parseTransactionImport,
|
||||
planImport
|
||||
} from './money-tracker';
|
||||
|
||||
describe('transaction import parsing', () => {
|
||||
it('handles quoted commas and semicolon-separated European amounts', () => {
|
||||
@@ -63,3 +70,50 @@ describe('transaction import parsing', () => {
|
||||
).toThrow('Quote values containing commas');
|
||||
});
|
||||
});
|
||||
|
||||
describe('revolut statements and import dedupe', () => {
|
||||
const statement =
|
||||
'Type,Product,Started Date,Completed Date,Description,Amount,Fee,Currency,State,Balance\n' +
|
||||
'CARD_PAYMENT,Current,2026-10-01 10:00:00,2026-10-02 09:00:00,Albert Heijn,-12.34,0.00,EUR,COMPLETED,100.00\n' +
|
||||
'TOPUP,Current,2026-10-03 10:00:00,,Pending top-up,50.00,0.00,EUR,PENDING,\n' +
|
||||
'TRANSFER,Current,2026-10-04 10:00:00,2026-10-04 10:00:01,To Joint,-100.00,0.50,EUR,COMPLETED,0.00\n' +
|
||||
'CARD_PAYMENT,Current,2026-10-05 10:00:00,2026-10-05 11:00:00,Coffee,-3.00,0.00,USD,COMPLETED,0.00\n';
|
||||
|
||||
it('keeps completed EUR rows, uses the completed date and deducts fees', () => {
|
||||
const rows = parseTransactionImport(statement, 'Revolut');
|
||||
expect(rows.map((row) => [row.date, row.account, row.description, row.amountCents])).toEqual([
|
||||
['2026-10-02', 'Revolut', 'Albert Heijn', -1234],
|
||||
['2026-10-04', 'Revolut', 'To Joint', -10050]
|
||||
]);
|
||||
expect(rows[0].externalId).toMatch(/^csv:[0-9a-f]{32}$/);
|
||||
expect(parseTransactionImport(statement, 'Revolut')[0].externalId).toBe(rows[0].externalId);
|
||||
expect(() => parseTransactionImport(statement)).toThrow('Choose which account');
|
||||
});
|
||||
|
||||
it('gives identical rows in one file distinct, stable ids', () => {
|
||||
const file =
|
||||
'date,account,description,amount\n2026-10-01,Cash,Kiosk,-2\n2026-10-01,Cash,Kiosk,-2\n';
|
||||
const [a, b] = parseTransactionImport(file);
|
||||
expect(a.externalId).not.toBe(b.externalId);
|
||||
expect(parseTransactionImport(file).map((row) => row.externalId)).toEqual([
|
||||
a.externalId,
|
||||
b.externalId
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips rows already imported or already entered by hand (±1 day)', () => {
|
||||
const rows = [
|
||||
{ accountId: 1, date: '2026-10-02', amountCents: -1234, externalId: 'csv:a' },
|
||||
{ accountId: 1, date: '2026-10-02', amountCents: -1234, externalId: 'csv:b' },
|
||||
{ accountId: 1, date: '2026-10-04', amountCents: -500, externalId: 'csv:c' },
|
||||
{ accountId: 2, date: '2026-10-04', amountCents: -500, externalId: 'csv:d' }
|
||||
];
|
||||
const existing = [
|
||||
{ id: 10, accountId: 1, date: '2026-10-01', amountCents: -1234, externalId: null },
|
||||
{ id: 11, accountId: 1, date: '2026-10-04', amountCents: -500, externalId: 'csv:c' }
|
||||
];
|
||||
const { insert, duplicates } = planImport(rows, existing);
|
||||
expect(insert.map((row) => row.externalId)).toEqual(['csv:b', 'csv:d']);
|
||||
expect(duplicates).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
export const utilityKinds = ['electricity', 'gas', 'water'] as const;
|
||||
export const cadences = ['weekly', 'monthly', 'yearly'] as const;
|
||||
@@ -130,21 +131,93 @@ export type ImportedTransaction = {
|
||||
account: string;
|
||||
description: string;
|
||||
amountCents: number;
|
||||
/** Stable per-row id ("csv:<hash>") so importing the same file twice adds nothing. */
|
||||
externalId: string;
|
||||
};
|
||||
|
||||
export function parseTransactionImport(input: string): ImportedTransaction[] {
|
||||
const rows = parseCsv(input);
|
||||
if (rows.length < 2 || rows.length > 20_001)
|
||||
throw new Error('The file must have a header and 1–20,000 rows.');
|
||||
const header = rows.shift()!.map((value) =>
|
||||
export type ImportFormat = 'tracker' | 'revolut';
|
||||
|
||||
function headerOf(rows: string[][]) {
|
||||
return rows[0].map((value) =>
|
||||
value
|
||||
.replace(/^\uFEFF/, '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
);
|
||||
}
|
||||
|
||||
/** Revolut app statement export: Type, Product, Started Date, Completed Date, Description, Amount, Fee, Currency, State, Balance. */
|
||||
export function isRevolutHeader(header: string[]) {
|
||||
return ['type', 'started date', 'description', 'amount', 'currency', 'state'].every((key) =>
|
||||
header.includes(key)
|
||||
);
|
||||
}
|
||||
|
||||
function importId(parts: (string | number)[], seen: Map<string, number>) {
|
||||
const base = parts.join('\u001f');
|
||||
// Identical rows in one file (two coffees, same day) stay distinct but stable.
|
||||
const occurrence = (seen.get(base) ?? 0) + 1;
|
||||
seen.set(base, occurrence);
|
||||
return (
|
||||
'csv:' + createHash('sha256').update(`${base}\u001f${occurrence}`).digest('hex').slice(0, 32)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses either the tracker's own export (date, account, description, amount) or a Revolut
|
||||
* statement. Revolut files have no account column, so `defaultAccount` names the tracker
|
||||
* account they belong to; only COMPLETED EUR rows are kept and the fee is deducted.
|
||||
*/
|
||||
export function parseTransactionImport(
|
||||
input: string,
|
||||
defaultAccount?: string
|
||||
): ImportedTransaction[] {
|
||||
const rows = parseCsv(input);
|
||||
if (rows.length < 2 || rows.length > 20_001)
|
||||
throw new Error('The file must have a header and 1–20,000 rows.');
|
||||
const header = headerOf(rows);
|
||||
rows.shift();
|
||||
const seen = new Map<string, number>();
|
||||
if (isRevolutHeader(header)) {
|
||||
const account = defaultAccount?.trim();
|
||||
if (!account) throw new Error('Choose which account this Revolut statement belongs to.');
|
||||
const col = (key: string) => header.indexOf(key);
|
||||
const parsed: ImportedTransaction[] = [];
|
||||
for (const [index, row] of rows.entries()) {
|
||||
const state = (row[col('state')] ?? '').trim().toUpperCase();
|
||||
if (state && state !== 'COMPLETED') continue;
|
||||
const currency = (row[col('currency')] ?? 'EUR').trim().toUpperCase();
|
||||
if (currency && currency !== 'EUR') continue;
|
||||
const started = (row[col('started date')] ?? '').trim();
|
||||
const completed = col('completed date') >= 0 ? (row[col('completed date')] ?? '').trim() : '';
|
||||
const date = csvDate((completed || started).slice(0, 10));
|
||||
const amount = importCents(row[col('amount')]);
|
||||
const fee = col('fee') >= 0 ? (importCents(row[col('fee')] || '0') ?? undefined) : 0;
|
||||
const description = (row[col('description')] ?? '').trim().slice(0, 500) || 'Revolut';
|
||||
if (!date || amount == null || fee == null)
|
||||
throw new Error(`Invalid data in statement row ${index + 1}. No rows were imported.`);
|
||||
const amountCents = amount - fee;
|
||||
if (!amountCents) continue;
|
||||
parsed.push({
|
||||
date,
|
||||
account,
|
||||
description,
|
||||
amountCents,
|
||||
externalId: importId(
|
||||
['revolut', account.toLowerCase(), started, amount, fee, description],
|
||||
seen
|
||||
)
|
||||
});
|
||||
}
|
||||
if (!parsed.length) throw new Error('No completed EUR transactions found in this statement.');
|
||||
return parsed.sort((a, b) => a.date.localeCompare(b.date));
|
||||
}
|
||||
const columns = ['date', 'account', 'description', 'amount'].map((key) => header.indexOf(key));
|
||||
if (columns.some((index) => index < 0))
|
||||
throw new Error('Headers must include date, account, description and amount.');
|
||||
const accountColumn = columns[1];
|
||||
if (columns[0] < 0 || columns[2] < 0 || columns[3] < 0 || (accountColumn < 0 && !defaultAccount))
|
||||
throw new Error(
|
||||
'Headers must include date, account, description and amount (or choose an account), or be a Revolut statement.'
|
||||
);
|
||||
const parsed: ImportedTransaction[] = [];
|
||||
for (const [index, row] of rows.entries()) {
|
||||
if (row.length !== header.length)
|
||||
@@ -152,7 +225,8 @@ export function parseTransactionImport(input: string): ImportedTransaction[] {
|
||||
`Data row ${index + 1} has ${row.length} columns; expected ${header.length}. Quote values containing commas. No rows were imported.`
|
||||
);
|
||||
const date = csvDate(row[columns[0]] ?? '');
|
||||
const account = (row[columns[1]] ?? '').trim();
|
||||
const account =
|
||||
(accountColumn >= 0 ? (row[accountColumn] ?? '') : '').trim() || defaultAccount?.trim() || '';
|
||||
const description = (row[columns[2]] ?? '').trim();
|
||||
const amountCents = importCents(row[columns[3]]);
|
||||
if (
|
||||
@@ -164,7 +238,64 @@ export function parseTransactionImport(input: string): ImportedTransaction[] {
|
||||
amountCents == null
|
||||
)
|
||||
throw new Error(`Invalid data in transaction ${index + 1}. No rows were imported.`);
|
||||
parsed.push({ date, account, description, amountCents });
|
||||
parsed.push({
|
||||
date,
|
||||
account,
|
||||
description,
|
||||
amountCents,
|
||||
externalId: importId([date, account.toLowerCase(), description, amountCents], seen)
|
||||
});
|
||||
}
|
||||
return parsed.sort((a, b) => a.date.localeCompare(b.date));
|
||||
}
|
||||
|
||||
export type ExistingImportRow = {
|
||||
id: number;
|
||||
accountId: number;
|
||||
date: string;
|
||||
amountCents: number;
|
||||
externalId: string | null;
|
||||
};
|
||||
|
||||
function daysApart(a: string, b: string) {
|
||||
return Math.abs(Date.parse(a + 'T00:00:00Z') - Date.parse(b + 'T00:00:00Z')) / 86_400_000;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
export function planImport<
|
||||
T extends { accountId: number; date: string; amountCents: number; externalId: string }
|
||||
>(rows: T[], existing: ExistingImportRow[]) {
|
||||
const ids = new Set(
|
||||
existing.flatMap((row) => (row.externalId ? [`${row.accountId}:${row.externalId}`] : []))
|
||||
);
|
||||
const byKey = new Map<string, ExistingImportRow[]>();
|
||||
for (const row of existing) {
|
||||
const key = `${row.accountId}:${row.amountCents}`;
|
||||
byKey.set(key, [...(byKey.get(key) ?? []), row]);
|
||||
}
|
||||
const claimed = new Set<number>();
|
||||
const insert: T[] = [];
|
||||
let duplicates = 0;
|
||||
for (const row of rows) {
|
||||
const idKey = `${row.accountId}:${row.externalId}`;
|
||||
if (ids.has(idKey)) {
|
||||
duplicates++;
|
||||
continue;
|
||||
}
|
||||
const match = (byKey.get(`${row.accountId}:${row.amountCents}`) ?? [])
|
||||
.filter((candidate) => !claimed.has(candidate.id) && daysApart(candidate.date, row.date) <= 1)
|
||||
.sort((a, b) => daysApart(a.date, row.date) - daysApart(b.date, row.date))[0];
|
||||
if (match) {
|
||||
claimed.add(match.id);
|
||||
duplicates++;
|
||||
continue;
|
||||
}
|
||||
ids.add(idKey);
|
||||
insert.push(row);
|
||||
}
|
||||
return { insert, duplicates };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user