// Link a bank to the money tracker through Enable Banking. Each bank gets its own session; // linking one never touches another bank's session. // // bun --env-file=$HOME/.config/money-sync.env scripts/bank-link.ts Revolut (personal + joint) // ... scripts/bank-link.ts --bank abn ABN AMRO -> "ABN Amro" // ... scripts/bank-link.ts --bank knab Knab -> "Knab" // ... 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 '' --account --country NL --user // // Then sync within the hour: the first sync of an account asks for the longest history the // bank serves, and most banks only serve ~90 days once consent is older than an hour. // // 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('='); }); // Enable Banking ASPSP names (GET /aspsps?country=NL) and the tracker account they feed. const BANKS: Record = { revolut: { aspsp: 'Revolut', account: 'Revolut', hint: 'Tick BOTH your personal account and the joint account with Juhee.' }, abn: { aspsp: 'ABN AMRO', account: 'ABN Amro', hint: 'Log in with the ABN AMRO app or identifier and approve your current account.' }, knab: { aspsp: 'Knab', account: 'Knab', hint: 'Approve your Knab current account (savings accounts are not needed).' } }; const bankKey = (value('--bank') ?? 'revolut').toLowerCase().replace(/[\s-]|amro/g, ''); const preset = value('--aspsp') && !value('--bank') ? undefined : BANKS[bankKey]; if (!preset && !value('--aspsp')) throw new Error(`Unknown --bank ${value('--bank')}; use ${Object.keys(BANKS).join(', ')}`); const client = createEnableBankingClient(configFromEnv()); const aspsp = { name: value('--aspsp') ?? preset!.aspsp, country: value('--country') ?? 'NL' }; const defaultAccount = value('--account') ?? preset?.account ?? aspsp.name; 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}${bank.bic ? ' ' + bank.bic : ''}: max consent ` + `${Math.floor(bank.maximum_consent_validity / 86400)} days${bank.beta ? ' (beta)' : ''}` + (bank.required_psu_headers?.length ? `; requires PSU headers ${bank.required_psu_headers.join(', ')} (background syncs ` + 'run as "PSU not present", which PSD2 limits to ~4 calls a day per account)' : '') ); console.log( `Linked accounts go to tracker account "${defaultAccount}" unless --map says otherwise.` ); 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 at ${bank.name}.`); console.log(` ${preset?.hint ?? 'Approve the account(s) you want to track.'}\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, { defaultAccount, joint: bank.name.toLowerCase() === 'revolut' }); 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); const linked = plan.filter((p) => p.tracker).map((p) => p.tracker!.name); const only = linked.length === 1 ? ` --account '${linked[0]}'` : ''; console.log('Saved. Within the next hour (full history is only served right after consent):'); console.log( ` bun --env-file=$HOME/.config/money-sync.env scripts/bank-sync.ts --reconcile --dry-run${only}` ); console.log( ` bun --env-file=$HOME/.config/money-sync.env scripts/bank-sync.ts --reconcile${only}` ); } finally { rl.close(); await sql.end(); }