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

474 lines 15.7 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 /**
283 * Two-step order: no payment_source in the body, so the buyer approves and the JS SDK
284 * captures. PayPal-Request-Id is optional here and deliberately omitted — see
285 * .claude/skills/coding-rules/payment-idempotency.md.
286 *
287 * Adding payment_source (card, vault_id, billing_agreement_id) makes this a single-step
288 * call that moves money on create. PayPal then REQUIRES PayPal-Request-Id (max 108 chars,
289 * keys stored 6h), and the idempotency design must be revisited before doing so.
290 */
291 public static function createOrder($purchaseUnit)
292 {
293 return self::makeRequest('checkout/orders', 'v2', 'POST', [
294 'intent' => 'CAPTURE',
295 'purchase_units' => [$purchaseUnit],
296 'application_context' => ['shipping_preference' => 'NO_SHIPPING'],
297 ]);
298 }
299
300 public static function verifyPayment($paymentId)
301 {
302 return self::makeRequest('checkout/orders/' . $paymentId, 'v2', 'GET');
303 }
304
305 /**
306 * Captures an APPROVED PayPal order server-side, moving the money. FluentCart creates
307 * the order with intent=CAPTURE but the buyer only AUTHORIZES it in the popup; the funds
308 * are not captured until this call runs. The server must never trust the browser to have
309 * captured — an APPROVED-but-uncaptured order means PayPal is holding $0.
310 *
311 * Capture MOVES MONEY, so it carries a PayPal-Request-Id for idempotency (see
312 * .claude/skills/coding-rules/payment-idempotency.md). The id is keyed on the PayPal
313 * order id, which is stable and unique per checkout attempt: a duplicate capture of the
314 * same order replays the cached response instead of double-capturing, while capturing an
315 * already-captured order returns 422 ORDER_ALREADY_CAPTURED (the caller re-GETs and
316 * continues). PayPal retains request ids for 6h — longer than the 3h order lifetime — so
317 * a keyed capture never replays a dead id.
318 *
319 * @param string $paymentId The PayPal order id (payId)
320 * @return mixed API response (the captured order) or WP_Error
321 */
322 public static function captureOrder($paymentId)
323 {
324 return self::makeRequest('checkout/orders/' . $paymentId . '/capture', 'v2', 'POST', [], '', [
325 'PayPal-Request-Id' => 'fct_paypal_capture_' . md5($paymentId),
326 ]);
327 }
328
329 public function verifySubscription($subscriptionId, $mode = '')
330 {
331 return self::makeRequest('billing/subscriptions/' . $subscriptionId, 'v1', 'GET', [], $mode);
332 }
333
334 /**
335 * Retrieves PayPal access token using WP_HTTP.
336 *
337 * @param string $mode The PayPal mode (live/sandbox).
338 * @param array $args Additional arguments including public_key and api_key.
339 * @return string|\WP_Error Access token on success, WP_Error on failure.
340 */
341 private static function getAccessToken($mode = '', $args = [])
342 {
343 if (!$mode) {
344 $mode = (new PayPalSettingsBase())->getMode();
345 }
346
347 static $accessToken;
348
349 // Check for cached token
350 if (!$args) {
351 if ($accessToken) {
352 return $accessToken;
353 }
354
355 $existingToken = fluent_cart_get_option('_paypal_access_token_' . $mode);
356 if ($existingToken && isset($existingToken['expires_at']) && $existingToken['expires_at'] > time()) {
357 $accessToken = $existingToken['access_token'];
358 return $accessToken;
359 }
360 }
361
362 $apiUrl = self::getAuthAPI($mode);
363
364 // Prepare headers
365 $headers = [
366 'Accept' => 'application/json',
367 'Accept-Language' => 'en_US',
368 'PayPal-Partner-Attribution-ID' => 'FLUENTCART_SP_PPCP'
369 ];
370
371 // Prepare body
372 $body = [
373 'grant_type' => 'client_credentials'
374 ];
375
376 // Get credentials
377 $publicKey = !empty($args['public_key']) ? $args['public_key'] : self::getPayPalSettings()->getPublicKey($mode);
378 $apiKey = !empty($args['api_key']) ? $args['api_key'] : self::getPayPalSettings()->getApiKey($mode);
379
380 // Add Basic Auth header
381 $headers['Authorization'] = 'Basic ' . base64_encode($publicKey . ':' . $apiKey);
382
383 // Make HTTP request
384 $response = wp_remote_post($apiUrl, [
385 'headers' => $headers,
386 'body' => $body,
387 'timeout' => 30
388 ]);
389
390 // Check for WP_Error
391 if (is_wp_error($response)) {
392 return $response;
393 }
394
395 // Get response code and body
396 $http_code = wp_remote_retrieve_response_code($response);
397 $response_body = wp_remote_retrieve_body($response);
398
399 if ($http_code === 200) {
400 $response_data = json_decode($response_body, true);
401 $accessToken = $response_data['access_token'];
402
403 $data = [
404 'access_token' => $accessToken,
405 'expires_at' => time() + (int)$response_data['expires_in'] - 120 // Subtract 2 minutes
406 ];
407
408 fluent_cart_update_option('_paypal_access_token_' . $mode, $data);
409
410 return $accessToken;
411 }
412
413 $error = json_decode($response_body, true);
414 $errorMessage = $error['error_description'] ?? $error['error'] ?? esc_html__('Failed to retrieve access token from PayPal.', 'fluent-cart');
415
416 return new \WP_Error(
417 'access_token_error',
418 $errorMessage,
419 $error
420 );
421 }
422
423 /**
424 * Generate a PayPal-Auth-Assertion JWT header (unsigned, alg=none).
425 *
426 * @param string $clientId Your platform's REST API client ID
427 * @param string $sellerPayerId Seller's PayPal payer_id (preferred) or email
428 * @return string The PayPal‑Auth‑Assertion header value
429 */
430 private static function generatePayPalAuthAssertion($clientId, $sellerPayerId)
431 {
432 $header = ['alg' => 'none'];
433 $encodedHeader = rtrim(strtr(base64_encode(json_encode($header)), '+/', '-_'), '=');
434 $payload = [
435 'iss' => $clientId,
436 'payer_id' => $sellerPayerId
437 ];
438 $encodedPayload = rtrim(strtr(base64_encode(json_encode($payload)), '+/', '-_'), '=');
439 return "{$encodedHeader}.{$encodedPayload}.";
440 }
441
442 private static function getAccountId($settings, $mode)
443 {
444 return Arr::get($settings->settings, $mode . '_account_id');
445 }
446
447 public static function verifyWebhookSignature($body)
448 {
449 $verify_url = ((new PayPalSettingsBase())->getMode() === 'live')
450 ? self::LIVE_VERIFYING_URL
451 : self::TEST_VERIFYING_URL;
452
453 $accessToken = self::getAccessToken();
454
455 $args = array(
456 'headers' => array(
457 'Content-Type' => 'application/json',
458 'Authorization' => 'Bearer ' . $accessToken,
459 ),
460 'body' => json_encode($body),
461 'timeout' => 30,
462 'data_format' => 'body'
463 );
464
465 $response = wp_remote_post($verify_url, $args);
466
467 if (is_wp_error($response)) {
468 return $response;
469 }
470
471 return $response;
472 }
473 }
474