// Import a Revolut statement (all-time CSV, Excel or PDF export) into one tracker account, // deduped against what's already there (bank-sync rows, Daisy's earlier imports, hand entries). // // bun --env-file=$HOME/.config/money-sync.env scripts/revolut-import.ts \ // --file statement.csv --account Joint [options] // (default) dry run: report new rows, duplicates, conflicts, resulting balances // --product Current Revolut sub-account to import (Savings/pockets are reported, skipped) // --currency EUR only this currency (other currency pockets are reported, skipped) // --opening PDF only: the statement's opening balance (default 0) // --list print every new row / conflict / tracker-only row // --insert-conflicts also insert rows that fall in the already-covered period but match nothing // --insert-conflicts-until YYYY-MM-DD same, but only conflicts up to that date (e.g. a row // the old "Start balance" had absorbed on its own date) // --transfer-pattern statement rows of type Transfer whose description matches get // category 'transfer' (e.g. Personal <-> Joint legs) // --tag-matched-transfers with --transfer-pattern: also set category 'transfer' on the // existing tracker rows those statement rows matched // --apply write (needs a money-schema backup in ~/backups from the last 2 hours) // --drop-start-balance with --apply: delete the account's "Start balance" row(s), only if the // statement opens at €0 and, after the import, the only difference // to the synced bank balance is the tracker-only rows (hand entries // the bank doesn't have). The sync's balance-adjustment row is then // set to that difference (removed when €0), so the total stays equal // to the bank balance. // // Matching: same amount, date within ±2 days (booking or started date), closest first, // description overlap as tie-breaker. Inserted rows get source 'csv' and a stable // external_id 'revolut-stmt:', so re-running never duplicates. import { createHash } from 'node:crypto'; import { readFileSync, readdirSync, statSync } from 'node:fs'; import { execFileSync } from 'node:child_process'; import { homedir } from 'node:os'; import postgres from 'postgres'; import { checkBalances, parseRevolutCsv, parseRevolutPdfLines, planImport, rowsFromTable, selectRows, statementExternalId, type StatementRow, type TrackerRow } from '../src/lib/server/revolut-statement'; import { BALANCE_ADJUSTMENT_ID, isOpeningBalance } from '../src/lib/server/bank-sync'; const args = process.argv.slice(2); const value = (name: string) => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : undefined; }; const flag = (name: string) => args.includes(name); const file = value('--file'); const accountName = value('--account'); if (!file || !accountName) throw new Error('usage: --file --account '); if (!process.env.DB_URL) throw new Error('DB_URL is not set'); const apply = flag('--apply'); const euro = (c: number | null | undefined) => (c == null ? '—' : `€${(c / 100).toFixed(2)}`); function xlsxTable(path: string): string[][] { const read = (entry: string) => { try { return execFileSync('unzip', ['-p', path, entry], { maxBuffer: 1 << 28 }).toString('utf8'); } catch { return ''; } }; const unxml = (s: string) => s .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, "'") .replace(/&/g, '&'); const shared = [...read('xl/sharedStrings.xml').matchAll(/([\s\S]*?)<\/si>/g)].map((m) => unxml([...m[1].matchAll(/]*>([\s\S]*?)<\/t>/g)].map((t) => t[1]).join('')) ); const sheet = read('xl/worksheets/sheet1.xml'); const colIndex = (ref: string) => [...ref.replace(/\d+/g, '')].reduce((n, ch) => n * 26 + ch.charCodeAt(0) - 64, 0) - 1; return [...sheet.matchAll(/]*>([\s\S]*?)<\/row>/g)].map((row) => { const cells: string[] = []; for (const c of row[1].matchAll(/]*?)(?:\/>|>([\s\S]*?)<\/c>)/g)) { const type = c[2].match(/t="(\w+)"/)?.[1]; const v = c[3]?.match(/([\s\S]*?)<\/v>/)?.[1] ?? c[3]?.match(/]*>([\s\S]*?)<\/t>/)?.[1] ?? ''; cells[colIndex(c[1])] = type === 's' ? (shared[Number(v)] ?? '') : unxml(v); } return Array.from(cells, (x) => x ?? ''); }); } async function pdfLines(path: string): Promise { const pdfjs = await import('pdfjs-dist/legacy/build/pdf.mjs'); const doc = await pdfjs.getDocument({ data: new Uint8Array(readFileSync(path)) }).promise; const lines: string[] = []; for (let p = 1; p <= doc.numPages; p++) { const content = await (await doc.getPage(p)).getTextContent(); const byY = new Map(); for (const item of content.items as { str: string; transform: number[] }[]) { const y = Math.round(item.transform[5]); byY.set(y, [...(byY.get(y) ?? []), { x: item.transform[4], s: item.str }]); } for (const y of [...byY.keys()].sort((a, b) => b - a)) lines.push( byY .get(y)! .sort((a, b) => a.x - b.x) .map((i) => i.s) .join(' ') ); } return lines; } function loadStatement(path: string): StatementRow[] { const lower = path.toLowerCase(); if (lower.endsWith('.xlsx')) return rowsFromTable(xlsxTable(path)); if (lower.endsWith('.csv') || lower.endsWith('.txt')) return parseRevolutCsv(readFileSync(path, 'utf8')); throw new Error('Use .csv, .xlsx or .pdf'); } const all = file.toLowerCase().endsWith('.pdf') ? parseRevolutPdfLines(await pdfLines(file), Math.round(Number(value('--opening') ?? 0) * 100)) : loadStatement(file); const { kept, skipped } = selectRows( all, value('--product') ?? 'Current', value('--currency') ?? 'EUR' ); // Chronological by booking date; file order breaks ties (it follows the running balance). const rows = kept.slice().sort((a, b) => a.date.localeCompare(b.date) || a.line - b.line); const balances = checkBalances(kept); const sql = postgres(process.env.DB_URL, { max: 2, connect_timeout: 10, onnotice: () => {} }); try { const [account] = await sql< { id: number; user_id: string; name: string; opening_balance_cents: number; bank_balance_cents: number | null; bank_balance_at: Date | null; }[] >`select id, user_id, name, opening_balance_cents, bank_balance_cents, bank_balance_at from money.accounts where lower(name) = lower(${accountName})`; if (!account) throw new Error(`No tracker account named ${accountName}`); const existing = await sql< { id: number; date: string; amount_cents: number; description: string; source: string; external_id: string | null; text: string; }[] >`select id, date::text, amount_cents, description, source, external_id, concat_ws(' ', description, counterparty, raw->'creditor'->>'name', raw->'debtor'->>'name', raw->>'remittance_information', raw->'revolut'->>'description') as text from money.transactions where account_id = ${account.id} order by date, id`; const startRows = existing.filter( (t) => isOpeningBalance(t.description) && t.source === 'manual' ); const adjustment = existing.find((t) => t.external_id === BALANCE_ADJUSTMENT_ID); const tracker: TrackerRow[] = existing .filter((t) => !startRows.includes(t) && t !== adjustment) .map((t) => ({ id: t.id, date: t.date, amountCents: t.amount_cents, description: t.description, text: t.text, source: t.source, externalId: t.external_id })); const plan = planImport(rows, tracker); const conflictsUntil = value('--insert-conflicts-until'); const toInsert = flag('--insert-conflicts') ? [...plan.fresh, ...plan.conflicts] : conflictsUntil ? [...plan.fresh, ...plan.conflicts.filter((r) => r.date <= conflictsUntil)] : plan.fresh; const transferRe = value('--transfer-pattern') ? new RegExp(value('--transfer-pattern')!, 'i') : null; const isTransfer = (r: StatementRow) => !!transferRe && /transfer/i.test(r.type) && transferRe.test(r.description); const today = new Date().toISOString().slice(0, 10); const sum = (xs: { amountCents: number; date: string }[]) => xs.filter((x) => x.date <= today).reduce((s, x) => s + x.amountCents, 0); const trackedNow = account.opening_balance_cents + sum(tracker) + sum(startRows.map((t) => ({ amountCents: t.amount_cents, date: t.date }))) + (adjustment?.amount_cents ?? 0); const afterNoStart = account.opening_balance_cents + sum(tracker) + sum(toInsert); const bank = account.bank_balance_cents; const residual = bank == null ? null : bank - afterNoStart; const trackerOnlyCents = sum(plan.trackerOnly); const dropOk = balances.openingCents === 0 && residual !== null && residual === -trackerOnlyCents; const boundary = plan.coverageFrom ? (rows.filter((r) => r.date < plan.coverageFrom! && r.balanceCents != null).at(-1) ?.balanceCents ?? 0) : null; console.log( `Statement ${file}: ${all.length} rows; importing ${account.name} = ${value('--product') ?? 'Current'} ${value('--currency') ?? 'EUR'}: ${kept.length} completed rows ${rows[0]?.date ?? ''}..${rows.at(-1)?.date ?? ''}` ); const skippedBy = new Map(); for (const r of skipped) { const key = `${r.product}/${r.currency}/${r.state}`; const e = skippedBy.get(key) ?? { n: 0, cents: 0 }; skippedBy.set(key, { n: e.n + 1, cents: e.cents + r.amountCents }); } for (const [k, v] of skippedBy) console.log(` skipped ${k}: ${v.n} rows (net ${euro(v.cents)})`); console.log( ` statement opening ${euro(balances.openingCents)}, closing ${euro(balances.closingCents)}, running-balance breaks ${balances.breaks.length}` ); console.log( `Tracker ${account.name} (#${account.id}): ${tracker.length} rows from ${plan.coverageFrom ?? '-'}; start balance rows ${startRows.map((t) => `#${t.id} ${t.date} ${euro(t.amount_cents)}`).join(', ') || 'none'}; adjustment ${euro(adjustment?.amount_cents)}` ); console.log( ` statement balance just before ${plan.coverageFrom}: ${euro(boundary)} (tracker start + adjustment: ${euro(sum(startRows.map((t) => ({ amountCents: t.amount_cents, date: t.date }))) + (adjustment?.amount_cents ?? 0))})` ); console.log( `Plan: new ${plan.fresh.length} (${euro(sum(plan.fresh))}), duplicates ${plan.duplicates.length} (${plan.duplicates.filter((d) => !d.descMatch).length} with differing description), conflicts ${plan.conflicts.length} (${euro(sum(plan.conflicts))}), tracker-only ${plan.trackerOnly.length} (${euro(sum(plan.trackerOnly))})` ); console.log( `Balances: bank ${euro(bank)} (synced ${account.bank_balance_at?.toISOString() ?? 'never'}), tracker now ${euro(trackedNow)}, after import without start balance/adjustment ${euro(afterNoStart)}, gap ${euro(residual)}${residual !== null && residual === -trackerOnlyCents ? ' = tracker-only rows, OK to drop the start balance' : ` (tracker-only rows ${euro(trackerOnlyCents)}; start balance must stay)`}${transferRe ? `; transfers matching pattern: ${rows.filter(isTransfer).length}` : ''}` ); if (flag('--list') || !apply) { const show = ( label: string, xs: { date: string; amountCents: number; description: string; id?: number; source?: string; }[], limit: number ) => { if (!xs.length) return; console.log(` ${label}:`); for (const x of xs.slice(0, flag('--list') ? Infinity : limit)) console.log( ` ${x.date} ${euro(x.amountCents).padStart(10)} ${x.description}${x.id ? ` (#${x.id} ${x.source})` : ''}` ); if (!flag('--list') && xs.length > limit) console.log(` … ${xs.length - limit} more (--list)`); }; show('conflicts (statement only, in covered period)', plan.conflicts, 30); show('tracker-only rows (covered period, no statement row)', plan.trackerOnly, 30); show( 'duplicates with differing description', plan.duplicates .filter((d) => !d.descMatch) .map((d) => ({ ...d.row, description: `${d.row.description} == ${d.match.description}` })), 10 ); show('new rows', plan.fresh, 5); } if (!apply) { console.log('Dry run: nothing written. Re-run with --apply (after a backup) to import.'); } else { const dir = `${homedir()}/backups`; const fresh = readdirSync(dir).some( (f) => f.startsWith('money-before-') && f.endsWith('.dump') && Date.now() - statSync(`${dir}/${f}`).mtimeMs < 2 * 3600_000 ); if (!fresh) throw new Error(`No money-before-*.dump in ${dir} from the last 2 hours; back up first`); const drop = flag('--drop-start-balance'); if (drop && !dropOk) throw new Error( `Refusing --drop-start-balance: statement opening ${euro(balances.openingCents)}, gap ${euro(residual)} vs tracker-only rows ${euro(trackerOnlyCents)}` ); const hash = (s: string) => createHash('sha256').update(s).digest('hex'); await sql.begin(async (q) => { let inserted = 0; for (const r of toInsert) { const res = await q` insert into money.transactions (user_id, account_id, date, value_date, description, amount_cents, external_id, source, booking_status, category, raw) values (${account.user_id}, ${account.id}, ${r.date}, ${r.startedDate}, ${r.description.slice(0, 500)}, ${r.amountCents}, ${statementExternalId(r, hash)}, 'csv', 'BOOK', ${isTransfer(r) ? 'transfer' : null}, ${q.json({ revolut: { type: r.type, product: r.product, started: r.startedDate, completed: r.completedAt, description: r.description, fee_cents: r.feeCents, currency: r.currency, balance_cents: r.balanceCents, file_line: r.line } })}) on conflict do nothing`; inserted += res.count; } console.log(`Inserted ${inserted} rows into ${account.name}.`); if (transferRe && flag('--tag-matched-transfers')) { const ids = plan.duplicates.filter((d) => isTransfer(d.row)).map((d) => d.match.id); const tagged = ids.length ? await q`update money.transactions set category = 'transfer' where account_id = ${account.id} and id in ${q(ids)} and category is distinct from 'transfer'` : { count: 0 }; console.log(`Tagged ${tagged.count} existing transfer rows.`); } if (drop) { for (const t of startRows) await q`delete from money.transactions where id = ${t.id} and account_id = ${account.id}`; const needed = residual!; if (adjustment && needed === 0) await q`delete from money.transactions where id = ${adjustment.id}`; else if (adjustment) await q`update money.transactions set amount_cents = ${needed} where id = ${adjustment.id}`; else if (needed !== 0) await q`insert into money.transactions (user_id, account_id, date, description, amount_cents, external_id, source, booking_status) values (${account.user_id}, ${account.id}, ${plan.coverageFrom ?? rows[0].date}, 'Bank balance adjustment', ${needed}, ${BALANCE_ADJUSTMENT_ID}, 'enablebanking', 'ADJ')`; const [check] = await q<{ total: number }[]>` select (a.opening_balance_cents + coalesce(sum(t.amount_cents) filter (where t.date <= ${today}), 0))::int as total from money.accounts a left join money.transactions t on t.account_id = a.id where a.id = ${account.id} group by a.id`; if (bank != null && check.total !== bank) throw new Error(`Total would be ${euro(check.total)}, bank ${euro(bank)}; rolled back`); console.log( `Removed start balance ${startRows.map((t) => '#' + t.id).join(', ') || '-'}; adjustment ${euro(adjustment?.amount_cents)} -> ${euro(needed)}; total ${euro(check.total)} = bank.` ); } }); } } finally { await sql.end(); }