Subscriptions) * - Admin subscription management screens * - Subscription product types in product creation * - Subscription settings tab * - Customer account subscription pages * - Related subscriptions section on order details * - Related orders meta box on admin order edit screen * - Purchasing of subscription products (makes them unpurchasable) * - Adding subscription products to admin orders (both search and validation) * - Order-pay endpoint when accessed with subscription IDs * * What this class does NOT affect: * - Stripe Billing webhook processing (invoice.paid, invoice.upcoming, etc.) * - Automatic renewal order creation via wcs_create_renewal_order() * - Subscription payment processing and completion * - Existing subscription data or meta * - Backend subscription status management * - Payment method updates * - Regular (non-subscription) products * * This ensures merchants and customers cannot create or manage subscriptions * through the UI while Stripe Billing continues to process renewals automatically. */ class WC_Payments_Subscriptions_Disabler { /** * Initiates hooks that hide bundled subscriptions management entry points. * * This method registers UI-layer hooks only. It does NOT hook into: * - Payment processing (woocommerce_subscription_payment_complete, etc.) * - Renewal order creation (woocommerce_renewal_order_payment_complete, etc.) * - Webhook handling (invoice.paid, invoice.upcoming, etc.) * - Subscription status changes (woocommerce_subscription_status_*, etc.) * * Admin hooks (menu/screen blocking): * - Removes admin menu items * - Blocks direct access to subscription screens * - Removes subscription product types from product editor * - Removes subscription settings tab * - Removes "Related Orders" meta box from order edit screen * * Frontend hooks (customer-facing blocking): * - Removes subscription navigation from My Account * - Blocks direct access to subscription endpoints * - Removes subscription details from order views * - Makes subscription products unpurchasable (prevents new subscriptions) * * @return void */ public function init_hooks() { if ( is_admin() ) { add_action( 'admin_menu', [ $this, 'remove_admin_menu_items' ], 99 ); add_action( 'current_screen', [ $this, 'maybe_block_admin_subscription_screen' ] ); add_filter( 'product_type_selector', [ $this, 'filter_product_type_selector' ], 99 ); add_filter( 'woocommerce_settings_tabs_array', [ $this, 'filter_settings_tabs' ], 99 ); add_action( 'admin_init', [ $this, 'maybe_redirect_settings_tab' ], 99 ); add_action( 'admin_notices', [ $this, 'display_subscription_disabled_notice' ] ); add_filter( 'woocommerce_json_search_found_products', [ $this, 'filter_admin_product_search' ] ); add_filter( 'woocommerce_ajax_add_order_item_validation', [ $this, 'validate_admin_order_item' ], 10, 4 ); add_action( 'add_meta_boxes', [ $this, 'remove_related_orders_meta_box' ], 99, 2 ); } add_filter( 'woocommerce_account_menu_items', [ $this, 'remove_account_menu_item' ], 99 ); add_action( 'pre_get_posts', [ $this, 'maybe_redirect_subscription_endpoints' ], 1 ); add_action( 'template_redirect', [ $this, 'maybe_redirect_account_endpoints' ], 5 ); add_action( 'init', [ $this, 'remove_related_subscriptions_section' ], 99 ); add_filter( 'woocommerce_is_purchasable', [ $this, 'make_subscription_products_unpurchasable' ], 10, 2 ); add_filter( 'woocommerce_cart_item_removed_message', [ $this, 'filter_subscription_removal_message' ], 10, 2 ); } /** * Removes WooCommerce > Subscriptions menu entries. * * Hides the subscriptions admin menu for both CPT and HPOS implementations. * Does not affect subscription data or the ability for renewals to process. * * @return void */ public function remove_admin_menu_items() { remove_submenu_page( 'woocommerce', 'edit.php?post_type=shop_subscription' ); remove_submenu_page( 'woocommerce', 'wc-orders--shop_subscription' ); remove_menu_page( 'wc-orders--shop_subscription' ); } /** * Removes the "Related Orders" meta box from order edit screens. * * This meta box displays subscription-related orders (renewals, parent orders, etc.) * on the admin order edit screen. We remove it to hide subscription relationships * from merchants when viewing orders. * * The meta box is added by WooCommerce Subscriptions via: * - WCS_Admin_Meta_Boxes::add_meta_boxes() at priority 10 on 'add_meta_boxes' * - Meta box ID: 'subscription_renewal_orders' * - Title: 'Related Orders' * * We run this at priority 99 to ensure it executes after WCS adds the meta box. * * @param string $post_type The post type of the current post being edited. * @param WP_Post|WC_Order|null $post_or_order_object The post or order currently being edited. * @return void */ public function remove_related_orders_meta_box( $post_type, $post_or_order_object = null ) { // Only process when WCS functions are available. if ( ! function_exists( 'wcs_get_page_screen_id' ) ) { return; } // Get the order screen ID (handles both CPT and HPOS). $order_screen_id = wcs_get_page_screen_id( 'shop_order' ); // Remove the Related Orders meta box from the order edit screen. // The 'normal' context matches where WCS registers the meta box. remove_meta_box( 'subscription_renewal_orders', $order_screen_id, 'normal' ); } /** * Redirects attempts to access admin subscription management screens. * * Prevents direct URL access to subscription edit/list screens by redirecting * to the WooCommerce overview. Does not run during AJAX or REST requests to * avoid interfering with legitimate background operations. * * @param WP_Screen $screen Current screen instance. * @return void */ public function maybe_block_admin_subscription_screen( $screen ) { if ( wp_doing_ajax() || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) { return; } if ( ! $screen instanceof WP_Screen ) { return; } $screen_id = (string) $screen->id; if ( $this->is_blocked_admin_screen( $screen_id ) || $this->is_subscription_post_type_request() ) { $this->redirect_to_admin_overview(); } } /** * Removes the subscriptions tab from the My Account navigation. * * @param array $items My Account menu items. * @return array Filtered menu items. */ public function remove_account_menu_item( $items ) { $subscriptions_endpoint = $this->get_account_endpoint_slug( 'subscriptions' ); if ( isset( $items[ $subscriptions_endpoint ] ) ) { unset( $items[ $subscriptions_endpoint ] ); } return $items; } /** * Removes subscription related product types from product selector. * * Prevents merchants from creating new subscription products by hiding * the product types from the dropdown. Existing subscription products * remain in the database and can still process renewals. * * @param array $product_types Registered product types. * @return array Filtered product types without subscription options. */ public function filter_product_type_selector( $product_types ) { unset( $product_types['subscription'], $product_types['variable-subscription'] ); return $product_types; } /** * Removes subscription tab from WooCommerce settings. * * @param array $tabs Registered WooCommerce settings tabs. * @return array */ public function filter_settings_tabs( $tabs ) { unset( $tabs['subscriptions'] ); return $tabs; } /** * Redirects attempts to access the removed subscriptions settings tab. * * @return void */ public function maybe_redirect_settings_tab() { if ( empty( $_GET['page'] ) || empty( $_GET['tab'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended return; } $page = sanitize_key( wp_unslash( $_GET['page'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended $tab = sanitize_key( wp_unslash( $_GET['tab'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ( 'wc-settings' !== $page || 'subscriptions' !== $tab ) { return; } $this->redirect( add_query_arg( [ 'page' => 'wc-settings', 'tab' => 'general', ], admin_url( 'admin.php' ) ) ); } /** * Redirects subscription endpoints during query parsing. * * This runs on the pre_get_posts hook (priority 1) to intercept subscription * endpoint requests BEFORE WooCommerce Subscriptions can redirect them to * the order-pay page. This is critical because WCS_Query::maybe_redirect_payment_methods() * runs at priority 10 on pre_get_posts and would redirect /my-account/subscription-payment-method/ID * to /checkout/order-pay/ID/?change_payment_method=ID before we can block it. * * @param WP_Query $query The WP_Query instance. * @return void */ public function maybe_redirect_subscription_endpoints( $query ) { // Only process main queries. if ( ! $query->is_main_query() ) { return; } // Check each subscription endpoint. $endpoints = [ 'subscriptions', 'view-subscription', 'subscription-payment-method', ]; foreach ( $endpoints as $endpoint_key ) { $endpoint_slug = $this->get_account_endpoint_slug( $endpoint_key ); // Check if this query is for a subscription endpoint. if ( ! empty( $query->get( $endpoint_slug ) ) ) { // Redirect to My Account before WCS can redirect elsewhere. $this->redirect( wc_get_page_permalink( 'myaccount' ) ); } } } /** * Redirects subscription related customer account endpoints. * * Prevents customers from accessing subscription management pages including: * - Subscriptions list (/my-account/subscriptions) * - View subscription detail (/my-account/view-subscription/123) * - Payment method management (/my-account/subscription-payment-method/123) * * Redirects all attempts to the My Account dashboard. Does not affect * subscription data or automated renewal payments. * * @return void */ public function maybe_redirect_account_endpoints() { foreach ( $this->get_blocked_account_endpoints() as $endpoint ) { if ( empty( $endpoint ) ) { continue; } if ( $this->is_endpoint_url( $endpoint ) ) { $this->redirect( wc_get_page_permalink( 'myaccount' ) ); } } // Also block order-pay endpoint if it contains a subscription ID. $this->maybe_redirect_order_pay_for_subscription(); } /** * Redirects order-pay requests that target subscription IDs. * * This prevents users from accessing the "pay for order" page using a * subscription ID in two ways: * * 1. Direct access: /checkout/order-pay/{subscription_id}/ * 2. Via change_payment_method parameter: /checkout/order-pay/ID/?change_payment_method={subscription_id} * * The second case occurs when WooCommerce Subscriptions redirects from * /my-account/subscription-payment-method/{subscription_id}/ to the order-pay * endpoint during the pre_get_posts hook. * * @return void */ private function maybe_redirect_order_pay_for_subscription() { global $wp; $subscription_id = null; // Check if we're on the order-pay endpoint with a subscription ID. if ( ! empty( $wp->query_vars['order-pay'] ) ) { $order_id = absint( $wp->query_vars['order-pay'] ); $post_type = get_post_type( $order_id ); if ( 'shop_subscription' === $post_type ) { $subscription_id = $order_id; } } // Also check for change_payment_method parameter (when redirected from subscription-payment-method endpoint). // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce verified by WooCommerce core. if ( ! $subscription_id && ! empty( $_GET['change_payment_method'] ) ) { $change_payment_id = absint( $_GET['change_payment_method'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended $post_type = get_post_type( $change_payment_id ); if ( 'shop_subscription' === $post_type ) { $subscription_id = $change_payment_id; } } // If we found a subscription ID in either place, redirect. if ( $subscription_id ) { $this->redirect( wc_get_page_permalink( 'myaccount' ) ); } } /** * Removes the related subscriptions section from order details. * * Hides the "Related Subscriptions" section that normally appears on * order detail pages (both admin and customer-facing). This prevents * users from viewing subscription information through renewal orders. * * The underlying subscription and renewal order relationship remains intact; * only the display is hidden. * * @return void */ public function remove_related_subscriptions_section() { if ( class_exists( 'WC_Subscriptions_Order' ) ) { remove_action( 'woocommerce_order_details_after_order_table', [ 'WC_Subscriptions_Order', 'add_subscriptions_to_view_order_templates' ], 10 ); } } /** * Makes subscription products unpurchasable to prevent new subscriptions. * * This prevents customers from adding subscription products to their cart * or purchasing them during checkout. Runs early in the purchase flow to * provide the cleanest user experience. * * Does NOT affect: * - Renewal order processing (renewals don't check is_purchasable) * - Existing subscriptions in the database * - Regular (non-subscription) products * * @param bool $is_purchasable Whether the product can be purchased. * @param WC_Product $product Product object. * @return bool False for subscription products, original value otherwise. */ public function make_subscription_products_unpurchasable( $is_purchasable, $product ) { if ( ! $product ) { return $is_purchasable; } // Check if product is a subscription type. if ( $product->is_type( [ 'subscription', 'variable-subscription', 'subscription_variation' ] ) ) { return false; } return $is_purchasable; } /** * Filters the cart item removal message for subscription products. * * When WooCommerce removes unpurchasable products from the cart, this filter * customizes the message for subscription products to be more customer-friendly. * * @param string $message The default removal message from WooCommerce. * @param WC_Product $product The product being removed. * @return string The filtered message. */ public function filter_subscription_removal_message( $message, $product ) { // Only modify the message if this is a subscription product. if ( ! $product || ! $product->is_type( [ 'subscription', 'variable-subscription', 'subscription_variation' ] ) ) { return $message; } // Return a customer-friendly message that matches WooCommerce's standard format. return sprintf( /* translators: %s: product name */ __( '%s has been removed from your cart because it can no longer be purchased. Please contact us if you need assistance.', 'woocommerce-payments' ), $product->get_name() ); } /** * Filters subscription products from admin product search results. * * Removes subscription products from the AJAX product search used in the * admin order editor "Add item(s)" modal. This prevents admins from seeing * subscription products as options when manually creating orders. * * @param array $products Array of products (product_id => product_name). * @return array Filtered array without subscription products. */ public function filter_admin_product_search( $products ) { if ( empty( $products ) ) { return $products; } $filtered = []; foreach ( $products as $product_id => $product_name ) { $product = wc_get_product( $product_id ); // Skip if not a valid product or is a subscription type. if ( ! $product || $product->is_type( [ 'subscription', 'variable-subscription', 'subscription_variation' ] ) ) { continue; } $filtered[ $product_id ] = $product_name; } return $filtered; } /** * Validates that subscription products cannot be added to admin orders. * * This provides server-side validation as a backup to the search filtering. * If an admin somehow attempts to add a subscription product to an order * (e.g., by manipulating the AJAX request), this will block it with an error. * * @param WP_Error $validation_error Error object to populate if validation fails. * @param WC_Product $product Product being added to order. * @param WC_Order $order Order object. * @param int $qty Quantity being added. * @return WP_Error Error object (populated if validation fails). */ public function validate_admin_order_item( $validation_error, $product, $order, $qty ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- Required by filter signature. unset( $order, $qty ); if ( ! $product ) { return $validation_error; } // Check if product is a subscription type. if ( $product->is_type( [ 'subscription', 'variable-subscription', 'subscription_variation' ] ) ) { return new WP_Error( 'subscription_not_allowed_in_admin_order', __( 'Subscription products cannot be added to orders. Please install WooCommerce Subscriptions to manage subscriptions.', 'woocommerce-payments' ) ); } return $validation_error; } /** * Determines if the given screen ID should be blocked. * * Checks if a screen ID contains subscription-related identifiers for * both CPT (shop_subscription) and HPOS (wc-orders--shop_subscription). * * @param string $screen_id Screen ID. * @return bool True if the screen should be blocked, false otherwise. */ private function is_blocked_admin_screen( $screen_id ) { if ( '' === $screen_id ) { return false; } return false !== strpos( $screen_id, 'shop_subscription' ) || false !== strpos( $screen_id, 'wc-orders--shop_subscription' ); } /** * Determines if the current request is targeting the subscription post type. * * This handles: * - Listing: ?post_type=shop_subscription * - Adding new: ?post_type=shop_subscription * - Editing by post ID: ?post=123&action=edit (checked via post type lookup) * * @return bool */ private function is_subscription_post_type_request() { // Check for explicit post_type parameter. if ( ! empty( $_GET['post_type'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended return 'shop_subscription' === sanitize_key( wp_unslash( $_GET['post_type'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended } // Check if editing a specific post that might be a subscription. if ( ! empty( $_GET['post'] ) && ! empty( $_GET['action'] ) && 'edit' === $_GET['action'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended $post_id = absint( $_GET['post'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ( $post_id > 0 ) { $post_type = get_post_type( $post_id ); // Block subscription orders. if ( 'shop_subscription' === $post_type ) { return true; } // Block subscription products. if ( 'product' === $post_type && $this->is_subscription_product( $post_id ) ) { return true; } } } return false; } /** * Checks if a product ID is a subscription product. * * @param int $product_id Product ID to check. * @return bool True if the product is a subscription product, false otherwise. */ private function is_subscription_product( $product_id ) { $product = wc_get_product( $product_id ); if ( ! $product ) { return false; } // Check product type directly - more reliable than using WC_Subscriptions_Product // which may not be available in all contexts. return $product->is_type( [ 'subscription', 'variable-subscription', 'subscription_variation' ] ); } /** * Displays an admin notice when users are redirected from disabled subscription features. * * @return void */ public function display_subscription_disabled_notice() { if ( empty( $_GET['wcpay_subscription_disabled'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended return; } if ( empty( $_GET['page'] ) || 'wc-settings' !== $_GET['page'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended return; } if ( empty( $_GET['section'] ) || 'woocommerce_payments' !== $_GET['section'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended return; } $message = sprintf( /* translators: %1$s: WooCommerce Subscriptions link */ __( 'To access your subscriptions data and keep managing recurring payments, please install WooCommerce Subscriptions. Built-in support for subscriptions is no longer available in WooPayments.', 'woocommerce-payments' ), 'https://woocommerce.com/products/woocommerce-subscriptions/' ); ?>

[ 'href' => [], 'target' => [], ], ] ); ?>

'wc-settings', 'tab' => 'checkout', 'section' => 'woocommerce_payments', 'wcpay_subscription_disabled' => '1', ], admin_url( 'admin.php' ) ); $this->redirect( $redirect_url ); } /** * Gets the account endpoint slug for the supplied option key. * * @param string $key Subscriptions endpoint option key suffix. * @return string */ private function get_account_endpoint_slug( $key ) { switch ( $key ) { case 'view-subscription': return get_option( 'woocommerce_myaccount_view_subscription_endpoint', 'view-subscription' ); case 'subscription-payment-method': return get_option( 'woocommerce_myaccount_subscription_payment_method_endpoint', 'subscription-payment-method' ); case 'subscriptions': default: return get_option( 'woocommerce_myaccount_subscriptions_endpoint', 'subscriptions' ); } } /** * Returns the list of account endpoints which should be blocked. * * Retrieves all subscription-related My Account endpoints that customers * should not be able to access. Endpoint slugs are configurable via * WooCommerce settings, so we fetch them dynamically. * * @return array Array of endpoint slugs to block. */ private function get_blocked_account_endpoints() { return [ $this->get_account_endpoint_slug( 'subscriptions' ), $this->get_account_endpoint_slug( 'view-subscription' ), $this->get_account_endpoint_slug( 'subscription-payment-method' ), ]; } /** * Redirects the current request to the provided URL and exits execution. * * @param string $target Target URL. * @return void */ protected function redirect( $target ) { wp_safe_redirect( $target ); exit; } /** * Checks whether the current request matches the provided endpoint. * * @param string $endpoint Endpoint slug. * @return bool */ protected function is_endpoint_url( $endpoint ) { return is_wc_endpoint_url( $endpoint ); } }