Finanzmanager/tests/32_BankinstituteTest.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

305 lines
14 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);
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;
}
}