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: 89ca914
  • Build time: 2025-11-25 23:29:01 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.27 kB (0%)

Total Size: 877 kB

Filename Size Change
release/woocommerce-payments/dist/subscription-edit-page.js 1.98 kB +1.27 kB (+181%) 🆘
ℹ️ 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.9 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.2 kB
release/woocommerce-payments/dist/index.js 153 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.8 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?

userId = newUserId;
render();

// Looaded, loading, or errored out, we do not need to load anything.
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
// Looaded, loading, or errored out, we do not need to load anything.
// Loaded, loading, or errored out, we do not need to load anything.

const internalCallback = () =>
callback( parseInt( customerUserSelect.value, 10 ) || 0 );

// Add the listner with the right technique, as select2 does not emit <select> events.
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
// Add the listner with the right technique, as select2 does not emit <select> events.
// Add the listener with the right technique, as select2 does not emit <select> events.

Comment on lines +16 to +19
export interface Token {
tokenId: number;
displayName: string;
}
Copy link
Contributor

Choose a reason for hiding this comment

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

(minor) the Token type should also have a isDefault boolean, since I see it in render_custom_payment_meta_input. Although it doesn't seem to be used by the JS.

Comment on lines +6 to +11
/**
* Props for the WCPayPaymentMethodElement component
*/
export interface PaymentMethodElementProps {
element: HTMLSpanElement;
}
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
/**
* Props for the WCPayPaymentMethodElement component
*/
export interface PaymentMethodElementProps {
element: HTMLSpanElement;
}

This doesn't seem to be used anywhere

Comment on lines +41 to +43
const customerUserSelect = document.getElementById(
'customer_user'
) as HTMLSelectElement | null;
Copy link
Contributor

Choose a reason for hiding this comment

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

(minor) rather than using as, we can check the type returned by the getElementById. This way we don't need to force the casting of the type.

Suggested change
const customerUserSelect = document.getElementById(
'customer_user'
) as HTMLSelectElement | null;
const element = document.getElementById( 'customer_user' );
const customerUserSelect =
element instanceof HTMLSelectElement ? element : null;

Comment on lines +195 to +198
const input = element.querySelector( 'select,input' ) as
| HTMLSelectElement
| HTMLInputElement
| null;
Copy link
Contributor

Choose a reason for hiding this comment

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

(minor) similar to above - we can check the returned type, instead of forcing it.

Suggested change
const input = element.querySelector( 'select,input' ) as
| HTMLSelectElement
| HTMLInputElement
| null;
const input = element.querySelector( 'select,input' );
if ( ! input ) {
return;
}
if (
! (
input instanceof HTMLSelectElement ||
input instanceof HTMLInputElement
)
) {
return;
}

Comment on lines +273 to +277
document
.querySelectorAll( '.wcpay-subscription-payment-method' )
.forEach( ( element ) => {
setupPaymentSelector( element as HTMLSpanElement, cache );
} );
Copy link
Contributor

Choose a reason for hiding this comment

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

(minor) to remove the forced casting

Suggested change
document
.querySelectorAll( '.wcpay-subscription-payment-method' )
.forEach( ( element ) => {
setupPaymentSelector( element as HTMLSpanElement, cache );
} );
document
.querySelectorAll< HTMLSpanElement >(
'.wcpay-subscription-payment-method'
)
.forEach( ( element ) => {
setupPaymentSelector( element, cache );
} );


if ( ! customerUserSelect ) {
return (): void => {
// No-op cleanup function when element is not found
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
// No-op cleanup function when element is not found
// No-op cleanup function when an element is not found

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 🤷

@frosso frosso removed their assignment Nov 26, 2025
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