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'); } }