Finanzmanager/src/Services/PaypalNvp.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

366 lines
13 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);
namespace Finanzmanager\Services;
/**
* Die PayPal Classic NVP API – der Zugang, über den sich Umsätze eines
* PayPal-Kontos abrufen lassen (`TransactionSearch`) samt Kontostand
* (`GetBalance`).
*
* Übernommen aus dem Parallelprojekt `Steuerkram`, wo die Fallstricke schon
* einmal aufgelaufen sind:
*
* - **Zwei Endpunkte.** Live- und Sandbox-Zugangsdaten passen jeweils nur zu
* ihrem eigenen. Mit dem falschen antwortet PayPal ausschließlich mit
* *„Security header is not valid“* – eine Meldung, die nach einem Tippfehler
* in den Zugangsdaten aussieht und keiner ist.
* - **Zeitscheiben.** `TransactionSearch` liefert je Aufruf höchstens 100
* Buchungen und meldet das nur als Warnung. Abgefragt wird deshalb in
* Abschnitten von 30 Tagen.
* - **Listenfelder.** Die Antwort ist ein Formularstring; Listen stehen als
* `L_TRANSACTIONID0`, `L_TRANSACTIONID1`, … darin und enden dort, wo der
* nächste Index fehlt.
* - **`GetBalance` antwortet zweierlei.** Die Dokumentation nennt die flachen
* Felder `AMT`/`CURRENCYCODE`; Live-Konten liefern stattdessen die Liste
* `L_AMT0`/`L_CURRENCYCODE0`. Beides muss gelesen werden, sonst bleibt der
* Kontostand auf echten Konten leer.
* - **Nullbuchungen.** Einwilligungen und Abonnements erscheinen als Buchung
* über 0,00 € und gehören nicht ins Kontoblatt.
*
* Die Auswertung der Antworten steht bewusst in statischen Methoden ohne
* Netzzugriff: Nur so lässt sie sich prüfen, ohne ein PayPal-Konto zu haben.
*/
class PaypalNvp
{
/** Fassung der Schnittstelle. 204 ist die letzte, die TransactionSearch führt. */
public const VERSION = '204';
public const ENDPUNKT_LIVE = 'https://api-3t.paypal.com/nvp';
public const ENDPUNKT_SANDBOX = 'https://api-3t.sandbox.paypal.com/nvp';
/** Länge einer Zeitscheibe der Umsatzabfrage in Tagen. */
public const ZEITSCHEIBE_TAGE = 30;
/** Die Längen, die PayPal für Kennwort und Signatur vergibt. */
private const LAENGE_KENNWORT = 16;
private const LAENGE_SIGNATUR = 56;
/** Zeitüberschreitung einer Abfrage in Sekunden. */
private const ZEITGRENZE = 30;
public function __construct(
private string $benutzer,
private string $kennwort,
private string $signatur,
private bool $sandbox = false
) {
}
public function endpunkt(): string
{
return $this->sandbox ? self::ENDPUNKT_SANDBOX : self::ENDPUNKT_LIVE;
}
/**
* Ein Aufruf der Schnittstelle.
*
* @param array<string,string> $felder
* @return array<string,string>|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<string,string> $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<string,string>|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<string,string> $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<string,string> $antwort
*/
public static function abgeschnitten(array $antwort): bool
{
return ($antwort['ACK'] ?? '') === 'SuccessWithWarning';
}
/**
* Die Fehlermeldung einer abgelehnten Antwort – oder null, wenn sie taugt.
*
* @param array<string,string> $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<string>
*/
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<string,string> $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<string,string> $antwort
* @return list<array<string,mixed>>
*/
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');
}
}
}