From 5468ce226bbd4694937a00e6f51965b5a5cfcfe6 Mon Sep 17 00:00:00 2001 From: David Stone Date: Mon, 12 Jan 2026 11:40:54 -0700 Subject: [PATCH 01/11] Refactor PayPal gateway with modern REST API and subscription support - Add Base_PayPal_Gateway abstract class for shared PayPal functionality - Add PayPal_REST_Gateway with modern PayPal REST API implementation - Add PayPal_OAuth_Handler for OAuth 2.0 authentication flow - Add PayPal_Webhook_Handler for webhook event processing - Update Gateway_Manager to register new PayPal REST gateway and use filters - Fix Base_Gateway methods to properly return strings for filter callbacks - Maintain backwards compatibility with legacy PayPal gateway Co-Authored-By: Claude Opus 4.5 --- inc/gateways/class-base-gateway.php | 21 +- inc/gateways/class-base-paypal-gateway.php | 300 +++ inc/gateways/class-paypal-gateway.php | 102 +- inc/gateways/class-paypal-oauth-handler.php | 755 ++++++++ inc/gateways/class-paypal-rest-gateway.php | 1624 +++++++++++++++++ inc/gateways/class-paypal-webhook-handler.php | 578 ++++++ inc/managers/class-gateway-manager.php | 27 +- 7 files changed, 3329 insertions(+), 78 deletions(-) create mode 100644 inc/gateways/class-base-paypal-gateway.php create mode 100644 inc/gateways/class-paypal-oauth-handler.php create mode 100644 inc/gateways/class-paypal-rest-gateway.php create mode 100644 inc/gateways/class-paypal-webhook-handler.php diff --git a/inc/gateways/class-base-gateway.php b/inc/gateways/class-base-gateway.php index 35974ea1..6aa92077 100644 --- a/inc/gateways/class-base-gateway.php +++ b/inc/gateways/class-base-gateway.php @@ -563,9 +563,12 @@ public function process_confirmation() {} * @since 2.0.0 * * @param string $gateway_payment_id The gateway payment id. - * @return void|string + * @return string */ - public function get_payment_url_on_gateway($gateway_payment_id) {} + public function get_payment_url_on_gateway($gateway_payment_id): string { + + return ''; + } /** * Returns the external link to view the membership on the membership gateway. @@ -575,9 +578,12 @@ public function get_payment_url_on_gateway($gateway_payment_id) {} * @since 2.0.0 * * @param string $gateway_subscription_id The gateway subscription id. - * @return void|string. + * @return string */ - public function get_subscription_url_on_gateway($gateway_subscription_id) {} + public function get_subscription_url_on_gateway($gateway_subscription_id): string { + + return ''; + } /** * Returns the external link to view the membership on the membership gateway. @@ -587,9 +593,12 @@ public function get_subscription_url_on_gateway($gateway_subscription_id) {} * @since 2.0.0 * * @param string $gateway_customer_id The gateway customer id. - * @return void|string. + * @return string */ - public function get_customer_url_on_gateway($gateway_customer_id) {} + public function get_customer_url_on_gateway($gateway_customer_id): string { + + return ''; + } /** * Reflects membership changes on the gateway. diff --git a/inc/gateways/class-base-paypal-gateway.php b/inc/gateways/class-base-paypal-gateway.php new file mode 100644 index 00000000..33e02893 --- /dev/null +++ b/inc/gateways/class-base-paypal-gateway.php @@ -0,0 +1,300 @@ +test_mode ? 'https://www.sandbox.paypal.com' : 'https://www.paypal.com'; + } + + /** + * Returns the PayPal API base URL based on test mode. + * + * @since 2.0.0 + * @return string + */ + protected function get_api_base_url(): string { + + return $this->test_mode ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com'; + } + + /** + * Get the subscription description. + * + * @since 2.0.0 + * + * @param \WP_Ultimo\Checkout\Cart $cart The cart object. + * @return string + */ + protected function get_subscription_description($cart): string { + + $descriptor = $cart->get_cart_descriptor(); + + $desc = html_entity_decode(substr($descriptor, 0, 127), ENT_COMPAT, 'UTF-8'); + + return $desc; + } + + /** + * Returns the external link to view the payment on the payment gateway. + * + * Return an empty string to hide the link element. + * + * @since 2.0.0 + * + * @param string $gateway_payment_id The gateway payment id. + * @return string + */ + public function get_payment_url_on_gateway($gateway_payment_id): string { + + if (empty($gateway_payment_id)) { + return ''; + } + + $sandbox_prefix = $this->test_mode ? 'sandbox.' : ''; + + return sprintf( + 'https://www.%spaypal.com/activity/payment/%s', + $sandbox_prefix, + $gateway_payment_id + ); + } + + /** + * Returns the external link to view the subscription on PayPal. + * + * Return an empty string to hide the link element. + * + * @since 2.0.0 + * + * @param string $gateway_subscription_id The gateway subscription id. + * @return string + */ + public function get_subscription_url_on_gateway($gateway_subscription_id): string { + + if (empty($gateway_subscription_id)) { + return ''; + } + + $sandbox_prefix = $this->test_mode ? 'sandbox.' : ''; + + // Check if this is a REST API subscription ID (starts with I-) or legacy NVP profile ID + if (str_starts_with($gateway_subscription_id, 'I-')) { + // REST API subscription + return sprintf( + 'https://www.%spaypal.com/billing/subscriptions/%s', + $sandbox_prefix, + $gateway_subscription_id + ); + } + + // Legacy NVP recurring payment profile + $base_url = 'https://www.%spaypal.com/us/cgi-bin/webscr?cmd=_profile-recurring-payments&encrypted_profile_id=%s'; + + return sprintf($base_url, $sandbox_prefix, $gateway_subscription_id); + } + + /** + * Returns whether a gateway subscription ID is from the REST API. + * + * REST API subscription IDs start with "I-" prefix. + * + * @since 2.0.0 + * + * @param string $subscription_id The subscription ID to check. + * @return bool + */ + protected function is_rest_subscription_id(string $subscription_id): bool { + + return str_starts_with($subscription_id, 'I-'); + } + + /** + * Adds partner attribution to API request headers. + * + * This should be called when making REST API requests to PayPal + * to ensure partner tracking and revenue sharing. + * + * @since 2.0.0 + * + * @param array $headers Existing headers array. + * @return array Headers with partner attribution added. + */ + protected function add_partner_attribution_header(array $headers): array { + + $headers['PayPal-Partner-Attribution-Id'] = $this->bn_code; + + return $headers; + } + + /** + * Log a PayPal-related message. + * + * @since 2.0.0 + * + * @param string $message The message to log. + * @param string $level Log level (default: 'info'). + * @return void + */ + protected function log(string $message, string $level = 'info'): void { + + wu_log_add('paypal', $message, $level); + } + + /** + * Adds the necessary hooks for PayPal gateways. + * + * Child classes should call parent::hooks() and add their own hooks. + * + * @since 2.0.0 + * @return void + */ + public function hooks(): void { + + // Add admin links to PayPal for membership management + add_filter('wu_element_get_site_actions', [$this, 'add_site_actions'], 10, 4); + } + + /** + * Adds PayPal-related actions to the site actions. + * + * Allows viewing subscription on PayPal for connected memberships. + * + * @since 2.0.0 + * + * @param array $actions The site actions. + * @param array $atts The widget attributes. + * @param \WP_Ultimo\Models\Site $site The current site object. + * @param \WP_Ultimo\Models\Membership $membership The current membership object. + * @return array + */ + public function add_site_actions($actions, $atts, $site, $membership) { + + if (! $membership) { + return $actions; + } + + $payment_gateway = $membership->get_gateway(); + + if (! in_array($payment_gateway, $this->other_ids, true)) { + return $actions; + } + + $subscription_id = $membership->get_gateway_subscription_id(); + + if (empty($subscription_id)) { + return $actions; + } + + $subscription_url = $this->get_subscription_url_on_gateway($subscription_id); + + if (! empty($subscription_url)) { + $actions['view_on_paypal'] = [ + 'label' => __('View on PayPal', 'ultimate-multisite'), + 'icon_classes' => 'dashicons-wu-paypal wu-align-middle', + 'href' => $subscription_url, + 'target' => '_blank', + ]; + } + + return $actions; + } + + /** + * Checks if PayPal is properly configured. + * + * @since 2.0.0 + * @return bool + */ + abstract public function is_configured(): bool; + + /** + * Returns the connection status for display in settings. + * + * @since 2.0.0 + * @return array{connected: bool, message: string, details: array} + */ + abstract public function get_connection_status(): array; +} diff --git a/inc/gateways/class-paypal-gateway.php b/inc/gateways/class-paypal-gateway.php index 8f2bc9d8..99116d6c 100644 --- a/inc/gateways/class-paypal-gateway.php +++ b/inc/gateways/class-paypal-gateway.php @@ -1,6 +1,13 @@ username) && ! empty($this->password) && ! empty($this->signature); } /** - * Declares support to subscription amount updates. - * - * @since 2.1.2 - * @return true - */ - public function supports_amount_update(): bool { - - return true; - } - - /** - * Adds the necessary hooks for the manual gateway. + * Returns the connection status for display in settings. * * @since 2.0.0 - * @return void + * @return array{connected: bool, message: string, details: array} */ - public function hooks() {} + public function get_connection_status(): array { + + $configured = $this->is_configured(); + + return [ + 'connected' => $configured, + 'message' => $configured + ? __('PayPal credentials configured', 'ultimate-multisite') + : __('PayPal credentials not configured', 'ultimate-multisite'), + 'details' => [ + 'mode' => $this->test_mode ? 'sandbox' : 'live', + 'username' => ! empty($this->username) ? substr($this->username, 0, 10) . '...' : '', + ], + ]; + } /** * Initialization code. @@ -417,7 +424,7 @@ public function process_membership_update(&$membership, $customer) { * @param \WP_Ultimo\Models\Customer $customer The customer checking out. * @param \WP_Ultimo\Checkout\Cart $cart The cart object. * @param string $type The checkout type. Can be 'new', 'retry', 'upgrade', 'downgrade', 'addon'. - * + * @throws \Exception When PayPal API call fails or returns an error. * @return void * @throws \Exception If something goes really wrong. * @since 2.0.0 @@ -650,7 +657,7 @@ public function process_checkout($payment, $membership, $customer, $cart, $type) * * Redirect to the PayPal checkout URL. */ - wp_redirect($this->checkout_url . $body['TOKEN']); // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect + wp_redirect($this->checkout_url . $body['TOKEN']); // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect -- PayPal external URL exit; } @@ -1427,23 +1434,6 @@ protected function create_recurring_profile($details, $cart, $payment, $membersh } } - /** - * Get the subscription description. - * - * @since 2.0.0 - * - * @param \WP_Ultimo\Checkout\Cart $cart The cart object. - * @return string - */ - protected function get_subscription_description($cart) { - - $descriptor = $cart->get_cart_descriptor(); - - $desc = html_entity_decode(substr($descriptor, 0, 127), ENT_COMPAT, 'UTF-8'); - - return $desc; - } - /** * Create a single payment on PayPal. * @@ -1673,37 +1663,19 @@ public function get_checkout_details($token = '') { /** * Returns the external link to view the payment on the payment gateway. * - * Return an empty string to hide the link element. + * For the legacy NVP API, there's no reliable payment link, so we return empty. + * The base class provides a URL that may work for some transactions. * * @since 2.0.0 * * @param string $gateway_payment_id The gateway payment id. - * @return string. + * @return string */ public function get_payment_url_on_gateway($gateway_payment_id): string { return ''; } - /** - * Returns the external link to view the membership on the membership gateway. - * - * Return an empty string to hide the link element. - * - * @since 2.0.0 - * - * @param string $gateway_subscription_id The gateway subscription id. - * @return string. - */ - public function get_subscription_url_on_gateway($gateway_subscription_id): string { - - $sandbox_prefix = $this->test_mode ? 'sandbox.' : ''; - - $base_url = 'https://www.%spaypal.com/us/cgi-bin/webscr?cmd=_profile-recurring-payments&encrypted_profile_id=%s'; - - return sprintf($base_url, $sandbox_prefix, $gateway_subscription_id); - } - /** * Verifies that the IPN notification actually came from PayPal. * diff --git a/inc/gateways/class-paypal-oauth-handler.php b/inc/gateways/class-paypal-oauth-handler.php new file mode 100644 index 00000000..171e7f23 --- /dev/null +++ b/inc/gateways/class-paypal-oauth-handler.php @@ -0,0 +1,755 @@ +test_mode = (bool) (int) wu_get_setting('paypal_rest_sandbox_mode', true); + + $this->load_partner_credentials(); + + // Register AJAX handlers + add_action('wp_ajax_wu_paypal_connect', [$this, 'ajax_initiate_oauth']); + add_action('wp_ajax_wu_paypal_disconnect', [$this, 'ajax_disconnect']); + + // Handle OAuth return callback + add_action('admin_init', [$this, 'handle_oauth_return']); + } + + /** + * Load partner credentials from settings. + * + * Partner credentials are used to authenticate with PayPal's Partner API + * to initiate the OAuth flow for merchants. + * + * @since 2.0.0 + * @return void + */ + protected function load_partner_credentials(): void { + + // In production, these would be Ultimate Multisite's partner credentials + // For now, merchants can use their own REST app credentials for testing + $mode_prefix = $this->test_mode ? 'sandbox_' : 'live_'; + + $this->partner_client_id = wu_get_setting("paypal_rest_{$mode_prefix}partner_client_id", ''); + $this->partner_client_secret = wu_get_setting("paypal_rest_{$mode_prefix}partner_client_secret", ''); + $this->partner_merchant_id = wu_get_setting("paypal_rest_{$mode_prefix}partner_merchant_id", ''); + } + + /** + * Returns the PayPal API base URL based on test mode. + * + * @since 2.0.0 + * @return string + */ + protected function get_api_base_url(): string { + + return $this->test_mode ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com'; + } + + /** + * Returns the PayPal web base URL based on test mode. + * + * @since 2.0.0 + * @return string + */ + protected function get_paypal_web_url(): string { + + return $this->test_mode ? 'https://www.sandbox.paypal.com' : 'https://www.paypal.com'; + } + + /** + * Get an access token for the partner application. + * + * @since 2.0.0 + * @return string|\WP_Error Access token or error. + */ + protected function get_partner_access_token() { + + // Check for cached token + $cache_key = 'wu_paypal_partner_token_' . ($this->test_mode ? 'sandbox' : 'live'); + $cached_token = get_site_transient($cache_key); + + if ($cached_token) { + return $cached_token; + } + + if (empty($this->partner_client_id) || empty($this->partner_client_secret)) { + return new \WP_Error( + 'wu_paypal_missing_partner_credentials', + __('Partner credentials not configured. Please configure the partner client ID and secret.', 'ultimate-multisite') + ); + } + + $response = wp_remote_post( + $this->get_api_base_url() . '/v1/oauth2/token', + [ + 'headers' => [ + 'Authorization' => 'Basic ' . base64_encode($this->partner_client_id . ':' . $this->partner_client_secret), + 'Content-Type' => 'application/x-www-form-urlencoded', + ], + 'body' => 'grant_type=client_credentials', + 'timeout' => 30, + ] + ); + + if (is_wp_error($response)) { + wu_log_add('paypal', 'Failed to get partner access token: ' . $response->get_error_message(), LogLevel::ERROR); + return $response; + } + + $body = json_decode(wp_remote_retrieve_body($response), true); + $code = wp_remote_retrieve_response_code($response); + + if (200 !== $code || empty($body['access_token'])) { + $error_msg = $body['error_description'] ?? __('Failed to obtain access token', 'ultimate-multisite'); + wu_log_add('paypal', 'Failed to get partner access token: ' . $error_msg, LogLevel::ERROR); + return new \WP_Error('wu_paypal_token_error', $error_msg); + } + + // Cache the token (expires_in is in seconds, subtract 5 minutes for safety) + $expires_in = isset($body['expires_in']) ? (int) $body['expires_in'] - 300 : 3300; + set_site_transient($cache_key, $body['access_token'], $expires_in); + + return $body['access_token']; + } + + /** + * Generate a partner referral URL for merchant onboarding. + * + * Uses PayPal Partner Referrals API v2 to create an onboarding link. + * + * @since 2.0.0 + * @return array|\WP_Error Array with action_url and tracking_id, or error. + */ + public function generate_referral_url() { + + $access_token = $this->get_partner_access_token(); + + if (is_wp_error($access_token)) { + return $access_token; + } + + // Generate a unique tracking ID for this onboarding attempt + $tracking_id = 'wu_' . wp_generate_uuid4(); + + // Store tracking ID for verification when merchant returns + set_site_transient( + 'wu_paypal_onboarding_' . $tracking_id, + [ + 'started' => time(), + 'test_mode' => $this->test_mode, + ], + DAY_IN_SECONDS + ); + + // Build the return URL + $return_url = add_query_arg( + [ + 'page' => 'wp-ultimo-settings', + 'tab' => 'payment-gateways', + 'wu_paypal_onboarding' => 'complete', + 'tracking_id' => $tracking_id, + ], + network_admin_url('admin.php') + ); + + // Build the partner referral request + $referral_data = [ + 'tracking_id' => $tracking_id, + 'partner_config_override' => [ + 'return_url' => $return_url, + ], + 'operations' => [ + [ + 'operation' => 'API_INTEGRATION', + 'api_integration_preference' => [ + 'rest_api_integration' => [ + 'integration_method' => 'PAYPAL', + 'integration_type' => 'THIRD_PARTY', + 'third_party_details' => [ + 'features' => [ + 'PAYMENT', + 'REFUND', + 'PARTNER_FEE', + 'DELAY_FUNDS_DISBURSEMENT', + ], + ], + ], + ], + ], + ], + 'products' => ['EXPRESS_CHECKOUT'], + 'legal_consents' => [ + [ + 'type' => 'SHARE_DATA_CONSENT', + 'granted' => true, + ], + ], + ]; + + /** + * Filters the partner referral data before sending to PayPal. + * + * @since 2.0.0 + * + * @param array $referral_data The referral request data. + * @param string $tracking_id The tracking ID for this onboarding. + */ + $referral_data = apply_filters('wu_paypal_partner_referral_data', $referral_data, $tracking_id); + + $response = wp_remote_post( + $this->get_api_base_url() . '/v2/customer/partner-referrals', + [ + 'headers' => [ + 'Authorization' => 'Bearer ' . $access_token, + 'Content-Type' => 'application/json', + 'PayPal-Partner-Attribution-Id' => $this->bn_code, + ], + 'body' => wp_json_encode($referral_data), + 'timeout' => 30, + ] + ); + + if (is_wp_error($response)) { + wu_log_add('paypal', 'Failed to create partner referral: ' . $response->get_error_message(), LogLevel::ERROR); + return $response; + } + + $body = json_decode(wp_remote_retrieve_body($response), true); + $code = wp_remote_retrieve_response_code($response); + + if (201 !== $code || empty($body['links'])) { + $error_msg = $body['message'] ?? __('Failed to create partner referral', 'ultimate-multisite'); + wu_log_add('paypal', 'Failed to create partner referral: ' . wp_json_encode($body), LogLevel::ERROR); + return new \WP_Error('wu_paypal_referral_error', $error_msg); + } + + // Find the action_url link + $action_url = ''; + foreach ($body['links'] as $link) { + if ('action_url' === $link['rel']) { + $action_url = $link['href']; + break; + } + } + + if (empty($action_url)) { + return new \WP_Error('wu_paypal_no_action_url', __('No action URL returned from PayPal', 'ultimate-multisite')); + } + + wu_log_add('paypal', sprintf('Partner referral created. Tracking ID: %s', $tracking_id)); + + return [ + 'action_url' => $action_url, + 'tracking_id' => $tracking_id, + ]; + } + + /** + * AJAX handler to initiate OAuth flow. + * + * @since 2.0.0 + * @return void + */ + public function ajax_initiate_oauth(): void { + + check_ajax_referer('wu_paypal_oauth', 'nonce'); + + if (! current_user_can('manage_network_options')) { + wp_send_json_error( + [ + 'message' => __('You do not have permission to do this.', 'ultimate-multisite'), + ] + ); + } + + // Update test mode from request if provided + if (isset($_POST['sandbox_mode'])) { + $this->test_mode = (bool) (int) $_POST['sandbox_mode']; + $this->load_partner_credentials(); + } + + $result = $this->generate_referral_url(); + + if (is_wp_error($result)) { + wp_send_json_error( + [ + 'message' => $result->get_error_message(), + ] + ); + } + + wp_send_json_success( + [ + 'redirect_url' => $result['action_url'], + 'tracking_id' => $result['tracking_id'], + ] + ); + } + + /** + * Handle the OAuth return callback. + * + * When the merchant completes the PayPal onboarding flow, they are redirected + * back to WordPress with parameters indicating success/failure. + * + * @since 2.0.0 + * @return void + */ + public function handle_oauth_return(): void { + + // Check if this is an OAuth return - nonce verification not possible for external OAuth callback + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- OAuth callback from PayPal + if (! isset($_GET['wu_paypal_onboarding']) || 'complete' !== $_GET['wu_paypal_onboarding']) { + return; + } + + // Verify we're on the settings page + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- OAuth callback from PayPal + if (! isset($_GET['page']) || 'wp-ultimo-settings' !== $_GET['page']) { + return; + } + + // Get parameters from PayPal + // phpcs:disable WordPress.Security.NonceVerification.Recommended -- OAuth callback from PayPal + $merchant_id = isset($_GET['merchantIdInPayPal']) ? sanitize_text_field(wp_unslash($_GET['merchantIdInPayPal'])) : ''; + $merchant_email = isset($_GET['merchantId']) ? sanitize_email(wp_unslash($_GET['merchantId'])) : ''; + $permissions_granted = isset($_GET['permissionsGranted']) && 'true' === $_GET['permissionsGranted']; + $consent_status = isset($_GET['consentStatus']) && 'true' === $_GET['consentStatus']; + $risk_status = isset($_GET['isEmailConfirmed']) ? sanitize_text_field(wp_unslash($_GET['isEmailConfirmed'])) : ''; + $tracking_id = isset($_GET['tracking_id']) ? sanitize_text_field(wp_unslash($_GET['tracking_id'])) : ''; + // phpcs:enable WordPress.Security.NonceVerification.Recommended + + // Verify tracking ID + $onboarding_data = get_site_transient('wu_paypal_onboarding_' . $tracking_id); + + if (! $onboarding_data) { + wu_log_add('paypal', 'OAuth return with invalid tracking ID: ' . $tracking_id, LogLevel::WARNING); + $this->add_oauth_notice('error', __('Invalid onboarding session. Please try again.', 'ultimate-multisite')); + return; + } + + // Update test mode to match the onboarding session + $this->test_mode = $onboarding_data['test_mode']; + + // Check if permissions were granted + if (! $permissions_granted) { + wu_log_add('paypal', 'OAuth: Merchant did not grant permissions', LogLevel::WARNING); + $this->add_oauth_notice('warning', __('PayPal permissions were not granted. Please try again and approve the required permissions.', 'ultimate-multisite')); + return; + } + + // Verify the merchant status with PayPal + $merchant_status = $this->verify_merchant_status($merchant_id, $tracking_id); + + if (is_wp_error($merchant_status)) { + wu_log_add('paypal', 'Failed to verify merchant status: ' . $merchant_status->get_error_message(), LogLevel::ERROR); + $this->add_oauth_notice('error', __('Failed to verify your PayPal account status. Please try again.', 'ultimate-multisite')); + return; + } + + // Store the merchant credentials + $mode_prefix = $this->test_mode ? 'sandbox' : 'live'; + + wu_save_setting("paypal_rest_{$mode_prefix}_merchant_id", $merchant_id); + wu_save_setting("paypal_rest_{$mode_prefix}_merchant_email", $merchant_email); + wu_save_setting('paypal_rest_connected', true); + wu_save_setting('paypal_rest_connection_date', current_time('mysql')); + wu_save_setting('paypal_rest_connection_mode', $mode_prefix); + + // Store additional status info if available + if (! empty($merchant_status['payments_receivable'])) { + wu_save_setting("paypal_rest_{$mode_prefix}_payments_receivable", $merchant_status['payments_receivable']); + } + if (! empty($merchant_status['primary_email_confirmed'])) { + wu_save_setting("paypal_rest_{$mode_prefix}_email_confirmed", $merchant_status['primary_email_confirmed']); + } + + // Clean up the tracking transient + delete_site_transient('wu_paypal_onboarding_' . $tracking_id); + + wu_log_add('paypal', sprintf('PayPal OAuth completed. Merchant ID: %s, Mode: %s', $merchant_id, $mode_prefix)); + + // Automatically install webhooks for the connected account + $this->install_webhook_after_oauth($mode_prefix); + + $this->add_oauth_notice('success', __('PayPal account connected successfully!', 'ultimate-multisite')); + + // Redirect to remove query parameters + wp_safe_redirect( + add_query_arg( + [ + 'page' => 'wp-ultimo-settings', + 'tab' => 'payment-gateways', + 'paypal_connected' => '1', + ], + network_admin_url('admin.php') + ) + ); + exit; + } + + /** + * Verify merchant status after OAuth completion. + * + * Calls PayPal to verify the merchant's integration status and capabilities. + * + * @since 2.0.0 + * + * @param string $merchant_id The merchant's PayPal ID. + * @param string $tracking_id The tracking ID from onboarding. + * @return array|\WP_Error Merchant status data or error. + */ + protected function verify_merchant_status(string $merchant_id, string $tracking_id) { + + $access_token = $this->get_partner_access_token(); + + if (is_wp_error($access_token)) { + return $access_token; + } + + if (empty($this->partner_merchant_id)) { + // If no partner merchant ID, we can't verify status via partner API + // Return basic success + return [ + 'merchant_id' => $merchant_id, + 'payments_receivable' => true, + ]; + } + + $response = wp_remote_get( + $this->get_api_base_url() . '/v1/customer/partners/' . $this->partner_merchant_id . '/merchant-integrations/' . $merchant_id, + [ + 'headers' => [ + 'Authorization' => 'Bearer ' . $access_token, + 'Content-Type' => 'application/json', + 'PayPal-Partner-Attribution-Id' => $this->bn_code, + ], + 'timeout' => 30, + ] + ); + + if (is_wp_error($response)) { + return $response; + } + + $body = json_decode(wp_remote_retrieve_body($response), true); + $code = wp_remote_retrieve_response_code($response); + + if (200 !== $code) { + $error_msg = $body['message'] ?? __('Failed to verify merchant status', 'ultimate-multisite'); + return new \WP_Error('wu_paypal_verify_error', $error_msg); + } + + return [ + 'merchant_id' => $body['merchant_id'] ?? $merchant_id, + 'tracking_id' => $body['tracking_id'] ?? $tracking_id, + 'payments_receivable' => $body['payments_receivable'] ?? false, + 'primary_email_confirmed' => $body['primary_email_confirmed'] ?? false, + 'oauth_integrations' => $body['oauth_integrations'] ?? [], + ]; + } + + /** + * AJAX handler to disconnect PayPal. + * + * @since 2.0.0 + * @return void + */ + public function ajax_disconnect(): void { + + check_ajax_referer('wu_paypal_oauth', 'nonce'); + + if (! current_user_can('manage_network_options')) { + wp_send_json_error( + [ + 'message' => __('You do not have permission to do this.', 'ultimate-multisite'), + ] + ); + } + + // Delete webhooks before clearing credentials + $this->delete_webhooks_on_disconnect(); + + // Clear all connection data + $settings_to_clear = [ + 'paypal_rest_connected', + 'paypal_rest_connection_date', + 'paypal_rest_connection_mode', + 'paypal_rest_sandbox_merchant_id', + 'paypal_rest_sandbox_merchant_email', + 'paypal_rest_sandbox_payments_receivable', + 'paypal_rest_sandbox_email_confirmed', + 'paypal_rest_live_merchant_id', + 'paypal_rest_live_merchant_email', + 'paypal_rest_live_payments_receivable', + 'paypal_rest_live_email_confirmed', + 'paypal_rest_sandbox_webhook_id', + 'paypal_rest_live_webhook_id', + ]; + + foreach ($settings_to_clear as $setting) { + wu_save_setting($setting, ''); + } + + // Clear cached access tokens + delete_site_transient('wu_paypal_partner_token_sandbox'); + delete_site_transient('wu_paypal_partner_token_live'); + delete_site_transient('wu_paypal_rest_access_token_sandbox'); + delete_site_transient('wu_paypal_rest_access_token_live'); + + wu_log_add('paypal', 'PayPal account disconnected'); + + wp_send_json_success( + [ + 'message' => __('PayPal account disconnected successfully.', 'ultimate-multisite'), + ] + ); + } + + /** + * Add an admin notice for OAuth status. + * + * @since 2.0.0 + * + * @param string $type Notice type: 'success', 'error', 'warning', 'info'. + * @param string $message The notice message. + * @return void + */ + protected function add_oauth_notice(string $type, string $message): void { + + set_site_transient( + 'wu_paypal_oauth_notice', + [ + 'type' => $type, + 'message' => $message, + ], + 60 + ); + } + + /** + * Display OAuth notices. + * + * Should be called on admin_notices hook. + * + * @since 2.0.0 + * @return void + */ + public function display_oauth_notices(): void { + + $notice = get_site_transient('wu_paypal_oauth_notice'); + + if ($notice) { + delete_site_transient('wu_paypal_oauth_notice'); + + $class = 'notice notice-' . esc_attr($notice['type']) . ' is-dismissible'; + printf( + '

%2$s

', + esc_attr($class), + esc_html($notice['message']) + ); + } + } + + /** + * Check if OAuth is fully configured. + * + * @since 2.0.0 + * @return bool + */ + public function is_configured(): bool { + + return ! empty($this->partner_client_id) && ! empty($this->partner_client_secret); + } + + /** + * Check if a merchant is connected via OAuth. + * + * @since 2.0.0 + * + * @param bool $sandbox Whether to check sandbox mode. + * @return bool + */ + public function is_merchant_connected(bool $sandbox = true): bool { + + $mode_prefix = $sandbox ? 'sandbox' : 'live'; + $merchant_id = wu_get_setting("paypal_rest_{$mode_prefix}_merchant_id", ''); + + return ! empty($merchant_id); + } + + /** + * Get connected merchant details. + * + * @since 2.0.0 + * + * @param bool $sandbox Whether to get sandbox mode details. + * @return array + */ + public function get_merchant_details(bool $sandbox = true): array { + + $mode_prefix = $sandbox ? 'sandbox' : 'live'; + + return [ + 'merchant_id' => wu_get_setting("paypal_rest_{$mode_prefix}_merchant_id", ''), + 'merchant_email' => wu_get_setting("paypal_rest_{$mode_prefix}_merchant_email", ''), + 'payments_receivable' => wu_get_setting("paypal_rest_{$mode_prefix}_payments_receivable", false), + 'email_confirmed' => wu_get_setting("paypal_rest_{$mode_prefix}_email_confirmed", false), + 'connection_date' => wu_get_setting('paypal_rest_connection_date', ''), + ]; + } + + /** + * Install webhooks after successful OAuth connection. + * + * Creates the webhook endpoint in PayPal to receive subscription and payment events. + * + * @since 2.0.0 + * + * @param string $mode_prefix The mode prefix ('sandbox' or 'live'). + * @return void + */ + protected function install_webhook_after_oauth(string $mode_prefix): void { + + try { + // Get the PayPal REST gateway instance + $gateway_manager = \WP_Ultimo\Managers\Gateway_Manager::get_instance(); + $gateway = $gateway_manager->get_gateway('paypal-rest'); + + if (! $gateway instanceof PayPal_REST_Gateway) { + wu_log_add('paypal', 'Could not get PayPal REST gateway instance for webhook installation', LogLevel::WARNING); + return; + } + + // Ensure the gateway is in the correct mode + $gateway->set_test_mode('sandbox' === $mode_prefix); + + // Install the webhook + $result = $gateway->install_webhook(); + + if (true === $result) { + wu_log_add('paypal', sprintf('Webhook installed successfully for %s mode after OAuth', $mode_prefix)); + } elseif (is_wp_error($result)) { + wu_log_add('paypal', sprintf('Failed to install webhook after OAuth: %s', $result->get_error_message()), LogLevel::ERROR); + } + } catch (\Exception $e) { + wu_log_add('paypal', sprintf('Exception installing webhook after OAuth: %s', $e->getMessage()), LogLevel::ERROR); + } + } + + /** + * Delete webhooks when disconnecting from PayPal. + * + * Attempts to delete webhooks from both sandbox and live modes. + * + * @since 2.0.0 + * @return void + */ + protected function delete_webhooks_on_disconnect(): void { + + try { + $gateway_manager = \WP_Ultimo\Managers\Gateway_Manager::get_instance(); + $gateway = $gateway_manager->get_gateway('paypal-rest'); + + if (! $gateway instanceof PayPal_REST_Gateway) { + return; + } + + // Try to delete sandbox webhook + $gateway->set_test_mode(true); + $result = $gateway->delete_webhook(); + if (is_wp_error($result)) { + wu_log_add('paypal', sprintf('Failed to delete sandbox webhook: %s', $result->get_error_message()), LogLevel::WARNING); + } else { + wu_log_add('paypal', 'Sandbox webhook deleted during disconnect'); + } + + // Try to delete live webhook + $gateway->set_test_mode(false); + $result = $gateway->delete_webhook(); + if (is_wp_error($result)) { + wu_log_add('paypal', sprintf('Failed to delete live webhook: %s', $result->get_error_message()), LogLevel::WARNING); + } else { + wu_log_add('paypal', 'Live webhook deleted during disconnect'); + } + } catch (\Exception $e) { + wu_log_add('paypal', sprintf('Exception deleting webhooks during disconnect: %s', $e->getMessage()), LogLevel::WARNING); + } + } +} diff --git a/inc/gateways/class-paypal-rest-gateway.php b/inc/gateways/class-paypal-rest-gateway.php new file mode 100644 index 00000000..086fee2b --- /dev/null +++ b/inc/gateways/class-paypal-rest-gateway.php @@ -0,0 +1,1624 @@ +test_mode = (bool) (int) wu_get_setting('paypal_rest_sandbox_mode', true); + + $this->load_credentials(); + } + + /** + * Load credentials from settings. + * + * @since 2.0.0 + * @return void + */ + protected function load_credentials(): void { + + $mode_prefix = $this->test_mode ? 'sandbox' : 'live'; + + // First check for OAuth-connected merchant + $this->merchant_id = wu_get_setting("paypal_rest_{$mode_prefix}_merchant_id", ''); + + // Load client credentials (either from OAuth or manual entry) + $this->client_id = wu_get_setting("paypal_rest_{$mode_prefix}_client_id", ''); + $this->client_secret = wu_get_setting("paypal_rest_{$mode_prefix}_client_secret", ''); + } + + /** + * Set the test mode and reload credentials. + * + * @since 2.0.0 + * + * @param bool $test_mode Whether to use sandbox mode. + * @return void + */ + public function set_test_mode(bool $test_mode): void { + + $this->test_mode = $test_mode; + $this->access_token = ''; // Clear cached token + + $this->load_credentials(); + } + + /** + * Adds the necessary hooks for the REST PayPal gateway. + * + * @since 2.0.0 + * @return void + */ + public function hooks(): void { + + parent::hooks(); + + // Initialize OAuth handler + PayPal_OAuth_Handler::get_instance()->init(); + + // Handle webhook installation after settings save + add_action('wu_after_save_settings', [$this, 'maybe_install_webhook'], 10, 3); + + // AJAX handler for manual webhook installation + add_action('wp_ajax_wu_paypal_install_webhook', [$this, 'ajax_install_webhook']); + + // Display OAuth notices + add_action('admin_notices', [PayPal_OAuth_Handler::get_instance(), 'display_oauth_notices']); + } + + /** + * Checks if PayPal REST is properly configured. + * + * @since 2.0.0 + * @return bool + */ + public function is_configured(): bool { + + // Either OAuth connected OR manual credentials + $has_oauth = ! empty($this->merchant_id); + $has_manual = ! empty($this->client_id) && ! empty($this->client_secret); + + return $has_oauth || $has_manual; + } + + /** + * Returns the connection status for display in settings. + * + * @since 2.0.0 + * @return array{connected: bool, message: string, details: array} + */ + public function get_connection_status(): array { + + $oauth_handler = PayPal_OAuth_Handler::get_instance(); + $is_sandbox = $this->test_mode; + + if ($oauth_handler->is_merchant_connected($is_sandbox)) { + $merchant_details = $oauth_handler->get_merchant_details($is_sandbox); + + return [ + 'connected' => true, + 'message' => __('Connected via PayPal', 'ultimate-multisite'), + 'details' => [ + 'mode' => $is_sandbox ? 'sandbox' : 'live', + 'merchant_id' => $merchant_details['merchant_id'], + 'email' => $merchant_details['merchant_email'], + 'connected_at' => $merchant_details['connection_date'], + 'method' => 'oauth', + ], + ]; + } + + if (! empty($this->client_id) && ! empty($this->client_secret)) { + return [ + 'connected' => true, + 'message' => __('Connected via API credentials', 'ultimate-multisite'), + 'details' => [ + 'mode' => $is_sandbox ? 'sandbox' : 'live', + 'client_id' => substr($this->client_id, 0, 20) . '...', + 'method' => 'manual', + ], + ]; + } + + return [ + 'connected' => false, + 'message' => __('Not connected', 'ultimate-multisite'), + 'details' => [ + 'mode' => $is_sandbox ? 'sandbox' : 'live', + ], + ]; + } + + /** + * Get an access token for API requests. + * + * @since 2.0.0 + * @return string|\WP_Error Access token or error. + */ + protected function get_access_token() { + + if (! empty($this->access_token)) { + return $this->access_token; + } + + // Check for cached token + $cache_key = 'wu_paypal_rest_access_token_' . ($this->test_mode ? 'sandbox' : 'live'); + $cached_token = get_site_transient($cache_key); + + if ($cached_token) { + $this->access_token = $cached_token; + return $this->access_token; + } + + if (empty($this->client_id) || empty($this->client_secret)) { + return new \WP_Error( + 'wu_paypal_missing_credentials', + __('PayPal API credentials not configured.', 'ultimate-multisite') + ); + } + + $response = wp_remote_post( + $this->get_api_base_url() . '/v1/oauth2/token', + [ + 'headers' => [ + 'Authorization' => 'Basic ' . base64_encode($this->client_id . ':' . $this->client_secret), + 'Content-Type' => 'application/x-www-form-urlencoded', + ], + 'body' => 'grant_type=client_credentials', + 'timeout' => 30, + ] + ); + + if (is_wp_error($response)) { + $this->log('Failed to get access token: ' . $response->get_error_message(), LogLevel::ERROR); + return $response; + } + + $body = json_decode(wp_remote_retrieve_body($response), true); + $code = wp_remote_retrieve_response_code($response); + + if (200 !== $code || empty($body['access_token'])) { + $error_msg = $body['error_description'] ?? __('Failed to obtain access token', 'ultimate-multisite'); + $this->log('Failed to get access token: ' . $error_msg, LogLevel::ERROR); + return new \WP_Error('wu_paypal_token_error', $error_msg); + } + + // Cache the token (expires_in is in seconds, subtract 5 minutes for safety) + $expires_in = isset($body['expires_in']) ? (int) $body['expires_in'] - 300 : 3300; + set_site_transient($cache_key, $body['access_token'], $expires_in); + + $this->access_token = $body['access_token']; + + return $this->access_token; + } + + /** + * Make an API request to PayPal REST API. + * + * @since 2.0.0 + * + * @param string $endpoint API endpoint (relative to base URL). + * @param array $data Request data. + * @param string $method HTTP method. + * @return array|\WP_Error Response data or error. + */ + protected function api_request(string $endpoint, array $data = [], string $method = 'POST') { + + $access_token = $this->get_access_token(); + + if (is_wp_error($access_token)) { + return $access_token; + } + + $headers = [ + 'Authorization' => 'Bearer ' . $access_token, + 'Content-Type' => 'application/json', + ]; + + $headers = $this->add_partner_attribution_header($headers); + + $args = [ + 'headers' => $headers, + 'method' => $method, + 'timeout' => 45, + ]; + + if (! empty($data) && in_array($method, ['POST', 'PATCH', 'PUT'], true)) { + $args['body'] = wp_json_encode($data); + } + + $url = $this->get_api_base_url() . $endpoint; + + $this->log(sprintf('API Request: %s %s', $method, $endpoint)); + + $response = wp_remote_request($url, $args); + + if (is_wp_error($response)) { + $this->log('API Request failed: ' . $response->get_error_message(), LogLevel::ERROR); + return $response; + } + + $body = json_decode(wp_remote_retrieve_body($response), true); + $code = wp_remote_retrieve_response_code($response); + + if ($code >= 400) { + $error_msg = $body['message'] ?? ($body['error_description'] ?? __('API request failed', 'ultimate-multisite')); + $this->log(sprintf('API Error (%d): %s', $code, wp_json_encode($body)), LogLevel::ERROR); + return new \WP_Error( + 'wu_paypal_api_error', + $error_msg, + [ + 'status' => $code, + 'response' => $body, + ] + ); + } + + return $body ?? []; + } + + /** + * Process a checkout. + * + * @since 2.0.0 + * + * @param \WP_Ultimo\Models\Payment $payment The payment associated with the checkout. + * @param \WP_Ultimo\Models\Membership $membership The membership. + * @param \WP_Ultimo\Models\Customer $customer The customer checking out. + * @param \WP_Ultimo\Checkout\Cart $cart The cart object. + * @param string $type The checkout type. + * @return void + */ + public function process_checkout($payment, $membership, $customer, $cart, $type): void { + + $should_auto_renew = $cart->should_auto_renew(); + $is_recurring = $cart->has_recurring(); + + if ($should_auto_renew && $is_recurring) { + $this->create_subscription($payment, $membership, $customer, $cart, $type); + } else { + $this->create_order($payment, $membership, $customer, $cart, $type); + } + } + + /** + * Create a PayPal subscription for recurring payments. + * + * @since 2.0.0 + * + * @param \WP_Ultimo\Models\Payment $payment The payment. + * @param \WP_Ultimo\Models\Membership $membership The membership. + * @param \WP_Ultimo\Models\Customer $customer The customer. + * @param \WP_Ultimo\Checkout\Cart $cart The cart object. + * @param string $type The checkout type. + * @return void + */ + protected function create_subscription($payment, $membership, $customer, $cart, $type): void { + + $currency = strtoupper($payment->get_currency()); + $description = $this->get_subscription_description($cart); + + // First, create or get the billing plan + $plan_id = $this->get_or_create_plan($cart, $currency); + + if (is_wp_error($plan_id)) { + wp_die( + esc_html($plan_id->get_error_message()), + esc_html__('PayPal Error', 'ultimate-multisite'), + [ + 'back_link' => true, + 'response' => '200', + ] + ); + } + + // Create the subscription + $subscription_data = [ + 'plan_id' => $plan_id, + 'subscriber' => [ + 'name' => [ + 'given_name' => $customer->get_display_name(), + ], + 'email_address' => $customer->get_email_address(), + ], + 'application_context' => [ + 'brand_name' => wu_get_setting('company_name', get_network_option(null, 'site_name')), + 'locale' => str_replace('_', '-', get_locale()), + 'shipping_preference' => 'NO_SHIPPING', + 'user_action' => 'SUBSCRIBE_NOW', + 'return_url' => $this->get_confirm_url(), + 'cancel_url' => $this->get_cancel_url(), + ], + 'custom_id' => sprintf('%s|%s|%s', $payment->get_id(), $membership->get_id(), $customer->get_id()), + ]; + + // Handle initial payment if different from recurring + $initial_amount = $payment->get_total(); + $recurring_amount = $cart->get_recurring_total(); + + if ($initial_amount > 0 && abs($initial_amount - $recurring_amount) > 0.01) { + // Add setup fee for the difference + $setup_fee = $initial_amount - $recurring_amount; + if ($setup_fee > 0) { + $subscription_data['plan'] = [ + 'payment_preferences' => [ + 'setup_fee' => [ + 'value' => number_format($setup_fee, 2, '.', ''), + 'currency_code' => $currency, + ], + ], + ]; + } + } + + // Handle trial periods + if ($membership->is_trialing()) { + $trial_end = $membership->get_date_trial_end(); + if ($trial_end) { + $subscription_data['start_time'] = gmdate('Y-m-d\TH:i:s\Z', strtotime($trial_end)); + } + } + + /** + * Filter subscription data before creating. + * + * @since 2.0.0 + * + * @param array $subscription_data The subscription data. + * @param \WP_Ultimo\Models\Membership $membership The membership. + * @param \WP_Ultimo\Checkout\Cart $cart The cart. + */ + $subscription_data = apply_filters('wu_paypal_rest_subscription_data', $subscription_data, $membership, $cart); + + $result = $this->api_request('/v1/billing/subscriptions', $subscription_data); + + if (is_wp_error($result)) { + wp_die( + esc_html($result->get_error_message()), + esc_html__('PayPal Error', 'ultimate-multisite'), + [ + 'back_link' => true, + 'response' => '200', + ] + ); + } + + // Find approval URL + $approval_url = ''; + foreach ($result['links'] ?? [] as $link) { + if ('approve' === $link['rel']) { + $approval_url = $link['href']; + break; + } + } + + if (empty($approval_url)) { + wp_die( + esc_html__('Failed to get PayPal approval URL', 'ultimate-multisite'), + esc_html__('PayPal Error', 'ultimate-multisite'), + [ + 'back_link' => true, + 'response' => '200', + ] + ); + } + + // Store subscription ID for confirmation + $membership->set_gateway_subscription_id($result['id']); + $membership->save(); + + $this->log(sprintf('Subscription created: %s. Redirecting to approval.', $result['id'])); + + // Redirect to PayPal for approval + wp_redirect($approval_url); // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect -- PayPal external URL + exit; + } + + /** + * Get or create a billing plan for the subscription. + * + * @since 2.0.0 + * + * @param \WP_Ultimo\Checkout\Cart $cart The cart object. + * @param string $currency The currency code. + * @return string|\WP_Error Plan ID or error. + */ + protected function get_or_create_plan($cart, string $currency) { + + // Generate a unique plan key based on cart contents + $plan_key = 'wu_paypal_plan_' . md5( + wp_json_encode( + [ + 'amount' => $cart->get_recurring_total(), + 'currency' => $currency, + 'duration' => $cart->get_duration(), + 'duration_unit' => $cart->get_duration_unit(), + ] + ) + ); + + // Check if we already have this plan + $existing_plan_id = get_site_option($plan_key); + if ($existing_plan_id) { + // Verify the plan still exists + $plan = $this->api_request('/v1/billing/plans/' . $existing_plan_id, [], 'GET'); + if (! is_wp_error($plan) && isset($plan['id'])) { + return $plan['id']; + } + } + + // First create a product + $product_name = wu_get_setting('company_name', get_network_option(null, 'site_name')) . ' - ' . $cart->get_cart_descriptor(); + + $product_data = [ + 'name' => substr($product_name, 0, 127), + 'description' => substr($this->get_subscription_description($cart), 0, 256), + 'type' => 'SERVICE', + 'category' => 'SOFTWARE', + ]; + + $product = $this->api_request('/v1/catalogs/products', $product_data); + + if (is_wp_error($product)) { + return $product; + } + + // Convert duration unit to PayPal format + $interval_unit = strtoupper($cart->get_duration_unit()); + $interval_map = [ + 'DAY' => 'DAY', + 'WEEK' => 'WEEK', + 'MONTH' => 'MONTH', + 'YEAR' => 'YEAR', + ]; + $paypal_interval = $interval_map[ $interval_unit ] ?? 'MONTH'; + + // Create the billing plan + $plan_data = [ + 'product_id' => $product['id'], + 'name' => substr($product_name, 0, 127), + 'description' => substr($this->get_subscription_description($cart), 0, 127), + 'billing_cycles' => [ + [ + 'frequency' => [ + 'interval_unit' => $paypal_interval, + 'interval_count' => $cart->get_duration(), + ], + 'tenure_type' => 'REGULAR', + 'sequence' => 1, + 'total_cycles' => 0, // 0 = unlimited + 'pricing_scheme' => [ + 'fixed_price' => [ + 'value' => number_format($cart->get_recurring_total(), 2, '.', ''), + 'currency_code' => $currency, + ], + ], + ], + ], + 'payment_preferences' => [ + 'auto_bill_outstanding' => true, + 'payment_failure_threshold' => 3, + ], + ]; + + $plan = $this->api_request('/v1/billing/plans', $plan_data); + + if (is_wp_error($plan)) { + return $plan; + } + + // Cache the plan ID + update_site_option($plan_key, $plan['id']); + + $this->log(sprintf('Billing plan created: %s', $plan['id'])); + + return $plan['id']; + } + + /** + * Create a PayPal order for one-time payments. + * + * @since 2.0.0 + * + * @param \WP_Ultimo\Models\Payment $payment The payment. + * @param \WP_Ultimo\Models\Membership $membership The membership. + * @param \WP_Ultimo\Models\Customer $customer The customer. + * @param \WP_Ultimo\Checkout\Cart $cart The cart object. + * @param string $type The checkout type. + * @return void + */ + protected function create_order($payment, $membership, $customer, $cart, $type): void { + + $currency = strtoupper($payment->get_currency()); + $description = $this->get_subscription_description($cart); + + $order_data = [ + 'intent' => 'CAPTURE', + 'purchase_units' => [ + [ + 'reference_id' => $payment->get_hash(), + 'description' => substr($description, 0, 127), + 'custom_id' => sprintf('%s|%s|%s', $payment->get_id(), $membership->get_id(), $customer->get_id()), + 'amount' => [ + 'currency_code' => $currency, + 'value' => number_format($payment->get_total(), 2, '.', ''), + 'breakdown' => [ + 'item_total' => [ + 'currency_code' => $currency, + 'value' => number_format($payment->get_subtotal(), 2, '.', ''), + ], + 'tax_total' => [ + 'currency_code' => $currency, + 'value' => number_format($payment->get_tax_total(), 2, '.', ''), + ], + ], + ], + 'items' => $this->build_order_items($cart, $currency), + ], + ], + 'application_context' => [ + 'brand_name' => wu_get_setting('company_name', get_network_option(null, 'site_name')), + 'locale' => str_replace('_', '-', get_locale()), + 'shipping_preference' => 'NO_SHIPPING', + 'user_action' => 'PAY_NOW', + 'return_url' => $this->get_confirm_url(), + 'cancel_url' => $this->get_cancel_url(), + ], + ]; + + /** + * Filter order data before creating. + * + * @since 2.0.0 + * + * @param array $order_data The order data. + * @param \WP_Ultimo\Models\Payment $payment The payment. + * @param \WP_Ultimo\Checkout\Cart $cart The cart. + */ + $order_data = apply_filters('wu_paypal_rest_order_data', $order_data, $payment, $cart); + + $result = $this->api_request('/v2/checkout/orders', $order_data); + + if (is_wp_error($result)) { + wp_die( + esc_html($result->get_error_message()), + esc_html__('PayPal Error', 'ultimate-multisite'), + [ + 'back_link' => true, + 'response' => '200', + ] + ); + } + + // Find approval URL + $approval_url = ''; + foreach ($result['links'] ?? [] as $link) { + if ('approve' === $link['rel']) { + $approval_url = $link['href']; + break; + } + } + + if (empty($approval_url)) { + wp_die( + esc_html__('Failed to get PayPal approval URL', 'ultimate-multisite'), + esc_html__('PayPal Error', 'ultimate-multisite'), + [ + 'back_link' => true, + 'response' => '200', + ] + ); + } + + // Store order ID for confirmation + $payment->set_gateway_payment_id($result['id']); + $payment->save(); + + $this->log(sprintf('Order created: %s. Redirecting to approval.', $result['id'])); + + // Redirect to PayPal for approval + wp_redirect($approval_url); // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect -- PayPal external URL + exit; + } + + /** + * Build order items array for PayPal. + * + * @since 2.0.0 + * + * @param \WP_Ultimo\Checkout\Cart $cart The cart object. + * @param string $currency The currency code. + * @return array + */ + protected function build_order_items($cart, string $currency): array { + + $items = []; + + foreach ($cart->get_line_items() as $line_item) { + $items[] = [ + 'name' => substr($line_item->get_title(), 0, 127), + 'description' => substr($line_item->get_description(), 0, 127) ?: null, + 'unit_amount' => [ + 'currency_code' => $currency, + 'value' => number_format($line_item->get_unit_price(), 2, '.', ''), + ], + 'quantity' => (string) $line_item->get_quantity(), + 'category' => 'DIGITAL_GOODS', + ]; + } + + return $items; + } + + /** + * Process confirmation after PayPal approval. + * + * @since 2.0.0 + * @return void + */ + public function process_confirmation(): void { + + $token = sanitize_text_field(wu_request('token', '')); + $subscription_id = sanitize_text_field(wu_request('subscription_id', '')); + + if (! empty($subscription_id)) { + $this->confirm_subscription($subscription_id); + } elseif (! empty($token)) { + $this->confirm_order($token); + } else { + wp_die( + esc_html__('Invalid PayPal confirmation', 'ultimate-multisite'), + esc_html__('PayPal Error', 'ultimate-multisite'), + ['back_link' => true] + ); + } + } + + /** + * Confirm a subscription after PayPal approval. + * + * @since 2.0.0 + * + * @param string $subscription_id The PayPal subscription ID. + * @return void + */ + protected function confirm_subscription(string $subscription_id): void { + + // Get subscription details + $subscription = $this->api_request('/v1/billing/subscriptions/' . $subscription_id, [], 'GET'); + + if (is_wp_error($subscription)) { + wp_die( + esc_html($subscription->get_error_message()), + esc_html__('PayPal Error', 'ultimate-multisite'), + ['back_link' => true] + ); + } + + // Parse custom_id to get our IDs + $custom_parts = explode('|', $subscription['custom_id'] ?? ''); + if (count($custom_parts) !== 3) { + wp_die( + esc_html__('Invalid subscription data', 'ultimate-multisite'), + esc_html__('PayPal Error', 'ultimate-multisite'), + ['back_link' => true] + ); + } + + [$payment_id, $membership_id, $customer_id] = $custom_parts; + + $payment = wu_get_payment($payment_id); + $membership = wu_get_membership($membership_id); + + if (! $payment || ! $membership) { + wp_die( + esc_html__('Payment or membership not found', 'ultimate-multisite'), + esc_html__('PayPal Error', 'ultimate-multisite'), + ['back_link' => true] + ); + } + + // Check subscription status + if ('ACTIVE' === $subscription['status'] || 'APPROVED' === $subscription['status']) { + // Update membership + $membership->set_gateway('paypal-rest'); + $membership->set_gateway_subscription_id($subscription_id); + $membership->set_gateway_customer_id($subscription['subscriber']['payer_id'] ?? ''); + $membership->set_auto_renew(true); + + // Handle based on status + if ('ACTIVE' === $subscription['status']) { + // Payment already processed + $payment->set_status(Payment_Status::COMPLETED); + $membership->renew(false); + } else { + // Will be activated on first payment webhook + $payment->set_status(Payment_Status::PENDING); + } + + $payment->set_gateway('paypal-rest'); + $payment->save(); + $membership->save(); + + $this->log(sprintf('Subscription confirmed: %s, Status: %s', $subscription_id, $subscription['status'])); + + $this->payment = $payment; + wp_safe_redirect($this->get_return_url()); + exit; + } + + wp_die( + // translators: %s is the subscription status + esc_html(sprintf(__('Subscription not approved. Status: %s', 'ultimate-multisite'), $subscription['status'])), + esc_html__('PayPal Error', 'ultimate-multisite'), + ['back_link' => true] + ); + } + + /** + * Confirm an order after PayPal approval. + * + * @since 2.0.0 + * + * @param string $token The PayPal order token. + * @return void + */ + protected function confirm_order(string $token): void { + + // Capture the order + $capture = $this->api_request('/v2/checkout/orders/' . $token . '/capture', []); + + if (is_wp_error($capture)) { + wp_die( + esc_html($capture->get_error_message()), + esc_html__('PayPal Error', 'ultimate-multisite'), + ['back_link' => true] + ); + } + + if ('COMPLETED' !== $capture['status']) { + wp_die( + // translators: %s is the order status + esc_html(sprintf(__('Order not completed. Status: %s', 'ultimate-multisite'), $capture['status'])), + esc_html__('PayPal Error', 'ultimate-multisite'), + ['back_link' => true] + ); + } + + // Parse custom_id + $purchase_unit = $capture['purchase_units'][0] ?? []; + $custom_parts = explode('|', $purchase_unit['payments']['captures'][0]['custom_id'] ?? ''); + + if (count($custom_parts) !== 3) { + wp_die( + esc_html__('Invalid order data', 'ultimate-multisite'), + esc_html__('PayPal Error', 'ultimate-multisite'), + ['back_link' => true] + ); + } + + [$payment_id, $membership_id, $customer_id] = $custom_parts; + + $payment = wu_get_payment($payment_id); + $membership = wu_get_membership($membership_id); + + if (! $payment || ! $membership) { + wp_die( + esc_html__('Payment or membership not found', 'ultimate-multisite'), + esc_html__('PayPal Error', 'ultimate-multisite'), + ['back_link' => true] + ); + } + + // Get transaction ID from capture + $transaction_id = $purchase_unit['payments']['captures'][0]['id'] ?? $token; + + // Update payment + $payment->set_gateway('paypal-rest'); + $payment->set_gateway_payment_id($transaction_id); + $payment->set_status(Payment_Status::COMPLETED); + $payment->save(); + + // Update membership + $membership->set_gateway('paypal-rest'); + $membership->set_gateway_customer_id($capture['payer']['payer_id'] ?? ''); + $membership->add_to_times_billed(1); + $membership->renew(false); + + $this->log(sprintf('Order captured: %s, Transaction: %s', $token, $transaction_id)); + + $this->payment = $payment; + wp_safe_redirect($this->get_return_url()); + exit; + } + + /** + * Process cancellation of a subscription. + * + * @since 2.0.0 + * + * @param \WP_Ultimo\Models\Membership $membership The membership. + * @param \WP_Ultimo\Models\Customer $customer The customer. + * @return void + */ + public function process_cancellation($membership, $customer): void { + + $subscription_id = $membership->get_gateway_subscription_id(); + + if (empty($subscription_id)) { + return; + } + + $result = $this->api_request( + '/v1/billing/subscriptions/' . $subscription_id . '/cancel', + ['reason' => __('Cancelled by user', 'ultimate-multisite')] + ); + + if (is_wp_error($result)) { + $this->log('Failed to cancel subscription: ' . $result->get_error_message(), LogLevel::ERROR); + return; + } + + $this->log(sprintf('Subscription cancelled: %s', $subscription_id)); + } + + /** + * Process refund. + * + * @since 2.0.0 + * + * @param float $amount The amount to refund. + * @param \WP_Ultimo\Models\Payment $payment The payment. + * @param \WP_Ultimo\Models\Membership $membership The membership. + * @param \WP_Ultimo\Models\Customer $customer The customer. + * @return void + * @throws \Exception When refund fails. + */ + public function process_refund($amount, $payment, $membership, $customer): void { + + $capture_id = $payment->get_gateway_payment_id(); + + if (empty($capture_id)) { + throw new \Exception(esc_html__('No capture ID found for this payment.', 'ultimate-multisite')); + } + + $refund_data = []; + + // Only include amount for partial refunds + if ($amount < $payment->get_total()) { + $refund_data['amount'] = [ + 'value' => number_format($amount, 2, '.', ''), + 'currency_code' => strtoupper($payment->get_currency()), + ]; + } + + $result = $this->api_request('/v2/payments/captures/' . $capture_id . '/refund', $refund_data); + + if (is_wp_error($result)) { + throw new \Exception(esc_html($result->get_error_message())); + } + + $this->log(sprintf('Refund processed: %s for capture %s', $result['id'] ?? 'unknown', $capture_id)); + } + + /** + * Reflects membership changes on the gateway. + * + * @since 2.0.0 + * + * @param \WP_Ultimo\Models\Membership $membership The membership. + * @param \WP_Ultimo\Models\Customer $customer The customer. + * @return bool|\WP_Error + */ + public function process_membership_update(&$membership, $customer) { + + $subscription_id = $membership->get_gateway_subscription_id(); + + if (empty($subscription_id)) { + return new \WP_Error( + 'wu_paypal_no_subscription', + __('No subscription ID found for this membership.', 'ultimate-multisite') + ); + } + + // Note: PayPal subscription updates are limited + // For significant changes, may need to cancel and recreate + $this->log(sprintf('Membership update requested for subscription: %s', $subscription_id)); + + return true; + } + + /** + * Adds the PayPal REST Gateway settings to the settings screen. + * + * @since 2.0.0 + * @return void + */ + public function settings(): void { + + wu_register_settings_field( + 'payment-gateways', + 'paypal_rest_header', + [ + 'title' => __('PayPal', 'ultimate-multisite'), + 'desc' => __('Modern PayPal integration with Connect with PayPal onboarding.', 'ultimate-multisite'), + 'type' => 'header', + 'show_as_submenu' => true, + 'require' => [ + 'active_gateways' => 'paypal-rest', + ], + ] + ); + + // Connection status display + wu_register_settings_field( + 'payment-gateways', + 'paypal_rest_connection_status', + [ + 'title' => __('Connection Status', 'ultimate-multisite'), + 'type' => 'note', + 'desc' => [$this, 'render_connection_status'], + 'require' => [ + 'active_gateways' => 'paypal-rest', + ], + ] + ); + + // Sandbox mode toggle + wu_register_settings_field( + 'payment-gateways', + 'paypal_rest_sandbox_mode', + [ + 'title' => __('PayPal Sandbox Mode', 'ultimate-multisite'), + 'desc' => __('Enable sandbox mode for testing.', 'ultimate-multisite'), + 'type' => 'toggle', + 'default' => 1, + 'html_attr' => [ + 'v-model' => 'paypal_rest_sandbox_mode', + ], + 'require' => [ + 'active_gateways' => 'paypal-rest', + ], + ] + ); + + // Connect with PayPal button + wu_register_settings_field( + 'payment-gateways', + 'paypal_rest_connect_button', + [ + 'title' => __('Connect with PayPal', 'ultimate-multisite'), + 'type' => 'note', + 'desc' => [$this, 'render_connect_button'], + 'require' => [ + 'active_gateways' => 'paypal-rest', + ], + ] + ); + + // Advanced/Manual credentials header + wu_register_settings_field( + 'payment-gateways', + 'paypal_rest_manual_header', + [ + 'title' => __('Manual Configuration', 'ultimate-multisite'), + 'desc' => __('Advanced: Enter API credentials manually if Connect with PayPal is not available.', 'ultimate-multisite'), + 'type' => 'header', + 'require' => [ + 'active_gateways' => 'paypal-rest', + ], + ] + ); + + // Sandbox Client ID + wu_register_settings_field( + 'payment-gateways', + 'paypal_rest_sandbox_client_id', + [ + 'title' => __('Sandbox Client ID', 'ultimate-multisite'), + 'placeholder' => __('e.g. AX7MV...', 'ultimate-multisite'), + 'type' => 'text', + 'default' => '', + 'capability' => 'manage_api_keys', + 'require' => [ + 'active_gateways' => 'paypal-rest', + 'paypal_rest_sandbox_mode' => 1, + ], + ] + ); + + // Sandbox Client Secret + wu_register_settings_field( + 'payment-gateways', + 'paypal_rest_sandbox_client_secret', + [ + 'title' => __('Sandbox Client Secret', 'ultimate-multisite'), + 'placeholder' => __('e.g. EK4jT...', 'ultimate-multisite'), + 'type' => 'text', + 'default' => '', + 'capability' => 'manage_api_keys', + 'require' => [ + 'active_gateways' => 'paypal-rest', + 'paypal_rest_sandbox_mode' => 1, + ], + ] + ); + + // Live Client ID + wu_register_settings_field( + 'payment-gateways', + 'paypal_rest_live_client_id', + [ + 'title' => __('Live Client ID', 'ultimate-multisite'), + 'placeholder' => __('e.g. AX7MV...', 'ultimate-multisite'), + 'type' => 'text', + 'default' => '', + 'capability' => 'manage_api_keys', + 'require' => [ + 'active_gateways' => 'paypal-rest', + 'paypal_rest_sandbox_mode' => 0, + ], + ] + ); + + // Live Client Secret + wu_register_settings_field( + 'payment-gateways', + 'paypal_rest_live_client_secret', + [ + 'title' => __('Live Client Secret', 'ultimate-multisite'), + 'placeholder' => __('e.g. EK4jT...', 'ultimate-multisite'), + 'type' => 'text', + 'default' => '', + 'capability' => 'manage_api_keys', + 'require' => [ + 'active_gateways' => 'paypal-rest', + 'paypal_rest_sandbox_mode' => 0, + ], + ] + ); + + // Webhook URL display + wu_register_settings_field( + 'payment-gateways', + 'paypal_rest_webhook_url', + [ + 'title' => __('Webhook URL', 'ultimate-multisite'), + 'desc' => __('Webhooks are automatically configured when you connect your PayPal account.', 'ultimate-multisite'), + 'type' => 'text-display', + 'copy' => true, + 'value' => $this->get_webhook_listener_url(), + 'capability' => 'manage_api_keys', + 'require' => [ + 'active_gateways' => 'paypal-rest', + ], + ] + ); + + // Webhook status + wu_register_settings_field( + 'payment-gateways', + 'paypal_rest_webhook_status', + [ + 'title' => __('Webhook Status', 'ultimate-multisite'), + 'type' => 'note', + 'desc' => [$this, 'render_webhook_status'], + 'require' => [ + 'active_gateways' => 'paypal-rest', + ], + ] + ); + } + + /** + * Render connection status HTML. + * + * @since 2.0.0 + * @return string + */ + public function render_connection_status(): string { + + $status = $this->get_connection_status(); + + if ($status['connected']) { + $mode_label = 'sandbox' === $status['details']['mode'] + ? __('Sandbox', 'ultimate-multisite') + : __('Live', 'ultimate-multisite'); + + $email = $status['details']['email'] ?? ($status['details']['client_id'] ?? ''); + + return sprintf( + '
+ + %s (%s)
+ %s +
', + esc_html($status['message']), + esc_html($mode_label), + esc_html($email) + ); + } + + return sprintf( + '
+ + %s +
', + esc_html__('Not connected. Use Connect with PayPal below or enter credentials manually.', 'ultimate-multisite') + ); + } + + /** + * Render the Connect with PayPal button. + * + * @since 2.0.0 + * @return string + */ + public function render_connect_button(): string { + + $nonce = wp_create_nonce('wu_paypal_oauth'); + $is_sandbox = $this->test_mode; + $oauth = PayPal_OAuth_Handler::get_instance(); + $is_connected = $oauth->is_merchant_connected($is_sandbox); + + if ($is_connected) { + return sprintf( + ' +

%s

', + esc_attr($nonce), + esc_html__('Disconnect PayPal', 'ultimate-multisite'), + esc_html__('This will remove the PayPal connection. Existing subscriptions will continue to work.', 'ultimate-multisite') + ); + } + + return sprintf( + ' +

%s

+ ', + esc_attr($nonce), + $is_sandbox ? 1 : 0, + esc_html__('Connect with PayPal', 'ultimate-multisite'), + esc_html__('Click to securely connect your PayPal account.', 'ultimate-multisite'), + esc_js(__('Connecting...', 'ultimate-multisite')), + esc_js(__('Connection failed. Please try again.', 'ultimate-multisite')), + esc_js(__('Connect with PayPal', 'ultimate-multisite')), + esc_js(__('Connection failed. Please try again.', 'ultimate-multisite')), + esc_js(__('Connect with PayPal', 'ultimate-multisite')), + esc_js(__('Are you sure you want to disconnect PayPal?', 'ultimate-multisite')) + ); + } + + /** + * Render webhook status HTML. + * + * @since 2.0.0 + * @return string + */ + public function render_webhook_status(): string { + + $mode_prefix = $this->test_mode ? 'sandbox' : 'live'; + $mode_label = $this->test_mode + ? __('Sandbox', 'ultimate-multisite') + : __('Live', 'ultimate-multisite'); + + $webhook_id = wu_get_setting("paypal_rest_{$mode_prefix}_webhook_id", ''); + + if (! empty($webhook_id)) { + return sprintf( + '
+ + %s
+ %s: %s +
', + esc_html__('Webhook configured', 'ultimate-multisite'), + esc_html($mode_label), + esc_html($webhook_id) + ); + } + + // Check if we have credentials but no webhook + if ($this->is_configured()) { + return sprintf( + '
+ + %s
+ +
+ ', + esc_html__('Webhook not configured. Click below to configure automatically.', 'ultimate-multisite'), + esc_attr(wp_create_nonce('wu_paypal_webhook')), + esc_html__('Configure Webhook', 'ultimate-multisite'), + esc_js(__('Configuring...', 'ultimate-multisite')), + esc_js(__('Failed to configure webhook. Please try again.', 'ultimate-multisite')), + esc_js(__('Configure Webhook', 'ultimate-multisite')), + esc_js(__('Failed to configure webhook. Please try again.', 'ultimate-multisite')), + esc_js(__('Configure Webhook', 'ultimate-multisite')) + ); + } + + return sprintf( + '
+ + %s +
', + esc_html__('Connect with PayPal to automatically configure webhooks.', 'ultimate-multisite') + ); + } + + /** + * Maybe install webhook after settings save. + * + * Automatically creates a webhook in PayPal when credentials are configured. + * + * @since 2.0.0 + * + * @param array $settings The final settings array. + * @param array $settings_to_save Settings being updated. + * @param array $saved_settings Original settings. + * @return void + */ + public function maybe_install_webhook($settings, $settings_to_save, $saved_settings): void { + + $active_gateways = (array) wu_get_isset($settings_to_save, 'active_gateways', []); + + if (! in_array('paypal-rest', $active_gateways, true)) { + return; + } + + // Check if settings changed + $changed_settings = [ + $settings['paypal_rest_sandbox_mode'] ?? '', + $settings['paypal_rest_sandbox_client_id'] ?? '', + $settings['paypal_rest_sandbox_client_secret'] ?? '', + $settings['paypal_rest_live_client_id'] ?? '', + $settings['paypal_rest_live_client_secret'] ?? '', + ]; + + $original_settings = [ + $saved_settings['paypal_rest_sandbox_mode'] ?? '', + $saved_settings['paypal_rest_sandbox_client_id'] ?? '', + $saved_settings['paypal_rest_sandbox_client_secret'] ?? '', + $saved_settings['paypal_rest_live_client_id'] ?? '', + $saved_settings['paypal_rest_live_client_secret'] ?? '', + ]; + + // Only install if settings changed + if ($changed_settings === $original_settings) { + return; + } + + // Reload credentials with new settings + $this->test_mode = (bool) (int) ($settings['paypal_rest_sandbox_mode'] ?? true); + $this->load_credentials(); + + // Check if we have credentials + if (! $this->is_configured()) { + return; + } + + $this->install_webhook(); + } + + /** + * Install webhook in PayPal. + * + * @since 2.0.0 + * @return bool|\WP_Error True on success, WP_Error on failure. + */ + public function install_webhook() { + + $mode_prefix = $this->test_mode ? 'sandbox' : 'live'; + + // Check if we already have a webhook installed + $existing_webhook_id = wu_get_setting("paypal_rest_{$mode_prefix}_webhook_id", ''); + + if (! empty($existing_webhook_id)) { + // Verify it still exists + $existing = $this->api_request('/v1/notifications/webhooks/' . $existing_webhook_id, [], 'GET'); + + if (! is_wp_error($existing) && ! empty($existing['id'])) { + $this->log(sprintf('Webhook already exists: %s', $existing_webhook_id)); + return true; + } + + // Webhook was deleted, clear the setting + wu_save_setting("paypal_rest_{$mode_prefix}_webhook_id", ''); + } + + $webhook_url = $this->get_webhook_listener_url(); + + // Define the events we want to receive + $event_types = [ + ['name' => 'BILLING.SUBSCRIPTION.CREATED'], + ['name' => 'BILLING.SUBSCRIPTION.ACTIVATED'], + ['name' => 'BILLING.SUBSCRIPTION.UPDATED'], + ['name' => 'BILLING.SUBSCRIPTION.CANCELLED'], + ['name' => 'BILLING.SUBSCRIPTION.SUSPENDED'], + ['name' => 'BILLING.SUBSCRIPTION.PAYMENT.FAILED'], + ['name' => 'PAYMENT.SALE.COMPLETED'], + ['name' => 'PAYMENT.CAPTURE.COMPLETED'], + ['name' => 'PAYMENT.CAPTURE.REFUNDED'], + ]; + + $webhook_data = [ + 'url' => $webhook_url, + 'event_types' => $event_types, + ]; + + $this->log(sprintf('Creating webhook for URL: %s', $webhook_url)); + + $result = $this->api_request('/v1/notifications/webhooks', $webhook_data); + + if (is_wp_error($result)) { + $this->log(sprintf('Failed to create webhook: %s', $result->get_error_message()), LogLevel::ERROR); + return $result; + } + + if (empty($result['id'])) { + $this->log('Webhook created but no ID returned', LogLevel::ERROR); + return new \WP_Error('wu_paypal_webhook_no_id', __('Webhook created but no ID returned', 'ultimate-multisite')); + } + + // Save the webhook ID + wu_save_setting("paypal_rest_{$mode_prefix}_webhook_id", $result['id']); + + $this->log(sprintf('Webhook created successfully: %s', $result['id'])); + + return true; + } + + /** + * Check if webhook is installed. + * + * @since 2.0.0 + * @return bool|array False if not installed, webhook data if installed. + */ + public function has_webhook_installed() { + + $mode_prefix = $this->test_mode ? 'sandbox' : 'live'; + $webhook_id = wu_get_setting("paypal_rest_{$mode_prefix}_webhook_id", ''); + + if (empty($webhook_id)) { + return false; + } + + $webhook = $this->api_request('/v1/notifications/webhooks/' . $webhook_id, [], 'GET'); + + if (is_wp_error($webhook)) { + return false; + } + + return $webhook; + } + + /** + * Delete webhook from PayPal. + * + * @since 2.0.0 + * @return bool True on success. + */ + public function delete_webhook(): bool { + + $mode_prefix = $this->test_mode ? 'sandbox' : 'live'; + $webhook_id = wu_get_setting("paypal_rest_{$mode_prefix}_webhook_id", ''); + + if (empty($webhook_id)) { + return true; + } + + $result = $this->api_request('/v1/notifications/webhooks/' . $webhook_id, [], 'DELETE'); + + // Clear the stored webhook ID regardless of result + wu_save_setting("paypal_rest_{$mode_prefix}_webhook_id", ''); + + if (is_wp_error($result)) { + $this->log(sprintf('Failed to delete webhook: %s', $result->get_error_message()), LogLevel::WARNING); + return false; + } + + $this->log(sprintf('Webhook deleted: %s', $webhook_id)); + + return true; + } + + /** + * AJAX handler to manually install webhook. + * + * @since 2.0.0 + * @return void + */ + public function ajax_install_webhook(): void { + + check_ajax_referer('wu_paypal_webhook', 'nonce'); + + if (! current_user_can('manage_network_options')) { + wp_send_json_error( + [ + 'message' => __('You do not have permission to do this.', 'ultimate-multisite'), + ] + ); + } + + // Reload credentials to ensure we have the latest + $this->load_credentials(); + + if (! $this->is_configured()) { + wp_send_json_error( + [ + 'message' => __('PayPal credentials are not configured.', 'ultimate-multisite'), + ] + ); + } + + $result = $this->install_webhook(); + + if (true === $result) { + wp_send_json_success( + [ + 'message' => __('Webhook configured successfully.', 'ultimate-multisite'), + ] + ); + } elseif (is_wp_error($result)) { + wp_send_json_error( + [ + 'message' => $result->get_error_message(), + ] + ); + } else { + wp_send_json_error( + [ + 'message' => __('Failed to configure webhook. Please try again.', 'ultimate-multisite'), + ] + ); + } + } +} diff --git a/inc/gateways/class-paypal-webhook-handler.php b/inc/gateways/class-paypal-webhook-handler.php new file mode 100644 index 00000000..9bbfd06c --- /dev/null +++ b/inc/gateways/class-paypal-webhook-handler.php @@ -0,0 +1,578 @@ +test_mode = (bool) (int) wu_get_setting('paypal_rest_sandbox_mode', true); + + // Register webhook listener + add_action('wu_paypal-rest_process_webhooks', [$this, 'process_webhook']); + } + + /** + * Returns the PayPal API base URL based on test mode. + * + * @since 2.0.0 + * @return string + */ + protected function get_api_base_url(): string { + + return $this->test_mode ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com'; + } + + /** + * Process incoming webhook. + * + * @since 2.0.0 + * @return void + */ + public function process_webhook(): void { + + $raw_body = file_get_contents('php://input'); + + if (empty($raw_body)) { + $this->log('Webhook received with empty body', LogLevel::WARNING); + status_header(400); + exit; + } + + $event = json_decode($raw_body, true); + + if (json_last_error() !== JSON_ERROR_NONE || empty($event['event_type'])) { + $this->log('Webhook received with invalid JSON', LogLevel::WARNING); + status_header(400); + exit; + } + + $this->log(sprintf('Webhook received: %s, ID: %s', $event['event_type'], $event['id'] ?? 'unknown')); + + // Verify webhook signature + if (! $this->verify_webhook_signature($raw_body)) { + $this->log('Webhook signature verification failed', LogLevel::ERROR); + status_header(401); + exit; + } + + // Process based on event type + $event_type = $event['event_type']; + $resource = $event['resource'] ?? []; + + switch ($event_type) { + // Subscription events + case 'BILLING.SUBSCRIPTION.CREATED': + $this->handle_subscription_created($resource); + break; + + case 'BILLING.SUBSCRIPTION.ACTIVATED': + $this->handle_subscription_activated($resource); + break; + + case 'BILLING.SUBSCRIPTION.UPDATED': + $this->handle_subscription_updated($resource); + break; + + case 'BILLING.SUBSCRIPTION.CANCELLED': + $this->handle_subscription_cancelled($resource); + break; + + case 'BILLING.SUBSCRIPTION.SUSPENDED': + $this->handle_subscription_suspended($resource); + break; + + case 'BILLING.SUBSCRIPTION.PAYMENT.FAILED': + $this->handle_subscription_payment_failed($resource); + break; + + // Payment events + case 'PAYMENT.SALE.COMPLETED': + $this->handle_payment_completed($resource); + break; + + case 'PAYMENT.CAPTURE.COMPLETED': + $this->handle_capture_completed($resource); + break; + + case 'PAYMENT.CAPTURE.REFUNDED': + $this->handle_capture_refunded($resource); + break; + + default: + $this->log(sprintf('Unhandled webhook event: %s', $event_type)); + break; + } + + status_header(200); + exit; + } + + /** + * Verify the webhook signature. + * + * PayPal REST webhooks use RSA-SHA256 signatures for verification. + * + * @since 2.0.0 + * + * @param string $raw_body The raw request body. + * @return bool + */ + protected function verify_webhook_signature(string $raw_body): bool { + + // Get webhook headers - these come from PayPal's webhook signature + $auth_algo = isset($_SERVER['HTTP_PAYPAL_AUTH_ALGO']) ? sanitize_text_field(wp_unslash($_SERVER['HTTP_PAYPAL_AUTH_ALGO'])) : ''; + $cert_url = isset($_SERVER['HTTP_PAYPAL_CERT_URL']) ? sanitize_url(wp_unslash($_SERVER['HTTP_PAYPAL_CERT_URL'])) : ''; + $transmission_id = isset($_SERVER['HTTP_PAYPAL_TRANSMISSION_ID']) ? sanitize_text_field(wp_unslash($_SERVER['HTTP_PAYPAL_TRANSMISSION_ID'])) : ''; + $transmission_sig = isset($_SERVER['HTTP_PAYPAL_TRANSMISSION_SIG']) ? sanitize_text_field(wp_unslash($_SERVER['HTTP_PAYPAL_TRANSMISSION_SIG'])) : ''; + $transmission_time = isset($_SERVER['HTTP_PAYPAL_TRANSMISSION_TIME']) ? sanitize_text_field(wp_unslash($_SERVER['HTTP_PAYPAL_TRANSMISSION_TIME'])) : ''; + + // If headers are missing, we can't verify + if (empty($auth_algo) || empty($cert_url) || empty($transmission_id) || empty($transmission_sig) || empty($transmission_time)) { + $this->log('Missing webhook signature headers', LogLevel::WARNING); + // In development/testing, you might want to skip verification + if ($this->test_mode && defined('WP_DEBUG') && WP_DEBUG) { + $this->log('Skipping signature verification in debug mode'); + return true; + } + return false; + } + + // Get webhook ID from settings + $mode_prefix = $this->test_mode ? 'sandbox' : 'live'; + $webhook_id = wu_get_setting("paypal_rest_{$mode_prefix}_webhook_id", ''); + + if (empty($webhook_id)) { + $this->log('Webhook ID not configured, skipping verification', LogLevel::WARNING); + // Allow in test mode without webhook ID + return $this->test_mode; + } + + // Get access token for verification API call + $gateway = wu_get_gateway('paypal-rest'); + if (! $gateway) { + $this->log('PayPal REST gateway not available', LogLevel::ERROR); + return false; + } + + // Build verification request + $verify_data = [ + 'auth_algo' => $auth_algo, + 'cert_url' => $cert_url, + 'transmission_id' => $transmission_id, + 'transmission_sig' => $transmission_sig, + 'transmission_time' => $transmission_time, + 'webhook_id' => $webhook_id, + 'webhook_event' => json_decode($raw_body, true), + ]; + + // Get client credentials + $client_id = wu_get_setting("paypal_rest_{$mode_prefix}_client_id", ''); + $client_secret = wu_get_setting("paypal_rest_{$mode_prefix}_client_secret", ''); + + if (empty($client_id) || empty($client_secret)) { + $this->log('Client credentials not configured for webhook verification', LogLevel::WARNING); + return $this->test_mode; + } + + // Get access token + $token_response = wp_remote_post( + $this->get_api_base_url() . '/v1/oauth2/token', + [ + 'headers' => [ + 'Authorization' => 'Basic ' . base64_encode($client_id . ':' . $client_secret), + 'Content-Type' => 'application/x-www-form-urlencoded', + ], + 'body' => 'grant_type=client_credentials', + 'timeout' => 30, + ] + ); + + if (is_wp_error($token_response)) { + $this->log('Failed to get token for webhook verification: ' . $token_response->get_error_message(), LogLevel::ERROR); + return false; + } + + $token_body = json_decode(wp_remote_retrieve_body($token_response), true); + $access_token = $token_body['access_token'] ?? ''; + + if (empty($access_token)) { + $this->log('Failed to get access token for webhook verification', LogLevel::ERROR); + return false; + } + + // Call verification endpoint + $verify_response = wp_remote_post( + $this->get_api_base_url() . '/v1/notifications/verify-webhook-signature', + [ + 'headers' => [ + 'Authorization' => 'Bearer ' . $access_token, + 'Content-Type' => 'application/json', + ], + 'body' => wp_json_encode($verify_data), + 'timeout' => 30, + ] + ); + + if (is_wp_error($verify_response)) { + $this->log('Webhook verification request failed: ' . $verify_response->get_error_message(), LogLevel::ERROR); + return false; + } + + $verify_body = json_decode(wp_remote_retrieve_body($verify_response), true); + + if (($verify_body['verification_status'] ?? '') === 'SUCCESS') { + $this->log('Webhook signature verified successfully'); + return true; + } + + $this->log(sprintf('Webhook signature verification returned: %s', $verify_body['verification_status'] ?? 'unknown'), LogLevel::WARNING); + + return false; + } + + /** + * Handle subscription created event. + * + * @since 2.0.0 + * + * @param array $resource The subscription resource data. + * @return void + */ + protected function handle_subscription_created(array $resource): void { + + $subscription_id = $resource['id'] ?? ''; + $this->log(sprintf('Subscription created: %s', $subscription_id)); + + // Subscription created is usually handled during checkout flow + // This webhook is mainly for logging/verification + } + + /** + * Handle subscription activated event. + * + * @since 2.0.0 + * + * @param array $resource The subscription resource data. + * @return void + */ + protected function handle_subscription_activated(array $resource): void { + + $subscription_id = $resource['id'] ?? ''; + + $membership = $this->get_membership_by_subscription($subscription_id); + + if (! $membership) { + $this->log(sprintf('Subscription activated but no membership found: %s', $subscription_id), LogLevel::WARNING); + return; + } + + // Update membership status if needed + if ($membership->get_status() !== Membership_Status::ACTIVE) { + $membership->set_status(Membership_Status::ACTIVE); + $membership->save(); + + $this->log(sprintf('Membership %d activated via webhook', $membership->get_id())); + } + } + + /** + * Handle subscription updated event. + * + * @since 2.0.0 + * + * @param array $resource The subscription resource data. + * @return void + */ + protected function handle_subscription_updated(array $resource): void { + + $subscription_id = $resource['id'] ?? ''; + $this->log(sprintf('Subscription updated: %s', $subscription_id)); + + // Handle any subscription updates as needed + } + + /** + * Handle subscription cancelled event. + * + * @since 2.0.0 + * + * @param array $resource The subscription resource data. + * @return void + */ + protected function handle_subscription_cancelled(array $resource): void { + + $subscription_id = $resource['id'] ?? ''; + + $membership = $this->get_membership_by_subscription($subscription_id); + + if (! $membership) { + $this->log(sprintf('Subscription cancelled but no membership found: %s', $subscription_id), LogLevel::WARNING); + return; + } + + // Cancel at end of period + $membership->set_auto_renew(false); + $membership->save(); + + $this->log(sprintf('Membership %d set to not auto-renew after cancellation', $membership->get_id())); + } + + /** + * Handle subscription suspended event. + * + * @since 2.0.0 + * + * @param array $resource The subscription resource data. + * @return void + */ + protected function handle_subscription_suspended(array $resource): void { + + $subscription_id = $resource['id'] ?? ''; + + $membership = $this->get_membership_by_subscription($subscription_id); + + if (! $membership) { + $this->log(sprintf('Subscription suspended but no membership found: %s', $subscription_id), LogLevel::WARNING); + return; + } + + $membership->set_status(Membership_Status::ON_HOLD); + $membership->save(); + + $this->log(sprintf('Membership %d suspended via webhook', $membership->get_id())); + } + + /** + * Handle subscription payment failed event. + * + * @since 2.0.0 + * + * @param array $resource The subscription resource data. + * @return void + */ + protected function handle_subscription_payment_failed(array $resource): void { + + $subscription_id = $resource['id'] ?? ''; + + $membership = $this->get_membership_by_subscription($subscription_id); + + if (! $membership) { + $this->log(sprintf('Subscription payment failed but no membership found: %s', $subscription_id), LogLevel::WARNING); + return; + } + + $this->log(sprintf('Payment failed for membership %d, subscription %s', $membership->get_id(), $subscription_id)); + + // Optionally record a failed payment + // The membership status might be updated by PayPal's retry logic + } + + /** + * Handle payment sale completed event. + * + * This is triggered for subscription payments. + * + * @since 2.0.0 + * + * @param array $resource The sale resource data. + * @return void + */ + protected function handle_payment_completed(array $resource): void { + + $sale_id = $resource['id'] ?? ''; + $billing_id = $resource['billing_agreement_id'] ?? ''; + $custom_id = $resource['custom'] ?? ($resource['custom_id'] ?? ''); + $amount = $resource['amount']['total'] ?? ($resource['amount']['value'] ?? 0); + $currency = $resource['amount']['currency'] ?? ($resource['amount']['currency_code'] ?? 'USD'); + + $this->log(sprintf('Payment completed: %s, Amount: %s %s', $sale_id, $amount, $currency)); + + // Try to find membership by billing agreement (subscription ID) + $membership = null; + if (! empty($billing_id)) { + $membership = $this->get_membership_by_subscription($billing_id); + } + + // Fallback to custom_id parsing + if (! $membership && ! empty($custom_id)) { + $custom_parts = explode('|', $custom_id); + if (count($custom_parts) >= 2) { + $membership = wu_get_membership((int) $custom_parts[1]); + } + } + + if (! $membership) { + $this->log(sprintf('Payment completed but no membership found for sale: %s', $sale_id), LogLevel::WARNING); + return; + } + + // Check if this is a renewal payment (not initial) + $existing_payment = wu_get_payment_by('gateway_payment_id', $sale_id); + + if ($existing_payment) { + $this->log(sprintf('Payment %s already recorded', $sale_id)); + return; + } + + // Create renewal payment + $payment_data = [ + 'customer_id' => $membership->get_customer_id(), + 'membership_id' => $membership->get_id(), + 'gateway' => 'paypal-rest', + 'gateway_payment_id' => $sale_id, + 'currency' => $currency, + 'subtotal' => (float) $amount, + 'total' => (float) $amount, + 'status' => Payment_Status::COMPLETED, + 'product_id' => $membership->get_plan_id(), + ]; + + $payment = wu_create_payment($payment_data); + + if (is_wp_error($payment)) { + $this->log(sprintf('Failed to create renewal payment: %s', $payment->get_error_message()), LogLevel::ERROR); + return; + } + + // Update membership + $membership->add_to_times_billed(1); + $membership->renew(false); + + $this->log(sprintf('Renewal payment created: %d for membership %d', $payment->get_id(), $membership->get_id())); + } + + /** + * Handle capture completed event. + * + * This is triggered for one-time payments. + * + * @since 2.0.0 + * + * @param array $resource The capture resource data. + * @return void + */ + protected function handle_capture_completed(array $resource): void { + + $capture_id = $resource['id'] ?? ''; + $this->log(sprintf('Capture completed: %s', $capture_id)); + + // Capture completed is usually handled during the confirmation flow + // This webhook is for verification/edge cases + } + + /** + * Handle capture refunded event. + * + * @since 2.0.0 + * + * @param array $resource The refund resource data. + * @return void + */ + protected function handle_capture_refunded(array $resource): void { + + $refund_id = $resource['id'] ?? ''; + $capture_id = ''; + $amount = $resource['amount']['value'] ?? 0; + + // Find the original capture ID from links + foreach ($resource['links'] ?? [] as $link) { + if ('up' === $link['rel']) { + // Extract capture ID from the link + preg_match('/captures\/([A-Z0-9]+)/', $link['href'], $matches); + $capture_id = $matches[1] ?? ''; + break; + } + } + + $this->log(sprintf('Refund processed: %s for capture %s, Amount: %s', $refund_id, $capture_id, $amount)); + + if (empty($capture_id)) { + return; + } + + // Find the payment + $payment = wu_get_payment_by('gateway_payment_id', $capture_id); + + if (! $payment) { + $this->log(sprintf('Refund webhook: payment not found for capture %s', $capture_id), LogLevel::WARNING); + return; + } + + // Update payment status if fully refunded + if ($amount >= $payment->get_total()) { + $payment->set_status(Payment_Status::REFUND); + $payment->save(); + $this->log(sprintf('Payment %d marked as refunded', $payment->get_id())); + } + } + + /** + * Get membership by PayPal subscription ID. + * + * @since 2.0.0 + * + * @param string $subscription_id The PayPal subscription ID. + * @return \WP_Ultimo\Models\Membership|null + */ + protected function get_membership_by_subscription(string $subscription_id) { + + if (empty($subscription_id)) { + return null; + } + + return wu_get_membership_by('gateway_subscription_id', $subscription_id); + } + + /** + * Log a message. + * + * @since 2.0.0 + * + * @param string $message The message to log. + * @param string $level Log level. + * @return void + */ + protected function log(string $message, string $level = 'info'): void { + + wu_log_add('paypal', '[Webhook] ' . $message, $level); + } +} diff --git a/inc/managers/class-gateway-manager.php b/inc/managers/class-gateway-manager.php index 89920014..e26f30ff 100644 --- a/inc/managers/class-gateway-manager.php +++ b/inc/managers/class-gateway-manager.php @@ -19,6 +19,8 @@ use WP_Ultimo\Gateways\Stripe_Gateway; use WP_Ultimo\Gateways\Stripe_Checkout_Gateway; use WP_Ultimo\Gateways\PayPal_Gateway; +use WP_Ultimo\Gateways\PayPal_REST_Gateway; +use WP_Ultimo\Gateways\PayPal_Webhook_Handler; use WP_Ultimo\Gateways\Manual_Gateway; // Exit if accessed directly @@ -428,10 +430,21 @@ public function add_default_gateways(): void { wu_register_gateway('stripe-checkout', __('Stripe Checkout', 'ultimate-multisite'), $stripe_checkout_desc, Stripe_Checkout_Gateway::class); /* - * PayPal Payments + * PayPal Payments (REST API - Modern) */ - $paypal_desc = __('PayPal is the leading provider in checkout solutions and it is the easier way to get your network subscriptions going.', 'ultimate-multisite'); - wu_register_gateway('paypal', __('PayPal', 'ultimate-multisite'), $paypal_desc, PayPal_Gateway::class); + $paypal_rest_desc = __('Modern PayPal integration with Connect with PayPal onboarding. Recommended for new setups.', 'ultimate-multisite'); + wu_register_gateway('paypal-rest', __('PayPal', 'ultimate-multisite'), $paypal_rest_desc, PayPal_REST_Gateway::class); + + /* + * PayPal Payments (Legacy NVP API) + */ + $paypal_desc = __('PayPal Express Checkout (Legacy). Uses username/password/signature authentication. For existing integrations only.', 'ultimate-multisite'); + wu_register_gateway('paypal', __('PayPal (Legacy)', 'ultimate-multisite'), $paypal_desc, PayPal_Gateway::class); + + /* + * Initialize PayPal REST webhook handler + */ + PayPal_Webhook_Handler::get_instance()->init(); /* * Manual Payments @@ -543,11 +556,11 @@ public function install_hooks($class_name): void { add_action("wu_{$gateway_id}_process_webhooks", [$gateway, 'process_webhooks']); - add_action("wu_{$gateway_id}_remote_payment_url", [$gateway, 'get_payment_url_on_gateway']); // @phpstan-ignore-line Used as filter via apply_filters. + add_filter("wu_{$gateway_id}_remote_payment_url", [$gateway, 'get_payment_url_on_gateway']); - add_action("wu_{$gateway_id}_remote_subscription_url", [$gateway, 'get_subscription_url_on_gateway']); + add_filter("wu_{$gateway_id}_remote_subscription_url", [$gateway, 'get_subscription_url_on_gateway']); - add_action("wu_{$gateway_id}_remote_customer_url", [$gateway, 'get_customer_url_on_gateway']); + add_filter("wu_{$gateway_id}_remote_customer_url", [$gateway, 'get_customer_url_on_gateway']); /* * Renders the gateway fields. @@ -556,7 +569,7 @@ public function install_hooks($class_name): void { 'wu_checkout_gateway_fields', function () use ($gateway) { - $field_content = call_user_func([$gateway, 'fields']); // @phpstan-ignore-line Subclass implementations return string. + $field_content = $gateway->fields(); ob_start(); From bc0bed6ff3d8a9d650f9394a343c68b5c3924e5f Mon Sep 17 00:00:00 2001 From: David Stone Date: Fri, 27 Feb 2026 16:21:39 -0700 Subject: [PATCH 02/11] Security fixes, PHPCS compliance, and unit tests for PayPal REST gateway - Remove WP_DEBUG-based webhook signature bypass, use wu_paypal_skip_webhook_verification filter - Fix empty webhook_id and client_credentials fallbacks to return false instead of test_mode - Add WU_PAYPAL_PARTNER_* constants and wu_paypal_partner_credentials filter support - Hide OAuth Connect button when partner credentials not configured - Add phpcs:ignore for base64_encode (required for PayPal Basic auth) - Rename $resource parameter to $event_data to avoid reserved keyword warning - Fix PHPCS formatting errors in oauth handler - Add 20 unit tests for PayPal REST Gateway (config, URLs, credentials) - Add 14 unit tests for PayPal Webhook Handler (signature, subscriptions, refunds) - Add paypal-rest and paypal assertions to Gateway Manager tests Co-Authored-By: Claude Opus 4.6 --- inc/gateways/class-paypal-oauth-handler.php | 53 +- inc/gateways/class-paypal-rest-gateway.php | 32 +- inc/gateways/class-paypal-webhook-handler.php | 91 ++-- .../Gateways/PayPal_REST_Gateway_Test.php | 314 ++++++++++++ .../Gateways/PayPal_Webhook_Handler_Test.php | 470 ++++++++++++++++++ .../Managers/Gateway_Manager_Test.php | 2 + 6 files changed, 901 insertions(+), 61 deletions(-) create mode 100644 tests/WP_Ultimo/Gateways/PayPal_REST_Gateway_Test.php create mode 100644 tests/WP_Ultimo/Gateways/PayPal_Webhook_Handler_Test.php diff --git a/inc/gateways/class-paypal-oauth-handler.php b/inc/gateways/class-paypal-oauth-handler.php index 171e7f23..b5bbc0e5 100644 --- a/inc/gateways/class-paypal-oauth-handler.php +++ b/inc/gateways/class-paypal-oauth-handler.php @@ -101,13 +101,54 @@ public function init(): void { */ protected function load_partner_credentials(): void { - // In production, these would be Ultimate Multisite's partner credentials - // For now, merchants can use their own REST app credentials for testing $mode_prefix = $this->test_mode ? 'sandbox_' : 'live_'; - $this->partner_client_id = wu_get_setting("paypal_rest_{$mode_prefix}partner_client_id", ''); - $this->partner_client_secret = wu_get_setting("paypal_rest_{$mode_prefix}partner_client_secret", ''); - $this->partner_merchant_id = wu_get_setting("paypal_rest_{$mode_prefix}partner_merchant_id", ''); + // Check for constants first (recommended for production) + $const_prefix = $this->test_mode ? 'WU_PAYPAL_SANDBOX_PARTNER_' : 'WU_PAYPAL_PARTNER_'; + + $this->partner_client_id = defined($const_prefix . 'CLIENT_ID') ? constant($const_prefix . 'CLIENT_ID') : ''; + $this->partner_client_secret = defined($const_prefix . 'CLIENT_SECRET') ? constant($const_prefix . 'CLIENT_SECRET') : ''; + $this->partner_merchant_id = defined($const_prefix . 'MERCHANT_ID') ? constant($const_prefix . 'MERCHANT_ID') : ''; + + // Fall back to settings if constants not defined + if (empty($this->partner_client_id)) { + $this->partner_client_id = wu_get_setting("paypal_rest_{$mode_prefix}partner_client_id", ''); + } + + if (empty($this->partner_client_secret)) { + $this->partner_client_secret = wu_get_setting("paypal_rest_{$mode_prefix}partner_client_secret", ''); + } + + if (empty($this->partner_merchant_id)) { + $this->partner_merchant_id = wu_get_setting("paypal_rest_{$mode_prefix}partner_merchant_id", ''); + } + + /** + * Filters partner credentials for PayPal OAuth. + * + * @since 2.0.0 + * + * @param array $credentials { + * Partner credentials. + * @type string $client_id Partner client ID. + * @type string $client_secret Partner client secret. + * @type string $merchant_id Partner merchant ID. + * } + * @param bool $test_mode Whether sandbox mode is active. + */ + $credentials = apply_filters( + 'wu_paypal_partner_credentials', + [ + 'client_id' => $this->partner_client_id, + 'client_secret' => $this->partner_client_secret, + 'merchant_id' => $this->partner_merchant_id, + ], + $this->test_mode + ); + + $this->partner_client_id = $credentials['client_id'] ?? ''; + $this->partner_client_secret = $credentials['client_secret'] ?? ''; + $this->partner_merchant_id = $credentials['merchant_id'] ?? ''; } /** @@ -159,7 +200,7 @@ protected function get_partner_access_token() { $this->get_api_base_url() . '/v1/oauth2/token', [ 'headers' => [ - 'Authorization' => 'Basic ' . base64_encode($this->partner_client_id . ':' . $this->partner_client_secret), + 'Authorization' => 'Basic ' . base64_encode($this->partner_client_id . ':' . $this->partner_client_secret), // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Required for PayPal API Basic auth 'Content-Type' => 'application/x-www-form-urlencoded', ], 'body' => 'grant_type=client_credentials', diff --git a/inc/gateways/class-paypal-rest-gateway.php b/inc/gateways/class-paypal-rest-gateway.php index 086fee2b..bc1cfc8f 100644 --- a/inc/gateways/class-paypal-rest-gateway.php +++ b/inc/gateways/class-paypal-rest-gateway.php @@ -241,7 +241,7 @@ protected function get_access_token() { $this->get_api_base_url() . '/v1/oauth2/token', [ 'headers' => [ - 'Authorization' => 'Basic ' . base64_encode($this->client_id . ':' . $this->client_secret), + 'Authorization' => 'Basic ' . base64_encode($this->client_id . ':' . $this->client_secret), // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Required for PayPal API Basic auth 'Content-Type' => 'application/x-www-form-urlencoded', ], 'body' => 'grant_type=client_credentials', @@ -1062,19 +1062,23 @@ public function settings(): void { ] ); - // Connect with PayPal button - wu_register_settings_field( - 'payment-gateways', - 'paypal_rest_connect_button', - [ - 'title' => __('Connect with PayPal', 'ultimate-multisite'), - 'type' => 'note', - 'desc' => [$this, 'render_connect_button'], - 'require' => [ - 'active_gateways' => 'paypal-rest', - ], - ] - ); + // Connect with PayPal button (only when partner credentials are configured) + $oauth_handler = PayPal_OAuth_Handler::get_instance(); + + if ($oauth_handler->is_configured() || $oauth_handler->is_merchant_connected($this->test_mode)) { + wu_register_settings_field( + 'payment-gateways', + 'paypal_rest_connect_button', + [ + 'title' => __('Connect with PayPal', 'ultimate-multisite'), + 'type' => 'note', + 'desc' => [$this, 'render_connect_button'], + 'require' => [ + 'active_gateways' => 'paypal-rest', + ], + ] + ); + } // Advanced/Manual credentials header wu_register_settings_field( diff --git a/inc/gateways/class-paypal-webhook-handler.php b/inc/gateways/class-paypal-webhook-handler.php index 9bbfd06c..e571db1d 100644 --- a/inc/gateways/class-paypal-webhook-handler.php +++ b/inc/gateways/class-paypal-webhook-handler.php @@ -168,11 +168,21 @@ protected function verify_webhook_signature(string $raw_body): bool { // If headers are missing, we can't verify if (empty($auth_algo) || empty($cert_url) || empty($transmission_id) || empty($transmission_sig) || empty($transmission_time)) { $this->log('Missing webhook signature headers', LogLevel::WARNING); - // In development/testing, you might want to skip verification - if ($this->test_mode && defined('WP_DEBUG') && WP_DEBUG) { - $this->log('Skipping signature verification in debug mode'); + + /** + * Filters whether to skip webhook signature verification. + * + * Only use this for local development or testing environments. + * + * @since 2.0.0 + * + * @param bool $skip Whether to skip verification. Default false. + */ + if (apply_filters('wu_paypal_skip_webhook_verification', false)) { + $this->log('Skipping signature verification via filter'); return true; } + return false; } @@ -181,9 +191,8 @@ protected function verify_webhook_signature(string $raw_body): bool { $webhook_id = wu_get_setting("paypal_rest_{$mode_prefix}_webhook_id", ''); if (empty($webhook_id)) { - $this->log('Webhook ID not configured, skipping verification', LogLevel::WARNING); - // Allow in test mode without webhook ID - return $this->test_mode; + $this->log('Webhook ID not configured, cannot verify signature', LogLevel::WARNING); + return false; } // Get access token for verification API call @@ -210,7 +219,7 @@ protected function verify_webhook_signature(string $raw_body): bool { if (empty($client_id) || empty($client_secret)) { $this->log('Client credentials not configured for webhook verification', LogLevel::WARNING); - return $this->test_mode; + return false; } // Get access token @@ -218,7 +227,7 @@ protected function verify_webhook_signature(string $raw_body): bool { $this->get_api_base_url() . '/v1/oauth2/token', [ 'headers' => [ - 'Authorization' => 'Basic ' . base64_encode($client_id . ':' . $client_secret), + 'Authorization' => 'Basic ' . base64_encode($client_id . ':' . $client_secret), // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Required for PayPal API Basic auth 'Content-Type' => 'application/x-www-form-urlencoded', ], 'body' => 'grant_type=client_credentials', @@ -274,12 +283,12 @@ protected function verify_webhook_signature(string $raw_body): bool { * * @since 2.0.0 * - * @param array $resource The subscription resource data. + * @param array $event_data The subscription event data. * @return void */ - protected function handle_subscription_created(array $resource): void { + protected function handle_subscription_created(array $event_data): void { - $subscription_id = $resource['id'] ?? ''; + $subscription_id = $event_data['id'] ?? ''; $this->log(sprintf('Subscription created: %s', $subscription_id)); // Subscription created is usually handled during checkout flow @@ -291,12 +300,12 @@ protected function handle_subscription_created(array $resource): void { * * @since 2.0.0 * - * @param array $resource The subscription resource data. + * @param array $event_data The subscription event data. * @return void */ - protected function handle_subscription_activated(array $resource): void { + protected function handle_subscription_activated(array $event_data): void { - $subscription_id = $resource['id'] ?? ''; + $subscription_id = $event_data['id'] ?? ''; $membership = $this->get_membership_by_subscription($subscription_id); @@ -319,12 +328,12 @@ protected function handle_subscription_activated(array $resource): void { * * @since 2.0.0 * - * @param array $resource The subscription resource data. + * @param array $event_data The subscription event data. * @return void */ - protected function handle_subscription_updated(array $resource): void { + protected function handle_subscription_updated(array $event_data): void { - $subscription_id = $resource['id'] ?? ''; + $subscription_id = $event_data['id'] ?? ''; $this->log(sprintf('Subscription updated: %s', $subscription_id)); // Handle any subscription updates as needed @@ -335,12 +344,12 @@ protected function handle_subscription_updated(array $resource): void { * * @since 2.0.0 * - * @param array $resource The subscription resource data. + * @param array $event_data The subscription event data. * @return void */ - protected function handle_subscription_cancelled(array $resource): void { + protected function handle_subscription_cancelled(array $event_data): void { - $subscription_id = $resource['id'] ?? ''; + $subscription_id = $event_data['id'] ?? ''; $membership = $this->get_membership_by_subscription($subscription_id); @@ -361,12 +370,12 @@ protected function handle_subscription_cancelled(array $resource): void { * * @since 2.0.0 * - * @param array $resource The subscription resource data. + * @param array $event_data The subscription event data. * @return void */ - protected function handle_subscription_suspended(array $resource): void { + protected function handle_subscription_suspended(array $event_data): void { - $subscription_id = $resource['id'] ?? ''; + $subscription_id = $event_data['id'] ?? ''; $membership = $this->get_membership_by_subscription($subscription_id); @@ -386,12 +395,12 @@ protected function handle_subscription_suspended(array $resource): void { * * @since 2.0.0 * - * @param array $resource The subscription resource data. + * @param array $event_data The subscription event data. * @return void */ - protected function handle_subscription_payment_failed(array $resource): void { + protected function handle_subscription_payment_failed(array $event_data): void { - $subscription_id = $resource['id'] ?? ''; + $subscription_id = $event_data['id'] ?? ''; $membership = $this->get_membership_by_subscription($subscription_id); @@ -413,16 +422,16 @@ protected function handle_subscription_payment_failed(array $resource): void { * * @since 2.0.0 * - * @param array $resource The sale resource data. + * @param array $event_data The sale event data. * @return void */ - protected function handle_payment_completed(array $resource): void { + protected function handle_payment_completed(array $event_data): void { - $sale_id = $resource['id'] ?? ''; - $billing_id = $resource['billing_agreement_id'] ?? ''; - $custom_id = $resource['custom'] ?? ($resource['custom_id'] ?? ''); - $amount = $resource['amount']['total'] ?? ($resource['amount']['value'] ?? 0); - $currency = $resource['amount']['currency'] ?? ($resource['amount']['currency_code'] ?? 'USD'); + $sale_id = $event_data['id'] ?? ''; + $billing_id = $event_data['billing_agreement_id'] ?? ''; + $custom_id = $event_data['custom'] ?? ($event_data['custom_id'] ?? ''); + $amount = $event_data['amount']['total'] ?? ($event_data['amount']['value'] ?? 0); + $currency = $event_data['amount']['currency'] ?? ($event_data['amount']['currency_code'] ?? 'USD'); $this->log(sprintf('Payment completed: %s, Amount: %s %s', $sale_id, $amount, $currency)); @@ -487,12 +496,12 @@ protected function handle_payment_completed(array $resource): void { * * @since 2.0.0 * - * @param array $resource The capture resource data. + * @param array $event_data The capture event data. * @return void */ - protected function handle_capture_completed(array $resource): void { + protected function handle_capture_completed(array $event_data): void { - $capture_id = $resource['id'] ?? ''; + $capture_id = $event_data['id'] ?? ''; $this->log(sprintf('Capture completed: %s', $capture_id)); // Capture completed is usually handled during the confirmation flow @@ -504,17 +513,17 @@ protected function handle_capture_completed(array $resource): void { * * @since 2.0.0 * - * @param array $resource The refund resource data. + * @param array $event_data The refund event data. * @return void */ - protected function handle_capture_refunded(array $resource): void { + protected function handle_capture_refunded(array $event_data): void { - $refund_id = $resource['id'] ?? ''; + $refund_id = $event_data['id'] ?? ''; $capture_id = ''; - $amount = $resource['amount']['value'] ?? 0; + $amount = $event_data['amount']['value'] ?? 0; // Find the original capture ID from links - foreach ($resource['links'] ?? [] as $link) { + foreach ($event_data['links'] ?? [] as $link) { if ('up' === $link['rel']) { // Extract capture ID from the link preg_match('/captures\/([A-Z0-9]+)/', $link['href'], $matches); diff --git a/tests/WP_Ultimo/Gateways/PayPal_REST_Gateway_Test.php b/tests/WP_Ultimo/Gateways/PayPal_REST_Gateway_Test.php new file mode 100644 index 00000000..d53fd07b --- /dev/null +++ b/tests/WP_Ultimo/Gateways/PayPal_REST_Gateway_Test.php @@ -0,0 +1,314 @@ +gateway = new PayPal_REST_Gateway(); + } + + /** + * Test gateway ID. + */ + public function test_gateway_id(): void { + + $this->assertEquals('paypal-rest', $this->gateway->get_id()); + } + + /** + * Test gateway supports recurring. + */ + public function test_supports_recurring(): void { + + $this->assertTrue($this->gateway->supports_recurring()); + } + + /** + * Test gateway supports amount update. + */ + public function test_supports_amount_update(): void { + + $this->assertTrue($this->gateway->supports_amount_update()); + } + + /** + * Test not configured when no credentials. + */ + public function test_not_configured_without_credentials(): void { + + $this->gateway->init(); + $this->assertFalse($this->gateway->is_configured()); + } + + /** + * Test configured with manual client credentials. + */ + public function test_configured_with_manual_credentials(): void { + + wu_save_setting('paypal_rest_sandbox_client_id', 'test_client_id_123'); + wu_save_setting('paypal_rest_sandbox_client_secret', 'test_client_secret_456'); + wu_save_setting('paypal_rest_sandbox_mode', 1); + + $gateway = new PayPal_REST_Gateway(); + $gateway->init(); + + $this->assertTrue($gateway->is_configured()); + } + + /** + * Test configured with OAuth merchant ID. + */ + public function test_configured_with_oauth_merchant_id(): void { + + wu_save_setting('paypal_rest_sandbox_merchant_id', 'MERCHANT123'); + wu_save_setting('paypal_rest_sandbox_mode', 1); + + $gateway = new PayPal_REST_Gateway(); + $gateway->init(); + + $this->assertTrue($gateway->is_configured()); + } + + /** + * Test connection status when not connected. + */ + public function test_connection_status_not_connected(): void { + + $this->gateway->init(); + $status = $this->gateway->get_connection_status(); + + $this->assertFalse($status['connected']); + $this->assertArrayHasKey('message', $status); + $this->assertArrayHasKey('details', $status); + } + + /** + * Test connection status with manual credentials. + */ + public function test_connection_status_with_manual_credentials(): void { + + wu_save_setting('paypal_rest_sandbox_client_id', 'test_client_id_123'); + wu_save_setting('paypal_rest_sandbox_client_secret', 'test_client_secret_456'); + wu_save_setting('paypal_rest_sandbox_mode', 1); + + $gateway = new PayPal_REST_Gateway(); + $gateway->init(); + $status = $gateway->get_connection_status(); + + $this->assertTrue($status['connected']); + $this->assertEquals('manual', $status['details']['method']); + $this->assertEquals('sandbox', $status['details']['mode']); + } + + /** + * Test connection status with OAuth merchant. + */ + public function test_connection_status_with_oauth(): void { + + wu_save_setting('paypal_rest_sandbox_merchant_id', 'MERCHANT123'); + wu_save_setting('paypal_rest_sandbox_merchant_email', 'merchant@example.com'); + wu_save_setting('paypal_rest_sandbox_mode', 1); + wu_save_setting('paypal_rest_connection_date', '2026-01-01 00:00:00'); + + $gateway = new PayPal_REST_Gateway(); + $gateway->init(); + $status = $gateway->get_connection_status(); + + $this->assertTrue($status['connected']); + $this->assertEquals('oauth', $status['details']['method']); + $this->assertEquals('MERCHANT123', $status['details']['merchant_id']); + } + + /** + * Test set_test_mode switches credentials. + */ + public function test_set_test_mode(): void { + + wu_save_setting('paypal_rest_sandbox_client_id', 'sandbox_id'); + wu_save_setting('paypal_rest_sandbox_client_secret', 'sandbox_secret'); + wu_save_setting('paypal_rest_live_client_id', 'live_id'); + wu_save_setting('paypal_rest_live_client_secret', 'live_secret'); + wu_save_setting('paypal_rest_sandbox_mode', 1); + + $gateway = new PayPal_REST_Gateway(); + $gateway->init(); + + // Verify sandbox mode credentials + $reflection = new \ReflectionClass($gateway); + $prop = $reflection->getProperty('client_id'); + + $this->assertEquals('sandbox_id', $prop->getValue($gateway)); + + // Switch to live mode + $gateway->set_test_mode(false); + + $this->assertEquals('live_id', $prop->getValue($gateway)); + } + + /** + * Test payment URL generation. + */ + public function test_payment_url_on_gateway(): void { + + $this->gateway->init(); + + $url = $this->gateway->get_payment_url_on_gateway('PAY-123'); + $this->assertStringContainsString('sandbox.paypal.com', $url); + $this->assertStringContainsString('PAY-123', $url); + } + + /** + * Test payment URL empty for empty ID. + */ + public function test_payment_url_empty_for_empty_id(): void { + + $this->gateway->init(); + + $url = $this->gateway->get_payment_url_on_gateway(''); + $this->assertEmpty($url); + } + + /** + * Test subscription URL for REST API subscription. + */ + public function test_subscription_url_rest_api(): void { + + $this->gateway->init(); + + $url = $this->gateway->get_subscription_url_on_gateway('I-SUBSCRIPTION123'); + $this->assertStringContainsString('sandbox.paypal.com', $url); + $this->assertStringContainsString('billing/subscriptions', $url); + $this->assertStringContainsString('I-SUBSCRIPTION123', $url); + } + + /** + * Test subscription URL for legacy NVP profile. + */ + public function test_subscription_url_legacy_nvp(): void { + + $this->gateway->init(); + + $url = $this->gateway->get_subscription_url_on_gateway('LEGACY-PROFILE-123'); + $this->assertStringContainsString('sandbox.paypal.com', $url); + $this->assertStringContainsString('_profile-recurring-payments', $url); + } + + /** + * Test subscription URL empty for empty ID. + */ + public function test_subscription_url_empty_for_empty_id(): void { + + $this->gateway->init(); + + $url = $this->gateway->get_subscription_url_on_gateway(''); + $this->assertEmpty($url); + } + + /** + * Test live mode URLs. + */ + public function test_live_mode_urls(): void { + + wu_save_setting('paypal_rest_sandbox_mode', 0); + wu_save_setting('paypal_rest_live_client_id', 'live_id'); + wu_save_setting('paypal_rest_live_client_secret', 'live_secret'); + + $gateway = new PayPal_REST_Gateway(); + $gateway->init(); + + $url = $gateway->get_payment_url_on_gateway('PAY-123'); + $this->assertStringContainsString('www.paypal.com', $url); + $this->assertStringNotContainsString('sandbox', $url); + } + + /** + * Test other_ids includes both paypal and paypal-rest. + */ + public function test_other_ids(): void { + + $all_ids = $this->gateway->get_all_ids(); + + $this->assertContains('paypal-rest', $all_ids); + $this->assertContains('paypal', $all_ids); + } + + /** + * Test API base URL in sandbox mode. + */ + public function test_api_base_url_sandbox(): void { + + $this->gateway->init(); + + $reflection = new \ReflectionClass($this->gateway); + $method = $reflection->getMethod('get_api_base_url'); + + $url = $method->invoke($this->gateway); + $this->assertEquals('https://api-m.sandbox.paypal.com', $url); + } + + /** + * Test API base URL in live mode. + */ + public function test_api_base_url_live(): void { + + $this->gateway->set_test_mode(false); + + $reflection = new \ReflectionClass($this->gateway); + $method = $reflection->getMethod('get_api_base_url'); + + $url = $method->invoke($this->gateway); + $this->assertEquals('https://api-m.paypal.com', $url); + } + + /** + * Test access token error without credentials. + */ + public function test_access_token_error_without_credentials(): void { + + $this->gateway->init(); + + $reflection = new \ReflectionClass($this->gateway); + $method = $reflection->getMethod('get_access_token'); + + $result = $method->invoke($this->gateway); + $this->assertInstanceOf(\WP_Error::class, $result); + $this->assertEquals('wu_paypal_missing_credentials', $result->get_error_code()); + } +} diff --git a/tests/WP_Ultimo/Gateways/PayPal_Webhook_Handler_Test.php b/tests/WP_Ultimo/Gateways/PayPal_Webhook_Handler_Test.php new file mode 100644 index 00000000..0faf8b48 --- /dev/null +++ b/tests/WP_Ultimo/Gateways/PayPal_Webhook_Handler_Test.php @@ -0,0 +1,470 @@ +handler = PayPal_Webhook_Handler::get_instance(); + $this->handler->init(); + } + + /** + * Test handler is singleton. + */ + public function test_singleton(): void { + + $instance1 = PayPal_Webhook_Handler::get_instance(); + $instance2 = PayPal_Webhook_Handler::get_instance(); + + $this->assertSame($instance1, $instance2); + } + + /** + * Test handler initialization registers webhook action. + */ + public function test_init_registers_webhook_action(): void { + + $this->assertNotFalse(has_action('wu_paypal-rest_process_webhooks', [$this->handler, 'process_webhook'])); + } + + /** + * Test API base URL in sandbox mode. + */ + public function test_api_base_url_sandbox(): void { + + $reflection = new \ReflectionClass($this->handler); + $method = $reflection->getMethod('get_api_base_url'); + + $url = $method->invoke($this->handler); + $this->assertEquals('https://api-m.sandbox.paypal.com', $url); + } + + /** + * Test API base URL in live mode. + */ + public function test_api_base_url_live(): void { + + wu_save_setting('paypal_rest_sandbox_mode', 0); + + $handler = PayPal_Webhook_Handler::get_instance(); + $handler->init(); + + $reflection = new \ReflectionClass($handler); + $method = $reflection->getMethod('get_api_base_url'); + + $url = $method->invoke($handler); + $this->assertEquals('https://api-m.paypal.com', $url); + } + + /** + * Test verify_webhook_signature returns false with missing headers. + */ + public function test_verify_signature_fails_without_headers(): void { + + // Clear any server headers + unset( + $_SERVER['HTTP_PAYPAL_AUTH_ALGO'], + $_SERVER['HTTP_PAYPAL_CERT_URL'], + $_SERVER['HTTP_PAYPAL_TRANSMISSION_ID'], + $_SERVER['HTTP_PAYPAL_TRANSMISSION_SIG'], + $_SERVER['HTTP_PAYPAL_TRANSMISSION_TIME'] + ); + + $reflection = new \ReflectionClass($this->handler); + $method = $reflection->getMethod('verify_webhook_signature'); + + $result = $method->invoke($this->handler, '{"test": true}'); + $this->assertFalse($result); + } + + /** + * Test verify_webhook_signature with skip filter. + */ + public function test_verify_signature_skip_with_filter(): void { + + // Clear server headers + unset( + $_SERVER['HTTP_PAYPAL_AUTH_ALGO'], + $_SERVER['HTTP_PAYPAL_CERT_URL'], + $_SERVER['HTTP_PAYPAL_TRANSMISSION_ID'], + $_SERVER['HTTP_PAYPAL_TRANSMISSION_SIG'], + $_SERVER['HTTP_PAYPAL_TRANSMISSION_TIME'] + ); + + add_filter('wu_paypal_skip_webhook_verification', '__return_true'); + + $reflection = new \ReflectionClass($this->handler); + $method = $reflection->getMethod('verify_webhook_signature'); + + $result = $method->invoke($this->handler, '{"test": true}'); + $this->assertTrue($result); + + remove_filter('wu_paypal_skip_webhook_verification', '__return_true'); + } + + /** + * Test verify_webhook_signature fails without webhook_id. + */ + public function test_verify_signature_fails_without_webhook_id(): void { + + // Set headers but no webhook ID + $_SERVER['HTTP_PAYPAL_AUTH_ALGO'] = 'SHA256withRSA'; + $_SERVER['HTTP_PAYPAL_CERT_URL'] = 'https://api.sandbox.paypal.com/cert.pem'; + $_SERVER['HTTP_PAYPAL_TRANSMISSION_ID'] = 'trans-123'; + $_SERVER['HTTP_PAYPAL_TRANSMISSION_SIG'] = 'sig-abc'; + $_SERVER['HTTP_PAYPAL_TRANSMISSION_TIME'] = '2026-01-01T00:00:00Z'; + + wu_save_setting('paypal_rest_sandbox_webhook_id', ''); + + $reflection = new \ReflectionClass($this->handler); + $method = $reflection->getMethod('verify_webhook_signature'); + + $result = $method->invoke($this->handler, '{"test": true}'); + $this->assertFalse($result); + + // Cleanup + unset( + $_SERVER['HTTP_PAYPAL_AUTH_ALGO'], + $_SERVER['HTTP_PAYPAL_CERT_URL'], + $_SERVER['HTTP_PAYPAL_TRANSMISSION_ID'], + $_SERVER['HTTP_PAYPAL_TRANSMISSION_SIG'], + $_SERVER['HTTP_PAYPAL_TRANSMISSION_TIME'] + ); + } + + /** + * Test get_membership_by_subscription returns null for empty ID. + */ + public function test_get_membership_by_subscription_empty(): void { + + $reflection = new \ReflectionClass($this->handler); + $method = $reflection->getMethod('get_membership_by_subscription'); + + $result = $method->invoke($this->handler, ''); + $this->assertNull($result); + } + + /** + * Test get_membership_by_subscription returns null/false for non-existent subscription. + */ + public function test_get_membership_by_subscription_not_found(): void { + + $reflection = new \ReflectionClass($this->handler); + $method = $reflection->getMethod('get_membership_by_subscription'); + + $result = $method->invoke($this->handler, 'I-NONEXISTENT123'); + $this->assertEmpty($result); + } + + /** + * Test handle_subscription_activated with valid membership. + */ + public function test_handle_subscription_activated(): void { + + // Create test data + $customer = wu_create_customer([ + 'user_id' => self::factory()->user->create(), + 'email' => 'paypal-test@example.com', + 'username' => 'paypaltest', + ]); + + $product = wu_create_product([ + 'name' => 'PayPal Test Plan', + 'slug' => 'paypal-test-plan', + 'type' => 'plan', + 'amount' => 29.99, + 'recurring' => true, + 'duration' => 1, + 'duration_unit' => 'month', + 'currency' => 'USD', + 'list_order' => 0, + 'pricing_type' => 'paid', + 'feature_list' => [], + ]); + + $membership = wu_create_membership([ + 'customer_id' => $customer->get_id(), + 'plan_id' => $product->get_id(), + 'gateway' => 'paypal-rest', + 'gateway_subscription_id' => 'I-TESTACTIVATE', + 'status' => Membership_Status::PENDING, + 'amount' => 29.99, + 'currency' => 'USD', + ]); + + $reflection = new \ReflectionClass($this->handler); + $method = $reflection->getMethod('handle_subscription_activated'); + + $method->invoke($this->handler, ['id' => 'I-TESTACTIVATE']); + + // Reload membership + $membership = wu_get_membership($membership->get_id()); + $this->assertEquals(Membership_Status::ACTIVE, $membership->get_status()); + } + + /** + * Test handle_subscription_cancelled sets auto_renew to false. + */ + public function test_handle_subscription_cancelled(): void { + + $customer = wu_create_customer([ + 'user_id' => self::factory()->user->create(), + 'email' => 'paypal-cancel@example.com', + 'username' => 'paypalcancel', + ]); + + $product = wu_create_product([ + 'name' => 'PayPal Cancel Plan', + 'slug' => 'paypal-cancel-plan', + 'type' => 'plan', + 'amount' => 19.99, + 'recurring' => true, + 'duration' => 1, + 'duration_unit' => 'month', + 'currency' => 'USD', + 'list_order' => 0, + 'pricing_type' => 'paid', + 'feature_list' => [], + ]); + + $membership = wu_create_membership([ + 'customer_id' => $customer->get_id(), + 'plan_id' => $product->get_id(), + 'gateway' => 'paypal-rest', + 'gateway_subscription_id' => 'I-TESTCANCEL', + 'status' => Membership_Status::ACTIVE, + 'auto_renew' => true, + 'amount' => 19.99, + 'currency' => 'USD', + ]); + + $reflection = new \ReflectionClass($this->handler); + $method = $reflection->getMethod('handle_subscription_cancelled'); + + $method->invoke($this->handler, ['id' => 'I-TESTCANCEL']); + + // Reload membership + $membership = wu_get_membership($membership->get_id()); + $this->assertFalse($membership->should_auto_renew()); + } + + /** + * Test handle_payment_completed creates renewal payment and prevents duplicates. + */ + public function test_handle_payment_completed_creates_renewal(): void { + + $customer = wu_create_customer([ + 'user_id' => self::factory()->user->create(), + 'email' => 'paypal-renew@example.com', + 'username' => 'paypalrenew', + ]); + + $product = wu_create_product([ + 'name' => 'PayPal Renewal Plan', + 'slug' => 'paypal-renewal-plan', + 'type' => 'plan', + 'amount' => 49.99, + 'recurring' => true, + 'duration' => 1, + 'duration_unit' => 'month', + 'currency' => 'USD', + 'list_order' => 0, + 'pricing_type' => 'paid', + 'feature_list' => [], + ]); + + $membership = wu_create_membership([ + 'customer_id' => $customer->get_id(), + 'plan_id' => $product->get_id(), + 'gateway' => 'paypal-rest', + 'gateway_subscription_id' => 'I-TESTRENEWAL', + 'status' => Membership_Status::ACTIVE, + 'amount' => 49.99, + 'currency' => 'USD', + ]); + + $reflection = new \ReflectionClass($this->handler); + $method = $reflection->getMethod('handle_payment_completed'); + + $resource = [ + 'id' => 'SALE-12345', + 'billing_agreement_id' => 'I-TESTRENEWAL', + 'amount' => [ + 'total' => '49.99', + 'currency' => 'USD', + ], + ]; + + $method->invoke($this->handler, $resource); + + // Check that a payment was created + $payment = wu_get_payment_by('gateway_payment_id', 'SALE-12345'); + $this->assertNotNull($payment); + $this->assertEquals(Payment_Status::COMPLETED, $payment->get_status()); + $this->assertEquals('paypal-rest', $payment->get_gateway()); + + // Call again with same sale ID — should not create duplicate + $method->invoke($this->handler, $resource); + + // Still only one payment + $all_payments = wu_get_payments([ + 'gateway_payment_id' => 'SALE-12345', + ]); + $this->assertCount(1, $all_payments); + } + + /** + * Test handle_subscription_suspended sets membership on hold. + */ + public function test_handle_subscription_suspended(): void { + + $customer = wu_create_customer([ + 'user_id' => self::factory()->user->create(), + 'email' => 'paypal-suspend@example.com', + 'username' => 'paypalsuspend', + ]); + + $product = wu_create_product([ + 'name' => 'PayPal Suspend Plan', + 'slug' => 'paypal-suspend-plan', + 'type' => 'plan', + 'amount' => 9.99, + 'recurring' => true, + 'duration' => 1, + 'duration_unit' => 'month', + 'currency' => 'USD', + 'list_order' => 0, + 'pricing_type' => 'paid', + 'feature_list' => [], + ]); + + $membership = wu_create_membership([ + 'customer_id' => $customer->get_id(), + 'plan_id' => $product->get_id(), + 'gateway' => 'paypal-rest', + 'gateway_subscription_id' => 'I-TESTSUSPEND', + 'status' => Membership_Status::ACTIVE, + 'amount' => 9.99, + 'currency' => 'USD', + ]); + + $reflection = new \ReflectionClass($this->handler); + $method = $reflection->getMethod('handle_subscription_suspended'); + + $method->invoke($this->handler, ['id' => 'I-TESTSUSPEND']); + + // Reload membership + $membership = wu_get_membership($membership->get_id()); + $this->assertEquals(Membership_Status::ON_HOLD, $membership->get_status()); + } + + /** + * Test handle_capture_refunded marks payment as refunded. + */ + public function test_handle_capture_refunded(): void { + + $customer = wu_create_customer([ + 'user_id' => self::factory()->user->create(), + 'email' => 'paypal-refund@example.com', + 'username' => 'paypalrefund', + ]); + + $product = wu_create_product([ + 'name' => 'PayPal Refund Plan', + 'slug' => 'paypal-refund-plan', + 'type' => 'plan', + 'amount' => 59.99, + 'recurring' => false, + 'duration' => 0, + 'duration_unit' => 'month', + 'currency' => 'USD', + 'list_order' => 0, + 'pricing_type' => 'paid', + 'feature_list' => [], + ]); + + $membership = wu_create_membership([ + 'customer_id' => $customer->get_id(), + 'plan_id' => $product->get_id(), + 'gateway' => 'paypal-rest', + 'status' => Membership_Status::ACTIVE, + 'amount' => 59.99, + 'currency' => 'USD', + ]); + + $payment = wu_create_payment([ + 'customer_id' => $customer->get_id(), + 'membership_id' => $membership->get_id(), + 'gateway' => 'paypal-rest', + 'gateway_payment_id' => '8MC585209K746631H', + 'status' => Payment_Status::COMPLETED, + 'total' => 59.99, + 'subtotal' => 59.99, + 'currency' => 'USD', + 'product_id' => $product->get_id(), + ]); + + $reflection = new \ReflectionClass($this->handler); + $method = $reflection->getMethod('handle_capture_refunded'); + + $resource = [ + 'id' => 'REFUND-456', + 'amount' => ['value' => '59.99'], + 'links' => [ + [ + 'rel' => 'up', + 'href' => 'https://api.sandbox.paypal.com/v2/payments/captures/8MC585209K746631H', + ], + ], + ]; + + $method->invoke($this->handler, $resource); + + // Reload payment + $payment = wu_get_payment($payment->get_id()); + $this->assertEquals(Payment_Status::REFUND, $payment->get_status()); + } + + /** + * Cleanup after all tests. + */ + public static function tear_down_after_class(): void { + global $wpdb; + + $wpdb->query("TRUNCATE TABLE {$wpdb->base_prefix}wu_memberships"); + $wpdb->query("TRUNCATE TABLE {$wpdb->base_prefix}wu_payments"); + $wpdb->query("TRUNCATE TABLE {$wpdb->base_prefix}wu_customers"); + $wpdb->query("TRUNCATE TABLE {$wpdb->base_prefix}wu_products"); + + parent::tear_down_after_class(); + } +} diff --git a/tests/WP_Ultimo/Managers/Gateway_Manager_Test.php b/tests/WP_Ultimo/Managers/Gateway_Manager_Test.php index 4a93022d..c36e242a 100644 --- a/tests/WP_Ultimo/Managers/Gateway_Manager_Test.php +++ b/tests/WP_Ultimo/Managers/Gateway_Manager_Test.php @@ -65,6 +65,8 @@ public function test_add_default_gateways() { $this->assertIsArray($registered_gateways); $this->assertArrayHasKey('free', $registered_gateways); $this->assertArrayHasKey('manual', $registered_gateways); + $this->assertArrayHasKey('paypal-rest', $registered_gateways); + $this->assertArrayHasKey('paypal', $registered_gateways); } /** From ce610f919bf23a21d08cb41340f728a35c064efb Mon Sep 17 00:00:00 2001 From: David Stone Date: Fri, 27 Feb 2026 16:54:52 -0700 Subject: [PATCH 03/11] Refactor PayPal OAuth to use proxy for partner credentials Move partner credential handling to a proxy server at ultimatemultisite.com/wp-json/paypal-connect/v1, matching the existing Stripe Connect proxy pattern. This keeps partner credentials out of the open source codebase. The OAuth handler now: - Calls POST /oauth/init on the proxy to create referral URLs - Calls POST /oauth/verify to verify merchant status - Calls POST /deauthorize on disconnect (non-blocking) - No longer loads or stores partner credentials locally Co-Authored-By: Claude Opus 4.6 --- inc/gateways/class-paypal-oauth-handler.php | 443 ++++++-------------- 1 file changed, 121 insertions(+), 322 deletions(-) diff --git a/inc/gateways/class-paypal-oauth-handler.php b/inc/gateways/class-paypal-oauth-handler.php index b5bbc0e5..a44cb8ed 100644 --- a/inc/gateways/class-paypal-oauth-handler.php +++ b/inc/gateways/class-paypal-oauth-handler.php @@ -2,9 +2,9 @@ /** * PayPal OAuth Handler. * - * Handles the OAuth "Connect with PayPal" flow using PayPal's Partner Referrals API. - * This enables merchants to connect their PayPal accounts without manually - * copying API credentials. + * Handles the OAuth "Connect with PayPal" flow via a proxy server. + * The proxy holds the partner credentials securely; this handler + * communicates with the proxy to initiate onboarding and verify merchants. * * @package WP_Ultimo * @subpackage Gateways @@ -27,14 +27,6 @@ class PayPal_OAuth_Handler { use \WP_Ultimo\Traits\Singleton; - /** - * Partner Attribution ID (BN Code) for PayPal Partner Program tracking. - * - * @since 2.0.0 - * @var string - */ - protected $bn_code = 'UltimateMultisite_SP_PPCP'; - /** * Holds if we are in test mode. * @@ -43,33 +35,6 @@ class PayPal_OAuth_Handler { */ protected $test_mode = true; - /** - * Client ID for the partner application. - * - * This is Ultimate Multisite's partner application credentials, - * used to initiate the OAuth flow on behalf of merchants. - * - * @since 2.0.0 - * @var string - */ - protected $partner_client_id = ''; - - /** - * Client secret for the partner application. - * - * @since 2.0.0 - * @var string - */ - protected $partner_client_secret = ''; - - /** - * Partner merchant ID. - * - * @since 2.0.0 - * @var string - */ - protected $partner_merchant_id = ''; - /** * Initialize the OAuth handler. * @@ -80,8 +45,6 @@ public function init(): void { $this->test_mode = (bool) (int) wu_get_setting('paypal_rest_sandbox_mode', true); - $this->load_partner_credentials(); - // Register AJAX handlers add_action('wp_ajax_wu_paypal_connect', [$this, 'ajax_initiate_oauth']); add_action('wp_ajax_wu_paypal_disconnect', [$this, 'ajax_disconnect']); @@ -91,69 +54,32 @@ public function init(): void { } /** - * Load partner credentials from settings. - * - * Partner credentials are used to authenticate with PayPal's Partner API - * to initiate the OAuth flow for merchants. + * Get the PayPal Connect proxy URL. * * @since 2.0.0 - * @return void + * @return string */ - protected function load_partner_credentials(): void { - - $mode_prefix = $this->test_mode ? 'sandbox_' : 'live_'; - - // Check for constants first (recommended for production) - $const_prefix = $this->test_mode ? 'WU_PAYPAL_SANDBOX_PARTNER_' : 'WU_PAYPAL_PARTNER_'; - - $this->partner_client_id = defined($const_prefix . 'CLIENT_ID') ? constant($const_prefix . 'CLIENT_ID') : ''; - $this->partner_client_secret = defined($const_prefix . 'CLIENT_SECRET') ? constant($const_prefix . 'CLIENT_SECRET') : ''; - $this->partner_merchant_id = defined($const_prefix . 'MERCHANT_ID') ? constant($const_prefix . 'MERCHANT_ID') : ''; - - // Fall back to settings if constants not defined - if (empty($this->partner_client_id)) { - $this->partner_client_id = wu_get_setting("paypal_rest_{$mode_prefix}partner_client_id", ''); - } - - if (empty($this->partner_client_secret)) { - $this->partner_client_secret = wu_get_setting("paypal_rest_{$mode_prefix}partner_client_secret", ''); - } - - if (empty($this->partner_merchant_id)) { - $this->partner_merchant_id = wu_get_setting("paypal_rest_{$mode_prefix}partner_merchant_id", ''); - } + protected function get_proxy_url(): string { /** - * Filters partner credentials for PayPal OAuth. + * Filters the PayPal Connect proxy URL. * * @since 2.0.0 * - * @param array $credentials { - * Partner credentials. - * @type string $client_id Partner client ID. - * @type string $client_secret Partner client secret. - * @type string $merchant_id Partner merchant ID. - * } - * @param bool $test_mode Whether sandbox mode is active. + * @param string $url Proxy server URL. */ - $credentials = apply_filters( - 'wu_paypal_partner_credentials', - [ - 'client_id' => $this->partner_client_id, - 'client_secret' => $this->partner_client_secret, - 'merchant_id' => $this->partner_merchant_id, - ], - $this->test_mode + return apply_filters( + 'wu_paypal_connect_proxy_url', + 'https://ultimatemultisite.com/wp-json/paypal-connect/v1' ); - - $this->partner_client_id = $credentials['client_id'] ?? ''; - $this->partner_client_secret = $credentials['client_secret'] ?? ''; - $this->partner_merchant_id = $credentials['merchant_id'] ?? ''; } /** * Returns the PayPal API base URL based on test mode. * + * Used only for the access token call needed by the REST gateway + * (merchant's own credentials, not partner credentials). + * * @since 2.0.0 * @return string */ @@ -163,242 +89,101 @@ protected function get_api_base_url(): string { } /** - * Returns the PayPal web base URL based on test mode. - * - * @since 2.0.0 - * @return string - */ - protected function get_paypal_web_url(): string { - - return $this->test_mode ? 'https://www.sandbox.paypal.com' : 'https://www.paypal.com'; - } - - /** - * Get an access token for the partner application. + * AJAX handler to initiate OAuth flow via the proxy. * * @since 2.0.0 - * @return string|\WP_Error Access token or error. + * @return void */ - protected function get_partner_access_token() { - - // Check for cached token - $cache_key = 'wu_paypal_partner_token_' . ($this->test_mode ? 'sandbox' : 'live'); - $cached_token = get_site_transient($cache_key); + public function ajax_initiate_oauth(): void { - if ($cached_token) { - return $cached_token; - } + check_ajax_referer('wu_paypal_oauth', 'nonce'); - if (empty($this->partner_client_id) || empty($this->partner_client_secret)) { - return new \WP_Error( - 'wu_paypal_missing_partner_credentials', - __('Partner credentials not configured. Please configure the partner client ID and secret.', 'ultimate-multisite') + if (! current_user_can('manage_network_options')) { + wp_send_json_error( + [ + 'message' => __('You do not have permission to do this.', 'ultimate-multisite'), + ] ); } - $response = wp_remote_post( - $this->get_api_base_url() . '/v1/oauth2/token', - [ - 'headers' => [ - 'Authorization' => 'Basic ' . base64_encode($this->partner_client_id . ':' . $this->partner_client_secret), // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Required for PayPal API Basic auth - 'Content-Type' => 'application/x-www-form-urlencoded', - ], - 'body' => 'grant_type=client_credentials', - 'timeout' => 30, - ] - ); - - if (is_wp_error($response)) { - wu_log_add('paypal', 'Failed to get partner access token: ' . $response->get_error_message(), LogLevel::ERROR); - return $response; - } - - $body = json_decode(wp_remote_retrieve_body($response), true); - $code = wp_remote_retrieve_response_code($response); - - if (200 !== $code || empty($body['access_token'])) { - $error_msg = $body['error_description'] ?? __('Failed to obtain access token', 'ultimate-multisite'); - wu_log_add('paypal', 'Failed to get partner access token: ' . $error_msg, LogLevel::ERROR); - return new \WP_Error('wu_paypal_token_error', $error_msg); - } - - // Cache the token (expires_in is in seconds, subtract 5 minutes for safety) - $expires_in = isset($body['expires_in']) ? (int) $body['expires_in'] - 300 : 3300; - set_site_transient($cache_key, $body['access_token'], $expires_in); - - return $body['access_token']; - } - - /** - * Generate a partner referral URL for merchant onboarding. - * - * Uses PayPal Partner Referrals API v2 to create an onboarding link. - * - * @since 2.0.0 - * @return array|\WP_Error Array with action_url and tracking_id, or error. - */ - public function generate_referral_url() { - - $access_token = $this->get_partner_access_token(); - - if (is_wp_error($access_token)) { - return $access_token; + // Update test mode from request if provided + if (isset($_POST['sandbox_mode'])) { + $this->test_mode = (bool) (int) $_POST['sandbox_mode']; } - // Generate a unique tracking ID for this onboarding attempt - $tracking_id = 'wu_' . wp_generate_uuid4(); - - // Store tracking ID for verification when merchant returns - set_site_transient( - 'wu_paypal_onboarding_' . $tracking_id, - [ - 'started' => time(), - 'test_mode' => $this->test_mode, - ], - DAY_IN_SECONDS - ); - // Build the return URL $return_url = add_query_arg( [ 'page' => 'wp-ultimo-settings', 'tab' => 'payment-gateways', 'wu_paypal_onboarding' => 'complete', - 'tracking_id' => $tracking_id, ], network_admin_url('admin.php') ); - // Build the partner referral request - $referral_data = [ - 'tracking_id' => $tracking_id, - 'partner_config_override' => [ - 'return_url' => $return_url, - ], - 'operations' => [ - [ - 'operation' => 'API_INTEGRATION', - 'api_integration_preference' => [ - 'rest_api_integration' => [ - 'integration_method' => 'PAYPAL', - 'integration_type' => 'THIRD_PARTY', - 'third_party_details' => [ - 'features' => [ - 'PAYMENT', - 'REFUND', - 'PARTNER_FEE', - 'DELAY_FUNDS_DISBURSEMENT', - ], - ], - ], - ], - ], - ], - 'products' => ['EXPRESS_CHECKOUT'], - 'legal_consents' => [ - [ - 'type' => 'SHARE_DATA_CONSENT', - 'granted' => true, - ], - ], - ]; - - /** - * Filters the partner referral data before sending to PayPal. - * - * @since 2.0.0 - * - * @param array $referral_data The referral request data. - * @param string $tracking_id The tracking ID for this onboarding. - */ - $referral_data = apply_filters('wu_paypal_partner_referral_data', $referral_data, $tracking_id); + $proxy_url = $this->get_proxy_url(); + // Call the proxy to initiate the OAuth flow $response = wp_remote_post( - $this->get_api_base_url() . '/v2/customer/partner-referrals', + $proxy_url . '/oauth/init', [ + 'body' => wp_json_encode( + [ + 'returnUrl' => $return_url, + 'testMode' => $this->test_mode, + ] + ), 'headers' => [ - 'Authorization' => 'Bearer ' . $access_token, - 'Content-Type' => 'application/json', - 'PayPal-Partner-Attribution-Id' => $this->bn_code, + 'Content-Type' => 'application/json', ], - 'body' => wp_json_encode($referral_data), 'timeout' => 30, ] ); if (is_wp_error($response)) { - wu_log_add('paypal', 'Failed to create partner referral: ' . $response->get_error_message(), LogLevel::ERROR); - return $response; - } - - $body = json_decode(wp_remote_retrieve_body($response), true); - $code = wp_remote_retrieve_response_code($response); - - if (201 !== $code || empty($body['links'])) { - $error_msg = $body['message'] ?? __('Failed to create partner referral', 'ultimate-multisite'); - wu_log_add('paypal', 'Failed to create partner referral: ' . wp_json_encode($body), LogLevel::ERROR); - return new \WP_Error('wu_paypal_referral_error', $error_msg); - } - - // Find the action_url link - $action_url = ''; - foreach ($body['links'] as $link) { - if ('action_url' === $link['rel']) { - $action_url = $link['href']; - break; - } - } + wu_log_add('paypal', 'Proxy init failed: ' . $response->get_error_message(), LogLevel::ERROR); - if (empty($action_url)) { - return new \WP_Error('wu_paypal_no_action_url', __('No action URL returned from PayPal', 'ultimate-multisite')); + wp_send_json_error( + [ + 'message' => __('Could not reach the PayPal Connect service. Please check that your server can make outbound HTTPS requests and try again.', 'ultimate-multisite'), + ] + ); } - wu_log_add('paypal', sprintf('Partner referral created. Tracking ID: %s', $tracking_id)); - - return [ - 'action_url' => $action_url, - 'tracking_id' => $tracking_id, - ]; - } - - /** - * AJAX handler to initiate OAuth flow. - * - * @since 2.0.0 - * @return void - */ - public function ajax_initiate_oauth(): void { + $status_code = wp_remote_retrieve_response_code($response); + $data = json_decode(wp_remote_retrieve_body($response), true); - check_ajax_referer('wu_paypal_oauth', 'nonce'); + if (200 !== $status_code || empty($data['actionUrl'])) { + $error_msg = $data['error'] ?? __('Failed to initiate PayPal onboarding', 'ultimate-multisite'); + wu_log_add('paypal', 'Proxy init error: ' . $error_msg, LogLevel::ERROR); - if (! current_user_can('manage_network_options')) { wp_send_json_error( [ - 'message' => __('You do not have permission to do this.', 'ultimate-multisite'), + 'message' => $error_msg, ] ); } - // Update test mode from request if provided - if (isset($_POST['sandbox_mode'])) { - $this->test_mode = (bool) (int) $_POST['sandbox_mode']; - $this->load_partner_credentials(); - } - - $result = $this->generate_referral_url(); + // Store the tracking ID locally for verification on return + $tracking_id = $data['trackingId'] ?? ''; - if (is_wp_error($result)) { - wp_send_json_error( + if ($tracking_id) { + set_site_transient( + 'wu_paypal_onboarding_' . $tracking_id, [ - 'message' => $result->get_error_message(), - ] + 'started' => time(), + 'test_mode' => $this->test_mode, + ], + DAY_IN_SECONDS ); } + wu_log_add('paypal', sprintf('OAuth initiated via proxy. Tracking ID: %s', $tracking_id)); + wp_send_json_success( [ - 'redirect_url' => $result['action_url'], - 'tracking_id' => $result['tracking_id'], + 'redirect_url' => $data['actionUrl'], + 'tracking_id' => $tracking_id, ] ); } @@ -436,12 +221,13 @@ public function handle_oauth_return(): void { $tracking_id = isset($_GET['tracking_id']) ? sanitize_text_field(wp_unslash($_GET['tracking_id'])) : ''; // phpcs:enable WordPress.Security.NonceVerification.Recommended - // Verify tracking ID + // Verify tracking ID was created by us $onboarding_data = get_site_transient('wu_paypal_onboarding_' . $tracking_id); if (! $onboarding_data) { wu_log_add('paypal', 'OAuth return with invalid tracking ID: ' . $tracking_id, LogLevel::WARNING); $this->add_oauth_notice('error', __('Invalid onboarding session. Please try again.', 'ultimate-multisite')); + return; } @@ -452,15 +238,17 @@ public function handle_oauth_return(): void { if (! $permissions_granted) { wu_log_add('paypal', 'OAuth: Merchant did not grant permissions', LogLevel::WARNING); $this->add_oauth_notice('warning', __('PayPal permissions were not granted. Please try again and approve the required permissions.', 'ultimate-multisite')); + return; } - // Verify the merchant status with PayPal - $merchant_status = $this->verify_merchant_status($merchant_id, $tracking_id); + // Verify the merchant status via the proxy + $merchant_status = $this->verify_merchant_via_proxy($merchant_id, $tracking_id); if (is_wp_error($merchant_status)) { wu_log_add('paypal', 'Failed to verify merchant status: ' . $merchant_status->get_error_message(), LogLevel::ERROR); $this->add_oauth_notice('error', __('Failed to verify your PayPal account status. Please try again.', 'ultimate-multisite')); + return; } @@ -474,11 +262,12 @@ public function handle_oauth_return(): void { wu_save_setting('paypal_rest_connection_mode', $mode_prefix); // Store additional status info if available - if (! empty($merchant_status['payments_receivable'])) { - wu_save_setting("paypal_rest_{$mode_prefix}_payments_receivable", $merchant_status['payments_receivable']); + if (! empty($merchant_status['paymentsReceivable'])) { + wu_save_setting("paypal_rest_{$mode_prefix}_payments_receivable", $merchant_status['paymentsReceivable']); } - if (! empty($merchant_status['primary_email_confirmed'])) { - wu_save_setting("paypal_rest_{$mode_prefix}_email_confirmed", $merchant_status['primary_email_confirmed']); + + if (! empty($merchant_status['emailConfirmed'])) { + wu_save_setting("paypal_rest_{$mode_prefix}_email_confirmed", $merchant_status['emailConfirmed']); } // Clean up the tracking transient @@ -506,40 +295,33 @@ public function handle_oauth_return(): void { } /** - * Verify merchant status after OAuth completion. + * Verify merchant status via the proxy. * - * Calls PayPal to verify the merchant's integration status and capabilities. + * The proxy holds the partner credentials needed to check the + * merchant's integration status with PayPal. * * @since 2.0.0 * - * @param string $merchant_id The merchant's PayPal ID. - * @param string $tracking_id The tracking ID from onboarding. + * @param string $merchant_id The merchant's PayPal ID. + * @param string $tracking_id The tracking ID from onboarding. * @return array|\WP_Error Merchant status data or error. */ - protected function verify_merchant_status(string $merchant_id, string $tracking_id) { - - $access_token = $this->get_partner_access_token(); - - if (is_wp_error($access_token)) { - return $access_token; - } + protected function verify_merchant_via_proxy(string $merchant_id, string $tracking_id) { - if (empty($this->partner_merchant_id)) { - // If no partner merchant ID, we can't verify status via partner API - // Return basic success - return [ - 'merchant_id' => $merchant_id, - 'payments_receivable' => true, - ]; - } + $proxy_url = $this->get_proxy_url(); - $response = wp_remote_get( - $this->get_api_base_url() . '/v1/customer/partners/' . $this->partner_merchant_id . '/merchant-integrations/' . $merchant_id, + $response = wp_remote_post( + $proxy_url . '/oauth/verify', [ + 'body' => wp_json_encode( + [ + 'merchantId' => $merchant_id, + 'trackingId' => $tracking_id, + 'testMode' => $this->test_mode, + ] + ), 'headers' => [ - 'Authorization' => 'Bearer ' . $access_token, - 'Content-Type' => 'application/json', - 'PayPal-Partner-Attribution-Id' => $this->bn_code, + 'Content-Type' => 'application/json', ], 'timeout' => 30, ] @@ -549,21 +331,16 @@ protected function verify_merchant_status(string $merchant_id, string $tracking_ return $response; } - $body = json_decode(wp_remote_retrieve_body($response), true); - $code = wp_remote_retrieve_response_code($response); + $status_code = wp_remote_retrieve_response_code($response); + $data = json_decode(wp_remote_retrieve_body($response), true); + + if (200 !== $status_code) { + $error_msg = $data['error'] ?? __('Failed to verify merchant status', 'ultimate-multisite'); - if (200 !== $code) { - $error_msg = $body['message'] ?? __('Failed to verify merchant status', 'ultimate-multisite'); return new \WP_Error('wu_paypal_verify_error', $error_msg); } - return [ - 'merchant_id' => $body['merchant_id'] ?? $merchant_id, - 'tracking_id' => $body['tracking_id'] ?? $tracking_id, - 'payments_receivable' => $body['payments_receivable'] ?? false, - 'primary_email_confirmed' => $body['primary_email_confirmed'] ?? false, - 'oauth_integrations' => $body['oauth_integrations'] ?? [], - ]; + return $data; } /** @@ -587,6 +364,24 @@ public function ajax_disconnect(): void { // Delete webhooks before clearing credentials $this->delete_webhooks_on_disconnect(); + // Notify proxy of disconnect (non-blocking) + $proxy_url = $this->get_proxy_url(); + + wp_remote_post( + $proxy_url . '/deauthorize', + [ + 'body' => wp_json_encode( + [ + 'siteUrl' => get_site_url(), + 'testMode' => $this->test_mode, + ] + ), + 'headers' => ['Content-Type' => 'application/json'], + 'timeout' => 10, + 'blocking' => false, + ] + ); + // Clear all connection data $settings_to_clear = [ 'paypal_rest_connected', @@ -609,8 +404,6 @@ public function ajax_disconnect(): void { } // Clear cached access tokens - delete_site_transient('wu_paypal_partner_token_sandbox'); - delete_site_transient('wu_paypal_partner_token_live'); delete_site_transient('wu_paypal_rest_access_token_sandbox'); delete_site_transient('wu_paypal_rest_access_token_live'); @@ -669,14 +462,17 @@ public function display_oauth_notices(): void { } /** - * Check if OAuth is fully configured. + * Check if the proxy is reachable and configured. + * + * This replaces the old is_configured() which checked for local partner credentials. + * Now we just check if the proxy URL is set (it always is by default). * * @since 2.0.0 * @return bool */ public function is_configured(): bool { - return ! empty($this->partner_client_id) && ! empty($this->partner_client_secret); + return ! empty($this->get_proxy_url()); } /** @@ -735,6 +531,7 @@ protected function install_webhook_after_oauth(string $mode_prefix): void { if (! $gateway instanceof PayPal_REST_Gateway) { wu_log_add('paypal', 'Could not get PayPal REST gateway instance for webhook installation', LogLevel::WARNING); + return; } @@ -775,6 +572,7 @@ protected function delete_webhooks_on_disconnect(): void { // Try to delete sandbox webhook $gateway->set_test_mode(true); $result = $gateway->delete_webhook(); + if (is_wp_error($result)) { wu_log_add('paypal', sprintf('Failed to delete sandbox webhook: %s', $result->get_error_message()), LogLevel::WARNING); } else { @@ -784,6 +582,7 @@ protected function delete_webhooks_on_disconnect(): void { // Try to delete live webhook $gateway->set_test_mode(false); $result = $gateway->delete_webhook(); + if (is_wp_error($result)) { wu_log_add('paypal', sprintf('Failed to delete live webhook: %s', $result->get_error_message()), LogLevel::WARNING); } else { From 87373bc91955d02d6e72e9c55028d15d79cca912 Mon Sep 17 00:00:00 2001 From: David Stone Date: Fri, 27 Feb 2026 16:59:52 -0700 Subject: [PATCH 04/11] Move inline scripts to admin_footer to fix settings page rendering The settings renderer was escaping ', - esc_attr($nonce), - $is_sandbox ? 1 : 0, - esc_html__('Connect with PayPal', 'ultimate-multisite'), - esc_html__('Click to securely connect your PayPal account.', 'ultimate-multisite'), - esc_js(__('Connecting...', 'ultimate-multisite')), - esc_js(__('Connection failed. Please try again.', 'ultimate-multisite')), - esc_js(__('Connect with PayPal', 'ultimate-multisite')), - esc_js(__('Connection failed. Please try again.', 'ultimate-multisite')), - esc_js(__('Connect with PayPal', 'ultimate-multisite')), - esc_js(__('Are you sure you want to disconnect PayPal?', 'ultimate-multisite')) + + is_configured()) { + $this->enqueue_webhook_scripts(); + return sprintf( '
@@ -1353,39 +1377,10 @@ public function render_webhook_status(): string { -
- ', + ', esc_html__('Webhook not configured. Click below to configure automatically.', 'ultimate-multisite'), esc_attr(wp_create_nonce('wu_paypal_webhook')), - esc_html__('Configure Webhook', 'ultimate-multisite'), - esc_js(__('Configuring...', 'ultimate-multisite')), - esc_js(__('Failed to configure webhook. Please try again.', 'ultimate-multisite')), - esc_js(__('Configure Webhook', 'ultimate-multisite')), - esc_js(__('Failed to configure webhook. Please try again.', 'ultimate-multisite')), - esc_js(__('Configure Webhook', 'ultimate-multisite')) + esc_html__('Configure Webhook', 'ultimate-multisite') ); } @@ -1398,6 +1393,55 @@ public function render_webhook_status(): string { ); } + /** + * Enqueue the webhook install button scripts. + * + * @since 2.0.0 + * @return void + */ + protected function enqueue_webhook_scripts(): void { + + static $enqueued = false; + + if ($enqueued) { + return; + } + + $enqueued = true; + + add_action( + 'admin_footer', + function () { + ?> + + Date: Fri, 27 Feb 2026 19:39:27 -0700 Subject: [PATCH 05/11] Embed nonces in JS instead of data attributes for wp_kses compatibility The settings framework runs description HTML through wp_kses() which strips data-nonce and data-sandbox attributes from buttons. Move nonce and sandbox values into the admin_footer script as JS variables instead. Co-Authored-By: Claude Opus 4.6 --- inc/gateways/class-paypal-rest-gateway.php | 40 +++++++++++++--------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/inc/gateways/class-paypal-rest-gateway.php b/inc/gateways/class-paypal-rest-gateway.php index 8ab5f980..ef3b10ea 100644 --- a/inc/gateways/class-paypal-rest-gateway.php +++ b/inc/gateways/class-paypal-rest-gateway.php @@ -1240,34 +1240,30 @@ public function render_connection_status(): string { */ public function render_connect_button(): string { - $nonce = wp_create_nonce('wu_paypal_oauth'); - $is_sandbox = $this->test_mode; $oauth = PayPal_OAuth_Handler::get_instance(); - $is_connected = $oauth->is_merchant_connected($is_sandbox); + $is_connected = $oauth->is_merchant_connected($this->test_mode); // Enqueue the inline JS via admin_footer to avoid script tag escaping + // Nonce/sandbox values are embedded in the JS since wp_kses strips data-* attributes $this->enqueue_connect_scripts(); if ($is_connected) { return sprintf( - '

%s

', - esc_attr($nonce), esc_html__('Disconnect PayPal', 'ultimate-multisite'), esc_html__('This will remove the PayPal connection. Existing subscriptions will continue to work.', 'ultimate-multisite') ); } return sprintf( - '

%s

', - esc_attr($nonce), - $is_sandbox ? 1 : 0, esc_html__('Connect with PayPal', 'ultimate-multisite'), esc_html__('Click to securely connect your PayPal account.', 'ultimate-multisite') ); @@ -1289,12 +1285,20 @@ protected function enqueue_connect_scripts(): void { $enqueued = true; + // Capture values now; wp_kses strips data-* attributes from the button HTML, + // so we embed nonce and sandbox values directly in the footer script. + $nonce = wp_create_nonce('wu_paypal_oauth'); + $sandbox = $this->test_mode ? 1 : 0; + add_action( 'admin_footer', - function () { + function () use ($nonce, $sandbox) { ?> - Date: Fri, 27 Feb 2026 21:11:21 -0700 Subject: [PATCH 09/11] Hide legacy PayPal gateway when no existing configuration Only register the PayPal (Legacy) NVP gateway if it's already active or has credentials configured. New installs will only see the modern PayPal REST gateway. Co-Authored-By: Claude Opus 4.6 --- inc/managers/class-gateway-manager.php | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/inc/managers/class-gateway-manager.php b/inc/managers/class-gateway-manager.php index e26f30ff..79a87d11 100644 --- a/inc/managers/class-gateway-manager.php +++ b/inc/managers/class-gateway-manager.php @@ -437,9 +437,15 @@ public function add_default_gateways(): void { /* * PayPal Payments (Legacy NVP API) + * Only show if already active or has existing credentials configured. */ - $paypal_desc = __('PayPal Express Checkout (Legacy). Uses username/password/signature authentication. For existing integrations only.', 'ultimate-multisite'); - wu_register_gateway('paypal', __('PayPal (Legacy)', 'ultimate-multisite'), $paypal_desc, PayPal_Gateway::class); + $legacy_paypal_active = in_array('paypal', (array) wu_get_setting('active_gateways', []), true); + $legacy_paypal_has_creds = wu_get_setting('paypal_test_username', '') || wu_get_setting('paypal_live_username', ''); + + if ($legacy_paypal_active || $legacy_paypal_has_creds) { + $paypal_desc = __('PayPal Express Checkout (Legacy). Uses username/password/signature authentication. For existing integrations only.', 'ultimate-multisite'); + wu_register_gateway('paypal', __('PayPal (Legacy)', 'ultimate-multisite'), $paypal_desc, PayPal_Gateway::class); + } /* * Initialize PayPal REST webhook handler From 0160deb21ae6e963c3848593ea2b8b0e451c937b Mon Sep 17 00:00:00 2001 From: David Stone Date: Fri, 27 Feb 2026 21:31:34 -0700 Subject: [PATCH 10/11] Add unit tests for PayPal OAuth handler and E2E Cypress test - PayPal_REST_Gateway_Test: 9 new tests for settings registration, render_oauth_connection output, webhook listener URL, and maybe_install_webhook skip logic - PayPal_OAuth_Handler_Test: 10 tests for singleton, is_configured, is_merchant_connected (sandbox/live), get_merchant_details, and init hook registration - Gateway_Manager_Test: 4 new tests for legacy PayPal conditional registration (hidden without config, shown with credentials, shown when active, paypal-rest always registered) - E2E Cypress spec: 035-paypal-checkout-flow.spec.js tests PayPal gateway setup, checkout form rendering, and form submission with PayPal selected - PHP fixtures for gateway setup and result verification Co-Authored-By: Claude Opus 4.6 --- .../Gateways/PayPal_OAuth_Handler_Test.php | 162 ++++++++++++++ .../Gateways/PayPal_REST_Gateway_Test.php | 198 +++++++++++++++++- .../Managers/Gateway_Manager_Test.php | 107 +++++++++- .../cypress/fixtures/setup-paypal-gateway.php | 42 ++++ .../verify-paypal-checkout-results.php | 52 +++++ .../035-paypal-checkout-flow.spec.js | 193 +++++++++++++++++ 6 files changed, 744 insertions(+), 10 deletions(-) create mode 100644 tests/WP_Ultimo/Gateways/PayPal_OAuth_Handler_Test.php create mode 100644 tests/e2e/cypress/fixtures/setup-paypal-gateway.php create mode 100644 tests/e2e/cypress/fixtures/verify-paypal-checkout-results.php create mode 100644 tests/e2e/cypress/integration/035-paypal-checkout-flow.spec.js diff --git a/tests/WP_Ultimo/Gateways/PayPal_OAuth_Handler_Test.php b/tests/WP_Ultimo/Gateways/PayPal_OAuth_Handler_Test.php new file mode 100644 index 00000000..1988b36f --- /dev/null +++ b/tests/WP_Ultimo/Gateways/PayPal_OAuth_Handler_Test.php @@ -0,0 +1,162 @@ +handler = PayPal_OAuth_Handler::get_instance(); + } + + /** + * Test handler is singleton. + */ + public function test_singleton(): void { + + $instance1 = PayPal_OAuth_Handler::get_instance(); + $instance2 = PayPal_OAuth_Handler::get_instance(); + + $this->assertSame($instance1, $instance2); + } + + /** + * Test is_configured returns true (proxy URL is always set). + */ + public function test_is_configured(): void { + + $this->assertTrue($this->handler->is_configured()); + } + + /** + * Test is_configured returns false when proxy URL is empty. + */ + public function test_is_configured_false_without_proxy(): void { + + add_filter('wu_paypal_connect_proxy_url', '__return_empty_string'); + + $this->assertFalse($this->handler->is_configured()); + + remove_filter('wu_paypal_connect_proxy_url', '__return_empty_string'); + } + + /** + * Test merchant not connected without merchant ID. + */ + public function test_merchant_not_connected_without_id(): void { + + $this->assertFalse($this->handler->is_merchant_connected(true)); + $this->assertFalse($this->handler->is_merchant_connected(false)); + } + + /** + * Test merchant connected in sandbox mode. + */ + public function test_merchant_connected_sandbox(): void { + + wu_save_setting('paypal_rest_sandbox_merchant_id', 'SANDBOX_MERCHANT_123'); + + $this->assertTrue($this->handler->is_merchant_connected(true)); + $this->assertFalse($this->handler->is_merchant_connected(false)); + } + + /** + * Test merchant connected in live mode. + */ + public function test_merchant_connected_live(): void { + + wu_save_setting('paypal_rest_live_merchant_id', 'LIVE_MERCHANT_456'); + + $this->assertFalse($this->handler->is_merchant_connected(true)); + $this->assertTrue($this->handler->is_merchant_connected(false)); + } + + /** + * Test get_merchant_details returns correct sandbox data. + */ + public function test_get_merchant_details_sandbox(): void { + + wu_save_setting('paypal_rest_sandbox_merchant_id', 'MERCHANT_ID_TEST'); + wu_save_setting('paypal_rest_sandbox_merchant_email', 'test@merchant.com'); + wu_save_setting('paypal_rest_sandbox_payments_receivable', true); + wu_save_setting('paypal_rest_sandbox_email_confirmed', true); + wu_save_setting('paypal_rest_connection_date', '2026-02-01 10:00:00'); + + $details = $this->handler->get_merchant_details(true); + + $this->assertEquals('MERCHANT_ID_TEST', $details['merchant_id']); + $this->assertEquals('test@merchant.com', $details['merchant_email']); + $this->assertTrue($details['payments_receivable']); + $this->assertTrue($details['email_confirmed']); + $this->assertEquals('2026-02-01 10:00:00', $details['connection_date']); + } + + /** + * Test get_merchant_details returns correct live data. + */ + public function test_get_merchant_details_live(): void { + + wu_save_setting('paypal_rest_live_merchant_id', 'LIVE_MID'); + wu_save_setting('paypal_rest_live_merchant_email', 'live@merchant.com'); + + $details = $this->handler->get_merchant_details(false); + + $this->assertEquals('LIVE_MID', $details['merchant_id']); + $this->assertEquals('live@merchant.com', $details['merchant_email']); + } + + /** + * Test get_merchant_details returns empty defaults when not connected. + */ + public function test_get_merchant_details_empty(): void { + + $details = $this->handler->get_merchant_details(true); + + $this->assertEmpty($details['merchant_id']); + $this->assertEmpty($details['merchant_email']); + $this->assertEmpty($details['connection_date']); + } + + /** + * Test init registers AJAX hooks. + */ + public function test_init_registers_hooks(): void { + + $this->handler->init(); + + $this->assertNotFalse(has_action('wp_ajax_wu_paypal_connect', [$this->handler, 'ajax_initiate_oauth'])); + $this->assertNotFalse(has_action('wp_ajax_wu_paypal_disconnect', [$this->handler, 'ajax_disconnect'])); + $this->assertNotFalse(has_action('admin_init', [$this->handler, 'handle_oauth_return'])); + } +} diff --git a/tests/WP_Ultimo/Gateways/PayPal_REST_Gateway_Test.php b/tests/WP_Ultimo/Gateways/PayPal_REST_Gateway_Test.php index d2371bba..e6aae9d7 100644 --- a/tests/WP_Ultimo/Gateways/PayPal_REST_Gateway_Test.php +++ b/tests/WP_Ultimo/Gateways/PayPal_REST_Gateway_Test.php @@ -172,7 +172,7 @@ public function test_set_test_mode(): void { // Verify sandbox mode credentials $reflection = new \ReflectionClass($gateway); - $prop = $reflection->getProperty('client_id'); + $prop = $reflection->getProperty('client_id'); $this->assertEquals('sandbox_id', $prop->getValue($gateway)); @@ -277,7 +277,7 @@ public function test_api_base_url_sandbox(): void { $this->gateway->init(); $reflection = new \ReflectionClass($this->gateway); - $method = $reflection->getMethod('get_api_base_url'); + $method = $reflection->getMethod('get_api_base_url'); $url = $method->invoke($this->gateway); $this->assertEquals('https://api-m.sandbox.paypal.com', $url); @@ -291,7 +291,7 @@ public function test_api_base_url_live(): void { $this->gateway->set_test_mode(false); $reflection = new \ReflectionClass($this->gateway); - $method = $reflection->getMethod('get_api_base_url'); + $method = $reflection->getMethod('get_api_base_url'); $url = $method->invoke($this->gateway); $this->assertEquals('https://api-m.paypal.com', $url); @@ -305,7 +305,7 @@ public function test_access_token_error_without_credentials(): void { $this->gateway->init(); $reflection = new \ReflectionClass($this->gateway); - $method = $reflection->getMethod('get_access_token'); + $method = $reflection->getMethod('get_access_token'); $result = $method->invoke($this->gateway); $this->assertInstanceOf(\WP_Error::class, $result); @@ -357,7 +357,7 @@ public function test_build_auth_assertion(): void { $this->gateway->init(); $reflection = new \ReflectionClass($this->gateway); - $method = $reflection->getMethod('build_auth_assertion'); + $method = $reflection->getMethod('build_auth_assertion'); $assertion = $method->invoke($this->gateway, 'PARTNER_CLIENT_ID', 'MERCHANT_PAYER_ID'); @@ -384,14 +384,194 @@ public function test_get_partner_data_error_on_proxy_failure(): void { $this->gateway->init(); // Override proxy URL to a non-existent server - add_filter('wu_paypal_connect_proxy_url', function () { - return 'https://nonexistent-proxy.test/wp-json/paypal-connect/v1'; - }); + add_filter( + 'wu_paypal_connect_proxy_url', + function () { + return 'https://nonexistent-proxy.test/wp-json/paypal-connect/v1'; + } + ); $reflection = new \ReflectionClass($this->gateway); - $method = $reflection->getMethod('get_partner_data'); + $method = $reflection->getMethod('get_partner_data'); $result = $method->invoke($this->gateway); $this->assertInstanceOf(\WP_Error::class, $result); } + + /** + * Test settings method registers required fields. + */ + public function test_settings_registers_fields(): void { + + $this->gateway->init(); + + // Capture fields registered via wu_register_settings_field + $registered_fields = []; + + // Use the filter to capture registered fields + add_filter( + 'wu_settings_section_payment-gateways_fields', + function ($fields) use (&$registered_fields) { + $registered_fields = $fields; + + return $fields; + } + ); + + $this->gateway->settings(); + + // Apply the filter to get the fields + $registered_fields = apply_filters('wu_settings_section_payment-gateways_fields', []); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores + + $field_ids = array_keys($registered_fields); + + // Verify key fields are registered + $this->assertContains('paypal_rest_header', $field_ids); + $this->assertContains('paypal_rest_sandbox_mode', $field_ids); + $this->assertContains('paypal_rest_oauth_connection', $field_ids); + $this->assertContains('paypal_rest_show_manual_keys', $field_ids); + $this->assertContains('paypal_rest_sandbox_client_id', $field_ids); + $this->assertContains('paypal_rest_webhook_url', $field_ids); + } + + /** + * Test settings fields require active gateway. + */ + public function test_settings_fields_require_active_gateway(): void { + + $this->gateway->init(); + $this->gateway->settings(); + + $fields = apply_filters('wu_settings_section_payment-gateways_fields', []); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores + + // All PayPal REST fields should require the gateway to be active + foreach ($fields as $field_id => $field) { + if (strpos($field_id, 'paypal_rest_') === 0) { + $this->assertArrayHasKey('require', $field, "Field $field_id should have require key"); + $this->assertEquals('paypal-rest', $field['require']['active_gateways'] ?? '', "Field $field_id should require paypal-rest gateway"); + } + } + } + + /** + * Test manual credential fields require show_manual_keys toggle. + */ + public function test_manual_fields_require_advanced_toggle(): void { + + $this->gateway->init(); + $this->gateway->settings(); + + $fields = apply_filters('wu_settings_section_payment-gateways_fields', []); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores + + $manual_fields = [ + 'paypal_rest_sandbox_client_id', + 'paypal_rest_sandbox_client_secret', + 'paypal_rest_live_client_id', + 'paypal_rest_live_client_secret', + ]; + + foreach ($manual_fields as $field_id) { + $this->assertArrayHasKey($field_id, $fields, "Field $field_id should be registered"); + $this->assertEquals(1, $fields[ $field_id ]['require']['paypal_rest_show_manual_keys'] ?? null, "Field $field_id should require paypal_rest_show_manual_keys = 1"); + } + } + + /** + * Test OAuth connection field uses html type with content callback. + */ + public function test_oauth_connection_field_type(): void { + + $this->gateway->init(); + $this->gateway->settings(); + + $fields = apply_filters('wu_settings_section_payment-gateways_fields', []); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores + + $this->assertArrayHasKey('paypal_rest_oauth_connection', $fields); + $this->assertEquals('html', $fields['paypal_rest_oauth_connection']['type']); + $this->assertIsCallable($fields['paypal_rest_oauth_connection']['content']); + } + + /** + * Test render_oauth_connection outputs disconnected state. + */ + public function test_render_oauth_connection_disconnected(): void { + + $this->gateway->init(); + + ob_start(); + $this->gateway->render_oauth_connection(); + $output = ob_get_clean(); + + // Should show the disconnected/manual keys prompt since no proxy configured in test + $this->assertStringContainsString('wu-oauth-status', $output); + $this->assertStringContainsString('wu-disconnected', $output); + } + + /** + * Test render_oauth_connection outputs connected state with merchant ID. + */ + public function test_render_oauth_connection_connected(): void { + + wu_save_setting('paypal_rest_sandbox_merchant_id', 'TESTMERCHANT456'); + wu_save_setting('paypal_rest_sandbox_mode', 1); + + $gateway = new PayPal_REST_Gateway(); + $gateway->init(); + + ob_start(); + $gateway->render_oauth_connection(); + $output = ob_get_clean(); + + $this->assertStringContainsString('wu-connected', $output); + $this->assertStringContainsString('TESTMERCHANT456', $output); + $this->assertStringContainsString('wu-paypal-disconnect', $output); + } + + /** + * Test render_oauth_connection includes fee notice. + */ + public function test_render_oauth_connection_fee_notice(): void { + + $this->gateway->init(); + + ob_start(); + $this->gateway->render_oauth_connection(); + $output = ob_get_clean(); + + // Fee notice should be present (unless addon is purchased) + $this->assertStringContainsString('fee', strtolower($output)); + } + + /** + * Test webhook listener URL is well-formed. + */ + public function test_webhook_listener_url(): void { + + $this->gateway->init(); + + $reflection = new \ReflectionClass($this->gateway); + $method = $reflection->getMethod('get_webhook_listener_url'); + + $url = $method->invoke($this->gateway); + $this->assertNotEmpty($url); + $this->assertStringContainsString('paypal-rest', $url); + } + + /** + * Test maybe_install_webhook skips when gateway not active. + */ + public function test_maybe_install_webhook_skips_inactive_gateway(): void { + + $this->gateway->init(); + + // Should not throw errors or install when gateway is not active + $this->gateway->maybe_install_webhook( + [], + ['active_gateways' => ['stripe']], + [] + ); + + // No assertion needed — just verify it doesn't error + $this->assertTrue(true); + } } diff --git a/tests/WP_Ultimo/Managers/Gateway_Manager_Test.php b/tests/WP_Ultimo/Managers/Gateway_Manager_Test.php index c36e242a..37aa4fec 100644 --- a/tests/WP_Ultimo/Managers/Gateway_Manager_Test.php +++ b/tests/WP_Ultimo/Managers/Gateway_Manager_Test.php @@ -12,6 +12,7 @@ use WP_Ultimo\Gateways\Base_Gateway; use WP_Ultimo\Gateways\Free_Gateway; use WP_Ultimo\Gateways\Manual_Gateway; +use WP_Ultimo\Gateways\PayPal_REST_Gateway; use WP_UnitTestCase; /** @@ -66,7 +67,7 @@ public function test_add_default_gateways() { $this->assertArrayHasKey('free', $registered_gateways); $this->assertArrayHasKey('manual', $registered_gateways); $this->assertArrayHasKey('paypal-rest', $registered_gateways); - $this->assertArrayHasKey('paypal', $registered_gateways); + // Legacy PayPal is conditionally registered (only when active or has credentials) } /** @@ -183,4 +184,108 @@ public function test_gateway_error_handling() { $this->assertTrue($e instanceof \Error); } } + + /** + * Test legacy PayPal is hidden when no credentials or active gateway. + */ + public function test_legacy_paypal_hidden_without_config(): void { + + // Ensure no legacy PayPal credentials or active status + wu_save_setting('paypal_test_username', ''); + wu_save_setting('paypal_live_username', ''); + wu_save_setting('active_gateways', ['stripe']); + + // Clear registered gateways + $reflection = new \ReflectionClass($this->manager); + $property = $reflection->getProperty('registered_gateways'); + + if (PHP_VERSION_ID < 80100) { + $property->setAccessible(true); + } + + $property->setValue($this->manager, []); + + $this->manager->add_default_gateways(); + + $registered = $this->manager->get_registered_gateways(); + + $this->assertArrayHasKey('paypal-rest', $registered); + $this->assertArrayNotHasKey('paypal', $registered, 'Legacy PayPal should NOT be registered without credentials'); + } + + /** + * Test legacy PayPal is shown when it has existing credentials. + */ + public function test_legacy_paypal_shown_with_credentials(): void { + + wu_save_setting('paypal_test_username', 'legacy_api_user'); + wu_save_setting('active_gateways', ['stripe']); + + $reflection = new \ReflectionClass($this->manager); + $property = $reflection->getProperty('registered_gateways'); + + if (PHP_VERSION_ID < 80100) { + $property->setAccessible(true); + } + + $property->setValue($this->manager, []); + + $this->manager->add_default_gateways(); + + $registered = $this->manager->get_registered_gateways(); + + $this->assertArrayHasKey('paypal', $registered, 'Legacy PayPal should be registered when credentials exist'); + + // Cleanup + wu_save_setting('paypal_test_username', ''); + } + + /** + * Test legacy PayPal is shown when it is an active gateway. + */ + public function test_legacy_paypal_shown_when_active(): void { + + wu_save_setting('paypal_test_username', ''); + wu_save_setting('paypal_live_username', ''); + wu_save_setting('active_gateways', ['paypal']); + + $reflection = new \ReflectionClass($this->manager); + $property = $reflection->getProperty('registered_gateways'); + + if (PHP_VERSION_ID < 80100) { + $property->setAccessible(true); + } + + $property->setValue($this->manager, []); + + $this->manager->add_default_gateways(); + + $registered = $this->manager->get_registered_gateways(); + + $this->assertArrayHasKey('paypal', $registered, 'Legacy PayPal should be registered when it is active'); + + // Cleanup + wu_save_setting('active_gateways', []); + } + + /** + * Test PayPal REST gateway is always registered. + */ + public function test_paypal_rest_always_registered(): void { + + $reflection = new \ReflectionClass($this->manager); + $property = $reflection->getProperty('registered_gateways'); + + if (PHP_VERSION_ID < 80100) { + $property->setAccessible(true); + } + + $property->setValue($this->manager, []); + + $this->manager->add_default_gateways(); + + $registered = $this->manager->get_registered_gateways(); + + $this->assertArrayHasKey('paypal-rest', $registered, 'PayPal REST should always be registered'); + } } diff --git a/tests/e2e/cypress/fixtures/setup-paypal-gateway.php b/tests/e2e/cypress/fixtures/setup-paypal-gateway.php new file mode 100644 index 00000000..1f8d27b0 --- /dev/null +++ b/tests/e2e/cypress/fixtures/setup-paypal-gateway.php @@ -0,0 +1,42 @@ + + */ + +$client_id = isset($args[0]) ? $args[0] : ''; +$client_secret = isset($args[1]) ? $args[1] : ''; + +if (empty($client_id) || empty($client_secret)) { + echo wp_json_encode(['error' => 'Missing PayPal sandbox keys. Pass client_id and client_secret as arguments.']); + return; +} + +// Enable sandbox mode +wu_save_setting('paypal_rest_sandbox_mode', 1); + +// Set sandbox keys +wu_save_setting('paypal_rest_sandbox_client_id', $client_id); +wu_save_setting('paypal_rest_sandbox_client_secret', $client_secret); + +// Show manual keys so settings reflect the credentials +wu_save_setting('paypal_rest_show_manual_keys', 1); + +// Add paypal-rest to active gateways while keeping existing ones +$active_gateways = (array) wu_get_setting('active_gateways', []); + +if (!in_array('paypal-rest', $active_gateways, true)) { + $active_gateways[] = 'paypal-rest'; +} + +wu_save_setting('active_gateways', $active_gateways); + +echo wp_json_encode( + [ + 'success' => true, + 'active_gateways' => wu_get_setting('active_gateways', []), + 'sandbox_mode' => wu_get_setting('paypal_rest_sandbox_mode', false), + 'client_id_set' => !empty(wu_get_setting('paypal_rest_sandbox_client_id')), + 'client_secret_set' => !empty(wu_get_setting('paypal_rest_sandbox_client_secret')), + ] +); diff --git a/tests/e2e/cypress/fixtures/verify-paypal-checkout-results.php b/tests/e2e/cypress/fixtures/verify-paypal-checkout-results.php new file mode 100644 index 00000000..5875a250 --- /dev/null +++ b/tests/e2e/cypress/fixtures/verify-paypal-checkout-results.php @@ -0,0 +1,52 @@ + 1, + 'orderby' => 'id', + 'order' => 'DESC', + ] +); +$um_payment_status = $payments ? $payments[0]->get_status() : 'no-payments'; +$um_payment_gateway = $payments ? $payments[0]->get_gateway() : 'none'; +$um_payment_total = $payments ? (float) $payments[0]->get_total() : 0; +$gateway_payment_id = $payments ? $payments[0]->get_gateway_payment_id() : ''; + +// UM membership (most recent) +$memberships = WP_Ultimo\Models\Membership::query( + [ + 'number' => 1, + 'orderby' => 'id', + 'order' => 'DESC', + ] +); +$um_membership_status = $memberships ? $memberships[0]->get_status() : 'no-memberships'; +$gateway_customer_id = $memberships ? $memberships[0]->get_gateway_customer_id() : ''; +$gateway_subscription_id = $memberships ? $memberships[0]->get_gateway_subscription_id() : ''; + +// UM sites +$sites = WP_Ultimo\Models\Site::query(['type__in' => ['customer_owned']]); +$um_site_count = count($sites); +$um_site_type = $sites ? $sites[0]->get_type() : 'no-sites'; + +echo wp_json_encode( + [ + 'um_payment_status' => $um_payment_status, + 'um_payment_gateway' => $um_payment_gateway, + 'um_payment_total' => $um_payment_total, + 'um_membership_status' => $um_membership_status, + 'um_site_count' => $um_site_count, + 'um_site_type' => $um_site_type, + 'gateway_payment_id' => $gateway_payment_id, + 'gateway_customer_id' => $gateway_customer_id, + 'gateway_subscription_id' => $gateway_subscription_id, + ] +); diff --git a/tests/e2e/cypress/integration/035-paypal-checkout-flow.spec.js b/tests/e2e/cypress/integration/035-paypal-checkout-flow.spec.js new file mode 100644 index 00000000..a844f2f4 --- /dev/null +++ b/tests/e2e/cypress/integration/035-paypal-checkout-flow.spec.js @@ -0,0 +1,193 @@ +describe("PayPal REST Gateway Checkout Flow", () => { + const timestamp = Date.now(); + const customerData = { + username: `paypalcust${timestamp}`, + email: `paypalcust${timestamp}@test.com`, + password: "xK9#mL2$vN5@qR", + }; + const siteData = { + title: "PayPal Test Site", + path: `paypalsite${timestamp}`, + }; + + before(() => { + const clientId = Cypress.env("PAYPAL_SANDBOX_CLIENT_ID"); + const clientSecret = Cypress.env("PAYPAL_SANDBOX_CLIENT_SECRET"); + + if (!clientId || !clientSecret) { + throw new Error( + "Skipping PayPal tests: PAYPAL_SANDBOX_CLIENT_ID and PAYPAL_SANDBOX_CLIENT_SECRET env vars are required" + ); + } + + cy.loginByForm( + Cypress.env("admin").username, + Cypress.env("admin").password + ); + + // Enable PayPal gateway with sandbox keys + cy.exec( + `npx wp-env run tests-cli wp eval-file /var/www/html/wp-content/plugins/ultimate-multisite/tests/e2e/cypress/fixtures/setup-paypal-gateway.php '${clientId}' '${clientSecret}'`, + { timeout: 60000 } + ).then((result) => { + const data = JSON.parse(result.stdout.trim()); + cy.log(`PayPal setup: ${JSON.stringify(data)}`); + expect(data.success).to.equal(true); + }); + }); + + it("Should show PayPal as a payment option on the checkout form", { + retries: 0, + }, () => { + cy.clearCookies(); + cy.visit("/register", { failOnStatusCode: false }); + + // Wait for checkout form to render + cy.get("#field-email_address", { timeout: 30000 }).should( + "be.visible" + ); + cy.wait(3000); + + // Select the plan + cy.get('#wrapper-field-pricing_table label[id^="wu-product-"]', { + timeout: 15000, + }) + .first() + .click(); + + cy.wait(3000); + + // PayPal gateway radio should be available + cy.get( + 'input[type="radio"][name="gateway"][value="paypal-rest"]', + { timeout: 10000 } + ).should("exist"); + }); + + it("Should submit checkout form with PayPal gateway selected", { + retries: 0, + }, () => { + cy.clearCookies(); + cy.visit("/register", { failOnStatusCode: false }); + + // Wait for checkout form to render + cy.get("#field-email_address", { timeout: 30000 }).should( + "be.visible" + ); + cy.wait(3000); + + // Select the plan + cy.get('#wrapper-field-pricing_table label[id^="wu-product-"]', { + timeout: 15000, + }) + .first() + .click(); + + cy.wait(3000); + + // Fill account details + cy.get("#field-email_address").clear().type(customerData.email); + cy.get("#field-username") + .should("be.visible") + .clear() + .type(customerData.username); + cy.get("#field-password") + .should("be.visible") + .clear() + .type(customerData.password); + + cy.get("body").then(($body) => { + if ($body.find("#field-password_conf").length > 0) { + cy.get("#field-password_conf").clear().type(customerData.password); + } + }); + + // Fill site details + cy.get("#field-site_title") + .should("be.visible") + .clear() + .type(siteData.title); + cy.get("#field-site_url") + .should("be.visible") + .clear() + .type(siteData.path); + + // Select PayPal REST gateway + cy.get( + 'input[type="radio"][name="gateway"][value="paypal-rest"]' + ).check({ force: true }); + + // Fill billing address + cy.get("#field-billing_country", { timeout: 15000 }) + .should("be.visible") + .select("US"); + + cy.get("#field-billing_zip_code", { timeout: 15000 }) + .should("be.visible") + .clear() + .type("94105"); + + // Intercept the checkout AJAX call to capture the redirect URL. + // PayPal checkout creates an order and returns an approval_url that + // redirects the user to paypal.com — we can't follow that in CI, + // but we can verify the gateway processes the request correctly. + cy.intercept("POST", "**/admin-ajax.php").as("checkoutAjax"); + + // Submit the checkout form + cy.get( + '#wrapper-field-checkout button[type="submit"], button.wu-checkout-submit, #field-checkout, button[type="submit"]', + { timeout: 10000 } + ) + .filter(":visible") + .last() + .click(); + + // Wait for the AJAX response or a redirect. + // PayPal checkout will either: + // 1. Redirect to paypal.com for payment approval (success) + // 2. Stay on the page with an error message + // 3. Redirect to status=done (for $0 orders) + // + // We check for either a redirect or verify the checkout processed + cy.wait(15000); + + // Check the current URL — if redirected to paypal.com, the checkout worked + cy.url().then((url) => { + if (url.includes("paypal.com")) { + // Successfully redirected to PayPal for approval + cy.log("PayPal redirect successful"); + expect(url).to.include("paypal.com"); + } else if (url.includes("status=done")) { + // Free or $0 order completed + cy.log("Order completed (free/zero amount)"); + } else { + // Still on register page — check if form processed correctly + cy.log(`Still on page: ${url}`); + // The checkout should have at least created the pending entities + } + }); + }); + + it("Should verify PayPal gateway is correctly configured via WP-CLI", () => { + cy.exec( + `npx wp-env run tests-cli wp eval ' + $gateways = (array) wu_get_setting("active_gateways", []); + $sandbox = wu_get_setting("paypal_rest_sandbox_mode", 0); + $client_id = wu_get_setting("paypal_rest_sandbox_client_id", ""); + echo json_encode([ + "paypal_active" => in_array("paypal-rest", $gateways), + "sandbox_mode" => (bool)(int)$sandbox, + "has_client_id" => !empty($client_id), + ]); + '`, + { timeout: 30000 } + ).then((result) => { + const data = JSON.parse(result.stdout.trim()); + cy.log(`PayPal config: ${JSON.stringify(data)}`); + + expect(data.paypal_active).to.equal(true); + expect(data.sandbox_mode).to.equal(true); + expect(data.has_client_id).to.equal(true); + }); + }); +}); From 8a7f27f719bc1fe87a6526f38c9c7da55f6d5431 Mon Sep 17 00:00:00 2001 From: David Stone Date: Sat, 28 Feb 2026 10:52:22 -0700 Subject: [PATCH 11/11] Gate PayPal OAuth Connect behind proxy-controlled feature flag OAuth Connect button is now hidden by default and only shown when the PayPal proxy plugin at ultimatemultisite.com reports that partner credentials are configured (GET /status endpoint). When OAuth is disabled (current state): - Manual API key fields shown directly without advanced toggle - No Connect with PayPal button visible When OAuth is enabled (after partnership setup): - Full Connect UI with connect/disconnect buttons - Manual keys behind "Use Direct API Keys (Advanced)" toggle Feature flag checks (in priority order): 1. WU_PAYPAL_OAUTH_ENABLED constant 2. wu_paypal_oauth_enabled filter 3. Cached proxy /status response (12h transient) Co-Authored-By: Claude Opus 4.6 --- inc/gateways/class-paypal-oauth-handler.php | 74 ++++++++++++ inc/gateways/class-paypal-rest-gateway.php | 108 +++++++++-------- .../Gateways/PayPal_OAuth_Handler_Test.php | 47 ++++++++ .../Gateways/PayPal_REST_Gateway_Test.php | 109 +++++++++++++----- 4 files changed, 255 insertions(+), 83 deletions(-) diff --git a/inc/gateways/class-paypal-oauth-handler.php b/inc/gateways/class-paypal-oauth-handler.php index 1bc05f91..16df60f5 100644 --- a/inc/gateways/class-paypal-oauth-handler.php +++ b/inc/gateways/class-paypal-oauth-handler.php @@ -473,6 +473,80 @@ public function is_configured(): bool { return ! empty($this->get_proxy_url()); } + /** + * Check if the PayPal OAuth Connect feature is enabled. + * + * The feature flag is controlled by the PayPal proxy plugin on + * ultimatemultisite.com. OAuth Connect is only available when the + * proxy has partner credentials configured (i.e. the PayPal + * partnership is active). The result is cached for 12 hours. + * + * Local override: define WU_PAYPAL_OAUTH_ENABLED as true in + * wp-config.php to force-enable without the proxy check. + * + * @since 2.0.0 + * @return bool + */ + public function is_oauth_feature_enabled(): bool { + + // Local constant override (useful for dev/testing) + if (defined('WU_PAYPAL_OAUTH_ENABLED')) { + return (bool) WU_PAYPAL_OAUTH_ENABLED; + } + + /** + * Filters whether the PayPal OAuth Connect feature is enabled. + * + * Return a non-null value to override the remote check. + * + * @since 2.0.0 + * + * @param bool|null $enabled Null to use remote check, bool to override. + */ + $override = apply_filters('wu_paypal_oauth_enabled', null); + + if (null !== $override) { + return (bool) $override; + } + + // Check cached flag from proxy + $cached = get_site_transient('wu_paypal_oauth_enabled'); + + if (false !== $cached) { + return 'yes' === $cached; + } + + // Fetch from proxy /status endpoint + $proxy_url = $this->get_proxy_url(); + + if (empty($proxy_url)) { + return false; + } + + $response = wp_remote_get( + $proxy_url . '/status', + ['timeout' => 5] + ); + + if (is_wp_error($response)) { + // Cache failure as disabled for 1 hour (retry sooner) + set_site_transient('wu_paypal_oauth_enabled', 'no', HOUR_IN_SECONDS); + + return false; + } + + $body = json_decode(wp_remote_retrieve_body($response), true); + $enabled = ! empty($body['oauth_enabled']); + + set_site_transient( + 'wu_paypal_oauth_enabled', + $enabled ? 'yes' : 'no', + 12 * HOUR_IN_SECONDS + ); + + return $enabled; + } + /** * Check if a merchant is connected via OAuth. * diff --git a/inc/gateways/class-paypal-rest-gateway.php b/inc/gateways/class-paypal-rest-gateway.php index 1a20247b..309d2745 100644 --- a/inc/gateways/class-paypal-rest-gateway.php +++ b/inc/gateways/class-paypal-rest-gateway.php @@ -1266,38 +1266,60 @@ public function settings(): void { ] ); - // PayPal Connect (combined connection status + button, like Stripe) - wu_register_settings_field( - 'payment-gateways', - 'paypal_rest_oauth_connection', - [ - 'title' => __('PayPal Connect (Recommended)', 'ultimate-multisite'), - 'desc' => __('Connect your PayPal account securely with one click. This provides easier setup and automatic webhook configuration.', 'ultimate-multisite'), - 'type' => 'html', - 'content' => [$this, 'render_oauth_connection'], - 'require' => [ - 'active_gateways' => 'paypal-rest', - ], - ] - ); + $oauth_enabled = PayPal_OAuth_Handler::get_instance()->is_oauth_feature_enabled(); - // Advanced: Show Direct API Keys Toggle - wu_register_settings_field( - 'payment-gateways', - 'paypal_rest_show_manual_keys', - [ - 'title' => __('Use Direct API Keys (Advanced)', 'ultimate-multisite'), - 'desc' => __('Toggle to manually enter API keys instead of using PayPal Connect. Use this for backwards compatibility or advanced configurations.', 'ultimate-multisite'), - 'type' => 'toggle', - 'default' => 0, - 'html_attr' => [ - 'v-model' => 'paypal_rest_show_manual_keys', - ], - 'require' => [ - 'active_gateways' => 'paypal-rest', - ], - ] - ); + // PayPal Connect section — only shown when OAuth feature is enabled via proxy + if ($oauth_enabled) { + wu_register_settings_field( + 'payment-gateways', + 'paypal_rest_oauth_connection', + [ + 'title' => __('PayPal Connect (Recommended)', 'ultimate-multisite'), + 'desc' => __('Connect your PayPal account securely with one click. This provides easier setup and automatic webhook configuration.', 'ultimate-multisite'), + 'type' => 'html', + 'content' => [$this, 'render_oauth_connection'], + 'require' => [ + 'active_gateways' => 'paypal-rest', + ], + ] + ); + + // Advanced: Show Direct API Keys Toggle (only when OAuth is available) + wu_register_settings_field( + 'payment-gateways', + 'paypal_rest_show_manual_keys', + [ + 'title' => __('Use Direct API Keys (Advanced)', 'ultimate-multisite'), + 'desc' => __('Toggle to manually enter API keys instead of using PayPal Connect. Use this for backwards compatibility or advanced configurations.', 'ultimate-multisite'), + 'type' => 'toggle', + 'default' => 0, + 'html_attr' => [ + 'v-model' => 'paypal_rest_show_manual_keys', + ], + 'require' => [ + 'active_gateways' => 'paypal-rest', + ], + ] + ); + } + + // Build the require array for manual key fields. + // When OAuth is enabled, keys are behind the advanced toggle. + // When OAuth is disabled, keys are shown directly. + $sandbox_key_require = [ + 'active_gateways' => 'paypal-rest', + 'paypal_rest_sandbox_mode' => 1, + ]; + + $live_key_require = [ + 'active_gateways' => 'paypal-rest', + 'paypal_rest_sandbox_mode' => 0, + ]; + + if ($oauth_enabled) { + $sandbox_key_require['paypal_rest_show_manual_keys'] = 1; + $live_key_require['paypal_rest_show_manual_keys'] = 1; + } // Sandbox Client ID wu_register_settings_field( @@ -1309,11 +1331,7 @@ public function settings(): void { 'type' => 'text', 'default' => '', 'capability' => 'manage_api_keys', - 'require' => [ - 'active_gateways' => 'paypal-rest', - 'paypal_rest_sandbox_mode' => 1, - 'paypal_rest_show_manual_keys' => 1, - ], + 'require' => $sandbox_key_require, ] ); @@ -1327,11 +1345,7 @@ public function settings(): void { 'type' => 'text', 'default' => '', 'capability' => 'manage_api_keys', - 'require' => [ - 'active_gateways' => 'paypal-rest', - 'paypal_rest_sandbox_mode' => 1, - 'paypal_rest_show_manual_keys' => 1, - ], + 'require' => $sandbox_key_require, ] ); @@ -1345,11 +1359,7 @@ public function settings(): void { 'type' => 'text', 'default' => '', 'capability' => 'manage_api_keys', - 'require' => [ - 'active_gateways' => 'paypal-rest', - 'paypal_rest_sandbox_mode' => 0, - 'paypal_rest_show_manual_keys' => 1, - ], + 'require' => $live_key_require, ] ); @@ -1363,11 +1373,7 @@ public function settings(): void { 'type' => 'text', 'default' => '', 'capability' => 'manage_api_keys', - 'require' => [ - 'active_gateways' => 'paypal-rest', - 'paypal_rest_sandbox_mode' => 0, - 'paypal_rest_show_manual_keys' => 1, - ], + 'require' => $live_key_require, ] ); diff --git a/tests/WP_Ultimo/Gateways/PayPal_OAuth_Handler_Test.php b/tests/WP_Ultimo/Gateways/PayPal_OAuth_Handler_Test.php index 1988b36f..a2e1ff23 100644 --- a/tests/WP_Ultimo/Gateways/PayPal_OAuth_Handler_Test.php +++ b/tests/WP_Ultimo/Gateways/PayPal_OAuth_Handler_Test.php @@ -159,4 +159,51 @@ public function test_init_registers_hooks(): void { $this->assertNotFalse(has_action('wp_ajax_wu_paypal_disconnect', [$this->handler, 'ajax_disconnect'])); $this->assertNotFalse(has_action('admin_init', [$this->handler, 'handle_oauth_return'])); } + + /** + * Test is_oauth_feature_enabled defaults to false (proxy unreachable in tests). + */ + public function test_oauth_feature_disabled_by_default(): void { + + // Clear any cached transient + delete_site_transient('wu_paypal_oauth_enabled'); + + // In test environment the proxy is unreachable, so it should be false + // We use a filter override to avoid the actual HTTP call + add_filter('wu_paypal_oauth_enabled', '__return_false'); + + $this->assertFalse($this->handler->is_oauth_feature_enabled()); + + remove_filter('wu_paypal_oauth_enabled', '__return_false'); + } + + /** + * Test is_oauth_feature_enabled returns true with filter override. + */ + public function test_oauth_feature_enabled_via_filter(): void { + + add_filter('wu_paypal_oauth_enabled', '__return_true'); + + $this->assertTrue($this->handler->is_oauth_feature_enabled()); + + remove_filter('wu_paypal_oauth_enabled', '__return_true'); + } + + /** + * Test is_oauth_feature_enabled respects cached transient. + */ + public function test_oauth_feature_uses_transient_cache(): void { + + // Set the transient directly + set_site_transient('wu_paypal_oauth_enabled', 'yes', HOUR_IN_SECONDS); + + $this->assertTrue($this->handler->is_oauth_feature_enabled()); + + set_site_transient('wu_paypal_oauth_enabled', 'no', HOUR_IN_SECONDS); + + $this->assertFalse($this->handler->is_oauth_feature_enabled()); + + // Cleanup + delete_site_transient('wu_paypal_oauth_enabled'); + } } diff --git a/tests/WP_Ultimo/Gateways/PayPal_REST_Gateway_Test.php b/tests/WP_Ultimo/Gateways/PayPal_REST_Gateway_Test.php index e6aae9d7..926ea72e 100644 --- a/tests/WP_Ultimo/Gateways/PayPal_REST_Gateway_Test.php +++ b/tests/WP_Ultimo/Gateways/PayPal_REST_Gateway_Test.php @@ -399,39 +399,55 @@ function () { } /** - * Test settings method registers required fields. + * Test settings registers fields without OAuth feature flag. + * + * When OAuth is disabled (default), manual keys are shown directly + * without the advanced toggle or OAuth connection field. */ - public function test_settings_registers_fields(): void { + public function test_settings_registers_fields_without_oauth(): void { $this->gateway->init(); - // Capture fields registered via wu_register_settings_field - $registered_fields = []; - - // Use the filter to capture registered fields - add_filter( - 'wu_settings_section_payment-gateways_fields', - function ($fields) use (&$registered_fields) { - $registered_fields = $fields; - - return $fields; - } - ); + // Ensure OAuth feature flag is off (default state) + add_filter('wu_paypal_oauth_enabled', '__return_false'); $this->gateway->settings(); - // Apply the filter to get the fields - $registered_fields = apply_filters('wu_settings_section_payment-gateways_fields', []); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores + $fields = apply_filters('wu_settings_section_payment-gateways_fields', []); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores + $field_ids = array_keys($fields); - $field_ids = array_keys($registered_fields); - - // Verify key fields are registered + // Core fields always present $this->assertContains('paypal_rest_header', $field_ids); $this->assertContains('paypal_rest_sandbox_mode', $field_ids); + $this->assertContains('paypal_rest_sandbox_client_id', $field_ids); + $this->assertContains('paypal_rest_webhook_url', $field_ids); + + // OAuth fields should NOT be present + $this->assertNotContains('paypal_rest_oauth_connection', $field_ids); + $this->assertNotContains('paypal_rest_show_manual_keys', $field_ids); + + remove_filter('wu_paypal_oauth_enabled', '__return_false'); + } + + /** + * Test settings registers OAuth fields when feature flag is on. + */ + public function test_settings_registers_oauth_fields_when_enabled(): void { + + $this->gateway->init(); + + add_filter('wu_paypal_oauth_enabled', '__return_true'); + + $this->gateway->settings(); + + $fields = apply_filters('wu_settings_section_payment-gateways_fields', []); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores + $field_ids = array_keys($fields); + $this->assertContains('paypal_rest_oauth_connection', $field_ids); $this->assertContains('paypal_rest_show_manual_keys', $field_ids); $this->assertContains('paypal_rest_sandbox_client_id', $field_ids); - $this->assertContains('paypal_rest_webhook_url', $field_ids); + + remove_filter('wu_paypal_oauth_enabled', '__return_true'); } /** @@ -454,26 +470,50 @@ public function test_settings_fields_require_active_gateway(): void { } /** - * Test manual credential fields require show_manual_keys toggle. + * Test manual keys shown directly when OAuth is disabled. */ - public function test_manual_fields_require_advanced_toggle(): void { + public function test_manual_fields_shown_directly_without_oauth(): void { $this->gateway->init(); + + add_filter('wu_paypal_oauth_enabled', '__return_false'); + $this->gateway->settings(); $fields = apply_filters('wu_settings_section_payment-gateways_fields', []); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores - $manual_fields = [ - 'paypal_rest_sandbox_client_id', - 'paypal_rest_sandbox_client_secret', - 'paypal_rest_live_client_id', - 'paypal_rest_live_client_secret', - ]; + // Manual key fields should NOT require the show_manual_keys toggle + $this->assertArrayHasKey('paypal_rest_sandbox_client_id', $fields); + $this->assertArrayNotHasKey( + 'paypal_rest_show_manual_keys', + $fields['paypal_rest_sandbox_client_id']['require'], + 'Manual keys should be shown directly when OAuth is disabled' + ); - foreach ($manual_fields as $field_id) { - $this->assertArrayHasKey($field_id, $fields, "Field $field_id should be registered"); - $this->assertEquals(1, $fields[ $field_id ]['require']['paypal_rest_show_manual_keys'] ?? null, "Field $field_id should require paypal_rest_show_manual_keys = 1"); - } + remove_filter('wu_paypal_oauth_enabled', '__return_false'); + } + + /** + * Test manual fields require toggle when OAuth is enabled. + */ + public function test_manual_fields_require_toggle_with_oauth(): void { + + $this->gateway->init(); + + add_filter('wu_paypal_oauth_enabled', '__return_true'); + + $this->gateway->settings(); + + $fields = apply_filters('wu_settings_section_payment-gateways_fields', []); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores + + $this->assertArrayHasKey('paypal_rest_sandbox_client_id', $fields); + $this->assertEquals( + 1, + $fields['paypal_rest_sandbox_client_id']['require']['paypal_rest_show_manual_keys'] ?? null, + 'Manual keys should require toggle when OAuth is enabled' + ); + + remove_filter('wu_paypal_oauth_enabled', '__return_true'); } /** @@ -482,6 +522,9 @@ public function test_manual_fields_require_advanced_toggle(): void { public function test_oauth_connection_field_type(): void { $this->gateway->init(); + + add_filter('wu_paypal_oauth_enabled', '__return_true'); + $this->gateway->settings(); $fields = apply_filters('wu_settings_section_payment-gateways_fields', []); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores @@ -489,6 +532,8 @@ public function test_oauth_connection_field_type(): void { $this->assertArrayHasKey('paypal_rest_oauth_connection', $fields); $this->assertEquals('html', $fields['paypal_rest_oauth_connection']['type']); $this->assertIsCallable($fields['paypal_rest_oauth_connection']['content']); + + remove_filter('wu_paypal_oauth_enabled', '__return_true'); } /**