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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user