All checks were successful
Deploy production / deploy (push) Successful in 2m49s
162 lines
7.3 KiB
TypeScript
162 lines
7.3 KiB
TypeScript
// 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 '<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 '<ASPSP name>' --account <Tracker account> --country NL --user <id>
|
|
//
|
|
// 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<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('=');
|
|
});
|
|
|
|
// Enable Banking ASPSP names (GET /aspsps?country=NL) and the tracker account they feed.
|
|
const BANKS: Record<string, { aspsp: string; account: string; hint: string }> = {
|
|
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'; // registered with Enable Banking; 308s to /trackers/money/bank-callback with the query kept
|
|
|
|
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 <uid>=<Tracker account> 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();
|
|
}
|