← All changes
|
app/Modules/PaymentMethods/PayPalGateway/API/API.php
+93
-5
1.5.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). |