diff --git a/.env.example b/.env.example index d61aaae5..0ae3ae96 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,8 @@ DB_DATA_DIR=./data/db UPLOADS_DIR=./data/uploads FACTORY_DEFAULTS_DIR=./data/factory-defaults BACKUPS_DIR=./data/backups +# Auto-aktualisierter BLZ-/Bankdaten-Datensatz (Bundesbank-Bankleitzahlen) +BANKDATA_DIR=./data/bankdata # ============== DATENBANK ============== # Der App-User (DB_USER) wird beim ersten Start automatisch von MariaDB diff --git a/backend/.gitignore b/backend/.gitignore index cd1098ea..4ba2a64c 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -36,3 +36,4 @@ npm-debug.log* # OS .DS_Store Thumbs.db +bankdata/ diff --git a/backend/Dockerfile b/backend/Dockerfile index cab83cf3..8e770697 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -67,7 +67,7 @@ COPY backend/factory-defaults /app/factory-defaults-builtin COPY backend/scripts /app/scripts # Daten-Verzeichnisse (werden via Bind-Mount überlagert; hier nur als Fallback) -RUN mkdir -p uploads factory-defaults prisma/backups +RUN mkdir -p uploads factory-defaults prisma/backups bankdata # Healthcheck HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ diff --git a/backend/src/controllers/bankLookup.controller.ts b/backend/src/controllers/bankLookup.controller.ts index fd5f9d90..9050e916 100644 --- a/backend/src/controllers/bankLookup.controller.ts +++ b/backend/src/controllers/bankLookup.controller.ts @@ -1,13 +1,14 @@ import { Response } from 'express'; import { isValidIBAN } from 'ibantools'; -import { bankDataByIBAN } from 'bankdata-germany'; +import * as blzData from '../services/blzData.service.js'; import { ApiResponse, AuthRequest } from '../types/index.js'; /** * IBAN-Lookup: prüft die Prüfziffer (mod-97, offline über ibantools) und liefert * – für deutsche IBANs – BIC und Banknamen aus der Bundesbank-Bankleitzahlen- - * datei (offline über bankdata-germany). Die IBAN verlässt NICHT den Server; es - * findet KEIN externer Request statt. Zurückgegeben werden nur öffentliche + * datei (offline; siehe blzData.service – Volume-Datensatz bevorzugt, sonst die + * ins Image gebackenen Daten). Die IBAN verlässt NICHT den Server; es findet + * KEIN externer Request statt. Zurückgegeben werden nur öffentliche * Bankverzeichnis-Daten (BIC/Name), keine kundenbezogenen Informationen. * * Antwortformen (immer HTTP 200, sofern eine IBAN übergeben wurde): @@ -32,7 +33,7 @@ export async function lookupIban(req: AuthRequest, res: Response): Promise } const country = iban.slice(0, 2); - const bank = country === 'DE' ? bankDataByIBAN(iban) : null; + const bank = country === 'DE' ? blzData.lookupByIban(iban) : null; res.json({ success: true, diff --git a/backend/src/controllers/blzData.controller.ts b/backend/src/controllers/blzData.controller.ts new file mode 100644 index 00000000..3923929c --- /dev/null +++ b/backend/src/controllers/blzData.controller.ts @@ -0,0 +1,40 @@ +import { Response } from 'express'; +import { ApiResponse, AuthRequest } from '../types/index.js'; +import { logChange } from '../services/audit.service.js'; +import * as blzData from '../services/blzData.service.js'; + +// Status der BLZ-/Bankdaten (installierte Version, Quelle, Auto-Update-Stand). +export async function getStatus(req: AuthRequest, res: Response): Promise { + try { + const status = await blzData.getStatus(); + res.json({ success: true, data: status } as ApiResponse); + } catch (error) { + res.status(500).json({ + success: false, + error: error instanceof Error ? error.message : 'Fehler beim Laden des BLZ-Status', + } as ApiResponse); + } +} + +// Jetzt prüfen/aktualisieren (manueller Trigger). ?force=1 lädt auch bei +// gleicher Version neu. +export async function updateNow(req: AuthRequest, res: Response): Promise { + try { + const force = req.query.force === '1' || req.query.force === 'true'; + const result = await blzData.runUpdate(force); + await logChange({ + req, + action: 'UPDATE', + resourceType: 'AppSetting', + resourceId: 'blz-data', + label: `BLZ-Bankdaten: ${result.message}`, + }); + const status = await blzData.getStatus(); + res.json({ success: true, data: { result, status } } as ApiResponse); + } catch (error) { + res.status(502).json({ + success: false, + error: error instanceof Error ? error.message : 'BLZ-Aktualisierung fehlgeschlagen', + } as ApiResponse); + } +} diff --git a/backend/src/index.ts b/backend/src/index.ts index e8a0adad..95e2c0dc 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -65,6 +65,7 @@ import factoryDefaultsRoutes from './routes/factoryDefaults.routes.js'; import { downloadFile } from './controllers/fileDownload.controller.js'; import { startBirthdayScheduler } from './services/birthdayScheduler.service.js'; import { startContractStatusScheduler } from './services/contractStatusScheduler.service.js'; +import { startBlzUpdateScheduler } from './services/blzUpdateScheduler.service.js'; import { startSecurityMonitorScheduler } from './services/securityAlert.service.js'; import monitoringRoutes from './routes/monitoring.routes.js'; import { auditContextMiddleware } from './middleware/auditContext.js'; @@ -486,4 +487,5 @@ app.listen(PORT as number, LISTEN_ADDR, () => { startBirthdayScheduler(); startContractStatusScheduler(); startSecurityMonitorScheduler(); + startBlzUpdateScheduler(); }); diff --git a/backend/src/routes/appSetting.routes.ts b/backend/src/routes/appSetting.routes.ts index 805554d4..e033f7ad 100644 --- a/backend/src/routes/appSetting.routes.ts +++ b/backend/src/routes/appSetting.routes.ts @@ -3,6 +3,7 @@ import multer from 'multer'; import * as appSettingController from '../controllers/appSetting.controller.js'; import * as backupController from '../controllers/backup.controller.js'; import * as rateLimitAdminController from '../controllers/rateLimitAdmin.controller.js'; +import * as blzDataController from '../controllers/blzData.controller.js'; import { authenticate, requirePermission } from '../middleware/auth.js'; // Multer für Backup-Upload (in Memory speichern) @@ -115,6 +116,24 @@ router.get( backupController.getBackupLogDetail ); +// ==================== BLZ-/BANKDATEN ==================== + +// Status (installierte Version, Quelle, Auto-Update-Stand) +router.get( + '/blz', + authenticate, + requirePermission('settings:read'), + blzDataController.getStatus, +); + +// Jetzt prüfen/aktualisieren (?force=1 lädt auch bei gleicher Version) +router.post( + '/blz/update-now', + authenticate, + requirePermission('settings:update'), + blzDataController.updateNow, +); + // Rate-Limit-Verwaltung (Admin) router.get( '/rate-limits/active', diff --git a/backend/src/services/appSetting.service.ts b/backend/src/services/appSetting.service.ts index bbd1db03..96d9abc0 100644 --- a/backend/src/services/appSetting.service.ts +++ b/backend/src/services/appSetting.service.ts @@ -12,6 +12,10 @@ const DEFAULT_SETTINGS: Record = { // Ausweis-Ablauf: Fristenschwellen (in Tagen) documentExpiryCriticalDays: '30', // Rot: Kritisch (Standard 30 Tage) documentExpiryWarningDays: '90', // Gelb: Warnung (Standard 90 Tage) + // BLZ-/Bankdaten-Auto-Update (Bundesbank-Bankleitzahlen via bankdata-germany). + // Der Updater lädt nur eine öffentliche Datendatei – keine Kundendaten. + blzAutoUpdateEnabled: 'true', // Auto-Update an/aus + blzUpdateIntervalDays: '30', // Prüf-/Update-Intervall in Tagen }; // Whitelist erlaubter Setting-Keys. PUT /api/settings nimmt KEINE @@ -90,8 +94,22 @@ export function validateSettingValue(key: string, rawValue: string): { ok: true; return { ok: true, value: trimmed }; } + // BLZ-Update-Intervall: mind. 1 Tag, höchstens 365 (verhindert Dauer-Polling + // bzw. faktisch nie). + if (key === 'blzUpdateIntervalDays') { + const trimmed = rawValue.trim(); + if (!/^\d+$/.test(trimmed)) { + return { ok: false, error: 'Das Intervall muss eine positive ganze Zahl (Tage) sein.' }; + } + const n = parseInt(trimmed, 10); + if (n < 1 || n > 365) { + return { ok: false, error: 'Das Intervall muss zwischen 1 und 365 Tagen liegen.' }; + } + return { ok: true, value: String(n) }; + } + // Bool-Settings - if (key === 'customerSupportTicketsEnabled' || key === 'monitoringDigestEnabled') { + if (key === 'customerSupportTicketsEnabled' || key === 'monitoringDigestEnabled' || key === 'blzAutoUpdateEnabled') { const trimmed = rawValue.trim().toLowerCase(); if (trimmed !== 'true' && trimmed !== 'false') { return { ok: false, error: `${key} muss 'true' oder 'false' sein.` }; diff --git a/backend/src/services/blzData.service.ts b/backend/src/services/blzData.service.ts new file mode 100644 index 00000000..0535e982 --- /dev/null +++ b/backend/src/services/blzData.service.ts @@ -0,0 +1,348 @@ +import fs from 'fs'; +import path from 'path'; +import { getSetting, getSettingBool, setSetting } from './appSetting.service.js'; + +/** + * BLZ-/Bankdaten-Service. + * + * Liefert BIC + Banknamen zu einer deutschen BLZ/IBAN. Zwei Datenquellen, + * in dieser Reihenfolge: + * 1. VOLUME – ein zur Laufzeit aktualisierter Datensatz unter BANKDATA_DIR + * (Bind-Mount, siehe docker-compose). Wird vom Auto-Updater + * befüllt. + * 2. BUILTIN – die ins Image gebackenen JSON-Daten des npm-Pakets + * `bankdata-germany` (Fallback, immer vorhanden). + * + * Es wird KEIN Paketcode ausgeführt – nur die reinen JSON-Datendateien + * (current.json = { "": ["Name","BIC"] }, next.json = Delta der nächsten + * Periode) werden gelesen und mit eigener Logik indiziert. + * + * Datenschutz: Beim Lookup verlässt KEINE IBAN den Server. Nur der + * Auto-Updater macht ausgehende Requests – und lädt dabei lediglich eine + * öffentliche Datendatei (keine Kundendaten). + */ + +// Kompiliert nach CommonJS – das native `require` ist zur Laufzeit verfügbar +// und wird nur genutzt, um den Pfad der gebackenen Paketdaten aufzulösen. +declare const require: NodeRequire; + +// ---- Pfade / Quellen (per Env überschreibbar) ---- +const BANKDATA_DIR = process.env.BANKDATA_DIR || path.join(process.cwd(), 'bankdata'); +const DATASET_FILE = path.join(BANKDATA_DIR, 'blz-dataset.json'); +const PACKAGE = 'bankdata-germany'; +const CDN_BASE = process.env.BLZ_CDN_BASE || 'https://cdn.jsdelivr.net/npm'; +const REGISTRY_BASE = process.env.BLZ_REGISTRY_BASE || 'https://registry.npmjs.org'; +const FETCH_TIMEOUT_MS = 20_000; + +// ---- Typen ---- +type BankTuple = [string, string]; // [bankName, bic] +type CurrentData = Record; +interface NextData { + valid: string; + upsert: Record; + remove: string[]; +} +interface RawDataset { + current: CurrentData; + next: NextData; +} +interface LoadedDataset extends RawDataset { + source: 'volume' | 'builtin'; + version: string; + fetchedAt: string | null; +} +export interface BankInfo { + bankName: string; + bic: string; + blz: string; +} + +// ---- In-Memory-Cache (per mtime invalidiert) ---- +let cache: { dataset: LoadedDataset; combined: CurrentData; key: string } | null = null; + +function builtinDataDir(): string { + // require.resolve liefert .../dist/cjs/main.js → data/ liegt daneben. + const main = require.resolve(PACKAGE); + return path.join(path.dirname(main), 'data'); +} + +function builtinVersion(): string { + try { + const main = require.resolve(PACKAGE); + // main = /dist/cjs/main.js → package.json zwei Ebenen höher. + const pkgPath = path.join(path.dirname(main), '..', '..', 'package.json'); + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); + return typeof pkg.version === 'string' ? pkg.version : 'unbekannt'; + } catch { + return 'unbekannt'; + } +} + +function readJson(file: string): any { + return JSON.parse(fs.readFileSync(file, 'utf8')); +} + +/** + * Lädt den aktiven Datensatz (Volume bevorzugt, sonst Builtin). Cached anhand + * der mtime der Volume-Datei bzw. eines statischen Keys für Builtin. + */ +function loadDataset(): LoadedDataset { + // Volume vorhanden? + let volumeMtime: number | null = null; + try { + volumeMtime = fs.statSync(DATASET_FILE).mtimeMs; + } catch { + volumeMtime = null; + } + + const key = volumeMtime !== null ? `volume:${volumeMtime}` : 'builtin'; + if (cache && cache.key === key) return cache.dataset; + + let dataset: LoadedDataset; + if (volumeMtime !== null) { + try { + const raw: any = readJson(DATASET_FILE); + // Metadaten VOR der Assertion lesen (die narrowt raw auf RawDataset). + const version = typeof raw.version === 'string' ? raw.version : 'unbekannt'; + const fetchedAt = typeof raw.fetchedAt === 'string' ? raw.fetchedAt : null; + assertValidRawDataset(raw); + dataset = { + source: 'volume', + version, + fetchedAt, + current: raw.current, + next: raw.next, + }; + } catch (err) { + console.error('[BLZ] Volume-Datensatz unlesbar, nutze Builtin:', err); + dataset = loadBuiltin(); + } + } else { + dataset = loadBuiltin(); + } + + const combined = combine(dataset); + cache = { dataset, combined, key }; + return dataset; +} + +function loadBuiltin(): LoadedDataset { + const dir = builtinDataDir(); + const current = readJson(path.join(dir, 'current.json')) as CurrentData; + const next = readJson(path.join(dir, 'next.json')) as NextData; + return { source: 'builtin', version: builtinVersion(), fetchedAt: null, current, next }; +} + +/** + * Kombiniert current + next-Delta, wenn das aktuelle Datum den Gültig-ab- + * Zeitpunkt der nächsten Periode erreicht hat (identische Logik wie das + * npm-Paket). + */ +function combine(ds: RawDataset): CurrentData { + const validFrom = new Date(ds.next?.valid); + if (ds.next && !Number.isNaN(validFrom.getTime()) && new Date() >= validFrom) { + const merged: CurrentData = { ...ds.current, ...ds.next.upsert }; + for (const blz of ds.next.remove || []) delete merged[blz]; + return merged; + } + return ds.current; +} + +function combinedData(): CurrentData { + loadDataset(); + return cache!.combined; +} + +// ---- Lookup ---- + +export function lookupBlz(blz: string): BankInfo | null { + if (!/^[1-9]\d{7}$/.test(blz)) return null; + const entry = combinedData()[blz]; + if (!entry) return null; + return { bankName: entry[0], bic: entry[1], blz }; +} + +/** Extrahiert die BLZ aus einer deutschen IBAN und schlägt sie nach. */ +export function lookupByIban(iban: string): BankInfo | null { + if (!/^DE\d{20}$/i.test(iban)) return null; + // IBAN: DE + 2 Prüfziffern + 8 BLZ + 10 Kontonummer + return lookupBlz(iban.slice(4, 12)); +} + +// ---- Validierung des Roh-Datensatzes (gegen Müll/HTML-Antworten) ---- + +function assertValidRawDataset(raw: any): asserts raw is RawDataset { + if (!raw || typeof raw !== 'object') throw new Error('Datensatz ist kein Objekt'); + const cur = raw.current; + if (!cur || typeof cur !== 'object' || Array.isArray(cur)) throw new Error('current fehlt/ungültig'); + const keys = Object.keys(cur); + if (keys.length < 1000) throw new Error(`current zu klein (${keys.length} Einträge)`); + const sample = cur[keys[0]]; + if (!Array.isArray(sample) || sample.length !== 2 || typeof sample[0] !== 'string' || typeof sample[1] !== 'string') { + throw new Error('current-Eintrag hat unerwartetes Format'); + } + const next = raw.next; + if (!next || typeof next !== 'object' || typeof next.valid !== 'string' || typeof next.upsert !== 'object' || !Array.isArray(next.remove)) { + throw new Error('next fehlt/ungültig'); + } +} + +// ---- Ausgehende Requests (nur Updater) ---- + +async function fetchJson(url: string): Promise { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS); + try { + const res = await fetch(url, { + signal: ctrl.signal, + headers: { Accept: 'application/json', 'User-Agent': 'OpenCRM-BLZ-Updater' }, + }); + if (!res.ok) throw new Error(`HTTP ${res.status} bei ${url}`); + return await res.json(); + } finally { + clearTimeout(t); + } +} + +/** Ermittelt die neueste verfügbare Paket-Version (npm dist-tag latest). */ +export async function fetchLatestVersion(): Promise { + const manifest = await fetchJson(`${REGISTRY_BASE}/${PACKAGE}/latest`); + if (!manifest || typeof manifest.version !== 'string') throw new Error('Registry lieferte keine Version'); + return manifest.version; +} + +// ---- Versionsvergleich (numerische Segmente, z.B. 2.2603.0) ---- + +export function compareVersions(a: string, b: string): number { + const pa = a.split('.').map((n) => parseInt(n, 10) || 0); + const pb = b.split('.').map((n) => parseInt(n, 10) || 0); + for (let i = 0; i < Math.max(pa.length, pb.length); i++) { + const d = (pa[i] || 0) - (pb[i] || 0); + if (d !== 0) return d > 0 ? 1 : -1; + } + return 0; +} + +function activeVersion(): string { + return loadDataset().version; +} + +// ---- Update-Durchführung ---- + +let updating = false; + +export interface UpdateResult { + changed: boolean; + version: string; + message: string; +} + +/** + * Lädt (falls neuer) den aktuellen Datensatz von der CDN ins Volume. + * `force` lädt auch bei gleicher Version neu. + */ +export async function runUpdate(force = false): Promise { + if (updating) { + return { changed: false, version: activeVersion(), message: 'Update läuft bereits.' }; + } + updating = true; + const nowIso = new Date().toISOString(); + try { + const latest = await fetchLatestVersion(); + await setSetting('blzLatestVersion', latest); + await setSetting('blzLastCheckedAt', nowIso); + + const current = activeVersion(); + if (!force && current !== 'unbekannt' && compareVersions(latest, current) <= 0) { + await setSetting('blzLastError', ''); + return { changed: false, version: current, message: `Bereits aktuell (${current}).` }; + } + + // Datendateien der Zielversion laden. + const base = `${CDN_BASE}/${PACKAGE}@${latest}/dist/cjs/data`; + const [currentData, nextData] = await Promise.all([ + fetchJson(`${base}/current.json`), + fetchJson(`${base}/next.json`), + ]); + + const raw = { current: currentData, next: nextData }; + assertValidRawDataset(raw); // wirft bei Müll → kein Überschreiben + + const payload = JSON.stringify({ + version: latest, + fetchedAt: nowIso, + source: 'cdn', + current: currentData, + next: nextData, + }); + + fs.mkdirSync(BANKDATA_DIR, { recursive: true }); + const tmp = `${DATASET_FILE}.tmp`; + fs.writeFileSync(tmp, payload, 'utf8'); + fs.renameSync(tmp, DATASET_FILE); // atomar + cache = null; // Cache invalidieren → nächster Lookup lädt neu + + await setSetting('blzLastUpdatedAt', nowIso); + await setSetting('blzLastError', ''); + + console.log(`[BLZ] Datensatz aktualisiert auf ${latest} (${Object.keys(currentData).length} Einträge).`); + return { changed: true, version: latest, message: `Aktualisiert auf ${latest}.` }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + await setSetting('blzLastError', msg).catch(() => {}); + await setSetting('blzLastCheckedAt', nowIso).catch(() => {}); + console.error('[BLZ] Update fehlgeschlagen:', msg); + throw err instanceof Error ? err : new Error(msg); + } finally { + updating = false; + } +} + +// ---- Status für die Einstellungen-Seite ---- + +export interface BlzStatus { + activeSource: 'volume' | 'builtin'; + activeVersion: string; + builtinVersion: string; + entryCount: number; + nextValidFrom: string | null; + fetchedAt: string | null; + autoUpdateEnabled: boolean; + intervalDays: number; + lastCheckedAt: string | null; + lastUpdatedAt: string | null; + latestVersion: string | null; + updateAvailable: boolean; + lastError: string | null; +} + +export async function getStatus(): Promise { + const ds = loadDataset(); + const combined = combinedData(); + + const autoUpdateEnabled = await getSettingBool('blzAutoUpdateEnabled'); + const intervalDays = parseInt((await getSetting('blzUpdateIntervalDays')) || '30', 10) || 30; + const lastCheckedAt = (await getSetting('blzLastCheckedAt')) || null; + const lastUpdatedAt = (await getSetting('blzLastUpdatedAt')) || null; + const latestVersion = (await getSetting('blzLatestVersion')) || null; + const lastErrorRaw = (await getSetting('blzLastError')) || ''; + + const updateAvailable = + !!latestVersion && ds.version !== 'unbekannt' && compareVersions(latestVersion, ds.version) > 0; + + return { + activeSource: ds.source, + activeVersion: ds.version, + builtinVersion: builtinVersion(), + entryCount: Object.keys(combined).length, + nextValidFrom: ds.next?.valid || null, + fetchedAt: ds.fetchedAt, + autoUpdateEnabled, + intervalDays, + lastCheckedAt, + lastUpdatedAt, + latestVersion, + updateAvailable, + lastError: lastErrorRaw || null, + }; +} diff --git a/backend/src/services/blzUpdateScheduler.service.ts b/backend/src/services/blzUpdateScheduler.service.ts new file mode 100644 index 00000000..2d24ee1e --- /dev/null +++ b/backend/src/services/blzUpdateScheduler.service.ts @@ -0,0 +1,51 @@ +/** + * Scheduler für das automatische BLZ-/Bankdaten-Update. + * + * Prüft täglich (03:30) sowie 90s nach Start, ob ein Update fällig ist: + * Auto-Update aktiv UND (noch nie aktualisiert ODER letzte Aktualisierung + * länger her als das eingestellte Intervall in Tagen). Trifft das zu, wird + * der neueste Datensatz geladen (siehe blzData.service). + * + * Das eigentliche Intervall ist in den Einstellungen konfigurierbar + * (`blzUpdateIntervalDays`); der Cron-Tick prüft nur die Fälligkeit. + */ +import cron from 'node-cron'; +import { getSetting, getSettingBool } from './appSetting.service.js'; +import { runUpdate } from './blzData.service.js'; + +async function maybeRun(): Promise { + const enabled = await getSettingBool('blzAutoUpdateEnabled'); + if (!enabled) { + return; + } + + const intervalDays = parseInt((await getSetting('blzUpdateIntervalDays')) || '30', 10) || 30; + const lastUpdatedAt = await getSetting('blzLastUpdatedAt'); + + if (lastUpdatedAt) { + const last = new Date(lastUpdatedAt).getTime(); + if (!Number.isNaN(last)) { + const ageDays = (Date.now() - last) / 86_400_000; + if (ageDays < intervalDays) { + return; // noch nicht fällig + } + } + } + + console.log('[BLZ-Scheduler] Update fällig – prüfe auf neueren Datensatz…'); + await runUpdate(false); +} + +export function startBlzUpdateScheduler(): void { + // Täglich um 03:30 (Server-Zeit) Fälligkeit prüfen. + cron.schedule('30 3 * * *', () => { + maybeRun().catch((err) => console.error('[BLZ-Scheduler] Lauf fehlgeschlagen:', err)); + }); + + // Catch-up 90s nach Start. + setTimeout(() => { + maybeRun().catch((err) => console.error('[BLZ-Scheduler] Catch-up fehlgeschlagen:', err)); + }, 90_000); + + console.log('[BLZ-Scheduler] Gestartet – tägliche Fälligkeitsprüfung + Catch-up nach 90s'); +} diff --git a/docker-compose.yml b/docker-compose.yml index da6d0770..f5ff0b75 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -81,6 +81,8 @@ services: - ${UPLOADS_DIR:-./data/uploads}:/app/uploads - ${FACTORY_DEFAULTS_DIR:-./data/factory-defaults}:/app/factory-defaults - ${BACKUPS_DIR:-./data/backups}:/app/prisma/backups + # Auto-aktualisierter BLZ-/Bankdaten-Datensatz (Bundesbank-Bankleitzahlen) + - ${BANKDATA_DIR:-./data/bankdata}:/app/bankdata adminer: image: adminer:latest diff --git a/docs/todo.md b/docs/todo.md index e12e1fb4..2ab1f3f2 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -97,6 +97,31 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung ## ✅ Erledigt +- [x] **🔄 BLZ-/Bankdaten: Auto-Update via Volume + Einstellungen** (2026-08-12) + - Neue Einstellungen-Seite **Einstellungen → Bankdaten (BLZ)** (`/settings/bank-data`): + zeigt Datenstand (aktive Quelle Volume/Image, Version, Anzahl Banken, + zuletzt geprüft/aktualisiert, neueste verfügbare Version + „Update + verfügbar"-Badge, letzter Fehler). Button **„Jetzt prüfen & aktualisieren"** + (+ „Neu laden erzwingen"). Auto-Update-Schalter + **Intervall in Tagen** (1–365). + - **Echtes Laufzeit-Update** statt Rebuild: Ein Scheduler (täglich 03:30 + + Catch-up 90s nach Start) prüft, ob laut Intervall fällig, und lädt dann den + aktuellen Datensatz. Quelle = npm/jsDelivr (`bankdata-germany` current.json + + next.json), gespeichert im **Bind-Mount-Volume** `BANKDATA_DIR` + (`./data/bankdata` → `/app/bankdata`, neu in docker-compose + Dockerfile mkdir + + .env.example). + - **Lookup-Vorrang:** Volume-Datensatz (aktuell) vor ins Image gebackenen Daten + (`bankdata-germany`, Fallback). Kombiniert current+next-Delta wie das Paket. + Kein Fremdcode – nur JSON wird gelesen. Validierung (≥1000 Einträge, Format) + verhindert, dass Müll/HTML den guten Datenstand überschreibt (atomarer + Write via tmp+rename). + - **Datenschutz:** Der Updater sendet KEINE Kundendaten – lädt nur eine + öffentliche Datendatei. Beim IBAN-Lookup verlässt weiterhin nichts den Server. + Auto-Update ist abschaltbar; ohne Egress/bei Fehler greift der Builtin-Stand. + - Endpoints: `GET /api/settings/blz` (settings:read), `POST /api/settings/blz/update-now` + (settings:update, `?force=1`). Settings-Keys `blzAutoUpdateEnabled` (default true), + `blzUpdateIntervalDays` (default 30) in Whitelist + validiert. + - **Damit entfällt die manuelle `npm update`-Wartung** aus dem vorherigen Punkt. + - [x] **🏦 Bankkarte-Modal: IBAN-Prüfung + BIC/Bank-Auto-Ausfüllen** (2026-08-12) - Button „BIC & Bank aus IBAN abrufen" unter dem IBAN-Feld (Bankkarte hinzufügen/bearbeiten). Füllt BIC + Banknamen automatisch aus und prüft diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 122554f4..03be65c3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -29,6 +29,7 @@ import CompanyProfileSettings from './pages/settings/CompanyProfileSettings'; import ViewSettings from './pages/settings/ViewSettings'; import PortalSettings from './pages/settings/PortalSettings'; import DeadlineSettings from './pages/settings/DeadlineSettings'; +import BankDataSettings from './pages/settings/BankDataSettings'; import EmailProviders from './pages/settings/EmailProviders'; import DatabaseBackup from './pages/settings/DatabaseBackup'; import FactoryDefaults from './pages/settings/FactoryDefaults'; @@ -231,6 +232,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 1d164dda..76a3e72b 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -1,7 +1,7 @@ import { Link } from 'react-router-dom'; import { useAuth } from '../context/AuthContext'; import Card from '../components/ui/Card'; -import { Settings as SettingsIcon, Code, Store, Clock, Calendar, UserCog, ChevronRight, Building2, FileType, Eye, Globe, Mail, Database, Shield, ShieldAlert, ShieldOff, FileText, FileEdit, PackageCheck, Hash } from 'lucide-react'; +import { Settings as SettingsIcon, Code, Store, Clock, Calendar, UserCog, ChevronRight, Building2, FileType, Eye, Globe, Mail, Database, Shield, ShieldAlert, ShieldOff, FileText, FileEdit, PackageCheck, Hash, Landmark } from 'lucide-react'; export default function Settings() { const { hasPermission, developerMode, setDeveloperMode } = useAuth(); @@ -146,6 +146,23 @@ export default function Settings() { + +
+
+ +
+
+

+ Bankdaten (BLZ) + +

+

Datenstand der Bankleitzahlen (BIC/Bank aus IBAN) + Auto-Update-Intervall.

+
+
+ blzDataApi.getStatus(), + }); + const status = statusData?.data; + + const { data: settingsData } = useQuery({ + queryKey: ['app-settings'], + queryFn: () => appSettingsApi.getAll(), + }); + + const [autoUpdate, setAutoUpdate] = useState(true); + const [intervalDays, setIntervalDays] = useState('30'); + const [hasChanges, setHasChanges] = useState(false); + + useEffect(() => { + if (settingsData?.data) { + setAutoUpdate((settingsData.data.blzAutoUpdateEnabled ?? 'true') === 'true'); + setIntervalDays(settingsData.data.blzUpdateIntervalDays || '30'); + setHasChanges(false); + } + }, [settingsData]); + + const saveMutation = useMutation({ + mutationFn: (settings: Record) => appSettingsApi.update(settings), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['app-settings'] }); + queryClient.invalidateQueries({ queryKey: ['blz-status'] }); + setHasChanges(false); + toast.success('Einstellungen gespeichert.'); + }, + onError: (err: any) => toast.error(err?.response?.data?.error || 'Speichern fehlgeschlagen.'), + }); + + const updateMutation = useMutation({ + mutationFn: (force: boolean) => blzDataApi.updateNow(force), + onSuccess: (res) => { + queryClient.invalidateQueries({ queryKey: ['blz-status'] }); + refetch(); + toast.success(res.data?.result?.message || 'Aktualisierung abgeschlossen.'); + }, + onError: (err: any) => toast.error(err?.response?.data?.error || 'Aktualisierung fehlgeschlagen.'), + }); + + const handleSave = () => { + const n = parseInt(intervalDays, 10); + if (Number.isNaN(n) || n < 1 || n > 365) { + toast.error('Das Intervall muss zwischen 1 und 365 Tagen liegen.'); + return; + } + saveMutation.mutate({ + blzAutoUpdateEnabled: autoUpdate ? 'true' : 'false', + blzUpdateIntervalDays: String(n), + }); + }; + + if (isLoading) { + return ( +
+
Laden...
+
+ ); + } + + return ( +
+
+ + + +
+ +

Bankdaten (BLZ)

+
+
+ + {/* Info */} +
+ +
+

+ Diese Daten (Bundesbank-Bankleitzahlen) füllen im Bankkarten-Formular beim + IBAN-Abruf BIC und Bankname aus. Der Abruf läuft rein lokal – + keine IBAN verlässt den Server. +

+

+ Das Auto-Update lädt lediglich eine öffentliche Datendatei (npm/jsDelivr) in + ein Volume – ebenfalls ohne Kundendaten. Ist es aus oder scheitert der Abruf, + wird automatisch der ins Image gebackene Datenstand verwendet. +

+
+
+ + {/* Status */} + +
+
+
Aktive Quelle
+
+ {status?.activeSource === 'volume' ? ( + + Volume (aktualisiert) + + ) : ( + + Image (eingebaut) + + )} +
+
+
+
Aktive Version
+
{status?.activeVersion || '–'}
+
+
+
Einträge (Banken)
+
{status?.entryCount?.toLocaleString('de-DE') ?? '–'}
+
+
+
Eingebaute Version (Fallback)
+
{status?.builtinVersion || '–'}
+
+
+
Zuletzt aktualisiert
+
{fmt(status?.lastUpdatedAt ?? null)}
+
+
+
Zuletzt geprüft
+
{fmt(status?.lastCheckedAt ?? null)}
+
+
+
Neueste verfügbare Version
+
+ {status?.latestVersion || '– (noch nicht geprüft)'} + {status?.updateAvailable && ( + + Update verfügbar + + )} +
+
+
+
Delta gültig ab
+
{status?.nextValidFrom ? fmt(status.nextValidFrom) : '–'}
+
+
+ + {status?.lastError && ( +
+ + Letzter Fehler beim Abruf: {status.lastError} +
+ )} + +
+ + +
+
+ + {/* Auto-Update-Einstellungen */} + + + +
+ + { setIntervalDays(e.target.value); setHasChanges(true); }} + className="w-24" + disabled={!autoUpdate} + /> + Tage +
+

+ Die Bundesbank ändert die Bankleitzahlen bis zu 4×/Jahr. Ein Wert von 30 (monatlich) + reicht komfortabel. Der Server prüft täglich, ob laut Intervall eine Aktualisierung fällig ist. +

+ +
+ +
+
+
+ ); +} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 37ac5205..2ea71680 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -1139,6 +1139,36 @@ export const appSettingsApi = { }, }; +// BLZ-/Bankdaten (Bundesbank-Bankleitzahlen) – Status + Auto-Update +export interface BlzStatus { + activeSource: 'volume' | 'builtin'; + activeVersion: string; + builtinVersion: string; + entryCount: number; + nextValidFrom: string | null; + fetchedAt: string | null; + autoUpdateEnabled: boolean; + intervalDays: number; + lastCheckedAt: string | null; + lastUpdatedAt: string | null; + latestVersion: string | null; + updateAvailable: boolean; + lastError: string | null; +} + +export const blzDataApi = { + getStatus: async () => { + const res = await api.get>('/settings/blz'); + return res.data; + }, + updateNow: async (force = false) => { + const res = await api.post>( + `/settings/blz/update-now${force ? '?force=1' : ''}`, + ); + return res.data; + }, +}; + // Backup & Restore export interface BackupInfo { name: string;