import crypto from 'node:crypto' import { readFileSync } from 'node:fs' import { config } from '../../config.js' /** * Client fuer die Enable-Banking-API (PSD2-Lesezugriff auf das Finom-Geschaeftskonto). * * Jeder Aufruf traegt ein frisch signiertes JWT (RS256, kid = App-ID). Der private Schluessel wird bei * jedem JWT von der Platte gelesen, ein Schluesseltausch wirkt also ohne Neustart. Betraege sind intern * immer ganze Cent (Ganzzahl). Weder der Schluessel noch Sitzungs- oder Konto-IDs landen in Fehlermeldungen * oder Logs: Meldungen nennen nur den Vorgang, den HTTP-Status und den Fehlercode der API. */ // JWT-Laufzeit (Sekunden) und Zeitlimit je Anfrage (Millisekunden) const JWT_TTL_SECONDS = 3600 const REQUEST_TIMEOUT_MS = 30_000 // Obergrenze fuer das Blaettern durch Umsatzseiten (Schutz vor einer endlosen continuation_key-Kette) const MAX_TRANSACTION_PAGES = 20 /** Fehler der Bank-Schnittstelle. status = HTTP-Status (0 = keine Antwort), code = Fehlercode der API oder null. */ export class EnableBankingError extends Error { constructor(message, { status = 0, code = null } = {}) { super(message) this.name = 'EnableBankingError' this.status = status this.code = code } } const base64url = (value) => Buffer.from(value).toString('base64url') /** * Signiertes JWT fuer die API. now in Millisekunden (nur fuer Tests ueberschreiben). * Header { typ: 'JWT', alg: 'RS256', kid: App-ID }, Claims iss/aud/iat/exp mit einer Stunde Laufzeit. */ export function createJwt({ now = Date.now() } = {}) { const { appId, keyPath, enabled } = config.enableBanking if (!enabled) { throw new EnableBankingError('Die Bankanbindung ist noch nicht eingerichtet (ENABLE_BANKING_APP_ID/ENABLE_BANKING_KEY_PATH fehlen).', { status: 503, code: 'NOT_CONFIGURED', }) } const iat = Math.floor(now / 1000) const header = base64url(JSON.stringify({ typ: 'JWT', alg: 'RS256', kid: appId })) const payload = base64url(JSON.stringify({ iss: 'enablebanking.com', aud: 'api.enablebanking.com', iat, exp: iat + JWT_TTL_SECONDS })) let signature try { // Absichtlich ohne Fehlerdetails weitergeben: Pfad und Schluesselinhalt sollen nirgends auftauchen const key = crypto.createPrivateKey(readFileSync(keyPath)) signature = crypto.sign('sha256', Buffer.from(`${header}.${payload}`), key).toString('base64url') } catch { throw new EnableBankingError('Der Schlüssel für die Bankanbindung konnte nicht gelesen werden.', { status: 500, code: 'KEY_UNREADABLE' }) } return `${header}.${payload}.${signature}` } /** * Ein API-Aufruf. label beschreibt den Vorgang fuer die Fehlermeldung (ohne IDs). psu = { ip, userAgent } * wird als Psu-Ip-Address / Psu-User-Agent mitgeschickt, wenn der Nutzer den Abruf selbst ausgeloest hat. */ async function request(method, path, { label, query = null, body = null, psu = null } = {}) { const url = new URL(`${config.enableBanking.apiBase}${path}`) for (const [key, value] of Object.entries(query || {})) { if (value !== undefined && value !== null && value !== '') url.searchParams.set(key, value) } const headers = { Authorization: `Bearer ${createJwt()}`, Accept: 'application/json' } if (body) headers['Content-Type'] = 'application/json' if (psu?.ip) headers['Psu-Ip-Address'] = psu.ip if (psu?.userAgent) headers['Psu-User-Agent'] = psu.userAgent let res let text try { res = await fetch(url, { method, headers, body: body ? JSON.stringify(body) : undefined, // Der Bearer-Token geht nie an eine andere Adresse redirect: 'error', signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), }) text = await res.text() } catch (err) { // Die Originalmeldung nennt die Adresse (mit Sitzungs-/Konto-ID) und bleibt deshalb draussen throw new EnableBankingError(`Bankanbindung: ${label} nicht erreichbar.`, { status: 0, code: err?.name === 'TimeoutError' ? 'TIMEOUT' : 'NETWORK', }) } let data = null try { data = text ? JSON.parse(text) : null } catch { data = null } if (!res.ok) { const code = data && typeof data === 'object' ? (data.code ?? data.error ?? null) : null throw new EnableBankingError(`Bankanbindung: ${label} fehlgeschlagen (HTTP ${res.status}${code === null ? '' : `, ${code}`}).`, { status: res.status, code, }) } if (data === null || typeof data !== 'object') { throw new EnableBankingError(`Bankanbindung: ${label} lieferte eine unlesbare Antwort.`, { status: res.status, code: null }) } return data } /** * Dezimalbetrag als Zeichenkette ("108.93", "-0.5") in ganze Cent. Ganzzahl- und Nachkommateil werden * getrennt gelesen, es gibt keine Gleitkommarechnung und keine stille Rundung: mehr als zwei von 0 * verschiedene Nachkommastellen oder alles, was kein Dezimalbetrag ist, wirft einen Fehler. */ export function toCents(amount) { const text = typeof amount === 'number' && Number.isFinite(amount) ? String(amount) : amount const match = typeof text === 'string' ? text.trim().match(/^([+-]?)(\d+)(?:\.(\d+))?$/) : null if (!match) throw new Error('Ungültiger Betrag von der Bank.') const [, sign, whole, fraction = ''] = match if (/[^0]/.test(fraction.slice(2))) throw new Error('Ungültiger Betrag von der Bank (mehr als zwei Nachkommastellen).') const cents = Number(whole) * 100 + Number(fraction.slice(0, 2).padEnd(2, '0')) if (!Number.isSafeInteger(cents)) throw new Error('Ungültiger Betrag von der Bank (zu groß).') if (cents === 0) return 0 return sign === '-' ? -cents : cents } /** * Freigabe starten (POST /auth): liefert die Bank-Adresse, zu der der Nutzer geschickt wird, und die * Freigabe-ID. state kommt unveraendert an die Rueckkehradresse zurueck; validUntil (ISO) ist das Ende der Freigabe. */ export async function startAuth({ state, validUntil }) { const { aspsp, redirectUrl } = config.enableBanking const data = await request('POST', '/auth', { label: 'Freigabe starten', body: { access: { valid_until: validUntil }, aspsp: { name: aspsp.name, country: aspsp.country }, state, redirect_url: redirectUrl, psu_type: 'business', }, }) return { url: data.url, authorizationId: data.authorization_id } } /** Aus dem Code der Rueckkehradresse eine Sitzung mit den freigegebenen Konten machen (POST /sessions). */ export async function createSession(code) { const data = await request('POST', '/sessions', { label: 'Sitzung anlegen', body: { code } }) return { sessionId: data.session_id, validUntil: data.access?.valid_until ?? null, accounts: (data.accounts || []).map((account) => ({ uid: account.uid, identificationHash: account.identification_hash ?? null, iban: account.account_id?.iban ?? null, name: account.name ?? null, currency: account.currency ?? null, })), } } /** Zustand einer Sitzung (GET /sessions/:id): status (z. B. AUTHORIZED, EXPIRED), Ende der Freigabe, Konto-IDs. */ export async function getSession(sessionId) { const data = await request('GET', `/sessions/${encodeURIComponent(sessionId)}`, { label: 'Sitzung abrufen' }) return { status: data.status, validUntil: data.access?.valid_until ?? null, // Die API nennt hier nur die IDs; Konto-Objekte werden trotzdem toleriert accountUids: (data.accounts || []).map((account) => (typeof account === 'string' ? account : account.uid)), } } /** Salden eines Kontos (GET /accounts/:uid/balances) in ganzen Cent. */ export async function getBalances(accountUid, { psu = null } = {}) { const data = await request('GET', `/accounts/${encodeURIComponent(accountUid)}/balances`, { label: 'Salden abrufen', psu }) return (data.balances || []).map((balance) => ({ type: balance.balance_type, amountCents: toCents(balance.balance_amount?.amount), currency: balance.balance_amount?.currency ?? null, date: balance.reference_date ?? null, })) } /** * Umsaetze eines Kontos ab dateFrom (YYYY-MM-DD), alle Seiten (continuation_key), hoechstens 20 Aufrufe. * Liefert die Rohobjekte von Enable Banking; die Aufbereitung uebernimmt der Aufrufer. */ export async function getTransactions(accountUid, { dateFrom = null, psu = null } = {}) { const all = [] let continuationKey = null for (let page = 0; page < MAX_TRANSACTION_PAGES; page += 1) { const data = await request('GET', `/accounts/${encodeURIComponent(accountUid)}/transactions`, { label: 'Umsätze abrufen', query: { date_from: dateFrom, continuation_key: continuationKey }, psu, }) all.push(...(data.transactions || [])) continuationKey = data.continuation_key || null if (!continuationKey) break } return all }