WooCommerce Code Reference

class-wc-rest-paypal-standard-controller.php

Source code

<?php
/**
 *
 * REST API PayPal Standard controller
 *
 * Handles requests to the /paypal-standard endpoint.
 *
 * @package WooCommerce\RestApi
 * @since   10.3.0
 */

declare(strict_types=1);

defined( 'ABSPATH' ) || exit;

if ( ! class_exists( 'WC_Gateway_Paypal_Helper' ) ) {
	require_once WC_ABSPATH . 'includes/gateways/paypal/includes/class-wc-gateway-paypal-helper.php';
}

if ( ! class_exists( 'WC_Gateway_Paypal' ) ) {
	require_once WC_ABSPATH . 'includes/gateways/paypal/class-wc-gateway-paypal.php';
}

if ( ! class_exists( 'WC_Gateway_Paypal_Request' ) ) {
	require_once WC_ABSPATH . 'includes/gateways/paypal/includes/class-wc-gateway-paypal-request.php';
}

/**
 * REST API PayPal Standard controller class.
 *
 * @package WooCommerce\RestApi
 * @extends WC_REST_Controller
 */
class WC_REST_Paypal_Standard_Controller extends WC_REST_Controller {

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

	/**
	 * Route base.
	 *
	 * @var string
	 */
	protected $rest_base = 'paypal-standard';

	/**
	 * Register the routes for PayPal Standard REST API requests.
	 *
	 * @return void
	 */
	public function register_routes() {
		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/update-shipping',
			array(
				'methods'             => WP_REST_Server::CREATABLE,
				'callback'            => array( $this, 'process_shipping_callback' ),
				'permission_callback' => '__return_true',
			)
		);
	}

	/**
	 * Callback for when the customer updates their shipping details in PayPal.
	 * https://developer.paypal.com/docs/checkout/standard/customize/shipping-module/#server-side-shipping-callbacks
	 *
	 * @param WP_REST_Request $request The request object.
	 * @return WP_REST_Response The response object.
	 */
	public function process_shipping_callback( WP_REST_Request $request ) {
		$paypal_order_id  = $request->get_param( 'id' );
		$shipping_address = $request->get_param( 'shipping_address' );
		$shipping_option  = $request->get_param( 'shipping_option' );
		$purchase_units   = $request->get_param( 'purchase_units' );

		// Note: shipping_option may or may not be present.
		if ( empty( $paypal_order_id ) || empty( $shipping_address ) || empty( $purchase_units ) ) {
			$response = $this->get_update_shipping_error_response();
			return new WP_REST_Response( $response, 422 );
		}

		// Get the WC order.
		$order = WC_Gateway_Paypal_Helper::get_wc_order_from_paypal_custom_id( $purchase_units[0]['custom_id'] ?? '{}' );
		if ( ! $order ) {
			$custom_id = isset( $purchase_units[0]['custom_id'] ) ? $purchase_units[0]['custom_id'] : '{}';
			WC_Gateway_Paypal::log( 'Unable to determine WooCommerce order from PayPal custom ID: ' . $custom_id );
			$response = $this->get_update_shipping_error_response();
			return new WP_REST_Response( $response, 422 );
		}

		// Compare PayPal order IDs.
		$paypal_order_id_from_order_meta = $order->get_meta( '_paypal_order_id', true );
		if ( $paypal_order_id !== $paypal_order_id_from_order_meta ) {
			WC_Gateway_Paypal::log(
				'PayPal order ID mismatch. Order ID: ' . $order->get_id() .
				'. PayPal order ID (request): ' . $paypal_order_id .
				'. PayPal order ID (order meta): ' . $paypal_order_id_from_order_meta
			);
			$response = $this->get_update_shipping_error_response();
			return new WP_REST_Response( $response, 422 );
		}

		if ( ! WC()->session ) {
			WC()->session = new WC_Session_Handler();
		}
		WC()->session->init();

		// Update the shipping address before we do anything else.
		$this->update_order_shipping_address( $order, $shipping_address );

		// We need to rebuild the cart from the order, as we do not have session cart data
		// for REST API requests.
		$this->rebuild_cart_from_order( $order );

		// Get the new shipping options, which depend on the new shipping address.
		$updated_shipping_options = $this->get_updated_shipping_options( $order, $shipping_option );
		if ( empty( $updated_shipping_options ) ) {
			WC_Gateway_Paypal::log(
				'No shipping options found for address. Order ID: ' . $order->get_id() .
				'. Address: ' . wp_json_encode( $shipping_address )
			);
			$response = $this->get_update_shipping_error_response();
			return new WP_REST_Response( $response, 422 );
		}

		// Set the chosen shipping method in the session.
		if ( ! empty( $shipping_option ) ) {
			WC()->session->set( 'chosen_shipping_methods', array( $shipping_option['id'] ) );
		}

		// Recompute fees after everything has been updated.
		$this->recompute_fees( $order );

		$paypal_request = new WC_Gateway_Paypal_Request( WC_Gateway_Paypal::get_instance() );
		$updated_amount = $paypal_request->get_paypal_order_purchase_unit_amount( $order );

		$response = array(
			'id'             => $paypal_order_id,
			'purchase_units' => array(
				array(
					'reference_id'     => isset( $purchase_units[0]['reference_id'] ) ? $purchase_units[0]['reference_id'] : '', // No change.
					'amount'           => $updated_amount,
					'shipping_options' => $updated_shipping_options,
				),
			),
		);

		return new WP_REST_Response( $response, 200 );
	}

	/**
	 * Rebuild the session cart.
	 *
	 * @param WC_Order $order The order object.
	 * @return void
	 */
	private function rebuild_cart_from_order( $order ) {
		wc_load_cart();
		WC()->cart->empty_cart();
		foreach ( $order->get_items() as $item ) {
			$product_id = $item->get_product_id();
			$product    = $item->get_product();

			if ( ! $product ) {
				continue;
			}

			if ( $product->is_type( 'variation' ) ) {
				$variation_id = $item->get_variation_id();
				WC()->cart->add_to_cart( $product_id, $item->get_quantity(), $variation_id );
				continue;
			}

			WC()->cart->add_to_cart( $product_id, $item->get_quantity() );
		}

		// Re-apply coupons present on the order so discounts/totals are accurate.
		if ( method_exists( $order, 'get_coupon_codes' ) ) {
			foreach ( (array) $order->get_coupon_codes() as $code ) {
				if ( $code ) {
					WC()->cart->apply_coupon( $code );
				}
			}
		}

		// Re-apply shipping methods present on the order so totals are accurate.
		$order_shipping_rate_id = $this->get_order_shipping_rate_id( $order );
		if ( ! empty( $order_shipping_rate_id ) ) {
			WC()->session->set( 'chosen_shipping_methods', array( $order_shipping_rate_id ) );
		}
	}

	/**
	 * Recompute the fees for the order.
	 *
	 * @param WC_Order $order The order object.
	 * @return void
	 */
	private function recompute_fees( $order ) {
		WC()->cart->calculate_fees();
		WC()->cart->calculate_shipping();
		WC()->cart->calculate_totals();

		$order->remove_order_items();
		WC()->checkout->set_data_from_cart( $order );
		$order->save();
	}

	/**
	 * Update the WooCommerce order with the new shipping address.
	 *
	 * @param WC_Order $order The order object.
	 * @param array    $shipping_address The shipping address.
	 * @return void
	 */
	private function update_order_shipping_address( $order, $shipping_address ) {
		$country  = $shipping_address['country_code'] ?? '';
		$postcode = $shipping_address['postal_code'] ?? '';
		$state    = $shipping_address['admin_area_1'] ?? '';
		$city     = $shipping_address['admin_area_2'] ?? '';

		$order->set_shipping_country( $country );
		$order->set_shipping_postcode( $postcode );
		$order->set_shipping_state( $state );
		$order->set_shipping_city( $city );

		// We do not have the address line 1 and 2 -- we are clearing them here to avoid
		// showing stale data. The final address will be updated when the
		// customer approves the order, via 'woocommerce_thankyou_paypal' hook.
		$order->set_shipping_address_1( '' );
		$order->set_shipping_address_2( '' );
		$order->save();

		// Get customer from order and update their shipping location.
		$customer = new WC_Customer();
		$customer->set_location( $country, $state, $postcode, $city );
		$customer->set_shipping_location( $country, $state, $postcode, $city );
		$customer->set_calculated_shipping( true );
		WC()->customer = $customer;
	}

	/**
	 * Get the shipping options for the order.
	 *
	 * @param WC_Order $order The order object.
	 * @param array    $selected_shipping_option The selected shipping option.
	 * @return array The shipping options.
	 */
	private function get_updated_shipping_options( $order, $selected_shipping_option ) {
		WC()->cart->calculate_shipping();
		$packages               = WC()->shipping()->get_packages();
		$order_shipping_rate_id = $this->get_order_shipping_rate_id( $order );

		$has_selected_shipping_option = false;
		$options                      = array();
		foreach ( $packages as $package ) {
			$rates = $package['rates'] ?? array();
			foreach ( $rates as $rate ) {
				if ( ! $rate instanceof \WC_Shipping_Rate ) {
					continue;
				}

				$shipping_option_id = $rate->get_id();
				// If a selected shipping option is sent in the request, check if it matches the shipping option id.
				// Otherwise, if the order has a shipping method, check if the rate id matches the shipping option id.
				if ( isset( $selected_shipping_option['id'] ) ) {
					$is_selected = $shipping_option_id === $selected_shipping_option['id'];
				} else {
					$is_selected = $shipping_option_id === $order_shipping_rate_id;
				}

				if ( $is_selected ) {
					$has_selected_shipping_option = true;
				}
				$options[] = array(
					'id'       => $shipping_option_id,
					'type'     => 'SHIPPING',
					'amount'   => array(
						'currency_code' => $order->get_currency(),
						'value'         => wc_format_decimal( (float) $rate->get_cost(), wc_get_price_decimals() ),
					),
					'label'    => $rate->get_label(),
					'selected' => $is_selected,
				);
			}
		}

		// Set first option as selected if no option is selected.
		if ( ! empty( $options ) && ! $has_selected_shipping_option ) {
			$options[0]['selected'] = true;
		}

		return $options;
	}

	/**
	 * Get the shipping rate id from the order.
	 *
	 * @param WC_Order $order The order object.
	 * @return string The shipping rate id.
	 */
	private function get_order_shipping_rate_id( $order ) {
		$order_shipping_item = current( $order->get_items( 'shipping' ) ) ?? null;

		if ( $order_shipping_item ) {
			$method_id   = $order_shipping_item->get_method_id();
			$instance_id = $order_shipping_item->get_instance_id();
			$rate_id     = ( '' === $instance_id || null === $instance_id ) ? $method_id : "{$method_id}:{$instance_id}";

			return $rate_id;
		}

		return '';
	}

	/**
	 * Get the error response for the update shipping request.
	 *
	 * @param string $issue The issue with the shipping address.
	 * @return array The error response.
	 */
	private function get_update_shipping_error_response( $issue = 'ADDRESS_ERROR' ) {
		// See https://developer.paypal.com/docs/checkout/standard/customize/shipping-module/#merchant-decline-response.
		return array(
			'name'    => 'UNPROCESSABLE_ENTITY',
			'details' => array(
				array( 'issue' => $issue ),
			),
		);
	}
}