money: Enable Banking client, bank-link consent CLI, bank-sync with reconcile, systemd timer (not installed)
This commit is contained in:
@@ -39,3 +39,9 @@ HOUSE_WORKER_TOKEN=
|
|||||||
HOUSE_DISCORD_WEBHOOK_URL=
|
HOUSE_DISCORD_WEBHOOK_URL=
|
||||||
HOUSE_NTFY_URL=
|
HOUSE_NTFY_URL=
|
||||||
HOUSE_NTFY_TOKEN=
|
HOUSE_NTFY_TOKEN=
|
||||||
|
|
||||||
|
# Money tracker bank sync (scripts/bank-link.ts, scripts/bank-sync.ts; not read by the web app).
|
||||||
|
# Keep these in ~/.config/money-sync.env (mode 600), never in git.
|
||||||
|
ENABLEBANKING_APP_ID=
|
||||||
|
ENABLEBANKING_KEY_PATH=
|
||||||
|
ENABLEBANKING_REDIRECT_URL=https://stanrunge.dev/projects/money-tracker/bank-callback
|
||||||
|
|||||||
1
bun.lock
1
bun.lock
@@ -14,6 +14,7 @@
|
|||||||
"drizzle-orm": "^1.0.0-rc.4",
|
"drizzle-orm": "^1.0.0-rc.4",
|
||||||
"echarts": "^6.1.0",
|
"echarts": "^6.1.0",
|
||||||
"highlight.js": "^11.12.0",
|
"highlight.js": "^11.12.0",
|
||||||
|
"jose": "^6.2.10",
|
||||||
"marked": "^18.0.11",
|
"marked": "^18.0.11",
|
||||||
"marked-highlight": "^2.2.4",
|
"marked-highlight": "^2.2.4",
|
||||||
"mode-watcher": "^1.1.0",
|
"mode-watcher": "^1.1.0",
|
||||||
|
|||||||
13
deploy/systemd/money-sync.service
Normal file
13
deploy/systemd/money-sync.service
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Money tracker bank sync (Enable Banking -> money.transactions)
|
||||||
|
Wants=network-online.target
|
||||||
|
After=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
# A checkout of stan/website (main once merged). Not the live dev tree.
|
||||||
|
WorkingDirectory=%h/dev/website-money
|
||||||
|
# DB_URL (app role), ENABLEBANKING_APP_ID, ENABLEBANKING_KEY_PATH; mode 600, never committed.
|
||||||
|
EnvironmentFile=%h/.config/money-sync.env
|
||||||
|
ExecStart=/usr/bin/bun scripts/bank-sync.ts
|
||||||
|
TimeoutStartSec=600
|
||||||
10
deploy/systemd/money-sync.timer
Normal file
10
deploy/systemd/money-sync.timer
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Money tracker bank sync, 4x per day (PSD2 allows ~4 unattended fetches/day)
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-* 06,11,16,21:20:00 Europe/Amsterdam
|
||||||
|
RandomizedDelaySec=300
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
@@ -92,6 +92,7 @@
|
|||||||
"drizzle-orm": "^1.0.0-rc.4",
|
"drizzle-orm": "^1.0.0-rc.4",
|
||||||
"echarts": "^6.1.0",
|
"echarts": "^6.1.0",
|
||||||
"highlight.js": "^11.12.0",
|
"highlight.js": "^11.12.0",
|
||||||
|
"jose": "^6.2.10",
|
||||||
"marked": "^18.0.11",
|
"marked": "^18.0.11",
|
||||||
"marked-highlight": "^2.2.4",
|
"marked-highlight": "^2.2.4",
|
||||||
"mode-watcher": "^1.1.0",
|
"mode-watcher": "^1.1.0",
|
||||||
|
|||||||
112
scripts/bank-link.ts
Normal file
112
scripts/bank-link.ts
Normal 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
48
scripts/bank-sync.ts
Normal 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();
|
||||||
|
}
|
||||||
176
src/lib/server/bank-sync.test.ts
Normal file
176
src/lib/server/bank-sync.test.ts
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { generateKeyPairSync } from 'node:crypto';
|
||||||
|
import { decodeProtectedHeader, jwtVerify } from 'jose';
|
||||||
|
import {
|
||||||
|
amountToCents,
|
||||||
|
externalIdFor,
|
||||||
|
findMatch,
|
||||||
|
mapBankTransaction,
|
||||||
|
planAccountMapping
|
||||||
|
} from './bank-sync';
|
||||||
|
import { createEnableBankingClient, parseAuthCode, type BankTransaction } from './enable-banking';
|
||||||
|
|
||||||
|
const tx = (overrides: Partial<BankTransaction> = {}): BankTransaction => ({
|
||||||
|
entry_reference: 'ref-1',
|
||||||
|
transaction_amount: { currency: 'EUR', amount: '12.34' },
|
||||||
|
credit_debit_indicator: 'DBIT',
|
||||||
|
status: 'BOOK',
|
||||||
|
booking_date: '2026-10-20',
|
||||||
|
value_date: '2026-10-19',
|
||||||
|
creditor: { name: 'Albert Heijn' },
|
||||||
|
remittance_information: ['AH 1234 Gouda'],
|
||||||
|
...overrides
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('bank transaction mapping', () => {
|
||||||
|
it('parses decimal amounts exactly', () => {
|
||||||
|
expect(amountToCents('12.34')).toBe(1234);
|
||||||
|
expect(amountToCents('-0.5')).toBe(-50);
|
||||||
|
expect(amountToCents('100')).toBe(10000);
|
||||||
|
expect(amountToCents('1.005')).toBe(101);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('signs debits negative and uses the counterparty as description', () => {
|
||||||
|
const row = mapBankTransaction(tx())!;
|
||||||
|
expect(row).toMatchObject({
|
||||||
|
externalId: 'ref-1',
|
||||||
|
date: '2026-10-20',
|
||||||
|
valueDate: '2026-10-19',
|
||||||
|
amountCents: -1234,
|
||||||
|
description: 'Albert Heijn',
|
||||||
|
counterparty: 'Albert Heijn',
|
||||||
|
bookingStatus: 'BOOK'
|
||||||
|
});
|
||||||
|
const credit = mapBankTransaction(
|
||||||
|
tx({ credit_debit_indicator: 'CRDT', creditor: null, debtor: { name: 'Juhee' } })
|
||||||
|
)!;
|
||||||
|
expect(credit.amountCents).toBe(1234);
|
||||||
|
expect(credit.description).toBe('Juhee');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips pending transactions', () => {
|
||||||
|
expect(mapBankTransaction(tx({ status: 'PDNG' }))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to remittance info and to a stable content hash', () => {
|
||||||
|
const anonymous = tx({ entry_reference: null, creditor: null });
|
||||||
|
const row = mapBankTransaction(anonymous)!;
|
||||||
|
expect(row.description).toBe('AH 1234 Gouda');
|
||||||
|
expect(row.externalId).toMatch(/^h:[0-9a-f]{32}$/);
|
||||||
|
expect(externalIdFor(anonymous)).toBe(row.externalId);
|
||||||
|
expect(
|
||||||
|
externalIdFor({ ...anonymous, transaction_amount: { currency: 'EUR', amount: '1.00' } })
|
||||||
|
).not.toBe(row.externalId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('reconcile matching', () => {
|
||||||
|
const rows = [
|
||||||
|
{ id: 1, date: '2026-10-18', amountCents: -1234, description: 'Groceries' },
|
||||||
|
{ id: 2, date: '2026-10-21', amountCents: -1234, description: 'Albert Heijn' },
|
||||||
|
{ id: 3, date: '2026-10-20', amountCents: -999, description: 'Albert Heijn' }
|
||||||
|
];
|
||||||
|
it('matches same amount within one day, preferring description overlap', () => {
|
||||||
|
const m = findMatch(
|
||||||
|
{ date: '2026-10-20', amountCents: -1234, description: 'Albert Heijn' },
|
||||||
|
rows,
|
||||||
|
new Set()
|
||||||
|
);
|
||||||
|
expect(m?.id).toBe(2);
|
||||||
|
});
|
||||||
|
it('never reuses a row and ignores rows more than a day away', () => {
|
||||||
|
expect(
|
||||||
|
findMatch({ date: '2026-10-20', amountCents: -1234, description: 'x' }, rows, new Set([2]))
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('account mapping', () => {
|
||||||
|
const tracker = [
|
||||||
|
{ id: 1, name: 'Revolut', scope: 'personal', iban: null, external_uid: null },
|
||||||
|
{ id: 4, name: 'Joint', scope: 'joint', iban: null, external_uid: null }
|
||||||
|
];
|
||||||
|
it('maps the joint account and the personal EUR account', () => {
|
||||||
|
const plan = planAccountMapping(
|
||||||
|
[
|
||||||
|
{ uid: 'a', account_id: { iban: 'LT1' }, name: 'Stan Runge', currency: 'EUR' },
|
||||||
|
{ uid: 'b', account_id: { iban: 'LT2' }, name: 'Stan Runge & Juhee', currency: 'EUR' },
|
||||||
|
{ uid: 'c', account_id: { iban: 'LT3' }, name: 'Stan Runge', currency: 'USD' }
|
||||||
|
],
|
||||||
|
tracker
|
||||||
|
);
|
||||||
|
expect(plan.map((p) => [p.bank.uid, p.tracker?.name ?? null])).toEqual([
|
||||||
|
['b', 'Joint'],
|
||||||
|
['a', 'Revolut'],
|
||||||
|
['c', null]
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
it('honours overrides and existing IBANs', () => {
|
||||||
|
const plan = planAccountMapping(
|
||||||
|
[
|
||||||
|
{ uid: 'a', account_id: { iban: 'LT1' }, name: 'X', currency: 'EUR' },
|
||||||
|
{ uid: 'b', account_id: { iban: 'LT2' }, name: 'Y', currency: 'EUR' }
|
||||||
|
],
|
||||||
|
[{ ...tracker[0], iban: 'LT2' }, tracker[1]],
|
||||||
|
{ a: 'Joint' }
|
||||||
|
);
|
||||||
|
expect(plan.map((p) => [p.bank.uid, p.tracker?.name])).toEqual([
|
||||||
|
['a', 'Joint'],
|
||||||
|
['b', 'Revolut']
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('enable banking client', () => {
|
||||||
|
it('parses codes from redirect URLs, query strings and bare codes', () => {
|
||||||
|
expect(
|
||||||
|
parseAuthCode(
|
||||||
|
'https://stanrunge.dev/projects/money-tracker/bank-callback?state=s&code=abc-123'
|
||||||
|
)
|
||||||
|
).toEqual({
|
||||||
|
code: 'abc-123',
|
||||||
|
state: 's',
|
||||||
|
error: undefined
|
||||||
|
});
|
||||||
|
expect(parseAuthCode('abc-123').code).toBe('abc-123');
|
||||||
|
expect(parseAuthCode('?error=access_denied&error_description=Cancelled').error).toBe(
|
||||||
|
'access_denied: Cancelled'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('signs RS256 JWTs with the app id as kid and pages through transactions', async () => {
|
||||||
|
const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
|
||||||
|
const calls: string[] = [];
|
||||||
|
const fakeFetch = (async (url: string, init: RequestInit) => {
|
||||||
|
calls.push(url);
|
||||||
|
const auth = String((init.headers as Record<string, string>).Authorization).slice(7);
|
||||||
|
expect(decodeProtectedHeader(auth)).toMatchObject({
|
||||||
|
alg: 'RS256',
|
||||||
|
kid: 'app-id',
|
||||||
|
typ: 'JWT'
|
||||||
|
});
|
||||||
|
const { payload } = await jwtVerify(auth, publicKey, {
|
||||||
|
issuer: 'enablebanking.com',
|
||||||
|
audience: 'api.enablebanking.com'
|
||||||
|
});
|
||||||
|
expect(payload.exp! - payload.iat!).toBeLessThanOrEqual(86400);
|
||||||
|
const page = new URL(url).searchParams.get('continuation_key');
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify(
|
||||||
|
page
|
||||||
|
? { transactions: [tx({ entry_reference: 'r2' })] }
|
||||||
|
: { transactions: [tx()], continuation_key: 'next' }
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}) as unknown as typeof fetch;
|
||||||
|
const client = createEnableBankingClient({
|
||||||
|
appId: 'app-id',
|
||||||
|
privateKeyPem: privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(),
|
||||||
|
fetch: fakeFetch
|
||||||
|
});
|
||||||
|
const all = await client.transactions('uid-1', { dateFrom: '2026-01-01' });
|
||||||
|
expect(all.map((t) => t.entry_reference)).toEqual(['ref-1', 'r2']);
|
||||||
|
expect(calls[1]).toContain('continuation_key=next');
|
||||||
|
expect(calls[0]).toContain('date_from=2026-01-01');
|
||||||
|
});
|
||||||
|
});
|
||||||
411
src/lib/server/bank-sync.ts
Normal file
411
src/lib/server/bank-sync.ts
Normal file
@@ -0,0 +1,411 @@
|
|||||||
|
// Bank -> money.transactions sync (Enable Banking). Pure mapping/matching helpers are
|
||||||
|
// exported for unit tests; the DB functions take a postgres.js `Sql` so they run the
|
||||||
|
// same from the bun scripts (scripts/bank-link.ts, scripts/bank-sync.ts).
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import type { Sql } from 'postgres';
|
||||||
|
import type {
|
||||||
|
BankAccount,
|
||||||
|
BankTransaction,
|
||||||
|
EnableBankingClient,
|
||||||
|
SessionResponse
|
||||||
|
} from './enable-banking';
|
||||||
|
import { EnableBankingError } from './enable-banking';
|
||||||
|
|
||||||
|
export type MappedTransaction = {
|
||||||
|
externalId: string;
|
||||||
|
date: string;
|
||||||
|
valueDate: string | null;
|
||||||
|
description: string;
|
||||||
|
counterparty: string | null;
|
||||||
|
amountCents: number;
|
||||||
|
bookingStatus: string;
|
||||||
|
raw: BankTransaction;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function amountToCents(amount: string): number {
|
||||||
|
const raw = amount.trim();
|
||||||
|
if (!/^-?\d+(?:\.\d+)?$/.test(raw)) throw new Error(`Invalid amount ${amount}`);
|
||||||
|
const negative = raw.startsWith('-');
|
||||||
|
const [whole, fraction = ''] = raw.replace('-', '').split('.');
|
||||||
|
const cents = Number(whole) * 100 + Number((fraction + '00').slice(0, 2));
|
||||||
|
const rounded = Number(fraction.slice(2, 3) || '0') >= 5 ? cents + 1 : cents;
|
||||||
|
return negative ? -rounded : rounded;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stable id for a bank transaction: the bank's own reference, else a content hash. */
|
||||||
|
export function externalIdFor(tx: BankTransaction): string {
|
||||||
|
const ref = tx.entry_reference?.trim() || tx.transaction_id?.trim();
|
||||||
|
if (ref) return ref;
|
||||||
|
const fingerprint = [
|
||||||
|
tx.booking_date ?? tx.transaction_date ?? tx.value_date ?? '',
|
||||||
|
tx.credit_debit_indicator,
|
||||||
|
tx.transaction_amount.amount,
|
||||||
|
tx.transaction_amount.currency,
|
||||||
|
tx.creditor?.name ?? '',
|
||||||
|
tx.debtor?.name ?? '',
|
||||||
|
(tx.remittance_information ?? []).join('|')
|
||||||
|
].join('\u001f');
|
||||||
|
return 'h:' + createHash('sha256').update(fingerprint).digest('hex').slice(0, 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isBooked(tx: BankTransaction) {
|
||||||
|
return tx.status ? tx.status === 'BOOK' : Boolean(tx.booking_date);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Maps one booked bank transaction to a tracker row, or null for pending ones. */
|
||||||
|
export function mapBankTransaction(tx: BankTransaction): MappedTransaction | null {
|
||||||
|
if (!isBooked(tx)) return null;
|
||||||
|
const date = tx.booking_date ?? tx.transaction_date ?? tx.value_date;
|
||||||
|
if (!date) return null;
|
||||||
|
const magnitude = Math.abs(amountToCents(tx.transaction_amount.amount));
|
||||||
|
const amountCents = tx.credit_debit_indicator === 'DBIT' ? -magnitude : magnitude;
|
||||||
|
const counterparty =
|
||||||
|
(tx.credit_debit_indicator === 'DBIT' ? tx.creditor?.name : tx.debtor?.name)?.trim() || null;
|
||||||
|
const remittance = (tx.remittance_information ?? []).map((line) => line.trim()).filter(Boolean);
|
||||||
|
const description = (
|
||||||
|
counterparty ||
|
||||||
|
remittance.join(' ') ||
|
||||||
|
tx.bank_transaction_code?.description ||
|
||||||
|
tx.note ||
|
||||||
|
(amountCents < 0 ? 'Card payment' : 'Incoming transfer')
|
||||||
|
).slice(0, 500);
|
||||||
|
return {
|
||||||
|
externalId: externalIdFor(tx),
|
||||||
|
date: date.slice(0, 10),
|
||||||
|
valueDate: tx.value_date?.slice(0, 10) ?? null,
|
||||||
|
description,
|
||||||
|
counterparty,
|
||||||
|
amountCents,
|
||||||
|
bookingStatus: tx.status ?? 'BOOK',
|
||||||
|
raw: tx
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function dayDiff(a: string, b: string) {
|
||||||
|
return Math.abs(Date.parse(a + 'T00:00:00Z') - Date.parse(b + 'T00:00:00Z')) / 86_400_000;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ExistingRow = { id: number; date: string; amountCents: number; description: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds the hand-entered row a bank transaction corresponds to: same amount, date within
|
||||||
|
* ±1 day, not matched yet. Prefers the closest date, then a description overlap.
|
||||||
|
*/
|
||||||
|
export function findMatch(
|
||||||
|
tx: Pick<MappedTransaction, 'date' | 'amountCents' | 'description'>,
|
||||||
|
candidates: ExistingRow[],
|
||||||
|
used: Set<number>
|
||||||
|
): ExistingRow | null {
|
||||||
|
const words = new Set(
|
||||||
|
tx.description
|
||||||
|
.toLowerCase()
|
||||||
|
.split(/\W+/)
|
||||||
|
.filter((w) => w.length > 2)
|
||||||
|
);
|
||||||
|
let best: { row: ExistingRow; score: number } | null = null;
|
||||||
|
for (const row of candidates) {
|
||||||
|
if (used.has(row.id) || row.amountCents !== tx.amountCents) continue;
|
||||||
|
const diff = dayDiff(row.date, tx.date);
|
||||||
|
if (diff > 1) continue;
|
||||||
|
const overlap = row.description
|
||||||
|
.toLowerCase()
|
||||||
|
.split(/\W+/)
|
||||||
|
.some((w) => words.has(w));
|
||||||
|
const score = diff * 10 - (overlap ? 1 : 0) + row.id / 1e9;
|
||||||
|
if (!best || score < best.score) best = { row, score };
|
||||||
|
}
|
||||||
|
return best?.row ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isoDate(date: Date) {
|
||||||
|
return date.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addDays(date: string, days: number) {
|
||||||
|
return isoDate(new Date(Date.parse(date + 'T00:00:00Z') + days * 86_400_000));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Heuristic: is this bank account the joint one shared with Juhee? */
|
||||||
|
export function looksJoint(account: BankAccount) {
|
||||||
|
const text = [account.name, account.details, account.product].filter(Boolean).join(' ');
|
||||||
|
return /joint|gezamenlijk|shared|\bjuhee\b|\s&\s|\sand\s|,/i.test(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
type TrackerAccount = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
scope: string;
|
||||||
|
iban: string | null;
|
||||||
|
external_uid: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decides which tracker account each bank account feeds. Order: explicit override
|
||||||
|
* (uid/IBAN -> tracker account name), same IBAN, joint heuristic -> the joint-scoped
|
||||||
|
* account, first remaining EUR current account -> "Revolut". Others are skipped.
|
||||||
|
*/
|
||||||
|
export function planAccountMapping(
|
||||||
|
bankAccounts: BankAccount[],
|
||||||
|
tracker: TrackerAccount[],
|
||||||
|
overrides: Record<string, string> = {}
|
||||||
|
) {
|
||||||
|
const byName = new Map(tracker.map((a) => [a.name.toLowerCase(), a]));
|
||||||
|
const used = new Set<number>();
|
||||||
|
const plan: { bank: BankAccount; tracker: TrackerAccount | null; reason: string }[] = [];
|
||||||
|
const take = (bank: BankAccount, account: TrackerAccount | undefined | null, reason: string) => {
|
||||||
|
if (account && !used.has(account.id)) {
|
||||||
|
used.add(account.id);
|
||||||
|
plan.push({ bank, tracker: account, reason });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
const pending: BankAccount[] = [];
|
||||||
|
for (const bank of bankAccounts) {
|
||||||
|
const iban = bank.account_id?.iban?.replace(/\s/g, '').toUpperCase();
|
||||||
|
const override = overrides[bank.uid ?? ''] ?? (iban ? overrides[iban] : undefined);
|
||||||
|
if (override !== undefined) {
|
||||||
|
if (override === '' || override === '-')
|
||||||
|
plan.push({ bank, tracker: null, reason: 'skipped (override)' });
|
||||||
|
else if (!take(bank, byName.get(override.toLowerCase()), 'override'))
|
||||||
|
plan.push({ bank, tracker: null, reason: `override target "${override}" not found/used` });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
iban &&
|
||||||
|
take(
|
||||||
|
bank,
|
||||||
|
tracker.find((a) => a.iban?.replace(/\s/g, '').toUpperCase() === iban),
|
||||||
|
'same IBAN'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue;
|
||||||
|
pending.push(bank);
|
||||||
|
}
|
||||||
|
const eur = (bank: BankAccount) => !bank.currency || bank.currency === 'EUR';
|
||||||
|
// Joint first, so a personal-looking account listed earlier can't take "Revolut" from it.
|
||||||
|
const rest = pending.filter(
|
||||||
|
(bank) =>
|
||||||
|
!(
|
||||||
|
eur(bank) &&
|
||||||
|
looksJoint(bank) &&
|
||||||
|
take(
|
||||||
|
bank,
|
||||||
|
tracker.find((a) => a.scope === 'joint'),
|
||||||
|
'joint account'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
for (const bank of rest) {
|
||||||
|
if (
|
||||||
|
eur(bank) &&
|
||||||
|
bank.cash_account_type !== 'SVGS' &&
|
||||||
|
take(bank, byName.get('revolut'), 'personal EUR account')
|
||||||
|
)
|
||||||
|
continue;
|
||||||
|
plan.push({ bank, tracker: null, reason: 'no match (use --map uid=Account)' });
|
||||||
|
}
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function trackerUserId(sql: Sql, explicit?: string) {
|
||||||
|
if (explicit) return explicit;
|
||||||
|
const rows = await sql<{ user_id: string }[]>`select distinct user_id from money.accounts`;
|
||||||
|
if (rows.length !== 1)
|
||||||
|
throw new Error(`Expected one money-tracker user, found ${rows.length}; pass --user <id>`);
|
||||||
|
return rows[0].user_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stores a freshly authorised session and links bank accounts to tracker accounts. */
|
||||||
|
export async function saveSession(
|
||||||
|
sql: Sql,
|
||||||
|
userId: string,
|
||||||
|
session: SessionResponse,
|
||||||
|
plan: ReturnType<typeof planAccountMapping>
|
||||||
|
) {
|
||||||
|
const accounts = session.accounts.map((a) => ({
|
||||||
|
uid: a.uid ?? null,
|
||||||
|
iban: a.account_id?.iban ?? null,
|
||||||
|
name: a.name ?? null,
|
||||||
|
details: a.details ?? null,
|
||||||
|
product: a.product ?? null,
|
||||||
|
currency: a.currency ?? null,
|
||||||
|
cash_account_type: a.cash_account_type ?? null,
|
||||||
|
identification_hash: a.identification_hash ?? null
|
||||||
|
}));
|
||||||
|
await sql.begin(async (tx) => {
|
||||||
|
await tx`
|
||||||
|
insert into money.bank_sessions
|
||||||
|
(user_id, provider, session_id, aspsp_name, aspsp_country, psu_type, status, valid_until, accounts)
|
||||||
|
values (${userId}, 'enablebanking', ${session.session_id}, ${session.aspsp.name},
|
||||||
|
${session.aspsp.country}, ${session.psu_type}, 'authorized', ${session.access.valid_until},
|
||||||
|
${tx.json(accounts)})
|
||||||
|
on conflict (session_id) do update set status = 'authorized', valid_until = excluded.valid_until,
|
||||||
|
accounts = excluded.accounts, last_error = null`;
|
||||||
|
for (const { bank, tracker } of plan) {
|
||||||
|
if (!tracker || !bank.uid) continue;
|
||||||
|
// Account uids are per session: free the uid from any older mapping first.
|
||||||
|
await tx`update money.accounts set external_uid = null where external_uid = ${bank.uid} and id <> ${tracker.id}`;
|
||||||
|
await tx`
|
||||||
|
update money.accounts
|
||||||
|
set external_uid = ${bank.uid}, provider = 'enablebanking',
|
||||||
|
iban = coalesce(${bank.account_id?.iban ?? null}, iban)
|
||||||
|
where id = ${tracker.id} and user_id = ${userId}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SyncOptions = {
|
||||||
|
dryRun?: boolean;
|
||||||
|
/** Match against the whole history of hand-entered rows (one-time), not just the window. */
|
||||||
|
reconcile?: boolean;
|
||||||
|
from?: string;
|
||||||
|
accountName?: string;
|
||||||
|
log?: (line: string) => void;
|
||||||
|
today?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AccountSyncResult = {
|
||||||
|
account: string;
|
||||||
|
fetched: number;
|
||||||
|
booked: number;
|
||||||
|
inserted: number;
|
||||||
|
matched: number;
|
||||||
|
alreadyKnown: number;
|
||||||
|
balance?: string;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function syncBankAccounts(
|
||||||
|
sql: Sql,
|
||||||
|
client: EnableBankingClient,
|
||||||
|
options: SyncOptions = {}
|
||||||
|
) {
|
||||||
|
const log = options.log ?? console.log;
|
||||||
|
const today = options.today ?? isoDate(new Date());
|
||||||
|
const accounts = await sql<
|
||||||
|
{ id: number; user_id: string; name: string; external_uid: string }[]
|
||||||
|
>`select id, user_id, name, external_uid from money.accounts
|
||||||
|
where provider = 'enablebanking' and external_uid is not null order by id`;
|
||||||
|
const selected = options.accountName
|
||||||
|
? accounts.filter((a) => a.name.toLowerCase() === options.accountName!.toLowerCase())
|
||||||
|
: accounts;
|
||||||
|
if (!selected.length) {
|
||||||
|
log('No linked bank accounts. Run scripts/bank-link.ts first.');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const results: AccountSyncResult[] = [];
|
||||||
|
for (const account of selected) {
|
||||||
|
const result: AccountSyncResult = {
|
||||||
|
account: account.name,
|
||||||
|
fetched: 0,
|
||||||
|
booked: 0,
|
||||||
|
inserted: 0,
|
||||||
|
matched: 0,
|
||||||
|
alreadyKnown: 0
|
||||||
|
};
|
||||||
|
results.push(result);
|
||||||
|
try {
|
||||||
|
const [{ last }] = await sql<{ last: string | null }[]>`
|
||||||
|
select max(date)::text as last from money.transactions
|
||||||
|
where account_id = ${account.id} and source = 'enablebanking'`;
|
||||||
|
const [{ first }] = await sql<{ first: string | null }[]>`
|
||||||
|
select min(date)::text as first from money.transactions where account_id = ${account.id}`;
|
||||||
|
const dateFrom = options.from ?? (last ? addDays(last, -7) : (first ?? addDays(today, -89)));
|
||||||
|
let raw: BankTransaction[];
|
||||||
|
try {
|
||||||
|
raw = await client.transactions(account.external_uid, { dateFrom });
|
||||||
|
} catch (error) {
|
||||||
|
// Many banks only serve ~90 days of history after the first hour of consent.
|
||||||
|
if (error instanceof EnableBankingError && error.code === 'WRONG_TRANSACTIONS_PERIOD') {
|
||||||
|
log(`${account.name}: ${dateFrom} is too far back; retrying from 89 days ago`);
|
||||||
|
raw = await client.transactions(account.external_uid, { dateFrom: addDays(today, -89) });
|
||||||
|
} else throw error;
|
||||||
|
}
|
||||||
|
result.fetched = raw.length;
|
||||||
|
const mapped = raw.map(mapBankTransaction).filter((t): t is MappedTransaction => t !== null);
|
||||||
|
result.booked = mapped.length;
|
||||||
|
if (!mapped.length) continue;
|
||||||
|
|
||||||
|
const ids = mapped.map((t) => t.externalId);
|
||||||
|
const known = new Set(
|
||||||
|
(
|
||||||
|
await sql<{ external_id: string }[]>`
|
||||||
|
select external_id from money.transactions
|
||||||
|
where account_id = ${account.id} and external_id = any(${ids})`
|
||||||
|
).map((r) => r.external_id)
|
||||||
|
);
|
||||||
|
const minDate = mapped.reduce((m, t) => (t.date < m ? t.date : m), mapped[0].date);
|
||||||
|
const candidates = await sql<ExistingRow[]>`
|
||||||
|
select id, date::text as date, amount_cents as "amountCents", description
|
||||||
|
from money.transactions
|
||||||
|
where account_id = ${account.id} and external_id is null
|
||||||
|
and date >= ${options.reconcile ? '0001-01-01' : addDays(minDate, -1)}`;
|
||||||
|
const used = new Set<number>();
|
||||||
|
const inserts: MappedTransaction[] = [];
|
||||||
|
const updates: { id: number; tx: MappedTransaction }[] = [];
|
||||||
|
// Oldest first so matches are claimed in chronological order.
|
||||||
|
for (const tx of [...mapped].sort((a, b) => a.date.localeCompare(b.date))) {
|
||||||
|
if (known.has(tx.externalId)) {
|
||||||
|
result.alreadyKnown++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
known.add(tx.externalId);
|
||||||
|
const match = findMatch(tx, candidates, used);
|
||||||
|
if (match) {
|
||||||
|
used.add(match.id);
|
||||||
|
updates.push({ id: match.id, tx });
|
||||||
|
} else inserts.push(tx);
|
||||||
|
}
|
||||||
|
result.matched = updates.length;
|
||||||
|
result.inserted = inserts.length;
|
||||||
|
if (!options.dryRun) {
|
||||||
|
await sql.begin(async (q) => {
|
||||||
|
for (const { id, tx } of updates) {
|
||||||
|
// Keep Stan's own description/category; attach the bank identity.
|
||||||
|
await q`
|
||||||
|
update money.transactions set external_id = ${tx.externalId},
|
||||||
|
counterparty = coalesce(counterparty, ${tx.counterparty}),
|
||||||
|
booking_status = ${tx.bookingStatus}, value_date = ${tx.valueDate},
|
||||||
|
raw = ${q.json(tx.raw as never)}
|
||||||
|
where id = ${id} and external_id is null`;
|
||||||
|
}
|
||||||
|
for (const tx of inserts) {
|
||||||
|
await q`
|
||||||
|
insert into money.transactions
|
||||||
|
(user_id, account_id, date, description, amount_cents, external_id, source,
|
||||||
|
booking_status, counterparty, raw, value_date)
|
||||||
|
values (${account.user_id}, ${account.id}, ${tx.date}, ${tx.description},
|
||||||
|
${tx.amountCents}, ${tx.externalId}, 'enablebanking', ${tx.bookingStatus},
|
||||||
|
${tx.counterparty}, ${q.json(tx.raw as never)}, ${tx.valueDate})
|
||||||
|
on conflict (account_id, external_id) do nothing`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { balances } = await client.balances(account.external_uid);
|
||||||
|
const preferred =
|
||||||
|
balances.find((b) => b.balance_type === 'CLBD') ??
|
||||||
|
balances.find((b) => b.balance_type === 'ITAV') ??
|
||||||
|
balances[0];
|
||||||
|
if (preferred)
|
||||||
|
result.balance = `${preferred.balance_amount.amount} ${preferred.balance_amount.currency} (${preferred.balance_type})`;
|
||||||
|
} catch {
|
||||||
|
/* balances are informational only */
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
result.error = error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!options.dryRun) {
|
||||||
|
const failed = results.filter((r) => r.error).map((r) => `${r.account}: ${r.error}`);
|
||||||
|
const expired = failed.some((f) => /EXPIRED_SESSION|REVOKED_SESSION|CLOSED_SESSION/.test(f));
|
||||||
|
await sql`
|
||||||
|
update money.bank_sessions set last_synced_at = now(),
|
||||||
|
last_error = ${failed.length ? failed.join('\n').slice(0, 2000) : null},
|
||||||
|
status = case when ${expired} then 'expired'
|
||||||
|
when valid_until is not null and valid_until < now() then 'expired' else status end
|
||||||
|
where status = 'authorized'`;
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
260
src/lib/server/enable-banking.ts
Normal file
260
src/lib/server/enable-banking.ts
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
// Minimal Enable Banking (https://enablebanking.com/docs/api/reference/) client for
|
||||||
|
// account information (AIS): consent (POST /auth + POST /sessions), balances and
|
||||||
|
// transactions. Every request carries a short-lived RS256 JWT signed with the
|
||||||
|
// application's private key; the key never leaves this process.
|
||||||
|
//
|
||||||
|
// Framework-free on purpose: used by the SvelteKit callback route and by the
|
||||||
|
// bun scripts in scripts/ (bank-link.ts, bank-sync.ts), which don't load $app/env.
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { createPrivateKey, randomUUID, type KeyObject } from 'node:crypto';
|
||||||
|
import { SignJWT } from 'jose';
|
||||||
|
|
||||||
|
export const ENABLE_BANKING_API = 'https://api.enablebanking.com';
|
||||||
|
|
||||||
|
export type EnableBankingConfig = {
|
||||||
|
appId: string;
|
||||||
|
/** PEM private key (PKCS#1 or PKCS#8). Prefer keyPath so the key isn't in env. */
|
||||||
|
privateKeyPem?: string;
|
||||||
|
keyPath?: string;
|
||||||
|
baseUrl?: string;
|
||||||
|
fetch?: typeof fetch;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Amount = { currency: string; amount: string };
|
||||||
|
|
||||||
|
export type BankAccount = {
|
||||||
|
uid?: string;
|
||||||
|
account_id?: { iban?: string; other?: { identification: string; scheme_name: string } };
|
||||||
|
name?: string;
|
||||||
|
details?: string;
|
||||||
|
product?: string;
|
||||||
|
currency?: string;
|
||||||
|
cash_account_type?: string;
|
||||||
|
usage?: string;
|
||||||
|
identification_hash?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BankTransaction = {
|
||||||
|
entry_reference?: string | null;
|
||||||
|
transaction_id?: string | null;
|
||||||
|
transaction_amount: Amount;
|
||||||
|
credit_debit_indicator: 'CRDT' | 'DBIT';
|
||||||
|
status?: string;
|
||||||
|
booking_date?: string | null;
|
||||||
|
value_date?: string | null;
|
||||||
|
transaction_date?: string | null;
|
||||||
|
creditor?: { name?: string | null } | null;
|
||||||
|
debtor?: { name?: string | null } | null;
|
||||||
|
creditor_account?: { iban?: string | null } | null;
|
||||||
|
debtor_account?: { iban?: string | null } | null;
|
||||||
|
remittance_information?: string[] | null;
|
||||||
|
bank_transaction_code?: { description?: string | null } | null;
|
||||||
|
merchant_category_code?: string | null;
|
||||||
|
note?: string | null;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Balance = {
|
||||||
|
name?: string;
|
||||||
|
balance_amount: Amount;
|
||||||
|
balance_type: string;
|
||||||
|
reference_date?: string | null;
|
||||||
|
last_change_date_time?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StartAuthResponse = { url: string; authorization_id: string; psu_id_hash?: string };
|
||||||
|
export type SessionResponse = {
|
||||||
|
session_id: string;
|
||||||
|
accounts: BankAccount[];
|
||||||
|
aspsp: { name: string; country: string };
|
||||||
|
psu_type: string;
|
||||||
|
access: { valid_until: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
export class EnableBankingError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly status: number,
|
||||||
|
readonly code: string | undefined,
|
||||||
|
message: string
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'EnableBankingError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads ENABLEBANKING_APP_ID and ENABLEBANKING_KEY_PATH / ENABLEBANKING_PRIVATE_KEY. */
|
||||||
|
export function configFromEnv(env: Record<string, string | undefined> = process.env) {
|
||||||
|
const appId = env.ENABLEBANKING_APP_ID?.trim();
|
||||||
|
if (!appId) throw new Error('ENABLEBANKING_APP_ID is not set');
|
||||||
|
const keyPath = env.ENABLEBANKING_KEY_PATH?.trim();
|
||||||
|
const privateKeyPem = env.ENABLEBANKING_PRIVATE_KEY?.replace(/\\n/g, '\n');
|
||||||
|
if (!keyPath && !privateKeyPem)
|
||||||
|
throw new Error('Set ENABLEBANKING_KEY_PATH (preferred) or ENABLEBANKING_PRIVATE_KEY');
|
||||||
|
return { appId, keyPath, privateKeyPem } satisfies EnableBankingConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createEnableBankingClient(config: EnableBankingConfig) {
|
||||||
|
const baseUrl = config.baseUrl ?? ENABLE_BANKING_API;
|
||||||
|
const doFetch = config.fetch ?? fetch;
|
||||||
|
let key: KeyObject | undefined;
|
||||||
|
let cached: { token: string; expires: number } | undefined;
|
||||||
|
|
||||||
|
function privateKey() {
|
||||||
|
if (!key) {
|
||||||
|
const pem =
|
||||||
|
config.privateKeyPem ?? (config.keyPath ? readFileSync(config.keyPath, 'utf8') : '');
|
||||||
|
if (!pem) throw new Error('No Enable Banking private key configured');
|
||||||
|
key = createPrivateKey(pem);
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** RS256 JWT, valid for one hour (the API accepts at most 24 h). */
|
||||||
|
async function token() {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
if (cached && cached.expires - 60 > now) return cached.token;
|
||||||
|
const jwt = await new SignJWT({})
|
||||||
|
.setProtectedHeader({ typ: 'JWT', alg: 'RS256', kid: config.appId })
|
||||||
|
.setIssuer('enablebanking.com')
|
||||||
|
.setAudience('api.enablebanking.com')
|
||||||
|
.setIssuedAt(now)
|
||||||
|
.setExpirationTime(now + 3600)
|
||||||
|
.sign(privateKey());
|
||||||
|
cached = { token: jwt, expires: now + 3600 };
|
||||||
|
return jwt;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||||
|
const response = await doFetch(baseUrl + path, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${await token()}`,
|
||||||
|
Accept: 'application/json',
|
||||||
|
...(body === undefined ? {} : { 'Content-Type': 'application/json' })
|
||||||
|
},
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
signal: AbortSignal.timeout(60_000)
|
||||||
|
});
|
||||||
|
const text = await response.text();
|
||||||
|
let data: unknown;
|
||||||
|
try {
|
||||||
|
data = text ? JSON.parse(text) : undefined;
|
||||||
|
} catch {
|
||||||
|
data = text;
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
const detail = (data ?? {}) as { message?: string; error?: string; detail?: unknown };
|
||||||
|
throw new EnableBankingError(
|
||||||
|
response.status,
|
||||||
|
detail.error,
|
||||||
|
`Enable Banking ${method} ${path.split('?')[0]} failed (${response.status}${detail.error ? ' ' + detail.error : ''}): ${detail.message ?? text.slice(0, 300)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return data as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
token,
|
||||||
|
application: () =>
|
||||||
|
request<{
|
||||||
|
name: string;
|
||||||
|
kid: string;
|
||||||
|
environment: string;
|
||||||
|
active: boolean;
|
||||||
|
redirect_urls: string[];
|
||||||
|
countries: string[];
|
||||||
|
services: string[];
|
||||||
|
}>('GET', '/application'),
|
||||||
|
aspsps: (country: string, psuType = 'personal') =>
|
||||||
|
request<{
|
||||||
|
aspsps: {
|
||||||
|
name: string;
|
||||||
|
country: string;
|
||||||
|
maximum_consent_validity: number;
|
||||||
|
psu_types: string[];
|
||||||
|
}[];
|
||||||
|
}>('GET', `/aspsps?${new URLSearchParams({ country, psu_type: psuType, service: 'AIS' })}`),
|
||||||
|
startAuth: (input: {
|
||||||
|
aspsp: { name: string; country: string };
|
||||||
|
redirectUrl: string;
|
||||||
|
validUntil: Date;
|
||||||
|
psuType?: 'personal' | 'business';
|
||||||
|
state?: string;
|
||||||
|
language?: string;
|
||||||
|
}) =>
|
||||||
|
request<StartAuthResponse>('POST', '/auth', {
|
||||||
|
access: { valid_until: input.validUntil.toISOString() },
|
||||||
|
aspsp: input.aspsp,
|
||||||
|
state: input.state ?? randomUUID(),
|
||||||
|
redirect_url: input.redirectUrl,
|
||||||
|
psu_type: input.psuType ?? 'personal',
|
||||||
|
...(input.language ? { language: input.language } : {})
|
||||||
|
}),
|
||||||
|
createSession: (code: string) => request<SessionResponse>('POST', '/sessions', { code }),
|
||||||
|
getSession: (sessionId: string) =>
|
||||||
|
request<{
|
||||||
|
status: string;
|
||||||
|
accounts: string[];
|
||||||
|
access: { valid_until: string };
|
||||||
|
aspsp: { name: string; country: string };
|
||||||
|
}>('GET', `/sessions/${encodeURIComponent(sessionId)}`),
|
||||||
|
deleteSession: (sessionId: string) =>
|
||||||
|
request<{ message: string }>('DELETE', `/sessions/${encodeURIComponent(sessionId)}`),
|
||||||
|
accountDetails: (uid: string) =>
|
||||||
|
request<BankAccount>('GET', `/accounts/${encodeURIComponent(uid)}/details`),
|
||||||
|
balances: (uid: string) =>
|
||||||
|
request<{ balances: Balance[] }>('GET', `/accounts/${encodeURIComponent(uid)}/balances`),
|
||||||
|
/** All transactions from dateFrom (inclusive), following continuation_key pages. */
|
||||||
|
async transactions(
|
||||||
|
uid: string,
|
||||||
|
options: {
|
||||||
|
dateFrom?: string;
|
||||||
|
dateTo?: string;
|
||||||
|
status?: 'BOOK' | 'PDNG';
|
||||||
|
maxPages?: number;
|
||||||
|
} = {}
|
||||||
|
) {
|
||||||
|
const all: BankTransaction[] = [];
|
||||||
|
let continuationKey: string | undefined;
|
||||||
|
for (let page = 0; page < (options.maxPages ?? 200); page++) {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (options.dateFrom) query.set('date_from', options.dateFrom);
|
||||||
|
if (options.dateTo) query.set('date_to', options.dateTo);
|
||||||
|
if (options.status) query.set('transaction_status', options.status);
|
||||||
|
if (continuationKey) query.set('continuation_key', continuationKey);
|
||||||
|
const result = await request<{
|
||||||
|
transactions: BankTransaction[];
|
||||||
|
continuation_key?: string | null;
|
||||||
|
}>(
|
||||||
|
'GET',
|
||||||
|
`/accounts/${encodeURIComponent(uid)}/transactions${query.size ? '?' + query : ''}`
|
||||||
|
);
|
||||||
|
all.push(...(result.transactions ?? []));
|
||||||
|
continuationKey = result.continuation_key ?? undefined;
|
||||||
|
if (!continuationKey) break;
|
||||||
|
}
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EnableBankingClient = ReturnType<typeof createEnableBankingClient>;
|
||||||
|
|
||||||
|
/** Extracts the authorisation code from a pasted redirect URL, query string or bare code. */
|
||||||
|
export function parseAuthCode(input: string): { code?: string; error?: string; state?: string } {
|
||||||
|
const value = input.trim();
|
||||||
|
if (!value) return {};
|
||||||
|
if (/^[\w-]+$/.test(value) && !value.includes('=')) return { code: value };
|
||||||
|
let params: URLSearchParams;
|
||||||
|
try {
|
||||||
|
params = new URL(value).searchParams;
|
||||||
|
} catch {
|
||||||
|
params = new URLSearchParams(value.replace(/^.*\?/, ''));
|
||||||
|
}
|
||||||
|
const error = params.get('error');
|
||||||
|
return {
|
||||||
|
code: params.get('code') ?? undefined,
|
||||||
|
state: params.get('state') ?? undefined,
|
||||||
|
error: error ? `${error}: ${params.get('error_description') ?? ''}`.trim() : undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { requireAdmin } from '#lib/server/authorization.js';
|
||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
// Enable Banking redirects here after Revolut consent. The code is single-use and only
|
||||||
|
// shown to the admin, who pastes it into scripts/bank-link.ts on the server.
|
||||||
|
export const load: PageServerLoad = async (event) => {
|
||||||
|
if (!event.locals.user)
|
||||||
|
redirect(303, '/login?returnTo=' + encodeURIComponent(event.url.pathname + event.url.search));
|
||||||
|
requireAdmin(event);
|
||||||
|
const params = event.url.searchParams;
|
||||||
|
return {
|
||||||
|
code: params.get('code')?.slice(0, 500) ?? '',
|
||||||
|
error: params.get('error')?.slice(0, 200) ?? '',
|
||||||
|
errorDescription: params.get('error_description')?.slice(0, 500) ?? '',
|
||||||
|
fullUrl: event.url.href
|
||||||
|
};
|
||||||
|
};
|
||||||
53
src/routes/projects/money-tracker/bank-callback/+page.svelte
Normal file
53
src/routes/projects/money-tracker/bank-callback/+page.svelte
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
import { Button } from '#lib/components/ui/button/index.js';
|
||||||
|
import * as Card from '#lib/components/ui/card/index.js';
|
||||||
|
import * as Alert from '#lib/components/ui/alert/index.js';
|
||||||
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
|
let { data }: PageProps = $props();
|
||||||
|
let copied = $state(false);
|
||||||
|
async function copy() {
|
||||||
|
await navigator.clipboard.writeText(data.fullUrl);
|
||||||
|
copied = true;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head
|
||||||
|
><title>Bank connection · Money tracker</title><meta
|
||||||
|
name="robots"
|
||||||
|
content="noindex"
|
||||||
|
/></svelte:head
|
||||||
|
>
|
||||||
|
|
||||||
|
<section class="mx-auto max-w-xl space-y-6 p-6">
|
||||||
|
<h1 class="text-2xl font-bold">Bank connection</h1>
|
||||||
|
{#if data.error}
|
||||||
|
<Alert.Root variant="destructive"
|
||||||
|
><Alert.Title>Revolut did not grant access</Alert.Title><Alert.Description
|
||||||
|
>{data.error}{data.errorDescription ? `: ${data.errorDescription}` : ''}. Run
|
||||||
|
<code>scripts/bank-link.ts</code> again to retry.</Alert.Description
|
||||||
|
></Alert.Root
|
||||||
|
>
|
||||||
|
{:else if data.code}
|
||||||
|
<Card.Root
|
||||||
|
><Card.Header
|
||||||
|
><Card.Title>Almost done</Card.Title><Card.Description
|
||||||
|
>Copy this address and paste it into the waiting <code>bank-link</code> prompt. The code is
|
||||||
|
single-use and expires within minutes.</Card.Description
|
||||||
|
></Card.Header
|
||||||
|
><Card.Content class="space-y-3"
|
||||||
|
><code class="block break-all rounded bg-muted p-3 text-xs">{data.fullUrl}</code><Button
|
||||||
|
onclick={copy}>{copied ? 'Copied' : 'Copy address'}</Button
|
||||||
|
></Card.Content
|
||||||
|
></Card.Root
|
||||||
|
>
|
||||||
|
{:else}
|
||||||
|
<p>
|
||||||
|
No authorisation code in this link. Start a connection with <code>scripts/bank-link.ts</code>.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
<p>
|
||||||
|
<a class="underline" href={resolve('/projects/money-tracker')}>Back to the money tracker</a>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
Reference in New Issue
Block a user