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
%s %s (%s)
%s
+ +%s
+%s
+%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 @@ +