diff --git a/inc/gateways/class-base-gateway.php b/inc/gateways/class-base-gateway.php index 35974ea1..fb7d5ec5 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 { + unset($gateway_payment_id); + 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 { + unset($gateway_subscription_id); + 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 { + unset($gateway_customer_id); + 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..4468da85 --- /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..16df60f5 --- /dev/null +++ b/inc/gateways/class-paypal-oauth-handler.php @@ -0,0 +1,665 @@ +test_mode = (bool) (int) wu_get_setting('paypal_rest_sandbox_mode', true); + + // 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']); + } + + /** + * Get the PayPal Connect proxy URL. + * + * @since 2.0.0 + * @return string + */ + protected function get_proxy_url(): string { + + /** + * Filters the PayPal Connect proxy URL. + * + * @since 2.0.0 + * + * @param string $url Proxy server URL. + */ + return apply_filters( + 'wu_paypal_connect_proxy_url', + 'https://ultimatemultisite.com/wp-json/paypal-connect/v1' + ); + } + + /** + * 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 + */ + protected function get_api_base_url(): string { + + return $this->test_mode ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com'; + } + + /** + * AJAX handler to initiate OAuth flow via the proxy. + * + * @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']; + } + + // Build the return URL + $return_url = add_query_arg( + [ + 'page' => 'wp-ultimo-settings', + 'tab' => 'payment-gateways', + 'wu_paypal_onboarding' => 'complete', + ], + network_admin_url('admin.php') + ); + + $proxy_url = $this->get_proxy_url(); + + // Call the proxy to initiate the OAuth flow + $response = wp_remote_post( + $proxy_url . '/oauth/init', + [ + 'body' => wp_json_encode( + [ + 'returnUrl' => $return_url, + 'testMode' => $this->test_mode, + ] + ), + 'headers' => [ + 'Content-Type' => 'application/json', + ], + 'timeout' => 30, + ] + ); + + if (is_wp_error($response)) { + wu_log_add('paypal', 'Proxy init failed: ' . $response->get_error_message(), LogLevel::ERROR); + + 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'), + ] + ); + } + + $status_code = wp_remote_retrieve_response_code($response); + $data = json_decode(wp_remote_retrieve_body($response), true); + + 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); + + wp_send_json_error( + [ + 'message' => $error_msg, + ] + ); + } + + // Store the tracking ID locally for verification on return + $tracking_id = $data['trackingId'] ?? ''; + + if ($tracking_id) { + set_site_transient( + 'wu_paypal_onboarding_' . $tracking_id, + [ + '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' => $data['actionUrl'], + 'tracking_id' => $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']; + $tracking_id = isset($_GET['tracking_id']) ? sanitize_text_field(wp_unslash($_GET['tracking_id'])) : ''; + // phpcs:enable WordPress.Security.NonceVerification.Recommended + + // 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; + } + + // 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 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; + } + + // 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['paymentsReceivable'])) { + wu_save_setting("paypal_rest_{$mode_prefix}_payments_receivable", $merchant_status['paymentsReceivable']); + } + + if (! empty($merchant_status['emailConfirmed'])) { + wu_save_setting("paypal_rest_{$mode_prefix}_email_confirmed", $merchant_status['emailConfirmed']); + } + + // 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 via the proxy. + * + * 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. + * @return array|\WP_Error Merchant status data or error. + */ + protected function verify_merchant_via_proxy(string $merchant_id, string $tracking_id) { + + $proxy_url = $this->get_proxy_url(); + + $response = wp_remote_post( + $proxy_url . '/oauth/verify', + [ + 'body' => wp_json_encode( + [ + 'merchantId' => $merchant_id, + 'trackingId' => $tracking_id, + 'testMode' => $this->test_mode, + ] + ), + 'headers' => [ + 'Content-Type' => 'application/json', + ], + 'timeout' => 30, + ] + ); + + if (is_wp_error($response)) { + return $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'); + + return new \WP_Error('wu_paypal_verify_error', $error_msg); + } + + return $data; + } + + /** + * 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(); + + // 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', + '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_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 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->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. + * + * @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 = wu_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 = wu_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..309d2745 --- /dev/null +++ b/inc/gateways/class-paypal-rest-gateway.php @@ -0,0 +1,1795 @@ +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', + ], + ]; + } + + /** + * Whether to apply platform fees to payments. + * + * Platform fees only apply when the merchant connected via OAuth + * (Partner Referral flow) and has not purchased any addon. + * Manual credential users are not charged platform fees. + * + * @since 2.0.0 + * @return bool + */ + public function should_apply_platform_fee(): bool { + + if (empty($this->merchant_id)) { + return false; + } + + $addon_repo = \WP_Ultimo::get_instance()->get_addon_repository(); + + return ! $addon_repo->has_addon_purchase(); + } + + /** + * Gets the platform fee percentage. + * + * @since 2.0.0 + * @return float + */ + public function get_platform_fee_percent(): float { + + return 3.0; + } + + /** + * Get partner data (access token and client ID) from the proxy. + * + * Cached in a transient to avoid calling the proxy on every payment. + * + * @since 2.0.0 + * @return array{access_token: string, partner_client_id: string}|\WP_Error + */ + protected function get_partner_data() { + + $cache_key = 'wu_paypal_partner_data_' . ($this->test_mode ? 'sandbox' : 'live'); + $cached = get_site_transient($cache_key); + + if ($cached && ! empty($cached['access_token'])) { + return $cached; + } + + $proxy_url = apply_filters('wu_paypal_connect_proxy_url', 'https://ultimatemultisite.com/wp-json/paypal-connect/v1'); + + $response = wp_remote_post( + $proxy_url . '/partner-token', + [ + 'body' => wp_json_encode(['testMode' => $this->test_mode]), + 'headers' => ['Content-Type' => 'application/json'], + 'timeout' => 15, + ] + ); + + 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 || empty($body['access_token'])) { + return new \WP_Error( + 'wu_paypal_partner_token_error', + $body['error'] ?? __('Failed to get partner token from proxy.', 'ultimate-multisite') + ); + } + + $data = [ + 'access_token' => $body['access_token'], + 'partner_client_id' => $body['partner_client_id'] ?? '', + ]; + + $expires_in = (int) ($body['expires_in'] ?? 3300); + set_site_transient($cache_key, $data, $expires_in); + + return $data; + } + + /** + * Build PayPal-Auth-Assertion JWT header. + * + * Used to make API calls on behalf of a merchant using the partner's token. + * + * @see https://developer.paypal.com/docs/api/reference/api-requests/#paypal-auth-assertion + * + * @since 2.0.0 + * + * @param string $partner_client_id The partner's PayPal client ID. + * @param string $merchant_payer_id The merchant's PayPal payer/merchant ID. + * @return string The JWT assertion string. + */ + protected function build_auth_assertion(string $partner_client_id, string $merchant_payer_id): string { + + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Required for PayPal Auth Assertion JWT + $header = base64_encode(wp_json_encode(['alg' => 'none'])); + + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Required for PayPal Auth Assertion JWT + $payload = base64_encode( + wp_json_encode( + [ + 'iss' => $partner_client_id, + 'payer_id' => $merchant_payer_id, + ] + ) + ); + + return $header . '.' . $payload . '.'; + } + + /** + * Create a PayPal order with platform fees via partner credentials. + * + * Uses the partner's access token and PayPal-Auth-Assertion header + * to create an order on behalf of the merchant with a platform fee. + * + * @since 2.0.0 + * + * @param array $order_data The order data (without platform fee). + * @param string $currency The currency code. + * @param float $total The total amount. + * @return array|\WP_Error The PayPal API response or error. + */ + protected function create_order_with_platform_fee(array $order_data, string $currency, float $total) { + + $partner_data = $this->get_partner_data(); + + if (is_wp_error($partner_data)) { + $this->log('Platform fee skipped: ' . $partner_data->get_error_message()); + + return $partner_data; + } + + if (empty($partner_data['partner_client_id'])) { + return new \WP_Error('wu_paypal_no_partner_id', 'Partner client ID not available.'); + } + + // Calculate the platform fee + $fee_amount = round($total * $this->get_platform_fee_percent() / 100, 2); + + if ($fee_amount < 0.01) { + return new \WP_Error('wu_paypal_fee_too_small', 'Platform fee amount too small.'); + } + + // Add platform fee to the first purchase unit + $order_data['purchase_units'][0]['payment_instruction'] = [ + 'platform_fees' => [ + [ + 'amount' => [ + 'currency_code' => $currency, + 'value' => number_format($fee_amount, 2, '.', ''), + ], + ], + ], + ]; + + // Build the auth assertion + $auth_assertion = $this->build_auth_assertion( + $partner_data['partner_client_id'], + $this->merchant_id + ); + + // Make the API call with partner credentials + $response = wp_remote_post( + $this->get_api_base_url() . '/v2/checkout/orders', + [ + 'headers' => [ + 'Authorization' => 'Bearer ' . $partner_data['access_token'], + 'Content-Type' => 'application/json', + 'PayPal-Auth-Assertion' => $auth_assertion, + 'PayPal-Partner-Attribution-Id' => $this->bn_code, + ], + 'body' => wp_json_encode($order_data), + '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 ($code < 200 || $code >= 300) { + $error_msg = $body['message'] ?? __('PayPal API error', 'ultimate-multisite'); + + return new \WP_Error('wu_paypal_order_error', $error_msg); + } + + $this->log(sprintf('Order created with %.2f%% platform fee ($%s)', $this->get_platform_fee_percent(), number_format($fee_amount, 2))); + + return $body; + } + + /** + * 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), // 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)) { + $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. + * @param array $extra_headers Additional HTTP headers to include. + * @return array|\WP_Error Response data or error. + */ + protected function api_request(string $endpoint, array $data = [], string $method = 'POST', array $extra_headers = []) { + + $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); + $headers = array_merge($headers, $extra_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 (PayPal returns 'approve' or 'payer-action' depending on context) + $approval_url = ''; + foreach ($result['links'] ?? [] as $link) { + if (in_array($link['rel'], ['approve', 'payer-action'], true)) { + $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 = null; + + // Try creating with platform fee if applicable + if ($this->should_apply_platform_fee()) { + $result = $this->create_order_with_platform_fee($order_data, $currency, (float) $payment->get_total()); + + if (is_wp_error($result)) { + $this->log('Platform fee order failed, falling back to standard: ' . $result->get_error_message()); + $result = null; + } + } + + // Standard order creation (no platform fee or fallback) + if (null === $result) { + $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 (PayPal returns 'approve' or 'payer-action' depending on context) + $approval_url = ''; + foreach ($result['links'] ?? [] as $link) { + if (in_array($link['rel'], ['approve', 'payer-action'], true)) { + $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 (Prefer header ensures full response with capture details) + $capture = $this->api_request('/v2/checkout/orders/' . $token . '/capture', [], 'POST', ['Prefer' => 'return=representation']); + + 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' => __('Use the settings section below to configure PayPal as a payment method.', 'ultimate-multisite'), + 'type' => 'header', + 'show_as_submenu' => true, + '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' => __('Toggle this to put PayPal on sandbox mode. This is useful for testing and making sure PayPal is correctly setup to handle your payments.', 'ultimate-multisite'), + 'type' => 'toggle', + 'default' => 1, + 'html_attr' => [ + 'v-model' => 'paypal_rest_sandbox_mode', + ], + 'require' => [ + 'active_gateways' => 'paypal-rest', + ], + ] + ); + + $oauth_enabled = PayPal_OAuth_Handler::get_instance()->is_oauth_feature_enabled(); + + // 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( + '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' => $sandbox_key_require, + ] + ); + + // 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' => $sandbox_key_require, + ] + ); + + // 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' => $live_key_require, + ] + ); + + // 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' => $live_key_require, + ] + ); + + $webhook_message = sprintf( + '%s', + __('Webhooks are automatically configured when you connect your PayPal account or save settings with valid API credentials.', 'ultimate-multisite') + ); + + wu_register_settings_field( + 'payment-gateways', + 'paypal_rest_webhook_url', + [ + 'title' => __('Webhook Listener URL', 'ultimate-multisite'), + 'desc' => $webhook_message, + 'tooltip' => __('This is the URL PayPal should send webhook calls to.', 'ultimate-multisite'), + 'type' => 'text-display', + 'copy' => true, + 'default' => $this->get_webhook_listener_url(), + 'wrapper_classes' => '', + 'require' => [ + 'active_gateways' => 'paypal-rest', + ], + ] + ); + } + + /** + * Render the PayPal OAuth connection status and button. + * + * Mirrors the Stripe Connect pattern: shows connected status with disconnect, + * or disconnected status with connect button, plus fee notice. + * + * @since 2.0.0 + * @return void + */ + public function render_oauth_connection(): void { + + $oauth = PayPal_OAuth_Handler::get_instance(); + $is_connected = $oauth->is_merchant_connected($this->test_mode); + + if ($is_connected) { + $status = $this->get_connection_status(); + $mode_label = 'sandbox' === ($status['details']['mode'] ?? '') + ? __('Sandbox', 'ultimate-multisite') + : __('Live', 'ultimate-multisite'); + $identifier = $status['details']['merchant_id'] + ?? ($status['details']['email'] ?? ($status['details']['client_id'] ?? '')); + + // Connected state + printf( + '
+
+ + %s +
+

%s %s (%s)

+ +
', + esc_html__('Connected via PayPal', 'ultimate-multisite'), + esc_html__('Merchant ID:', 'ultimate-multisite'), + esc_html($identifier), + esc_html($mode_label), + esc_html__('Disconnect', 'ultimate-multisite') + ); + } else { + + // Disconnected state - show connect button + $can_connect = $oauth->is_configured(); + + if ($can_connect) { + printf( + '
+

%s

+ +

%s

+
', + esc_html__('Connect your PayPal account with one click. Webhooks will be configured automatically.', 'ultimate-multisite'), + esc_html__('Connect with PayPal', 'ultimate-multisite'), + esc_html__('You will be redirected to PayPal to securely authorize the connection.', 'ultimate-multisite') + ); + } else { + printf( + '
+

%s

+
', + esc_html__('Use the Direct API Keys option below to enter your PayPal credentials manually.', 'ultimate-multisite') + ); + } + } + + // Enqueue the connect/disconnect scripts + $this->enqueue_connect_scripts(); + + // Fee notice (mirrors Stripe Connect fee notice) + if (! \WP_Ultimo::get_instance()->get_addon_repository()->has_addon_purchase()) { + printf( + '
%s
%s
', + esc_html( + sprintf( + /* translators: %s: the fee percentage */ + __('There is a %s%% fee per-transaction to use the PayPal integration included in the free Ultimate Multisite plugin.', 'ultimate-multisite'), + number_format_i18n($this->get_platform_fee_percent(), 0) + ) + ), + esc_url(network_admin_url('admin.php?page=wp-ultimo-addons')), + esc_html__('Remove this fee by purchasing any addon and connecting your store.', 'ultimate-multisite') + ); + } else { + printf( + '

%s

', + esc_html__('No application fee — thank you for your support!', 'ultimate-multisite') + ); + } + } + + /** + * Enqueue the connect/disconnect button scripts. + * + * @since 2.0.0 + * @return void + */ + protected function enqueue_connect_scripts(): void { + + static $enqueued = false; + + if ($enqueued) { + return; + } + + $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 () use ($nonce, $sandbox) { + ?> + + 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..e571db1d --- /dev/null +++ b/inc/gateways/class-paypal-webhook-handler.php @@ -0,0 +1,587 @@ +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); + + /** + * 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; + } + + // 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, cannot verify signature', LogLevel::WARNING); + return false; + } + + // 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 false; + } + + // 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), // 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($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 $event_data The subscription event data. + * @return void + */ + protected function handle_subscription_created(array $event_data): void { + + $subscription_id = $event_data['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 $event_data The subscription event data. + * @return void + */ + protected function handle_subscription_activated(array $event_data): void { + + $subscription_id = $event_data['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 $event_data The subscription event data. + * @return void + */ + protected function handle_subscription_updated(array $event_data): void { + + $subscription_id = $event_data['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 $event_data The subscription event data. + * @return void + */ + protected function handle_subscription_cancelled(array $event_data): void { + + $subscription_id = $event_data['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 $event_data The subscription event data. + * @return void + */ + protected function handle_subscription_suspended(array $event_data): void { + + $subscription_id = $event_data['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 $event_data The subscription event data. + * @return void + */ + protected function handle_subscription_payment_failed(array $event_data): void { + + $subscription_id = $event_data['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 $event_data The sale event data. + * @return void + */ + protected function handle_payment_completed(array $event_data): void { + + $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)); + + // 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 $event_data The capture event data. + * @return void + */ + protected function handle_capture_completed(array $event_data): void { + + $capture_id = $event_data['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 $event_data The refund event data. + * @return void + */ + protected function handle_capture_refunded(array $event_data): void { + + $refund_id = $event_data['id'] ?? ''; + $capture_id = ''; + $amount = $event_data['amount']['value'] ?? 0; + + // Find the original capture ID from links + 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); + $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..79a87d11 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,27 @@ 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) + * Only show if already active or has existing credentials configured. + */ + $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 + */ + PayPal_Webhook_Handler::get_instance()->init(); /* * Manual Payments @@ -543,11 +562,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 +575,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(); 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..a2e1ff23 --- /dev/null +++ b/tests/WP_Ultimo/Gateways/PayPal_OAuth_Handler_Test.php @@ -0,0 +1,209 @@ +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'])); + } + + /** + * 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 new file mode 100644 index 00000000..926ea72e --- /dev/null +++ b/tests/WP_Ultimo/Gateways/PayPal_REST_Gateway_Test.php @@ -0,0 +1,622 @@ +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()); + } + + /** + * Test platform fee not applied without OAuth merchant ID. + */ + public function test_platform_fee_not_applied_without_oauth(): void { + + wu_save_setting('paypal_rest_sandbox_client_id', 'test_client_id'); + wu_save_setting('paypal_rest_sandbox_client_secret', 'test_secret'); + + $gateway = new PayPal_REST_Gateway(); + $gateway->init(); + + $this->assertFalse($gateway->should_apply_platform_fee()); + } + + /** + * Test platform fee applied with OAuth merchant ID and no addon purchase. + */ + public function test_platform_fee_applied_with_oauth(): void { + + wu_save_setting('paypal_rest_sandbox_merchant_id', 'MERCHANT123'); + + $gateway = new PayPal_REST_Gateway(); + $gateway->init(); + + // Fee should apply when OAuth connected and no addon purchased + // (has_addon_purchase returns false by default in test) + $this->assertTrue($gateway->should_apply_platform_fee()); + } + + /** + * Test platform fee percentage. + */ + public function test_platform_fee_percent(): void { + + $this->assertEquals(3.0, $this->gateway->get_platform_fee_percent()); + } + + /** + * Test PayPal-Auth-Assertion JWT format. + */ + public function test_build_auth_assertion(): void { + + $this->gateway->init(); + + $reflection = new \ReflectionClass($this->gateway); + $method = $reflection->getMethod('build_auth_assertion'); + + $assertion = $method->invoke($this->gateway, 'PARTNER_CLIENT_ID', 'MERCHANT_PAYER_ID'); + + // Should be base64(header).base64(payload). + $parts = explode('.', $assertion); + $this->assertCount(3, $parts); + $this->assertEquals('', $parts[2]); // Empty signature + + // Decode header + $header = json_decode(base64_decode($parts[0]), true); // phpcs:ignore + $this->assertEquals('none', $header['alg']); + + // Decode payload + $payload = json_decode(base64_decode($parts[1]), true); // phpcs:ignore + $this->assertEquals('PARTNER_CLIENT_ID', $payload['iss']); + $this->assertEquals('MERCHANT_PAYER_ID', $payload['payer_id']); + } + + /** + * Test get_partner_data returns error when proxy unavailable. + */ + 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'; + } + ); + + $reflection = new \ReflectionClass($this->gateway); + $method = $reflection->getMethod('get_partner_data'); + + $result = $method->invoke($this->gateway); + $this->assertInstanceOf(\WP_Error::class, $result); + } + + /** + * 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_without_oauth(): void { + + $this->gateway->init(); + + // Ensure OAuth feature flag is off (default state) + 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 + $field_ids = array_keys($fields); + + // 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); + + remove_filter('wu_paypal_oauth_enabled', '__return_true'); + } + + /** + * 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 keys shown directly when OAuth is disabled. + */ + 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 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' + ); + + 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'); + } + + /** + * Test OAuth connection field uses html type with content callback. + */ + 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 + + $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'); + } + + /** + * 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/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..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; /** @@ -65,6 +66,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); + // Legacy PayPal is conditionally registered (only when active or has credentials) } /** @@ -181,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); + }); + }); +});