e when the Store API checkout draft order is first materialised. * * Use this hook for first-touch logic that should only run when the draft * order is initially created (e.g. analytics, abandoned-cart trackers). As * of WooCommerce 10.8.0 the Store API defers draft order creation to * place-order time, so this action fires once at POST rather than on the * first PATCH. * * @since 10.8.0 * * @param \WC_Order $order Order object. */ do_action( 'woocommerce_store_api_checkout_order_created', $this->order ); } else { $this->order_controller->update_order_from_cart( $this->order, true ); wc_log_order_step( '[Store API #4::create_or_update_draft_order] Updated order from cart', array( 'order_object' => $this->order ) ); } wc_do_deprecated_action( '__experimental_woocommerce_blocks_checkout_update_order_meta', array( $this->order, ), '6.3.0', 'woocommerce_store_api_checkout_update_order_meta', 'This action was deprecated in WooCommerce Blocks version 6.3.0. Please use woocommerce_store_api_checkout_update_order_meta instead.' ); wc_do_deprecated_action( 'woocommerce_blocks_checkout_update_order_meta', array( $this->order, ), '7.2.0', 'woocommerce_store_api_checkout_update_order_meta', 'This action was deprecated in WooCommerce Blocks version 7.2.0. Please use woocommerce_store_api_checkout_update_order_meta instead.' ); /** * Fires when the Checkout Block/Store API updates an order's meta data. * * This hook gives extensions the chance to add or update meta data on the $order. * Throwing an exception from a callback attached to this action will make the Checkout Block render in a warning state, effectively preventing checkout. * * This is similar to existing core hook woocommerce_checkout_update_order_meta. * We're using a new action: * - To keep the interface focused (only pass $order, not passing request data). * - This also explicitly indicates these orders are from checkout block/StoreAPI. * * @since 7.2.0 * * @see https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/3686 * * @param \WC_Order $order Order object. */ do_action( 'woocommerce_store_api_checkout_update_order_meta', $this->order ); // Confirm order is valid before proceeding further. if ( ! $this->order instanceof \WC_Order ) { throw new RouteException( 'woocommerce_rest_checkout_missing_order', esc_html__( 'Unable to create order', 'woocommerce' ), 500 ); } // Store order ID to session. $this->set_draft_order_id( $this->order->get_id() ); wc_log_order_step( '[Store API #4::create_or_update_draft_order] Set order draft id', array( 'order_object' => $this->order ) ); } /** * Updates a customer address field. * * @param \WC_Customer $customer The customer to update. * @param string $key The key of the field to update. * @param mixed $value The value to update the field to. * @param string $address_type The type of address to update (billing|shipping). */ private function update_customer_address_field( $customer, $key, $value, $address_type ) { $callback = "set_{$address_type}_{$key}"; if ( is_callable( [ $customer, $callback ] ) ) { $customer->$callback( $value ); return; } if ( $this->additional_fields_controller->is_field( $key ) ) { $this->additional_fields_controller->persist_field_for_customer( $key, $value, $customer, $address_type ); } } /** * Updates the current customer session using data from the request (e.g. address data). * * Address session data is synced to the order itself later on by OrderController::update_order_from_cart() * * @param \WP_REST_Request $request Full details about the request. */ private function update_customer_from_request( \WP_REST_Request $request ) { $customer = WC()->customer; $additional_field_contexts = [ 'shipping_address' => [ 'group' => 'shipping', 'location' => 'address', 'param' => 'shipping_address', ], 'billing_address' => [ 'group' => 'billing', 'location' => 'address', 'param' => 'billing_address', ], 'contact' => [ 'group' => 'other', 'location' => 'contact', 'param' => 'additional_fields', ], ]; foreach ( $additional_field_contexts as $context => $context_data ) { $document_object = $this->get_document_object_from_rest_request( $request ); $document_object->set_context( $context ); $additional_fields = $this->additional_fields_controller->get_contextual_fields_for_location( $context_data['location'], $document_object ); if ( 'shipping_address' === $context_data['param'] ) { $field_values = WC()->cart->needs_shipping() ? (array) ( $request['shipping_address'] ?? $request['billing_address'] ?? [] ) : (array) ( $request['billing_address'] ?? [] ); } else { $field_values = (array) $request[ $context_data['param'] ] ?? []; } if ( 'address' === $context_data['location'] ) { $persist_keys = array_merge( $this->additional_fields_controller->get_address_fields_keys(), [ 'email' ], array_keys( $additional_fields ) ); } else { $persist_keys = array_keys( $additional_fields ); } foreach ( $field_values as $key => $value ) { if ( in_array( $key, $persist_keys, true ) ) { $this->update_customer_address_field( $customer, $key, $value, $context_data['group'] ); } } wc_log_order_step( '[Store API #3::update_customer_from_request] Persisted ' . $context . ' fields' ); } /** * Fires when the Checkout Block/Store API updates a customer from the API request data. * * @since 8.2.0 * * @param \WC_Customer $customer Customer object. * @param \WP_REST_Request $request Full details about the request. */ do_action( 'woocommerce_store_api_checkout_update_customer_from_request', $customer, $request ); $customer->save(); } /** * Gets the chosen payment method from the request. * * @throws RouteException On error. * @param \WP_REST_Request $request Request object. * @return \WC_Payment_Gateway|null */ private function get_request_payment_method( \WP_REST_Request $request ) { $request_payment_method = wc_clean( wp_unslash( $request['payment_method'] ?? '' ) ); if ( empty( $request_payment_method ) ) { return null; } $available_gateways = WC()->payment_gateways->get_available_payment_gateways(); if ( ! isset( $available_gateways[ $request_payment_method ] ) ) { $all_payment_gateways = WC()->payment_gateways->payment_gateways(); $gateway_title = isset( $all_payment_gateways[ $request_payment_method ] ) ? $all_payment_gateways[ $request_payment_method ]->get_title() : $request_payment_method; throw new RouteException( 'woocommerce_rest_checkout_payment_method_disabled', sprintf( // Translators: %s Payment method ID. esc_html__( '%s is not available for this order—please choose a different payment method', 'woocommerce' ), esc_html( $gateway_title ) ), 400 ); } return $available_gateways[ $request_payment_method ]; } /** * Order processing relating to customer account. * * Creates a customer account as needed (based on request & store settings) and updates the order with the new customer ID. * Updates the order with user details (e.g. address). * * @throws RouteException API error object with error details. * @param \WP_REST_Request $request Request object. */ private function process_customer( \WP_REST_Request $request ) { $order = $this->get_order_or_throw(); if ( $this->should_create_customer_account( $request ) ) { $customer_id = wc_create_new_customer( $request['billing_address']['email'], '', $request['customer_password'], [ 'first_name' => $request['billing_address']['first_name'], 'last_name' => $request['billing_address']['last_name'], 'source' => 'store-api', ] ); if ( is_wp_error( $customer_id ) ) { throw new RouteException( esc_html( $customer_id->get_error_code() ), esc_html( $customer_id->get_error_message() ), 400 ); } // Associate customer with the order. $order->set_customer_id( $customer_id ); // Set the customer auth cookie. wc_set_customer_auth_cookie( $customer_id ); wc_log_order_step( '[Store API #6::process_customer] Created new customer', array( 'customer_id' => $customer_id ) ); } // Persist customer address data to account. $this->order_controller->sync_customer_data_with_order( $order ); wc_log_order_step( '[Store API #6::process_customer] Synced customer data from order', array( 'customer_id' => $order->get_customer_id() ) ); } /** * Check request options and store (shop) config to determine if a user account should be created as part of order * processing. * * @param \WP_REST_Request $request The current request object being handled. * @return boolean True if a new user account should be created. */ private function should_create_customer_account( \WP_REST_Request $request ) { if ( is_user_logged_in() ) { return false; } // Return false if registration is not enabled for the store. if ( false === filter_var( WC()->checkout()->is_registration_enabled(), FILTER_VALIDATE_BOOLEAN ) ) { return false; } // Return true if the store requires an account for all purchases. Note - checkbox is not displayed to shopper in this case. if ( true === filter_var( WC()->checkout()->is_registration_required(), FILTER_VALIDATE_BOOLEAN ) ) { return true; } // Create an account if requested via the endpoint. if ( true === filter_var( $request['create_account'], FILTER_VALIDATE_BOOLEAN ) ) { // User has requested an account as part of checkout processing. return true; } return false; } /** * Reject the order if what the customer is going to be charged at is higher from what they saw. * * Runs on POST /checkout only, before the draft order is materialised, so a mismatch leaves * no order behind. The check only runs when the client sends the total it displayed; flows * that cannot know the final total up front (e.g. some express payment methods) omit it and * are unaffected. * * @phpstan-param \WP_REST_Request> $request * * @param \WP_REST_Request $request Request object. * @throws RouteException When the recalculated total is higher. Returns HTTP 409 with the refreshed cart so the client can display the updated total. */ private function validate_order_totals( \WP_REST_Request $request ): void { // The route schema constrains this to minor-unit digits, so the integer comparison below is exact. $expected_total = (string) ( $request['expected_total'] ?? '' ); if ( '' === $expected_total ) { return; } // CartSchema::prepare_money_response() is not exported, so we use the money formatter directly here to match the total_price format. $decimals = wc_get_price_decimals(); $actual_total = woocommerce_store_api_get_formatter( 'money' )->format( $this->cart_controller->get_cart_instance()->get_total( 'edit' ), [ 'decimals' => $decimals ] ); if ( (int) $actual_total <= (int) $expected_total ) { return; } $expected_amount = wc_remove_number_precision( absint( $expected_total ) ); $actual_amount = wc_remove_number_precision( absint( $actual_total ) ); throw new RouteException( 'woocommerce_rest_checkout_total_mismatch', sprintf( /* translators: %1$s: total the shopper confirmed, %2$s: updated, higher total */ esc_html__( 'The order total changed from %1$s to %2$s while you were checking out. Please review the updated total and place your order again.', 'woocommerce' ), esc_html( wc_price( $expected_amount, [ 'in_span' => false ] ) ), esc_html( wc_price( $actual_amount, [ 'in_span' => false ] ) ) ), 409, [ 'expected_total' => absint( $expected_total ), 'actual_total' => absint( $actual_total ), ] ); } /** * This validates if the order can be placed regarding settings in WooCommerce > Settings > Accounts & Privacy * If registration during checkout is disabled, guest checkout is disabled and the user is not logged in, prevent checkout. * * @throws RouteException If user cannot place order. */ private function validate_user_can_place_order() { if ( // "woocommerce_enable_signup_and_login_from_checkout" === no. false === filter_var( WC()->checkout()->is_registration_enabled(), FILTER_VALIDATE_BOOLEAN ) && // "woocommerce_enable_guest_checkout" === no. true === filter_var( WC()->checkout()->is_registration_required(), FILTER_VALIDATE_BOOLEAN ) && ! is_user_logged_in() ) { throw new RouteException( 'woocommerce_rest_guest_checkout_disabled', esc_html( /** * Filter to customize the checkout message when a user must be logged in. * * @since 9.4.3 * * @param string $message Message to display when a user must be logged in to check out. */ apply_filters( 'woocommerce_checkout_must_be_logged_in_message', __( 'You must be logged in to checkout.', 'woocommerce' ) ) ), 403 ); } } }