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>
305 lines
14 KiB
PHP
305 lines
14 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
use Finanzmanager\Models\Bankinstitut;
|
||
use Finanzmanager\Models\Bankzugang;
|
||
use Finanzmanager\Services\FintsInstituteImport;
|
||
|
||
/**
|
||
* Das Bankleitzahlen-Verzeichnis: Import der FinTS-Institutsliste, Suche und
|
||
* die Vorbelegung der FinTS-Adresse beim Anlegen einer Bankverbindung.
|
||
*
|
||
* Die Prüfsteine sind die Eigenheiten der echten Datei: ISO-8859-1 statt UTF-8,
|
||
* ein Institutsname mit Zeilenumbruch in Anführungszeichen, dieselbe
|
||
* Bankleitzahl in vielen Filialzeilen und Zeilen ohne Zugangspunkt.
|
||
*/
|
||
class BankinstituteTest extends TestCase
|
||
{
|
||
protected string $testName = 'Test 32: Bankleitzahlen-Verzeichnis';
|
||
|
||
public function run(): void
|
||
{
|
||
$this->mandantAnlegen();
|
||
|
||
// Das Verzeichnis gehört keinem Benutzer: Was die Instanz schon
|
||
// eingelesen hat, stünde sonst in jeder Zählung mit drin. Der Rollback
|
||
// am Ende des Tests bringt es zurück.
|
||
Bankinstitut::leeren();
|
||
|
||
$this->importLiestDieEchteKopfzeile();
|
||
$this->importErsetztDenBestand();
|
||
$this->falscheDateiWirdAbgewiesen();
|
||
$this->sucheFindetUeberBlzNameUndOrt();
|
||
$this->zugangUebernimmtDieAdresseAusDemVerzeichnis();
|
||
}
|
||
|
||
// ── Testdaten ────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Ein Ausschnitt der echten Datei – mit allen Spalten, in ISO-8859-1 und
|
||
* mit den beiden Stolpersteinen: der über zwei Zeilen laufende, in
|
||
* Anführungszeichen gesetzte Institutsname und die doppelte Bankleitzahl.
|
||
*/
|
||
private function beispieldatei(): string
|
||
{
|
||
$kopf = 'Nr.;BLZ;BIC;Institut;Ort;RZ;Organisation;HBCI-Zugang DNS;'
|
||
. 'HBCI- Zugang IP-Adresse;HBCI-Version;DDV;RDH-1;RDH-2;RDH-3;RDH-4;RDH-5;'
|
||
. 'RDH-6;RDH-7;RDH-8;RDH-9;RDH-10;RAH-7;RAH-9;RAH-10;PIN/TAN-Zugang URL;Version;'
|
||
. 'Datum letzte Änderung';
|
||
|
||
$zeilen = [
|
||
$kopf,
|
||
'1525;50050201;HELADEF1822;Frankfurter Sparkasse;Frankfurt am Main;'
|
||
. 'Finanz Informatik GmbH & Co. KG;DSGV;;;;;;;;;;;;;;;;;;'
|
||
. 'https://banking-hs6.s-fints-pt-hs.de/fints30;FinTS V3.0;09.02.2022',
|
||
'1526;50050222;HELADEF1822;1822direkt (PIN/TAN);Frankfurt am Main;'
|
||
. 'Finanz Informatik GmbH & Co. KG;DSGV;;;;;;;;;;;;;;;;;;'
|
||
. 'https://fints.1822direkt.com/fints/hbci;FinTS V3.0;09.02.2022',
|
||
// Zwei Filialen derselben Bankleitzahl, identischer Zugangspunkt.
|
||
'29;12070000;DEUTDEBB160;Deutsche Bank;Potsdam;eigenes Rechenzentrum;BdB;;;3.0;'
|
||
. ';;;;;;;;;;;;;;https://fints.deutsche-bank.de/;FinTS V3.0;12.03.2026',
|
||
'30;12070000;DEUTDEBB160;Deutsche Bank;Cottbus;eigenes Rechenzentrum;BdB;;;3.0;'
|
||
. ';;;;;;;;;;;;;;https://fints.deutsche-bank.de/;FinTS V3.0;12.03.2026',
|
||
// Ein Institutsname über zwei Zeilen, in Anführungszeichen.
|
||
'622;28030300;FORTDEH4XXX;"Oldenburgische Landesbank AG',
|
||
'(vormals W. Fortmann & Söhne)";Oldenburg (Oldb);eigenes Rechenzentrum;;'
|
||
. 'hbci.olb.de;;3.0;;ja;;;;;;;;ja;ja;;;;https://hbci.olb.de;FinTS V3.0;29.09.2017',
|
||
// Ohne PIN/TAN-Zugang – die Zeile zählt mit, taugt aber nicht als Vorlage.
|
||
'1822;55033300;SCFBDE33XXX;Santander Bank;Mainz;eigenes Rechenzentrum;BdB;'
|
||
. 'sec.santanderbank.de;193243184115;3.0;;ja;;;;;;;;;;;;;;;26.02.2014',
|
||
// Fußnote ohne Bankleitzahl – sie wird übergangen. (Eine völlig
|
||
// leere Zeile fiele schon in CsvTabelle heraus und käme hier nie an.)
|
||
'Angaben ohne Gewähr;;;;;;;;;;;;;;;;;;;;;;;;;;',
|
||
];
|
||
|
||
$utf8 = implode("\r\n", $zeilen) . "\r\n";
|
||
|
||
// Die echte Datei kommt in ISO-8859-1; genau das soll der Import
|
||
// erkennen, ohne dass jemand sie vorher umwandelt.
|
||
return (string)mb_convert_encoding($utf8, 'ISO-8859-1', 'UTF-8');
|
||
}
|
||
|
||
// ── Import ───────────────────────────────────────────────────────────────
|
||
|
||
private function importLiestDieEchteKopfzeile(): void
|
||
{
|
||
$pfad = $this->tempPfad('.csv');
|
||
file_put_contents($pfad, $this->beispieldatei());
|
||
|
||
$probe = FintsInstituteImport::ausDatei($pfad)->ausfuehren(true);
|
||
|
||
$this->assertEquals(7, $probe['gelesen'], 'Alle Datenzeilen wurden gelesen');
|
||
$this->assertEquals(6, $probe['uebernommen'], 'Sechs Zeilen nennen eine Bankleitzahl');
|
||
$this->assertEquals(1, $probe['ohne_blz'], 'Die Fußnote wurde übergangen');
|
||
$this->assertEquals(5, $probe['banken'], 'Fünf verschiedene Bankleitzahlen');
|
||
$this->assertEquals(5, $probe['mit_zugang'], 'Fünf nennen einen PIN/TAN-Zugangspunkt');
|
||
$this->assertEquals('2026-03-12', $probe['stand'], 'Jüngste Änderung ist der 12.03.2026');
|
||
$this->assertEquals(0, Bankinstitut::anzahl(), 'Der Trockenlauf hat nichts geschrieben');
|
||
|
||
FintsInstituteImport::ausDatei($pfad)->ausfuehren(false);
|
||
|
||
$this->assertEquals(6, Bankinstitut::anzahl(), 'Der Import hat sechs Zeilen geschrieben');
|
||
$this->assertEquals(5, Bankinstitut::anzahlMitZugang(), 'Davon fünf mit Zugangspunkt');
|
||
$this->assertEquals('2026-03-12', Bankinstitut::juengsterStand(), 'Der Stand steht in der Tabelle');
|
||
|
||
$direkt = Bankinstitut::findeNachBlz('50050222');
|
||
$this->assertNotEmpty($direkt, '1822direkt wurde gefunden');
|
||
$this->assertEquals(
|
||
'https://fints.1822direkt.com/fints/hbci',
|
||
(string)$direkt['pintan_url'],
|
||
'Die FinTS-Adresse der 1822direkt stimmt'
|
||
);
|
||
$this->assertEquals('HELADEF1822', (string)$direkt['bic'], 'Der BIC kam mit');
|
||
$this->assertEquals('Frankfurt am Main', (string)$direkt['ort'], 'Der Ort kam mit');
|
||
$this->assertEquals('FinTS V3.0', (string)$direkt['pintan_version'], 'Die Version kam mit');
|
||
|
||
// ISO-8859-1 und der Zeilenumbruch im Anführungszeichenfeld.
|
||
$olb = Bankinstitut::findeNachBlz('28030300');
|
||
$this->assertNotEmpty($olb, 'Die Zeile mit dem Umbruch im Namen wurde gelesen');
|
||
$this->assert(
|
||
str_contains((string)$olb['institut'], 'Söhne'),
|
||
'Der Umlaut aus ISO-8859-1 kam als „Söhne“ an'
|
||
);
|
||
|
||
// Bei mehreren Filialen entscheidet die Reihenfolge der Datei.
|
||
$deutsche = Bankinstitut::findeNachBlz('12070000');
|
||
$this->assertEquals('Potsdam', (string)$deutsche['ort'], 'Die erste Filialzeile gewinnt');
|
||
|
||
// Führende Nullen und Leerzeichen dürfen in der Anfrage stehen.
|
||
$this->assertNotEmpty(
|
||
Bankinstitut::findeNachBlz('500 502 22'),
|
||
'Eine Bankleitzahl mit Leerzeichen wird trotzdem gefunden'
|
||
);
|
||
$this->assertNull(
|
||
Bankinstitut::findeNachBlz(''),
|
||
'Eine leere Bankleitzahl liefert keinen Treffer'
|
||
);
|
||
|
||
// Kein Zugangspunkt heißt: leeres Feld, nicht „nicht unterstützt“.
|
||
$santander = Bankinstitut::findeNachBlz('55033300');
|
||
$this->assertEquals('', (string)$santander['pintan_url'], 'Ohne Zugang bleibt die Adresse leer');
|
||
}
|
||
|
||
/** Ein zweiter Import ersetzt den Bestand, statt ihn zu verdoppeln. */
|
||
private function importErsetztDenBestand(): void
|
||
{
|
||
$pfad = $this->tempPfad('.csv');
|
||
file_put_contents($pfad, $this->beispieldatei());
|
||
|
||
FintsInstituteImport::ausDatei($pfad)->ausfuehren(false);
|
||
$this->assertEquals(6, Bankinstitut::anzahl(), 'Nach dem zweiten Import stehen keine Dubletten da');
|
||
|
||
$kurz = "Nr.;BLZ;BIC;Institut;Ort;PIN/TAN-Zugang URL\r\n"
|
||
. "1;10010010;PBNKDEFFXXX;Postbank;Berlin;https://hbci.postbank.de/banking/hbci.do\r\n";
|
||
FintsInstituteImport::ausText($kurz)->ausfuehren(false);
|
||
|
||
$this->assertEquals(1, Bankinstitut::anzahl(), 'Der neue Bestand hat den alten abgelöst');
|
||
$this->assertNull(Bankinstitut::findeNachBlz('50050222'), 'Die alten Zeilen sind fort');
|
||
$this->assertNotEmpty(Bankinstitut::findeNachBlz('10010010'), 'Die neue Zeile steht da');
|
||
$this->assertNull(
|
||
Bankinstitut::juengsterStand(),
|
||
'Ohne Datumsspalte bleibt der Stand offen, statt zu raten'
|
||
);
|
||
|
||
// Für die folgenden Prüfungen wieder die volle Liste.
|
||
FintsInstituteImport::ausDatei($pfad)->ausfuehren(false);
|
||
}
|
||
|
||
private function falscheDateiWirdAbgewiesen(): void
|
||
{
|
||
$this->assert(
|
||
$this->wirftFehler(fn() => FintsInstituteImport::ausText(
|
||
"Datum;Betrag;Empfänger\r\n01.02.2026;-12,50;Bäcker\r\n"
|
||
)),
|
||
'Ein Kontoauszug ist keine Institutsliste'
|
||
);
|
||
|
||
$this->assert(
|
||
$this->wirftFehler(fn() => FintsInstituteImport::ausText('')),
|
||
'Eine leere Datei wird abgewiesen'
|
||
);
|
||
|
||
$this->assert(
|
||
$this->wirftFehler(fn() => FintsInstituteImport::ausText(
|
||
"Nr.;BLZ;Institut;PIN/TAN-Zugang URL\r\n;;;\r\n"
|
||
)),
|
||
'Eine Datei ohne eine einzige Bankleitzahl wird abgewiesen'
|
||
);
|
||
|
||
$this->assertEquals(6, Bankinstitut::anzahl(), 'Der Bestand blieb dabei unangetastet');
|
||
}
|
||
|
||
// ── Suche ────────────────────────────────────────────────────────────────
|
||
|
||
private function sucheFindetUeberBlzNameUndOrt(): void
|
||
{
|
||
$this->assertEquals(0, count(Bankinstitut::suche('')), 'Eine leere Suche liefert nichts');
|
||
|
||
// Ziffern gelten als Bankleitzahl und werden von vorn verglichen.
|
||
$praefix = Bankinstitut::suche('5005');
|
||
$this->assertEquals(2, count($praefix), 'Zwei Frankfurter Leitzahlen beginnen mit 5005');
|
||
|
||
$nachName = Bankinstitut::suche('1822direkt');
|
||
$this->assertEquals(1, count($nachName), 'Der Name führt zu genau einem Treffer');
|
||
$this->assertEquals('50050222', (string)$nachName[0]['blz'], 'Und zwar zur richtigen Leitzahl');
|
||
|
||
$nachOrt = Bankinstitut::suche('Frankfurt');
|
||
$this->assertEquals(2, count($nachOrt), 'Der Ort findet beide Frankfurter Institute');
|
||
|
||
$nachBic = Bankinstitut::suche('DEUTDEBB160');
|
||
$this->assertEquals(2, count($nachBic), 'Der BIC findet beide Filialzeilen');
|
||
|
||
// Eine Ziffernfolge wird nicht nur als Bankleitzahl gelesen: „1822“
|
||
// steht bei der 1822direkt im Namen und bei der Frankfurter Sparkasse
|
||
// im BIC – in keiner der beiden Bankleitzahlen.
|
||
$gemischt = array_column(Bankinstitut::suche('1822'), 'blz');
|
||
$this->assertEquals(2, count($gemischt), '„1822“ findet beide über Name und BIC');
|
||
$this->assert(
|
||
in_array('50050222', $gemischt, true),
|
||
'Darunter die 1822direkt, deren Leitzahl ganz anders anfängt'
|
||
);
|
||
|
||
// Umgekehrt geht die Bankleitzahl vor: Ein Treffer in ihr steht oben.
|
||
$this->assertEquals(
|
||
'12070000',
|
||
(string)Bankinstitut::suche('1207')[0]['blz'],
|
||
'Ein Treffer in der Bankleitzahl steht vor den Texttreffern'
|
||
);
|
||
|
||
// Die Platzhalter von LIKE dürfen in der Eingabe stehen, ohne zu wirken.
|
||
$this->assertEquals(
|
||
0,
|
||
count(Bankinstitut::suche('%')),
|
||
'Ein Prozentzeichen sucht nach einem Prozentzeichen, nicht nach allem'
|
||
);
|
||
}
|
||
|
||
// ── Zusammenspiel mit der Bankverbindung ─────────────────────────────────
|
||
|
||
private function zugangUebernimmtDieAdresseAusDemVerzeichnis(): void
|
||
{
|
||
$institut = Bankinstitut::findeNachBlz('50050222');
|
||
|
||
$id = Bankzugang::anlegen([
|
||
'name' => '1822direkt',
|
||
'bankname' => (string)$institut['institut'],
|
||
'blz' => (string)$institut['blz'],
|
||
'fints_url' => (string)$institut['pintan_url'],
|
||
'benutzerkennung' => 'testkunde',
|
||
'pin' => 'geheim1234',
|
||
'abrufzeitraum' => 'letzter_abruf',
|
||
'aktiv' => 1,
|
||
]);
|
||
|
||
$zugang = Bankzugang::find($id);
|
||
$this->assertEquals(
|
||
'https://fints.1822direkt.com/fints/hbci',
|
||
(string)$zugang['fints_url'],
|
||
'Die Adresse aus dem Verzeichnis steht im Zugang'
|
||
);
|
||
$this->assertEquals('50050222', (string)$zugang['blz'], 'Die Bankleitzahl steht ohne Leerzeichen da');
|
||
|
||
// Ohne Adresse gibt es keinen FinTS-Zugang – der Controller schlägt sie
|
||
// vorher nach, und findet auch er nichts, endet es hier mit einer
|
||
// Meldung statt mit einem Zugang, der niemanden fragen kann.
|
||
$this->assert(
|
||
$this->wirftFehler(fn() => Bankzugang::anlegen([
|
||
'name' => 'Ohne Adresse',
|
||
'blz' => '99999999',
|
||
'fints_url' => '',
|
||
'benutzerkennung' => 'testkunde',
|
||
'pin' => 'geheim1234',
|
||
'aktiv' => 1,
|
||
])),
|
||
'Ein FinTS-Zugang ohne Adresse wird abgewiesen'
|
||
);
|
||
|
||
// PayPal führt keine FinTS-Adresse – dort darf sie leer bleiben.
|
||
$paypal = Bankzugang::anlegen([
|
||
'typ' => 'paypal',
|
||
'name' => 'PayPal-Verzeichnisprobe',
|
||
'api_benutzer' => 'api_test',
|
||
'api_kennwort' => 'geheim',
|
||
'api_signatur' => 'signatur',
|
||
'aktiv' => 1,
|
||
]);
|
||
$this->assertEquals(
|
||
'',
|
||
(string)Bankzugang::find($paypal)['fints_url'],
|
||
'Ein PayPal-Zugang kommt ohne FinTS-Adresse aus'
|
||
);
|
||
}
|
||
|
||
/** Ruft $fn auf und meldet, ob dabei eine Ausnahme flog. */
|
||
private function wirftFehler(callable $fn): bool
|
||
{
|
||
try {
|
||
$fn();
|
||
} catch (\Throwable) {
|
||
return true;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
}
|