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.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
← All changes | app/Modules/PaymentMethods/PayPalGateway/API/API.php +99 -2 1.3.20 → 1.6.5 View file →
@@ -59,12 +59,14 @@
59 59 * @param string $path API path ex: checkout/orders (Required)
60 60 * @param string $version API version ex: v1, v2 (Optional)
61 61 * @param string $method HTTP method ex: GET, POST, DELETE (Optional)
62 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)
63 65 * @return mixed $response API response
64 66 * @throws \Exception if error occurs
65 67 */
66 - public static function makeRequest($path, $version = 'v1', $method = 'POST', $args = [], $mode = '')
68 + public static function makeRequest($path, $version = 'v1', $method = 'POST', $args = [], $mode = '', $extraHeaders = [])
67 69 {
68 70 if (empty($path)) {
69 71 return new \WP_Error('invalid_path', esc_html__('API path is required', 'fluent-cart'));
70 72 }
@@ -119,12 +121,18 @@
119 121 if ('POST' === $method) {
120 122 $headers['Prefer'] = 'return=representation';
121 123 }
122 124
125 + foreach ($extraHeaders as $headerKey => $headerValue) {
126 + $headers[$headerKey] = $headerValue;
127 + }
128 +
123 129 $response = wp_remote_post($paypal_api_url, [
124 130 'headers' => $headers,
125 131 'method' => $method,
126 - 'body' => json_encode($args)
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())
127 135 ]);
128 136
129 137 if (is_wp_error($response)) {
130 138 return new \WP_Error('general_error', $response->get_error_message(), $response);
@@ -272,13 +280,58 @@
272 280
273 281 return new \WP_Error($http_code, $message, $body);
274 282 }
275 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 +
276 305 public static function verifyPayment($paymentId)
277 306 {
278 307 return self::makeRequest('checkout/orders/' . $paymentId, 'v2', 'GET');
279 308 }
280 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 +
281 334 public function verifySubscription($subscriptionId, $mode = '')
282 335 {
283 336 return self::makeRequest('billing/subscriptions/' . $subscriptionId, 'v1', 'GET', [], $mode);
284 337 }
@@ -369,8 +422,52 @@
369 422 'access_token_error',
370 423 $errorMessage,
371 424 $error
372 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'];
373 470 }
374 471
375 472 /**
376 473 * Generate a PayPal-Auth-Assertion JWT header (unsigned, alg=none).