*/ private array $protokoll = []; private int $neu = 0; private int $uebersprungen = 0; /** @param array $zugang mit entschlüsselten Zugangsdaten */ private function __construct( private array $zugang, private int $abrufId ) { } /** * Startet einen Abruf. * * `$stichtag` gilt nur für diesen Lauf und geht dem Abrufzeitraum des * Zugangs vor – wie beim FinTS-Abruf. * * @return array{status: string, meldung: string, protokoll: list, * neu: int, uebersprungen: int, zugang_id: int, modus: string, * einrichtung: bool} */ public static function starten(int $zugangId, ?string $stichtag = null): array { $zugang = Bankzugang::zugangsdaten($zugangId); if ($zugang === null || (string)$zugang['typ'] !== 'paypal') { return self::ergebnis('fehler', 'Den PayPal-Zugang gibt es nicht.', [], 0, 0, $zugangId); } foreach ([ 'api_benutzer' => 'API-Benutzername', 'api_kennwort' => 'API-Kennwort', 'api_signatur' => 'API-Signatur', ] as $feld => $bezeichnung) { if (trim((string)$zugang[$feld]) === '') { return self::ergebnis('fehler', 'Es fehlt: ' . $bezeichnung . '.', [], 0, 0, $zugangId); } } $stichtag = trim((string)$stichtag); if ($stichtag !== '' && Bankzugang::stichtagDatum($stichtag) === null) { return self::ergebnis('fehler', 'Der Stichtag ist kein gültiges Datum.', [], 0, 0, $zugangId); } $abruf = new self($zugang, Bankabruf::beginnen($zugangId)); try { return $abruf->lauf($stichtag); } catch (\Throwable $e) { DebugLog::fehler($e); return $abruf->beenden('fehler', $e->getMessage()); } } // ── Ablauf ─────────────────────────────────────────────────────────────── /** @return array */ private function lauf(string $stichtag): array { $nvp = new PaypalNvp( (string)$this->zugang['api_benutzer'], (string)$this->zugang['api_kennwort'], (string)$this->zugang['api_signatur'], (int)$this->zugang['sandbox'] === 1 ); $this->notiere('Endpunkt: ' . $nvp->endpunkt()); // Die Längen der drei Angaben gehören ins Protokoll, nicht die Angaben // selbst: Scheitert die Anmeldung, ist das die einzige Spur, an der sich // ablesen lässt, welche Angabe klemmt – PayPals Antwort nennt sie nie. $auffaellig = PaypalNvp::auffaelligkeiten( (string)$this->zugang['api_benutzer'], (string)$this->zugang['api_kennwort'], (string)$this->zugang['api_signatur'] ); foreach ($auffaellig as $hinweis) { $this->notiere('Hinweis: ' . $hinweis); } // Ein PayPal-Zugang hat genau ein Konto; beim ersten Abruf entsteht es. $bankkonto = Bankzugang::paypalKonto((int)$this->zugang['id']); $pruefung = $nvp->abfrage(['METHOD' => 'GetBalance']); if ($pruefung === null) { return $this->beenden('fehler', 'PayPal antwortet nicht.'); } $fehler = PaypalNvp::fehlermeldung($pruefung); if ($fehler !== null) { // Weist PayPal die Anmeldung ab, gehören die Auffälligkeiten in die // Meldung selbst: Sie ist das Einzige, was der Nutzer zu sehen // bekommt, und sie sagt sonst nur, dass etwas nicht stimmt. return $this->beenden('fehler', $auffaellig === [] ? $fehler : $fehler . ' Dazu fällt auf: ' . implode(' ', $auffaellig)); } $saldo = PaypalNvp::saldoAusAntwort($pruefung); if ($saldo !== null) { Bankzugang::merkeSaldo((int)$bankkonto['id'], $saldo['betrag']); $this->notiere(sprintf( 'Kontostand: %s %s', number_format($saldo['betrag'], 2, ',', '.'), $saldo['waehrung'] )); } else { $this->notiere('Verbindung steht; einen Kontostand nennt PayPal nicht.'); } [$von, $beschreibung] = Bankzugang::abrufBeginn($this->zugang, $stichtag); $bis = new \DateTimeImmutable('today'); $this->notiere('Zeitraum: ' . $beschreibung . ' (ab ' . $von->format('d.m.Y') . ').'); // PayPal liefert je Aufruf höchstens 100 Buchungen. Der Zeitraum wird // deshalb in Abschnitte zerlegt – ohne das fehlten in einem vollen Monat // still und leise die ältesten Buchungen. $regeln = Einstellung::getBool('banking_regeln_beim_abruf', true) ? AbgleichRegel::alle(true) : []; $cursor = $von; while ($cursor <= $bis) { $ende = $cursor->modify('+' . PaypalNvp::ZEITSCHEIBE_TAGE . ' days'); if ($ende > $bis) { $ende = $bis; } $antwort = $nvp->umsaetze($cursor, $ende); if ($antwort === null) { return $this->beenden('fehler', sprintf( 'PayPal antwortet nicht (%s bis %s).', $cursor->format('d.m.Y'), $ende->format('d.m.Y') )); } $fehler = PaypalNvp::fehlermeldung($antwort); if ($fehler !== null) { return $this->beenden('fehler', $fehler); } if (PaypalNvp::abgeschnitten($antwort)) { $this->notiere(sprintf( ' %s bis %s: PayPal meldet mehr Buchungen, als ein Aufruf hergibt –' . ' mit einem späteren Stichtag noch einmal abrufen.', $cursor->format('d.m.Y'), $ende->format('d.m.Y') )); } $this->schreibeUmsaetze( $bankkonto, PaypalNvp::umsaetzeAusAntwort($antwort), $regeln, $cursor, $ende ); // Beide Grenzen zählen mit; der nächste Abschnitt beginnt am Tag // danach, sonst käme jeder Grenztag zweimal. $cursor = $ende->modify('+1 day'); } Bankzugang::merkeAbruf((int)$this->zugang['id']); $meldung = sprintf( '%d neue Umsätze, %d bereits bekannt.', $this->neu, $this->uebersprungen ); $gebucht = Bankabgleich::automatisch(); if ($gebucht !== null) { $bericht = Bankabgleich::bericht($gebucht); $this->notiere($bericht); $meldung .= ' ' . $bericht; } return $this->beenden('erfolg', $meldung); } /** * Schreibt die Buchungen einer Zeitscheibe weg. * * @param array $bankkonto * @param list> $umsaetze * @param list> $regeln */ private function schreibeUmsaetze( array $bankkonto, array $umsaetze, array $regeln, \DateTimeImmutable $von, \DateTimeImmutable $bis ): void { $neu = 0; $alt = 0; foreach ($umsaetze as $umsatz) { $id = Bankumsatz::anlegen([ 'zugang_id' => (int)$this->zugang['id'], 'konto_id' => $bankkonto['konto_id'], 'bank_konto_id' => (int)$bankkonto['id'], 'extern_id' => Bankumsatz::externIdKennung( (int)$bankkonto['id'], (string)$umsatz['kennung'] ), 'buchungstag' => $umsatz['datum'], 'wertstellung' => $umsatz['datum'], 'betrag' => $umsatz['betrag'], 'waehrung' => $umsatz['waehrung'], 'empfaenger' => $umsatz['empfaenger'], 'verwendungszweck' => $umsatz['verwendungszweck'], 'buchungsart' => $umsatz['art'], 'roh' => json_encode([ 'kennung' => $umsatz['kennung'], 'art' => $umsatz['art'], 'status' => $umsatz['status'], 'brutto' => $umsatz['brutto'], 'netto' => $umsatz['betrag'], ], JSON_UNESCAPED_UNICODE), ]); if ($id === null) { $alt++; continue; } $neu++; // Wie beim FinTS-Abruf zählt die Regel ihren Treffer schon hier – // die Regelliste zeigt damit, welche im Alltag überhaupt greifen. if ($regeln !== []) { $regel = AbgleichRegel::passendeRegel( [ 'empfaenger' => $umsatz['empfaenger'], 'verwendungszweck' => $umsatz['verwendungszweck'], 'betrag' => $umsatz['betrag'], ], $regeln ); if ($regel !== null) { AbgleichRegel::zaehleTreffer((int)$regel['id']); } } } $this->neu += $neu; $this->uebersprungen += $alt; $this->notiere(sprintf( '%s bis %s: %d neu, %d bereits bekannt.', $von->format('d.m.Y'), $bis->format('d.m.Y'), $neu, $alt )); } // ── Protokoll und Ergebnis ─────────────────────────────────────────────── private function notiere(string $zeile): void { $this->protokoll[] = date('H:i:s') . ' ' . $zeile; DebugLog::schreibe('paypal', $zeile); } /** @return array */ private function beenden(string $status, string $meldung): array { $this->protokoll[] = date('H:i:s') . ' ' . $meldung; Bankabruf::abschliessen( $this->abrufId, $status, $this->neu, $this->uebersprungen, $meldung, $this->protokoll ); return self::ergebnis( $status, $meldung, $this->protokoll, $this->neu, $this->uebersprungen, (int)$this->zugang['id'] ); } /** * Dieselbe Form wie bei `FinTsAbruf` – der Controller wertet beide gleich * aus. * * @param list $protokoll * @return array */ private static function ergebnis( string $status, string $meldung, array $protokoll, int $neu, int $uebersprungen, int $zugangId ): array { return [ 'status' => $status, 'meldung' => $meldung, 'protokoll' => $protokoll, 'neu' => $neu, 'uebersprungen' => $uebersprungen, 'zugang_id' => $zugangId, 'modus' => 'voll', 'einrichtung' => false, ]; } }