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 +91 -13 1.5.3 → 1.6.5 View file →
@@ -60,13 +60,13 @@
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 63 * @param string $mode PayPal mode ex: live, test (Optional)
64 - * @param string|null $requestId Idempotency id sent as PayPal-Request-Id (Optional)
64 + * @param array $extraHeaders Additional request headers ex: PayPal-Request-Id (Optional)
65 65 * @return mixed $response API response
66 66 * @throws \Exception if error occurs
67 67 */
68 - public static function makeRequest($path, $version = 'v1', $method = 'POST', $args = [], $mode = '', $requestId = null)
68 + public static function makeRequest($path, $version = 'v1', $method = 'POST', $args = [], $mode = '', $extraHeaders = [])
69 69 {
70 70 if (empty($path)) {
71 71 return new \WP_Error('invalid_path', esc_html__('API path is required', 'fluent-cart'));
72 72 }
@@ -119,22 +119,20 @@
119 119 }
120 120
121 121 if ('POST' === $method) {
122 122 $headers['Prefer'] = 'return=representation';
123 + }
123 124
124 - // PayPal dedupes POSTs by PayPal-Request-Id — a duplicate returns the
125 - // ORIGINAL object. Unlike Stripe, a reused id with a changed body is NOT
126 - // rejected (the new body is silently ignored), so callers must fingerprint
127 - // charge-material params into the id itself.
128 - if ($requestId) {
129 - $headers['PayPal-Request-Id'] = $requestId;
130 - }
125 + foreach ($extraHeaders as $headerKey => $headerValue) {
126 + $headers[$headerKey] = $headerValue;
131 127 }
132 128
133 129 $response = wp_remote_post($paypal_api_url, [
134 130 'headers' => $headers,
135 131 'method' => $method,
136 - '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())
137 135 ]);
138 136
139 137 if (is_wp_error($response)) {
140 138 return new \WP_Error('general_error', $response->get_error_message(), $response);
@@ -282,15 +280,27 @@
282 280
283 281 return new \WP_Error($http_code, $message, $body);
284 282 }
285 283
286 - public static function createOrder($purchaseUnit, $requestId = null)
284 + public static function createOrder($purchaseUnit, $extraBody = [], $extraHeaders = [])
287 285 {
288 - return self::makeRequest('checkout/orders', 'v2', 'POST', [
286 + $body = [
289 287 'intent' => 'CAPTURE',
290 288 'purchase_units' => [$purchaseUnit],
291 289 'application_context' => ['shipping_preference' => 'NO_SHIPPING'],
292 - ], '', $requestId);
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);
293 303 }
294 304
295 305 public static function verifyPayment($paymentId)
296 306 {
@@ -296,8 +306,32 @@
296 306 {
297 307 return self::makeRequest('checkout/orders/' . $paymentId, 'v2', 'GET');
298 308 }
299 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 +
300 334 public function verifySubscription($subscriptionId, $mode = '')
301 335 {
302 336 return self::makeRequest('billing/subscriptions/' . $subscriptionId, 'v1', 'GET', [], $mode);
303 337 }
@@ -388,8 +422,52 @@
388 422 'access_token_error',
389 423 $errorMessage,
390 424 $error
391 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'];
392 470 }
393 471
394 472 /**
395 473 * Generate a PayPal-Auth-Assertion JWT header (unsigned, alg=none).