$clientId, 'api_key' => $clientSecret ]); if (is_wp_error($result)) { return $result; } return true; } /** * @param string $path API path ex: checkout/orders (Required) * @param string $version API version ex: v1, v2 (Optional) * @param string $method HTTP method ex: GET, POST, DELETE (Optional) * @param array $args API request arguments (Optional) * @param string $mode PayPal mode ex: live, test (Optional) * @param array $extraHeaders Additional request headers ex: PayPal-Request-Id (Optional) * @return mixed $response API response * @throws \Exception if error occurs */ public static function makeRequest($path, $version = 'v1', $method = 'POST', $args = [], $mode = '', $extraHeaders = []) { if (empty($path)) { return new \WP_Error('invalid_path', esc_html__('API path is required', 'fluent-cart')); } $settings = self::getPayPalSettings(); if (!$mode) { $mode = $settings->getMode(); } $paypal_api_url = self::getAPIUrl($mode) . '/' . $version . '/' . $path; $accessToken = self::getAccessToken($mode); if (is_wp_error($accessToken)) { return $accessToken; } //unset auth asertion headers, if platform app not connected if ($settings->getProviderType() === 'api_keys') { $headers = array( 'Authorization' => 'Bearer ' . $accessToken, 'Content-Type' => 'application/json', 'Accept' => 'application/json', ); } else { $authAssertion = static::generatePayPalAuthAssertion( $settings->getPublicKey($mode), static::getAccountId($settings, $mode) ); $headers = array( 'Authorization' => 'Bearer ' . $accessToken, 'PayPal-Partner-Attribution-ID: FLUENTCART_SP_PPCP', 'Content-Type' => 'application/json', 'Accept' => 'application/json', 'PayPal-Auth-Assertion' => $authAssertion ); } if ('GET' === $method) { // if args is not empty then append it to the url if (!empty($args)) { $paypal_api_url .= '?' . http_build_query($args); } return self::getRequest($paypal_api_url, $accessToken, $mode); } if ('POST' === $method) { $headers['Prefer'] = 'return=representation'; } foreach ($extraHeaders as $headerKey => $headerValue) { $headers[$headerKey] = $headerValue; } $response = wp_remote_post($paypal_api_url, [ 'headers' => $headers, 'method' => $method, // An empty array encodes to a literal [], which PayPal rejects // with MALFORMED_REQUEST_JSON — it requires a {} body. 'body' => json_encode($args ?: new \stdClass()) ]); if (is_wp_error($response)) { return new \WP_Error('general_error', $response->get_error_message(), $response); } $http_code = wp_remote_retrieve_response_code($response); $body = json_decode(wp_remote_retrieve_body($response), true); if ($http_code > 299) { $code = 'general_error'; $message = 'PayPal General Error'; if (isset($body['error'])) { $code = $body['error']; } if ($code === 'invalid_token') { fluent_cart_update_option('_paypal_access_token_' . $mode, []); } if (!empty($body['message'])) { $message = $body['message']; if (isset($body['details'])) { $message = Arr::get($body, 'details.0.issue', $message); } } return new \WP_Error($code, $message, $body); } // it's success response with no content if ($http_code == 204) { return [ 'status' => 'success', 'body' => 'No Content', 'code' => 204 ]; } return $body; } public static function getResource($path, $data = [], $mode = '') { return self::makeRequest($path, 'v1', 'GET', $data, $mode); } public static function createResource($path, $data = [], $mode = '') { return self::makeRequest($path, 'v1', 'POST', $data, $mode); } public static function retrieveAccount($settings, $mode = '') { $paypalSettings = self::getPayPalSettings(); if (empty($mode)) { $mode = $paypalSettings->getMode(); } $settings = $paypalSettings->settings; $clientId = Arr::get($settings, $mode . '_client_id'); $secretId = Arr::get($settings, $mode . '_client_secret'); $merchantId = Arr::get($settings, $mode . '_account_id'); $email = Arr::get($settings, $mode . '_email_address'); $accountType = Arr::get($settings, $mode . '_account_status'); if (!$clientId || !$secretId || !$merchantId) { return false; } return [ 'account_id' => $merchantId, 'display_name' => 'Merchant ID: ' . $merchantId, 'email' => $email, 'account_type' => $accountType ]; } public static function getRequest($url, $accessToken = null, $mode = '') { if (!$accessToken) { $accessToken = self::getAccessToken($mode); if (is_wp_error($accessToken)) { return $accessToken; } } $headers = array( 'Authorization' => 'Bearer ' . $accessToken, 'Content-Type' => 'application/json', 'Accept' => 'application/json', 'PayPal-Partner-Attribution-ID: FLUENTCART_SP_PPCP' ); $response = wp_safe_remote_get($url, [ 'headers' => $headers ]); if (is_wp_error($response)) { return new \WP_Error('general_error', $response->get_error_message(), $response); } $http_code = wp_remote_retrieve_response_code($response); $body = json_decode(wp_remote_retrieve_body($response), true); if ($http_code == 200) { return $body; } // it's success response with no content if ($http_code == 204) { return [ 'status' => 'success', 'body' => 'No Content', 'code' => 204 ]; } if ($http_code > 299) { $code = 'general_error'; if (isset($body['error'])) { $code = $body['error']; } if ($code === 'invalid_token' && $mode) { fluent_cart_update_option('_paypal_access_token_' . $mode, []); } if (!empty($body['message'])) { $message = $body['message']; if (isset($body['details'])) { $message = Arr::get($body, 'details.0.description'); } } return new \WP_Error($code, $message, $body); } $message = $body['message'] ?? 'PayPal General Error'; if (isset($body['details'])) { $message = $body['details'][0]['issue']; } return new \WP_Error($http_code, $message, $body); } public static function createOrder($purchaseUnit, $extraBody = [], $extraHeaders = []) { $body = [ 'intent' => 'CAPTURE', 'purchase_units' => [$purchaseUnit], 'application_context' => ['shipping_preference' => 'NO_SHIPPING'], ]; if ($extraBody) { // The legacy application_context cannot be combined with the // payment_source object (vaulting / merchant-initiated charges) — // shipping preference then rides experience_context instead. if (isset($extraBody['payment_source'])) { unset($body['application_context']); } $body = array_merge($body, $extraBody); } return self::makeRequest('checkout/orders', 'v2', 'POST', $body, '', $extraHeaders); } public static function verifyPayment($paymentId) { return self::makeRequest('checkout/orders/' . $paymentId, 'v2', 'GET'); } /** * Captures an APPROVED PayPal order server-side, moving the money. FluentCart creates * the order with intent=CAPTURE but the buyer only AUTHORIZES it in the popup; the funds * are not captured until this call runs. The server must never trust the browser to have * captured — an APPROVED-but-uncaptured order means PayPal is holding $0. * * Capture MOVES MONEY, so it carries a PayPal-Request-Id for idempotency (see * .claude/skills/coding-rules/payment-idempotency.md). The id is keyed on the PayPal * order id, which is stable and unique per checkout attempt: a duplicate capture of the * same order replays the cached response instead of double-capturing, while capturing an * already-captured order returns 422 ORDER_ALREADY_CAPTURED (the caller re-GETs and * continues). PayPal retains request ids for 6h — longer than the 3h order lifetime — so * a keyed capture never replays a dead id. * * @param string $paymentId The PayPal order id (payId) * @return mixed API response (the captured order) or WP_Error */ public static function captureOrder($paymentId) { return self::makeRequest('checkout/orders/' . $paymentId . '/capture', 'v2', 'POST', [], '', [ 'PayPal-Request-Id' => 'fct_paypal_capture_' . md5($paymentId), ]); } public function verifySubscription($subscriptionId, $mode = '') { return self::makeRequest('billing/subscriptions/' . $subscriptionId, 'v1', 'GET', [], $mode); } /** * Retrieves PayPal access token using WP_HTTP. * * @param string $mode The PayPal mode (live/sandbox). * @param array $args Additional arguments including public_key and api_key. * @return string|\WP_Error Access token on success, WP_Error on failure. */ private static function getAccessToken($mode = '', $args = []) { if (!$mode) { $mode = (new PayPalSettingsBase())->getMode(); } static $accessToken; // Check for cached token if (!$args) { if ($accessToken) { return $accessToken; } $existingToken = fluent_cart_get_option('_paypal_access_token_' . $mode); if ($existingToken && isset($existingToken['expires_at']) && $existingToken['expires_at'] > time()) { $accessToken = $existingToken['access_token']; return $accessToken; } } $apiUrl = self::getAuthAPI($mode); // Prepare headers $headers = [ 'Accept' => 'application/json', 'Accept-Language' => 'en_US', 'PayPal-Partner-Attribution-ID' => 'FLUENTCART_SP_PPCP' ]; // Prepare body $body = [ 'grant_type' => 'client_credentials' ]; // Get credentials $publicKey = !empty($args['public_key']) ? $args['public_key'] : self::getPayPalSettings()->getPublicKey($mode); $apiKey = !empty($args['api_key']) ? $args['api_key'] : self::getPayPalSettings()->getApiKey($mode); // Add Basic Auth header $headers['Authorization'] = 'Basic ' . base64_encode($publicKey . ':' . $apiKey); // Make HTTP request $response = wp_remote_post($apiUrl, [ 'headers' => $headers, 'body' => $body, 'timeout' => 30 ]); // Check for WP_Error if (is_wp_error($response)) { return $response; } // Get response code and body $http_code = wp_remote_retrieve_response_code($response); $response_body = wp_remote_retrieve_body($response); if ($http_code === 200) { $response_data = json_decode($response_body, true); $accessToken = $response_data['access_token']; $data = [ 'access_token' => $accessToken, 'expires_at' => time() + (int)$response_data['expires_in'] - 120 // Subtract 2 minutes ]; fluent_cart_update_option('_paypal_access_token_' . $mode, $data); return $accessToken; } $error = json_decode($response_body, true); $errorMessage = $error['error_description'] ?? $error['error'] ?? esc_html__('Failed to retrieve access token from PayPal.', 'fluent-cart'); return new \WP_Error( 'access_token_error', $errorMessage, $error ); } /** * Browser-safe id token for the JS SDK vault (save-without-purchase) flow — * rendered as the SDK script's data-user-id-token attribute. Short-lived * (~15 min), so it is generated per checkout page render and never cached. * * @param string $mode The PayPal mode (live/test). * @return string|\WP_Error */ public static function getUserIdToken($mode = '') { if (!$mode) { $mode = self::getPayPalSettings()->getMode(); } $headers = [ 'Accept' => 'application/json', 'PayPal-Partner-Attribution-ID' => 'FLUENTCART_SP_PPCP', 'Authorization' => 'Basic ' . base64_encode( self::getPayPalSettings()->getPublicKey($mode) . ':' . self::getPayPalSettings()->getApiKey($mode) ), ]; $response = wp_remote_post(self::getAuthAPI($mode), [ 'headers' => $headers, 'body' => [ 'grant_type' => 'client_credentials', 'response_type' => 'id_token' ], 'timeout' => 30 ]); if (is_wp_error($response)) { return $response; } $body = json_decode(wp_remote_retrieve_body($response), true); if (wp_remote_retrieve_response_code($response) !== 200 || empty($body['id_token'])) { return new \WP_Error('id_token_error', __('Could not generate a PayPal id token.', 'fluent-cart'), $body); } return $body['id_token']; } /** * Generate a PayPal-Auth-Assertion JWT header (unsigned, alg=none). * * @param string $clientId Your platform's REST API client ID * @param string $sellerPayerId Seller's PayPal payer_id (preferred) or email * @return string The PayPal‑Auth‑Assertion header value */ private static function generatePayPalAuthAssertion($clientId, $sellerPayerId) { $header = ['alg' => 'none']; $encodedHeader = rtrim(strtr(base64_encode(json_encode($header)), '+/', '-_'), '='); $payload = [ 'iss' => $clientId, 'payer_id' => $sellerPayerId ]; $encodedPayload = rtrim(strtr(base64_encode(json_encode($payload)), '+/', '-_'), '='); return "{$encodedHeader}.{$encodedPayload}."; } private static function getAccountId($settings, $mode) { return Arr::get($settings->settings, $mode . '_account_id'); } public static function verifyWebhookSignature($body) { $verify_url = ((new PayPalSettingsBase())->getMode() === 'live') ? self::LIVE_VERIFYING_URL : self::TEST_VERIFYING_URL; $accessToken = self::getAccessToken(); $args = array( 'headers' => array( 'Content-Type' => 'application/json', 'Authorization' => 'Bearer ' . $accessToken, ), 'body' => json_encode($body), 'timeout' => 30, 'data_format' => 'body' ); $response = wp_remote_post($verify_url, $args); if (is_wp_error($response)) { return $response; } return $response; } }