diff --git a/includes/plugins/woocommerce-subscriptions/group-subscription/class-group-subscription-settings.php b/includes/plugins/woocommerce-subscriptions/group-subscription/class-group-subscription-settings.php
index 95a5dea1f5..0fa9640911 100644
--- a/includes/plugins/woocommerce-subscriptions/group-subscription/class-group-subscription-settings.php
+++ b/includes/plugins/woocommerce-subscriptions/group-subscription/class-group-subscription-settings.php
@@ -19,6 +19,7 @@ class Group_Subscription_Settings {
const DEFAULT_SETTINGS = [
'enabled' => false,
'limit' => 0,
+ 'name' => '',
];
/**
@@ -39,6 +40,26 @@ public static function init() {
\add_action( 'add_meta_boxes', [ __CLASS__, 'add_group_subscription_meta_box' ], 26, 2 );
\add_action( 'woocommerce_process_shop_order_meta', [ __CLASS__, 'save_group_subscription_meta' ], 10, 2 );
\add_action( 'wp_ajax_newspack_group_subscription_search_users', [ __CLASS__, 'ajax_search_users' ] );
+
+ // Customize subscription column in admin list table for group subscriptions.
+ \add_filter( 'woocommerce_subscription_list_table_column_content', [ __CLASS__, 'filter_subscription_column_content' ], 10, 3 );
+
+ // Group subscription filter dropdown on subscription list table.
+ \add_action( 'woocommerce_order_list_table_restrict_manage_orders', [ __CLASS__, 'add_group_subscription_filter' ] );
+ \add_action( 'restrict_manage_posts', [ __CLASS__, 'add_group_subscription_filter' ] );
+
+ // Filter subscription list table query by group status.
+ \add_filter( 'woocommerce_shop_subscription_list_table_prepare_items_query_args', [ __CLASS__, 'filter_subscriptions_by_group' ] );
+ \add_filter( 'request', [ __CLASS__, 'filter_subscriptions_by_group_cpt' ] );
+
+ // Clear group subscription IDs cache when product group settings change.
+ \add_action( 'woocommerce_process_product_meta', [ __CLASS__, 'maybe_clear_cache_on_product_save' ] );
+
+ // Include group name in subscription search.
+ \add_filter( 'woocommerce_shop_subscription_search_fields', [ __CLASS__, 'add_group_name_search_field' ] );
+ \add_filter( 'woocommerce_order_table_search_query_meta_keys', [ __CLASS__, 'add_group_name_hpos_search_field' ] );
+ \add_filter( 'posts_join', [ __CLASS__, 'search_group_name_join' ], 10, 2 );
+ \add_filter( 'posts_search', [ __CLASS__, 'search_group_name_where' ], 10, 2 );
}
/**
@@ -139,6 +160,45 @@ public static function add_custom_product_pricing_options( $custom_product_prici
return $custom_product_pricing_options;
}
+ /**
+ * Filter the subscription list table column content to show group name
+ * and member count for group subscriptions.
+ *
+ * @param string $column_content The column content HTML.
+ * @param \WC_Subscription $subscription The subscription object.
+ * @param string $column The column name.
+ *
+ * @return string The filtered column content.
+ */
+ public static function filter_subscription_column_content( $column_content, $subscription, $column ) {
+ if ( 'order_title' !== $column ) {
+ return $column_content;
+ }
+ if ( ! Group_Subscription::is_group_subscription( $subscription ) ) {
+ return $column_content;
+ }
+ $settings = self::get_subscription_settings( $subscription );
+ $members = Group_Subscription::get_members( $subscription );
+ $member_count = count( $members );
+ $limit = $settings['limit'] > 0
+ ? $settings['limit']
+ : __( 'unlimited', 'newspack-plugin' );
+
+ return sprintf(
+ '%s (%s)',
+ \esc_url( $subscription->get_edit_order_url() ),
+ \esc_html( $settings['name'] ),
+ \esc_html(
+ sprintf(
+ /* translators: 1: member count, 2: member limit or "unlimited" */
+ __( '%1$s of %2$s members', 'newspack-plugin' ),
+ $member_count,
+ $limit
+ )
+ )
+ );
+ }
+
/**
* Get the group subscription settings for a product.
*
@@ -182,9 +242,17 @@ public static function get_subscription_settings( $subscription ) {
return self::DEFAULT_SETTINGS;
}
$product_id = WooCommerce_Subscriptions::get_subscription_product_id( $subscription );
+ $owner_name = trim( $subscription->get_formatted_billing_full_name() );
$settings = self::get_product_settings( $product_id );
$settings['enabled'] = $subscription->get_meta( self::GROUP_SUBSCRIPTION_META_PREFIX . 'enabled', true ) ? \wc_string_to_bool( $subscription->get_meta( self::GROUP_SUBSCRIPTION_META_PREFIX . 'enabled', true ) ) : $settings['enabled'];
$settings['limit'] = (int) $subscription->get_meta( self::GROUP_SUBSCRIPTION_META_PREFIX . 'limit', true ) ?: $settings['limit']; // phpcs:ignore Universal.Operators.DisallowShortTernary.Found
+ $settings['name'] = $subscription->get_meta( self::GROUP_SUBSCRIPTION_META_PREFIX . 'name', true ) ?
+ $subscription->get_meta( self::GROUP_SUBSCRIPTION_META_PREFIX . 'name', true ) :
+ sprintf(
+ /* translators: %s: The subscription owner's name. */
+ __( '%s Group', 'newspack-plugin' ),
+ $owner_name ? $owner_name . '’s' : __( 'Unnamed', 'newspack-plugin' )
+ );
/**
* Filter the group subscription settings for a subscription.
@@ -225,6 +293,11 @@ public static function update_subscription_settings( $subscription, $settings )
}
if ( $should_save ) {
$subscription->save();
+
+ // Clear the cached group subscription IDs if the enabled setting changed.
+ if ( isset( $settings['enabled'] ) ) {
+ self::clear_group_subscription_ids_cache();
+ }
}
}
@@ -290,6 +363,22 @@ public static function add_group_subscription_options( $subscription ) {
+
+ self::GROUP_SUBSCRIPTION_META_PREFIX . 'name',
+ 'name' => self::GROUP_SUBSCRIPTION_META_PREFIX . 'name',
+ 'label' => __( 'Group subscription name', 'newspack-plugin' ),
+ 'value' => $settings['name'],
+ 'type' => 'text',
+ 'wrapper_class' => 'show_if_newspack_group_subscription_enabled',
+ ]
+ )
+ );
+ ?>
+
$is_enabled,
'limit' => $limit,
+ 'name' => $name,
+ ]
+ );
+ }
+
+ /**
+ * Add a group subscription filter dropdown to the subscription list table.
+ *
+ * @param string $order_type The order type (post type or HPOS order type).
+ */
+ public static function add_group_subscription_filter( $order_type = '' ) {
+ if ( '' === $order_type ) {
+ $order_type = isset( $GLOBALS['typenow'] ) ? $GLOBALS['typenow'] : '';
+ }
+
+ if ( 'shop_subscription' !== $order_type ) {
+ return;
+ }
+
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ $selected = isset( $_GET['_newspack_group_subscription'] ) ? \sanitize_text_field( \wp_unslash( $_GET['_newspack_group_subscription'] ) ) : '';
+
+ ?>
+
+ postmeta} AS np_group_name ON ( {$wpdb->posts}.ID = np_group_name.post_id AND np_group_name.meta_key = '" . esc_sql( self::GROUP_SUBSCRIPTION_META_PREFIX . 'name' ) . "' ) ";
+ return $join;
+ }
+
+ /**
+ * Extend the search WHERE clause to include group name meta in WP_Query.
+ *
+ * @param string $search The search WHERE clause.
+ * @param \WP_Query $query The WP_Query instance.
+ *
+ * @return string The modified search WHERE clause.
+ */
+ public static function search_group_name_where( $search, $query ) {
+ global $wpdb;
+ if ( ! self::is_subscription_search_query( $query ) || empty( $search ) ) {
+ return $search;
+ }
+ $term = $query->get( 's' );
+ if ( empty( $term ) ) {
+ return $search;
+ }
+ $like = '%' . $wpdb->esc_like( $term ) . '%';
+ $or_clause = $wpdb->prepare( ' OR ( np_group_name.meta_value LIKE %s )', $like );
+
+ // Insert the OR clause inside the existing grouped search condition.
+ // WP's search clause can end with )) or ) depending on search terms.
+ if ( preg_match( '/\)\)\s*$/', $search ) ) {
+ $search = preg_replace( '/\)\)\s*$/', $or_clause . ' ))', $search, 1 );
+ } else {
+ $search = preg_replace( '/\)\s*$/', $or_clause . ' )', $search, 1 );
+ }
+ return $search;
+ }
+
+ /**
+ * Check if a WP_Query is a search query for shop_subscription post type.
+ *
+ * @param \WP_Query $query The WP_Query instance.
+ *
+ * @return bool Whether this is a subscription search query.
+ */
+ private static function is_subscription_search_query( $query ) {
+ return $query->is_search() && 'shop_subscription' === $query->get( 'post_type' );
+ }
+
+ /**
+ * Transient key for caching group subscription IDs.
+ */
+ const GROUP_SUBSCRIPTION_IDS_TRANSIENT = 'newspack_group_subscription_ids';
+
+ /**
+ * Get all subscription IDs that are group subscriptions.
+ *
+ * Collects IDs from two sources:
+ * 1. Subscriptions with the group enabled meta set directly.
+ * 2. Subscriptions whose product has group subscriptions enabled (inheritance).
+ *
+ * Results are cached in a transient for 5 minutes.
+ *
+ * @return int[] Array of subscription IDs.
+ */
+ public static function get_group_subscription_ids() {
+ $cached = \get_transient( self::GROUP_SUBSCRIPTION_IDS_TRANSIENT );
+ if ( false !== $cached ) {
+ return $cached;
+ }
+
+ $meta_key = self::GROUP_SUBSCRIPTION_META_PREFIX . 'enabled';
+
+ // 1. Subscription IDs with group enabled meta set directly.
+ // Uses wcs_get_subscriptions which properly handles meta_query in both
+ // CPT and HPOS modes (via wcs_get_orders_with_meta_query internally).
+ $enabled_ids = [];
+ if ( function_exists( 'wcs_get_subscriptions' ) ) {
+ $enabled_ids = array_keys(
+ \wcs_get_subscriptions(
+ [
+ 'subscriptions_per_page' => -1,
+ 'subscription_status' => 'any',
+ 'meta_query' => [ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
+ [
+ 'key' => $meta_key,
+ 'value' => 'yes',
+ ],
+ ],
+ ]
+ )
+ );
+ }
+
+ // 2. Subscription IDs whose product has group subscriptions enabled.
+ $product_ids = \get_posts( // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.get_posts_get_posts
+ [
+ 'post_type' => [ 'product', 'product_variation' ],
+ 'posts_per_page' => -1, // phpcs:ignore WordPress.WP.PostsPerPage.posts_per_page_posts_per_page
+ 'fields' => 'ids',
+ 'meta_key' => $meta_key, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
+ 'meta_value' => 'yes', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
]
);
+
+ $product_sub_ids = [];
+ if ( ! empty( $product_ids ) && function_exists( 'wcs_get_subscriptions_for_product' ) && function_exists( 'wc_get_product' ) ) {
+ foreach ( $product_ids as $product_id ) {
+ $product = \wc_get_product( $product_id );
+ if ( ! $product ) {
+ continue;
+ }
+ // Skip variable parent products: variations have their own group settings
+ // and do not inherit from the parent.
+ if ( $product->is_type( [ 'variable', 'variable-subscription' ] ) ) {
+ continue;
+ }
+ $product_sub_ids = array_merge(
+ $product_sub_ids,
+ array_keys( \wcs_get_subscriptions_for_product( $product_id ) )
+ );
+ }
+ }
+
+ // 3. Remove subscriptions that explicitly opted out via an 'enabled = no' override.
+ // A subscription's own meta takes precedence over product inheritance.
+ $opted_out_ids = [];
+ if ( ! empty( $product_sub_ids ) && function_exists( 'wcs_get_subscriptions' ) ) {
+ $opted_out_ids = array_keys(
+ \wcs_get_subscriptions(
+ [
+ 'subscriptions_per_page' => -1,
+ 'subscription_status' => 'any',
+ 'meta_query' => [ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
+ [
+ 'key' => $meta_key,
+ 'value' => 'no',
+ ],
+ ],
+ ]
+ )
+ );
+ }
+
+ $product_sub_ids = array_diff( $product_sub_ids, $opted_out_ids );
+
+ $result = array_values( array_unique( array_merge( $enabled_ids, $product_sub_ids ) ) );
+
+ \set_transient( self::GROUP_SUBSCRIPTION_IDS_TRANSIENT, $result, 5 * MINUTE_IN_SECONDS );
+
+ return $result;
+ }
+
+ /**
+ * Clear the group subscription IDs transient cache.
+ *
+ * Called when group subscription settings change to ensure the filter
+ * reflects current state.
+ */
+ public static function clear_group_subscription_ids_cache() {
+ \delete_transient( self::GROUP_SUBSCRIPTION_IDS_TRANSIENT );
+ }
+
+ /**
+ * Clear the group subscription IDs cache when a product's group settings
+ * may have changed.
+ *
+ * @param int $product_id The product ID being saved.
+ */
+ public static function maybe_clear_cache_on_product_save( $product_id ) {
+ // phpcs:ignore WordPress.Security.NonceVerification.Missing
+ if ( isset( $_POST[ self::GROUP_SUBSCRIPTION_META_PREFIX . 'enabled' ] ) || \get_post_meta( $product_id, self::GROUP_SUBSCRIPTION_META_PREFIX . 'enabled', true ) ) {
+ self::clear_group_subscription_ids_cache();
+ }
+ }
+
+ /**
+ * Filter the subscription list table query by group subscription status (HPOS).
+ *
+ * @param array $query_args The query args for the list table.
+ *
+ * @return array The filtered query args.
+ */
+ public static function filter_subscriptions_by_group( $query_args ) {
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ if ( empty( $_GET['_newspack_group_subscription'] ) ) {
+ return $query_args;
+ }
+
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ $filter = \sanitize_text_field( \wp_unslash( $_GET['_newspack_group_subscription'] ) );
+ if ( ! in_array( $filter, [ 'group', 'non-group' ], true ) ) {
+ return $query_args;
+ }
+
+ $group_ids = self::get_group_subscription_ids();
+
+ if ( 'group' === $filter ) {
+ if ( empty( $group_ids ) ) {
+ $query_args['post__in'] = [ 0 ];
+ } elseif ( ! isset( $query_args['post__in'] ) ) {
+ $query_args['post__in'] = $group_ids;
+ } else {
+ $intersected = array_intersect( $query_args['post__in'], $group_ids );
+ $query_args['post__in'] = empty( $intersected ) ? [ 0 ] : array_values( $intersected );
+ }
+ } elseif ( 'non-group' === $filter ) {
+ if ( ! empty( $group_ids ) ) {
+ $query_args['post__not_in'] = isset( $query_args['post__not_in'] ) // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_post__not_in
+ ? array_merge( $query_args['post__not_in'], $group_ids )
+ : $group_ids;
+ }
+ }
+
+ return $query_args;
+ }
+
+ /**
+ * Filter the subscription list by group subscription status (CPT mode).
+ *
+ * Uses the 'request' filter, which is the same approach WCS uses for its
+ * own product/customer/payment method filters on edit.php.
+ *
+ * @param array $query_vars The query vars for the admin list table request.
+ *
+ * @return array The filtered query vars.
+ */
+ public static function filter_subscriptions_by_group_cpt( $query_vars ) {
+ global $typenow;
+
+ if ( ! is_admin() || 'shop_subscription' !== $typenow ) {
+ return $query_vars;
+ }
+
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ if ( empty( $_GET['_newspack_group_subscription'] ) ) {
+ return $query_vars;
+ }
+
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ $filter = \sanitize_text_field( \wp_unslash( $_GET['_newspack_group_subscription'] ) );
+ if ( ! in_array( $filter, [ 'group', 'non-group' ], true ) ) {
+ return $query_vars;
+ }
+
+ $group_ids = self::get_group_subscription_ids();
+
+ if ( 'group' === $filter ) {
+ if ( empty( $group_ids ) ) {
+ $query_vars['post__in'] = [ 0 ];
+ } elseif ( ! isset( $query_vars['post__in'] ) ) {
+ $query_vars['post__in'] = $group_ids;
+ } else {
+ $intersected = array_intersect( $query_vars['post__in'], $group_ids );
+ $query_vars['post__in'] = empty( $intersected ) ? [ 0 ] : array_values( $intersected );
+ }
+ } elseif ( 'non-group' === $filter ) {
+ if ( ! empty( $group_ids ) ) {
+ $query_vars['post__not_in'] = isset( $query_vars['post__not_in'] ) // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_post__not_in
+ ? array_merge( $query_vars['post__not_in'], $group_ids )
+ : $group_ids;
+ }
+ }
+
+ return $query_vars;
}
}
Group_Subscription_Settings::init();
diff --git a/tests/mocks/wc-mocks.php b/tests/mocks/wc-mocks.php
index 254685bd25..8ff66441a2 100644
--- a/tests/mocks/wc-mocks.php
+++ b/tests/mocks/wc-mocks.php
@@ -343,6 +343,12 @@ public function update_dates( $dates ) {
$this->data['dates'][ $type ] = $date;
}
}
+ public function get_formatted_billing_full_name() {
+ $first = $this->data['billing_first_name'] ?? '';
+ $last = $this->data['billing_last_name'] ?? '';
+ $name = trim( "$first $last" );
+ return $name ? $name : '';
+ }
public function get_items() {
return $this->data['items'] ?? [];
}
diff --git a/tests/unit-tests/content-gate/group-subscriptions.php b/tests/unit-tests/content-gate/group-subscriptions.php
index a4570797c9..3ecb7e152d 100644
--- a/tests/unit-tests/content-gate/group-subscriptions.php
+++ b/tests/unit-tests/content-gate/group-subscriptions.php
@@ -49,7 +49,7 @@ public function tear_down() {
*
* @param int $customer_id Customer/owner user ID.
* @param array $settings Optional group-subscription settings to apply.
- * Supported keys: 'enabled' (bool), 'limit' (int).
+ * Supported keys: 'enabled' (bool), 'limit' (int), 'name' (string).
* @return \WC_Subscription
*/
private function create_group_subscription( $customer_id, $settings = [] ) {
@@ -57,6 +57,7 @@ private function create_group_subscription( $customer_id, $settings = [] ) {
[
'enabled' => true,
'limit' => 0,
+ 'name' => '',
],
$settings
);
@@ -75,6 +76,9 @@ private function create_group_subscription( $customer_id, $settings = [] ) {
if ( $settings['limit'] > 0 ) {
$sub->update_meta_data( Group_Subscription_Settings::GROUP_SUBSCRIPTION_META_PREFIX . 'limit', $settings['limit'] );
}
+ if ( ! empty( $settings['name'] ) ) {
+ $sub->update_meta_data( Group_Subscription_Settings::GROUP_SUBSCRIPTION_META_PREFIX . 'name', $settings['name'] );
+ }
return $sub;
}
@@ -425,6 +429,101 @@ public function test_get_group_subscriptions_for_user_with_no_memberships() {
$this->assertEmpty( $result );
}
+ // -------------------------------------------------------------------------
+ // Group_Subscription_Settings name tests
+ // -------------------------------------------------------------------------
+
+ /**
+ * Test get_subscription_settings() returns a default name based on the
+ * subscription owner's billing name when no custom name is set.
+ */
+ public function test_group_name_defaults_to_owner_name() {
+ $owner_id = $this->create_reader_user();
+ $group_sub = $this->create_group_subscription( $owner_id );
+
+ // Set billing name on the subscription so get_formatted_billing_full_name() returns a real name.
+ $group_sub->data['billing_first_name'] = 'Jane';
+ $group_sub->data['billing_last_name'] = 'Doe';
+
+ $settings = Group_Subscription_Settings::get_subscription_settings( $group_sub );
+
+ $this->assertNotEmpty( $settings['name'], 'Default name should not be empty' );
+ $this->assertStringContainsString(
+ 'Jane',
+ $settings['name'],
+ 'Default name should contain the owner first name'
+ );
+ $this->assertStringContainsString(
+ "\u{2019}s Group",
+ $settings['name'],
+ 'Default name should end with the possessive Group suffix'
+ );
+ }
+
+ /**
+ * Test get_subscription_settings() returns a custom name when one is stored
+ * in subscription meta.
+ */
+ public function test_group_name_custom_override() {
+ $owner_id = $this->create_reader_user();
+ $group_sub = $this->create_group_subscription( $owner_id, [ 'name' => 'Acme Newsroom' ] );
+
+ $settings = Group_Subscription_Settings::get_subscription_settings( $group_sub );
+
+ $this->assertEquals( 'Acme Newsroom', $settings['name'] );
+ }
+
+ /**
+ * Test update_subscription_settings() persists a name change that is
+ * reflected in subsequent get_subscription_settings() calls.
+ */
+ public function test_group_name_update_and_read_back() {
+ $owner_id = $this->create_reader_user();
+ $group_sub = $this->create_group_subscription( $owner_id );
+
+ Group_Subscription_Settings::update_subscription_settings(
+ $group_sub,
+ [ 'name' => 'Daily Planet' ]
+ );
+
+ $settings = Group_Subscription_Settings::get_subscription_settings( $group_sub );
+
+ $this->assertEquals( 'Daily Planet', $settings['name'] );
+ }
+
+ /**
+ * Test that an empty name in update_subscription_settings() stores an
+ * empty string, which causes get_subscription_settings() to fall back
+ * to the default owner-based name.
+ */
+ public function test_group_name_empty_falls_back_to_default() {
+ $owner_id = $this->create_reader_user();
+ $group_sub = $this->create_group_subscription( $owner_id, [ 'name' => 'Temp Name' ] );
+
+ // Set billing name on the subscription so the fallback name is based on the actual owner name.
+ $group_sub->data['billing_first_name'] = 'Jane';
+ $group_sub->data['billing_last_name'] = 'Doe';
+
+ Group_Subscription_Settings::update_subscription_settings(
+ $group_sub,
+ [ 'name' => '' ]
+ );
+
+ $settings = Group_Subscription_Settings::get_subscription_settings( $group_sub );
+
+ // After clearing the custom name, it should revert to the default.
+ $this->assertStringContainsString(
+ 'Jane',
+ $settings['name'],
+ 'Clearing the name should revert to the default owner-based name'
+ );
+ $this->assertStringContainsString(
+ "\u{2019}s Group",
+ $settings['name'],
+ 'Default name should end with the possessive Group suffix'
+ );
+ }
+
// -------------------------------------------------------------------------
// Group_Subscription_Invite tests
// -------------------------------------------------------------------------