uest ); $response = null; $nonce_check = $this->requires_nonce( $request ) ? $this->check_nonce( $request ) : null; if ( is_wp_error( $nonce_check ) ) { $response = $nonce_check; } // Block early if session is blocked by fraud protection. if ( wc_get_container()->get( FraudProtectionController::class )->feature_is_enabled() && wc_get_container()->get( SessionClearanceManager::class )->is_session_blocked() ) { $response = $this->get_route_error_response( 'woocommerce_rest_checkout_error', wc_get_container()->get( BlockedSessionNotice::class )->get_message_plaintext( 'checkout' ), 403 ); } if ( ! $response ) { try { $response = $this->get_response_by_request_method( $request ); } catch ( InvalidCartException $error ) { $response = $this->get_route_error_response_from_object( $error->getError(), $error->getCode(), $error->getAdditionalData() ); } catch ( RouteException $error ) { $response = $this->get_route_error_response( $error->getErrorCode(), $error->getMessage(), $error->getCode(), $error->getAdditionalData() ); } catch ( \Exception $error ) { $response = $this->get_route_error_response( 'woocommerce_rest_unknown_server_error', $error->getMessage(), 500 ); } } if ( is_wp_error( $response ) ) { $response = $this->error_to_response( $response ); // If we encountered an exception, free up stock and release held coupons. if ( $this->order ) { wc_release_stock_for_order( $this->order ); wc_release_coupons_for_order( $this->order ); } if ( $request->get_method() === \WP_REST_Server::CREATABLE ) { // Step logs the exception. If nothing abnormal occurred during the place order POST request, flow the log is removed. wc_log_order_step( '[Store API #FAIL] Placing Order failed', array( 'status' => $response->get_status(), 'data' => $response->get_data(), ), true ); } } return $this->add_response_headers( $response ); } /** * Convert the cart into a new draft order, or update an existing draft order, and return an updated cart response. * * @throws RouteException On error. * @param \WP_REST_Request $request Request object. * @return \WP_REST_Response */ protected function get_route_response( \WP_REST_Request $request ) { $this->create_or_update_draft_order( $request ); return $this->prepare_item_for_response( (object) [ 'order' => $this->order, 'payment_result' => new PaymentResult(), ], $request ); } /** * Validation callback for the checkout route. * * This runs after individual field validation_callbacks have been called. * * @param \WP_REST_Request $request Request object. * @return true|\WP_Error */ public function validate_callback( $request ) { $validate_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', ], 'order' => [ 'group' => 'other', 'location' => 'order', 'param' => 'additional_fields', ], ]; if ( ! WC()->cart->needs_shipping() ) { unset( $validate_contexts['shipping_address'] ); } $invalid_groups = []; $invalid_details = []; $is_partial = in_array( $request->get_method(), [ 'PUT', 'PATCH' ], true ); foreach ( $validate_contexts as $context => $context_data ) { $errors = new \WP_Error(); $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 ); // These values are used to validate custom rules and generate the document object. $field_values = (array) $request->get_param( $context_data['param'] ) ?? []; foreach ( $additional_fields as $field_key => $field ) { // Skip values that were not posted if the request is partial or the field is not required. if ( ! isset( $field_values[ $field_key ] ) && ( $is_partial || true !== $field['required'] ) ) { continue; } // Clean the field value to trim whitespace. $field_value = wc_clean( wp_unslash( $field_values[ $field_key ] ?? '' ) ); if ( empty( $field_value ) ) { if ( true === $field['required'] ) { /* translators: %s: is the field label */ $error_message = sprintf( __( '%s is required', 'woocommerce' ), $field['label'] ); if ( 'shipping_address' === $context ) { /* translators: %s: is the field error message */ $error_message = sprintf( __( 'There was a problem with the provided shipping address: %s', 'woocommerce' ), $error_message ); } elseif ( 'billing_address' === $context ) { /* translators: %s: is the field error message */ $error_message = sprintf( __( 'There was a problem with the provided billing address: %s', 'woocommerce' ), $error_message ); } $errors->add( 'woocommerce_required_checkout_field', $error_message, [ 'key' => $field_key ] ); } continue; } $valid_check = $this->additional_fields_controller->validate_field( $field, $field_value ); if ( is_wp_error( $valid_check ) && $valid_check->has_errors() ) { foreach ( $valid_check->get_error_codes() as $code ) { $valid_check->add_data( array( 'location' => $context_data['location'], 'key' => $field_key, ), $code ); } $errors->merge_from( $valid_check ); continue; } } // Validate all fields for this location (this runs custom validation callbacks). $valid_location_check = $this->additional_fields_controller->validate_fields_for_location( $field_values, $context_data['location'], $context_data['group'] ); if ( is_wp_error( $valid_location_check ) && $valid_location_check->has_errors() ) { foreach ( $valid_location_check->get_error_codes() as $code ) { $valid_location_check->add_data( array( 'location' => $context_data['location'], ), $code ); } $errors->merge_from( $valid_location_check ); } if ( $errors->has_errors() ) { $invalid_groups[ $context_data['param'] ] = $errors->get_error_message(); $invalid_details[ $context_data['param'] ] = rest_convert_error_to_response( $errors )->get_data(); } } if ( $invalid_groups ) { return new \WP_Error( 'rest_invalid_param', /* translators: %s: List of invalid parameters. */ esc_html( sprintf( __( 'Invalid parameter(s): %s', 'woocommerce' ), implode( ', ', array_keys( $invalid_groups ) ) ) ), array( 'status' => 400, 'params' => $invalid_groups, 'details' => $invalid_details, ) ); } return true; } /** * Get route response for PUT requests. * * @param \WP_REST_Request $request Request object. * @throws RouteException On error. * @return \WP_REST_Response|\WP_Error */ protected function get_route_update_response( \WP_REST_Request $request ) { $validation_callback = $this->validate_callback( $request ); if ( is_wp_error( $validation_callback ) ) { return $validation_callback; } /** * Create (or update) Draft Order and process request data. */ $this->create_or_update_draft_order( $request ); /** * Persist additional fields, order notes and payment method for order. */ $this->update_order_from_request( $request ); if ( $request->get_param( '__experimental_calc_totals' ) ) { /** * Before triggering validation, ensure totals are current and in turn, things such as shipping costs are present. * This is so plugins that validate other cart data (e.g. conditional shipping and payments) can access this data. */ $this->cart_controller->calculate_totals(); /** * Validate that the cart is not empty. */ $this->cart_controller->validate_cart_not_empty(); /** * Validate items and fix violations before the order is processed. */ $this->cart_controller->validate_cart(); } $this->order->save(); return $this->prepare_item_for_response( (object) [ 'order' => wc_get_order( $this->order ), 'cart' => $this->cart_controller->get_cart_instance(), ], $request ); } /** * Process an order. * * 1. Obtain Draft Order * 2. Process Request * 3. Process Customer * 4. Validate Order * 5. Process Payment * * @throws RouteException On error. * * @param \WP_REST_Request $request Request object. * * @return \WP_REST_Response|\WP_Error */ protected function get_route_post_response( \WP_REST_Request $request ) { wc_log_order_step( '[Store API #1] Place Order flow initiated', null, false, true ); $validation_callback = $this->validate_callback( $request ); if ( is_wp_error( $validation_callback ) ) { return $validation_callback; } /** * Ensure required permissions based on store settings are valid to place the order. */ $this->validate_user_can_place_order(); /** * Before triggering validation, ensure totals are current and in turn, things such as shipping costs are present. * This is so plugins that validate other cart data (e.g. conditional shipping and payments) can access this data. */ $this->cart_controller->calculate_totals(); /** * Validate that the cart is not empty. */ $this->cart_controller->validate_cart_not_empty(); wc_log_order_step( '[Store API #2] Cart validated' ); /** * Validate items and fix violations before the order is processed. */ $this->cart_controller->validate_cart(); /** * Persist customer session data from the request first so that OrderController::update_addresses_from_cart * uses the up-to-date customer address. */ $this->update_customer_from_request( $request ); wc_log_order_step( '[Store API #3] Updated customer data from request' ); /** * Create (or update) Draft Order and process request data. */ $this->create_or_update_draft_order( $request ); wc_log_order_step( '[Store API #4] Created/Updated draft order', array( 'order_object' => $this->order ) ); $this->update_order_from_request( $request ); wc_log_order_step( '[Store API #5] Updated order with posted data', array( 'order_object' => $this->order ) ); $this->process_customer( $request ); wc_log_order_step( '[Store API #6] Created and/or persisted customer data from order', array( 'order_object' => $this->order ) ); /** * Validate updated order before payment is attempted. */ $this->order_controller->validate_order_before_payment( $this->order ); wc_log_order_step( '[Store API #7] Validated order data', array( 'order_object' => $this->order ) ); /** * Hold coupons for the order as soon as the draft order is created. */ try { // $this->order->get_billing_email() is already validated by validate_order_before_payment() $this->order->hold_applied_coupons( $this->order->get_billing_email() ); } catch ( \Exception $e ) { // Turn the Exception into a RouteException for the API. throw new RouteException( 'woocommerce_rest_coupon_reserve_failed', esc_html( $e->getMessage() ), 400 ); } /** * Reserve stock for the order. * * In the shortcode based checkout, when POSTing the checkout form the order would be created and fire the * `woocommerce_checkout_order_created` action. This in turn would trigger the `wc_reserve_stock_for_order` * function so that stock would be held pending payment. * * Via the block based checkout and Store API we already have a draft order, but when POSTing to the /checkout * endpoint we do the same; reserve stock for the order to allow time to process payment. * * Note, stock is only "held" while the order has the status wc-checkout-draft or pending. Stock is freed when * the order changes status, or there is an exception. * * @see ReserveStock::get_query_for_reserved_stock() * * @since 9.2 Stock is no longer held for all draft orders, nor on non-POST requests. See https://github.com/woocommerce/woocommerce/issues/44231 * @since 9.2 Uses wc_reserve_stock_for_order() instead of using the ReserveStock class directly. */ try { wc_reserve_stock_for_order( $this->order ); } catch ( ReserveStockException $e ) { throw new RouteException( esc_html( $e->getErrorCode() ), esc_html( $e->getMessage() ), esc_html( $e->getCode() ) ); } wc_log_order_step( '[Store API #8] Reserved stock for order', array( 'order_object' => $this->order ) ); wc_do_deprecated_action( '__experimental_woocommerce_blocks_checkout_order_processed', array( $this->order, ), '6.3.0', 'woocommerce_store_api_checkout_order_processed', 'This action was deprecated in WooCommerce Blocks version 6.3.0. Please use woocommerce_store_api_checkout_order_processed instead.' ); wc_do_deprecated_action( 'woocommerce_blocks_checkout_order_processed', array( $this->order, ), '7.2.0', 'woocommerce_store_api_checkout_order_processed', 'This action was deprecated in WooCommerce Blocks version 7.2.0. Please use woocommerce_store_api_checkout_order_processed instead.' ); // Set the order status to 'pending' as an initial step. // This allows the order to proceed towards completion. The hook // 'woocommerce_store_api_checkout_order_processed' (fired below) can be used // to set a custom status *after* this point. // If payment isn't needed, the custom status is kept. If payment is needed, // the payment gateway's statuses take precedence. $this->order->update_status( 'pending' ); /** * Fires before an order is processed by the Checkout Block/Store API. * * This hook informs extensions that $order has completed processing and is ready for payment. * * This is similar to existing core hook woocommerce_checkout_order_processed. 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/3238 * @example See docs/examples/checkout-order-processed.md * @param \WC_Order $order Order object. */ do_action( 'woocommerce_store_api_checkout_order_processed', $this->order ); /** * Process the payment and return the results. */ $payment_result = new PaymentResult(); if ( $this->order->needs_payment() ) { $this->process_payment( $request, $payment_result ); } else { $this->process_without_payment( $request, $payment_result ); } wc_log_order_step( '[Store API #9] Order processed', array( 'order_object' => $this->order, 'processed_with_payment' => $this->order->needs_payment() ? 'yes' : 'no', 'payment_status' => $payment_result->status, ), true ); return $this->prepare_item_for_response( (object) [ 'order' => wc_get_order( $this->order ), 'payment_result' => $payment_result, ], $request ); } /** * Get route response when something went wrong. * * @param string $error_code String based error code. * @param string $error_message User facing error message. * @param int $http_status_code HTTP status. Defaults to 500. * @param array $additional_data Extra data (key value pairs) to expose in the error response. * @return \WP_Error WP Error object. */ protected function get_route_error_response( $error_code, $error_message, $http_status_code = 500, $additional_data = [] ) { $error_from_message = new \WP_Error( $error_code, $error_message ); // 409 is when there was a conflict, so we return the cart so the client can resolve it. if ( 409 === $http_status_code ) { return $this->add_data_to_error_object( $error_from_message, $additional_data, $http_status_code, true ); } return $this->add_data_to_error_object( $error_from_message, $additional_data, $http_status_code ); } /** * Get route response when something went wrong. * * @param \WP_Error $error_object User facing error message. * @param int $http_status_code HTTP status. Defaults to 500. * @param array $additional_data Extra data (key value pairs) to expose in the error response. * @return \WP_Error WP Error object. */ protected function get_route_error_response_from_object( $error_object, $http_status_code = 500, $additional_data = [] ) { // 409 is when there was a conflict, so we return the cart so the client can resolve it. if ( 409 === $http_status_code ) { return $this->add_data_to_error_object( $error_object, $additional_data, $http_status_code, true ); } return $this->add_data_to_error_object( $error_object, $additional_data, $http_status_code ); } /** * Adds additional data to the \WP_Error object. * * @param \WP_Error $error The error object to add the cart to. * @param array $data The data to add to the error object. * @param int $http_status_code The HTTP status code this error should return. * @param bool $include_cart Whether the cart should be included in the error data. * @returns \WP_Error The \WP_Error with the cart added. */ private function add_data_to_error_object( $error, $data, $http_status_code, bool $include_cart = false ) { $data = array_merge( $data, [ 'status' => $http_status_code ] ); if ( $include_cart ) { $data = array_merge( $data, [ 'cart' => $this->cart_schema->get_item_response( $this->cart_controller->get_cart_for_response() ) ] ); } $error->add_data( $data ); return $error; } /** * Create or update a draft order based on the cart. * * @param \WP_REST_Request $request Full details about the request. * @throws RouteException On error. */ private function create_or_update_draft_order( \WP_REST_Request $request ) { $this->order = $this->get_draft_order(); if ( ! $this->order ) { $this->order = $this->order_controller->create_order_from_cart(); wc_log_order_step( '[Store API #4::create_or_update_draft_order] Created order from cart', array( 'order_object' => $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 = (array) $request['shipping_address'] ?? ( $request['billing_address'] ?? [] ); if ( ! WC()->cart->needs_shipping() ) { $field_values = $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 ) { $available_gateways = WC()->payment_gateways->get_available_payment_gateways(); $request_payment_method = wc_clean( wp_unslash( $request['payment_method'] ?? '' ) ); // For PUT requests, the order never requires payment, only POST does. $requires_payment_method = $this->order->needs_payment() && 'POST' === $request->get_method(); if ( empty( $request_payment_method ) ) { if ( $requires_payment_method ) { throw new RouteException( 'woocommerce_rest_checkout_missing_payment_method', esc_html__( 'No payment method provided.', 'woocommerce' ), 400 ); } return null; } 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 ) { 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. $this->order->set_customer_id( $customer_id ); $this->order->save(); // 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( $this->order ); wc_log_order_step( '[Store API #6::process_customer] Synced customer data from order', array( 'customer_id' => $this->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; } /** * 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 ); } } } failed and the suggestion is not hidden. if ( false === $result ) { return false; } return true; } /** * Get the payment extension suggestions categories details. * * @return array The payment extension suggestions categories. */ public function get_extension_suggestion_categories(): array { $categories = array(); $categories[] = array( 'id' => self::CATEGORY_EXPRESS_CHECKOUT, '_priority' => 10, 'title' => esc_html__( 'Wallets & Express checkouts', 'woocommerce' ), 'description' => esc_html__( 'Allow shoppers to fast-track the checkout process with express options like Apple Pay and Google Pay.', 'woocommerce' ), ); $categories[] = array( 'id' => self::CATEGORY_BNPL, '_priority' => 20, 'title' => esc_html__( 'Buy Now, Pay Later', 'woocommerce' ), 'description' => esc_html__( 'Offer flexible payment options to your shoppers.', 'woocommerce' ), ); $categories[] = array( 'id' => self::CATEGORY_CRYPTO, '_priority' => 30, 'title' => esc_html__( 'Crypto Payments', 'woocommerce' ), 'description' => esc_html__( 'Offer cryptocurrency payment options to your shoppers.', 'woocommerce' ), ); $categories[] = array( 'id' => self::CATEGORY_PSP, '_priority' => 40, 'title' => esc_html__( 'Payment Providers', 'woocommerce' ), 'description' => esc_html__( 'Give your shoppers additional ways to pay.', 'woocommerce' ), ); return $categories; } /** * Get the payment providers order map. * * @return array The payment providers order map. */ public function get_order_map(): array { // This will also handle backwards compatibility. return $this->enhance_order_map( get_option( self::PROVIDERS_ORDER_OPTION, array() ) ); } /** * Save the payment providers order map. * * @param array $order_map The order map to save. * * @return bool True if the payment providers order map was successfully saved, false otherwise. */ public function save_order_map( array $order_map ): bool { return update_option( self::PROVIDERS_ORDER_OPTION, $order_map ); } /** * Update the payment providers order map. * * This has effects both on the Payments settings page and the checkout page * since registered payment gateways (enabled or not) are among the providers. * * @param array $order_map The new order for payment providers. * The order map should be an associative array where the keys are the payment provider IDs * and the values are the new integer order for the payment provider. * This can be a partial list of payment providers and their orders. * It can also contain new IDs and their orders. * * @return bool True if the payment providers ordering was successfully updated, false otherwise. */ public function update_payment_providers_order_map( array $order_map ): bool { $existing_order_map = get_option( self::PROVIDERS_ORDER_OPTION, array() ); $new_order_map = $this->payment_providers_order_map_apply_mappings( $existing_order_map, $order_map ); // This will also handle backwards compatibility. $new_order_map = $this->enhance_order_map( $new_order_map ); // Save the new order map to the DB. return $this->save_order_map( $new_order_map ); } /** * Enhance a payment providers order map. * * If the payments providers order map is empty, it will be initialized with the current WC payment gateway ordering. * If there are missing entries (registered payment gateways, suggestions, offline PMs, etc.), they will be added. * Various rules will be enforced (e.g., offline PMs and their relation with the offline PMs group). * * @param array $order_map The payment providers order map. * * @return array The updated payment providers order map. */ public function enhance_order_map( array $order_map ): array { // We don't request the display gateways list because we need to get the order of all the registered payment gateways. $payment_gateways = $this->get_payment_gateways( false ); // Make it a list keyed by the payment gateway ID. $payment_gateways = array_combine( array_map( fn( $gateway ) => $gateway->id, $payment_gateways ), $payment_gateways ); // Get the payment gateways order map. $payment_gateways_order_map = array_flip( array_keys( $payment_gateways ) ); // Get the payment gateways to suggestions map. // There will be null entries for payment gateways where we couldn't find a suggestion. $payment_gateways_to_suggestions_map = array_map( fn( $gateway ) => $this->extension_suggestions->get_by_plugin_slug( Utils::normalize_plugin_slug( $this->get_payment_gateway_plugin_slug( $gateway ) ) ), $payment_gateways ); /* * Initialize the order map with the current ordering. */ if ( empty( $order_map ) ) { $order_map = $payment_gateways_order_map; } $order_map = Utils::order_map_normalize( $order_map ); $handled_suggestion_ids = array(); /* * Go through the registered gateways and add any missing ones. */ // Use a map to keep track of the insertion offset for each suggestion ID. // We need this so we can place multiple PGs matching a suggestion right after it but maintain their relative order. $suggestion_order_map_id_to_offset_map = array(); foreach ( $payment_gateways_order_map as $id => $order ) { if ( isset( $order_map[ $id ] ) ) { continue; } // If there is a suggestion entry matching this payment gateway, // we will add the payment gateway right after it so gateways pop-up in place of matching suggestions. // We rely on suggestions and matching registered PGs being mutually exclusive in the UI. if ( ! empty( $payment_gateways_to_suggestions_map[ $id ] ) ) { $suggestion_id = $payment_gateways_to_suggestions_map[ $id ]['id']; $suggestion_order_map_id = $this->get_suggestion_order_map_id( $suggestion_id ); if ( isset( $order_map[ $suggestion_order_map_id ] ) ) { // Determine the offset for placing missing PGs after this suggestion. if ( ! isset( $suggestion_order_map_id_to_offset_map[ $suggestion_order_map_id ] ) ) { $suggestion_order_map_id_to_offset_map[ $suggestion_order_map_id ] = 0; } $suggestion_order_map_id_to_offset_map[ $suggestion_order_map_id ] += 1; // Place the missing payment gateway right after the suggestion, // with an offset to maintain relative order between multiple PGs matching the same suggestion. $order_map = Utils::order_map_place_at_order( $order_map, $id, $order_map[ $suggestion_order_map_id ] + $suggestion_order_map_id_to_offset_map[ $suggestion_order_map_id ] ); // Remember that we handled this suggestion - don't worry about remembering it multiple times. $handled_suggestion_ids[] = $suggestion_id; continue; } } // Add the missing payment gateway at the end. $order_map[ $id ] = empty( $order_map ) ? 0 : max( $order_map ) + 1; } $handled_suggestion_ids = array_unique( $handled_suggestion_ids ); /* * Place not yet handled suggestion entries right before their matching registered payment gateway IDs. * This means that registered PGs already in the order map force the suggestions * to be placed/moved right before them. We rely on suggestions and registered PGs being mutually exclusive. */ foreach ( array_keys( $order_map ) as $id ) { // If the id is not of a payment gateway or there is no suggestion for this payment gateway, ignore it. if ( ! array_key_exists( $id, $payment_gateways_to_suggestions_map ) || empty( $payment_gateways_to_suggestions_map[ $id ] ) ) { continue; } $suggestion = $payment_gateways_to_suggestions_map[ $id ]; // If the suggestion was already handled, skip it. if ( in_array( $suggestion['id'], $handled_suggestion_ids, true ) ) { continue; } // Place the suggestion at the same order as the payment gateway // thus ensuring that the suggestion is placed right before the payment gateway. $order_map = Utils::order_map_place_at_order( $order_map, $this->get_suggestion_order_map_id( $suggestion['id'] ), $order_map[ $id ] ); // Remember that we've handled this suggestion to avoid adding it multiple times. // We only want to attach the suggestion to the first payment gateway that matches the plugin slug. $handled_suggestion_ids[] = $suggestion['id']; } // Extract all the registered offline PMs and keep their order values. $offline_methods = array_filter( $order_map, array( $this, 'is_offline_payment_method' ), ARRAY_FILTER_USE_KEY ); if ( ! empty( $offline_methods ) ) { /* * If the offline PMs group is missing, add it before the last offline PM. */ if ( ! array_key_exists( self::OFFLINE_METHODS_ORDERING_GROUP, $order_map ) ) { $last_offline_method_order = max( $offline_methods ); $order_map = Utils::order_map_place_at_order( $order_map, self::OFFLINE_METHODS_ORDERING_GROUP, $last_offline_method_order ); } /* * Place all the offline PMs right after the offline PMs group entry. */ $target_order = $order_map[ self::OFFLINE_METHODS_ORDERING_GROUP ] + 1; // Sort the offline PMs by their order. asort( $offline_methods ); foreach ( $offline_methods as $offline_method => $order ) { $order_map = Utils::order_map_place_at_order( $order_map, $offline_method, $target_order ); ++$target_order; } } return Utils::order_map_normalize( $order_map ); } /** * Get the ID of the suggestion order map entry. * * @param string $suggestion_id The ID of the suggestion. * * @return string The ID of the suggestion order map entry. */ public function get_suggestion_order_map_id( string $suggestion_id ): string { return self::SUGGESTION_ORDERING_PREFIX . $suggestion_id; } /** * Check if the ID is a suggestion order map entry ID. * * @param string $id The ID to check. * * @return bool True if the ID is a suggestion order map entry ID, false otherwise. */ public function is_suggestion_order_map_id( string $id ): bool { return 0 === strpos( $id, self::SUGGESTION_ORDERING_PREFIX ); } /** * Get the ID of the suggestion from the suggestion order map entry ID. * * @param string $order_map_id The ID of the suggestion order map entry. * * @return string The ID of the suggestion. */ public function get_suggestion_id_from_order_map_id( string $order_map_id ): string { return str_replace( self::SUGGESTION_ORDERING_PREFIX, '', $order_map_id ); } /** * Reset the memoized data. Useful for testing purposes. * * @internal * @return void */ public function reset_memo(): void { $this->payment_gateways_memo = array(); $this->payment_gateways_for_display_memo = array(); } /** * Handle payment gateways with non-standard registration behavior. * * @param array $payment_gateways The payment gateways list. * * @return array The payment gateways list with the necessary adjustments. */ private function handle_non_standard_registration_for_payment_gateways( array $payment_gateways ): array { /* * Handle the Mollie gateway's particular behavior: if there are no API keys or no PMs enabled, * the extension doesn't register a gateway instance. * We will need to register a mock gateway to represent Mollie in the settings page. */ $payment_gateways = $this->maybe_add_pseudo_mollie_gateway( $payment_gateways ); return $payment_gateways; } /** * Add the pseudo Mollie gateway to the payment gateways list if necessary. * * @param array $payment_gateways The payment gateways list. * * @return array The payment gateways list with the pseudo Mollie gateway added if necessary. */ private function maybe_add_pseudo_mollie_gateway( array $payment_gateways ): array { $mollie_provider = $this->get_payment_gateway_provider_instance( 'mollie' ); // Do nothing if there is a Mollie gateway registered. if ( $mollie_provider->is_gateway_registered( $payment_gateways ) ) { return $payment_gateways; } // Get the Mollie suggestion and determine if the plugin is active. $mollie_suggestion = $this->get_extension_suggestion_by_id( ExtensionSuggestions::MOLLIE ); if ( empty( $mollie_suggestion ) ) { return $payment_gateways; } // Do nothing if the plugin is not active. if ( self::EXTENSION_ACTIVE !== $mollie_suggestion['plugin']['status'] ) { return $payment_gateways; } // Add the pseudo Mollie gateway to the list since the plugin is active but there is no Mollie gateway registered. $payment_gateways[] = $mollie_provider->get_pseudo_gateway( $mollie_suggestion ); return $payment_gateways; } /** * Enhance the payment gateway details with additional information from other sources. * * @param array $gateway_details The gateway details to enhance. * @param WC_Payment_Gateway $payment_gateway The payment gateway object. * @param string $country_code The country code for which the details are being enhanced. * This should be an ISO 3166-1 alpha-2 country code. * * @return array The enhanced gateway details. */ private function enhance_payment_gateway_details( array $gateway_details, WC_Payment_Gateway $payment_gateway, string $country_code ): array { // We discriminate between offline payment methods and gateways. $gateway_details['_type'] = $this->is_offline_payment_method( $payment_gateway->id ) ? self::TYPE_OFFLINE_PM : self::TYPE_GATEWAY; $plugin_slug = $gateway_details['plugin']['slug']; // The payment gateway plugin might use a non-standard directory name. // Try to normalize it to the common slug to avoid false negatives when matching. $normalized_plugin_slug = Utils::normalize_plugin_slug( $plugin_slug ); // If we have a matching suggestion, hoist details from there. // The suggestions only know about the normalized (aka official) plugin slug. $suggestion = $this->get_extension_suggestion_by_plugin_slug( $normalized_plugin_slug, $country_code ); if ( ! is_null( $suggestion ) ) { // The title, description, icon, and image from the suggestion take precedence over the ones from the gateway. // This is temporary until we update the partner extensions. // Do not override the title and description for certain suggestions because theirs are more descriptive // (like including the payment method when registering multiple gateways for the same provider). if ( ! in_array( $suggestion['id'], array( ExtensionSuggestions::PAYPAL_FULL_STACK, ExtensionSuggestions::PAYPAL_WALLET, ExtensionSuggestions::MOLLIE, ExtensionSuggestions::MONEI, ExtensionSuggestions::ANTOM, ExtensionSuggestions::MERCADO_PAGO, ExtensionSuggestions::AMAZON_PAY, ExtensionSuggestions::SQUARE, ExtensionSuggestions::PAYONEER, ExtensionSuggestions::AIRWALLEX, ExtensionSuggestions::COINBASE, // We don't have suggestion details yet. ExtensionSuggestions::AUTHORIZE_NET, // We don't have suggestion details yet. ExtensionSuggestions::BOLT, // We don't have suggestion details yet. ExtensionSuggestions::DEPAY, // We don't have suggestion details yet. ExtensionSuggestions::ELAVON, // We don't have suggestion details yet. ExtensionSuggestions::FORTISPAY, // We don't have suggestion details yet. ExtensionSuggestions::PAYPAL_ZETTLE, // We don't have suggestion details yet. ExtensionSuggestions::RAPYD, // We don't have suggestion details yet. ExtensionSuggestions::PAYPAL_BRAINTREE, // We don't have suggestion details yet. ), true ) ) { if ( ! empty( $suggestion['title'] ) ) { $gateway_details['title'] = $suggestion['title']; } if ( ! empty( $suggestion['description'] ) ) { $gateway_details['description'] = $suggestion['description']; } } if ( ! empty( $suggestion['icon'] ) ) { $gateway_details['icon'] = $suggestion['icon']; } if ( ! empty( $suggestion['image'] ) ) { $gateway_details['image'] = $suggestion['image']; } if ( empty( $gateway_details['links'] ) && ! empty( $suggestion['links'] ) ) { $gateway_details['links'] = $suggestion['links']; } if ( empty( $gateway_details['tags'] ) && ! empty( $suggestion['tags'] ) ) { $gateway_details['tags'] = $suggestion['tags']; } if ( empty( $gateway_details['plugin'] ) && ! empty( $suggestion['plugin'] ) ) { $gateway_details['plugin'] = $suggestion['plugin']; } if ( empty( $gateway_details['_incentive'] ) && ! empty( $suggestion['_incentive'] ) ) { $gateway_details['_incentive'] = $suggestion['_incentive']; } // Attach the suggestion ID to the gateway details so we can reference it with precision. $gateway_details['_suggestion_id'] = $suggestion['id']; } // Get the gateway's corresponding plugin details. $plugin_data = $this->proxy->call_static( PluginsHelper::class, 'get_plugin_data', $plugin_slug ); if ( ! empty( $plugin_data ) ) { // If there are no links, try to get them from the plugin data. if ( empty( $gateway_details['links'] ) ) { if ( is_array( $plugin_data ) && ! empty( $plugin_data['PluginURI'] ) ) { $gateway_details['links'] = array( array( '_type' => self::LINK_TYPE_ABOUT, 'url' => esc_url( $plugin_data['PluginURI'] ), ), ); } elseif ( ! empty( $gateway_details['plugin']['_type'] ) && ExtensionSuggestions::PLUGIN_TYPE_WPORG === $gateway_details['plugin']['_type'] ) { // Fallback to constructing the WPORG plugin URI from the normalized plugin slug. $gateway_details['links'] = array( array( '_type' => self::LINK_TYPE_ABOUT, 'url' => 'https://wordpress.org/plugins/' . $normalized_plugin_slug, ), ); } } } return $gateway_details; } /** * Check if the store has any enabled ecommerce gateways. * * We exclude offline payment methods from this check. * * @return bool True if the store has any enabled ecommerce gateways, false otherwise. */ private function has_enabled_ecommerce_gateways(): bool { $gateways = $this->get_payment_gateways( false ); // We want the raw gateways list. $enabled_gateways = array_filter( $gateways, function ( $gateway ) { // Filter out offline gateways. return 'yes' === $gateway->enabled && ! $this->is_offline_payment_method( $gateway->id ); } ); return ! empty( $enabled_gateways ); } /** * Enhance a payment extension suggestion with additional information. * * @param array $extension_suggestion The extension suggestion. * * @return array The enhanced payment extension suggestion. */ private function enhance_extension_suggestion( array $extension_suggestion ): array { // Determine the category of the extension. switch ( $extension_suggestion['_type'] ) { case ExtensionSuggestions::TYPE_PSP: $extension_suggestion['category'] = self::CATEGORY_PSP; break; case ExtensionSuggestions::TYPE_EXPRESS_CHECKOUT: $extension_suggestion['category'] = self::CATEGORY_EXPRESS_CHECKOUT; break; case ExtensionSuggestions::TYPE_BNPL: $extension_suggestion['category'] = self::CATEGORY_BNPL; break; case ExtensionSuggestions::TYPE_CRYPTO: $extension_suggestion['category'] = self::CATEGORY_CRYPTO; break; default: $extension_suggestion['category'] = ''; break; } // Determine the PES's plugin status. // Default to not installed. $extension_suggestion['plugin']['status'] = self::EXTENSION_NOT_INSTALLED; // Put in the default plugin file. $extension_suggestion['plugin']['file'] = ''; if ( ! empty( $extension_suggestion['plugin']['slug'] ) ) { // This is a best-effort approach, as the plugin might be sitting under a directory (slug) that we can't handle. // Always try the official plugin slug first, then the testing variations. $plugin_slug_variations = Utils::generate_testing_plugin_slugs( $extension_suggestion['plugin']['slug'], true ); // Favor active plugins by checking the entire variations list for active plugins first. // This way we handle cases where there are multiple variations installed and one is active. $found = false; foreach ( $plugin_slug_variations as $plugin_slug ) { if ( $this->proxy->call_static( PluginsHelper::class, 'is_plugin_active', $plugin_slug ) ) { $found = true; $extension_suggestion['plugin']['status'] = self::EXTENSION_ACTIVE; // Make sure we put in the actual slug and file path that we found. $extension_suggestion['plugin']['slug'] = $plugin_slug; $extension_suggestion['plugin']['file'] = $this->proxy->call_static( PluginsHelper::class, 'get_plugin_path_from_slug', $plugin_slug ); // Sanity check. if ( ! is_string( $extension_suggestion['plugin']['file'] ) ) { $extension_suggestion['plugin']['file'] = ''; break; } // Remove the .php extension from the file path. The WP API expects it without it. $extension_suggestion['plugin']['file'] = Utils::trim_php_file_extension( $extension_suggestion['plugin']['file'] ); break; } } if ( ! $found ) { foreach ( $plugin_slug_variations as $plugin_slug ) { if ( $this->proxy->call_static( PluginsHelper::class, 'is_plugin_installed', $plugin_slug ) ) { $extension_suggestion['plugin']['status'] = self::EXTENSION_INSTALLED; // Make sure we put in the actual slug and file path that we found. $extension_suggestion['plugin']['slug'] = $plugin_slug; $extension_suggestion['plugin']['file'] = $this->proxy->call_static( PluginsHelper::class, 'get_plugin_path_from_slug', $plugin_slug ); // Sanity check. if ( ! is_string( $extension_suggestion['plugin']['file'] ) ) { $extension_suggestion['plugin']['file'] = ''; break; } // Remove the .php extension from the file path. The WP API expects it without it. $extension_suggestion['plugin']['file'] = Utils::trim_php_file_extension( $extension_suggestion['plugin']['file'] ); break; } } } } // Finally, allow the extension suggestion's matching provider to add further details. $gateway_provider = $this->get_payment_extension_suggestion_provider_instance( $extension_suggestion['id'] ); $extension_suggestion = $gateway_provider->enhance_extension_suggestion( $extension_suggestion ); return $extension_suggestion; } /** * Check if a payment extension suggestion has been hidden by the user. * * @param array $extension The extension suggestion. * * @return bool True if the extension suggestion is hidden, false otherwise. */ private function is_payment_extension_suggestion_hidden( array $extension ): bool { $user_payments_nox_profile = get_user_meta( get_current_user_id(), Payments::PAYMENTS_NOX_PROFILE_KEY, true ); if ( empty( $user_payments_nox_profile ) ) { return false; } $user_payments_nox_profile = maybe_unserialize( $user_payments_nox_profile ); if ( empty( $user_payments_nox_profile['hidden_suggestions'] ) ) { return false; } return in_array( $extension['id'], array_column( $user_payments_nox_profile['hidden_suggestions'], 'id' ), true ); } /** * Apply order mappings to a base payment providers order map. * * @param array $base_map The base order map. * @param array $new_mappings The order mappings to apply. * This can be a full or partial list of the base one, * but it can also contain (only) new provider IDs and their orders. * * @return array The updated base order map, normalized. */ private function payment_providers_order_map_apply_mappings( array $base_map, array $new_mappings ): array { // Sanity checks. // Remove any null or non-integer values. $new_mappings = array_filter( $new_mappings, 'is_int' ); if ( empty( $new_mappings ) ) { $new_mappings = array(); } // If we have no existing order map or // both the base and the new map have the same length and keys, we can simply use the new map. if ( empty( $base_map ) || ( count( $base_map ) === count( $new_mappings ) && empty( array_diff( array_keys( $base_map ), array_keys( $new_mappings ) ) ) ) ) { $new_order_map = $new_mappings; } else { // If we are dealing with ONLY offline PMs updates (for all that are registered) and their group is present, // normalize the new order map to keep behavior as intended (i.e., reorder only inside the offline PMs list). $offline_pms = $this->get_offline_payment_methods_gateways(); // Make it a list keyed by the payment gateway ID. $offline_pms = array_combine( array_map( fn( $gateway ) => $gateway->id, $offline_pms ), $offline_pms ); if ( isset( $base_map[ self::OFFLINE_METHODS_ORDERING_GROUP ] ) && count( $new_mappings ) === count( $offline_pms ) && empty( array_diff( array_keys( $new_mappings ), array_keys( $offline_pms ) ) ) ) { $new_mappings = Utils::order_map_change_min_order( $new_mappings, $base_map[ self::OFFLINE_METHODS_ORDERING_GROUP ] + 1 ); } $new_order_map = Utils::order_map_apply_mappings( $base_map, $new_mappings ); } return Utils::order_map_normalize( $new_order_map ); } /** * Group payment gateways by their plugin extension filename. * * @param WC_Payment_Gateway[] $gateways The list of payment gateway instances to group. * @param string $country_code Optional. The country code for which the gateways are being generated. * This should be an ISO 3166-1 alpha-2 country code. * * @return array The grouped payment gateway instances, keyed by the plugin file. * Each group contains an array of payment gateway instances that belong to the same plugin. * If a payment gateway does not have a corresponding plugin file, * it will be grouped under the 'unknown_extension' key. */ private function group_gateways_by_extension( array $gateways, string $country_code = '' ): array { $grouped = array( // This is the group for gateways that we don't know how to group by extension. // It can be used for gateways that are not registered by a WP plugin. 'unknown_extension' => array(), ); foreach ( $gateways as $gateway ) { // Get the payment gateway details, but use a dummy gateway order since it is inconsequential here. $gateway_details = $this->get_payment_gateway_details( $gateway, 0, $country_code ); // If we don't have the necessary plugin details, put it in the unknown group. if ( empty( $gateway_details ) || ! isset( $gateway_details['plugin'] ) || empty( $gateway_details['plugin']['file'] ) ) { $grouped['unknown_extension'][] = $gateway; continue; } if ( empty( $grouped[ $gateway_details['plugin']['file'] ] ) ) { $grouped[ $gateway_details['plugin']['file'] ] = array(); } $grouped[ $gateway_details['plugin']['file'] ][] = $gateway; } return $grouped; } }