feat(payments): Anbieter-Gerüst core/payments mit PayPal Orders v2

- ProviderInterface/AbstractProvider: Vorgang mit Uhrzeit+Session (payment_transactions),
  Mail und Warenkorb erst nach Zahlung, idempotentes markPaid, Betragsabgleich,
  shop_event + PG payment_log
- Secrets verschluesselt (libsodium) in MySQL, Schluessel ausserhalb Docroot
- Cashdesk: ?payment_return/?payment_cancel, Pending-Riegel, zahlung_erst_spaeter()
- Backend Zahlart-Editor: Einstellungen speichern (war ohne Empfaenger), Secret-Felder
- payment_webhook.php (Standalone), cli.php (init-key, secret, reconcile, status)
- PayPal-v2-Blaupause vom Juli entfernt
This commit is contained in:
Thomas Bartelt
2026-09-13 19:51:52 +02:00
parent af81e8f63a
commit 397fb5944a
30 changed files with 2002 additions and 1104 deletions
@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace Payments\Providers\PaypalV2;
/**
* Duenner HTTP-Client fuer die PayPal-REST-API (Orders v2).
* TLS-Pruefung erzwungen. Access-Token wird pro Modus im Cache-Verzeichnis neben dem
* Secrets-Schluessel abgelegt (ausserhalb des Docroots).
*/
final class Client
{
public function __construct(
private readonly string $apiBase,
private readonly string $clientId,
private readonly string $secret,
private readonly string $cacheFile
) {
}
public function accessToken(): string
{
if (\is_file($this->cacheFile)) {
$c = \json_decode((string) @\file_get_contents($this->cacheFile), true);
if (\is_array($c) && ($c['base'] ?? '') === $this->apiBase && ($c['cid'] ?? '') === \substr($this->clientId, 0, 8)
&& isset($c['token'], $c['expires']) && (int) $c['expires'] > \time() + 60) {
return (string) $c['token'];
}
}
[$status, $body] = $this->raw('POST', '/v1/oauth2/token', 'grant_type=client_credentials',
['Content-Type: application/x-www-form-urlencoded'], $this->clientId . ':' . $this->secret);
$data = \json_decode($body, true);
if ($status !== 200 || !\is_array($data) || empty($data['access_token'])) {
throw new \RuntimeException('PayPal OAuth2 fehlgeschlagen (HTTP ' . $status . ')');
}
@\file_put_contents($this->cacheFile, \json_encode([
'token' => $data['access_token'], 'expires' => \time() + (int) ($data['expires_in'] ?? 300),
'base' => $this->apiBase, 'cid' => \substr($this->clientId, 0, 8),
]), LOCK_EX);
@\chmod($this->cacheFile, 0600);
return (string) $data['access_token'];
}
/**
* @param array<string,mixed>|null $payload
* @param list<string> $extraHeaders
* @return array{0:int,1:array<mixed>}
*/
public function request(string $method, string $path, ?array $payload = null, array $extraHeaders = []): array
{
$headers = \array_merge(['Content-Type: application/json', 'Authorization: Bearer ' . $this->accessToken()], $extraHeaders);
$bodyStr = $payload === null ? null : (string) \json_encode($payload, JSON_UNESCAPED_UNICODE);
[$status, $body] = $this->raw($method, $path, $bodyStr, $headers);
$decoded = $body === '' ? [] : \json_decode($body, true);
return [$status, \is_array($decoded) ? $decoded : ['raw' => $body]];
}
/** @param list<string> $headers @return array{0:int,1:string} */
private function raw(string $method, string $path, ?string $body, array $headers, ?string $basicAuth = null): array
{
$ch = \curl_init($this->apiBase . $path);
\curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method, CURLOPT_RETURNTRANSFER => true,
CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_TIMEOUT => 30, CURLOPT_CONNECTTIMEOUT => 10, CURLOPT_HTTPHEADER => $headers,
]);
if ($basicAuth !== null) {
\curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
\curl_setopt($ch, CURLOPT_USERPWD, $basicAuth);
}
if ($body !== null) {
\curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
}
$resp = \curl_exec($ch);
if ($resp === false) {
$err = \curl_error($ch);
\curl_close($ch);
throw new \RuntimeException('PayPal HTTP-Transportfehler: ' . $err);
}
$status = (int) \curl_getinfo($ch, CURLINFO_HTTP_CODE);
\curl_close($ch);
return [$status, (string) $resp];
}
}
@@ -0,0 +1,388 @@
<?php
declare(strict_types=1);
namespace Payments\Providers\PaypalV2;
use Payments\AbstractProvider;
use Payments\Log;
use Payments\Result;
use Payments\Secrets;
require_once __DIR__ . '/Client.php';
/**
* PayPal Checkout, Orders API v2 (REST). Ersetzt das NVP-Express-Checkout von 2010.
*
* Ablauf: start() legt eine PayPal-Order (intent CAPTURE) an und leitet zur
* Approve-URL. onReturn() captured und setzt bezahlt. onWebhook() bestaetigt
* PAYMENT.CAPTURE.COMPLETED serverseitig. reconcile() zieht Vorgaenge nach, deren
* Rueckkehr verloren ging (APPROVED aber nie gecaptured).
*
* Einstellungen (configuration, Gruppe paypal_v2): mode, brand_name, send_address, currency
* Secrets (payment_provider_secrets): sandbox_client_id, sandbox_secret,
* live_client_id, live_secret, webhook_id
*/
final class Provider extends AbstractProvider
{
private ?Client $client = null;
public function module(): string
{
return 'paypal_v2';
}
public function label(): string
{
return 'PayPal (v2)';
}
public function secretFields(): array
{
return [
'sandbox_client_id' => 'Sandbox Client-ID',
'sandbox_secret' => 'Sandbox Secret',
'live_client_id' => 'Live Client-ID',
'live_secret' => 'Live Secret',
'webhook_id' => 'Webhook-ID (aus dem PayPal-Dashboard, fuer den aktiven Modus)',
];
}
public function mode(): string
{
return $this->setting('mode', 'sandbox') === 'live' ? 'live' : 'sandbox';
}
private function apiBase(): string
{
return $this->mode() === 'live' ? 'https://api-m.paypal.com' : 'https://api-m.sandbox.paypal.com';
}
private function client(): Client
{
if ($this->client === null) {
$m = $this->mode();
$this->client = new Client(
$this->apiBase(),
$this->secret($m . '_client_id'),
$this->secret($m . '_secret'),
\dirname(Secrets::keyPath()) . '/paypal_v2_' . $m . '.token'
);
}
return $this->client;
}
// ------------------------------------------------------------------ start
public function start(int $orderId, float $amount): string
{
$order = $this->orderById($orderId);
$number = (string) $order->order_number;
$value = \number_format($amount, 2, '.', '');
$unit = [
'reference_id' => (string) $orderId,
'custom_id' => (string) $orderId,
'invoice_id' => $number . '-' . $orderId,
'description' => 'Bestellung ' . $number,
'amount' => ['currency_code' => $this->currency(), 'value' => $value],
];
$context = [
'brand_name' => $this->setting('brand_name', 'intelectra'),
'locale' => 'de-DE',
'landing_page' => 'NO_PREFERENCE',
'user_action' => 'PAY_NOW',
'return_url' => $this->returnUrl(),
'cancel_url' => $this->cancelUrl(),
'shipping_preference' => 'NO_SHIPPING',
];
// Lieferadresse mitgeben: Voraussetzung fuer PayPal-Verkaeuferschutz bei Warenversand.
if ($this->setting('send_address', '1') === '1') {
$a = $this->shippingAddress($order);
if ($a['street'] !== '' && $a['zip'] !== '' && $a['city'] !== '') {
$context['shipping_preference'] = 'SET_PROVIDED_ADDRESS';
$unit['shipping'] = [
'name' => ['full_name' => $a['name'] !== '' ? $a['name'] : $a['company']],
'address' => [
'address_line_1' => $a['street'],
'address_line_2' => $a['company'],
'postal_code' => $a['zip'],
'admin_area_2' => $a['city'],
'country_code' => $a['country'],
],
];
}
}
$payload = ['intent' => 'CAPTURE', 'purchase_units' => [$unit], 'application_context' => $context];
$requestId = 'create-' . $orderId . '-' . \substr(\hash('sha256', $number . '|' . $value), 0, 16);
[$status, $body] = $this->client()->request('POST', '/v2/checkout/orders', $payload, ['PayPal-Request-Id: ' . $requestId]);
Log::write($this->module(), 'create', $orderId, $number, (string) ($body['id'] ?? ''), (string) ($body['status'] ?? ''), $status, $body);
if ($status !== 201 || empty($body['id'])) {
throw new \RuntimeException('PayPal Order-Anlage fehlgeschlagen (HTTP ' . $status . '): ' . \json_encode($body['details'] ?? $body));
}
$approve = '';
foreach (($body['links'] ?? []) as $link) {
if (\in_array($link['rel'] ?? '', ['approve', 'payer-action'], true)) {
$approve = (string) $link['href'];
break;
}
}
if ($approve === '') {
throw new \RuntimeException('PayPal: kein Approve-Link in der Antwort.');
}
$this->openTransaction($orderId, (string) $body['id'], $amount, $approve);
return $approve;
}
// ----------------------------------------------------------------- return
public function onReturn(array $query): Result
{
$ref = (string) ($query['token'] ?? '');
if ($ref === '') {
return Result::error('Der PayPal-Vorgang konnte nicht zugeordnet werden. Ihr Warenkorb ist noch da.');
}
$tx = $this->transactionByReference($ref);
if (!$tx) {
return Result::error('Der PayPal-Vorgang ist unbekannt. Ihr Warenkorb ist noch da.');
}
$orderId = (int) $tx->order_id;
$number = (string) $tx->order_number;
if ($tx->status === 'paid') {
$this->pendingClear();
return Result::paid($orderId, $number);
}
try {
$cap = $this->capture($ref, $orderId, $number);
} catch (\Throwable $e) {
\error_log('PayPal v2 onReturn: ' . $e->getMessage());
$this->event('failed', $orderId, $number, null, $ref, null, (int) $tx->customer_id, $e->getMessage());
return Result::error('Beim Abschluss der PayPal-Zahlung ist ein Fehler aufgetreten. Ihr Warenkorb ist noch da, bitte versuchen Sie es erneut oder waehlen Sie eine andere Zahlart.', $orderId, $number);
}
if ($cap['capture_status'] === 'COMPLETED') {
if ($this->markPaid($ref, $cap['amount'], $cap['currency'], $cap['capture_id'], $cap['payer_email'], $cap['payer_id'], 'return')) {
return Result::paid($orderId, $number);
}
return Result::error('Die Zahlung konnte nicht zugeordnet werden. Bitte kontaktieren Sie uns unter Angabe der Bestellnummer ' . $number . '.', $orderId, $number);
}
if ($cap['capture_status'] === 'PENDING') {
$this->setTransactionStatus($ref, 'pending', 'capture PENDING: ' . ($cap['pending_reason'] ?? ''));
return Result::pending($orderId, $number, 'PayPal prueft die Zahlung noch. Sie erhalten die Bestellbestaetigung, sobald die Zahlung bestaetigt ist.');
}
$this->event('failed', $orderId, $number, null, $ref, $cap['capture_id'], (int) $tx->customer_id, 'capture ' . $cap['capture_status']);
return Result::error('PayPal hat die Zahlung nicht abgeschlossen (' . ($cap['capture_status'] ?: $cap['order_status']) . '). Ihr Warenkorb ist noch da.', $orderId, $number);
}
public function onCancel(array $query): Result
{
$ref = (string) ($query['token'] ?? '');
if ($ref === '') {
$p = $_SESSION['easyshop']['payment_pending'] ?? null;
if (\is_array($p) && ($p['module'] ?? '') === $this->module()) {
$ref = (string) $p['reference'];
}
}
if ($ref !== '') {
$tx = $this->transactionByReference($ref);
if ($tx && $tx->status === 'paid') {
return Result::paid((int) $tx->order_id, (string) $tx->order_number);
}
$this->markCancelled($ref, 'Kunde hat bei PayPal abgebrochen');
return Result::cancelled((int) ($tx->order_id ?? 0), (string) ($tx->order_number ?? ''));
}
$this->pendingClear();
return Result::cancelled();
}
// ---------------------------------------------------------------- webhook
public function onWebhook(array $headers, string $rawBody): int
{
$event = \json_decode($rawBody, true);
if (!\is_array($event)) {
return 400;
}
$type = (string) ($event['event_type'] ?? '');
if (!$this->verifyWebhook($headers, $event)) {
Log::write($this->module(), 'webhook', null, null, (string) ($event['id'] ?? ''), 'rejected', null, ['event_type' => $type], 'Signatur ungueltig');
return 400;
}
$resource = \is_array($event['resource'] ?? null) ? $event['resource'] : [];
$orderId = (int) ($resource['custom_id'] ?? ($resource['purchase_units'][0]['custom_id'] ?? 0));
Log::write($this->module(), 'webhook', $orderId ?: null, null, (string) ($event['id'] ?? ''), $type, null, $event);
switch ($type) {
case 'PAYMENT.CAPTURE.COMPLETED':
$tx = $orderId > 0 ? $this->transactionByOrder($orderId) : null;
if ($tx && ($resource['status'] ?? '') === 'COMPLETED') {
$this->markPaid((string) $tx->reference, (string) ($resource['amount']['value'] ?? ''),
(string) ($resource['amount']['currency_code'] ?? ''), (string) ($resource['id'] ?? ''), '', '', 'webhook');
}
break;
case 'CHECKOUT.ORDER.APPROVED':
// Kunde hat freigegeben, Capture macht die Rueckkehr oder der Abgleicher.
$tx = $orderId > 0 ? $this->transactionByOrder($orderId) : null;
if ($tx && $tx->status === 'open') {
$this->setTransactionStatus((string) $tx->reference, 'open', 'APPROVED laut Webhook');
}
break;
case 'PAYMENT.CAPTURE.DENIED':
case 'PAYMENT.CAPTURE.DECLINED':
$tx = $orderId > 0 ? $this->transactionByOrder($orderId) : null;
if ($tx) {
$this->markCancelled((string) $tx->reference, 'PayPal: ' . $type, 'webhook');
}
break;
case 'PAYMENT.CAPTURE.REFUNDED':
case 'PAYMENT.CAPTURE.REVERSED':
// Nur protokolliert; Erstattungen werden im Backend gepflegt.
break;
}
return 200;
}
/** @param array<string,string> $headers @param array<mixed> $event */
private function verifyWebhook(array $headers, array $event): bool
{
$webhookId = Secrets::get($this->db, $this->module(), 'webhook_id') ?? '';
if ($webhookId === '') {
\error_log('PayPal v2 Webhook: keine webhook_id hinterlegt -- Event abgelehnt.');
return false;
}
$payload = [
'transmission_id' => $headers['paypal-transmission-id'] ?? '',
'transmission_time' => $headers['paypal-transmission-time'] ?? '',
'cert_url' => $headers['paypal-cert-url'] ?? '',
'auth_algo' => $headers['paypal-auth-algo'] ?? '',
'transmission_sig' => $headers['paypal-transmission-sig'] ?? '',
'webhook_id' => $webhookId,
'webhook_event' => $event,
];
foreach (['transmission_id', 'transmission_time', 'cert_url', 'auth_algo', 'transmission_sig'] as $k) {
if ($payload[$k] === '') {
return false;
}
}
[$status, $body] = $this->client()->request('POST', '/v1/notifications/verify-webhook-signature', $payload);
return $status === 200 && (($body['verification_status'] ?? '') === 'SUCCESS');
}
// -------------------------------------------------------------- reconcile
public function reconcile(bool $dryRun = true): array
{
$out = [];
// Nach 10 Minuten nachschauen, nach 72 Stunden aufgeben (PayPal-Orders verfallen nach 3 Tagen).
foreach ($this->openTransactions(10, 72) as $tx) {
$ref = (string) $tx->reference;
try {
[$status, $body] = $this->client()->request('GET', '/v2/checkout/orders/' . \rawurlencode($ref));
} catch (\Throwable $e) {
$out[] = "$ref (Order {$tx->order_id}): API-Fehler " . $e->getMessage();
continue;
}
if ($status === 404) {
$out[] = "$ref (Order {$tx->order_id}): bei PayPal unbekannt -> storniert" . ($dryRun ? ' [dry-run]' : '');
if (!$dryRun) {
$this->markCancelled($ref, 'PayPal kennt den Vorgang nicht (404)', 'reconcile');
}
continue;
}
$pstatus = (string) ($body['status'] ?? '');
$cap = $this->normalize($body);
if ($pstatus === 'COMPLETED' && $cap['capture_status'] === 'COMPLETED') {
$out[] = "$ref (Order {$tx->order_id}): bei PayPal bezahlt, im Shop offen -> bezahlt setzen" . ($dryRun ? ' [dry-run]' : '');
if (!$dryRun) {
$this->markPaid($ref, $cap['amount'], $cap['currency'], $cap['capture_id'], $cap['payer_email'], $cap['payer_id'], 'reconcile');
}
} elseif ($pstatus === 'APPROVED') {
$out[] = "$ref (Order {$tx->order_id}): Kunde hat freigegeben, Rueckkehr verloren -> Capture nachholen" . ($dryRun ? ' [dry-run]' : '');
if (!$dryRun) {
try {
$c = $this->capture($ref, (int) $tx->order_id, (string) $tx->order_number);
if ($c['capture_status'] === 'COMPLETED') {
$this->markPaid($ref, $c['amount'], $c['currency'], $c['capture_id'], $c['payer_email'], $c['payer_id'], 'reconcile');
}
} catch (\Throwable $e) {
$out[] = " Capture fehlgeschlagen: " . $e->getMessage();
}
}
} elseif (\in_array($pstatus, ['VOIDED', 'EXPIRED'], true)) {
$out[] = "$ref (Order {$tx->order_id}): PayPal $pstatus -> storniert" . ($dryRun ? ' [dry-run]' : '');
if (!$dryRun) {
$this->markCancelled($ref, 'PayPal: ' . $pstatus, 'reconcile');
}
} else {
// CREATED / SAVED / PAYER_ACTION_REQUIRED: Kunde ist noch unterwegs oder hat das Fenster zugemacht.
$age = (\time() - \strtotime((string) $tx->created_at)) / 3600;
if ($age >= 24) {
$out[] = "$ref (Order {$tx->order_id}): seit " . \round($age) . " h $pstatus -> storniert" . ($dryRun ? ' [dry-run]' : '');
if (!$dryRun) {
$this->markCancelled($ref, 'PayPal ' . $pstatus . ' nach ' . \round($age) . ' h', 'reconcile');
}
} else {
$out[] = "$ref (Order {$tx->order_id}): $pstatus, " . \round($age, 1) . " h alt -> warten";
}
}
}
return $out;
}
// ----------------------------------------------------------------- intern
/** @return array{order_status:string,capture_id:string,capture_status:string,amount:string,currency:string,payer_email:string,payer_id:string,pending_reason:string} */
private function capture(string $ref, int $orderId, string $number): array
{
[$status, $body] = $this->client()->request('POST', '/v2/checkout/orders/' . \rawurlencode($ref) . '/capture', null,
['PayPal-Request-Id: capture-' . $ref, 'Prefer: return=representation']);
Log::write($this->module(), 'capture', $orderId, $number, $ref, (string) ($body['status'] ?? ''), $status, $body);
if ($status === 201 || $status === 200) {
return $this->normalize($body);
}
if ($status === 422 && $this->hasIssue($body, 'ORDER_ALREADY_CAPTURED')) {
[$s2, $b2] = $this->client()->request('GET', '/v2/checkout/orders/' . \rawurlencode($ref));
Log::write($this->module(), 'get', $orderId, $number, $ref, (string) ($b2['status'] ?? ''), $s2, $b2);
return $this->normalize($b2);
}
throw new \RuntimeException('Capture fehlgeschlagen (HTTP ' . $status . '): ' . \json_encode($body['details'] ?? $body));
}
/** @param array<mixed> $body */
private function normalize(array $body): array
{
$cap = $body['purchase_units'][0]['payments']['captures'][0] ?? [];
return [
'order_status' => (string) ($body['status'] ?? ''),
'capture_id' => (string) ($cap['id'] ?? ''),
'capture_status' => (string) ($cap['status'] ?? ''),
'amount' => (string) ($cap['amount']['value'] ?? ''),
'currency' => (string) ($cap['amount']['currency_code'] ?? ''),
'payer_email' => (string) ($body['payer']['email_address'] ?? ''),
'payer_id' => (string) ($body['payer']['payer_id'] ?? ''),
'pending_reason' => (string) ($cap['status_details']['reason'] ?? ''),
];
}
/** @param array<mixed> $body */
private function hasIssue(array $body, string $issue): bool
{
foreach (($body['details'] ?? []) as $d) {
if (($d['issue'] ?? '') === $issue) {
return true;
}
}
return false;
}
}