WooCommerce Code Reference

PickupLocationsRestController.php

Source code

<?php
declare( strict_types = 1 );

namespace Automattic\WooCommerce\Blocks\Shipping;

/**
 * REST controller for Local Pickup location settings.
 *
 * Exposes /wc/v3/pickup-locations so users with the manage_woocommerce
 * capability (e.g. Shop Managers) can save Local Pickup settings without
 * requiring the manage_options capability needed by /wp/v2/settings.
 *
 * @since 11.0.0
 */
class PickupLocationsRestController extends \WP_REST_Controller {

	/**
	 * REST API namespace.
	 *
	 * @var string
	 */
	protected $namespace = 'wc/v3';

	/**
	 * REST API resource base.
	 *
	 * @var string
	 */
	protected $rest_base = 'pickup-locations';

	/**
	 * Register routes.
	 *
	 * @return void
	 */
	public function register_routes() {
		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base,
			array(
				array(
					'methods'             => \WP_REST_Server::EDITABLE,
					'callback'            => array( $this, 'update_settings' ),
					'permission_callback' => array( $this, 'update_settings_permissions_check' ),
					'args'                => array(
						'pickup_location_settings' => array(
							'description' => __( 'Local pickup method settings.', 'woocommerce' ),
							'type'        => 'object',
						),
						'pickup_locations'         => array(
							'description' => __( 'List of local pickup locations.', 'woocommerce' ),
							'type'        => 'array',
						),
					),
				),
			)
		);
	}

	/**
	 * Check whether the current user can update pickup location settings.
	 *
	 * @param \WP_REST_Request<array<string, mixed>> $request Request object.
	 * @return true|\WP_Error
	 */
	public function update_settings_permissions_check( $request ) {
		if ( ! wc_rest_check_manager_permissions( 'settings', 'edit' ) ) {
			return new \WP_Error(
				'woocommerce_rest_cannot_edit',
				__( 'Sorry, you cannot edit this resource.', 'woocommerce' ),
				array( 'status' => rest_authorization_required_code() )
			);
		}

		return true;
	}

	/**
	 * Save pickup location settings and return the saved values.
	 *
	 * @param \WP_REST_Request<array<string, mixed>> $request Request object.
	 * @return \WP_REST_Response|\WP_Error
	 */
	public function update_settings( $request ) {
		$settings  = $request->get_param( 'pickup_location_settings' );
		$locations = $request->get_param( 'pickup_locations' );

		if ( is_array( $settings ) ) {
			$settings = $this->sanitize_pickup_location_settings( $settings );
			update_option( 'woocommerce_pickup_location_settings', $settings );
		}

		if ( is_array( $locations ) ) {
			$locations = $this->sanitize_pickup_locations( $locations );
			update_option( 'pickup_location_pickup_locations', $locations );
		}

		// The settings UI always saves both arrays together; a Tracks snapshot is
		// only meaningful with both present, so skip partial (non-UI) updates.
		if ( is_array( $settings ) && is_array( $locations ) ) {
			$this->record_save_event( $settings, $locations );
		}

		return rest_ensure_response(
			array(
				'pickup_location_settings' => $settings,
				'pickup_locations'         => $locations,
			)
		);
	}

	/**
	 * Record a Tracks event summarising a Local Pickup settings save.
	 *
	 * @param array $settings  Sanitized method settings.
	 * @param array $locations Sanitized list of pickup locations.
	 * @return void
	 */
	private function record_save_event( array $settings, array $locations ): void {
		$cost = $settings['cost'] ?? '';

		\WC_Tracks::record_event(
			'local_pickup_save_changes',
			array(
				'local_pickup_enabled'     => 'yes' === ( $settings['enabled'] ?? '' ),
				'title'                    => __( 'Pickup', 'woocommerce' ) === ( $settings['title'] ?? '' ),
				'price'                    => '' === $cost,
				'cost'                     => '' === $cost ? 0 : $cost,
				'taxes'                    => $settings['tax_status'] ?? '',
				'total_pickup_locations'   => count( $locations ),
				'pickup_locations_enabled' => count(
					array_filter(
						$locations,
						function ( $location ) {
							return ! empty( $location['enabled'] );
						}
					)
				),
			)
		);
	}

	/**
	 * Sanitize the pickup_location_settings payload before persisting.
	 *
	 * The WP REST dispatcher only auto-sanitizes top-level args, so nested
	 * object properties need to be cleaned here as defense in depth against
	 * stored HTML/JS in admin surfaces.
	 *
	 * @param array $settings Raw settings payload.
	 * @return array Sanitized settings payload.
	 */
	private function sanitize_pickup_location_settings( array $settings ): array {
		$sanitized = array();

		if ( isset( $settings['enabled'] ) ) {
			$sanitized['enabled'] = in_array( $settings['enabled'], array( 'yes', 'no' ), true )
				? $settings['enabled']
				: 'no';
		}

		if ( isset( $settings['title'] ) ) {
			$sanitized['title'] = sanitize_text_field( (string) $settings['title'] );
		}

		if ( isset( $settings['tax_status'] ) ) {
			$sanitized['tax_status'] = in_array( $settings['tax_status'], array( 'taxable', 'none' ), true )
				? $settings['tax_status']
				: 'none';
		}

		if ( isset( $settings['cost'] ) ) {
			// Cost may be a math expression (e.g. "5 + 1.50"), so strip HTML
			// without coercing to float — floatval would break formula syntax.
			$sanitized['cost'] = wp_strip_all_tags( (string) $settings['cost'] );
		}

		return $sanitized;
	}

	/**
	 * Sanitize the pickup_locations payload before persisting.
	 *
	 * @param array $locations Raw list of pickup locations.
	 * @return array Sanitized list of pickup locations.
	 */
	private function sanitize_pickup_locations( array $locations ): array {
		$sanitized = array();

		foreach ( $locations as $location ) {
			if ( ! is_array( $location ) ) {
				continue;
			}

			$name = isset( $location['name'] ) ? sanitize_text_field( (string) $location['name'] ) : '';

			// A pickup location with no name is unusable, and incomplete entries
			// would later trigger undefined-index notices in
			// ShippingController::hydrate_client_settings(), which reads these
			// fields unconditionally. Skip nameless entries and always emit every
			// key with a safe default for the ones we keep.
			if ( '' === $name ) {
				continue;
			}

			// Always emit every address key with a safe default. Downstream
			// readers such as ShippingController::filter_taxable_address() access
			// state/postcode/city unconditionally once country is set, so a
			// partial address (e.g. only country) would trigger undefined-index
			// notices.
			$address = array();
			if ( isset( $location['address'] ) && is_array( $location['address'] ) ) {
				foreach ( array( 'address_1', 'city', 'state', 'postcode', 'country' ) as $field ) {
					$address[ $field ] = isset( $location['address'][ $field ] )
						? sanitize_text_field( (string) $location['address'][ $field ] )
						: '';
				}
			}

			$enabled = isset( $location['enabled'] ) ? rest_sanitize_boolean( (string) $location['enabled'] ) : false;

			$sanitized[] = array(
				'name'    => $name,
				'address' => $address,
				// Details may contain limited HTML. Match the rendering side
				// in ShippingController::show_local_pickup_details() which uses
				// wp_kses_post().
				'details' => isset( $location['details'] ) ? wp_kses_post( (string) $location['details'] ) : '',
				'enabled' => $enabled,
			);
		}

		return $sanitized;
	}
}