Files
2025-11-24 21:33:55 +00:00

675 lines
24 KiB
PHP

<?php
/**
* Class WC_Payments_Subscriptions_Disabler
*
* Responsible for disabling merchant and customer facing management
* interfaces for bundled subscriptions while keeping renewal logic active.
*
* @package WooCommerce\Payments
*/
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
/**
* Disables bundled subscriptions management surfaces.
*
* This class hides UI elements and blocks access to subscription management
* interfaces for both merchants and customers. It also prevents new subscription
* products from being purchased or added to orders.
*
* What this class disables:
* - Admin menu items (WooCommerce > 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 <a target="_blank" href="%1$s">WooCommerce Subscriptions</a>. Built-in support for subscriptions is no longer available in WooPayments.', 'woocommerce-payments' ),
'https://woocommerce.com/products/woocommerce-subscriptions/'
);
?>
<div class="notice notice-info wcpay-notice">
<p><strong><?php esc_html_e( 'WooPayments', 'woocommerce-payments' ); ?></strong></p>
<p>
<?php
echo wp_kses(
$message,
[
'a' => [
'href' => [],
'target' => [],
],
]
);
?>
</p>
</div>
<?php
}
/**
* Redirects the current request to the WooCommerce Payments settings page.
*
* Adds a query parameter to trigger an informational notice after redirect.
*
* @return void
*/
protected function redirect_to_admin_overview() {
$redirect_url = add_query_arg(
[
'page' => '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 );
}
}