Skip to content

Conversation

@RadoslavGeorgiev
Copy link
Contributor

@RadoslavGeorgiev RadoslavGeorgiev commented Nov 20, 2025

Fixes WOOPMNT-5508

Changes proposed in this Pull Request

When editing subscriptions in WooCommerce admin, the payment method dropdown was showing all customers' saved payment methods instead of only the selected customer's methods for new subscriptions. For existing subscriptions, payment methods were not reloaded when the customer was changed. This PR fixes the issue by dynamically loading payment methods when the customer is changed.

Key changes:

  • Replaced static JavaScript with a React/TypeScript component that fetches payment methods via AJAX when the customer changes
  • Added UserTokenCache class to cache fetched tokens and manage loading/error states
  • Added AJAX endpoint (wcpay_get_user_payment_tokens) to fetch payment tokens for a specific user
  • Shows appropriate UI states: loading indicator, error messages, and "Please select a customer first" placeholder
  • Maintains backward compatibility with older WooCommerce Subscriptions versions

How can this code break?

  • AJAX request could fail if nonce expires or network issues occur → handled with error state display
  • Customer select uses select2 which doesn't emit standard events → handled by listening to both select2 and native change events

Testing instructions

  1. Create a store with multiple customers who have saved payment methods.
  2. Create a subscription with one of the customers.
  3. Go to WooCommerce → Subscriptions → Edit the subscription.
  4. Verify payment methods dropdown shows only the current customer's saved payment methods.
  5. Change the customer using the customer selector dropdown.
  6. Verify a loading indicator appears briefly.
  7. Verify the new customer's payment methods are loaded and displayed.
  8. Test with a customer who has no saved payment methods - should show "Please select a payment method" placeholder
  9. Test with no customer selected - should show "Please select a customer first"

Note: You will see failing workflows, but they should not be related to the changes from this PR.


  • Run npm run changelog to add a changelog file, choose patch to leave it empty if the change is not significant. You can add multiple changelog files in one PR by running this command a few times.
  • Covered with tests (or have a good reason not to test in description ☝️)

@github-actions
Copy link
Contributor

github-actions bot commented Nov 20, 2025

Test the build

Option 1. Jetpack Beta

  • Install and activate Jetpack Beta.
  • Use this build by searching for PR number 11143 or branch name woopmnt-5508-shows-all-customers-saved-payment-methods-instead-of in your-test.site/wp-admin/admin.php?page=jetpack-beta&plugin=woocommerce-payments

Option 2. Jurassic Ninja - available for logged-in A12s

🚀 Launch a JN site with this branch 🚀

ℹ️ Install this Tampermonkey script to get more options.


Build info:

  • Latest commit: 79f24b3
  • Build time: 2025-12-02 21:06:03 UTC

Note: the build is updated when a new commit is pushed to this PR.

@github-actions
Copy link
Contributor

github-actions bot commented Nov 20, 2025

Size Change: +1.22 kB (0%)

Total Size: 878 kB

Filename Size Change
release/woocommerce-payments/dist/index.js 155 kB +107 B (0%)
release/woocommerce-payments/dist/subscription-edit-page.js 1.82 kB +1.11 kB (+158%) 🆘
ℹ️ View Unchanged
Filename Size
release/woocommerce-payments/assets/css/admin.css 1.45 kB
release/woocommerce-payments/assets/css/admin.rtl.css 1.45 kB
release/woocommerce-payments/assets/css/success.css 1.06 kB
release/woocommerce-payments/assets/css/success.rtl.css 1.06 kB
release/woocommerce-payments/dist/blocks-checkout-rtl.css 3.05 kB
release/woocommerce-payments/dist/blocks-checkout.css 3.05 kB
release/woocommerce-payments/dist/blocks-checkout.js 54.6 kB
release/woocommerce-payments/dist/cart-block-rtl.css 113 B
release/woocommerce-payments/dist/cart-block.css 112 B
release/woocommerce-payments/dist/cart-block.js 16.7 kB
release/woocommerce-payments/dist/cart.js 5.27 kB
release/woocommerce-payments/dist/checkout-rtl.css 1.13 kB
release/woocommerce-payments/dist/checkout.css 1.13 kB
release/woocommerce-payments/dist/checkout.js 34.6 kB
release/woocommerce-payments/dist/express-checkout-rtl.css 367 B
release/woocommerce-payments/dist/express-checkout.css 367 B
release/woocommerce-payments/dist/express-checkout.js 16.8 kB
release/woocommerce-payments/dist/frontend-tracks.js 833 B
release/woocommerce-payments/dist/index-rtl.css 21.2 kB
release/woocommerce-payments/dist/index.css 21.3 kB
release/woocommerce-payments/dist/multi-currency-analytics.js 1.08 kB
release/woocommerce-payments/dist/multi-currency-rtl.css 3.82 kB
release/woocommerce-payments/dist/multi-currency-switcher-block.js 18.2 kB
release/woocommerce-payments/dist/multi-currency.css 3.83 kB
release/woocommerce-payments/dist/multi-currency.js 24.7 kB
release/woocommerce-payments/dist/order-rtl.css 740 B
release/woocommerce-payments/dist/order.css 740 B
release/woocommerce-payments/dist/order.js 21.3 kB
release/woocommerce-payments/dist/plugins-page-rtl.css 484 B
release/woocommerce-payments/dist/plugins-page.css 484 B
release/woocommerce-payments/dist/plugins-page.js 2.64 kB
release/woocommerce-payments/dist/product-details-rtl.css 433 B
release/woocommerce-payments/dist/product-details.css 436 B
release/woocommerce-payments/dist/product-details.js 12.3 kB
release/woocommerce-payments/dist/settings-rtl.css 11.8 kB
release/woocommerce-payments/dist/settings.css 11.7 kB
release/woocommerce-payments/dist/settings.js 141 kB
release/woocommerce-payments/dist/subscription-product-onboarding-modal-rtl.css 527 B
release/woocommerce-payments/dist/subscription-product-onboarding-modal.css 527 B
release/woocommerce-payments/dist/subscription-product-onboarding-modal.js 1.98 kB
release/woocommerce-payments/dist/subscription-product-onboarding-toast.js 730 B
release/woocommerce-payments/dist/subscriptions-empty-state-rtl.css 120 B
release/woocommerce-payments/dist/subscriptions-empty-state.css 120 B
release/woocommerce-payments/dist/subscriptions-empty-state.js 1.9 kB
release/woocommerce-payments/dist/success.js 6.03 kB
release/woocommerce-payments/dist/tos-rtl.css 235 B
release/woocommerce-payments/dist/tos.css 235 B
release/woocommerce-payments/dist/tos.js 3 kB
release/woocommerce-payments/dist/woopay-direct-checkout.js 5.68 kB
release/woocommerce-payments/dist/woopay-express-button.js 22.8 kB
release/woocommerce-payments/dist/woopay-rtl.css 4.27 kB
release/woocommerce-payments/dist/woopay.css 4.25 kB
release/woocommerce-payments/dist/woopay.js 70.8 kB
release/woocommerce-payments/includes/subscriptions/assets/css/plugin-page.css 625 B
release/woocommerce-payments/includes/subscriptions/assets/js/plugin-page.js 814 B
release/woocommerce-payments/vendor/automattic/jetpack-assets/build/i18n-loader.js 2.46 kB
release/woocommerce-payments/vendor/automattic/jetpack-assets/build/jetpack-script-data.js 957 B
release/woocommerce-payments/vendor/automattic/jetpack-assets/src/js/i18n-loader.js 1.02 kB
release/woocommerce-payments/vendor/automattic/jetpack-assets/src/js/script-data.js 69 B
release/woocommerce-payments/vendor/automattic/jetpack-connection/babel.config.js 163 B
release/woocommerce-payments/vendor/automattic/jetpack-connection/dist/identity-crisis.css 2.47 kB
release/woocommerce-payments/vendor/automattic/jetpack-connection/dist/identity-crisis.js 14.3 kB
release/woocommerce-payments/vendor/automattic/jetpack-connection/dist/identity-crisis.rtl.css 2.47 kB
release/woocommerce-payments/vendor/automattic/jetpack-connection/dist/jetpack-connection.css 10.1 kB
release/woocommerce-payments/vendor/automattic/jetpack-connection/dist/jetpack-connection.js 29.7 kB
release/woocommerce-payments/vendor/automattic/jetpack-connection/dist/jetpack-connection.rtl.css 10.1 kB
release/woocommerce-payments/vendor/automattic/jetpack-connection/dist/jetpack-sso-admin-create-user.css 198 B
release/woocommerce-payments/vendor/automattic/jetpack-connection/dist/jetpack-sso-admin-create-user.js 280 B
release/woocommerce-payments/vendor/automattic/jetpack-connection/dist/jetpack-sso-admin-create-user.rtl.css 198 B
release/woocommerce-payments/vendor/automattic/jetpack-connection/dist/jetpack-sso-login.css 625 B
release/woocommerce-payments/vendor/automattic/jetpack-connection/dist/jetpack-sso-login.js 333 B
release/woocommerce-payments/vendor/automattic/jetpack-connection/dist/jetpack-sso-login.rtl.css 626 B
release/woocommerce-payments/vendor/automattic/jetpack-connection/dist/jetpack-sso-users.js 417 B
release/woocommerce-payments/vendor/automattic/jetpack-connection/dist/jetpack-users-connection.js 161 B
release/woocommerce-payments/vendor/automattic/jetpack-connection/dist/tracks-ajax.js 521 B
release/woocommerce-payments/vendor/automattic/jetpack-connection/dist/tracks-callables.js 585 B
release/woocommerce-payments/vendor/automattic/jetpack-connection/src/sso/jetpack-sso-admin-create-user.css 215 B
release/woocommerce-payments/vendor/automattic/jetpack-connection/src/sso/jetpack-sso-admin-create-user.js 521 B
release/woocommerce-payments/vendor/automattic/jetpack-connection/src/sso/jetpack-sso-login.css 721 B
release/woocommerce-payments/vendor/automattic/jetpack-connection/src/sso/jetpack-sso-login.js 412 B
release/woocommerce-payments/vendor/automattic/jetpack-connection/src/sso/jetpack-sso-users.js 625 B
release/woocommerce-payments/vendor/woocommerce/subscriptions-core/assets/css/about.css 1.04 kB
release/woocommerce-payments/vendor/woocommerce/subscriptions-core/assets/css/admin-empty-state.css 294 B
release/woocommerce-payments/vendor/woocommerce/subscriptions-core/assets/css/admin-order-statuses.css 408 B
release/woocommerce-payments/vendor/woocommerce/subscriptions-core/assets/css/admin.css 3.59 kB
release/woocommerce-payments/vendor/woocommerce/subscriptions-core/assets/css/checkout.css 301 B
release/woocommerce-payments/vendor/woocommerce/subscriptions-core/assets/css/modal.css 746 B
release/woocommerce-payments/vendor/woocommerce/subscriptions-core/assets/css/view-subscription.css 574 B
release/woocommerce-payments/vendor/woocommerce/subscriptions-core/assets/css/wcs-upgrade.css 414 B
release/woocommerce-payments/vendor/woocommerce/subscriptions-core/assets/js/admin/admin-pointers.js 543 B
release/woocommerce-payments/vendor/woocommerce/subscriptions-core/assets/js/admin/admin.js 9.4 kB
release/woocommerce-payments/vendor/woocommerce/subscriptions-core/assets/js/admin/jstz.js 6.78 kB
release/woocommerce-payments/vendor/woocommerce/subscriptions-core/assets/js/admin/jstz.min.js 3.84 kB
release/woocommerce-payments/vendor/woocommerce/subscriptions-core/assets/js/admin/meta-boxes-coupon.js 545 B
release/woocommerce-payments/vendor/woocommerce/subscriptions-core/assets/js/admin/meta-boxes-subscription.js 2.52 kB
release/woocommerce-payments/vendor/woocommerce/subscriptions-core/assets/js/admin/moment.js 22.2 kB
release/woocommerce-payments/vendor/woocommerce/subscriptions-core/assets/js/admin/moment.min.js 11.7 kB
release/woocommerce-payments/vendor/woocommerce/subscriptions-core/assets/js/admin/payment-method-restrictions.js 1.29 kB
release/woocommerce-payments/vendor/woocommerce/subscriptions-core/assets/js/admin/wcs-meta-boxes-order.js 507 B
release/woocommerce-payments/vendor/woocommerce/subscriptions-core/assets/js/frontend/payment-methods.js 358 B
release/woocommerce-payments/vendor/woocommerce/subscriptions-core/assets/js/frontend/single-product.js 428 B
release/woocommerce-payments/vendor/woocommerce/subscriptions-core/assets/js/frontend/view-subscription.js 1.38 kB
release/woocommerce-payments/vendor/woocommerce/subscriptions-core/assets/js/frontend/wcs-cart.js 782 B
release/woocommerce-payments/vendor/woocommerce/subscriptions-core/assets/js/modal.js 1.09 kB
release/woocommerce-payments/vendor/woocommerce/subscriptions-core/assets/js/wcs-upgrade.js 1.26 kB
release/woocommerce-payments/vendor/woocommerce/subscriptions-core/build/index.css 391 B
release/woocommerce-payments/vendor/woocommerce/subscriptions-core/build/index.js 3.04 kB

compressed-size-action

@RadoslavGeorgiev RadoslavGeorgiev requested a review from a team November 25, 2025 23:35
@RadoslavGeorgiev RadoslavGeorgiev marked this pull request as ready for review November 25, 2025 23:35
@frosso frosso self-assigned this Nov 26, 2025
Copy link
Contributor

@frosso frosso left a comment

Choose a reason for hiding this comment

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

The pub/sub pattern on the token cache is a really cool concept; however I don't think it's necessary.
There is a lot of "manual" rendering happening, despite the implementation of PaymentMethodSelect as a ReactJS component.
The current approach is a bit "mixed", and seems to defeat the purpose of using ReactJS. React should handle re-rendering automatically through state changes, instead of manual function calls.
For example, I see that the onChange prop is drilled down to the select, but at every change it manually re-renders the whole root. It's kinda like ReactJS works, but re-invented :)

Here's a possible alternative.
Make the cache a global, it can just be a key-value-pair of userId and their tokens:

const cache: Record< number, Token[] > = {};

On the setupPaymentSelector, you can substitute most of the rendering/listening to just creating the root and rendering the PaymentMethodManager - which is a helper component around the PaymentMethodSelect:

const setupPaymentSelector = ( element: HTMLSpanElement ): void => {
	const data = JSON.parse(
		element.getAttribute( 'data-wcpay-pm-selector' ) || '{}'
	) as WCPayPMSelectorData;

	const initialUserId = data.userId ?? 0;
	const initialValue = data.value ?? 0;

	// Initial population of cache with pre-loaded tokens
	if ( initialUserId && data.tokens ) {
		cache[ initialUserId ] = data.tokens;
	}

	// In older Subscriptions versions, there was just a simple input.
	const input = element.querySelector( 'select,input' ) as
		| HTMLSelectElement
		| HTMLInputElement
		| null;
	if ( ! input ) {
		return;
	}

	const root = createRoot( element );
	root.render(
		<PaymentMethodManager
			inputName={ input.name }
			initialUserId={ initialUserId }
			initialValue={ initialValue }
			config={ data }
		/>
	);
};

Then, the PaymentMethodManager can take care of listening to DOM changes on the customer_user input and fetching data, providing the data to the select:

const PaymentMethodManager = ( {
	inputName,
	initialUserId,
	initialValue,
	config,
}: {
	inputName: string;
	initialUserId: number;
	initialValue: number;
	config: WCPayPMSelectorData;
} ) => {
	const [ userId, setUserId ] = useState< number >( initialUserId );
	const [ selectedToken, setSelectedToken ] = useState< number >(
		initialValue
	);
	const [ isLoading, setIsLoading ] = useState< boolean >( false );
	const [ loadingError, setLoadingError ] = useState< string >( '' );

	// Handle customer select changes
	useEffect( () => {
		const updateSelectedToken = ( tokens: Token[] ) => {
			const selectedTokenId = tokens.find(
				( token ) => token.tokenId === selectedToken
			)?.tokenId;
			setSelectedToken( selectedTokenId || 0 );
		};
		return addCustomerSelectListener( async ( newUserId ) => {
			setUserId( newUserId );

			// Check if already in cache
			const tokens = cache[ newUserId ];

			// If already loaded, no need to fetch again.
			if ( tokens ) {
				updateSelectedToken( tokens );
				return;
			}

			setIsLoading( true );

			try {
				const response = await fetchUserTokens(
					newUserId,
					config.ajaxUrl,
					config.nonce
				);
				if ( undefined === response ) {
					throw new Error(
						__(
							'Failed to fetch user tokens. Please reload the page and try again.',
							'woocommerce-payments'
						)
					);
				}

				cache[ newUserId ] = response.tokens;
				updateSelectedToken( response.tokens );
				setLoadingError( '' );
			} catch ( error ) {
				setLoadingError(
					error instanceof Error
						? error.message
						: __( 'Unknown error', 'woocommerce-payments' )
				);
			}

			setIsLoading( false );
		} );
	}, [ config.ajaxUrl, config.nonce, selectedToken ] );

	if ( loadingError ) {
		return <strong>{ loadingError }</strong>;
	}

	if ( isLoading || ! cache[ userId ] ) {
		return <>{ __( 'Loading…', 'woocommerce-payments' ) }</>;
	}

	return (
		<select name={ inputName } defaultValue={ initialValue } key={ userId }>
			<option value={ 0 } key="select" disabled>
				{ __(
					'Please select a payment method',
					'woocommerce-payments'
				) }
			</option>
			{ cache[ userId ].map( ( token ) => (
				<option value={ token.tokenId } key={ token.tokenId }>
					{ token.displayName }
				</option>
			) ) }
		</select>
	);
};

What do you think?

Comment on lines 11 to 17
// TypeScript declaration for jQuery
declare const jQuery: (
selector: any
) => {
on: ( event: string, handler: () => void ) => void;
off: ( event: string, handler: () => void ) => void;
};
Copy link
Contributor

Choose a reason for hiding this comment

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

Is this needed? I don't see TS blowing up if it gets removed 🤷

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Removed in 4043b49. I thought it would be good to get some types there, but this is more than redundant.

Copy link
Contributor

Choose a reason for hiding this comment

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

It looks like it came back :D

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Claude seems to be stubborn, and this one went under my radar... Removed again in 59b6b22, everything seems to still work well.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

And it's back again! 🙈

The front-end works fine, but tests are always failing without the declaration. I can't figure out a way around it.

@frosso frosso removed their assignment Nov 26, 2025
  - Convert UserTokenCache class tests to pure function tests (startLoading,
    tokensLoaded, loadingFailed, hasEntry, getUserEntry, userHasToken,
    getDefaultTokenId)
  - Update PaymentMethodSelect tests to use new props (initialValue,
    initialUserId, initialCache, nonce, ajaxUrl)
  - Add immutability tests for cache functions
  - Add jQuery type declaration to index.tsx for TypeScript compilation
  - Adjust tests for new placeholder behavior (only shown when value === 0)
…instead-of' of github.com:Automattic/woocommerce-payments into woopmnt-5508-shows-all-customers-saved-payment-methods-instead-of
@RadoslavGeorgiev
Copy link
Contributor Author

@frosso I did go a bit overboard with the cache as a separate class. I went back and combined some of your suggestions with a bit of cleaner logic.

Previously, I had things in a state that was a tad too messy, and it was difficult for me to break things down into testable and graspable methods. The PaymentMethodSelect was just too long to be stable. Now I switched to local cache, simplified things a bit, and I think that the separation of methods is satisfying. WDYT?

For example, I see that the onChange prop is drilled down to the select

Actually, that was due to me not knowing about the defaultValue prop and linters driving me nuts. Your suggestion helped me see that! 🙇

Copy link
Contributor

@frosso frosso left a comment

Choose a reason for hiding this comment

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

It doesn't look like it's functional anymore - I think because of the condition on the select?

Comment on lines 11 to 17
// TypeScript declaration for jQuery
declare const jQuery: (
selector: any
) => {
on: ( event: string, handler: () => void ) => void;
off: ( event: string, handler: () => void ) => void;
};
Copy link
Contributor

Choose a reason for hiding this comment

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

It looks like it came back :D

Comment on lines 160 to 168
// If there is no value but a user, try to fall back to the default token.
useEffect( () => {
if ( 0 !== userId && value === 0 ) {
const defaultTokenId = getDefaultTokenId( cache, userId );
if ( value !== defaultTokenId ) {
setValue( value );
}
}
}, [ userId, value, cache ] );
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
// If there is no value but a user, try to fall back to the default token.
useEffect( () => {
if ( 0 !== userId && value === 0 ) {
const defaultTokenId = getDefaultTokenId( cache, userId );
if ( value !== defaultTokenId ) {
setValue( value );
}
}
}, [ userId, value, cache ] );
// If there is no value but a user, try to fall back to the default token.
useEffect( () => {
if ( 0 !== userId && value === 0 ) {
const defaultTokenId = getDefaultTokenId( cache, userId );
if ( value !== defaultTokenId ) {
setValue( defaultTokenId );
}
}
}, [ userId, value, cache ] );

Given the description above the useEffect, should this have been the intent?

Also, is cache necessary as part of the dependencies?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Given the description above the useEffect, should this have been the intent?

Absolutely. I'm not sure how I let this through. Maybe there was some TS error in the test file, and I was actually looking at an outdated front-end, as this was one of the later changes. 🤔

I updated it to use defaultTokenId.

Also, is cache necessary as part of the dependencies?

Once tokens finish loading for a new user, we need to use the cache update to trigger the effect. userId and value are not directly changed when tokens get loaded.

cachedData: CachedUserData,
userId: number
): CachedUserDataItem | undefined => {
return cachedData.find( ( userData ) => userData.userId === userId );
Copy link
Contributor

Choose a reason for hiding this comment

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

Have you considered implementing the cache as a key-value-pair (where the key is the user id) for faster O(1) access? It seems that we're always looking for an entry by its user id.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, that's a good idea. Done in 59b6b22.

const value = data.value ?? 0;

// Initial population.
const cache: CachedUserData = [];
Copy link
Contributor

Choose a reason for hiding this comment

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

It looks like each instance of a .wcpay-subscription-payment-method is getting its own cache. And this array is also re-created as a useState down in the PaymentMethodSelect.
What do you think about having the cache as a global key-value-pair? Or it can also be a global variable in the client/subscription-edit-page/user-token-cache.ts file, so you don't need to pass it as an argument each time you want to call one of the utility functions. And you don't need to prop-drill it.

Copy link
Contributor Author

@RadoslavGeorgiev RadoslavGeorgiev Dec 2, 2025

Choose a reason for hiding this comment

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

At this stage, I find it arbitrary. I decided to go away from the global cache (it was never used in more than one instance anyway), and managing the cache in state made it relatively easier to break things down.

If I move to a global cache as an object to avoid having to pass it as an argument, I'll still need to contain loading and failure state management within the select. I feel like that brings me back to a bigger and unwieldier method, and I don't see a significant upside anyway.

I'll try to see how it works, and how much/what will need to still be passed around, but I'm not promising anything yet.

Nevermind, done. It's so much cleaner. Thank you for being persisent here!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants