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 +92 -14 1.5.4 → 1.6.5 View file →
@@ -60,12 +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 array $extraHeaders Additional request headers ex: PayPal-Request-Id (Optional)
64 65 * @return mixed $response API response
65 66 * @throws \Exception if error occurs
66 67 */
67 - public static function makeRequest($path, $version = 'v1', $method = 'POST', $args = [], $mode = '')
68 + public static function makeRequest($path, $version = 'v1', $method = 'POST', $args = [], $mode = '', $extraHeaders = [])
68 69 {
69 70 if (empty($path)) {
70 71 return new \WP_Error('invalid_path', esc_html__('API path is required', 'fluent-cart'));
71 72 }
@@ -120,12 +121,18 @@
120 121 if ('POST' === $method) {
121 122 $headers['Prefer'] = 'return=representation';
122 123 }
123 124
125 + foreach ($extraHeaders as $headerKey => $headerValue) {
126 + $headers[$headerKey] = $headerValue;
127 + }
128 +
124 129 $response = wp_remote_post($paypal_api_url, [
125 130 'headers' => $headers,
126 131 'method' => $method,
127 - '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())
128 135 ]);
129 136
130 137 if (is_wp_error($response)) {
131 138 return new \WP_Error('general_error', $response->get_error_message(), $response);
@@ -273,24 +280,27 @@
273 280
274 281 return new \WP_Error($http_code, $message, $body);
275 282 }
276 283
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)
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 - ]);
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).