200, 'pending' => 202, 'failure' => 400, 'error' => 500, ]; if ( isset( $item->payment_result ) && $item->payment_result instanceof PaymentResult ) { $response->set_status( $status_codes[ $item->payment_result->status ] ?? 200 ); } return $response; } /** * Returns the order being processed, throwing if it hasn't been materialised yet. * * Use the returned `WC_Order` (rather than `$this->order`) for type-safe access in * the rest of the calling method. * * @throws RouteException If `$this->order` is null. * @return \WC_Order */ private function get_order_or_throw(): \WC_Order { if ( ! $this->order instanceof \WC_Order ) { throw new RouteException( 'woocommerce_rest_checkout_missing_order', esc_html__( 'Unable to create order', 'woocommerce' ), 500 ); } return $this->order; } /** * For orders which do not require payment, just update status. * * Deliberately no recovery of the kind process_payment() does: nothing was charged, so a * failure costs the shopper only a retry, and claiming success would be the worse outcome. * * @throws RouteException If the order is missing or a cancelled unpaid order would be marked paid. * * @param \WP_REST_Request $request Request object. * @param PaymentResult $payment_result Payment result object. */ private function process_without_payment( \WP_REST_Request $request, PaymentResult $payment_result ) { $order = $this->get_order_or_throw(); // A cancelled order can reach this path after a stale cache read; never revive it without payment. if ( 0 < $order->get_total() && ! $order->is_paid() && $order->has_status( OrderStatus::CANCELLED ) ) { throw new RouteException( 'woocommerce_rest_checkout_payment_required', esc_html__( 'This order cannot be completed without payment. Please try again.', 'woocommerce' ), 400 ); } $order->payment_complete(); // Mark the payment as successful. $payment_result->set_status( 'success' ); $payment_result->set_redirect_url( $order->get_checkout_order_received_url() ); } /** * Fires an action hook instructing active payment gateways to process the payment for an order and provide a result. * * @throws RouteException If the order is missing, or on payment error. * @throws \Throwable If a gateway raised an Error before payment was taken. * * @param \WP_REST_Request $request Request object. * @param PaymentResult $payment_result Payment result object. */ private function process_payment( \WP_REST_Request $request, PaymentResult $payment_result ) { $order = $this->get_order_or_throw(); $session = WC()->session; $session->set( 'order_awaiting_payment', $order->get_id() ); // Persist before invoking gateways because redirects or stalled requests may prevent the session from being saved on shutdown. if ( is_callable( array( $session, 'save_data' ) ) ) { $session->save_data(); } /* * The hook below takes the result by reference and can replace it, with anything at all. * The caller keeps this instance either way, and it is the one serialised into the * response, so recovery has to write onto it rather than onto whatever the hook left. */ $result_for_recovery = $payment_result; try { // Prepare the payment context object to pass through payment hooks. $context = new PaymentContext(); $context->set_payment_method( $this->get_request_payment_method_id( $request ) ); $context->set_payment_data( $this->get_request_payment_data( $request ) ); $context->set_order( $order ); /** * Process payment with context. * * @hook woocommerce_rest_checkout_process_payment_with_context * * @throws \Exception If there is an error taking payment, an \Exception object can be thrown with an error message. * * @param PaymentContext $context Holds context for the payment, including order ID and payment method. * @param PaymentResult $payment_result Result object for the transaction. */ do_action_ref_array( 'woocommerce_rest_checkout_process_payment_with_context', [ $context, &$payment_result ] ); if ( ! $payment_result instanceof PaymentResult ) { throw new RouteException( 'woocommerce_rest_checkout_invalid_payment_result', __( 'Invalid payment result received from payment method.', 'woocommerce' ), 500 ); } } catch ( \Throwable $e ) { /* * The gateway may already have taken payment, for example when a post-payment * integration throws during the status transition. Reporting a failure would send the * shopper back to place the order again, and every retry pays for another order. * * Re-read first: a gateway that advanced the order may have done so on its own * instance, leaving the one held above reporting a stale status. */ $order = $this->refresh_order( $order ); if ( $this->order_moved_past_payment( $order ) ) { $this->recover_order_that_took_payment( $order, $e, $result_for_recovery ); return; } if ( ! $e instanceof \Exception ) { throw $e; } $additional_data = []; // phpcs:disable WooCommerce.Commenting.CommentHooks.MissingSinceComment /** * Allows to check if WP_DEBUG mode is enabled before returning previous Exception. * * @param bool $return_previous_exceptions Whether to include the previous exception. Defaults to the WP_DEBUG value. */ if ( apply_filters( 'woocommerce_return_previous_exceptions', Constants::is_true( 'WP_DEBUG' ) ) && $e->getPrevious() ) { $additional_data = [ 'previous' => get_class( $e->getPrevious() ), ]; } throw new RouteException( 'woocommerce_rest_checkout_process_payment_error', esc_html( $e->getMessage() ), 400, array_map( 'esc_attr', $additional_data ) ); } } /** * Re-reads the order so its status reflects whatever the gateway persisted, falling back * to the in-memory order when it can no longer be read. * * @param \WC_Order $order Order object. * @return \WC_Order */ private function refresh_order( \WC_Order $order ): \WC_Order { $refreshed_order = wc_get_order( $order->get_id() ); return $refreshed_order instanceof \WC_Order ? $refreshed_order : $order; } /** * Whether the order has moved beyond the point of awaiting payment. * * An order reaches a gateway awaiting payment or as a draft, so any other status means * something moved it on. This is about the status, not the money: an order parked on-hold * for review counts, because sending the shopper back to place it again would be wrong. * * The statuses a site declares payable count as awaiting payment, so a custom status an * extension parks the order in before the gateway runs is not mistaken for a paid one. * Not needs_payment(): that folds in the order total, and a fully discounted order that * failed would then keep its stock and coupon holds. * * @param \WC_Order $order Order object. * @return bool */ private function order_moved_past_payment( \WC_Order $order ): bool { /** * Filter the valid order statuses for payment. * * The same filter WC_Order::needs_payment() applies. A status a site declares payable * counts as awaiting payment here too, so a failure while the order is in it is reported * rather than recovered as one that took payment. * * @since 11.2.0 * * @param array $valid_order_statuses Array of valid order statuses for payment. * @param \WC_Order $order Order object. */ $payable_statuses = apply_filters( 'woocommerce_valid_order_statuses_for_payment', array( OrderStatus::PENDING, OrderStatus::FAILED ), $order ); return ! $order->has_status( array_merge( (array) $payable_statuses, array( OrderStatus::CHECKOUT_DRAFT, OrderStatus::CANCELLED, OrderStatus::REFUNDED, OrderStatus::TRASH, ) ) ); } /** * Records a failure that happened after payment was taken and reports the checkout as * successful, so the shopper is sent to the order confirmation rather than back to retry. * * @param \WC_Order $order Order object. * @param \Throwable $error The failure raised after payment was taken. * @param PaymentResult $payment_result Payment result object. */ private function recover_order_that_took_payment( \WC_Order $order, \Throwable $error, PaymentResult $payment_result ): void { /* * The failure goes in the message, not the context: the file handler renders context with * wp_json_encode(), and neither WC_Order nor Throwable exposes public properties, so the * objects alone would write an empty {}. This is now the only log of the error. */ wc_get_logger()->error( sprintf( 'Checkout for order #%1$d failed after payment was taken: %2$s: %3$s in %4$s:%5$d', $order->get_id(), get_class( $error ), $error->getMessage(), $error->getFile(), $error->getLine() ), array( 'source' => 'store-api' ) ); $order->add_order_note( sprintf( /* translators: %s: the error that was raised after payment was taken. */ __( 'Checkout could not be completed after payment was taken: %s', 'woocommerce' ), wp_strip_all_tags( $error->getMessage() ) ) ); $payment_result->set_status( 'success' ); /* * Keep a redirect the gateway set before it threw. An order can reach here still needing * the shopper to act, say one parked on-hold awaiting a 3DS challenge, and overwriting it * would walk them past that step. Order confirmation is the fallback. */ if ( '' === $payment_result->get_redirect_url() ) { $payment_result->set_redirect_url( $order->get_checkout_order_received_url() ); } /* * Keep both redirect fields in sync, as Legacy::process_legacy_payment does on the normal * path. Setting only one leaves a success result core would never otherwise emit, and * gateway client code reads the redirect from the payment details rather than from * redirect_url: WooPayments and WooCommerce Stripe both call String.match() on it * unguarded, stranding the shopper on the checkout of an order that was in fact paid. */ $payment_details = $payment_result->get_payment_details(); if ( ! isset( $payment_details['redirect'] ) ) { $payment_details['redirect'] = $payment_result->get_redirect_url(); $payment_result->set_payment_details( $payment_details ); } /* * The gateway never reached the point where it empties the cart, so do it here: otherwise * the shopper lands on the confirmation with the order still in their cart and can place * it again. Only when the cart still belongs to this order, since pay-for-order shares * this trait with an unrelated cart. Same guard as wc_clear_cart_after_payment(). */ if ( WC()->cart && $order->has_cart_hash( WC()->cart->get_cart_hash() ) ) { WC()->cart->empty_cart(); } } /** * Gets the chosen payment method ID from the request. * * @throws RouteException On error. * @param \WP_REST_Request $request Request object. * @return string */ private function get_request_payment_method_id( \WP_REST_Request $request ) { $payment_method = $this->get_request_payment_method( $request ); return is_null( $payment_method ) ? '' : $payment_method->id; } /** * Gets and formats payment request data. * * @param \WP_REST_Request $request Request object. * @return array */ private function get_request_payment_data( \WP_REST_Request $request ) { static $payment_data = []; if ( ! empty( $payment_data ) ) { return $payment_data; } if ( ! empty( $request['payment_data'] ) ) { foreach ( $request['payment_data'] as $data ) { $payment_data[ sanitize_key( $data['key'] ) ] = wc_clean( $data['value'] ); } } return $payment_data; } /** * Update the current order using the posted values from the request. * * Called only with a real, persisted order — either the place-order POST flow or * the rare failed-payment PATCH retry flow where `get_draft_order()` resolved to * an existing `pending`/`failed` order from the customer's session. Fresh-session * PATCHes never call this method; they go through the no-order draft path. * * @param \WP_REST_Request $request Full details about the request. * @param bool $persist Whether to persist the changes right away (defaults to true). * @throws RouteException If the order is missing, or if the order requires a payment method on POST and none was supplied. */ private function update_order_from_request( \WP_REST_Request $request, bool $persist = true ) { $order = $this->get_order_or_throw(); $order->set_customer_note( wc_sanitize_textarea( $request['customer_note'] ) ?? '' ); $payment_method = $this->get_request_payment_method( $request ); if ( null !== $payment_method ) { WC()->session->set( 'chosen_payment_method', $payment_method->id ); $order->set_payment_method( $payment_method->id ); $order->set_payment_method_title( $payment_method->title ); } else { $order_needs_payment = $order->needs_payment(); if ( $order_needs_payment && 'POST' === $request->get_method() ) { throw new RouteException( 'woocommerce_rest_checkout_missing_payment_method', esc_html__( 'No payment method provided.', 'woocommerce' ), 400 ); } if ( ! $order_needs_payment ) { $order->set_payment_method( '' ); } } wc_log_order_step( '[Store API #5::update_order_from_request] Set customer note and payment method', array( 'order_id' => $order->get_id(), 'payment' => $order->get_payment_method_title(), ) ); $this->persist_additional_fields_for_order( $request ); wc_log_order_step( '[Store API #5::update_order_from_request] Persisted additional fields', array( 'order_id' => $order->get_id(), 'payment' => $order->get_payment_method_title(), ) ); wc_do_deprecated_action( '__experimental_woocommerce_blocks_checkout_update_order_from_request', array( $order, $request, ), '6.3.0', 'woocommerce_store_api_checkout_update_order_from_request', 'This action was deprecated in WooCommerce Blocks version 6.3.0. Please use woocommerce_store_api_checkout_update_order_from_request instead.' ); wc_do_deprecated_action( 'woocommerce_blocks_checkout_update_order_from_request', array( $order, $request, ), '7.2.0', 'woocommerce_store_api_checkout_update_order_from_request', 'This action was deprecated in WooCommerce Blocks version 7.2.0. Please use woocommerce_store_api_checkout_update_order_from_request instead.' ); /** * Fires when the Checkout Block/Store API updates an order's from the API request data. * * This hook gives extensions the chance to update orders based on the data in the request. This can be used in * conjunction with the ExtendSchema class to post custom data and then process it. * * @since 7.2.0 * * @param \WC_Order $order Order object. * @param \WP_REST_Request $request Full details about the request. */ do_action( 'woocommerce_store_api_checkout_update_order_from_request', $order, $request ); if ( $persist ) { $order->save(); } } /** * Gets the chosen payment method title from the request. * * @throws RouteException On error. * @param \WP_REST_Request $request Request object. * @return string */ private function get_request_payment_method_title( \WP_REST_Request $request ) { $payment_method = $this->get_request_payment_method( $request ); return is_null( $payment_method ) ? '' : $payment_method->get_title(); } /** * Persist additional fields for the order after validating them. * * @throws RouteException If the order is missing. * * @param \WP_REST_Request $request Full details about the request. */ private function persist_additional_fields_for_order( \WP_REST_Request $request ) { // Local alias so the closure and downstream calls keep a non-null `WC_Order`. $order = $this->get_order_or_throw(); $this->resolve_and_persist_additional_fields( $request, function ( string $key, $value ) use ( $order ) { $this->additional_fields_controller->persist_field_for_order( $key, $value, $order, 'other', false ); } ); if ( 0 !== $order->get_customer_id() && get_current_user_id() === $order->get_customer_id() ) { $this->additional_fields_controller->sync_customer_additional_fields_with_order( $order, wc()->customer ); } } /** * Persist additional fields for the customer session. * * Counterpart to `persist_additional_fields_for_order` for routes that operate * without a persisted order (e.g. the deferred-draft PATCH path). * * @phpstan-param \WP_REST_Request> $request * * @param \WP_REST_Request $request Full details about the request. */ private function persist_additional_fields_for_customer( \WP_REST_Request $request ): void { $customer = wc()->customer; $this->resolve_and_persist_additional_fields( $request, function ( string $key, $value ) use ( $customer ) { $this->additional_fields_controller->persist_field_for_customer( $key, $value, $customer, 'other' ); } ); $customer->save(); } /** * Resolve the additional checkout fields from the request and persist each one * via the supplied callback. Fields hidden by conditional logic that were still * posted are cleared (passed with an empty value). * * @phpstan-param \WP_REST_Request> $request * * @param \WP_REST_Request $request Full details about the request. * @param callable $persist Callback invoked as `$persist( string $key, mixed $value )` for each field. */ private function resolve_and_persist_additional_fields( \WP_REST_Request $request, callable $persist ): void { if ( Features::is_enabled( 'experimental-blocks' ) ) { $document_object = $this->get_document_object_from_rest_request( $request ); $document_object->set_context( 'order' ); $additional_fields = array_merge( $this->additional_fields_controller->get_contextual_fields_for_location( 'order', $document_object ), $this->additional_fields_controller->get_contextual_fields_for_location( 'contact', $document_object ) ); } else { $additional_fields = array_merge( $this->additional_fields_controller->get_fields_for_location( 'order' ), $this->additional_fields_controller->get_fields_for_location( 'contact' ) ); } $field_values = isset( $request['additional_fields'] ) ? (array) $request['additional_fields'] : array(); foreach ( $additional_fields as $key => $field ) { if ( isset( $field_values[ $key ] ) ) { $persist( $key, $field_values[ $key ] ); } } $hidden_posted_field_values = array_diff_key( $field_values, $additional_fields ); foreach ( $hidden_posted_field_values as $key => $value ) { if ( $this->additional_fields_controller->is_field( $key ) ) { $persist( $key, '' ); } } } /** * Returns a document object from a REST request. * * @param \WP_REST_Request $request The REST request. * @return DocumentObject The document object or null if experimental blocks are not enabled. */ public function get_document_object_from_rest_request( \WP_REST_Request $request ) { return new DocumentObject( [ 'customer' => [ 'billing_address' => $request['billing_address'], 'shipping_address' => $request['shipping_address'], 'additional_fields' => array_intersect_key( $request['additional_fields'] ?? [], array_flip( $this->additional_fields_controller->get_contact_fields_keys() ) ), ], 'checkout' => [ 'payment_method' => $request['payment_method'], 'create_account' => $request['create_account'], 'customer_note' => $request['customer_note'], 'additional_fields' => array_intersect_key( $request['additional_fields'] ?? [], array_flip( $this->additional_fields_controller->get_order_fields_keys() ) ), ], ] ); } }