PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.1
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.1
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.6.1, at app/Modules/PaymentMethods/PayPalGateway/API/API.php

521 lines 17.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 array $extraHeaders Additional request headers ex: 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 = '', $extraHeaders = [])
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
125 foreach ($extraHeaders as $headerKey => $headerValue) {
126 $headers[$headerKey] = $headerValue;
127 }
128
129 $response = wp_remote_post($paypal_api_url, [
130 'headers' => $headers,
131 'method' => $method,
132 'body' => json_encode($args)
133 ]);
134
135 if (is_wp_error($response)) {
136 return new \WP_Error('general_error', $response->get_error_message(), $response);
137 }
138
139 $http_code = wp_remote_retrieve_response_code($response);
140 $body = json_decode(wp_remote_retrieve_body($response), true);
141
142 if ($http_code > 299) {
143 $code = 'general_error';
144 $message = 'PayPal General Error';
145 if (isset($body['error'])) {
146 $code = $body['error'];
147 }
148
149 if ($code === 'invalid_token') {
150 fluent_cart_update_option('_paypal_access_token_' . $mode, []);
151 }
152
153 if (!empty($body['message'])) {
154 $message = $body['message'];
155 if (isset($body['details'])) {
156 $message = Arr::get($body, 'details.0.issue', $message);
157 }
158 }
159
160 return new \WP_Error($code, $message, $body);
161 }
162
163 // it's success response with no content
164 if ($http_code == 204) {
165 return [
166 'status' => 'success',
167 'body' => 'No Content',
168 'code' => 204
169 ];
170 }
171
172 return $body;
173 }
174
175 public static function getResource($path, $data = [], $mode = '')
176 {
177 return self::makeRequest($path, 'v1', 'GET', $data, $mode);
178 }
179
180 public static function createResource($path, $data = [], $mode = '')
181 {
182 return self::makeRequest($path, 'v1', 'POST', $data, $mode);
183 }
184
185 public static function retrieveAccount($settings, $mode = '')
186 {
187 $paypalSettings = self::getPayPalSettings();
188 if (empty($mode)) {
189 $mode = $paypalSettings->getMode();
190 }
191
192 $settings = $paypalSettings->settings;
193
194 $clientId = Arr::get($settings, $mode . '_client_id');
195 $secretId = Arr::get($settings, $mode . '_client_secret');
196 $merchantId = Arr::get($settings, $mode . '_account_id');
197 $email = Arr::get($settings, $mode . '_email_address');
198 $accountType = Arr::get($settings, $mode . '_account_status');
199
200 if (!$clientId || !$secretId || !$merchantId) {
201 return false;
202 }
203
204 return [
205 'account_id' => $merchantId,
206 'display_name' => 'Merchant ID: ' . $merchantId,
207 'email' => $email,
208 'account_type' => $accountType
209 ];
210 }
211
212 public static function getRequest($url, $accessToken = null, $mode = '')
213 {
214 if (!$accessToken) {
215 $accessToken = self::getAccessToken($mode);
216
217 if (is_wp_error($accessToken)) {
218 return $accessToken;
219 }
220 }
221
222 $headers = array(
223 'Authorization' => 'Bearer ' . $accessToken,
224 'Content-Type' => 'application/json',
225 'Accept' => 'application/json',
226 'PayPal-Partner-Attribution-ID: FLUENTCART_SP_PPCP'
227 );
228
229 $response = wp_safe_remote_get($url, [
230 'headers' => $headers
231 ]);
232
233 if (is_wp_error($response)) {
234 return new \WP_Error('general_error', $response->get_error_message(), $response);
235 }
236
237 $http_code = wp_remote_retrieve_response_code($response);
238 $body = json_decode(wp_remote_retrieve_body($response), true);
239
240 if ($http_code == 200) {
241 return $body;
242 }
243
244 // it's success response with no content
245 if ($http_code == 204) {
246 return [
247 'status' => 'success',
248 'body' => 'No Content',
249 'code' => 204
250 ];
251 }
252
253 if ($http_code > 299) {
254 $code = 'general_error';
255 if (isset($body['error'])) {
256 $code = $body['error'];
257 }
258
259
260 if ($code === 'invalid_token' && $mode) {
261 fluent_cart_update_option('_paypal_access_token_' . $mode, []);
262 }
263
264 if (!empty($body['message'])) {
265 $message = $body['message'];
266 if (isset($body['details'])) {
267 $message = Arr::get($body, 'details.0.description');
268 }
269 }
270 return new \WP_Error($code, $message, $body);
271 }
272
273 $message = $body['message'] ?? 'PayPal General Error';
274
275 if (isset($body['details'])) {
276 $message = $body['details'][0]['issue'];
277 }
278
279 return new \WP_Error($http_code, $message, $body);
280 }
281
282 public static function createOrder($purchaseUnit, $extraBody = [], $extraHeaders = [])
283 {
284 $body = [
285 'intent' => 'CAPTURE',
286 'purchase_units' => [$purchaseUnit],
287 'application_context' => ['shipping_preference' => 'NO_SHIPPING'],
288 ];
289
290 if ($extraBody) {
291 // The legacy application_context cannot be combined with the
292 // payment_source object (vaulting / merchant-initiated charges) —
293 // shipping preference then rides experience_context instead.
294 if (isset($extraBody['payment_source'])) {
295 unset($body['application_context']);
296 }
297 $body = array_merge($body, $extraBody);
298 }
299
300 return self::makeRequest('checkout/orders', 'v2', 'POST', $body, '', $extraHeaders);
301 }
302
303 public static function verifyPayment($paymentId)
304 {
305 return self::makeRequest('checkout/orders/' . $paymentId, 'v2', 'GET');
306 }
307
308 /**
309 * Captures an APPROVED PayPal order server-side, moving the money. FluentCart creates
310 * the order with intent=CAPTURE but the buyer only AUTHORIZES it in the popup; the funds
311 * are not captured until this call runs. The server must never trust the browser to have
312 * captured — an APPROVED-but-uncaptured order means PayPal is holding $0.
313 *
314 * Capture MOVES MONEY, so it carries a PayPal-Request-Id for idempotency (see
315 * .claude/skills/coding-rules/payment-idempotency.md). The id is keyed on the PayPal
316 * order id, which is stable and unique per checkout attempt: a duplicate capture of the
317 * same order replays the cached response instead of double-capturing, while capturing an
318 * already-captured order returns 422 ORDER_ALREADY_CAPTURED (the caller re-GETs and
319 * continues). PayPal retains request ids for 6h — longer than the 3h order lifetime — so
320 * a keyed capture never replays a dead id.
321 *
322 * @param string $paymentId The PayPal order id (payId)
323 * @return mixed API response (the captured order) or WP_Error
324 */
325 public static function captureOrder($paymentId)
326 {
327 return self::makeRequest('checkout/orders/' . $paymentId . '/capture', 'v2', 'POST', [], '', [
328 'PayPal-Request-Id' => 'fct_paypal_capture_' . md5($paymentId),
329 ]);
330 }
331
332 public function verifySubscription($subscriptionId, $mode = '')
333 {
334 return self::makeRequest('billing/subscriptions/' . $subscriptionId, 'v1', 'GET', [], $mode);
335 }
336
337 /**
338 * Retrieves PayPal access token using WP_HTTP.
339 *
340 * @param string $mode The PayPal mode (live/sandbox).
341 * @param array $args Additional arguments including public_key and api_key.
342 * @return string|\WP_Error Access token on success, WP_Error on failure.
343 */
344 private static function getAccessToken($mode = '', $args = [])
345 {
346 if (!$mode) {
347 $mode = (new PayPalSettingsBase())->getMode();
348 }
349
350 static $accessToken;
351
352 // Check for cached token
353 if (!$args) {
354 if ($accessToken) {
355 return $accessToken;
356 }
357
358 $existingToken = fluent_cart_get_option('_paypal_access_token_' . $mode);
359 if ($existingToken && isset($existingToken['expires_at']) && $existingToken['expires_at'] > time()) {
360 $accessToken = $existingToken['access_token'];
361 return $accessToken;
362 }
363 }
364
365 $apiUrl = self::getAuthAPI($mode);
366
367 // Prepare headers
368 $headers = [
369 'Accept' => 'application/json',
370 'Accept-Language' => 'en_US',
371 'PayPal-Partner-Attribution-ID' => 'FLUENTCART_SP_PPCP'
372 ];
373
374 // Prepare body
375 $body = [
376 'grant_type' => 'client_credentials'
377 ];
378
379 // Get credentials
380 $publicKey = !empty($args['public_key']) ? $args['public_key'] : self::getPayPalSettings()->getPublicKey($mode);
381 $apiKey = !empty($args['api_key']) ? $args['api_key'] : self::getPayPalSettings()->getApiKey($mode);
382
383 // Add Basic Auth header
384 $headers['Authorization'] = 'Basic ' . base64_encode($publicKey . ':' . $apiKey);
385
386 // Make HTTP request
387 $response = wp_remote_post($apiUrl, [
388 'headers' => $headers,
389 'body' => $body,
390 'timeout' => 30
391 ]);
392
393 // Check for WP_Error
394 if (is_wp_error($response)) {
395 return $response;
396 }
397
398 // Get response code and body
399 $http_code = wp_remote_retrieve_response_code($response);
400 $response_body = wp_remote_retrieve_body($response);
401
402 if ($http_code === 200) {
403 $response_data = json_decode($response_body, true);
404 $accessToken = $response_data['access_token'];
405
406 $data = [
407 'access_token' => $accessToken,
408 'expires_at' => time() + (int)$response_data['expires_in'] - 120 // Subtract 2 minutes
409 ];
410
411 fluent_cart_update_option('_paypal_access_token_' . $mode, $data);
412
413 return $accessToken;
414 }
415
416 $error = json_decode($response_body, true);
417 $errorMessage = $error['error_description'] ?? $error['error'] ?? esc_html__('Failed to retrieve access token from PayPal.', 'fluent-cart');
418
419 return new \WP_Error(
420 'access_token_error',
421 $errorMessage,
422 $error
423 );
424 }
425
426 /**
427 * Browser-safe id token for the JS SDK vault (save-without-purchase) flow —
428 * rendered as the SDK script's data-user-id-token attribute. Short-lived
429 * (~15 min), so it is generated per checkout page render and never cached.
430 *
431 * @param string $mode The PayPal mode (live/test).
432 * @return string|\WP_Error
433 */
434 public static function getUserIdToken($mode = '')
435 {
436 if (!$mode) {
437 $mode = self::getPayPalSettings()->getMode();
438 }
439
440 $headers = [
441 'Accept' => 'application/json',
442 'PayPal-Partner-Attribution-ID' => 'FLUENTCART_SP_PPCP',
443 'Authorization' => 'Basic ' . base64_encode(
444 self::getPayPalSettings()->getPublicKey($mode) . ':' . self::getPayPalSettings()->getApiKey($mode)
445 ),
446 ];
447
448 $response = wp_remote_post(self::getAuthAPI($mode), [
449 'headers' => $headers,
450 'body' => [
451 'grant_type' => 'client_credentials',
452 'response_type' => 'id_token'
453 ],
454 'timeout' => 30
455 ]);
456
457 if (is_wp_error($response)) {
458 return $response;
459 }
460
461 $body = json_decode(wp_remote_retrieve_body($response), true);
462
463 if (wp_remote_retrieve_response_code($response) !== 200 || empty($body['id_token'])) {
464 return new \WP_Error('id_token_error', __('Could not generate a PayPal id token.', 'fluent-cart'), $body);
465 }
466
467 return $body['id_token'];
468 }
469
470 /**
471 * Generate a PayPal-Auth-Assertion JWT header (unsigned, alg=none).
472 *
473 * @param string $clientId Your platform's REST API client ID
474 * @param string $sellerPayerId Seller's PayPal payer_id (preferred) or email
475 * @return string The PayPal‑Auth‑Assertion header value
476 */
477 private static function generatePayPalAuthAssertion($clientId, $sellerPayerId)
478 {
479 $header = ['alg' => 'none'];
480 $encodedHeader = rtrim(strtr(base64_encode(json_encode($header)), '+/', '-_'), '=');
481 $payload = [
482 'iss' => $clientId,
483 'payer_id' => $sellerPayerId
484 ];
485 $encodedPayload = rtrim(strtr(base64_encode(json_encode($payload)), '+/', '-_'), '=');
486 return "{$encodedHeader}.{$encodedPayload}.";
487 }
488
489 private static function getAccountId($settings, $mode)
490 {
491 return Arr::get($settings->settings, $mode . '_account_id');
492 }
493
494 public static function verifyWebhookSignature($body)
495 {
496 $verify_url = ((new PayPalSettingsBase())->getMode() === 'live')
497 ? self::LIVE_VERIFYING_URL
498 : self::TEST_VERIFYING_URL;
499
500 $accessToken = self::getAccessToken();
501
502 $args = array(
503 'headers' => array(
504 'Content-Type' => 'application/json',
505 'Authorization' => 'Bearer ' . $accessToken,
506 ),
507 'body' => json_encode($body),
508 'timeout' => 30,
509 'data_format' => 'body'
510 );
511
512 $response = wp_remote_post($verify_url, $args);
513
514 if (is_wp_error($response)) {
515 return $response;
516 }
517
518 return $response;
519 }
520 }
521