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

358 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;
use Finanzmanager\Models\AbgleichRegel;
use Finanzmanager\Models\Bankabruf;
use Finanzmanager\Models\Bankumsatz;
use Finanzmanager\Models\Bankzugang;
use Finanzmanager\Models\Einstellung;
/**
* Der Umsatzabruf bei PayPal.
*
* Das Gegenstück zu `FinTsAbruf`, nur erheblich kürzer: PayPal führt je Konto
* genau ein Guthabenkonto, es gibt also weder eine Kontenabfrage noch eine
* Zuordnung mehrerer Konten. Der Ablauf ist:
*
* Zugangsdaten prüfen (und dabei den Kontostand holen) → Umsätze je
* Zeitscheibe → abgleichen
*
* Alles innerhalb eines Requests – anders als bei FinTS unterbricht hier nichts
* für eine Freigabe.
*
* **Freigaben (MFA).** Die Classic-NVP-API authentifiziert allein über
* API-Benutzernamen, Kennwort und Signatur; eine Rückfrage wie die TAN einer
* Bank stellt sie nicht. Verlangt PayPal eines Tages doch eine Bestätigung, ist
* dafür nichts umzubauen: Das Ergebnis dieser Klasse hat dieselbe Form wie das
* von `FinTsAbruf` – einschließlich des Status `tan` samt Feld `tan` –, der
* Controller schickt es durch dieselbe Auswertung, und `bank_abrufe` kennt den
* Status `tan_offen` bereits. Einzuhängen wäre dann nur die Stelle, an der die
* Antwort von PayPal als Freigabeanforderung erkannt wird.
*
* Ist `banking_regeln_beim_abruf` eingeschaltet, zählen die Abgleichregeln ihre
* Treffer schon hier; `Bankabgleich` bucht am Ende, was ein Konto hat.
*/
class PaypalAbruf
{
/** @var list<string> */
private array $protokoll = [];
private int $neu = 0;
private int $uebersprungen = 0;
/** @param array<string,mixed> $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<string>,
* 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<string,mixed> */
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<string,mixed> $bankkonto
* @param list<array<string,mixed>> $umsaetze
* @param list<array<string,mixed>> $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<string,mixed> */
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<string> $protokoll
* @return array<string,mixed>
*/
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,
];
}
}