Skip to content
Open
70 changes: 68 additions & 2 deletions includes/integrations/stripe/Settings.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ public function init() {
add_action( 'update_option_wpum_settings', array( $this, 'flush_product_cache' ) );
add_action( 'wp_ajax_wpum_stripe_connect_account_info', array( $this, 'stripe_connect_account_info_ajax_response' ) );
add_action( 'admin_init', array( $this, 'handle_stripe_connect_disconnect' ) );
add_action( 'admin_init', array( $this, 'handle_fetch_stripe_products' ) );
}

/**
Expand Down Expand Up @@ -256,11 +257,22 @@ public function register_settings( $settings ) {
'type' => 'hidden',
);

$fetch_products_url = add_query_arg(
array(
'page' => 'wpum-settings',
'fetch-products' => true,
),
admin_url( 'users.php' )
);

$fetch_products_url = wp_nonce_url( $fetch_products_url, 'wpum-stripe-fetch-products' );
$fetch_product_btn = sprintf( '<p><a href="%s#/stripe" class="button button-secondary">Fetch Stripe Products</a></p>', esc_url( $fetch_products_url ) );

if ( $this->products && $this->products->totalRecurringProducts() > 1 ) {
$settings['stripe'][] = array(
'id' => 'test_stripe_products',
'name' => __( 'Eligible Products', 'wp-user-manager' ),
'desc' => sprintf( 'Select the product prices users can subscribe to on the account page. This should be the same as the products defined in the <a target="_blank" href="%s">Stripe Customer Portal Subscription settings</a>.', 'https://wpusermanager.com/article/337-recurring-subscriptions/#configure-eligible-products' ),
'desc' => sprintf( 'Select the product prices users can subscribe to on the account page. This should be the same as the products defined in the <a target="_blank" href="%s">Stripe Customer Portal Subscription settings</a>. %s', 'https://wpusermanager.com/article/337-recurring-subscriptions/#configure-eligible-products ', $fetch_product_btn ),
'type' => 'multiselect',
Comment on lines 273 to 276

Copilot AI Feb 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The documentation URL in this description has a trailing space at the end of the href string, which will be URL-encoded and can break the link target. Remove the trailing space so the anchor points to the intended section.

Copilot uses AI. Check for mistakes.
'multiple' => true,
'options' => $this->products->get_plans(),
Expand All @@ -280,7 +292,7 @@ public function register_settings( $settings ) {
$settings['stripe'][] = array(
'id' => 'live_stripe_products',
'name' => __( 'Eligible Products', 'wp-user-manager' ),
'desc' => sprintf( 'Select the product prices users can subscribe to on the account page. This should be the same as the products defined in the <a target="_blank" href="%s">Stripe Customer Portal Subscription settings</a>.', 'https://wpusermanager.com/article/337-recurring-subscriptions/#configure-eligible-products' ),
'desc' => sprintf( 'Select the product prices users can subscribe to on the account page. This should be the same as the products defined in the <a target="_blank" href="%s">Stripe Customer Portal Subscription settings</a>. %s', 'https://wpusermanager.com/article/337-recurring-subscriptions/#configure-eligible-products', $fetch_product_btn ),
'type' => 'multiselect',
'multiple' => true,
'options' => $this->products->get_plans(),
Expand Down Expand Up @@ -602,4 +614,58 @@ public function handle_stripe_connect_disconnect() {

return wp_safe_redirect( esc_url_raw( $redirect ) );
}

/**
* Fetch Stripe Products
*/
public function handle_fetch_stripe_products() {
$page = filter_input( INPUT_GET, 'page', FILTER_UNSAFE_RAW );
$page = sanitize_text_field( $page );

if ( empty( $page ) ) {
return;
}

if ( 'wpum-settings' !== $page ) {
return;
}

$fetch_products = filter_input( INPUT_GET, 'fetch-products', FILTER_UNSAFE_RAW );
$fetch_products = sanitize_text_field( $fetch_products );

if ( empty( $fetch_products ) ) {
return;
}

// Current user cannot handle this request, bail.
if ( ! current_user_can( 'manage_options' ) ) {
return;
}

$nonce = filter_input( INPUT_GET, '_wpnonce', FILTER_UNSAFE_RAW );
$nonce = sanitize_text_field( $nonce );

if ( empty( $nonce ) ) {
return;
}

if ( ! wp_verify_nonce( $nonce, 'wpum-stripe-fetch-products' ) ) {
return;
}

// Clear product cache
$this->flush_product_cache();

$products = new Products( $this->connect->get_stripe_secret(), $this->connect->get_gateway_mode() );
$products->all( true );

Comment on lines +656 to +661

Copilot AI Feb 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This handler triggers a Stripe API fetch via Products::all(true). Products::getProducts() does not catch exceptions from the per-product Price::all() call, so an API error here can throw and break the settings page load. Consider wrapping the fetch in a try/catch and redirecting back (optionally with an admin_notice / query flag) when the refresh fails.

Copilot uses AI. Check for mistakes.
$redirect = remove_query_arg(
array(
'_wpnonce',
'fetch-products',
)
);

Copilot AI Feb 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After removing the query args, the redirect URL no longer includes the "#/stripe" fragment, so users may be redirected away from the Stripe tab (fragments are not sent to the server). Consider appending "#/stripe" to the redirect target so the UI reliably returns to the Stripe settings tab after fetching.

Suggested change
);
);
$redirect .= '#/stripe';

Copilot uses AI. Check for mistakes.

return wp_safe_redirect( esc_url_raw( $redirect ) );

Copilot AI Feb 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wp_safe_redirect() does not terminate execution. To avoid any additional processing after sending the Location header (and to match common WP redirect patterns), call exit immediately after the redirect.

Suggested change
return wp_safe_redirect( esc_url_raw( $redirect ) );
wp_safe_redirect( esc_url_raw( $redirect ) );
exit;

Copilot uses AI. Check for mistakes.
}
}
64 changes: 64 additions & 0 deletions includes/integrations/stripe/StripeWebhookController.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
use WPUserManager\Stripe\Controllers\Subscriptions;
use WPUserManager\Stripe\Models\Product;
use WPUserManager\Stripe\Models\User;
use WPUserManager\Stripe\Controllers\Products;

/**
* StripeWebhookController
Expand All @@ -38,6 +39,16 @@ class StripeWebhookController {
*/
protected $invoices;

/**
* @var string
*/
protected $gateway_mode;

/**
* @var string
*/
protected $secret_key;

/**
* StripeWebhookController constructor.
*
Expand All @@ -53,6 +64,8 @@ public function __construct( $secret_key, $webhook_secret, $gateway_mode ) {
$this->subscriptions = new Subscriptions( $gateway_mode );
$this->invoices = new Invoices( $gateway_mode );
$this->webhook_secret = $webhook_secret;
$this->gateway_mode = $gateway_mode;
$this->secret_key = $secret_key;
}

/**
Expand Down Expand Up @@ -310,4 +323,55 @@ protected function handleInvoicePaymentSucceeded( $payload ) {

return new \WP_REST_Response( 'Webhook handled', 200 );
}

/**
* Handle the product.created webhook to update the cached Stripe products.
*
* @param array $payload
*
* @return \WP_REST_Response
* @throws \Exception
*/
protected function handleProductCreated( $payload ) {
$products = new Products( $this->secret_key, $this->gateway_mode );
$products->all( true );

do_action( 'wpum_stripe_webhook_product_updated', $payload );

return new \WP_REST_Response( 'Webhook handled', 200 );
}
Comment on lines +335 to +342

Copilot AI Feb 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These three product webhook handlers duplicate the same cache-refresh logic and action dispatch. Consider extracting the shared "refresh products cache" behavior into a single protected/private helper to reduce maintenance risk if this flow changes later.

Copilot uses AI. Check for mistakes.

/**
* Handle the product.deleted webhook to update the cached Stripe products.
*
* @param array $payload
*
* @return \WP_REST_Response
* @throws \Exception
*/
protected function handleProductDeleted( $payload ) {
$products = new Products( $this->secret_key, $this->gateway_mode );
$products->all( true );

do_action( 'wpum_stripe_webhook_product_updated', $payload );

return new \WP_REST_Response( 'Webhook handled', 200 );
}

/**
* Handle the product.updated webhook to update the cached Stripe products.
*
* @param array $payload
*
* @return \WP_REST_Response
* @throws \Exception
*/
protected function handleProductUpdated( $payload ) {
$products = new Products( $this->secret_key, $this->gateway_mode );
$products->all( true );

do_action( 'wpum_stripe_webhook_product_updated', $payload );

return new \WP_REST_Response( 'Webhook handled', 200 );
}
}
6 changes: 5 additions & 1 deletion tests/e2e/helpers/stripe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,11 @@ export function configureStripeSettings(
wpCli(`eval 'wpum_update_option("test_stripe_webhook_secret", "${webhookSecret}");'`);
wpCli(`eval 'wpum_update_option("test_stripe_products", array("${priceId}"));'`);
// Clear the products transient so WPUM fetches fresh data from Stripe
wpCli(`eval 'delete_transient("wpum_test_stripe_products");'`);
try {
wpCli(`eval 'delete_transient("wpum_test_stripe_products");'`);
} catch {
// Transient deletion may trigger Stripe SDK load which can fail on older PHP
}
}

/**
Expand Down
112 changes: 112 additions & 0 deletions tests/e2e/stripe-fetch-products.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { test, expect, wpAdminLogin, wpCli, createUser, deleteUser } from './fixtures';
import {
isStripeConfigured,
installTestBillingOverride,
removeTestBillingOverride,
configureStripeSettings,
createStripeTestProduct,
deleteStripeTestProduct,
} from './helpers/stripe';

let testProductId: string;
let testPriceId: string;

test.describe('Stripe Fetch Products', () => {
test.beforeAll(async () => {
if (!isStripeConfigured()) {
test.skip();
return;
}

installTestBillingOverride();

const { productId, priceId } = createStripeTestProduct();
testProductId = productId;
testPriceId = priceId;

configureStripeSettings(
process.env.STRIPE_PUBLISHABLE_KEY!,
process.env.STRIPE_SECRET_KEY!,
process.env.STRIPE_WEBHOOK_SECRET || '',
testPriceId
);
});

test.afterAll(async () => {
if (!isStripeConfigured()) return;

removeTestBillingOverride();

if (testProductId) {
deleteStripeTestProduct(testProductId);
}

deleteUser('stripe_e2e_subscriber');
deleteUser('stripe_e2e_subscriber@example.com');
});

test('Fetch Stripe Products button appears on settings page', async ({ page }) => {
await wpAdminLogin(page);
await page.goto('/wp-admin/users.php?page=wpum-settings#/stripe');
await page.waitForLoadState('networkidle');

// The settings page uses wp-optionskit Vue SPA — click the Stripe tab
const stripeTab = page.locator('a[href="#/stripe"]');
if (await stripeTab.isVisible({ timeout: 3000 }).catch(() => false)) {
await stripeTab.click();
await page.waitForTimeout(1000);
}

// The Fetch Stripe Products button should be visible (appears in both test & live sections)
const fetchButton = page.locator('a.button.button-secondary', { hasText: 'Fetch Stripe Products' }).first();
await expect(fetchButton).toBeVisible({ timeout: 10000 });
});

test('Fetch button refreshes products and redirects back', async ({ page }) => {
await wpAdminLogin(page);
await page.goto('/wp-admin/users.php?page=wpum-settings#/stripe');
await page.waitForLoadState('networkidle');

// Navigate to Stripe tab
const stripeTab = page.locator('a[href="#/stripe"]');
if (await stripeTab.isVisible({ timeout: 3000 }).catch(() => false)) {
await stripeTab.click();
await page.waitForTimeout(1000);
}

// Click the Fetch button (appears in both test & live sections — use first)
const fetchButton = page.locator('a.button.button-secondary', { hasText: 'Fetch Stripe Products' }).first();
await fetchButton.click();

// Should redirect back to the settings page (nonce and fetch-products params removed)
await page.waitForURL(/page=wpum-settings/, { timeout: 15000 });
expect(page.url()).not.toContain('fetch-products');

// The settings page should still render correctly
const heading = page.locator('h1');
await expect(heading).toContainText(/Settings/i, { timeout: 5000 });
});

test('non-admin user cannot fetch products', async ({ page }) => {
// Create a subscriber user
deleteUser('stripe_e2e_subscriber');
createUser('stripe_e2e_subscriber', 'stripe_e2e_subscriber@example.com', 'TestPass123!', 'subscriber');

// Log in as subscriber
await wpAdminLogin(page, 'stripe_e2e_subscriber', 'TestPass123!');

// Try to access the fetch products URL directly
await page.goto('/wp-admin/users.php?page=wpum-settings&fetch-products=true');

// Subscriber should not see the settings page — WordPress redirects to admin
// or shows "You do not have sufficient permissions"
const url = page.url();
const content = await page.content();
const blocked =
!url.includes('page=wpum-settings') ||
content.includes('You need a higher level of permission') ||
content.includes('Sorry, you are not allowed');

expect(blocked).toBeTruthy();
});
});
Loading
Loading