From 458d007107bb04341f08f9f821dac91265439c20 Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Tue, 18 Aug 2026 09:12:19 +0200 Subject: [PATCH] Fix #187: Bankleitzahlen-Verzeichnis unter Einstellungen --- Changes.md | 11 + docs/funktionen.md | 2 + migrations/094_bankinstitute.sql | 32 +++ public/index.php | 6 + src/Controllers/BankinstituteController.php | 132 ++++++++++ src/Models/Bankinstitut.php | 131 ++++++++++ src/Services/FintsInstituteImport.php | 251 ++++++++++++++++++++ src/Views/bank/fints.php | 55 +++++ src/Views/layout/nav.php | 4 + src/Views/settings/bankinstitute.php | 235 ++++++++++++++++++ tests/92_BankinstituteTest.php | 107 +++++++++ 11 files changed, 966 insertions(+) create mode 100644 migrations/094_bankinstitute.sql create mode 100644 src/Controllers/BankinstituteController.php create mode 100644 src/Models/Bankinstitut.php create mode 100644 src/Services/FintsInstituteImport.php create mode 100644 src/Views/settings/bankinstitute.php create mode 100644 tests/92_BankinstituteTest.php diff --git a/Changes.md b/Changes.md index 036cd23..e432291 100644 --- a/Changes.md +++ b/Changes.md @@ -2,6 +2,17 @@ --- +## 2026-08-18 – Bankleitzahlen-Verzeichnis: FinTS-Institutsliste unter Einstellungen einlesen (#187) + +- Neuer Punkt unter Einstellungen (`/settings/bankinstitute`, analog zu „CSV Bankimport Formate"): nimmt die CSV-Institutsliste der Deutschen Kreditwirtschaft (hbci-zka.de) auf, damit `bank_providers.default_fints_url`/die FinTS-Server-URL nicht mehr per Migration gepflegt werden muss (siehe `069_bank_providers_fints_url.sql`). +- **`migrations/094_bankinstitute.sql`:** neue Tabelle `bankinstitute` (blz, name, ort, bic, pin_tan_url) – reine Nachschlagedaten, kein Fremdschlüssel, bewusst kein UNIQUE auf `blz` (die Quelldatei führt dieselbe Bankleitzahl mehrfach, eine Zeile je Filiale). Dazu `bankinstitute_import` als Singleton-Tabelle für die Metadaten des letzten Imports (Stand der Daten, Zeilen, Anzahl Bankleitzahlen, Zugangspunkte, Zeitpunkt). +- **`src/Services/FintsInstituteImport.php`** (neu): liest die Datei über `fgetcsv()` auf einem Stream statt zeilenweise, damit ein über zwei Zeilen laufender, in Anführungszeichen stehender Institutsname (z.B. Oldenburgische Landesbank) korrekt als eine Zeile gelesen wird. Konvertiert ISO-8859-1 → UTF-8, erkennt das Trennzeichen automatisch, spricht Spalten über ihre Überschrift an (nicht über ihre Position: `BLZ`/`Bankleitzahl`, `Institut`/`Kreditinstitut`, `Ort`, `BIC`, diverse PIN/TAN-Aliase) und bricht ab, wenn BLZ- oder Institut-Spalte fehlt. Zeilen ohne BLZ oder Institut werden übersprungen (deckt die hunderten leeren Zeilen mit laufender Nummer am Dateiende ab, ohne dass eine feste Zeilenzahl konfiguriert werden muss). Enthält eine Zelle in der PIN/TAN-Spalte keine `http(s)://`-Adresse, gilt sie als Text ohne Zugang. Die Chipkartenverfahren-Spalten (DDV, RDH-1…10, RAH-7/9/10) werden schlicht nicht gelesen. „Stand der Daten" wird heuristisch aus einer Titelzeile vor dem Header bzw. aus dem Dateinamen ermittelt (die Quelldatei hat kein festes Datumsfeld). +- **`src/Models/Bankinstitut.php`** (neu): `search()` (Bankleitzahl, BIC, Name, Ort in einem Zug; Treffer in der Bankleitzahl von vorn stehen oben, eine Ziffernfolge wird nicht automatisch als Bankleitzahl gelesen), `findAccessPointByBlz()` (erster Treffer mit gesetztem Zugangspunkt), `replaceAll()` (Import ersetzt immer den gesamten Bestand statt ihn zu ergänzen, chunked Bulk-Insert in einer Transaktion). +- **`src/Controllers/BankinstituteController.php`** (neu): `index()` (Bestandsübersicht + Suche), `check()`/`import()` als zwei Aktionen auf derselben hochgeladenen Datei (AJAX, die Datei wird nicht dauerhaft abgelegt – nur `$_FILES`-Tempdatei während des Requests), `search()` für die Live-Suche. +- **`src/Views/settings/bankinstitute.php`** (neu): Upload mit „Nur prüfen"/„Übernehmen", Prüfbericht (Zeilen, Bankleitzahlen, Zugangspunkte, Stand, erkannte Spalten, Sichtprobe der ersten Zeilen), Bestands- und Sucheanzeige. +- **`src/Views/bank/fints.php`:** im FinTS-Abschnitt des „Neue Direktverbindung"-Dialogs füllt eine Live-Suche gegen das neue Verzeichnis (`/bankinstitute/search`) BLZ und FinTS-Server-URL auf Klick vor, statt beides von Hand eintragen zu müssen. +- **`tests/92_BankinstituteTest.php`** (neu): Deckt `FintsInstituteImport` (ISO-8859-1, mehrzeiliger Institutsname in Anführungszeichen, doppelte Bankleitzahlen, überzählige Leerzeilen am Ende, Text statt Adresse in der PIN/TAN-Spalte, fehlende Pflicht-Header, Stand-Erkennung) sowie `Models\Bankinstitut` (`replaceAll`, `search`, `findAccessPointByBlz`, `importMeta`) ab. + ## 2026-08-18 – PayPal-Abruf: abgeschnittene NVP-Antworten im Protokoll (#185) - `PayPalNvpFetchService::externalIdExists()` dedupliziert bereits über `external_id` = die PayPal-Transaktionsnummer (`L_TRANSACTIONID`), nicht über Datum/Betrag/Text – eine nachträglich korrigierte Buchung wird also schon heute nicht doppelt importiert. Kein Änderungsbedarf an diesem Teil des Issues. diff --git a/docs/funktionen.md b/docs/funktionen.md index dd1d508..ea76c4c 100644 --- a/docs/funktionen.md +++ b/docs/funktionen.md @@ -98,6 +98,7 @@ Anlagenspiegel für abnutzbare Wirtschaftsgüter – siehe [anlagevermögen.md]( - **Neues CSV-Format manuell anlegen**: im „Neue Direktverbindung"-Dialog (Importieren) kann per „+ CSV-Importformat…" ein neues CSV-Format mit Trennzeichen, Datums-/Dezimalformat etc. angelegt werden; Spaltenzuordnung wird anschließend unter Einstellungen → CSV Bankimport Formate verfeinert - **Direktverbindung „PayPal"** (NVP/SOAP-API): API-Benutzer, API-Kennwort, Signature und Sandbox-Flag werden verschlüsselt abgelegt. Live-Log via Server-Sent Events - **Direktverbindung „FinTS"** (Comdirect, DKB und weitere): BLZ, Benutzerkennung und PIN, optional FinTS-Server-URL (sonst Default aus dem Provider) und IBAN-Filter. PIN wird verschlüsselt abgelegt (AES-256-GCM). Leseabruf mit PIN; TAN-pflichtige Aktionen werden mit Fehlermeldung abgebrochen. Die FinTS-Server-URL wird beim Speichern und erneut vor jedem Abruf mit `SsrfGuard` geprüft: nur `https://` zu öffentlich erreichbaren Hosts ist erlaubt, private/interne Ziele (RFC1918, Loopback, Link-Local) werden abgelehnt (Schutz vor SSRF) +- **Bankleitzahl nachschlagen**: im „Neue Direktverbindung"-Dialog (FinTS) kann die PIN/TAN-Server-URL über eine Live-Suche (BLZ, BIC, Name oder Ort) aus dem Bankleitzahlen-Verzeichnis übernommen werden, statt sie von Hand einzutragen - **Letzter Abrufzeitpunkt** wird pro Verbindung gespeichert; nächster Abruf startet ab 00:00 dieses Tages (max. ein Tag Überlappung für Dedup) - **Kontostand**: FinTS-Abrufe liefern den Schlusssaldo des letzten Statements, PayPal den aktuellen Saldo über `GetBalance`; der Wert wird zusammen mit dem Abrufzeitpunkt auf der Verbindungskachel angezeigt und bleibt bei Abrufen ohne ermittelten Kontostand erhalten - **Abrufen ab**: Im Bearbeiten-Dialog kann pro Direktverbindung ausgewählt werden, ab wann der nächste Lauf Buchungen holt: *Ab letztem Abruf* (Default), *Letzte 7 Tage*, *Aktueller Monat (ab 01.)*, *Letzte 30 Tage* oder *Ohne Datum (letzte 365 Tage)*. Die Sonderauswahl gilt nur einmalig – nach erfolgreichem Abruf fällt die Verbindung wieder auf den Default zurück @@ -139,6 +140,7 @@ Zuordnungsregeln: [berechnungslogik.md](berechnungslogik.md) - SMTP-Konfiguration für E-Mail-Versand (Host, Port, Benutzer, Passwort, TLS/SSL) - KI-Kontierungsvorschlag (Ollama oder OpenAI-kompatible API) - Nextcloud-Belegimport (WebDAV-Zugangsdaten) +- **Bankleitzahlen-Verzeichnis** (`/settings/bankinstitute`): nimmt die CSV-Institutsliste der Deutschen Kreditwirtschaft (hbci-zka.de) auf – BLZ, Institut, Ort, BIC und PIN/TAN-Zugangspunkt. „Nur prüfen" zeigt einen Prüfbericht (Zeilen, Bankleitzahlen, Zugangspunkte, Stand der Daten, Sichtprobe der ersten Zeilen) ohne die Datenbank zu ändern; „Übernehmen" ersetzt den gesamten Bestand. Die Datei selbst wird nicht gespeichert. Suche über Bankleitzahl, BIC, Name und Ort in einem Zug, Treffer in der Bankleitzahl stehen oben. Dient als Nachschlagequelle für die FinTS-Server-URL beim Anlegen einer Bank-Direktverbindung (siehe „Bank buchen") --- diff --git a/migrations/094_bankinstitute.sql b/migrations/094_bankinstitute.sql new file mode 100644 index 0000000..cf3104c --- /dev/null +++ b/migrations/094_bankinstitute.sql @@ -0,0 +1,32 @@ +-- Bankleitzahlen-Verzeichnis (Fix #187): reine Nachschlagedaten aus der +-- CSV-Institutsliste der Deutschen Kreditwirtschaft (hbci-zka.de), damit +-- die FinTS-PIN/TAN-Zugangspunkt-URL beim Anlegen einer Verbindung nicht +-- mehr per Migration gepflegt werden muss (siehe 069_bank_providers_fints_url.sql). +-- +-- Kein Fremdschlüssel, kein UNIQUE auf blz: dieselbe Bankleitzahl kommt in +-- der Quelldatei mehrfach vor (eine Zeile je Filiale). Ein Import ersetzt +-- immer den gesamten Bestand (siehe Services\FintsInstituteImport). +CREATE TABLE IF NOT EXISTS bankinstitute ( + id INT AUTO_INCREMENT PRIMARY KEY, + blz VARCHAR(8) NOT NULL, + name VARCHAR(255) NOT NULL, + ort VARCHAR(255) NOT NULL DEFAULT '', + bic VARCHAR(11) NOT NULL DEFAULT '', + pin_tan_url VARCHAR(255) NULL, + INDEX idx_bankinstitute_blz (blz) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Metadaten des letzten Imports (Singleton, id=1) – für die Anzeige unter +-- Einstellungen → Bankinstitute, ohne bei jedem Seitenaufruf die gesamte +-- Tabelle neu auszuzählen. +CREATE TABLE IF NOT EXISTS bankinstitute_import ( + id INT PRIMARY KEY, + quelle_dateiname VARCHAR(255) NULL, + stand VARCHAR(20) NULL, + zeilen INT NOT NULL DEFAULT 0, + blz_anzahl INT NOT NULL DEFAULT 0, + mit_zugangspunkt INT NOT NULL DEFAULT 0, + importiert_am DATETIME NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +INSERT IGNORE INTO bankinstitute_import (id) VALUES (1); diff --git a/public/index.php b/public/index.php index 0c17376..a8bf9c9 100644 --- a/public/index.php +++ b/public/index.php @@ -95,6 +95,7 @@ use Steuerkram\Controllers\InvoiceController; use Steuerkram\Controllers\SupplierController; use Steuerkram\Controllers\UstvaController; use Steuerkram\Controllers\EuerController; +use Steuerkram\Controllers\BankinstituteController; use Steuerkram\Controllers\SettingsController; use Steuerkram\Controllers\LoginController; use Steuerkram\Controllers\AssetController; @@ -255,6 +256,11 @@ $router->post('/settings/bankimport/dismiss-suggestion', fn($p) => (new Settings $router->post('/settings/bankimport/{id}/field', fn($p) => (new SettingsController())->updateBankImportField($p)); $router->post('/settings/bankimport/{id}/delete', fn($p) => (new SettingsController())->deleteBankImportFormat($p)); +$router->get('/settings/bankinstitute', fn($p) => (new BankinstituteController())->index($p)); +$router->post('/settings/bankinstitute/check', fn($p) => (new BankinstituteController())->check($p)); +$router->post('/settings/bankinstitute/import',fn($p) => (new BankinstituteController())->import($p)); +$router->get('/bankinstitute/search', fn($p) => (new BankinstituteController())->search($p)); + // ── Buchungsvorlagen ────────────────────────────────────────── $router->get('/einstellungen/buchungsvorlagen', fn($p) => (new BuchungsvorlageController())->index($p)); $router->get('/einstellungen/buchungsvorlagen/neu', fn($p) => (new BuchungsvorlageController())->create($p)); diff --git a/src/Controllers/BankinstituteController.php b/src/Controllers/BankinstituteController.php new file mode 100644 index 0000000..ac93a40 --- /dev/null +++ b/src/Controllers/BankinstituteController.php @@ -0,0 +1,132 @@ +render('layout/base.php', [ + 'pageTitle' => 'Bankinstitute', + 'content' => $this->renderView('settings/bankinstitute', [ + 'meta' => Bankinstitut::importMeta(), + 'count' => Bankinstitut::count(), + 'query' => $query, + 'rows' => $rows, + ]), + ]); + } + + public function check(array $params): void + { + header('Content-Type: application/json'); + + $analysis = $this->analyzeUpload(); + if ($analysis === null) { + return; + } + + echo json_encode([ + 'ok' => true, + 'header' => $analysis['header'], + 'columns' => $analysis['columns'], + 'stand' => $analysis['stand'], + 'zeilen' => $analysis['zeilen'], + 'blz_anzahl' => $analysis['blz_anzahl'], + 'mit_zugangspunkt' => $analysis['mit_zugangspunkt'], + 'preview' => $analysis['preview'], + ], JSON_UNESCAPED_UNICODE); + } + + public function import(array $params): void + { + header('Content-Type: application/json'); + + $analysis = $this->analyzeUpload(); + if ($analysis === null) { + return; + } + + if (empty($analysis['rows'])) { + http_response_code(422); + echo json_encode(['error' => 'Keine verwertbaren Institute in der Datei gefunden.']); + return; + } + + try { + Bankinstitut::replaceAll($analysis['rows'], [ + 'filename' => $_FILES['institute_file']['name'] ?? null, + 'stand' => $analysis['stand'], + 'zeilen' => $analysis['zeilen'], + 'blz_anzahl' => $analysis['blz_anzahl'], + 'mit_zugangspunkt' => $analysis['mit_zugangspunkt'], + ]); + } catch (\Throwable $e) { + http_response_code(500); + echo json_encode(['error' => 'Import fehlgeschlagen: ' . $e->getMessage()]); + return; + } + + echo json_encode([ + 'ok' => true, + 'zeilen' => $analysis['zeilen'], + 'blz_anzahl' => $analysis['blz_anzahl'], + 'mit_zugangspunkt' => $analysis['mit_zugangspunkt'], + 'stand' => $analysis['stand'], + ], JSON_UNESCAPED_UNICODE); + } + + /** + * AJAX: Suche über Bankleitzahl, BIC, Name und Ort – sowohl für die Liste + * unter Einstellungen als auch für die Bankleitzahl-Suche beim Anlegen + * einer FinTS-Direktverbindung (siehe bank/fints.php). + */ + public function search(array $params): void + { + header('Content-Type: application/json'); + $query = trim((string)($_GET['q'] ?? '')); + echo json_encode(Bankinstitut::search($query, 20), JSON_UNESCAPED_UNICODE); + } + + /** + * Validiert den Upload und liefert die Analyse von FintsInstituteImport, + * oder null nachdem bereits eine Fehlerantwort gesendet wurde. + */ + private function analyzeUpload(): ?array + { + $file = $_FILES['institute_file'] ?? null; + if (empty($file['tmp_name']) || $file['error'] !== UPLOAD_ERR_OK) { + http_response_code(400); + echo json_encode(['error' => 'Datei-Upload fehlgeschlagen.']); + return null; + } + + $origName = $file['name'] ?? 'upload'; + $ext = strtolower(pathinfo($origName, PATHINFO_EXTENSION)); + if (!in_array($ext, self::ALLOWED_EXT, true)) { + http_response_code(400); + echo json_encode(['error' => 'Nicht erlaubtes Dateiformat. Erlaubt: CSV, TXT.']); + return null; + } + + try { + return (new FintsInstituteImport())->analyze($file['tmp_name'], $origName); + } catch (\RuntimeException $e) { + http_response_code(422); + echo json_encode(['error' => $e->getMessage()]); + return null; + } + } +} diff --git a/src/Models/Bankinstitut.php b/src/Models/Bankinstitut.php new file mode 100644 index 0000000..c7ac5bd --- /dev/null +++ b/src/Models/Bankinstitut.php @@ -0,0 +1,131 @@ + ['blz', 'bankleitzahl'], + 'name' => ['institut', 'kreditinstitut', 'institutsname', 'bankname', 'name'], + 'ort' => ['ort'], + 'bic' => ['bic', 'swift-bic', 'swift'], + 'pin_tan_url' => [ + 'pin/tan-zugang url', 'pin/tan-adresse', 'pin/tan-url', 'pin/tan url', + 'pin/tan-zugang', 'pin/tan zugang', 'url', + ], + ]; + + /** + * Analysiert eine hochgeladene Datei vollständig, ohne die Datenbank + * anzufassen – Basis sowohl für die reine Prüfung als auch für den + * eigentlichen Import (der dasselbe Ergebnis anschließend übernimmt). + * + * @throws \RuntimeException wenn die Datei nicht lesbar ist oder keine + * Headerzeile mit den Spalten „BLZ“ und „Institut“ gefunden wird + */ + public function analyze(string $filePath, string $origFilename): array + { + $content = file_get_contents($filePath); + if ($content === false) { + throw new \RuntimeException('Datei konnte nicht gelesen werden.'); + } + + $content = preg_replace('/^\xEF\xBB\xBF/', '', $content); + if (!mb_check_encoding($content, 'UTF-8')) { + $content = mb_convert_encoding($content, 'UTF-8', 'ISO-8859-1'); + } + + $delimiter = $this->detectDelimiter($content); + + $stream = fopen('php://temp', 'r+'); + fwrite($stream, $content); + rewind($stream); + + try { + return $this->parseStream($stream, $delimiter, $origFilename); + } finally { + fclose($stream); + } + } + + /** + * @param resource $stream + */ + private function parseStream($stream, string $delimiter, string $origFilename): array + { + $preambleLines = []; + $header = null; + $columns = []; + + while (($cells = fgetcsv($stream, 0, $delimiter, '"')) !== false) { + if ($this->rowIsEmpty($cells)) { + continue; + } + + $normalized = array_map(fn($c) => $this->normalizeHeader((string)$c), $cells); + $candidate = $this->detectColumns($normalized); + if ($candidate['blz'] !== null && $candidate['name'] !== null) { + $header = $cells; + $columns = $candidate; + break; + } + + $preambleLines[] = implode(' ', $cells); + if (count($preambleLines) > self::MAX_HEADER_SCAN) { + break; + } + } + + if ($header === null) { + throw new \RuntimeException( + 'Kein Header mit den Spalten „BLZ“ und „Institut“ gefunden – Import abgebrochen.' + ); + } + + $rows = []; + $preview = []; + $blzSeen = []; + $mitZugangspunkt = 0; + + while (($cells = fgetcsv($stream, 0, $delimiter, '"')) !== false) { + if ($this->rowIsEmpty($cells)) { + continue; + } + + $blz = trim((string)($cells[$columns['blz']] ?? '')); + $name = trim((string)($cells[$columns['name']] ?? '')); + if ($blz === '' || $name === '') { + // Trailing Leerzeilen mit laufender Nummer, oder sonstiger Datenmüll. + continue; + } + + $pinTanUrl = $columns['pin_tan_url'] !== null + ? $this->normalizeUrl((string)($cells[$columns['pin_tan_url']] ?? '')) + : null; + + $row = [ + 'blz' => $blz, + 'name' => $name, + 'ort' => $columns['ort'] !== null ? trim((string)($cells[$columns['ort']] ?? '')) : '', + 'bic' => $columns['bic'] !== null ? trim((string)($cells[$columns['bic']] ?? '')) : '', + 'pin_tan_url' => $pinTanUrl, + ]; + + $rows[] = $row; + $blzSeen[$blz] = true; + if ($pinTanUrl !== null) { + $mitZugangspunkt++; + } + if (count($preview) < self::PREVIEW_ROWS) { + $preview[] = $row; + } + } + + return [ + 'header' => $header, + 'columns' => $columns, + 'delimiter' => $delimiter, + 'stand' => $this->detectStand($preambleLines, $origFilename), + 'zeilen' => count($rows), + 'blz_anzahl' => count($blzSeen), + 'mit_zugangspunkt' => $mitZugangspunkt, + 'rows' => $rows, + 'preview' => $preview, + ]; + } + + private function rowIsEmpty(array $cells): bool + { + foreach ($cells as $cell) { + if (trim((string)$cell) !== '') { + return false; + } + } + return true; + } + + private function detectDelimiter(string $content): string + { + $firstLine = strtok($content, "\n") ?: ''; + $firstLine = rtrim($firstLine, "\r"); + $semiCount = count(str_getcsv($firstLine, ';', '"')); + $commaCount = count(str_getcsv($firstLine, ',', '"')); + return $commaCount > $semiCount ? ',' : ';'; + } + + private function normalizeHeader(string $value): string + { + return mb_strtolower(trim($value)); + } + + /** @return array */ + private function detectColumns(array $normalizedHeader): array + { + $columns = []; + foreach (self::FIELD_ALIASES as $field => $aliases) { + $columns[$field] = $this->findColumn($normalizedHeader, $aliases); + } + return $columns; + } + + private function findColumn(array $normalizedHeader, array $aliases): ?int + { + foreach ($aliases as $alias) { + $idx = array_search($alias, $normalizedHeader, true); + if ($idx !== false) { + return $idx; + } + } + foreach ($aliases as $alias) { + foreach ($normalizedHeader as $idx => $cell) { + if ($cell !== '' && str_contains($cell, $alias)) { + return $idx; + } + } + } + return null; + } + + /** Nur Werte, die wie eine http(s)-Adresse aussehen, gelten als Zugangspunkt. */ + private function normalizeUrl(string $value): ?string + { + $value = trim($value); + if ($value === '' || !preg_match('#^https?://#i', $value)) { + return null; + } + return $value; + } + + /** + * Sucht ein Datum (TT.MM.JJJJ) zuerst in den Zeilen vor der Headerzeile, + * dann im Dateinamen. Beides ist reine Heuristik: die Quelldatei hat kein + * festes Feld für den Datenstand, DK-Exporte tragen ihn aber häufig als + * Titelzeile oder im Dateinamen. + */ + private function detectStand(array $preambleLines, string $origFilename): ?string + { + $pattern = '/(\d{2}\.\d{2}\.\d{4})/'; + + foreach ($preambleLines as $line) { + if (preg_match($pattern, $line, $m)) { + return $m[1]; + } + } + + if (preg_match($pattern, $origFilename, $m)) { + return $m[1]; + } + + if (preg_match('/(\d{4})[-_]?(\d{2})[-_]?(\d{2})/', $origFilename, $m)) { + return "{$m[3]}.{$m[2]}.{$m[1]}"; + } + + return null; + } +} diff --git a/src/Views/bank/fints.php b/src/Views/bank/fints.php index dba28a3..6933f75 100644 --- a/src/Views/bank/fints.php +++ b/src/Views/bank/fints.php @@ -215,6 +215,10 @@ $csvDateFormatOptions = [
+
+ + +
Die Konto-Auswahl wird beim ersten Abruf abgefragt.
@@ -491,6 +495,57 @@ $csvDateFormatOptions = [ if (btnCancel) btnCancel.addEventListener('click', closeNew); modalNew.addEventListener('click', (e) => { if (e.target === modalNew) closeNew(); }); + // ── Bankleitzahlen-Verzeichnis: Suche + Formular vorbefüllen (Fix #187) ── + (function () { + const blzField = document.getElementById('fc-blz'); + const urlField = document.getElementById('fc-fints-url'); + const searchInput = document.getElementById('fc-blz-search'); + const resultsBox = document.getElementById('fc-blz-results'); + if (!searchInput || !resultsBox) return; + + function esc(str) { + return String(str ?? '').replace(/&/g,'&').replace(//g,'>'); + } + + let debounceTimer = null; + searchInput.addEventListener('input', () => { + clearTimeout(debounceTimer); + const q = searchInput.value.trim(); + if (q.length < 2) { resultsBox.style.display = 'none'; resultsBox.innerHTML = ''; return; } + debounceTimer = setTimeout(async () => { + let rows; + try { + const res = await fetch('/bankinstitute/search?q=' + encodeURIComponent(q)); + rows = await res.json(); + } catch (_) { rows = []; } + + if (!Array.isArray(rows) || rows.length === 0) { + resultsBox.innerHTML = '
Keine Treffer
'; + resultsBox.style.display = 'block'; + return; + } + + resultsBox.innerHTML = rows.map((r, i) => ` +
+ ${esc(r.blz)} ${esc(r.name)} · ${esc(r.ort)} + ${r.pin_tan_url ? '' : ' (kein PIN/TAN-Zugang)'} +
+ `).join(''); + resultsBox.style.display = 'block'; + + resultsBox.querySelectorAll('.fc-blz-result').forEach((el) => { + el.addEventListener('click', () => { + const row = rows[parseInt(el.dataset.idx, 10)]; + blzField.value = row.blz; + urlField.value = row.pin_tan_url || ''; + resultsBox.style.display = 'none'; + searchInput.value = ''; + }); + }); + }, 300); + }); + })(); + function syncTypFields(typ) { apiNew.style.display = (typ === 'API') ? 'block' : 'none'; fintsNew.style.display = (typ === 'FinTS') ? 'block' : 'none'; diff --git a/src/Views/layout/nav.php b/src/Views/layout/nav.php index efbec6e..ad17908 100644 --- a/src/Views/layout/nav.php +++ b/src/Views/layout/nav.php @@ -113,6 +113,10 @@ function navActive(string $prefix, string $current): string { CSV Bankimport Formate + + + Bankinstitute + Buchungsvorlagen diff --git a/src/Views/settings/bankinstitute.php b/src/Views/settings/bankinstitute.php new file mode 100644 index 0000000..90067d1 --- /dev/null +++ b/src/Views/settings/bankinstitute.php @@ -0,0 +1,235 @@ + + +
+
Aktueller Bestand
+
+ +

Noch kein Import durchgeführt – 0 Institute in der Datenbank.

+ + + + + + + + + + + + + + + + + + + + + + + + +
Institute
Verschiedene Bankleitzahlen
Mit PIN/TAN-Zugangspunkt
Stand der Datenunbekannt' ?>
Importiert am
+ +
+
+ +
+
Institutsliste importieren
+
+

+ CSV-Institutsliste der Deutschen Kreditwirtschaft (hbci-zka.de). Ein Import ersetzt den gesamten + Bestand. Die Datei wird nicht gespeichert – nur ihr Inhalt landet in der Datenbank. +

+ +
+
+ +
+
+ + +
+
+ +
+ + +
+
+ +
+
Suche
+
+
+ + +
+ + +

Institute in der Datenbank – bitte einen Suchbegriff eingeben.

+ +

Keine Treffer für „".

+ + + + + + + + + + + + + + + + +
BLZInstitutOrtBICPIN/TAN-URL
–
+ +
+
+ + diff --git a/tests/92_BankinstituteTest.php b/tests/92_BankinstituteTest.php new file mode 100644 index 0000000..18f2682 --- /dev/null +++ b/tests/92_BankinstituteTest.php @@ -0,0 +1,107 @@ +tempPath('.csv'); + file_put_contents($path, mb_convert_encoding($utf8Content, 'ISO-8859-1', 'UTF-8')); + return $path; + } + + public function run(): void + { + $import = new \Steuerkram\Services\FintsInstituteImport(); + + // ── Vollständige, realistische Datei ─────────────────────────── + $csv = "Kreditinstitute mit PIN/TAN-Zugang Stand: 20.05.2026\n" + . "BLZ;Institut;Ort;BIC;PIN/TAN-Zugang URL;DDV;RDH-1\n" + . "10030000;Sparkasse Köln Bonn;Köln;COLSDE33XXX;https://banking.spk.de/fints;1;1\n" + . "10030000;Sparkasse Köln Bonn Filiale;Bonn;COLSDE33XXX;https://banking.spk.de/fints;1;1\n" + . "12030000;\"Oldenburgische\nLandesbank\";Oldenburg;OLBODEH2XXX;https://fints.oldenburgischelandesbank.de;0;1\n" + . "99999999;Kleine Bank ohne Zugang;Nirgendwo;;kein PIN/TAN-Zugang;0;0\n" + . "88888888;;;;;;\n" + . ";;;;;;\n" + . "88888889;;;;;;\n"; + + $tmp = $this->makeIso88591Csv($csv); + $result = $import->analyze($tmp, 'blz-aktuell-csv-data.csv'); + + $this->assertEquals(4, $result['zeilen'], 'Vier verwertbare Zeilen (Leerzeilen am Ende ausgeschlossen)'); + $this->assertEquals(3, $result['blz_anzahl'], 'Drei verschiedene Bankleitzahlen (10030000 doppelt)'); + $this->assertEquals(3, $result['mit_zugangspunkt'], 'Drei Zeilen mit echtem PIN/TAN-Zugangspunkt (die vierte hat nur Text statt Adresse)'); + $this->assertEquals('20.05.2026', $result['stand'], 'Stand aus der Titelzeile erkannt'); + + $names = array_column($result['rows'], 'name'); + $this->assert( + (bool)preg_grep('/Oldenburgische.*Landesbank/s', $names), + 'Mehrzeiliger, in Anführungszeichen stehender Institutsname wird als eine Zeile gelesen' + ); + + $koeln = $result['rows'][0]; + $this->assertEquals('Köln', mb_substr($koeln['ort'], 0, 4), 'ISO-8859-1 → UTF-8: Umlaut in "Köln" korrekt dekodiert'); + + $ohneZugang = array_values(array_filter($result['rows'], fn($r) => $r['blz'] === '99999999'))[0]; + $this->assert($ohneZugang['pin_tan_url'] === null, 'Text statt Adresse in der PIN/TAN-Spalte wird nicht als URL übernommen'); + + $this->assert( + !in_array('88888888', array_column($result['rows'], 'blz'), true) + && !in_array('88888889', array_column($result['rows'], 'blz'), true), + 'Zeilen ohne Institutsnamen (trailing Leerzeilen) werden übersprungen' + ); + + // ── Fehlender Pflicht-Header bricht den Import ab ────────────── + $csvOhneHeader = "Nur irgendein Text;ohne;Header\n1;2;3\n"; + $tmpBad = $this->makeIso88591Csv($csvOhneHeader); + $exceptionThrown = false; + try { + $import->analyze($tmpBad, 'kaputt.csv'); + } catch (\RuntimeException $e) { + $exceptionThrown = true; + } + $this->assert($exceptionThrown, 'Fehlen BLZ/Institut-Header, wird der Import mit RuntimeException abgebrochen'); + + // ── Models\Bankinstitut: replaceAll / search / findAccessPointByBlz ── + \Steuerkram\Models\Bankinstitut::replaceAll($result['rows'], [ + 'filename' => 'blz-aktuell-csv-data.csv', + 'stand' => $result['stand'], + 'zeilen' => $result['zeilen'], + 'blz_anzahl' => $result['blz_anzahl'], + 'mit_zugangspunkt' => $result['mit_zugangspunkt'], + ]); + + $this->assertEquals(4, \Steuerkram\Models\Bankinstitut::count(), 'replaceAll() hat vier Zeilen übernommen'); + + $treffer = \Steuerkram\Models\Bankinstitut::findAccessPointByBlz('10030000'); + $this->assert($treffer !== null && $treffer['pin_tan_url'] === 'https://banking.spk.de/fints', 'findAccessPointByBlz() liefert Zugangspunkt trotz doppelter BLZ'); + + $suchtreffer = \Steuerkram\Models\Bankinstitut::search('1822', 10); + $this->assertEquals(0, count($suchtreffer), 'Suche nach "1822" findet keinen Treffer, wenn weder BLZ noch Name/Ort/BIC passen'); + + $suchtreffer2 = \Steuerkram\Models\Bankinstitut::search('Köln', 10); + $this->assertGreaterThan(0, count($suchtreffer2), 'Suche über den Namen findet Treffer'); + + $meta = \Steuerkram\Models\Bankinstitut::importMeta(); + $this->assertEquals('20.05.2026', $meta['stand'] ?? null, 'importMeta() liefert den zuletzt importierten Stand'); + + // ── Ein zweiter Import ersetzt den Bestand vollständig ───────── + \Steuerkram\Models\Bankinstitut::replaceAll( + [['blz' => '11111111', 'name' => 'Nur noch dieses Institut', 'ort' => 'X', 'bic' => '', 'pin_tan_url' => null]], + ['filename' => 'neu.csv', 'stand' => null, 'zeilen' => 1, 'blz_anzahl' => 1, 'mit_zugangspunkt' => 0] + ); + $this->assertEquals(1, \Steuerkram\Models\Bankinstitut::count(), 'Ein erneuter Import ersetzt den gesamten Bestand statt ihn zu ergänzen'); + } +}