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

445 lines 14.1 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 * @return mixed $response API response
65 * @throws \Exception if error occurs
66 */
67 public static function makeRequest($path, $version = 'v1', $method = 'POST', $args = [], $mode = '')
68 {
69 if (empty($path)) {
70 return new \WP_Error('invalid_path', esc_html__('API path is required', 'fluent-cart'));
71 }
72
73 $settings = self::getPayPalSettings();
74
75 if (!$mode) {
76 $mode = $settings->getMode();
77 }
78
79 $paypal_api_url = self::getAPIUrl($mode) . '/' . $version . '/' . $path;
80
81 $accessToken = self::getAccessToken($mode);
82
83 if (is_wp_error($accessToken)) {
84 return $accessToken;
85 }
86
87
88 //unset auth asertion headers, if platform app not connected
89 if ($settings->getProviderType() === 'api_keys') {
90 $headers = array(
91 'Authorization' => 'Bearer ' . $accessToken,
92 'Content-Type' => 'application/json',
93 'Accept' => 'application/json',
94 );
95 } else {
96 $authAssertion = static::generatePayPalAuthAssertion(
97 $settings->getPublicKey($mode),
98 static::getAccountId($settings, $mode)
99 );
100
101 $headers = array(
102 'Authorization' => 'Bearer ' . $accessToken,
103 'PayPal-Partner-Attribution-ID: FLUENTCART_SP_PPCP',
104 'Content-Type' => 'application/json',
105 'Accept' => 'application/json',
106 'PayPal-Auth-Assertion' => $authAssertion
107 );
108 }
109
110
111 if ('GET' === $method) {
112 // if args is not empty then append it to the url
113 if (!empty($args)) {
114 $paypal_api_url .= '?' . http_build_query($args);
115 }
116
117 return self::getRequest($paypal_api_url, $accessToken, $mode);
118 }
119
120 if ('POST' === $method) {
121 $headers['Prefer'] = 'return=representation';
122 }
123
124 $response = wp_remote_post($paypal_api_url, [
125 'headers' => $headers,
126 'method' => $method,
127 'body' => json_encode($args)
128 ]);
129
130 if (is_wp_error($response)) {
131 return new \WP_Error('general_error', $response->get_error_message(), $response);
132 }
133
134 $http_code = wp_remote_retrieve_response_code($response);
135 $body = json_decode(wp_remote_retrieve_body($response), true);
136
137 if ($http_code > 299) {
138 $code = 'general_error';
139 $message = 'PayPal General Error';
140 if (isset($body['error'])) {
141 $code = $body['error'];
142 }
143
144 if ($code === 'invalid_token') {
145 fluent_cart_update_option('_paypal_access_token_' . $mode, []);
146 }
147
148 if (!empty($body['message'])) {
149 $message = $body['message'];
150 if (isset($body['details'])) {
151 $message = Arr::get($body, 'details.0.issue', $message);
152 }
153 }
154
155 return new \WP_Error($code, $message, $body);
156 }
157
158 // it's success response with no content
159 if ($http_code == 204) {
160 return [
161 'status' => 'success',
162 'body' => 'No Content',
163 'code' => 204
164 ];
165 }
166
167 return $body;
168 }
169
170 public static function getResource($path, $data = [], $mode = '')
171 {
172 return self::makeRequest($path, 'v1', 'GET', $data, $mode);
173 }
174
175 public static function createResource($path, $data = [], $mode = '')
176 {
177 return self::makeRequest($path, 'v1', 'POST', $data, $mode);
178 }
179
180 public static function retrieveAccount($settings, $mode = '')
181 {
182 $paypalSettings = self::getPayPalSettings();
183 if (empty($mode)) {
184 $mode = $paypalSettings->getMode();
185 }
186
187 $settings = $paypalSettings->settings;
188
189 $clientId = Arr::get($settings, $mode . '_client_id');
190 $secretId = Arr::get($settings, $mode . '_client_secret');
191 $merchantId = Arr::get($settings, $mode . '_account_id');
192 $email = Arr::get($settings, $mode . '_email_address');
193 $accountType = Arr::get($settings, $mode . '_account_status');
194
195 if (!$clientId || !$secretId || !$merchantId) {
196 return false;
197 }
198
199 return [
200 'account_id' => $merchantId,
201 'display_name' => 'Merchant ID: ' . $merchantId,
202 'email' => $email,
203 'account_type' => $accountType
204 ];
205 }
206
207 public static function getRequest($url, $accessToken = null, $mode = '')
208 {
209 if (!$accessToken) {
210 $accessToken = self::getAccessToken($mode);
211
212 if (is_wp_error($accessToken)) {
213 return $accessToken;
214 }
215 }
216
217 $headers = array(
218 'Authorization' => 'Bearer ' . $accessToken,
219 'Content-Type' => 'application/json',
220 'Accept' => 'application/json',
221 'PayPal-Partner-Attribution-ID: FLUENTCART_SP_PPCP'
222 );
223
224 $response = wp_safe_remote_get($url, [
225 'headers' => $headers
226 ]);
227
228 if (is_wp_error($response)) {
229 return new \WP_Error('general_error', $response->get_error_message(), $response);
230 }
231
232 $http_code = wp_remote_retrieve_response_code($response);
233 $body = json_decode(wp_remote_retrieve_body($response), true);
234
235 if ($http_code == 200) {
236 return $body;
237 }
238
239 // it's success response with no content
240 if ($http_code == 204) {
241 return [
242 'status' => 'success',
243 'body' => 'No Content',
244 'code' => 204
245 ];
246 }
247
248 if ($http_code > 299) {
249 $code = 'general_error';
250 if (isset($body['error'])) {
251 $code = $body['error'];
252 }
253
254
255 if ($code === 'invalid_token' && $mode) {
256 fluent_cart_update_option('_paypal_access_token_' . $mode, []);
257 }
258
259 if (!empty($body['message'])) {
260 $message = $body['message'];
261 if (isset($body['details'])) {
262 $message = Arr::get($body, 'details.0.description');
263 }
264 }
265 return new \WP_Error($code, $message, $body);
266 }
267
268 $message = $body['message'] ?? 'PayPal General Error';
269
270 if (isset($body['details'])) {
271 $message = $body['details'][0]['issue'];
272 }
273
274 return new \WP_Error($http_code, $message, $body);
275 }
276
277 /**
278 * Two-step order: no payment_source in the body, so the buyer approves and the JS SDK
279 * captures. PayPal-Request-Id is optional here and deliberately omitted — see
280 * .claude/skills/coding-rules/payment-idempotency.md.
281 *
282 * Adding payment_source (card, vault_id, billing_agreement_id) makes this a single-step
283 * call that moves money on create. PayPal then REQUIRES PayPal-Request-Id (max 108 chars,
284 * keys stored 6h), and the idempotency design must be revisited before doing so.
285 */
286 public static function createOrder($purchaseUnit)
287 {
288 return self::makeRequest('checkout/orders', 'v2', 'POST', [
289 'intent' => 'CAPTURE',
290 'purchase_units' => [$purchaseUnit],
291 'application_context' => ['shipping_preference' => 'NO_SHIPPING'],
292 ]);
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