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>
358 lines
13 KiB
PHP
358 lines
13 KiB
PHP
<?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,
|
||
];
|
||
}
|
||
}
|