money: Enable Banking client, bank-link consent CLI, bank-sync with reconcile, systemd timer (not installed)

This commit is contained in:
2026-10-24 00:50:45 +00:00
parent b1ce715ec7
commit 9a1b21ec39
12 changed files with 1109 additions and 0 deletions

112
scripts/bank-link.ts Normal file
View File

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

48
scripts/bank-sync.ts Normal file
View File

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