client = $client ?? new PayPalClient(); } /** * Erstellt eine PayPal-Order (intent=CAPTURE) und liefert Order-ID + Approve-URL. * * @return array{id:string,approve_url:string} */ public function create( float $amount, string $orderNumber, string $shopOrderId, string $returnUrl, string $cancelUrl ): array { $value = \number_format($amount, 2, '.', ''); $currency = (string) Config::get('currency', 'EUR'); $payload = [ 'intent' => 'CAPTURE', 'purchase_units' => [[ 'amount' => [ 'currency_code' => $currency, 'value' => $value, ], 'custom_id' => $shopOrderId, 'invoice_id' => $orderNumber . '-' . $shopOrderId, ]], 'application_context' => [ 'brand_name' => (string) Config::get('brand_name', 'Shop'), 'locale' => 'de-DE', 'landing_page' => 'NO_PREFERENCE', 'shipping_preference' => 'NO_SHIPPING', 'user_action' => 'PAY_NOW', 'return_url' => $returnUrl, 'cancel_url' => $cancelUrl, ], ]; // Idempotenz: gleicher Request-Id => PayPal legt keine Doppel-Order an. $requestId = 'create-' . $shopOrderId . '-' . \substr(\hash('sha256', $orderNumber . '|' . $value), 0, 16); [$status, $body] = $this->client->request('POST', '/v2/checkout/orders', $payload, [ 'PayPal-Request-Id: ' . $requestId, ]); if ($status !== 201 || empty($body['id'])) { throw new \RuntimeException('PayPal v2: Order-Anlage fehlgeschlagen (HTTP ' . $status . '): ' . \json_encode($body)); } $approve = ''; foreach (($body['links'] ?? []) as $link) { $rel = $link['rel'] ?? ''; if ($rel === 'approve' || $rel === 'payer-action') { $approve = (string) ($link['href'] ?? ''); break; } } if ($approve === '') { throw new \RuntimeException('PayPal v2: Kein Approve-Link in der Antwort.'); } return ['id' => (string) $body['id'], 'approve_url' => $approve]; } /** @return array */ public function get(string $paypalOrderId): array { [$status, $body] = $this->client->request('GET', '/v2/checkout/orders/' . \rawurlencode($paypalOrderId)); if ($status !== 200) { throw new \RuntimeException('PayPal v2: Order-Abruf fehlgeschlagen (HTTP ' . $status . ').'); } return $body; } /** * Captured eine vom Kunden freigegebene Order. Idempotent: * Ist bereits gecaptured (Doppel-Return / Webhook-Race), wird der bestehende * Zustand aus der Order gelesen statt einen Fehler zu werfen. * * @return array{order_status:string,capture_id:string,capture_status:string,amount:string,currency:string,payer_email:string,payer_id:string,raw:array} */ public function capture(string $paypalOrderId): array { [$status, $body] = $this->client->request( 'POST', '/v2/checkout/orders/' . \rawurlencode($paypalOrderId) . '/capture', null, [ 'PayPal-Request-Id: capture-' . $paypalOrderId, 'Prefer: return=representation', ] ); if ($status === 201 || $status === 200) { return $this->normalize($body); } if ($status === 422 && $this->hasIssue($body, 'ORDER_ALREADY_CAPTURED')) { return $this->normalize($this->get($paypalOrderId)); } throw new \RuntimeException('PayPal v2: Capture fehlgeschlagen (HTTP ' . $status . '): ' . \json_encode($body)); } /** * Vereinheitlicht Capture- bzw. Order-Antwort auf die Felder, die der Shop braucht. * * @param array $body * @return array{order_status:string,capture_id:string,capture_status:string,amount:string,currency:string,payer_email:string,payer_id:string,raw:array} */ public 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'] ?? ''), 'raw' => $body, ]; } /** @param array $body */ private function hasIssue(array $body, string $issue): bool { foreach (($body['details'] ?? []) as $d) { if (($d['issue'] ?? '') === $issue) { return true; } } return false; } }