// 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(); }