declare( strict_types=1 );
namespace Automattic\WooCommerce\Internal\Admin\Settings;
use Automattic\WooCommerce\Internal\RestApiControllerBase;
* Controller for the REST endpoints to service the Payments settings page.
class PaymentsRestController extends RestApiControllerBase {
* The root namespace for the JSON REST API endpoints.
protected string $route_namespace = 'wc-admin';
protected string $rest_base = 'settings/payments';
* The payments settings page service.
private Payments $payments;
* Get the WooCommerce REST API namespace for the class.
protected function get_rest_api_namespace(): string {
return 'wc-admin-settings-payments';
* Register the REST API endpoints handled by this controller.
* @param bool $override Whether to override the existing routes. Useful for testing.
public function register_routes( bool $override = false ) {
'/' . $this->rest_base . '/country',
'methods' => \WP_REST_Server::EDITABLE,
'callback' => fn( $request ) => $this->run( $request, 'set_country' ),
'validation_callback' => 'rest_validate_request_arg',
'permission_callback' => fn( $request ) => $this->check_permissions( $request ),
'description' => esc_html__( 'The ISO3166 alpha-2 country code to save for the current user.', 'woocommerce' ),
'pattern' => '[a-zA-Z]{2}', // Two alpha characters.
'validate_callback' => fn( $value, $request ) => $this->check_location_arg( $value, $request ),
'/' . $this->rest_base . '/providers',
'methods' => \WP_REST_Server::CREATABLE,
'callback' => fn( $request ) => $this->run( $request, 'get_providers' ),
'validation_callback' => 'rest_validate_request_arg',
'permission_callback' => fn( $request ) => $this->check_permissions( $request ),
'description' => esc_html__( 'ISO3166 alpha-2 country code. Defaults to WooCommerce\'s base location country.', 'woocommerce' ),
'pattern' => '[a-zA-Z]{2}', // Two alpha characters.
'validate_callback' => fn( $value, $request ) => $this->check_location_arg( $value, $request ),
'schema' => fn() => $this->get_schema_for_get_payment_providers(),
'/' . $this->rest_base . '/providers/order',
'methods' => \WP_REST_Server::EDITABLE,
'callback' => fn( $request ) => $this->run( $request, 'update_providers_order' ),
'permission_callback' => fn( $request ) => $this->check_permissions( $request ),
'description' => esc_html__( 'A map of provider ID to integer values representing the sort order.', 'woocommerce' ),
'validate_callback' => fn( $value ) => $this->check_providers_order_map_arg( $value ),
'sanitize_callback' => fn( $value ) => $this->sanitize_providers_order_arg( $value ),
'/' . $this->rest_base . '/suggestion/(?P<id>[\w\d\-]+)/attach',
'methods' => \WP_REST_Server::EDITABLE,
'callback' => fn( $request ) => $this->run( $request, 'attach_payment_extension_suggestion' ),
'permission_callback' => fn( $request ) => $this->check_permissions( $request ),
'/' . $this->rest_base . '/suggestion/(?P<id>[\w\d\-]+)/hide',
'methods' => \WP_REST_Server::EDITABLE,
'callback' => fn( $request ) => $this->run( $request, 'hide_payment_extension_suggestion' ),
'permission_callback' => fn( $request ) => $this->check_permissions( $request ),
'/' . $this->rest_base . '/suggestion/(?P<suggestion_id>[\w\d\-]+)/incentive/(?P<incentive_id>[\w\d\-]+)/dismiss',
'methods' => \WP_REST_Server::EDITABLE,
'callback' => fn( $request ) => $this->run( $request, 'dismiss_payment_extension_suggestion_incentive' ),
'permission_callback' => fn( $request ) => $this->check_permissions( $request ),
'description' => esc_html__( 'The context ID for which to dismiss the incentive. If not provided, will dismiss the incentive for all contexts.', 'woocommerce' ),
'sanitize_callback' => 'sanitize_key',
'description' => esc_html__( 'If true, the incentive dismissal will be ignored by tracking.', 'woocommerce' ),
'sanitize_callback' => 'rest_sanitize_boolean',
* Initialize the class instance.
* @param Payments $payments The payments settings page service.
final public function init( Payments $payments ): void {
$this->payments = $payments;
* Get the payment providers for the given location.
* @param WP_REST_Request $request The request object.
* @return WP_Error|WP_REST_Response
protected function get_providers( WP_REST_Request $request ) {
$location = $request->get_param( 'location' );
if ( empty( $location ) ) {
// Fall back to the providers country if no location is provided.
$location = $this->payments->get_country();
$providers = $this->payments->get_payment_providers( $location );
} catch ( Exception $e ) {
return new WP_Error( 'woocommerce_rest_payment_providers_error', $e->getMessage(), array( 'status' => 500 ) );
$suggestions = $this->get_extension_suggestions( $location );
} catch ( Exception $e ) {
return new WP_Error( 'woocommerce_rest_payment_providers_error', $e->getMessage(), array( 'status' => 500 ) );
// Separate the offline PMs from the main providers list.
$offline_payment_providers = array_values(
fn( $provider ) => PaymentsProviders::TYPE_OFFLINE_PM === $provider['_type']
$providers = array_values(
fn( $provider ) => PaymentsProviders::TYPE_OFFLINE_PM !== $provider['_type']
'providers' => $providers,
'offline_payment_methods' => $offline_payment_providers,
'suggestions' => $suggestions,
'suggestion_categories' => $this->payments->get_payment_extension_suggestion_categories(),
return rest_ensure_response( $this->prepare_payment_providers_response( $response ) );
* Set the country for the payment providers.
* @param WP_REST_Request $request The request object.
* @return WP_Error|WP_REST_Response
protected function set_country( WP_REST_Request $request ) {
$location = $request->get_param( 'location' );
$result = $this->payments->set_country( $location );
return rest_ensure_response( array( 'success' => $result ) );
* Update the payment providers order.
* @param WP_REST_Request $request The request object.
* @return WP_Error|WP_REST_Response
protected function update_providers_order( WP_REST_Request $request ) {
$order_map = $request->get_param( 'order_map' );
$result = $this->payments->update_payment_providers_order_map( $order_map );
return rest_ensure_response( array( 'success' => $result ) );
* Attach a payment extension suggestion.
* @param WP_REST_Request $request The request object.
* @return WP_Error|WP_REST_Response
protected function attach_payment_extension_suggestion( WP_REST_Request $request ) {
$suggestion_id = $request->get_param( 'id' );
$result = $this->payments->attach_payment_extension_suggestion( $suggestion_id );
} catch ( Exception $e ) {
return new WP_Error( 'woocommerce_rest_payment_extension_suggestion_error', $e->getMessage(), array( 'status' => 400 ) );
return rest_ensure_response( array( 'success' => $result ) );
* Hide a payment extension suggestion.
* @param WP_REST_Request $request The request object.
* @return WP_Error|WP_REST_Response
protected function hide_payment_extension_suggestion( WP_REST_Request $request ) {
$suggestion_id = $request->get_param( 'id' );
$result = $this->payments->hide_payment_extension_suggestion( $suggestion_id );
} catch ( Exception $e ) {
return new WP_Error( 'woocommerce_rest_payment_extension_suggestion_error', $e->getMessage(), array( 'status' => 400 ) );
return rest_ensure_response( array( 'success' => $result ) );
* Dismiss a payment extension suggestion incentive.
* @param WP_REST_Request $request The request object.
* @return WP_Error|WP_REST_Response
protected function dismiss_payment_extension_suggestion_incentive( WP_REST_Request $request ) {
$suggestion_id = $request->get_param( 'suggestion_id' );
$incentive_id = $request->get_param( 'incentive_id' );
$context = $request->get_param( 'context' ) ?? 'all';
$do_not_track = $request->get_param( 'do_not_track' ) ?? false;
$result = $this->payments->dismiss_extension_suggestion_incentive( $suggestion_id, $incentive_id, $context, $do_not_track );
} catch ( Exception $e ) {
return new WP_Error( 'woocommerce_rest_payment_extension_suggestion_incentive_error', $e->getMessage(), array( 'status' => 400 ) );
return rest_ensure_response( array( 'success' => $result ) );
* Get the payment extension suggestions (other) for the given location.
* @param string $location The location for which the suggestions are being fetched.
* @return array[] The payment extension suggestions for the given location,
* excluding the ones part of the main providers list.
* @throws Exception If there are malformed or invalid suggestions.
private function get_extension_suggestions( string $location ): array {
// If the requesting user can't install plugins, we don't suggest any extensions.
if ( ! current_user_can( 'install_plugins' ) ) {
$suggestions = $this->payments->get_payment_extension_suggestions( $location );
return $suggestions['other'] ?? array();
* General permissions check for payments settings REST API endpoint.
* @param WP_REST_Request $request The request for which the permission is checked.
* @return bool|WP_Error True if the current user has the capability, otherwise an "Unauthorized" error or False if no error is available for the request method.
private function check_permissions( WP_REST_Request $request ) {
if ( 'POST' === $request->get_method() ) {
} elseif ( 'DELETE' === $request->get_method() ) {
if ( wc_rest_check_manager_permissions( 'payment_gateways', $context ) ) {
$error_information = $this->get_authentication_error_by_method( $request->get_method() );
if ( is_null( $error_information ) ) {
$error_information['code'],
$error_information['message'],
array( 'status' => rest_authorization_required_code() )
* Validate the location argument.
* @param mixed $value Value of the argument.
* @param WP_REST_Request $request The current request object.
* @return WP_Error|true True if the location argument is valid, otherwise a WP_Error object.
private function check_location_arg( $value, WP_REST_Request $request ) {
// If the 'location' argument is not a string return an error.
if ( ! is_string( $value ) ) {
return new WP_Error( 'rest_invalid_param', esc_html__( 'The location argument must be a string.', 'woocommerce' ), array( 'status' => 400 ) );
// Get the registered attributes for this endpoint request.
$attributes = $request->get_attributes();
// Grab the location param schema.
$args = $attributes['args']['location'];
// If the location param doesn't match the regex pattern then we should return an error as well.
if ( ! preg_match( '/^' . $args['pattern'] . '$/', $value ) ) {
return new WP_Error( 'rest_invalid_param', esc_html__( 'The location argument must be a valid ISO3166 alpha-2 country code.', 'woocommerce' ), array( 'status' => 400 ) );
* Validate the providers order map argument.
* @param mixed $value Value of the argument.
* @return WP_Error|true True if the providers order map argument is valid, otherwise a WP_Error object.
private function check_providers_order_map_arg( $value ) {
if ( ! is_array( $value ) ) {
return new WP_Error( 'rest_invalid_param', esc_html__( 'The ordering argument must be an object.', 'woocommerce' ), array( 'status' => 400 ) );
foreach ( $value as $provider_id => $order ) {
if ( ! is_string( $provider_id ) || ! is_numeric( $order ) ) {
return new WP_Error( 'rest_invalid_param', esc_html__( 'The ordering argument must be an object with provider IDs as keys and numeric values as values.', 'woocommerce' ), array( 'status' => 400 ) );
if ( $this->sanitize_provider_id( $provider_id ) !== $provider_id ) {
return new WP_Error( 'rest_invalid_param', esc_html__( 'The provider ID must be a string with only ASCII letters, digits, underscores, and dashes.', 'woocommerce' ), array( 'status' => 400 ) );
if ( false === filter_var( $order, FILTER_VALIDATE_INT ) ) {
return new WP_Error( 'rest_invalid_param', esc_html__( 'The order value must be an integer.', 'woocommerce' ), array( 'status' => 400 ) );
* Sanitize the providers ordering argument.
* @param array $value Value of the argument.
private function sanitize_providers_order_arg( array $value ): array {
// Sanitize the ordering object to ensure that the order values are integers and the provider IDs are safe strings.
foreach ( $value as $provider_id => $order ) {
$id = $this->sanitize_provider_id( $provider_id );
$value[ $id ] = intval( $order );
* Sanitize a provider ID.
* This method ensures that the provider ID is a safe string by removing any unwanted characters.
* It strips all HTML tags, removes accents, percent-encoded characters, and HTML entities,
* and allows only lowercase and uppercase letters, digits, underscores, and dashes.
* @param string $provider_id The provider ID to sanitize.
* @return string The sanitized provider ID.
private function sanitize_provider_id( string $provider_id ): string {
$provider_id = wp_strip_all_tags( $provider_id );
$provider_id = remove_accents( $provider_id );
// Remove percent-encoded characters.
$provider_id = preg_replace( '|%([a-fA-F0-9][a-fA-F0-9])|', '', $provider_id );
$provider_id = preg_replace( '/&.+?;/', '', $provider_id );
// Only lowercase and uppercase ASCII letters, digits, underscores, and dashes are allowed.
$provider_id = preg_replace( '|[^a-z0-9_\-]|i', '', $provider_id );
* Prepare the response for the GET payment providers request.
* @param array $response The response to prepare.
* @return array The prepared response.
private function prepare_payment_providers_response( array $response ): array {
$response = $this->prepare_payment_providers_response_recursive( $response, $this->get_schema_for_get_payment_providers() );
$response['providers'] = $this->add_provider_links( $response['providers'] );
$response['suggestions'] = $this->add_suggestion_links( $response['suggestions'] );
* Recursively prepare the response items for the GET payment providers request.
* @param mixed $response_item The response item to prepare.
* @param array $schema The schema to use for preparing the response.
* @return mixed The prepared response item.
private function prepare_payment_providers_response_recursive( $response_item, array $schema ) {
if ( is_null( $response_item ) ||
! array_key_exists( 'properties', $schema ) ||
! is_array( $schema['properties'] ) ) {