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>
366 lines
13 KiB
PHP
366 lines
13 KiB
PHP
<?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');
|
||
}
|
||
}
|
||
}
|