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

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