113 lines
5.0 KiB
TypeScript
113 lines
5.0 KiB
TypeScript
// 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 '<redirect URL or code>' [--yes] finish a started consent
|
|
// ... scripts/bank-link.ts --map <uid|IBAN>=<Tracker account> [--map ...] override the mapping
|
|
// ... scripts/bank-link.ts --aspsp Revolut --country NL --user <auth user id>
|
|
//
|
|
// 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<string, string> = {};
|
|
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 <uid>=<Tracker account> 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();
|
|
}
|