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>
180 lines
6.1 KiB
PHP
180 lines
6.1 KiB
PHP
<?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);
|
||
}
|
||
}
|