Finanzen: Enable-Banking-Client (JWT, Freigabe, Sitzung, Salden, Umsaetze)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
53b50011e8
commit
9329318df5
4 files changed
+672
-1
No files matched your search
@@ -0,0 +1,203 @@
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user