Finanzmanager/src/Models/Bankinstitut.php
fds2610-bot 0b4a216954 Sammel-Commit: PayPal-Bankverbindung und Bankleitzahlen-Verzeichnis
Zwei Features an derselben Stelle, die im Arbeitsverzeichnis
nebeneinander lagen und sich dieselben Dateien teilen (Routen,
BankingController, Bankzugang, Zugangsformular, Dokumentation) –
getrennte Commits ließen sich daraus nur mit einem kaputten
Zwischenstand schneiden.

PayPal als zweite Art von Bankverbindung
----------------------------------------
„Bankverbindung anlegen“ führt zur Wahl der Art: HBCI/FinTS oder
PayPal (bank_zugaenge.typ, Migration 014). Die Abfrage läuft über die
Classic-NVP-API (Services\PaypalNvp, TransactionSearch und GetBalance).
Kein Dreischritt, da PayPal genau ein Guthabenkonto führt –
Zugangsdaten und Kontenzuordnung stehen in einem Formular. Konten,
Umsätze, Protokoll, Regeln und Abgleich bleiben dieselben.

Bankleitzahlen-Verzeichnis
--------------------------
Verwaltung → Bankleitzahlen (/verwaltung/bankinstitute, Migration 015)
nimmt die CSV-Institutsliste der Deutschen Kreditwirtschaft auf und
beantwortet damit die eine Angabe, die beim Anlegen einer
FinTS-Verbindung regelmäßig fehlt: den PIN/TAN-Zugangspunkt.

Reine Nachschlagedaten ohne benutzer_id; ein Import ersetzt den
gesamten Bestand, statt ihn zu ergänzen. „Nur prüfen“ und „Übernehmen“
sind zwei Knöpfe an derselben Datei – abgelegt wird sie nicht, ihr
Inhalt steht ja in der Tabelle.

Services\FintsInstituteImport nimmt der Datei ihre Eigenheiten ab:
ISO-8859-1 statt UTF-8, ein Institutsname über zwei Zeilen in
Anführungszeichen, dieselbe Bankleitzahl vielfach (eine Zeile je
Filiale), Hunderte leerer Zeilen am Ende, Text statt Adresse, wo es
keinen PIN/TAN-Zugang gibt. Spalten werden über ihre Überschrift
angesprochen; fehlen BLZ und Institut, bricht der Import ab.

Gesucht wird über Bankleitzahl, BIC, Name und Ort in einem Zug: „1822“
steht bei der 1822direkt im Namen und bei der Frankfurter Sparkasse im
BIC, in keiner der beiden Bankleitzahlen.

Aus der Trefferliste führt „Verbindung anlegen“ ins gefüllte
FinTS-Formular. Die FinTS-Adresse ist damit kein Pflichtfeld mehr –
bleibt sie leer, wird sie zur Bankleitzahl nachgeschlagen. Findet auch
das nichts, weist Bankzugang den Zugang ab, statt ihn ohne Gegenstelle
zu speichern.

Tests: 31_PaypalZugangTest, 32_BankinstituteTest,
e2e/specs/banking.spec.js, e2e/specs/bankinstitute.spec.js.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 08:48:14 +02:00

180 lines
6.1 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
declare(strict_types=1);
namespace Finanzmanager\Models;
use Finanzmanager\Database;
/**
* Ein Eintrag aus dem Bankleitzahlen-Verzeichnis der FinTS-Institute.
*
* Nachschlagedaten, keine Stammdaten: Die Tabelle gehört keinem Benutzer, wird
* nur gelesen und in einem Zug durch einen Import ersetzt
* (`Services\FintsInstituteImport`). Gebraucht wird sie an einer Stelle – beim
* Anlegen einer Bankverbindung, wo die FinTS-Adresse zur Bankleitzahl gesucht
* wird.
*/
class Bankinstitut
{
/** Wie viele Treffer eine Suche höchstens zeigt. */
public const TREFFER_GRENZE = 100;
public static function anzahl(): int
{
return (int)Database::fetchValue('SELECT COUNT(*) FROM bankinstitute', [], 0);
}
/** Wie viele Einträge einen PIN/TAN-Zugangspunkt nennen. */
public static function anzahlMitZugang(): int
{
return (int)Database::fetchValue(
"SELECT COUNT(*) FROM bankinstitute WHERE pintan_url <> ''",
[],
0
);
}
/** Das jüngste Änderungsdatum im Bestand – der Stand der Daten. */
public static function juengsterStand(): ?string
{
$wert = Database::fetchValue('SELECT MAX(stand) FROM bankinstitute');
return is_string($wert) && $wert !== '' ? $wert : null;
}
/**
* Der Eintrag zu einer Bankleitzahl.
*
* Steht die Bankleitzahl mehrfach in der Datei – bei den Großbanken einmal
* je Filiale –, gewinnt ein Eintrag mit Zugangspunkt vor einem ohne; unter
* gleichen entscheidet die Reihenfolge der Datei. Die Zugangspunkte
* derselben Bankleitzahl sind identisch, verschieden ist nur der Ort.
*
* @return array<string,mixed>|null
*/
public static function findeNachBlz(string $blz): ?array
{
$blz = self::nurZiffern($blz);
if ($blz === '') {
return null;
}
$zeile = Database::fetchOne(
"SELECT * FROM bankinstitute
WHERE blz = ?
ORDER BY (pintan_url <> '') DESC, nr
LIMIT 1",
[$blz]
);
return $zeile === false ? null : $zeile;
}
/**
* Suche über Bankleitzahl, BIC, Name und Ort.
*
* Gesucht wird immer in allen vier Feldern, und zwar in einem Zug: Wer
* „1822“ eingibt, meint mal die Bankleitzahl und mal die 1822direkt – eine
* Eingabe nur deshalb als Bankleitzahl zu lesen, weil sie aus Ziffern
* besteht, verschwiege ihm die Bank, die er sucht.
*
* Verglichen wird die Bankleitzahl von vorn („5005“ findet 50050201 und
* 50050222), alles Übrige als Wortbestandteil. Treffer in der
* Bankleitzahl stehen oben, dann die mit Zugangspunkt.
*
* @return list<array<string,mixed>>
*/
public static function suche(string $begriff, int $grenze = self::TREFFER_GRENZE): array
{
$begriff = trim($begriff);
if ($begriff === '') {
return [];
}
$grenze = max(1, min($grenze, self::TREFFER_GRENZE));
$muster = self::maskiere($begriff);
// Eine Bankleitzahl steht in der Tabelle ohne Leerzeichen; die Eingabe
// darf welche enthalten. Bei einer Eingabe ohne Ziffern bleibt nichts
// übrig – dann trifft der Vergleich schlicht nie.
$blz = self::nurZiffern($begriff);
$blzMuster = $blz === '' ? null : self::maskiere($blz) . '%';
$text = '%' . $muster . '%';
return Database::fetchAll(
"SELECT * FROM bankinstitute
WHERE (? IS NOT NULL AND blz LIKE ? ESCAPE '!')
OR institut LIKE ? ESCAPE '!'
OR ort LIKE ? ESCAPE '!'
OR bic LIKE ? ESCAPE '!'
ORDER BY (? IS NOT NULL AND blz LIKE ? ESCAPE '!') DESC,
(pintan_url <> '') DESC, blz, institut, ort
LIMIT " . $grenze,
[
$blzMuster, (string)$blzMuster, $text, $text, $text,
$blzMuster, (string)$blzMuster,
]
);
}
/**
* Ersetzt den gesamten Bestand.
*
* In einer Transaktion und satzweise über eine mehrzeilige INSERT-Anweisung:
* Die Datei bringt knapp viertausend Zeilen mit, und viertausend einzelne
* Anweisungen dauern spürbar länger als vierzig.
*
* @param list<array<string,mixed>> $saetze
* @return int Zahl der geschriebenen Zeilen
*/
public static function ersetze(array $saetze): int
{
$spalten = [
'nr', 'blz', 'bic', 'institut', 'ort', 'rechenzentrum', 'organisation',
'hbci_adresse', 'hbci_version', 'pintan_url', 'pintan_version', 'stand',
];
return (int)Database::transaktion(static function () use ($saetze, $spalten): int {
Database::execute('DELETE FROM bankinstitute');
$geschrieben = 0;
foreach (array_chunk($saetze, 100) as $block) {
$werte = [];
$platz = [];
foreach ($block as $satz) {
foreach ($spalten as $spalte) {
$werte[] = $satz[$spalte] ?? null;
}
$platz[] = '(' . implode(',', array_fill(0, count($spalten), '?')) . ')';
}
Database::execute(
'INSERT INTO bankinstitute (' . implode(', ', $spalten) . ') VALUES '
. implode(', ', $platz),
$werte
);
$geschrieben += count($block);
}
return $geschrieben;
});
}
public static function leeren(): void
{
Database::execute('DELETE FROM bankinstitute');
}
/** Bankleitzahlen stehen in der Datei mit führenden Nullen – nur Ziffern zählen. */
public static function nurZiffern(string $wert): string
{
return preg_replace('/\D/', '', $wert) ?? '';
}
/** Maskiert die LIKE-Platzhalter einer Sucheingabe. */
private static function maskiere(string $begriff): string
{
return str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $begriff);
}
}