From 9a1b21ec39fb4a9b89931ee968ea662c4000f73d Mon Sep 17 00:00:00 2001 From: Stan Runge Date: Sat, 24 Oct 2026 00:50:45 +0000 Subject: [PATCH] money: Enable Banking client, bank-link consent CLI, bank-sync with reconcile, systemd timer (not installed) --- .env.example | 6 + bun.lock | 1 + deploy/systemd/money-sync.service | 13 + deploy/systemd/money-sync.timer | 10 + package.json | 1 + scripts/bank-link.ts | 112 +++++ scripts/bank-sync.ts | 48 ++ src/lib/server/bank-sync.test.ts | 176 ++++++++ src/lib/server/bank-sync.ts | 411 ++++++++++++++++++ src/lib/server/enable-banking.ts | 260 +++++++++++ .../bank-callback/+page.server.ts | 18 + .../money-tracker/bank-callback/+page.svelte | 53 +++ 12 files changed, 1109 insertions(+) create mode 100644 deploy/systemd/money-sync.service create mode 100644 deploy/systemd/money-sync.timer create mode 100644 scripts/bank-link.ts create mode 100644 scripts/bank-sync.ts create mode 100644 src/lib/server/bank-sync.test.ts create mode 100644 src/lib/server/bank-sync.ts create mode 100644 src/lib/server/enable-banking.ts create mode 100644 src/routes/projects/money-tracker/bank-callback/+page.server.ts create mode 100644 src/routes/projects/money-tracker/bank-callback/+page.svelte diff --git a/.env.example b/.env.example index 93df803..97f2cd4 100755 --- a/.env.example +++ b/.env.example @@ -39,3 +39,9 @@ HOUSE_WORKER_TOKEN= HOUSE_DISCORD_WEBHOOK_URL= HOUSE_NTFY_URL= HOUSE_NTFY_TOKEN= + +# Money tracker bank sync (scripts/bank-link.ts, scripts/bank-sync.ts; not read by the web app). +# Keep these in ~/.config/money-sync.env (mode 600), never in git. +ENABLEBANKING_APP_ID= +ENABLEBANKING_KEY_PATH= +ENABLEBANKING_REDIRECT_URL=https://stanrunge.dev/projects/money-tracker/bank-callback diff --git a/bun.lock b/bun.lock index 44e5a05..e87b93f 100644 --- a/bun.lock +++ b/bun.lock @@ -14,6 +14,7 @@ "drizzle-orm": "^1.0.0-rc.4", "echarts": "^6.1.0", "highlight.js": "^11.12.0", + "jose": "^6.2.10", "marked": "^18.0.11", "marked-highlight": "^2.2.4", "mode-watcher": "^1.1.0", diff --git a/deploy/systemd/money-sync.service b/deploy/systemd/money-sync.service new file mode 100644 index 0000000..c4f1db6 --- /dev/null +++ b/deploy/systemd/money-sync.service @@ -0,0 +1,13 @@ +[Unit] +Description=Money tracker bank sync (Enable Banking -> money.transactions) +Wants=network-online.target +After=network-online.target + +[Service] +Type=oneshot +# A checkout of stan/website (main once merged). Not the live dev tree. +WorkingDirectory=%h/dev/website-money +# DB_URL (app role), ENABLEBANKING_APP_ID, ENABLEBANKING_KEY_PATH; mode 600, never committed. +EnvironmentFile=%h/.config/money-sync.env +ExecStart=/usr/bin/bun scripts/bank-sync.ts +TimeoutStartSec=600 diff --git a/deploy/systemd/money-sync.timer b/deploy/systemd/money-sync.timer new file mode 100644 index 0000000..a60376e --- /dev/null +++ b/deploy/systemd/money-sync.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Money tracker bank sync, 4x per day (PSD2 allows ~4 unattended fetches/day) + +[Timer] +OnCalendar=*-*-* 06,11,16,21:20:00 Europe/Amsterdam +RandomizedDelaySec=300 +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/package.json b/package.json index 57bb621..c7422d6 100755 --- a/package.json +++ b/package.json @@ -92,6 +92,7 @@ "drizzle-orm": "^1.0.0-rc.4", "echarts": "^6.1.0", "highlight.js": "^11.12.0", + "jose": "^6.2.10", "marked": "^18.0.11", "marked-highlight": "^2.2.4", "mode-watcher": "^1.1.0", diff --git a/scripts/bank-link.ts b/scripts/bank-link.ts new file mode 100644 index 0000000..ed7850b --- /dev/null +++ b/scripts/bank-link.ts @@ -0,0 +1,112 @@ +// Link Revolut (personal + joint) to the money tracker through Enable Banking. +// +// bun --env-file=$HOME/.config/money-sync.env scripts/bank-link.ts interactive consent +// ... scripts/bank-link.ts --check verify key + app id only +// ... scripts/bank-link.ts --code '' [--yes] finish a started consent +// ... scripts/bank-link.ts --map = [--map ...] override the mapping +// ... scripts/bank-link.ts --aspsp Revolut --country NL --user +// +// Needs ENABLEBANKING_APP_ID, ENABLEBANKING_KEY_PATH, DB_URL (and optionally +// ENABLEBANKING_REDIRECT_URL). Never prints the private key. +import { createInterface } from 'node:readline/promises'; +import { randomUUID } from 'node:crypto'; +import postgres from 'postgres'; +import { + configFromEnv, + createEnableBankingClient, + parseAuthCode +} from '../src/lib/server/enable-banking'; +import { planAccountMapping, saveSession, trackerUserId } from '../src/lib/server/bank-sync'; + +const args = process.argv.slice(2); +const flag = (name: string) => args.includes(name); +const value = (name: string) => { + const i = args.indexOf(name); + return i >= 0 ? args[i + 1] : undefined; +}; +const overrides: Record = {}; +args.forEach((arg, i) => { + if (arg !== '--map') return; + const [key, ...rest] = (args[i + 1] ?? '').split('='); + if (!key) return; + overrides[key] = rest.join('='); + overrides[key.replace(/\s/g, '').toUpperCase()] = rest.join('='); +}); + +const client = createEnableBankingClient(configFromEnv()); +const aspsp = { name: value('--aspsp') ?? 'Revolut', country: value('--country') ?? 'NL' }; +const redirectUrl = + process.env.ENABLEBANKING_REDIRECT_URL ?? + 'https://stanrunge.dev/projects/money-tracker/bank-callback'; + +const app = await client.application(); +console.log( + `Enable Banking app "${app.name}" (${app.environment}, ${app.active ? 'active' : 'INACTIVE'}), services ${app.services.join(',')}` +); +const { aspsps } = await client.aspsps(aspsp.country); +const bank = aspsps.find((a) => a.name.toLowerCase() === aspsp.name.toLowerCase()); +if (!bank) throw new Error(`${aspsp.name} (${aspsp.country}) is not available for this app`); +console.log( + `${bank.name} ${bank.country}: max consent ${Math.floor(bank.maximum_consent_validity / 86400)} days` +); +if (flag('--check')) process.exit(0); + +const rl = createInterface({ input: process.stdin, output: process.stdout }); +let pasted = value('--code'); +if (!pasted) { + // Stay one hour under the bank's maximum so the API never rejects valid_until. + const validUntil = new Date(Date.now() + (bank.maximum_consent_validity - 3600) * 1000); + const auth = await client.startAuth({ + aspsp: { name: bank.name, country: bank.country }, + redirectUrl, + validUntil, + psuType: 'personal', + state: randomUUID() + }); + console.log('\n1. Open this link and approve access in the Revolut app.'); + console.log(' Tick BOTH your personal account and the joint account with Juhee:\n'); + console.log(' ' + auth.url + '\n'); + console.log(`2. You'll land on ${redirectUrl}?code=...`); + console.log( + ' Copy the full address from the browser bar (or just the code) and paste it here.' + ); + console.log(' The code expires quickly, so paste it within a few minutes.\n'); + pasted = await rl.question('Redirect URL or code: '); +} +const parsed = parseAuthCode(pasted ?? ''); +if (parsed.error) throw new Error(`Consent failed: ${parsed.error}`); +if (!parsed.code) throw new Error('No authorisation code found in what was pasted'); + +const session = await client.createSession(parsed.code); +console.log( + `\nSession authorised until ${session.access.valid_until}; ${session.accounts.length} account(s):` +); + +const sql = postgres(process.env.DB_URL!, { max: 1, onnotice: () => {} }); +try { + const userId = await trackerUserId(sql, value('--user')); + const tracker = await sql< + { id: number; name: string; scope: string; iban: string | null; external_uid: string | null }[] + >`select id, name, scope, iban, external_uid from money.accounts where user_id = ${userId} order by id`; + const plan = planAccountMapping(session.accounts, tracker, overrides); + for (const { bank: account, tracker: target, reason } of plan) { + const iban = account.account_id?.iban ?? '(no IBAN)'; + console.log( + ` ${account.uid} ${iban} ${account.currency ?? ''} ${account.name ?? ''}${account.details ? ' / ' + account.details : ''}` + + `\n -> ${target ? target.name : 'not linked'} (${reason})` + ); + } + const answer = flag('--yes') ? 'y' : await rl.question('\nSave this session and mapping? [y/N] '); + if (!/^y(es)?$/i.test(answer.trim())) { + console.log('Nothing saved. Re-run with --code is not possible (codes are single-use);'); + console.log('start again and use --map = to fix the mapping.'); + process.exit(1); + } + await saveSession(sql, userId, session, plan); + console.log( + 'Saved. Next: bun --env-file=$HOME/.config/money-sync.env scripts/bank-sync.ts --reconcile --dry-run' + ); +} finally { + rl.close(); + await sql.end(); +} diff --git a/scripts/bank-sync.ts b/scripts/bank-sync.ts new file mode 100644 index 0000000..16c2ede --- /dev/null +++ b/scripts/bank-sync.ts @@ -0,0 +1,48 @@ +// Pull booked bank transactions into money.transactions (Enable Banking). +// +// bun --env-file=$HOME/.config/money-sync.env scripts/bank-sync.ts [options] +// --dry-run fetch and report, write nothing +// --reconcile match against ALL hand-entered rows (account + amount + date ±1 day) +// and attach the bank id to them instead of inserting duplicates; +// run once after linking (with --dry-run first) +// --from YYYY-MM-DD override the start date (default: last synced date - 7 days, or the +// account's first transaction on the first run) +// --account only this tracker account +// +// Dedupe: unique (account_id, external_id); re-running is always safe. +import postgres from 'postgres'; +import { configFromEnv, createEnableBankingClient } from '../src/lib/server/enable-banking'; +import { syncBankAccounts } 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 from = value('--from'); +if (from && !/^\d{4}-\d{2}-\d{2}$/.test(from)) throw new Error('--from must be YYYY-MM-DD'); +if (!process.env.DB_URL) throw new Error('DB_URL is not set'); + +const client = createEnableBankingClient(configFromEnv()); +const sql = postgres(process.env.DB_URL, { max: 2, connect_timeout: 10, onnotice: () => {} }); +const dryRun = args.includes('--dry-run'); +try { + const results = await syncBankAccounts(sql, client, { + dryRun, + reconcile: args.includes('--reconcile'), + from, + accountName: value('--account') + }); + for (const r of results) { + console.log( + `${r.account}: fetched ${r.fetched}, booked ${r.booked}, new ${r.inserted}, ` + + `matched to existing ${r.matched}, already synced ${r.alreadyKnown}` + + (r.balance ? `, bank balance ${r.balance}` : '') + + (r.error ? `\n ERROR ${r.error}` : '') + ); + } + if (dryRun) console.log('(dry run: nothing written)'); + if (results.some((r) => r.error)) process.exitCode = 1; +} finally { + await sql.end(); +} diff --git a/src/lib/server/bank-sync.test.ts b/src/lib/server/bank-sync.test.ts new file mode 100644 index 0000000..43f0613 --- /dev/null +++ b/src/lib/server/bank-sync.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, it } from 'vitest'; +import { generateKeyPairSync } from 'node:crypto'; +import { decodeProtectedHeader, jwtVerify } from 'jose'; +import { + amountToCents, + externalIdFor, + findMatch, + mapBankTransaction, + planAccountMapping +} from './bank-sync'; +import { createEnableBankingClient, parseAuthCode, type BankTransaction } from './enable-banking'; + +const tx = (overrides: Partial = {}): BankTransaction => ({ + entry_reference: 'ref-1', + transaction_amount: { currency: 'EUR', amount: '12.34' }, + credit_debit_indicator: 'DBIT', + status: 'BOOK', + booking_date: '2026-10-20', + value_date: '2026-10-19', + creditor: { name: 'Albert Heijn' }, + remittance_information: ['AH 1234 Gouda'], + ...overrides +}); + +describe('bank transaction mapping', () => { + it('parses decimal amounts exactly', () => { + expect(amountToCents('12.34')).toBe(1234); + expect(amountToCents('-0.5')).toBe(-50); + expect(amountToCents('100')).toBe(10000); + expect(amountToCents('1.005')).toBe(101); + }); + + it('signs debits negative and uses the counterparty as description', () => { + const row = mapBankTransaction(tx())!; + expect(row).toMatchObject({ + externalId: 'ref-1', + date: '2026-10-20', + valueDate: '2026-10-19', + amountCents: -1234, + description: 'Albert Heijn', + counterparty: 'Albert Heijn', + bookingStatus: 'BOOK' + }); + const credit = mapBankTransaction( + tx({ credit_debit_indicator: 'CRDT', creditor: null, debtor: { name: 'Juhee' } }) + )!; + expect(credit.amountCents).toBe(1234); + expect(credit.description).toBe('Juhee'); + }); + + it('skips pending transactions', () => { + expect(mapBankTransaction(tx({ status: 'PDNG' }))).toBeNull(); + }); + + it('falls back to remittance info and to a stable content hash', () => { + const anonymous = tx({ entry_reference: null, creditor: null }); + const row = mapBankTransaction(anonymous)!; + expect(row.description).toBe('AH 1234 Gouda'); + expect(row.externalId).toMatch(/^h:[0-9a-f]{32}$/); + expect(externalIdFor(anonymous)).toBe(row.externalId); + expect( + externalIdFor({ ...anonymous, transaction_amount: { currency: 'EUR', amount: '1.00' } }) + ).not.toBe(row.externalId); + }); +}); + +describe('reconcile matching', () => { + const rows = [ + { id: 1, date: '2026-10-18', amountCents: -1234, description: 'Groceries' }, + { id: 2, date: '2026-10-21', amountCents: -1234, description: 'Albert Heijn' }, + { id: 3, date: '2026-10-20', amountCents: -999, description: 'Albert Heijn' } + ]; + it('matches same amount within one day, preferring description overlap', () => { + const m = findMatch( + { date: '2026-10-20', amountCents: -1234, description: 'Albert Heijn' }, + rows, + new Set() + ); + expect(m?.id).toBe(2); + }); + it('never reuses a row and ignores rows more than a day away', () => { + expect( + findMatch({ date: '2026-10-20', amountCents: -1234, description: 'x' }, rows, new Set([2])) + ).toBeNull(); + }); +}); + +describe('account mapping', () => { + const tracker = [ + { id: 1, name: 'Revolut', scope: 'personal', iban: null, external_uid: null }, + { id: 4, name: 'Joint', scope: 'joint', iban: null, external_uid: null } + ]; + it('maps the joint account and the personal EUR account', () => { + const plan = planAccountMapping( + [ + { uid: 'a', account_id: { iban: 'LT1' }, name: 'Stan Runge', currency: 'EUR' }, + { uid: 'b', account_id: { iban: 'LT2' }, name: 'Stan Runge & Juhee', currency: 'EUR' }, + { uid: 'c', account_id: { iban: 'LT3' }, name: 'Stan Runge', currency: 'USD' } + ], + tracker + ); + expect(plan.map((p) => [p.bank.uid, p.tracker?.name ?? null])).toEqual([ + ['b', 'Joint'], + ['a', 'Revolut'], + ['c', null] + ]); + }); + it('honours overrides and existing IBANs', () => { + const plan = planAccountMapping( + [ + { uid: 'a', account_id: { iban: 'LT1' }, name: 'X', currency: 'EUR' }, + { uid: 'b', account_id: { iban: 'LT2' }, name: 'Y', currency: 'EUR' } + ], + [{ ...tracker[0], iban: 'LT2' }, tracker[1]], + { a: 'Joint' } + ); + expect(plan.map((p) => [p.bank.uid, p.tracker?.name])).toEqual([ + ['a', 'Joint'], + ['b', 'Revolut'] + ]); + }); +}); + +describe('enable banking client', () => { + it('parses codes from redirect URLs, query strings and bare codes', () => { + expect( + parseAuthCode( + 'https://stanrunge.dev/projects/money-tracker/bank-callback?state=s&code=abc-123' + ) + ).toEqual({ + code: 'abc-123', + state: 's', + error: undefined + }); + expect(parseAuthCode('abc-123').code).toBe('abc-123'); + expect(parseAuthCode('?error=access_denied&error_description=Cancelled').error).toBe( + 'access_denied: Cancelled' + ); + }); + + it('signs RS256 JWTs with the app id as kid and pages through transactions', async () => { + const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); + const calls: string[] = []; + const fakeFetch = (async (url: string, init: RequestInit) => { + calls.push(url); + const auth = String((init.headers as Record).Authorization).slice(7); + expect(decodeProtectedHeader(auth)).toMatchObject({ + alg: 'RS256', + kid: 'app-id', + typ: 'JWT' + }); + const { payload } = await jwtVerify(auth, publicKey, { + issuer: 'enablebanking.com', + audience: 'api.enablebanking.com' + }); + expect(payload.exp! - payload.iat!).toBeLessThanOrEqual(86400); + const page = new URL(url).searchParams.get('continuation_key'); + return new Response( + JSON.stringify( + page + ? { transactions: [tx({ entry_reference: 'r2' })] } + : { transactions: [tx()], continuation_key: 'next' } + ) + ); + }) as unknown as typeof fetch; + const client = createEnableBankingClient({ + appId: 'app-id', + privateKeyPem: privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(), + fetch: fakeFetch + }); + const all = await client.transactions('uid-1', { dateFrom: '2026-01-01' }); + expect(all.map((t) => t.entry_reference)).toEqual(['ref-1', 'r2']); + expect(calls[1]).toContain('continuation_key=next'); + expect(calls[0]).toContain('date_from=2026-01-01'); + }); +}); diff --git a/src/lib/server/bank-sync.ts b/src/lib/server/bank-sync.ts new file mode 100644 index 0000000..9046151 --- /dev/null +++ b/src/lib/server/bank-sync.ts @@ -0,0 +1,411 @@ +// Bank -> money.transactions sync (Enable Banking). Pure mapping/matching helpers are +// exported for unit tests; the DB functions take a postgres.js `Sql` so they run the +// same from the bun scripts (scripts/bank-link.ts, scripts/bank-sync.ts). +import { createHash } from 'node:crypto'; +import type { Sql } from 'postgres'; +import type { + BankAccount, + BankTransaction, + EnableBankingClient, + SessionResponse +} from './enable-banking'; +import { EnableBankingError } from './enable-banking'; + +export type MappedTransaction = { + externalId: string; + date: string; + valueDate: string | null; + description: string; + counterparty: string | null; + amountCents: number; + bookingStatus: string; + raw: BankTransaction; +}; + +export function amountToCents(amount: string): number { + const raw = amount.trim(); + if (!/^-?\d+(?:\.\d+)?$/.test(raw)) throw new Error(`Invalid amount ${amount}`); + const negative = raw.startsWith('-'); + const [whole, fraction = ''] = raw.replace('-', '').split('.'); + const cents = Number(whole) * 100 + Number((fraction + '00').slice(0, 2)); + const rounded = Number(fraction.slice(2, 3) || '0') >= 5 ? cents + 1 : cents; + return negative ? -rounded : rounded; +} + +/** Stable id for a bank transaction: the bank's own reference, else a content hash. */ +export function externalIdFor(tx: BankTransaction): string { + const ref = tx.entry_reference?.trim() || tx.transaction_id?.trim(); + if (ref) return ref; + const fingerprint = [ + tx.booking_date ?? tx.transaction_date ?? tx.value_date ?? '', + tx.credit_debit_indicator, + tx.transaction_amount.amount, + tx.transaction_amount.currency, + tx.creditor?.name ?? '', + tx.debtor?.name ?? '', + (tx.remittance_information ?? []).join('|') + ].join('\u001f'); + return 'h:' + createHash('sha256').update(fingerprint).digest('hex').slice(0, 32); +} + +export function isBooked(tx: BankTransaction) { + return tx.status ? tx.status === 'BOOK' : Boolean(tx.booking_date); +} + +/** Maps one booked bank transaction to a tracker row, or null for pending ones. */ +export function mapBankTransaction(tx: BankTransaction): MappedTransaction | null { + if (!isBooked(tx)) return null; + const date = tx.booking_date ?? tx.transaction_date ?? tx.value_date; + if (!date) return null; + const magnitude = Math.abs(amountToCents(tx.transaction_amount.amount)); + const amountCents = tx.credit_debit_indicator === 'DBIT' ? -magnitude : magnitude; + const counterparty = + (tx.credit_debit_indicator === 'DBIT' ? tx.creditor?.name : tx.debtor?.name)?.trim() || null; + const remittance = (tx.remittance_information ?? []).map((line) => line.trim()).filter(Boolean); + const description = ( + counterparty || + remittance.join(' ') || + tx.bank_transaction_code?.description || + tx.note || + (amountCents < 0 ? 'Card payment' : 'Incoming transfer') + ).slice(0, 500); + return { + externalId: externalIdFor(tx), + date: date.slice(0, 10), + valueDate: tx.value_date?.slice(0, 10) ?? null, + description, + counterparty, + amountCents, + bookingStatus: tx.status ?? 'BOOK', + raw: tx + }; +} + +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 type ExistingRow = { id: number; date: string; amountCents: number; description: string }; + +/** + * Finds the hand-entered row a bank transaction corresponds to: same amount, date within + * ±1 day, not matched yet. Prefers the closest date, then a description overlap. + */ +export function findMatch( + tx: Pick, + candidates: ExistingRow[], + used: Set +): ExistingRow | null { + const words = new Set( + tx.description + .toLowerCase() + .split(/\W+/) + .filter((w) => w.length > 2) + ); + let best: { row: ExistingRow; score: number } | null = null; + for (const row of candidates) { + if (used.has(row.id) || row.amountCents !== tx.amountCents) continue; + const diff = dayDiff(row.date, tx.date); + if (diff > 1) continue; + const overlap = row.description + .toLowerCase() + .split(/\W+/) + .some((w) => words.has(w)); + const score = diff * 10 - (overlap ? 1 : 0) + row.id / 1e9; + if (!best || score < best.score) best = { row, score }; + } + return best?.row ?? null; +} + +export function isoDate(date: Date) { + return date.toISOString().slice(0, 10); +} + +export function addDays(date: string, days: number) { + return isoDate(new Date(Date.parse(date + 'T00:00:00Z') + days * 86_400_000)); +} + +/** Heuristic: is this bank account the joint one shared with Juhee? */ +export function looksJoint(account: BankAccount) { + const text = [account.name, account.details, account.product].filter(Boolean).join(' '); + return /joint|gezamenlijk|shared|\bjuhee\b|\s&\s|\sand\s|,/i.test(text); +} + +type TrackerAccount = { + id: number; + name: string; + scope: string; + iban: string | null; + external_uid: string | null; +}; + +/** + * 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. + */ +export function planAccountMapping( + bankAccounts: BankAccount[], + tracker: TrackerAccount[], + overrides: Record = {} +) { + const byName = new Map(tracker.map((a) => [a.name.toLowerCase(), a])); + const used = new Set(); + const plan: { bank: BankAccount; tracker: TrackerAccount | null; reason: string }[] = []; + const take = (bank: BankAccount, account: TrackerAccount | undefined | null, reason: string) => { + if (account && !used.has(account.id)) { + used.add(account.id); + plan.push({ bank, tracker: account, reason }); + return true; + } + return false; + }; + const pending: BankAccount[] = []; + for (const bank of bankAccounts) { + const iban = bank.account_id?.iban?.replace(/\s/g, '').toUpperCase(); + const override = overrides[bank.uid ?? ''] ?? (iban ? overrides[iban] : undefined); + if (override !== undefined) { + if (override === '' || override === '-') + plan.push({ bank, tracker: null, reason: 'skipped (override)' }); + else if (!take(bank, byName.get(override.toLowerCase()), 'override')) + plan.push({ bank, tracker: null, reason: `override target "${override}" not found/used` }); + continue; + } + if ( + iban && + take( + bank, + tracker.find((a) => a.iban?.replace(/\s/g, '').toUpperCase() === iban), + 'same IBAN' + ) + ) + continue; + pending.push(bank); + } + const eur = (bank: BankAccount) => !bank.currency || bank.currency === 'EUR'; + // Joint first, so a personal-looking account listed earlier can't take "Revolut" from it. + const rest = pending.filter( + (bank) => + !( + eur(bank) && + looksJoint(bank) && + take( + bank, + tracker.find((a) => a.scope === 'joint'), + 'joint account' + ) + ) + ); + for (const bank of rest) { + if ( + eur(bank) && + bank.cash_account_type !== 'SVGS' && + take(bank, byName.get('revolut'), 'personal EUR account') + ) + continue; + plan.push({ bank, tracker: null, reason: 'no match (use --map uid=Account)' }); + } + return plan; +} + +export async function trackerUserId(sql: Sql, explicit?: string) { + if (explicit) return explicit; + const rows = await sql<{ user_id: string }[]>`select distinct user_id from money.accounts`; + if (rows.length !== 1) + throw new Error(`Expected one money-tracker user, found ${rows.length}; pass --user `); + return rows[0].user_id; +} + +/** Stores a freshly authorised session and links bank accounts to tracker accounts. */ +export async function saveSession( + sql: Sql, + userId: string, + session: SessionResponse, + plan: ReturnType +) { + const accounts = session.accounts.map((a) => ({ + uid: a.uid ?? null, + iban: a.account_id?.iban ?? null, + name: a.name ?? null, + details: a.details ?? null, + product: a.product ?? null, + currency: a.currency ?? null, + cash_account_type: a.cash_account_type ?? null, + identification_hash: a.identification_hash ?? null + })); + await sql.begin(async (tx) => { + await tx` + insert into money.bank_sessions + (user_id, provider, session_id, aspsp_name, aspsp_country, psu_type, status, valid_until, accounts) + values (${userId}, 'enablebanking', ${session.session_id}, ${session.aspsp.name}, + ${session.aspsp.country}, ${session.psu_type}, 'authorized', ${session.access.valid_until}, + ${tx.json(accounts)}) + on conflict (session_id) do update set status = 'authorized', valid_until = excluded.valid_until, + accounts = excluded.accounts, last_error = null`; + for (const { bank, tracker } of plan) { + if (!tracker || !bank.uid) continue; + // Account uids are per session: free the uid from any older mapping first. + await tx`update money.accounts set external_uid = null where external_uid = ${bank.uid} and id <> ${tracker.id}`; + await tx` + update money.accounts + set external_uid = ${bank.uid}, provider = 'enablebanking', + iban = coalesce(${bank.account_id?.iban ?? null}, iban) + where id = ${tracker.id} and user_id = ${userId}`; + } + }); +} + +export type SyncOptions = { + dryRun?: boolean; + /** Match against the whole history of hand-entered rows (one-time), not just the window. */ + reconcile?: boolean; + from?: string; + accountName?: string; + log?: (line: string) => void; + today?: string; +}; + +export type AccountSyncResult = { + account: string; + fetched: number; + booked: number; + inserted: number; + matched: number; + alreadyKnown: number; + balance?: string; + error?: string; +}; + +export async function syncBankAccounts( + sql: Sql, + client: EnableBankingClient, + options: SyncOptions = {} +) { + const log = options.log ?? console.log; + const today = options.today ?? isoDate(new Date()); + const accounts = await sql< + { id: number; user_id: string; name: string; external_uid: string }[] + >`select id, user_id, name, external_uid from money.accounts + where provider = 'enablebanking' and external_uid is not null order by id`; + const selected = options.accountName + ? accounts.filter((a) => a.name.toLowerCase() === options.accountName!.toLowerCase()) + : accounts; + if (!selected.length) { + log('No linked bank accounts. Run scripts/bank-link.ts first.'); + return []; + } + const results: AccountSyncResult[] = []; + for (const account of selected) { + const result: AccountSyncResult = { + account: account.name, + fetched: 0, + booked: 0, + inserted: 0, + matched: 0, + alreadyKnown: 0 + }; + results.push(result); + try { + 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))); + let raw: BankTransaction[]; + try { + raw = await client.transactions(account.external_uid, { 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`); + 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; + if (!mapped.length) continue; + + const ids = mapped.map((t) => t.externalId); + const known = new Set( + ( + await sql<{ external_id: string }[]>` + select external_id from money.transactions + where account_id = ${account.id} and external_id = any(${ids})` + ).map((r) => r.external_id) + ); + const minDate = mapped.reduce((m, t) => (t.date < m ? t.date : m), mapped[0].date); + const candidates = await sql` + 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)}`; + const used = new Set(); + const inserts: MappedTransaction[] = []; + const updates: { id: number; tx: MappedTransaction }[] = []; + // Oldest first so matches are claimed in chronological order. + for (const tx of [...mapped].sort((a, b) => a.date.localeCompare(b.date))) { + if (known.has(tx.externalId)) { + result.alreadyKnown++; + continue; + } + known.add(tx.externalId); + const match = findMatch(tx, candidates, used); + if (match) { + used.add(match.id); + updates.push({ id: match.id, tx }); + } else inserts.push(tx); + } + result.matched = updates.length; + result.inserted = inserts.length; + if (!options.dryRun) { + await sql.begin(async (q) => { + for (const { id, tx } of updates) { + // Keep Stan's own description/category; attach the bank identity. + await q` + update money.transactions set external_id = ${tx.externalId}, + counterparty = coalesce(counterparty, ${tx.counterparty}), + booking_status = ${tx.bookingStatus}, value_date = ${tx.valueDate}, + raw = ${q.json(tx.raw as never)} + where id = ${id} and external_id is null`; + } + for (const tx of inserts) { + await q` + insert into money.transactions + (user_id, account_id, date, description, amount_cents, external_id, source, + booking_status, counterparty, raw, value_date) + values (${account.user_id}, ${account.id}, ${tx.date}, ${tx.description}, + ${tx.amountCents}, ${tx.externalId}, 'enablebanking', ${tx.bookingStatus}, + ${tx.counterparty}, ${q.json(tx.raw as never)}, ${tx.valueDate}) + on conflict (account_id, external_id) do nothing`; + } + }); + } + try { + const { balances } = await client.balances(account.external_uid); + const preferred = + balances.find((b) => b.balance_type === 'CLBD') ?? + balances.find((b) => b.balance_type === 'ITAV') ?? + balances[0]; + if (preferred) + result.balance = `${preferred.balance_amount.amount} ${preferred.balance_amount.currency} (${preferred.balance_type})`; + } catch { + /* balances are informational only */ + } + } catch (error) { + result.error = error instanceof Error ? error.message : String(error); + } + } + 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'`; + } + return results; +} diff --git a/src/lib/server/enable-banking.ts b/src/lib/server/enable-banking.ts new file mode 100644 index 0000000..8233b38 --- /dev/null +++ b/src/lib/server/enable-banking.ts @@ -0,0 +1,260 @@ +// Minimal Enable Banking (https://enablebanking.com/docs/api/reference/) client for +// account information (AIS): consent (POST /auth + POST /sessions), balances and +// transactions. Every request carries a short-lived RS256 JWT signed with the +// application's private key; the key never leaves this process. +// +// Framework-free on purpose: used by the SvelteKit callback route and by the +// bun scripts in scripts/ (bank-link.ts, bank-sync.ts), which don't load $app/env. +import { readFileSync } from 'node:fs'; +import { createPrivateKey, randomUUID, type KeyObject } from 'node:crypto'; +import { SignJWT } from 'jose'; + +export const ENABLE_BANKING_API = 'https://api.enablebanking.com'; + +export type EnableBankingConfig = { + appId: string; + /** PEM private key (PKCS#1 or PKCS#8). Prefer keyPath so the key isn't in env. */ + privateKeyPem?: string; + keyPath?: string; + baseUrl?: string; + fetch?: typeof fetch; +}; + +export type Amount = { currency: string; amount: string }; + +export type BankAccount = { + uid?: string; + account_id?: { iban?: string; other?: { identification: string; scheme_name: string } }; + name?: string; + details?: string; + product?: string; + currency?: string; + cash_account_type?: string; + usage?: string; + identification_hash?: string; +}; + +export type BankTransaction = { + entry_reference?: string | null; + transaction_id?: string | null; + transaction_amount: Amount; + credit_debit_indicator: 'CRDT' | 'DBIT'; + status?: string; + booking_date?: string | null; + value_date?: string | null; + transaction_date?: string | null; + creditor?: { name?: string | null } | null; + debtor?: { name?: string | null } | null; + creditor_account?: { iban?: string | null } | null; + debtor_account?: { iban?: string | null } | null; + remittance_information?: string[] | null; + bank_transaction_code?: { description?: string | null } | null; + merchant_category_code?: string | null; + note?: string | null; + [key: string]: unknown; +}; + +export type Balance = { + name?: string; + balance_amount: Amount; + balance_type: string; + reference_date?: string | null; + last_change_date_time?: string | null; +}; + +export type StartAuthResponse = { url: string; authorization_id: string; psu_id_hash?: string }; +export type SessionResponse = { + session_id: string; + accounts: BankAccount[]; + aspsp: { name: string; country: string }; + psu_type: string; + access: { valid_until: string }; +}; + +export class EnableBankingError extends Error { + constructor( + readonly status: number, + readonly code: string | undefined, + message: string + ) { + super(message); + this.name = 'EnableBankingError'; + } +} + +/** Reads ENABLEBANKING_APP_ID and ENABLEBANKING_KEY_PATH / ENABLEBANKING_PRIVATE_KEY. */ +export function configFromEnv(env: Record = process.env) { + const appId = env.ENABLEBANKING_APP_ID?.trim(); + if (!appId) throw new Error('ENABLEBANKING_APP_ID is not set'); + const keyPath = env.ENABLEBANKING_KEY_PATH?.trim(); + const privateKeyPem = env.ENABLEBANKING_PRIVATE_KEY?.replace(/\\n/g, '\n'); + if (!keyPath && !privateKeyPem) + throw new Error('Set ENABLEBANKING_KEY_PATH (preferred) or ENABLEBANKING_PRIVATE_KEY'); + return { appId, keyPath, privateKeyPem } satisfies EnableBankingConfig; +} + +export function createEnableBankingClient(config: EnableBankingConfig) { + const baseUrl = config.baseUrl ?? ENABLE_BANKING_API; + const doFetch = config.fetch ?? fetch; + let key: KeyObject | undefined; + let cached: { token: string; expires: number } | undefined; + + function privateKey() { + if (!key) { + const pem = + config.privateKeyPem ?? (config.keyPath ? readFileSync(config.keyPath, 'utf8') : ''); + if (!pem) throw new Error('No Enable Banking private key configured'); + key = createPrivateKey(pem); + } + return key; + } + + /** RS256 JWT, valid for one hour (the API accepts at most 24 h). */ + async function token() { + const now = Math.floor(Date.now() / 1000); + if (cached && cached.expires - 60 > now) return cached.token; + const jwt = await new SignJWT({}) + .setProtectedHeader({ typ: 'JWT', alg: 'RS256', kid: config.appId }) + .setIssuer('enablebanking.com') + .setAudience('api.enablebanking.com') + .setIssuedAt(now) + .setExpirationTime(now + 3600) + .sign(privateKey()); + cached = { token: jwt, expires: now + 3600 }; + return jwt; + } + + async function request(method: string, path: string, body?: unknown): Promise { + const response = await doFetch(baseUrl + path, { + method, + headers: { + Authorization: `Bearer ${await token()}`, + Accept: 'application/json', + ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) + }, + body: body === undefined ? undefined : JSON.stringify(body), + signal: AbortSignal.timeout(60_000) + }); + const text = await response.text(); + let data: unknown; + try { + data = text ? JSON.parse(text) : undefined; + } catch { + data = text; + } + if (!response.ok) { + const detail = (data ?? {}) as { message?: string; error?: string; detail?: unknown }; + throw new EnableBankingError( + response.status, + detail.error, + `Enable Banking ${method} ${path.split('?')[0]} failed (${response.status}${detail.error ? ' ' + detail.error : ''}): ${detail.message ?? text.slice(0, 300)}` + ); + } + return data as T; + } + + return { + token, + application: () => + request<{ + name: string; + kid: string; + environment: string; + active: boolean; + redirect_urls: string[]; + countries: string[]; + services: string[]; + }>('GET', '/application'), + aspsps: (country: string, psuType = 'personal') => + request<{ + aspsps: { + name: string; + country: string; + maximum_consent_validity: number; + psu_types: string[]; + }[]; + }>('GET', `/aspsps?${new URLSearchParams({ country, psu_type: psuType, service: 'AIS' })}`), + startAuth: (input: { + aspsp: { name: string; country: string }; + redirectUrl: string; + validUntil: Date; + psuType?: 'personal' | 'business'; + state?: string; + language?: string; + }) => + request('POST', '/auth', { + access: { valid_until: input.validUntil.toISOString() }, + aspsp: input.aspsp, + state: input.state ?? randomUUID(), + redirect_url: input.redirectUrl, + psu_type: input.psuType ?? 'personal', + ...(input.language ? { language: input.language } : {}) + }), + createSession: (code: string) => request('POST', '/sessions', { code }), + getSession: (sessionId: string) => + request<{ + status: string; + accounts: string[]; + access: { valid_until: string }; + aspsp: { name: string; country: string }; + }>('GET', `/sessions/${encodeURIComponent(sessionId)}`), + deleteSession: (sessionId: string) => + request<{ message: string }>('DELETE', `/sessions/${encodeURIComponent(sessionId)}`), + accountDetails: (uid: string) => + request('GET', `/accounts/${encodeURIComponent(uid)}/details`), + balances: (uid: string) => + request<{ balances: Balance[] }>('GET', `/accounts/${encodeURIComponent(uid)}/balances`), + /** All transactions from dateFrom (inclusive), following continuation_key pages. */ + async transactions( + uid: string, + options: { + dateFrom?: string; + dateTo?: string; + status?: 'BOOK' | 'PDNG'; + maxPages?: number; + } = {} + ) { + const all: BankTransaction[] = []; + let continuationKey: string | undefined; + for (let page = 0; page < (options.maxPages ?? 200); page++) { + const query = new URLSearchParams(); + 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 (continuationKey) query.set('continuation_key', continuationKey); + const result = await request<{ + transactions: BankTransaction[]; + continuation_key?: string | null; + }>( + 'GET', + `/accounts/${encodeURIComponent(uid)}/transactions${query.size ? '?' + query : ''}` + ); + all.push(...(result.transactions ?? [])); + continuationKey = result.continuation_key ?? undefined; + if (!continuationKey) break; + } + return all; + } + }; +} + +export type EnableBankingClient = ReturnType; + +/** Extracts the authorisation code from a pasted redirect URL, query string or bare code. */ +export function parseAuthCode(input: string): { code?: string; error?: string; state?: string } { + const value = input.trim(); + if (!value) return {}; + if (/^[\w-]+$/.test(value) && !value.includes('=')) return { code: value }; + let params: URLSearchParams; + try { + params = new URL(value).searchParams; + } catch { + params = new URLSearchParams(value.replace(/^.*\?/, '')); + } + const error = params.get('error'); + return { + code: params.get('code') ?? undefined, + state: params.get('state') ?? undefined, + error: error ? `${error}: ${params.get('error_description') ?? ''}`.trim() : undefined + }; +} diff --git a/src/routes/projects/money-tracker/bank-callback/+page.server.ts b/src/routes/projects/money-tracker/bank-callback/+page.server.ts new file mode 100644 index 0000000..f6c841b --- /dev/null +++ b/src/routes/projects/money-tracker/bank-callback/+page.server.ts @@ -0,0 +1,18 @@ +import { requireAdmin } from '#lib/server/authorization.js'; +import { redirect } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; + +// Enable Banking redirects here after Revolut consent. The code is single-use and only +// shown to the admin, who pastes it into scripts/bank-link.ts on the server. +export const load: PageServerLoad = async (event) => { + if (!event.locals.user) + redirect(303, '/login?returnTo=' + encodeURIComponent(event.url.pathname + event.url.search)); + requireAdmin(event); + const params = event.url.searchParams; + return { + code: params.get('code')?.slice(0, 500) ?? '', + error: params.get('error')?.slice(0, 200) ?? '', + errorDescription: params.get('error_description')?.slice(0, 500) ?? '', + fullUrl: event.url.href + }; +}; diff --git a/src/routes/projects/money-tracker/bank-callback/+page.svelte b/src/routes/projects/money-tracker/bank-callback/+page.svelte new file mode 100644 index 0000000..ae2119c --- /dev/null +++ b/src/routes/projects/money-tracker/bank-callback/+page.svelte @@ -0,0 +1,53 @@ + + +Bank connection · Money tracker + +
+

Bank connection

+ {#if data.error} + Revolut did not grant access{data.error}{data.errorDescription ? `: ${data.errorDescription}` : ''}. Run + scripts/bank-link.ts again to retry. + {:else if data.code} + Almost doneCopy this address and paste it into the waiting bank-link prompt. The code is + single-use and expires within minutes.{data.fullUrl} + {:else} +

+ No authorisation code in this link. Start a connection with scripts/bank-link.ts. +

+ {/if} +

+ Back to the money tracker +

+