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 ), ), ); } }