Merge pull request 'Fix #187: Bankleitzahlen-Verzeichnis unter Einstellungen' (#188) from fds2610-bot/Steuerkram:main into main

Reviewed-on: https://codeberg.org/fds2610/Steuerkram/pulls/188
This commit is contained in:
fds2610 2026-08-18 10:11:39 +02:00
commit 0e8a956869
11 changed files with 966 additions and 0 deletions

View file

@ -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) ## 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. - `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.

View file

@ -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 - **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 „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) - **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) - **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 - **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 - **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) - SMTP-Konfiguration für E-Mail-Versand (Host, Port, Benutzer, Passwort, TLS/SSL)
- KI-Kontierungsvorschlag (Ollama oder OpenAI-kompatible API) - KI-Kontierungsvorschlag (Ollama oder OpenAI-kompatible API)
- Nextcloud-Belegimport (WebDAV-Zugangsdaten) - 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")
--- ---

View file

@ -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);

View file

@ -95,6 +95,7 @@ use Steuerkram\Controllers\InvoiceController;
use Steuerkram\Controllers\SupplierController; use Steuerkram\Controllers\SupplierController;
use Steuerkram\Controllers\UstvaController; use Steuerkram\Controllers\UstvaController;
use Steuerkram\Controllers\EuerController; use Steuerkram\Controllers\EuerController;
use Steuerkram\Controllers\BankinstituteController;
use Steuerkram\Controllers\SettingsController; use Steuerkram\Controllers\SettingsController;
use Steuerkram\Controllers\LoginController; use Steuerkram\Controllers\LoginController;
use Steuerkram\Controllers\AssetController; 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}/field', fn($p) => (new SettingsController())->updateBankImportField($p));
$router->post('/settings/bankimport/{id}/delete', fn($p) => (new SettingsController())->deleteBankImportFormat($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 ────────────────────────────────────────── // ── Buchungsvorlagen ──────────────────────────────────────────
$router->get('/einstellungen/buchungsvorlagen', fn($p) => (new BuchungsvorlageController())->index($p)); $router->get('/einstellungen/buchungsvorlagen', fn($p) => (new BuchungsvorlageController())->index($p));
$router->get('/einstellungen/buchungsvorlagen/neu', fn($p) => (new BuchungsvorlageController())->create($p)); $router->get('/einstellungen/buchungsvorlagen/neu', fn($p) => (new BuchungsvorlageController())->create($p));

View file

@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
namespace Steuerkram\Controllers;
use Steuerkram\Models\Bankinstitut;
use Steuerkram\Services\FintsInstituteImport;
use Steuerkram\View;
class BankinstituteController extends BaseController
{
private const ALLOWED_EXT = ['csv', 'txt'];
public function index(array $params): void
{
$query = trim((string)($_GET['q'] ?? ''));
$rows = $query !== '' ? Bankinstitut::search($query, 200) : [];
$view = new View();
$view->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;
}
}
}

131
src/Models/Bankinstitut.php Normal file
View file

@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace Steuerkram\Models;
use Steuerkram\Database;
/**
* Bankleitzahlen-Verzeichnis (Fix #187): reine Nachschlagedaten, kein
* Benutzerbezug, kein Fremdschlüssel. Bewusst kein UNIQUE auf blz – die
* Quelldatei führt dieselbe Bankleitzahl mehrfach (eine Zeile je Filiale).
*/
class Bankinstitut
{
/** Anzahl Zeilen je INSERT-Batch beim Import (Balance zwischen Rundreisen und Statement-Länge). */
private const IMPORT_CHUNK_SIZE = 500;
public static function count(): int
{
return (int)(Database::fetchOne('SELECT COUNT(*) AS c FROM bankinstitute')['c'] ?? 0);
}
/**
* Sucht über Bankleitzahl, BIC, Name und Ort in einem Zug.
* Treffer in der Bankleitzahl (von vorn) stehen oben – eine Ziffernfolge
* wird nicht automatisch als Bankleitzahl gelesen, nur weil sie aus
* Ziffern besteht (z.B. „1822“ im Namen der 1822direkt bzw. im BIC der
* Frankfurter Sparkasse, in keiner der beiden Bankleitzahlen).
*/
public static function search(string $query, int $limit = 30): array
{
$query = trim($query);
if ($query === '') {
return [];
}
$like = '%' . $query . '%';
return Database::fetchAll(
'SELECT * FROM bankinstitute
WHERE blz LIKE ? OR name LIKE ? OR ort LIKE ? OR bic LIKE ?
ORDER BY (blz LIKE ?) DESC, blz ASC, name ASC
LIMIT ?',
[$query . '%', $like, $like, $like, $query . '%', $limit]
);
}
/**
* Erster Eintrag zu einer Bankleitzahl mit gesetztem PIN/TAN-Zugangspunkt
* (dieselbe Bankleitzahl steht mehrfach in der Quelldatei, mit
* identischem Zugangspunkt je Filiale). Gibt es keinen Eintrag mit
* Zugangspunkt, wird ersatzweise der erste Treffer ohne geliefert.
*/
public static function findAccessPointByBlz(string $blz): ?array
{
$row = Database::fetchOne(
"SELECT * FROM bankinstitute WHERE blz = ? AND pin_tan_url IS NOT NULL ORDER BY id LIMIT 1",
[$blz]
);
if ($row) {
return $row;
}
$row = Database::fetchOne('SELECT * FROM bankinstitute WHERE blz = ? ORDER BY id LIMIT 1', [$blz]);
return $row ?: null;
}
public static function importMeta(): ?array
{
$row = Database::fetchOne('SELECT * FROM bankinstitute_import WHERE id = 1');
return $row ?: null;
}
/**
* Ersetzt den gesamten Bestand durch $rows – ein Import ergänzt nicht,
* sonst blieben Institute stehen, die es in der Quelldatei nicht mehr
* gibt. $rows: Liste von ['blz','name','ort','bic','pin_tan_url'].
*/
public static function replaceAll(array $rows, array $meta): void
{
Database::beginTransaction();
try {
Database::execute('DELETE FROM bankinstitute');
foreach (array_chunk($rows, self::IMPORT_CHUNK_SIZE) as $chunk) {
self::insertChunk($chunk);
}
Database::execute(
'UPDATE bankinstitute_import
SET quelle_dateiname = ?, stand = ?, zeilen = ?, blz_anzahl = ?, mit_zugangspunkt = ?, importiert_am = NOW()
WHERE id = 1',
[
$meta['filename'] ?? null,
$meta['stand'] ?? null,
(int)($meta['zeilen'] ?? count($rows)),
(int)($meta['blz_anzahl'] ?? 0),
(int)($meta['mit_zugangspunkt'] ?? 0),
]
);
Database::commit();
} catch (\Throwable $e) {
Database::rollBack();
throw $e;
}
}
private static function insertChunk(array $chunk): void
{
if (empty($chunk)) {
return;
}
$placeholders = [];
$params = [];
foreach ($chunk as $row) {
$placeholders[] = '(?, ?, ?, ?, ?)';
$params[] = $row['blz'];
$params[] = $row['name'];
$params[] = $row['ort'];
$params[] = $row['bic'];
$params[] = $row['pin_tan_url'];
}
Database::execute(
'INSERT INTO bankinstitute (blz, name, ort, bic, pin_tan_url) VALUES ' . implode(', ', $placeholders),
$params
);
}
}

View file

@ -0,0 +1,251 @@
<?php
declare(strict_types=1);
namespace Steuerkram\Services;
/**
* Liest die CSV-Institutsliste der Deutschen Kreditwirtschaft (hbci-zka.de)
* für das Bankleitzahlen-Verzeichnis ein (Fix #187).
*
* Fallstricke der Quelldatei, die hier absichtlich berücksichtigt sind:
* - Kodierung ist ISO-8859-1, nicht UTF-8.
* - Ein Institutsname kann über zwei Zeilen laufen (in Anführungszeichen,
* z.B. Oldenburgische Landesbank) – deshalb fgetcsv() auf einem Stream
* statt zeilenweisem Einlesen, das daran scheitern würde.
* - Dieselbe Bankleitzahl steht mehrfach darin (eine Zeile je Filiale, mit
* identischem Zugangspunkt) – bewusst kein Unique-Check hier.
* - Am Ende hängen hunderte leere Zeilen mit laufender Nummer – Zeilen ohne
* BLZ oder Institut werden übersprungen, unabhängig davon, ob andere
* Spalten (z.B. eine laufende Nummer) gefüllt sind.
* - Wo es keinen PIN/TAN-Zugang gibt, steht Text statt einer Adresse in der
* entsprechenden Spalte – nur Werte, die wie eine http(s)-URL aussehen,
* werden als Zugangspunkt übernommen.
* - Spalten werden über ihre Überschrift angesprochen, nicht über ihre
* Position. Die Chipkartenverfahren-Spalten (DDV, RDH-1…10, RAH-7/9/10)
* werden schlicht nicht gelesen.
*/
class FintsInstituteImport
{
private const MAX_HEADER_SCAN = 15;
private const PREVIEW_ROWS = 5;
private const FIELD_ALIASES = [
'blz' => ['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<string, int|null> */
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;
}
}

View file

@ -215,6 +215,10 @@ $csvDateFormatOptions = [
<div class="form-group"> <div class="form-group">
<label for="fc-fints-url">FinTS-Server-URL <span class="text-muted text-sm">(optional, wird sonst über BLZ ermittelt)</span></label> <label for="fc-fints-url">FinTS-Server-URL <span class="text-muted text-sm">(optional, wird sonst über BLZ ermittelt)</span></label>
<input type="url" id="fc-fints-url" name="fints_url" placeholder="https://..."> <input type="url" id="fc-fints-url" name="fints_url" placeholder="https://...">
<div style="margin-top:.4rem">
<input type="text" id="fc-blz-search" placeholder="Bankleitzahl, BIC, Name oder Ort suchen…" style="font-size:.8rem">
<div id="fc-blz-results" style="display:none;margin-top:.3rem;max-height:160px;overflow-y:auto;border:1px solid var(--color-border);border-radius:var(--radius)"></div>
</div>
</div> </div>
<div class="text-muted text-sm">Die Konto-Auswahl wird beim ersten Abruf abgefragt.</div> <div class="text-muted text-sm">Die Konto-Auswahl wird beim ersten Abruf abgefragt.</div>
</div> </div>
@ -491,6 +495,57 @@ $csvDateFormatOptions = [
if (btnCancel) btnCancel.addEventListener('click', closeNew); if (btnCancel) btnCancel.addEventListener('click', closeNew);
modalNew.addEventListener('click', (e) => { if (e.target === modalNew) 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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
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 = '<div style="padding:.4rem .6rem;font-size:.8rem;color:var(--color-muted)">Keine Treffer</div>';
resultsBox.style.display = 'block';
return;
}
resultsBox.innerHTML = rows.map((r, i) => `
<div class="fc-blz-result" data-idx="${i}" style="padding:.35rem .6rem;font-size:.8rem;cursor:pointer;border-bottom:1px solid var(--color-border)">
<strong>${esc(r.blz)}</strong> ${esc(r.name)} · ${esc(r.ort)}
${r.pin_tan_url ? '' : '<span style="color:var(--color-muted)"> (kein PIN/TAN-Zugang)</span>'}
</div>
`).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) { function syncTypFields(typ) {
apiNew.style.display = (typ === 'API') ? 'block' : 'none'; apiNew.style.display = (typ === 'API') ? 'block' : 'none';
fintsNew.style.display = (typ === 'FinTS') ? 'block' : 'none'; fintsNew.style.display = (typ === 'FinTS') ? 'block' : 'none';

View file

@ -113,6 +113,10 @@ function navActive(string $prefix, string $current): string {
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/><line x1="6" y1="15" x2="10" y2="15"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/><line x1="6" y1="15" x2="10" y2="15"/></svg>
CSV Bankimport Formate CSV Bankimport Formate
</a> </a>
<a href="/settings/bankinstitute" class="<?= navActive('/settings/bankinstitute', $path) ?>" style="padding-left:2.25rem;font-size:.875rem">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 21h18M4 21V9l8-6 8 6v12M9 21v-6h6v6"/></svg>
Bankinstitute
</a>
<a href="/einstellungen/buchungsvorlagen" class="<?= navActive('/einstellungen/buchungsvorlagen', $path) ?>" style="padding-left:2.25rem;font-size:.875rem"> <a href="/einstellungen/buchungsvorlagen" class="<?= navActive('/einstellungen/buchungsvorlagen', $path) ?>" style="padding-left:2.25rem;font-size:.875rem">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 5H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2"/><rect x="9" y="3" width="6" height="4" rx="1"/><path d="M9 12h6M9 16h4"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 5H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2"/><rect x="9" y="3" width="6" height="4" rx="1"/><path d="M9 12h6M9 16h4"/></svg>
Buchungsvorlagen Buchungsvorlagen

View file

@ -0,0 +1,235 @@
<?php
use Steuerkram\View;
?>
<div class="card mb-2">
<div class="card-header"><div class="card-title">Aktueller Bestand</div></div>
<div class="card-body">
<?php if (empty($meta) || empty($meta['importiert_am'])): ?>
<p class="text-muted text-sm" style="margin:0">Noch kein Import durchgeführt – 0 Institute in der Datenbank.</p>
<?php else: ?>
<table style="width:100%;border-collapse:collapse;font-size:.875rem">
<tbody>
<tr>
<td style="padding:.2rem .6rem .2rem 0;color:var(--color-muted);width:180px">Institute</td>
<td style="padding:.2rem 0"><?= (int)$count ?></td>
</tr>
<tr>
<td style="padding:.2rem .6rem .2rem 0;color:var(--color-muted)">Verschiedene Bankleitzahlen</td>
<td style="padding:.2rem 0"><?= (int)$meta['blz_anzahl'] ?></td>
</tr>
<tr>
<td style="padding:.2rem .6rem .2rem 0;color:var(--color-muted)">Mit PIN/TAN-Zugangspunkt</td>
<td style="padding:.2rem 0"><?= (int)$meta['mit_zugangspunkt'] ?></td>
</tr>
<tr>
<td style="padding:.2rem .6rem .2rem 0;color:var(--color-muted)">Stand der Daten</td>
<td style="padding:.2rem 0"><?= $meta['stand'] ? $h($meta['stand']) : '<span style="font-style:italic;color:var(--color-muted)">unbekannt</span>' ?></td>
</tr>
<tr>
<td style="padding:.2rem .6rem .2rem 0;color:var(--color-muted)">Importiert am</td>
<td style="padding:.2rem 0"><?= $h(date('d.m.Y H:i', strtotime((string)$meta['importiert_am']))) ?><?= $meta['quelle_dateiname'] ? ' – ' . $h($meta['quelle_dateiname']) : '' ?></td>
</tr>
</tbody>
</table>
<?php endif; ?>
</div>
</div>
<div class="card mb-2">
<div class="card-header"><div class="card-title">Institutsliste importieren</div></div>
<div class="card-body">
<p class="text-muted text-sm" style="margin:0 0 1rem">
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.
</p>
<form id="bi-upload-form">
<div class="form-group">
<input type="file" id="bi-file-input" accept=".csv,.txt">
</div>
<div style="display:flex;gap:.75rem;margin-top:.5rem">
<button type="button" id="bi-check-btn" class="btn btn-secondary" disabled>Nur prüfen</button>
<button type="button" id="bi-import-btn" class="btn btn-primary" disabled>Übernehmen</button>
</div>
</form>
<div id="bi-status" style="margin-top:1rem"></div>
<div id="bi-report" style="display:none;margin-top:1rem;background:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius);padding:1rem">
<div style="font-size:.8rem;font-weight:600;text-transform:uppercase;letter-spacing:.05em;color:var(--color-muted);margin-bottom:.75rem">Prüfbericht</div>
<table style="width:100%;border-collapse:collapse;font-size:.875rem;margin-bottom:1rem">
<tbody>
<tr>
<td style="padding:.2rem .6rem .2rem 0;color:var(--color-muted);width:180px">Verwertbare Zeilen</td>
<td id="bi-r-zeilen" style="padding:.2rem 0"></td>
</tr>
<tr>
<td style="padding:.2rem .6rem .2rem 0;color:var(--color-muted)">Verschiedene Bankleitzahlen</td>
<td id="bi-r-blz" style="padding:.2rem 0"></td>
</tr>
<tr>
<td style="padding:.2rem .6rem .2rem 0;color:var(--color-muted)">Mit PIN/TAN-Zugangspunkt</td>
<td id="bi-r-zugang" style="padding:.2rem 0"></td>
</tr>
<tr>
<td style="padding:.2rem .6rem .2rem 0;color:var(--color-muted)">Stand der Daten</td>
<td id="bi-r-stand" style="padding:.2rem 0"></td>
</tr>
</tbody>
</table>
<div style="font-size:.8rem;color:var(--color-muted);margin-bottom:.3rem">Erkannte Spalten (BLZ / Institut / Ort / BIC / PIN-TAN-URL)</div>
<div id="bi-r-columns" style="font-size:.8rem;font-family:monospace;background:#f8fafc;padding:.4rem .6rem;border-radius:4px;border:1px solid var(--color-border);margin-bottom:1rem"></div>
<div style="font-size:.8rem;color:var(--color-muted);margin-bottom:.3rem">Sichtprobe – erste Zeilen</div>
<table class="data-table" id="bi-r-preview">
<thead>
<tr><th>BLZ</th><th>Institut</th><th>Ort</th><th>BIC</th><th>PIN/TAN-URL</th></tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</div>
<div class="card">
<div class="card-header"><div class="card-title">Suche</div></div>
<div class="card-body">
<form method="get" action="/settings/bankinstitute" style="margin-bottom:1rem">
<input type="text" name="q" value="<?= $h($query) ?>" placeholder="Bankleitzahl, BIC, Name oder Ort…" style="max-width:320px">
<button type="submit" class="btn btn-secondary">Suchen</button>
</form>
<?php if ($query === ''): ?>
<p class="text-muted text-sm" style="margin:0"><?= (int)$count ?> Institute in der Datenbank – bitte einen Suchbegriff eingeben.</p>
<?php elseif (empty($rows)): ?>
<p class="text-muted text-sm" style="margin:0">Keine Treffer für „<?= $h($query) ?>".</p>
<?php else: ?>
<table class="data-table">
<thead>
<tr><th>BLZ</th><th>Institut</th><th>Ort</th><th>BIC</th><th>PIN/TAN-URL</th></tr>
</thead>
<tbody>
<?php foreach ($rows as $row): ?>
<tr>
<td><?= $h($row['blz']) ?></td>
<td><?= $h($row['name']) ?></td>
<td><?= $h($row['ort']) ?></td>
<td><?= $h($row['bic']) ?></td>
<td><?php if ($row['pin_tan_url']): ?><code style="font-size:.8rem;word-break:break-all"><?= $h($row['pin_tan_url']) ?></code><?php else: ?><span style="color:var(--color-muted);font-style:italic">–</span><?php endif; ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
</div>
<script>
(function () {
const fileInput = document.getElementById('bi-file-input');
const checkBtn = document.getElementById('bi-check-btn');
const importBtn = document.getElementById('bi-import-btn');
const statusBox = document.getElementById('bi-status');
const report = document.getElementById('bi-report');
function esc(str) {
return String(str ?? '')
.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function setStatus(html, isError) {
statusBox.innerHTML = html
? `<div style="padding:.6rem .9rem;border-radius:var(--radius);font-size:.875rem;${isError
? 'background:#fee2e2;border:1px solid #fca5a5;color:#991b1b'
: 'background:#f0fdf4;border:1px solid #86efac;color:#166534'}">${html}</div>`
: '';
}
fileInput.addEventListener('change', () => {
const hasFile = fileInput.files.length > 0;
checkBtn.disabled = !hasFile;
importBtn.disabled = !hasFile;
report.style.display = 'none';
setStatus('');
});
async function callEndpoint(url) {
const fd = new FormData();
fd.append('institute_file', fileInput.files[0]);
const res = await fetch(url, { method: 'POST', body: fd });
return res.json();
}
function renderReport(json) {
document.getElementById('bi-r-zeilen').textContent = json.zeilen;
document.getElementById('bi-r-blz').textContent = json.blz_anzahl;
document.getElementById('bi-r-zugang').textContent = json.mit_zugangspunkt;
document.getElementById('bi-r-stand').textContent = json.stand || 'unbekannt';
const c = json.columns || {};
const label = (idx) => (idx === null || idx === undefined) ? '–' : `Sp. ${idx} (${esc((json.header || [])[idx] ?? '')})`;
document.getElementById('bi-r-columns').textContent =
`BLZ: ${label(c.blz)} | Institut: ${label(c.name)} | Ort: ${label(c.ort)} | BIC: ${label(c.bic)} | PIN/TAN-URL: ${label(c.pin_tan_url)}`;
const tbody = document.querySelector('#bi-r-preview tbody');
tbody.innerHTML = (json.preview || []).map(r => `
<tr>
<td>${esc(r.blz)}</td>
<td>${esc(r.name)}</td>
<td>${esc(r.ort)}</td>
<td>${esc(r.bic)}</td>
<td>${r.pin_tan_url ? `<code style="font-size:.8rem;word-break:break-all">${esc(r.pin_tan_url)}</code>` : '–'}</td>
</tr>
`).join('');
report.style.display = 'block';
}
checkBtn.addEventListener('click', async () => {
if (!fileInput.files.length) return;
checkBtn.disabled = true;
setStatus('Wird geprüft…');
report.style.display = 'none';
let json;
try { json = await callEndpoint('/settings/bankinstitute/check'); }
catch (_) { json = { error: 'Verbindungsfehler.' }; }
checkBtn.disabled = false;
if (json.error) {
setStatus(esc(json.error), true);
return;
}
setStatus('Prüfung erfolgreich – siehe Bericht unten. Kein Institut wurde übernommen.', false);
renderReport(json);
});
importBtn.addEventListener('click', async () => {
if (!fileInput.files.length) return;
if (!confirm('Der bestehende Bestand an Bankinstituten wird vollständig ersetzt. Fortfahren?')) return;
importBtn.disabled = true;
checkBtn.disabled = true;
setStatus('Wird übernommen…');
let json;
try { json = await callEndpoint('/settings/bankinstitute/import'); }
catch (_) { json = { error: 'Verbindungsfehler.' }; }
importBtn.disabled = false;
checkBtn.disabled = false;
if (json.error) {
setStatus(esc(json.error), true);
return;
}
setStatus(`${json.zeilen} Institute übernommen.`, false);
renderReport(json);
window.location.reload();
});
})();
</script>

View file

@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
/**
* Test 92: Bankleitzahlen-Verzeichnis (Fix #187).
*
* 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.
*/
class BankinstituteTest extends TestCase
{
protected string $testName = 'Test 92: Bankleitzahlen-Verzeichnis';
private function makeIso88591Csv(string $utf8Content): string
{
$path = $this->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');
}
}