clientId = $config['client_id'] ?? ''; $this->clientSecret = $config['client_secret'] ?? ''; $this->env = $config['env'] ?? 'sandbox'; $this->caBundle = !empty($config['curl_ca_bundle']) ? $config['curl_ca_bundle'] : null; $this->baseApi = $this->env === 'live' ? 'https://api-m.paypal.com' : 'https://api-m.sandbox.paypal.com'; if (!$this->clientId || !$this->clientSecret) { throw new RuntimeException('Missing PayPal client_id/client_secret in config.php'); } } /** * cURL TLS options. We always verify the peer/host (never disable it — that * would be insecure and is rejected in production). When a CA bundle path is * configured we point cURL at it, which fixes environments that lack a * system-wide curl.cainfo (e.g. stock Windows PHP). */ private function tlsOpts(): array { $opts = [ CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, ]; if ($this->caBundle && is_file($this->caBundle)) { $opts[CURLOPT_CAINFO] = $this->caBundle; } return $opts; } public function getAccessToken(): string { $url = $this->baseApi . '/v1/oauth2/token'; $headers = [ 'Accept: application/json', 'Accept-Language: en_US', ]; $body = 'grant_type=client_credentials'; $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_HTTPHEADER => $headers, CURLOPT_POST => true, CURLOPT_POSTFIELDS => $body, CURLOPT_RETURNTRANSFER => true, CURLOPT_USERPWD => $this->clientId . ':' . $this->clientSecret, ] + $this->tlsOpts()); $resp = curl_exec($ch); if ($resp === false) { throw new RuntimeException('OAuth token request failed: ' . curl_error($ch)); } $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); $data = json_decode($resp, true); if ($code < 200 || $code >= 300 || !isset($data['access_token'])) { throw new RuntimeException('OAuth token response error (' . $code . '): ' . $resp); } return $data['access_token']; } public function api(string $method, string $path, array $payload = null, array $headers = []): array { $url = $this->baseApi . $path; $accessToken = $this->getAccessToken(); $defaultHeaders = [ 'Content-Type: application/json', 'Authorization: Bearer ' . $accessToken, 'PayPal-Partner-Attribution-Id: EZSSOFTWAREINC_SP_PPCP', ]; $allHeaders = array_merge($defaultHeaders, $headers); $debugId = null; $ch = curl_init($url); $opts = [ CURLOPT_HTTPHEADER => $allHeaders, CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => strtoupper($method), CURLOPT_HEADERFUNCTION => function ($ch, $header) use (&$debugId) { if (stripos($header, 'paypal-debug-id:') === 0) { $debugId = trim(explode(':', $header, 2)[1]); } return strlen($header); }, ]; if ($payload !== null) { $opts[CURLOPT_POSTFIELDS] = json_encode($payload); } curl_setopt_array($ch, $opts + $this->tlsOpts()); $resp = curl_exec($ch); if ($resp === false) { throw new RuntimeException('API request failed: ' . curl_error($ch)); } $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if (function_exists('writeLog')) { writeLog('PayPal API ' . strtoupper($method) . ' ' . $path . ' => HTTP ' . $code . ' debug-id=' . ($debugId ?? 'n/a')); } $data = json_decode($resp, true); if ($data === null && $resp !== '' && $resp !== 'null') { $data = ['raw' => $resp]; } return ['status' => $code, 'body' => $data, 'debug_id' => $debugId]; } public function buildAuthAssertionJwt(string $merchantId): string { $b64url = fn(string $v) => rtrim(strtr(base64_encode($v), '+/', '-_'), '='); $header = $b64url(json_encode(['alg' => 'none'])); $payload = $b64url(json_encode(['iss' => $this->clientId, 'payer_id' => $merchantId])); return $header . '.' . $payload . '.'; } public function createOrder(string $merchantId, array $payload, string $fraudnetId = ''): array { $headers = [ 'PayPal-Auth-Assertion: ' . $this->buildAuthAssertionJwt($merchantId), 'PayPal-Request-Id: ' . bin2hex(random_bytes(16)), // idempotency — prevents duplicate orders on network retry ]; if ($fraudnetId !== '') { $headers[] = 'paypal-client-metadata-id: ' . $fraudnetId; } return $this->api('POST', '/v2/checkout/orders', $payload, $headers); } public function captureOrder(string $merchantId, string $orderId): array { return $this->api( 'POST', '/v2/checkout/orders/' . urlencode($orderId) . '/capture', null, [ 'PayPal-Auth-Assertion: ' . $this->buildAuthAssertionJwt($merchantId), 'Prefer: return=representation', // ensures capture ID is in the response body ] ); } /** * Refund a captured payment (full or partial). * * @param array $refundData Optional. Keys: 'amount' (array with 'value'+'currency_code'), * 'note_to_payer' (string), 'invoice_id' (string). * Pass [] for a full refund. * @param string $idempotencyKey Caller-supplied key so safe retries don't double-refund. * Auto-generated if empty. */ public function refundCapture( string $merchantId, string $captureId, array $refundData = [], string $idempotencyKey = '' ): array { if ($idempotencyKey === '') { $idempotencyKey = bin2hex(random_bytes(16)); } return $this->api( 'POST', '/v2/payments/captures/' . urlencode($captureId) . '/refund', empty($refundData) ? null : $refundData, [ 'PayPal-Auth-Assertion: ' . $this->buildAuthAssertionJwt($merchantId), 'PayPal-Request-Id: ' . $idempotencyKey, ] ); } public function getSellerStatus(string $partnerMerchantId, string $merchantId): array { $path = '/v1/customer/partners/' . urlencode($partnerMerchantId) . '/merchant-integrations/' . urlencode($merchantId); $resp = $this->api('GET', $path); $body = $resp['body'] ?? []; $emailConfirmed = (bool)($body['primary_email_confirmed'] ?? false); $paymentsReceivable = (bool)($body['payments_receivable'] ?? false); $oauthIntegrations = $body['oauth_integrations'] ?? []; $oauthOk = !empty($oauthIntegrations); $sellerEmail = $body['primary_email'] ?? null; $products = $body['products'] ?? []; $capabilities = $body['capabilities'] ?? []; // ── ACDC (Advanced Card Processing) readiness ───────────────────── // Guide: check PPCP_CUSTOM vetting + CUSTOM_CARD_PROCESSING capability $acdcVettingStatus = null; foreach ($products as $product) { if (($product['name'] ?? '') === 'PPCP_CUSTOM') { $acdcVettingStatus = strtoupper($product['vetting_status'] ?? ''); break; } } $customCardCap = null; foreach ($capabilities as $cap) { if (($cap['name'] ?? '') === 'CUSTOM_CARD_PROCESSING') { $customCardCap = $cap; break; } } $acdcCapActive = ($customCardCap['status'] ?? '') === 'ACTIVE'; $acdcNoLimits = !isset($customCardCap['limits']); $acdcReady = $acdcVettingStatus === 'SUBSCRIBED' && $acdcCapActive && $acdcNoLimits; $acdcDenied = $acdcVettingStatus === 'DENIED'; $acdcPending = $acdcVettingStatus !== null && !$acdcReady && !$acdcDenied; $issues = []; if (!$emailConfirmed) { $issues[] = 'PRIMARY_EMAIL_CONFIRMED is false — seller must confirm their PayPal email before receiving payments.'; } if (!$paymentsReceivable) { $issues[] = 'PAYMENTS_RECEIVABLE is false — there may be a restriction on the seller\'s PayPal account.'; } if (!$oauthOk) { $issues[] = 'OAUTH_INTEGRATIONS is empty — seller has not granted third-party permissions.'; } if ($acdcDenied) { $issues[] = 'ACDC DENIED — seller can only use PayPal Wallet buttons, not Advanced Card Processing.'; } if ($acdcPending) { $issues[] = 'ACDC PENDING (vetting_status=' . $acdcVettingStatus . ') — waiting for PayPal to approve Advanced Card Processing.'; } return [ 'status' => $resp['status'], 'debug_id' => $resp['debug_id'], 'paypal_ready' => $emailConfirmed && $paymentsReceivable && $oauthOk, 'email_confirmed' => $emailConfirmed, 'payments_receivable' => $paymentsReceivable, 'oauth_ok' => $oauthOk, 'seller_email' => $sellerEmail, 'issues' => $issues, // ACDC fields 'acdc_ready' => $acdcReady, 'acdc_vetting_status' => $acdcVettingStatus, 'acdc_denied' => $acdcDenied, 'acdc_pending' => $acdcPending, ]; } public function getBaseApi(): string { return $this->baseApi; } }