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
← All changes | app/Modules/PaymentMethods/PayPalGateway/API/API.php +93 -5 1.4.1 → 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,15 +280,27 @@
272 280
273 281 return new \WP_Error($http_code, $message, $body);
274 282 }
275 283
276 - public static function createOrder($purchaseUnit)
284 + public static function createOrder($purchaseUnit, $extraBody = [], $extraHeaders = [])
277 285 {
278 - return self::makeRequest('checkout/orders', 'v2', 'POST', [
286 + $body = [
279 287 'intent' => 'CAPTURE',
280 288 'purchase_units' => [$purchaseUnit],
281 289 'application_context' => ['shipping_preference' => 'NO_SHIPPING'],
282 - ]);
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);
283 303 }
284 304
285 305 public static function verifyPayment($paymentId)
286 306 {
@@ -286,8 +306,32 @@
286 306 {
287 307 return self::makeRequest('checkout/orders/' . $paymentId, 'v2', 'GET');
288 308 }
289 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 +
290 334 public function verifySubscription($subscriptionId, $mode = '')
291 335 {
292 336 return self::makeRequest('billing/subscriptions/' . $subscriptionId, 'v1', 'GET', [], $mode);
293 337 }
@@ -378,8 +422,52 @@
378 422 'access_token_error',
379 423 $errorMessage,
380 424 $error
381 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'];
382 470 }
383 471
384 472 /**
385 473 * Generate a PayPal-Auth-Assertion JWT header (unsigned, alg=none).