PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.5.3
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.5.3
1.6.5 1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 1.3.20 1.3.19 trunk All 48 releases
fluent-cart / app / Modules / PaymentMethods / PayPalGateway / API / API.php

API.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.5.3, at app/Modules/PaymentMethods/PayPalGateway/API/API.php

445 lines 14.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentCart\App\Modules\PaymentMethods\PayPalGateway\API;
4
5 use FluentCart\App\Modules\PaymentMethods\PayPalGateway\PayPalSettingsBase;
6 use FluentCart\Framework\Support\Arr;
7
8 class API
9 {
10
11 private static $settings = null;
12 private const TEST_API_URL = 'https://api-m.sandbox.paypal.com';
13 private const LIVE_API_URL = 'https://api.paypal.com';
14
15 private const TEST_VERIFYING_URL = 'https://api-m.sandbox.paypal.com/v1/notifications/verify-webhook-signature';
16 private const LIVE_VERIFYING_URL = 'https://api-m.paypal.com/v1/notifications/verify-webhook-signature';
17
18 private static function getPayPalSettings()
19 {
20 if (!self::$settings) {
21 self::$settings = new PayPalSettingsBase();
22 }
23
24 return self::$settings;
25 }
26
27 public static function getAPIUrl($mode = 'test'): string
28 {
29 if ($mode === 'test') {
30 return self::TEST_API_URL;
31 }
32 return self::LIVE_API_URL;
33 }
34
35 protected static function getAuthAPI($mode = 'test')
36 {
37 if ($mode === 'live') {
38 return self::LIVE_API_URL . '/v1/oauth2/token';
39 }
40
41 return self::TEST_API_URL . '/v1/oauth2/token';
42 }
43
44 public static function validateCredentials($clientId, $clientSecret, $mode = 'test')
45 {
46 $result = self::getAccessToken($mode, [
47 'public_key' => $clientId,
48 'api_key' => $clientSecret
49 ]);
50
51 if (is_wp_error($result)) {
52 return $result;
53 }
54
55 return true;
56 }
57
58 /**
59 * @param string $path API path ex: checkout/orders (Required)
60 * @param string $version API version ex: v1, v2 (Optional)
61 * @param string $method HTTP method ex: GET, POST, DELETE (Optional)
62 * @param array $args API request arguments (Optional)
63 * @param string $mode PayPal mode ex: live, test (Optional)
64 * @param string|null $requestId Idempotency id sent as PayPal-Request-Id (Optional)
65 * @return mixed $response API response
66 * @throws \Exception if error occurs
67 */
68 public static function makeRequest($path, $version = 'v1', $method = 'POST', $args = [], $mode = '', $requestId = null)
69 {
70 if (empty($path)) {
71 return new \WP_Error('invalid_path', esc_html__('API path is required', 'fluent-cart'));
72 }
73
74 $settings = self::getPayPalSettings();
75
76 if (!$mode) {
77 $mode = $settings->getMode();
78 }
79
80 $paypal_api_url = self::getAPIUrl($mode) . '/' . $version . '/' . $path;
81
82 $accessToken = self::getAccessToken($mode);
83
84 if (is_wp_error($accessToken)) {
85 return $accessToken;
86 }
87
88
89 //unset auth asertion headers, if platform app not connected
90 if ($settings->getProviderType() === 'api_keys') {
91 $headers = array(
92 'Authorization' => 'Bearer ' . $accessToken,
93 'Content-Type' => 'application/json',
94 'Accept' => 'application/json',
95 );
96 } else {
97 $authAssertion = static::generatePayPalAuthAssertion(
98 $settings->getPublicKey($mode),
99 static::getAccountId($settings, $mode)
100 );
101
102 $headers = array(
103 'Authorization' => 'Bearer ' . $accessToken,
104 'PayPal-Partner-Attribution-ID: FLUENTCART_SP_PPCP',
105 'Content-Type' => 'application/json',
106 'Accept' => 'application/json',
107 'PayPal-Auth-Assertion' => $authAssertion
108 );
109 }
110
111
112 if ('GET' === $method) {
113 // if args is not empty then append it to the url
114 if (!empty($args)) {
115 $paypal_api_url .= '?' . http_build_query($args);
116 }
117
118 return self::getRequest($paypal_api_url, $accessToken, $mode);
119 }
120
121 if ('POST' === $method) {
122 $headers['Prefer'] = 'return=representation';
123
124 // PayPal dedupes POSTs by PayPal-Request-Id — a duplicate returns the
125 // ORIGINAL object. Unlike Stripe, a reused id with a changed body is NOT
126 // rejected (the new body is silently ignored), so callers must fingerprint
127 // charge-material params into the id itself.
128 if ($requestId) {
129 $headers['PayPal-Request-Id'] = $requestId;
130 }
131 }
132
133 $response = wp_remote_post($paypal_api_url, [
134 'headers' => $headers,
135 'method' => $method,
136 'body' => json_encode($args)
137 ]);
138
139 if (is_wp_error($response)) {
140 return new \WP_Error('general_error', $response->get_error_message(), $response);
141 }
142
143 $http_code = wp_remote_retrieve_response_code($response);
144 $body = json_decode(wp_remote_retrieve_body($response), true);
145
146 if ($http_code > 299) {
147 $code = 'general_error';
148 $message = 'PayPal General Error';
149 if (isset($body['error'])) {
150 $code = $body['error'];
151 }
152
153 if ($code === 'invalid_token') {
154 fluent_cart_update_option('_paypal_access_token_' . $mode, []);
155 }
156
157 if (!empty($body['message'])) {
158 $message = $body['message'];
159 if (isset($body['details'])) {
160 $message = Arr::get($body, 'details.0.issue', $message);
161 }
162 }
163
164 return new \WP_Error($code, $message, $body);
165 }
166
167 // it's success response with no content
168 if ($http_code == 204) {
169 return [
170 'status' => 'success',
171 'body' => 'No Content',
172 'code' => 204
173 ];
174 }
175
176 return $body;
177 }
178
179 public static function getResource($path, $data = [], $mode = '')
180 {
181 return self::makeRequest($path, 'v1', 'GET', $data, $mode);
182 }
183
184 public static function createResource($path, $data = [], $mode = '')
185 {
186 return self::makeRequest($path, 'v1', 'POST', $data, $mode);
187 }
188
189 public static function retrieveAccount($settings, $mode = '')
190 {
191 $paypalSettings = self::getPayPalSettings();
192 if (empty($mode)) {
193 $mode = $paypalSettings->getMode();
194 }
195
196 $settings = $paypalSettings->settings;
197
198 $clientId = Arr::get($settings, $mode . '_client_id');
199 $secretId = Arr::get($settings, $mode . '_client_secret');
200 $merchantId = Arr::get($settings, $mode . '_account_id');
201 $email = Arr::get($settings, $mode . '_email_address');
202 $accountType = Arr::get($settings, $mode . '_account_status');
203
204 if (!$clientId || !$secretId || !$merchantId) {
205 return false;
206 }
207
208 return [
209 'account_id' => $merchantId,
210 'display_name' => 'Merchant ID: ' . $merchantId,
211 'email' => $email,
212 'account_type' => $accountType
213 ];
214 }
215
216 public static function getRequest($url, $accessToken = null, $mode = '')
217 {
218 if (!$accessToken) {
219 $accessToken = self::getAccessToken($mode);
220
221 if (is_wp_error($accessToken)) {
222 return $accessToken;
223 }
224 }
225
226 $headers = array(
227 'Authorization' => 'Bearer ' . $accessToken,
228 'Content-Type' => 'application/json',
229 'Accept' => 'application/json',
230 'PayPal-Partner-Attribution-ID: FLUENTCART_SP_PPCP'
231 );
232
233 $response = wp_safe_remote_get($url, [
234 'headers' => $headers
235 ]);
236
237 if (is_wp_error($response)) {
238 return new \WP_Error('general_error', $response->get_error_message(), $response);
239 }
240
241 $http_code = wp_remote_retrieve_response_code($response);
242 $body = json_decode(wp_remote_retrieve_body($response), true);
243
244 if ($http_code == 200) {
245 return $body;
246 }
247
248 // it's success response with no content
249 if ($http_code == 204) {
250 return [
251 'status' => 'success',
252 'body' => 'No Content',
253 'code' => 204
254 ];
255 }
256
257 if ($http_code > 299) {
258 $code = 'general_error';
259 if (isset($body['error'])) {
260 $code = $body['error'];
261 }
262
263
264 if ($code === 'invalid_token' && $mode) {
265 fluent_cart_update_option('_paypal_access_token_' . $mode, []);
266 }
267
268 if (!empty($body['message'])) {
269 $message = $body['message'];
270 if (isset($body['details'])) {
271 $message = Arr::get($body, 'details.0.description');
272 }
273 }
274 return new \WP_Error($code, $message, $body);
275 }
276
277 $message = $body['message'] ?? 'PayPal General Error';
278
279 if (isset($body['details'])) {
280 $message = $body['details'][0]['issue'];
281 }
282
283 return new \WP_Error($http_code, $message, $body);
284 }
285
286 public static function createOrder($purchaseUnit, $requestId = null)
287 {
288 return self::makeRequest('checkout/orders', 'v2', 'POST', [
289 'intent' => 'CAPTURE',
290 'purchase_units' => [$purchaseUnit],
291 'application_context' => ['shipping_preference' => 'NO_SHIPPING'],
292 ], '', $requestId);
293 }
294
295 public static function verifyPayment($paymentId)
296 {
297 return self::makeRequest('checkout/orders/' . $paymentId, 'v2', 'GET');
298 }
299
300 public function verifySubscription($subscriptionId, $mode = '')
301 {
302 return self::makeRequest('billing/subscriptions/' . $subscriptionId, 'v1', 'GET', [], $mode);
303 }
304
305 /**
306 * Retrieves PayPal access token using WP_HTTP.
307 *
308 * @param string $mode The PayPal mode (live/sandbox).
309 * @param array $args Additional arguments including public_key and api_key.
310 * @return string|\WP_Error Access token on success, WP_Error on failure.
311 */
312 private static function getAccessToken($mode = '', $args = [])
313 {
314 if (!$mode) {
315 $mode = (new PayPalSettingsBase())->getMode();
316 }
317
318 static $accessToken;
319
320 // Check for cached token
321 if (!$args) {
322 if ($accessToken) {
323 return $accessToken;
324 }
325
326 $existingToken = fluent_cart_get_option('_paypal_access_token_' . $mode);
327 if ($existingToken && isset($existingToken['expires_at']) && $existingToken['expires_at'] > time()) {
328 $accessToken = $existingToken['access_token'];
329 return $accessToken;
330 }
331 }
332
333 $apiUrl = self::getAuthAPI($mode);
334
335 // Prepare headers
336 $headers = [
337 'Accept' => 'application/json',
338 'Accept-Language' => 'en_US',
339 'PayPal-Partner-Attribution-ID' => 'FLUENTCART_SP_PPCP'
340 ];
341
342 // Prepare body
343 $body = [
344 'grant_type' => 'client_credentials'
345 ];
346
347 // Get credentials
348 $publicKey = !empty($args['public_key']) ? $args['public_key'] : self::getPayPalSettings()->getPublicKey($mode);
349 $apiKey = !empty($args['api_key']) ? $args['api_key'] : self::getPayPalSettings()->getApiKey($mode);
350
351 // Add Basic Auth header
352 $headers['Authorization'] = 'Basic ' . base64_encode($publicKey . ':' . $apiKey);
353
354 // Make HTTP request
355 $response = wp_remote_post($apiUrl, [
356 'headers' => $headers,
357 'body' => $body,
358 'timeout' => 30
359 ]);
360
361 // Check for WP_Error
362 if (is_wp_error($response)) {
363 return $response;
364 }
365
366 // Get response code and body
367 $http_code = wp_remote_retrieve_response_code($response);
368 $response_body = wp_remote_retrieve_body($response);
369
370 if ($http_code === 200) {
371 $response_data = json_decode($response_body, true);
372 $accessToken = $response_data['access_token'];
373
374 $data = [
375 'access_token' => $accessToken,
376 'expires_at' => time() + (int)$response_data['expires_in'] - 120 // Subtract 2 minutes
377 ];
378
379 fluent_cart_update_option('_paypal_access_token_' . $mode, $data);
380
381 return $accessToken;
382 }
383
384 $error = json_decode($response_body, true);
385 $errorMessage = $error['error_description'] ?? $error['error'] ?? esc_html__('Failed to retrieve access token from PayPal.', 'fluent-cart');
386
387 return new \WP_Error(
388 'access_token_error',
389 $errorMessage,
390 $error
391 );
392 }
393
394 /**
395 * Generate a PayPal-Auth-Assertion JWT header (unsigned, alg=none).
396 *
397 * @param string $clientId Your platform's REST API client ID
398 * @param string $sellerPayerId Seller's PayPal payer_id (preferred) or email
399 * @return string The PayPal‑Auth‑Assertion header value
400 */
401 private static function generatePayPalAuthAssertion($clientId, $sellerPayerId)
402 {
403 $header = ['alg' => 'none'];
404 $encodedHeader = rtrim(strtr(base64_encode(json_encode($header)), '+/', '-_'), '=');
405 $payload = [
406 'iss' => $clientId,
407 'payer_id' => $sellerPayerId
408 ];
409 $encodedPayload = rtrim(strtr(base64_encode(json_encode($payload)), '+/', '-_'), '=');
410 return "{$encodedHeader}.{$encodedPayload}.";
411 }
412
413 private static function getAccountId($settings, $mode)
414 {
415 return Arr::get($settings->settings, $mode . '_account_id');
416 }
417
418 public static function verifyWebhookSignature($body)
419 {
420 $verify_url = ((new PayPalSettingsBase())->getMode() === 'live')
421 ? self::LIVE_VERIFYING_URL
422 : self::TEST_VERIFYING_URL;
423
424 $accessToken = self::getAccessToken();
425
426 $args = array(
427 'headers' => array(
428 'Content-Type' => 'application/json',
429 'Authorization' => 'Bearer ' . $accessToken,
430 ),
431 'body' => json_encode($body),
432 'timeout' => 30,
433 'data_format' => 'body'
434 );
435
436 $response = wp_remote_post($verify_url, $args);
437
438 if (is_wp_error($response)) {
439 return $response;
440 }
441
442 return $response;
443 }
444 }
445