sandbox ? self::ENDPUNKT_SANDBOX : self::ENDPUNKT_LIVE; } /** * Ein Aufruf der Schnittstelle. * * @param array $felder * @return array|null null, wenn die Gegenstelle nicht antwortet */ public function abfrage(array $felder): ?array { $rumpf = http_build_query(array_merge([ 'USER' => $this->benutzer, 'PWD' => $this->kennwort, 'SIGNATURE' => $this->signatur, 'VERSION' => self::VERSION, ], $felder)); $verbindung = curl_init($this->endpunkt()); if ($verbindung === false) { return null; } curl_setopt_array($verbindung, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_POSTFIELDS => $rumpf, CURLOPT_HTTPHEADER => ['Content-Type: application/x-www-form-urlencoded'], CURLOPT_TIMEOUT => self::ZEITGRENZE, ]); $antwort = curl_exec($verbindung); $status = (int)curl_getinfo($verbindung, CURLINFO_HTTP_CODE); curl_close($verbindung); if ($status !== 200 || !is_string($antwort) || $antwort === '') { DebugLog::schreibe('paypal', 'HTTP-Status ' . $status . ' vom PayPal-Endpunkt.'); return null; } parse_str($antwort, $ergebnis); /** @var array $ergebnis */ return $ergebnis; } /** Der Kontostand. */ public function saldo(): ?array { $antwort = $this->abfrage(['METHOD' => 'GetBalance']); return $antwort === null ? null : self::saldoAusAntwort($antwort); } /** * Die Buchungen eines Zeitraums. Beide Grenzen zählen mit. * * @return array|null */ public function umsaetze(\DateTimeImmutable $von, \DateTimeImmutable $bis): ?array { return $this->abfrage([ 'METHOD' => 'TransactionSearch', 'STARTDATE' => $von->format('Y-m-d') . 'T00:00:00Z', 'ENDDATE' => $bis->format('Y-m-d') . 'T23:59:59Z', ]); } // ── Antworten auswerten ────────────────────────────────────────────────── /** * Hat PayPal die Anfrage angenommen? `SuccessWithWarning` gilt als Erfolg: * Die Warnung ist fast immer der Hinweis, dass mehr Buchungen vorliegen als * geliefert werden – dagegen helfen die Zeitscheiben, nicht ein Abbruch. * * @param array $antwort */ public static function erfolgreich(array $antwort): bool { return in_array($antwort['ACK'] ?? '', ['Success', 'SuccessWithWarning'], true); } /** * Meldete PayPal eine Warnung? Sie steht dafür, dass die Antwort abgeschnitten * ist – der Zeitraum enthält mehr Buchungen, als ein Aufruf hergibt. * * @param array $antwort */ public static function abgeschnitten(array $antwort): bool { return ($antwort['ACK'] ?? '') === 'SuccessWithWarning'; } /** * Die Fehlermeldung einer abgelehnten Antwort – oder null, wenn sie taugt. * * @param array $antwort */ public static function fehlermeldung(array $antwort): ?string { if (self::erfolgreich($antwort)) { return null; } $meldung = trim((string)( $antwort['L_LONGMESSAGE0'] ?? $antwort['L_SHORTMESSAGE0'] ?? '' )); if ($meldung === '') { $meldung = 'PayPal hat die Anfrage abgelehnt (ACK ' . ($antwort['ACK'] ?? '?') . ').'; } // Diese eine Meldung sagt nicht, was sie meint: PayPal schickt sie sowohl // bei falschen Zugangsdaten als auch bei Zugangsdaten, die nicht zum // Endpunkt gehören. Beides muss dastehen – nennte man nur den Endpunkt, // suchte man den Fehler garantiert an der falschen Stelle. if (stripos($meldung, 'Security header') !== false) { $meldung .= ' – PayPal weist die Zugangsdaten ab. Zwei Ursachen kommen infrage:' . ' (1) eine der drei Angaben stimmt nicht,' . ' (2) sie gehören zum anderen Endpunkt – Live-Zugangsdaten brauchen den Haken' . ' „Sandbox“ ausgeschaltet, Sandbox-Zugangsdaten eingeschaltet.'; } return $meldung; } /** * Was an den drei Angaben nicht nach PayPal aussieht. * * Reine Formprüfung, bewusst **nur ein Hinweis** und kein Abweisen: PayPal * kann seine Formate ändern, und ein Zugang, der funktioniert, soll nicht an * einer Regel von hier scheitern. Der Sinn ist ein anderer – die Antwort * „Security header is not valid“ nennt nie, welche der drei Angaben klemmt, * und wer sie liest, sucht zuerst beim Endpunkt. * * @return list */ public static function auffaelligkeiten(string $benutzer, string $kennwort, string $signatur): array { $hinweise = []; // PayPal vergibt Namen der Form `name_api1.domain.de`. if ($benutzer !== '' && !str_contains($benutzer, '_api')) { $hinweise[] = sprintf( 'Der API-Benutzername („%s“, %d Zeichen) sieht nicht nach PayPal aus –' . ' er hat dort die Form name_api1.domain.de. Verwechselt wird er leicht mit der' . ' E-Mail-Adresse des Kontos.', self::gekuerzt($benutzer), strlen($benutzer) ); } if ($kennwort !== '' && strlen($kennwort) !== self::LAENGE_KENNWORT) { $hinweise[] = sprintf( 'Das API-Kennwort ist %d Zeichen lang; PayPal vergibt %d.', strlen($kennwort), self::LAENGE_KENNWORT ); } if ($signatur !== '' && strlen($signatur) !== self::LAENGE_SIGNATUR) { $hinweise[] = sprintf( 'Die API-Signatur ist %d Zeichen lang; PayPal vergibt %d.', strlen($signatur), self::LAENGE_SIGNATUR ); } return $hinweise; } /** Genug zum Wiedererkennen, zu wenig zum Mitlesen. */ private static function gekuerzt(string $wert): string { return strlen($wert) <= 8 ? substr($wert, 0, 2) . '…' : substr($wert, 0, 6) . '…'; } /** * Kontostand und Währung aus einer `GetBalance`-Antwort. * * @param array $antwort * @return array{betrag: float, waehrung: string}|null */ public static function saldoAusAntwort(array $antwort): ?array { if (!self::erfolgreich($antwort)) { return null; } // Der Dokumentationsfall … if (isset($antwort['AMT']) && is_numeric($antwort['AMT'])) { return [ 'betrag' => round((float)$antwort['AMT'], 2), 'waehrung' => (string)($antwort['CURRENCYCODE'] ?? 'EUR'), ]; } // … und der, den Live-Konten tatsächlich liefern. Index 0 ist die // Hauptwährung des Kontos. if (isset($antwort['L_AMT0']) && is_numeric($antwort['L_AMT0'])) { return [ 'betrag' => round((float)$antwort['L_AMT0'], 2), 'waehrung' => (string)($antwort['L_CURRENCYCODE0'] ?? 'EUR'), ]; } return null; } /** * Die Buchungen aus einer `TransactionSearch`-Antwort. * * Gerechnet wird mit `L_NETAMT`, dem Betrag nach Abzug der PayPal-Gebühr: * Genau der bewegt das Guthaben, und nur damit stimmt der Kontostand am Ende * mit der Summe der Buchungen überein. * * @param array $antwort * @return list> */ public static function umsaetzeAusAntwort(array $antwort): array { if (!self::erfolgreich($antwort)) { return []; } $umsaetze = []; for ($i = 0; isset($antwort['L_TRANSACTIONID' . $i]); $i++) { $kennung = trim((string)$antwort['L_TRANSACTIONID' . $i]); if ($kennung === '') { continue; } $brutto = (float)($antwort['L_AMT' . $i] ?? 0); $netto = isset($antwort['L_NETAMT' . $i]) ? (float)$antwort['L_NETAMT' . $i] : $brutto; // Einwilligungen und Abonnements stehen als Buchung über 0,00 € in // der Liste. Sie bewegen nichts und hätten im Kontoblatt nichts zu // suchen. if (round($netto, 2) === 0.0 && round($brutto, 2) === 0.0) { continue; } $art = trim((string)($antwort['L_TYPE' . $i] ?? '')); $name = trim((string)($antwort['L_NAME' . $i] ?? '')); $mail = trim((string)($antwort['L_EMAIL' . $i] ?? '')); $datum = self::datum((string)($antwort['L_TIMESTAMP' . $i] ?? '')); $umsaetze[] = [ 'kennung' => $kennung, 'datum' => $datum, 'betrag' => round($netto, 2), 'brutto' => round($brutto, 2), 'waehrung' => mb_substr((string)($antwort['L_CURRENCYCODE' . $i] ?? 'EUR'), 0, 3), 'art' => $art, 'status' => trim((string)($antwort['L_STATUS' . $i] ?? '')), // Wer gezahlt hat oder bezahlt wurde. Nennt PayPal keinen Namen, // ist die Adresse das Nächstbeste; fehlt auch die, bleibt die Art // der Buchung – etwa „Transfer“ bei der Abbuchung vom Bankkonto. 'empfaenger' => $name !== '' ? $name : ($mail !== '' ? $mail : ('PayPal ' . $art)), 'verwendungszweck' => self::zweck($name, $mail, $art), ]; } return $umsaetze; } /** * Der Verwendungszweck: die Adresse des Gegenübers und die Art der Buchung. * Doppelt genannt wird nichts – steht die Adresse schon als Empfänger da, * bleibt sie hier weg. */ private static function zweck(string $name, string $mail, string $art): string { $teile = []; if ($mail !== '' && $mail !== $name) { $teile[] = $mail; } if ($art !== '') { $teile[] = $art; } return implode(' · ', $teile); } /** Der Zeitstempel von PayPal als Datum – heute, wenn er unlesbar ist. */ private static function datum(string $roh): string { $roh = trim($roh); if ($roh === '') { return date('Y-m-d'); } try { return (new \DateTimeImmutable($roh))->format('Y-m-d'); } catch (\Throwable) { return date('Y-m-d'); } } }