money: quick-add bar, server-filtered transactions page, CSV import dedupe + Revolut statements
This commit is contained in:
269
src/lib/money-tracker/QuickAdd.svelte
Normal file
269
src/lib/money-tracker/QuickAdd.svelte
Normal file
@@ -0,0 +1,269 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { tick } from 'svelte';
|
||||
import { Button } from '#lib/components/ui/button/index.js';
|
||||
import { Input } from '#lib/components/ui/input/index.js';
|
||||
import { NativeSelect } from '#lib/components/ui/native-select/index.js';
|
||||
import Plus from 'lucide-svelte/icons/plus';
|
||||
import { descriptionSuggestions, type SuggestionSource } from './quick-add.js';
|
||||
|
||||
let {
|
||||
accounts,
|
||||
transactions,
|
||||
today
|
||||
}: {
|
||||
accounts: { id: number; name: string }[];
|
||||
transactions: SuggestionSource[];
|
||||
today: string;
|
||||
} = $props();
|
||||
|
||||
const storageKey = 'money-tracker:last-account';
|
||||
const euro = new Intl.NumberFormat('en-NL', { style: 'currency', currency: 'EUR' });
|
||||
let kind = $state<'expense' | 'income'>('expense');
|
||||
let description = $state('');
|
||||
let amount = $state('');
|
||||
let accountId = $state('');
|
||||
let date = $state('');
|
||||
let listOpen = $state(false);
|
||||
let highlighted = $state(-1);
|
||||
let message = $state('');
|
||||
let failed = $state(false);
|
||||
let saving = $state(false);
|
||||
let descriptionInput = $state<HTMLInputElement | null>(null);
|
||||
let amountInput = $state<HTMLInputElement | null>(null);
|
||||
|
||||
let suggestions = $derived(descriptionSuggestions(transactions, today));
|
||||
let matches = $derived.by(() => {
|
||||
const query = description.trim().toLowerCase();
|
||||
if (!query) return [];
|
||||
return suggestions
|
||||
.filter((item) => item.key.includes(query) && item.key !== query)
|
||||
.sort((a, b) => Number(b.key.startsWith(query)) - Number(a.key.startsWith(query)))
|
||||
.slice(0, 6);
|
||||
});
|
||||
let signedAmount = $derived.by(() => {
|
||||
const raw = amount.trim().replace(',', '.').replace(/^[-+]/, '');
|
||||
return raw ? (kind === 'expense' ? '-' : '') + raw : '';
|
||||
});
|
||||
|
||||
$effect.pre(() => {
|
||||
if (!date) date = today;
|
||||
if (!accountId && accounts.length) {
|
||||
const remembered =
|
||||
typeof localStorage === 'undefined' ? null : localStorage.getItem(storageKey);
|
||||
accountId = String(
|
||||
accounts.find((account) => String(account.id) === remembered)?.id ?? accounts[0].id
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
async function choose(index: number) {
|
||||
const item = matches[index];
|
||||
if (!item) return;
|
||||
description = item.description;
|
||||
amount = (Math.abs(item.amountCents) / 100).toFixed(2);
|
||||
kind = item.amountCents < 0 ? 'expense' : 'income';
|
||||
if (item.accountId && accounts.some((account) => account.id === item.accountId))
|
||||
accountId = String(item.accountId);
|
||||
listOpen = false;
|
||||
highlighted = -1;
|
||||
await tick();
|
||||
amountInput?.select();
|
||||
}
|
||||
|
||||
function keydown(event: KeyboardEvent) {
|
||||
if (!matches.length) return;
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
listOpen = true;
|
||||
const step = event.key === 'ArrowDown' ? 1 : -1;
|
||||
highlighted = (highlighted + step + matches.length) % matches.length;
|
||||
} else if (event.key === 'Enter' && listOpen && highlighted >= 0) {
|
||||
event.preventDefault();
|
||||
choose(highlighted);
|
||||
} else if (event.key === 'Escape') {
|
||||
listOpen = false;
|
||||
highlighted = -1;
|
||||
} else if (event.key === 'Tab' && listOpen && highlighted >= 0) choose(highlighted);
|
||||
}
|
||||
</script>
|
||||
|
||||
<form
|
||||
class="quick-add"
|
||||
method="POST"
|
||||
action="?/saveTransaction"
|
||||
aria-label="Quick add transaction"
|
||||
use:enhance={() => {
|
||||
saving = true;
|
||||
const saved = { description, signedAmount };
|
||||
return async ({ result, update }) => {
|
||||
saving = false;
|
||||
if (result.type === 'success') {
|
||||
localStorage.setItem(storageKey, accountId);
|
||||
failed = false;
|
||||
message = `Saved ${saved.description} · ${euro.format(Number(saved.signedAmount))}`;
|
||||
description = '';
|
||||
amount = '';
|
||||
await update({ reset: false });
|
||||
descriptionInput?.focus();
|
||||
} else {
|
||||
failed = true;
|
||||
message =
|
||||
result.type === 'failure' && typeof result.data?.message === 'string'
|
||||
? result.data.message
|
||||
: 'Could not save this transaction.';
|
||||
}
|
||||
};
|
||||
}}
|
||||
>
|
||||
<div class="quick-kind" role="group" aria-label="Transaction type">
|
||||
<Button
|
||||
type="button"
|
||||
variant={kind === 'expense' ? 'default' : 'ghost'}
|
||||
aria-pressed={kind === 'expense'}
|
||||
onclick={() => (kind = 'expense')}>Expense</Button
|
||||
><Button
|
||||
type="button"
|
||||
variant={kind === 'income' ? 'default' : 'ghost'}
|
||||
aria-pressed={kind === 'income'}
|
||||
onclick={() => (kind = 'income')}>Income</Button
|
||||
>
|
||||
</div>
|
||||
<div class="quick-description">
|
||||
<label class="sr-only" for="quick-description">Description</label>
|
||||
<Input
|
||||
id="quick-description"
|
||||
name="description"
|
||||
autocomplete="off"
|
||||
placeholder="What was it? e.g. Albert Heijn"
|
||||
required
|
||||
maxlength={500}
|
||||
role="combobox"
|
||||
aria-expanded={listOpen && matches.length > 0}
|
||||
aria-controls="quick-suggestions"
|
||||
aria-autocomplete="list"
|
||||
aria-activedescendant={highlighted >= 0 ? `quick-suggestion-${highlighted}` : undefined}
|
||||
bind:ref={descriptionInput}
|
||||
bind:value={description}
|
||||
oninput={() => {
|
||||
listOpen = true;
|
||||
highlighted = -1;
|
||||
}}
|
||||
onkeydown={keydown}
|
||||
onfocus={() => (listOpen = true)}
|
||||
onblur={() => setTimeout(() => (listOpen = false), 120)}
|
||||
/>
|
||||
{#if listOpen && matches.length}
|
||||
<ul id="quick-suggestions" role="listbox" class="quick-suggestions">
|
||||
{#each matches as item, index (item.key)}
|
||||
<li
|
||||
id={`quick-suggestion-${index}`}
|
||||
role="option"
|
||||
aria-selected={index === highlighted}
|
||||
class:highlighted={index === highlighted}
|
||||
onmousedown={(event) => {
|
||||
event.preventDefault();
|
||||
choose(index);
|
||||
}}
|
||||
>
|
||||
<span>{item.description}</span>
|
||||
<span class="muted"
|
||||
>{euro.format(item.amountCents / 100)} · {accounts.find(
|
||||
(a) => a.id === item.accountId
|
||||
)?.name ?? ''}</span
|
||||
>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
<label class="sr-only" for="quick-amount">Amount in euros</label>
|
||||
<Input
|
||||
id="quick-amount"
|
||||
class="quick-amount"
|
||||
inputmode="decimal"
|
||||
placeholder="0.00"
|
||||
required
|
||||
pattern="[-+]?[0-9]+([.,][0-9]{(1, 2)})?"
|
||||
bind:ref={amountInput}
|
||||
bind:value={amount}
|
||||
/>
|
||||
<input type="hidden" name="amount" value={signedAmount} />
|
||||
<label class="sr-only" for="quick-account">Account</label>
|
||||
<NativeSelect id="quick-account" name="accountId" bind:value={accountId} required
|
||||
>{#each accounts as account (account.id)}<option value={String(account.id)}
|
||||
>{account.name}</option
|
||||
>{/each}</NativeSelect
|
||||
>
|
||||
<label class="sr-only" for="quick-date">Date</label>
|
||||
<Input id="quick-date" class="quick-date" type="date" name="date" bind:value={date} required />
|
||||
<Button type="submit" disabled={saving || !accounts.length}><Plus size={16} /> Add</Button>
|
||||
{#if message}<p class="quick-message" class:form-error={failed} role="status">{message}</p>{/if}
|
||||
</form>
|
||||
|
||||
<style>
|
||||
.quick-add {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0 0 20px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.quick-kind {
|
||||
display: inline-flex;
|
||||
gap: 2px;
|
||||
padding: 2px;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.quick-description {
|
||||
position: relative;
|
||||
flex: 1 1 220px;
|
||||
}
|
||||
.quick-add :global(.quick-amount) {
|
||||
width: 110px;
|
||||
text-align: right;
|
||||
}
|
||||
.quick-add :global(.quick-date) {
|
||||
width: 150px;
|
||||
}
|
||||
.quick-suggestions {
|
||||
position: absolute;
|
||||
z-index: 30;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin: 0;
|
||||
padding: 4px;
|
||||
list-style: none;
|
||||
background: var(--site-bg);
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 24px rgb(0 0 0 / 0.12);
|
||||
}
|
||||
.quick-suggestions li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
.quick-suggestions li.highlighted,
|
||||
.quick-suggestions li:hover {
|
||||
background: var(--site-border);
|
||||
}
|
||||
.quick-message {
|
||||
flex-basis: 100%;
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
.muted {
|
||||
opacity: 0.65;
|
||||
}
|
||||
</style>
|
||||
24
src/lib/money-tracker/quick-add.test.ts
Normal file
24
src/lib/money-tracker/quick-add.test.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { descriptionSuggestions } from './quick-add';
|
||||
|
||||
describe('quick-add suggestions', () => {
|
||||
it('ranks by use and fills the usual amount and latest account', () => {
|
||||
const rows = [
|
||||
{ description: 'PLUS', amountCents: -1200, accountId: 1, date: '2026-10-01' },
|
||||
{ description: 'plus', amountCents: -1200, accountId: 1, date: '2026-10-03' },
|
||||
{ description: 'Plus', amountCents: -800, accountId: 4, date: '2026-10-05' },
|
||||
{ description: 'From Juhee', amountCents: 5000, accountId: 4, date: '2026-10-02' },
|
||||
{ description: 'Future', amountCents: -1, accountId: 1, date: '2026-12-01' }
|
||||
];
|
||||
const [first, second, ...rest] = descriptionSuggestions(rows, '2026-10-24');
|
||||
expect(first).toMatchObject({
|
||||
key: 'plus',
|
||||
description: 'Plus',
|
||||
amountCents: -1200,
|
||||
accountId: 4,
|
||||
count: 3
|
||||
});
|
||||
expect(second).toMatchObject({ description: 'From Juhee', amountCents: 5000 });
|
||||
expect(rest).toEqual([]);
|
||||
});
|
||||
});
|
||||
73
src/lib/money-tracker/quick-add.ts
Normal file
73
src/lib/money-tracker/quick-add.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
export type SuggestionSource = {
|
||||
description: string;
|
||||
amountCents: number;
|
||||
accountId: number | null;
|
||||
date: string;
|
||||
};
|
||||
|
||||
export type Suggestion = {
|
||||
key: string;
|
||||
description: string;
|
||||
/** Most common amount for this description (latest wins ties). */
|
||||
amountCents: number;
|
||||
/** Account used most recently with this description. */
|
||||
accountId: number | null;
|
||||
count: number;
|
||||
lastDate: string;
|
||||
};
|
||||
|
||||
/** Past descriptions ranked by use, each with its usual amount and latest account. */
|
||||
export function descriptionSuggestions(rows: SuggestionSource[], today: string): Suggestion[] {
|
||||
const groups = new Map<
|
||||
string,
|
||||
{
|
||||
description: string;
|
||||
amounts: Map<number, number>;
|
||||
accountId: number | null;
|
||||
count: number;
|
||||
lastDate: string;
|
||||
lastAmount: number;
|
||||
}
|
||||
>();
|
||||
for (const row of rows) {
|
||||
if (row.date > today) continue;
|
||||
const key = row.description.trim().toLowerCase();
|
||||
if (!key) continue;
|
||||
const group = groups.get(key) ?? {
|
||||
description: row.description.trim(),
|
||||
amounts: new Map(),
|
||||
accountId: row.accountId,
|
||||
count: 0,
|
||||
lastDate: '',
|
||||
lastAmount: row.amountCents
|
||||
};
|
||||
group.count++;
|
||||
group.amounts.set(row.amountCents, (group.amounts.get(row.amountCents) ?? 0) + 1);
|
||||
if (row.date >= group.lastDate) {
|
||||
group.lastDate = row.date;
|
||||
group.description = row.description.trim();
|
||||
group.accountId = row.accountId;
|
||||
group.lastAmount = row.amountCents;
|
||||
}
|
||||
groups.set(key, group);
|
||||
}
|
||||
return [...groups.entries()]
|
||||
.map(([key, group]) => {
|
||||
let amountCents = group.lastAmount;
|
||||
let best = group.amounts.get(amountCents) ?? 0;
|
||||
for (const [value, uses] of group.amounts)
|
||||
if (uses > best) {
|
||||
best = uses;
|
||||
amountCents = value;
|
||||
}
|
||||
return {
|
||||
key,
|
||||
description: group.description,
|
||||
amountCents,
|
||||
accountId: group.accountId,
|
||||
count: group.count,
|
||||
lastDate: group.lastDate
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.count - a.count || b.lastDate.localeCompare(a.lastDate));
|
||||
}
|
||||
@@ -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