BLZ-Bankdaten: Laufzeit-Auto-Update ins Volume + Einstellungen-Seite

Statt npm-Rebuild-Wartung aktualisiert sich der Bankleitzahlen-Datensatz
jetzt zur Laufzeit. Ein Scheduler (taeglich 03:30 + Catch-up 90s) prueft
gemaess konfigurierbarem Intervall und laedt current.json/next.json von
npm/jsDelivr (Paket bankdata-germany) in das neue Bind-Mount-Volume
BANKDATA_DIR (./data/bankdata -> /app/bankdata).

Lookup bevorzugt den Volume-Datensatz vor den ins Image gebackenen Daten
(Fallback). Es wird kein Fremdcode ausgefuehrt - nur JSON gelesen und die
current+next-Delta-Logik nachgebaut. Validierung (>=1000 Eintraege, Format)
+ atomarer Write (tmp+rename) schuetzen den guten Stand vor Muell.

Datenschutz: Der Updater sendet keine Kundendaten, laedt nur eine
oeffentliche Datendatei; abschaltbar; bei Fehler/ohne Egress greift Builtin.

Neue Einstellungen-Seite /settings/bank-data zeigt Datenstand, Update-
Verfuegbarkeit und bietet "Jetzt aktualisieren" + Auto-Update-Schalter +
Intervall. Endpoints GET /api/settings/blz, POST /api/settings/blz/update-now.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-12 13:15:07 +02:00
co-authored by Claude Opus 4.8
parent 73bd72bd9f
commit 91fbe12299
16 changed files with 788 additions and 7 deletions
+2
View File
@@ -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
+1
View File
@@ -36,3 +36,4 @@ npm-debug.log*
# OS
.DS_Store
Thumbs.db
bankdata/
+1 -1
View File
@@ -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 \
@@ -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<void>
}
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,
@@ -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<void> {
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<void> {
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);
}
}
+2
View File
@@ -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();
});
+19
View File
@@ -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',
+19 -1
View File
@@ -12,6 +12,10 @@ const DEFAULT_SETTINGS: Record<string, string> = {
// 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.` };
+348
View File
@@ -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 = { "<BLZ>": ["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<string, BankTuple>;
interface NextData {
valid: string;
upsert: Record<string, BankTuple>;
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 = <root>/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<any> {
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<string> {
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<UpdateResult> {
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<BlzStatus> {
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,
};
}
@@ -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<void> {
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');
}
+2
View File
@@ -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
+25
View File
@@ -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** (1365).
- **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
+2
View File
@@ -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() {
<Route path="settings/view" element={<ViewSettings />} />
<Route path="settings/portal" element={<PortalSettings />} />
<Route path="settings/deadlines" element={<DeadlineSettings />} />
<Route path="settings/bank-data" element={<BankDataSettings />} />
<Route path="settings/email-providers" element={<EmailProviders />} />
<Route path="settings/database-backup" element={<DatabaseBackup />} />
<Route path="settings/factory-defaults" element={<FactoryDefaults />} />
+18 -1
View File
@@ -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() {
</div>
</div>
</Link>
<Link
to="/settings/bank-data"
className="block p-4 bg-white border border-gray-200 rounded-lg shadow-sm hover:shadow-md hover:border-blue-300 transition-all group"
>
<div className="flex items-start gap-4">
<div className="p-2 bg-blue-50 rounded-lg group-hover:bg-blue-100 transition-colors">
<Landmark className="w-6 h-6 text-blue-600" />
</div>
<div className="flex-1">
<h3 className="font-semibold text-gray-900 group-hover:text-blue-600 transition-colors flex items-center gap-2">
Bankdaten (BLZ)
<ChevronRight className="w-4 h-4 opacity-0 group-hover:opacity-100 transition-opacity" />
</h3>
<p className="text-sm text-gray-500 mt-1">Datenstand der Bankleitzahlen (BIC/Bank aus IBAN) + Auto-Update-Intervall.</p>
</div>
</div>
</Link>
<Link
to="/settings/pdf-templates"
className="block p-4 bg-white border border-gray-200 rounded-lg shadow-sm hover:shadow-md hover:border-blue-300 transition-all group"
@@ -0,0 +1,223 @@
import { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import toast from 'react-hot-toast';
import { appSettingsApi, blzDataApi } from '../../services/api';
import Card from '../../components/ui/Card';
import Input from '../../components/ui/Input';
import Button from '../../components/ui/Button';
import { ArrowLeft, Landmark, RefreshCw, CheckCircle, AlertTriangle, Info, Download } from 'lucide-react';
function fmt(dt: string | null): string {
if (!dt) return '';
const d = new Date(dt);
return Number.isNaN(d.getTime()) ? '' : d.toLocaleString('de-DE');
}
export default function BankDataSettings() {
const queryClient = useQueryClient();
const { data: statusData, isLoading, refetch } = useQuery({
queryKey: ['blz-status'],
queryFn: () => 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<string, string>) => 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 (
<div className="flex items-center justify-center py-12">
<div className="text-gray-500">Laden...</div>
</div>
);
}
return (
<div>
<div className="flex items-center gap-4 mb-6">
<Link to="/settings" className="text-gray-500 hover:text-gray-700">
<ArrowLeft className="w-5 h-5" />
</Link>
<div className="flex items-center gap-3">
<Landmark className="w-6 h-6" />
<h1 className="text-2xl font-bold">Bankdaten (BLZ)</h1>
</div>
</div>
{/* Info */}
<div className="mb-6 flex items-start gap-3 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<Info className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" />
<div className="text-sm text-blue-900 space-y-1">
<p>
Diese Daten (Bundesbank-Bankleitzahlen) füllen im Bankkarten-Formular beim
IBAN-Abruf <strong>BIC und Bankname</strong> aus. Der Abruf läuft rein lokal
<strong> keine IBAN verlässt den Server</strong>.
</p>
<p>
Das Auto-Update lädt lediglich eine <strong>öffentliche Datendatei</strong> (npm/jsDelivr) in
ein Volume ebenfalls ohne Kundendaten. Ist es aus oder scheitert der Abruf,
wird automatisch der ins Image gebackene Datenstand verwendet.
</p>
</div>
</div>
{/* Status */}
<Card title="Aktueller Datenstand">
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-3 text-sm">
<div className="flex justify-between border-b border-gray-100 pb-2">
<dt className="text-gray-500">Aktive Quelle</dt>
<dd className="font-medium">
{status?.activeSource === 'volume' ? (
<span className="inline-flex items-center gap-1 text-green-700">
<CheckCircle className="w-4 h-4" /> Volume (aktualisiert)
</span>
) : (
<span className="inline-flex items-center gap-1 text-gray-700">
Image (eingebaut)
</span>
)}
</dd>
</div>
<div className="flex justify-between border-b border-gray-100 pb-2">
<dt className="text-gray-500">Aktive Version</dt>
<dd className="font-medium">{status?.activeVersion || ''}</dd>
</div>
<div className="flex justify-between border-b border-gray-100 pb-2">
<dt className="text-gray-500">Einträge (Banken)</dt>
<dd className="font-medium">{status?.entryCount?.toLocaleString('de-DE') ?? ''}</dd>
</div>
<div className="flex justify-between border-b border-gray-100 pb-2">
<dt className="text-gray-500">Eingebaute Version (Fallback)</dt>
<dd className="font-medium">{status?.builtinVersion || ''}</dd>
</div>
<div className="flex justify-between border-b border-gray-100 pb-2">
<dt className="text-gray-500">Zuletzt aktualisiert</dt>
<dd className="font-medium">{fmt(status?.lastUpdatedAt ?? null)}</dd>
</div>
<div className="flex justify-between border-b border-gray-100 pb-2">
<dt className="text-gray-500">Zuletzt geprüft</dt>
<dd className="font-medium">{fmt(status?.lastCheckedAt ?? null)}</dd>
</div>
<div className="flex justify-between border-b border-gray-100 pb-2">
<dt className="text-gray-500">Neueste verfügbare Version</dt>
<dd className="font-medium">
{status?.latestVersion || ' (noch nicht geprüft)'}
{status?.updateAvailable && (
<span className="ml-2 inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-amber-100 text-amber-800 text-xs font-semibold">
<Download className="w-3 h-3" /> Update verfügbar
</span>
)}
</dd>
</div>
<div className="flex justify-between border-b border-gray-100 pb-2">
<dt className="text-gray-500">Delta gültig ab</dt>
<dd className="font-medium">{status?.nextValidFrom ? fmt(status.nextValidFrom) : ''}</dd>
</div>
</dl>
{status?.lastError && (
<div className="mt-4 flex items-start gap-2 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-800">
<AlertTriangle className="w-4 h-4 flex-shrink-0 mt-0.5" />
<span>Letzter Fehler beim Abruf: {status.lastError}</span>
</div>
)}
<div className="mt-6 flex flex-wrap gap-3">
<Button onClick={() => updateMutation.mutate(false)} disabled={updateMutation.isPending}>
<RefreshCw className={`w-4 h-4 mr-2 ${updateMutation.isPending ? 'animate-spin' : ''}`} />
{updateMutation.isPending ? 'Prüfe…' : 'Jetzt prüfen & aktualisieren'}
</Button>
<Button variant="secondary" onClick={() => updateMutation.mutate(true)} disabled={updateMutation.isPending}>
Neu laden erzwingen
</Button>
</div>
</Card>
{/* Auto-Update-Einstellungen */}
<Card title="Automatische Aktualisierung" className="mt-6">
<label className="flex items-center gap-3 mb-5">
<input
type="checkbox"
checked={autoUpdate}
onChange={(e) => { setAutoUpdate(e.target.checked); setHasChanges(true); }}
className="rounded w-4 h-4"
/>
<span className="font-medium">Auto-Update aktiviert</span>
</label>
<div className="flex items-center gap-2 mb-2">
<label className="text-sm text-gray-700">Prüf-/Update-Intervall:</label>
<Input
type="number"
min="1"
max="365"
value={intervalDays}
onChange={(e) => { setIntervalDays(e.target.value); setHasChanges(true); }}
className="w-24"
disabled={!autoUpdate}
/>
<span className="text-sm text-gray-700">Tage</span>
</div>
<p className="text-sm text-gray-500">
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.
</p>
<div className="mt-6 flex justify-end">
<Button onClick={handleSave} disabled={!hasChanges || saveMutation.isPending}>
{saveMutation.isPending ? 'Speichere…' : 'Speichern'}
</Button>
</div>
</Card>
</div>
);
}
+30
View File
@@ -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<ApiResponse<BlzStatus>>('/settings/blz');
return res.data;
},
updateNow: async (force = false) => {
const res = await api.post<ApiResponse<{ result: { changed: boolean; version: string; message: string }; status: BlzStatus }>>(
`/settings/blz/update-now${force ? '?force=1' : ''}`,
);
return res.data;
},
};
// Backup & Restore
export interface BackupInfo {
name: string;