Finanzmanager/tests/31_PaypalZugangTest.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

406 lines
17 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\Bankumsatz;
use Finanzmanager\Models\Bankzugang;
use Finanzmanager\Models\Konto;
use Finanzmanager\Services\Crypto;
use Finanzmanager\Services\PaypalNvp;
/**
* PayPal als zweite Art von Bankverbindung – ohne PayPal.
*
* Geprüft wird alles, was um den API-Aufruf herum liegt: der Zugang mit seinen
* verschlüsselten Zugangsdaten, das eine Konto, der Fingerabdruck der Umsätze
* und die Auswertung der Antworten. Die Antworten selbst sind nachgestellt –
* genau in der Form, in der PayPal sie schickt, samt der Eigenheiten, die beim
* Anschluss aufgelaufen sind.
*/
class PaypalZugangTest extends TestCase
{
protected string $testName = 'Test 31: PayPal-Bankverbindung';
private int $zugang = 0;
public function run(): void
{
$this->mandantAnlegen();
$this->zugangAnlegen();
$this->zugangKenntSeinenTyp();
$this->geheimnisseStehenVerschluesseltInDerDatenbank();
$this->leereGeheimnisseBleibenUnveraendert();
$this->typBleibtBeimSpeichernStehen();
$this->kontoEntstehtGenauEinmal();
$this->kontoLaesstSichZuordnen();
$this->fingerabdruckHaengtAnDerTransaktionsnummer();
$this->endpunktRichtetSichNachDerSandbox();
$this->abgelehnteAntwortNenntDenGrund();
$this->sicherheitsfehlerNenntBeideUrsachen();
$this->auffaelligeAngabenWerdenBenannt();
$this->leerraumUmDieGeheimnisseFaelltWeg();
$this->saldoKommtAusBeidenAntwortformen();
$this->umsaetzeWerdenAusgelesen();
$this->nullbuchungenFallenHeraus();
$this->warnungGiltAlsErfolg();
}
private function zugangAnlegen(): void
{
$this->zugang = Bankzugang::anlegen([
'typ' => 'paypal',
'name' => 'PayPal-Test',
'bankname' => 'PayPal',
'api_benutzer' => 'test_api1.example.org',
'api_kennwort' => 'kennwort-geheim',
'api_signatur' => 'signatur-geheim',
'sandbox' => 1,
'abrufzeitraum' => 'letzter_abruf',
'aktiv' => 1,
]);
}
// ── Der Zugang ───────────────────────────────────────────────────────────
private function zugangKenntSeinenTyp(): void
{
$zugang = Bankzugang::find($this->zugang);
$this->assertEquals('paypal', $zugang['typ'], 'Der Zugang ist ein PayPal-Zugang');
$this->assertEquals('test_api1.example.org', $zugang['api_benutzer'], 'Der API-Benutzer steht im Klartext');
$this->assertEquals(1, (int)$zugang['sandbox'], 'Der Sandbox-Haken ist gesetzt');
}
private function geheimnisseStehenVerschluesseltInDerDatenbank(): void
{
if (!Crypto::einsatzbereit()) {
$this->assert(false, 'APP_KEY fehlt – ohne ihn lässt sich die Verschlüsselung nicht prüfen');
return;
}
$roh = Bankzugang::find($this->zugang);
$this->assert(
!str_contains((string)$roh['api_kennwort'], 'kennwort-geheim'),
'Das API-Kennwort steht nicht im Klartext in der Datenbank'
);
$this->assert(
!str_contains((string)$roh['api_signatur'], 'signatur-geheim'),
'Die API-Signatur steht nicht im Klartext in der Datenbank'
);
$daten = Bankzugang::zugangsdaten($this->zugang);
$this->assertEquals('kennwort-geheim', $daten['api_kennwort'], 'Das Kennwort kommt entschlüsselt zurück');
$this->assertEquals('signatur-geheim', $daten['api_signatur'], 'Die Signatur kommt entschlüsselt zurück');
}
private function leereGeheimnisseBleibenUnveraendert(): void
{
// Dieselbe Regel wie bei der FinTS-PIN: Ein leeres Feld im Formular
// heißt „unverändert“, sonst verlöre jedes Speichern die Zugangsdaten.
Bankzugang::speichern($this->zugang, [
'typ' => 'paypal',
'name' => 'PayPal-Test',
'api_benutzer' => 'test_api1.example.org',
'api_kennwort' => '',
'api_signatur' => '',
'sandbox' => 1,
'abrufzeitraum' => 'letzter_abruf',
'aktiv' => 1,
]);
$daten = Bankzugang::zugangsdaten($this->zugang);
$this->assertEquals('kennwort-geheim', $daten['api_kennwort'], 'Das Kennwort überlebt das Speichern');
$this->assertEquals('signatur-geheim', $daten['api_signatur'], 'Die Signatur überlebt das Speichern');
}
private function typBleibtBeimSpeichernStehen(): void
{
// Ein PayPal-Zugang, der sich in einen FinTS-Zugang verwandelte, hätte
// weder passende Zugangsdaten noch passende Konten.
Bankzugang::speichern($this->zugang, [
'typ' => 'fints',
'name' => 'PayPal-Test',
'api_benutzer' => 'test_api1.example.org',
'abrufzeitraum' => 'letzter_abruf',
'aktiv' => 1,
]);
$this->assertEquals(
'paypal',
Bankzugang::find($this->zugang)['typ'],
'Der Typ lässt sich nachträglich nicht umstellen'
);
}
// ── Das eine Konto ───────────────────────────────────────────────────────
private function kontoEntstehtGenauEinmal(): void
{
$erstes = Bankzugang::paypalKonto($this->zugang);
$zweites = Bankzugang::paypalKonto($this->zugang);
$this->assertEquals((int)$erstes['id'], (int)$zweites['id'], 'Der zweite Abruf findet dasselbe Konto wieder');
$this->assertEquals(
Bankzugang::PAYPAL_KENNUNG,
$erstes['kontonummer'],
'Das Konto trägt die feste PayPal-Kennung'
);
$this->assertEquals(1, count(Bankzugang::konten($this->zugang)), 'Ein PayPal-Zugang führt genau ein Konto');
}
private function kontoLaesstSichZuordnen(): void
{
$konto = Konto::anlegen([
'name' => 'PayPal-Guthaben', 'kontoart' => 'bank', 'anfangssaldo' => 0, 'aktiv' => 1,
]);
$bankkonto = Bankzugang::paypalKonto($this->zugang);
Bankzugang::ordneKontoZu((int)$bankkonto['id'], $konto);
$this->assertEquals(
$konto,
(int)Bankzugang::konten($this->zugang)[0]['konto_id'],
'Das Konto ist zugeordnet'
);
}
private function fingerabdruckHaengtAnDerTransaktionsnummer(): void
{
$bankkonto = (int)Bankzugang::paypalKonto($this->zugang)['id'];
$eine = Bankumsatz::externIdKennung($bankkonto, '3XY12345ABC678901');
$selbe = Bankumsatz::externIdKennung($bankkonto, ' 3XY12345ABC678901 ');
$andere = Bankumsatz::externIdKennung($bankkonto, '3XY12345ABC678902');
$this->assertEquals($selbe, $eine, 'Leerraum um die Nummer ändert den Fingerabdruck nicht');
$this->assert($eine !== $andere, 'Zwei Nummern ergeben zwei Fingerabdrücke');
$this->assertEquals(40, strlen($eine), 'Der Fingerabdruck passt in die Spalte');
// Derselbe Umsatz darf beim zweiten Lauf nicht noch einmal hereinkommen –
// die Abrufzeiträume überlappen sich fast immer.
$konto = (int)Bankzugang::konten($this->zugang)[0]['konto_id'];
$daten = [
'zugang_id' => $this->zugang,
'konto_id' => $konto,
'bank_konto_id' => $bankkonto,
'extern_id' => $eine,
'buchungstag' => '2026-08-01',
'wertstellung' => '2026-08-01',
'betrag' => -12.34,
'empfaenger' => 'Beispielshop',
];
$this->assertNotEmpty(Bankumsatz::anlegen($daten), 'Der Umsatz kommt herein');
$this->assertNull(Bankumsatz::anlegen($daten), 'Beim zweiten Mal nicht noch einmal');
}
// ── Die Antworten von PayPal ─────────────────────────────────────────────
private function endpunktRichtetSichNachDerSandbox(): void
{
$live = new PaypalNvp('b', 'k', 's', false);
$test = new PaypalNvp('b', 'k', 's', true);
$this->assertEquals(PaypalNvp::ENDPUNKT_LIVE, $live->endpunkt(), 'Ohne Haken der Live-Endpunkt');
$this->assertEquals(PaypalNvp::ENDPUNKT_SANDBOX, $test->endpunkt(), 'Mit Haken der Sandbox-Endpunkt');
}
private function abgelehnteAntwortNenntDenGrund(): void
{
$antwort = [
'ACK' => 'Failure',
'L_SHORTMESSAGE0' => 'Invalid argument',
'L_LONGMESSAGE0' => 'Die Suche war nicht erfolgreich.',
];
$this->assert(!PaypalNvp::erfolgreich($antwort), 'Failure gilt nicht als Erfolg');
$this->assert(
str_contains((string)PaypalNvp::fehlermeldung($antwort), 'Die Suche war nicht erfolgreich.'),
'Die ausführliche Meldung steht im Fehler'
);
$this->assertEquals([], PaypalNvp::umsaetzeAusAntwort($antwort), 'Aus einer Absage kommen keine Umsätze');
$this->assertNull(PaypalNvp::saldoAusAntwort($antwort), 'Und auch kein Kontostand');
}
private function sicherheitsfehlerNenntBeideUrsachen(): void
{
// Diese Meldung schickt PayPal bei falschen Zugangsdaten genauso wie bei
// Zugangsdaten, die zum anderen Endpunkt gehören. Nennte die Erklärung
// nur den Endpunkt, suchte man garantiert an der falschen Stelle.
$meldung = (string)PaypalNvp::fehlermeldung([
'ACK' => 'Failure',
'L_LONGMESSAGE0' => 'Security header is not valid',
]);
$this->assert(str_contains($meldung, 'Sandbox'), 'Der Sicherheitsfehler nennt den Endpunkt');
$this->assert(
str_contains($meldung, 'eine der drei Angaben stimmt nicht'),
'Und ebenso die falschen Zugangsdaten'
);
}
private function auffaelligeAngabenWerdenBenannt(): void
{
// Echte PayPal-Angaben: Name der Form name_api1.domain, Kennwort 16
// Zeichen, Signatur 56.
$this->assertEquals(
[],
PaypalNvp::auffaelligkeiten('test_api1.example.org', str_repeat('k', 16), str_repeat('s', 56)),
'An gültigen Angaben fällt nichts auf'
);
$hinweise = PaypalNvp::auffaelligkeiten('kurz', str_repeat('k', 15), str_repeat('s', 20));
$this->assertEquals(3, count($hinweise), 'Alle drei Angaben werden bemängelt');
$this->assert(
str_contains($hinweise[0], 'name_api1'),
'Der Benutzername bekommt die erwartete Form genannt'
);
$this->assert(str_contains($hinweise[1], '15'), 'Die Länge des Kennworts steht dabei');
$this->assert(str_contains($hinweise[2], '56'), 'Und die erwartete Länge der Signatur');
// Der Wert selbst darf nicht mitlesbar in der Meldung landen.
$this->assert(
!str_contains($hinweise[0], 'kurz'),
'Der Benutzername steht nur gekürzt in der Meldung'
);
// Leere Angaben bemängelt niemand hier – dafür gibt es die Prüfung auf
// Vollständigkeit vor dem Abruf.
$this->assertEquals([], PaypalNvp::auffaelligkeiten('', '', ''), 'Leeres bleibt hier unbeanstandet');
}
private function leerraumUmDieGeheimnisseFaelltWeg(): void
{
// Eine aus der Zwischenablage eingefügte Signatur bringt gern ein
// Leerzeichen mit – und PayPal antwortet darauf mit „Security header is
// not valid“, was alles Mögliche vermuten lässt, nur nicht das.
$id = Bankzugang::anlegen([
'typ' => 'paypal',
'name' => 'PayPal-Leerraum',
'api_benutzer' => ' test_api1.example.org ',
'api_kennwort' => " kennwort-mit-rand\n",
'api_signatur' => "signatur-mit-rand\t",
'abrufzeitraum' => 'letzter_abruf',
'aktiv' => 1,
]);
$daten = Bankzugang::zugangsdaten($id);
$this->assertEquals('test_api1.example.org', $daten['api_benutzer'], 'Der Benutzername steht ohne Rand');
$this->assertEquals('kennwort-mit-rand', $daten['api_kennwort'], 'Das Kennwort ebenso');
$this->assertEquals('signatur-mit-rand', $daten['api_signatur'], 'Und die Signatur');
}
private function saldoKommtAusBeidenAntwortformen(): void
{
// Die Form aus der Dokumentation …
$flach = PaypalNvp::saldoAusAntwort([
'ACK' => 'Success', 'AMT' => '42.50', 'CURRENCYCODE' => 'EUR',
]);
$this->assertEquals(42.50, $flach['betrag'], 'Der Kontostand aus AMT');
$this->assertEquals('EUR', $flach['waehrung'], 'Samt Währung');
// … und die, die Live-Konten tatsächlich liefern.
$liste = PaypalNvp::saldoAusAntwort([
'ACK' => 'Success', 'L_AMT0' => '-7.25', 'L_CURRENCYCODE0' => 'EUR',
'L_AMT1' => '13.00', 'L_CURRENCYCODE1' => 'USD',
]);
$this->assertEquals(-7.25, $liste['betrag'], 'Der Kontostand aus L_AMT0');
$this->assertEquals('EUR', $liste['waehrung'], 'Index 0 ist die Hauptwährung');
$this->assertNull(
PaypalNvp::saldoAusAntwort(['ACK' => 'Success']),
'Ohne Betrag gibt es keinen Kontostand'
);
}
private function umsaetzeWerdenAusgelesen(): void
{
$umsaetze = PaypalNvp::umsaetzeAusAntwort([
'ACK' => 'Success',
'L_TRANSACTIONID0' => '3XY12345ABC678901',
'L_TIMESTAMP0' => '2026-08-14T09:12:33Z',
'L_TYPE0' => 'Payment',
'L_STATUS0' => 'Completed',
'L_NAME0' => 'Beispielshop GmbH',
'L_EMAIL0' => 'shop@example.org',
'L_AMT0' => '-25.00',
'L_NETAMT0' => '-25.00',
'L_CURRENCYCODE0' => 'EUR',
'L_TRANSACTIONID1' => '9ZZ99999XYZ111111',
'L_TIMESTAMP1' => '2026-08-15T22:04:00Z',
'L_TYPE1' => 'Transfer',
'L_STATUS1' => 'Completed',
'L_NAME1' => '',
'L_EMAIL1' => '',
'L_AMT1' => '25.00',
'L_NETAMT1' => '25.00',
'L_CURRENCYCODE1' => 'EUR',
]);
$this->assertEquals(2, count($umsaetze), 'Beide Buchungen kommen heraus');
$this->assertEquals('3XY12345ABC678901', $umsaetze[0]['kennung'], 'Die Transaktionsnummer steht dabei');
$this->assertEquals('2026-08-14', $umsaetze[0]['datum'], 'Der Zeitstempel wird zum Buchungstag');
$this->assertEquals(-25.00, $umsaetze[0]['betrag'], 'Der Betrag trägt sein Vorzeichen');
$this->assertEquals('Beispielshop GmbH', $umsaetze[0]['empfaenger'], 'Der Name ist der Empfänger');
$this->assert(
str_contains($umsaetze[0]['verwendungszweck'], 'shop@example.org'),
'Die Adresse steht im Verwendungszweck'
);
$this->assertEquals('Payment', $umsaetze[0]['art'], 'Die Art der Buchung bleibt erhalten');
// Ohne Namen und Adresse bleibt nur die Art – etwa bei der Abbuchung vom
// hinterlegten Bankkonto.
$this->assertEquals('PayPal Transfer', $umsaetze[1]['empfaenger'], 'Namenlose Buchungen nennen ihre Art');
}
private function nullbuchungenFallenHeraus(): void
{
// Einwilligungen und Abonnements stehen als Buchung über 0,00 € in der
// Liste und bewegen nichts.
$umsaetze = PaypalNvp::umsaetzeAusAntwort([
'ACK' => 'Success',
'L_TRANSACTIONID0' => 'B-0000000000000000',
'L_TIMESTAMP0' => '2026-08-10T10:00:00Z',
'L_TYPE0' => 'Recurring Payment',
'L_AMT0' => '0.00',
'L_NETAMT0' => '0.00',
'L_TRANSACTIONID1' => '3XY00000ABC000001',
'L_TIMESTAMP1' => '2026-08-11T10:00:00Z',
'L_TYPE1' => 'Payment',
'L_AMT1' => '-9.99',
'L_NETAMT1' => '-9.99',
]);
$this->assertEquals(1, count($umsaetze), 'Die Nullbuchung fällt heraus');
$this->assertEquals('3XY00000ABC000001', $umsaetze[0]['kennung'], 'Die echte Buchung bleibt');
}
private function warnungGiltAlsErfolg(): void
{
// PayPal liefert je Aufruf höchstens 100 Buchungen und meldet das als
// Warnung. Abbrechen wäre falsch – die Antwort enthält Buchungen.
$antwort = [
'ACK' => 'SuccessWithWarning',
'L_LONGMESSAGE0' => 'The number of results were truncated.',
'L_TRANSACTIONID0' => '3XY11111ABC111111',
'L_TIMESTAMP0' => '2026-07-01T08:00:00Z',
'L_TYPE0' => 'Payment',
'L_AMT0' => '-1.00',
'L_NETAMT0' => '-1.00',
];
$this->assert(PaypalNvp::erfolgreich($antwort), 'Eine Warnung gilt als Erfolg');
$this->assertNull(PaypalNvp::fehlermeldung($antwort), 'Und ist kein Fehler');
$this->assert(PaypalNvp::abgeschnitten($antwort), 'Sie ist aber als abgeschnitten erkennbar');
$this->assertEquals(1, count(PaypalNvp::umsaetzeAusAntwort($antwort)), 'Die Buchung kommt trotzdem herein');
}
}