AgenticCheckoutUtils.php
4 months ago
ArrayUtils.php
2 years ago
CartController.php
1 month ago
CartTokenUtils.php
1 year ago
CheckoutTrait.php
1 month ago
DraftOrderTrait.php
1 year ago
JsonWebToken.php
11 months ago
LocalPickupUtils.php
5 months ago
NoticeHandler.php
1 year ago
OrderAuthorizationTrait.php
6 months ago
OrderController.php
1 month ago
Pagination.php
2 years ago
PaymentUtils.php
1 year ago
ProductItemTrait.php
1 month ago
ProductLinksTrait.php
3 months ago
ProductQuery.php
2 months ago
ProductQueryFilters.php
11 months ago
QuantityLimits.php
11 months ago
RateLimits.php
1 year ago
SanitizationUtils.php
2 years ago
ValidationUtils.php
2 years ago
CheckoutTrait.php
383 lines
| 1 | <?php |
| 2 | declare( strict_types = 1); |
| 3 | namespace Automattic\WooCommerce\StoreApi\Utilities; |
| 4 | |
| 5 | use Automattic\Jetpack\Constants; |
| 6 | use Automattic\WooCommerce\StoreApi\Exceptions\RouteException; |
| 7 | use Automattic\WooCommerce\StoreApi\Payments\PaymentContext; |
| 8 | use Automattic\WooCommerce\StoreApi\Payments\PaymentResult; |
| 9 | use Automattic\WooCommerce\Blocks\Domain\Services\CheckoutFieldsSchema\DocumentObject; |
| 10 | use Automattic\WooCommerce\Admin\Features\Features; |
| 11 | use WC_Customer; |
| 12 | |
| 13 | /** |
| 14 | * CheckoutTrait |
| 15 | * |
| 16 | * Shared functionality for checkout route. |
| 17 | */ |
| 18 | trait CheckoutTrait { |
| 19 | /** |
| 20 | * Prepare a single item for response. Handles setting the status based on the payment result. |
| 21 | * |
| 22 | * @param mixed $item Item to format to schema. |
| 23 | * @param \WP_REST_Request $request Request object. |
| 24 | * @return \WP_REST_Response $response Response data. |
| 25 | */ |
| 26 | public function prepare_item_for_response( $item, \WP_REST_Request $request ) { |
| 27 | $response = parent::prepare_item_for_response( $item, $request ); |
| 28 | $status_codes = [ |
| 29 | 'success' => 200, |
| 30 | 'pending' => 202, |
| 31 | 'failure' => 400, |
| 32 | 'error' => 500, |
| 33 | ]; |
| 34 | |
| 35 | if ( isset( $item->payment_result ) && $item->payment_result instanceof PaymentResult ) { |
| 36 | $response->set_status( $status_codes[ $item->payment_result->status ] ?? 200 ); |
| 37 | } |
| 38 | |
| 39 | return $response; |
| 40 | } |
| 41 | |
| 42 | /** |
| 43 | * Returns the order being processed, throwing if it hasn't been materialised yet. |
| 44 | * |
| 45 | * Use the returned `WC_Order` (rather than `$this->order`) for type-safe access in |
| 46 | * the rest of the calling method. |
| 47 | * |
| 48 | * @throws RouteException If `$this->order` is null. |
| 49 | * @return \WC_Order |
| 50 | */ |
| 51 | private function get_order_or_throw(): \WC_Order { |
| 52 | if ( ! $this->order instanceof \WC_Order ) { |
| 53 | throw new RouteException( |
| 54 | 'woocommerce_rest_checkout_missing_order', |
| 55 | esc_html__( 'Unable to create order', 'woocommerce' ), |
| 56 | 500 |
| 57 | ); |
| 58 | } |
| 59 | return $this->order; |
| 60 | } |
| 61 | |
| 62 | /** |
| 63 | * For orders which do not require payment, just update status. |
| 64 | * |
| 65 | * @throws RouteException If the order is missing. |
| 66 | * |
| 67 | * @param \WP_REST_Request $request Request object. |
| 68 | * @param PaymentResult $payment_result Payment result object. |
| 69 | */ |
| 70 | private function process_without_payment( \WP_REST_Request $request, PaymentResult $payment_result ) { |
| 71 | $order = $this->get_order_or_throw(); |
| 72 | |
| 73 | $order->payment_complete(); |
| 74 | |
| 75 | // Mark the payment as successful. |
| 76 | $payment_result->set_status( 'success' ); |
| 77 | $payment_result->set_redirect_url( $order->get_checkout_order_received_url() ); |
| 78 | } |
| 79 | |
| 80 | /** |
| 81 | * Fires an action hook instructing active payment gateways to process the payment for an order and provide a result. |
| 82 | * |
| 83 | * @throws RouteException If the order is missing, or on payment error. |
| 84 | * |
| 85 | * @param \WP_REST_Request $request Request object. |
| 86 | * @param PaymentResult $payment_result Payment result object. |
| 87 | */ |
| 88 | private function process_payment( \WP_REST_Request $request, PaymentResult $payment_result ) { |
| 89 | $order = $this->get_order_or_throw(); |
| 90 | |
| 91 | try { |
| 92 | // Prepare the payment context object to pass through payment hooks. |
| 93 | $context = new PaymentContext(); |
| 94 | $context->set_payment_method( $this->get_request_payment_method_id( $request ) ); |
| 95 | $context->set_payment_data( $this->get_request_payment_data( $request ) ); |
| 96 | $context->set_order( $order ); |
| 97 | |
| 98 | /** |
| 99 | * Process payment with context. |
| 100 | * |
| 101 | * @hook woocommerce_rest_checkout_process_payment_with_context |
| 102 | * |
| 103 | * @throws \Exception If there is an error taking payment, an \Exception object can be thrown with an error message. |
| 104 | * |
| 105 | * @param PaymentContext $context Holds context for the payment, including order ID and payment method. |
| 106 | * @param PaymentResult $payment_result Result object for the transaction. |
| 107 | */ |
| 108 | do_action_ref_array( 'woocommerce_rest_checkout_process_payment_with_context', [ $context, &$payment_result ] ); |
| 109 | |
| 110 | if ( ! $payment_result instanceof PaymentResult ) { |
| 111 | throw new RouteException( 'woocommerce_rest_checkout_invalid_payment_result', __( 'Invalid payment result received from payment method.', 'woocommerce' ), 500 ); |
| 112 | } |
| 113 | } catch ( \Exception $e ) { |
| 114 | $additional_data = []; |
| 115 | |
| 116 | // phpcs:disable WooCommerce.Commenting.CommentHooks.MissingSinceComment |
| 117 | /** |
| 118 | * Allows to check if WP_DEBUG mode is enabled before returning previous Exception. |
| 119 | * |
| 120 | * @param bool The WP_DEBUG mode. |
| 121 | */ |
| 122 | if ( apply_filters( 'woocommerce_return_previous_exceptions', Constants::is_true( 'WP_DEBUG' ) ) && $e->getPrevious() ) { |
| 123 | $additional_data = [ |
| 124 | 'previous' => get_class( $e->getPrevious() ), |
| 125 | ]; |
| 126 | } |
| 127 | |
| 128 | throw new RouteException( 'woocommerce_rest_checkout_process_payment_error', esc_html( $e->getMessage() ), 400, array_map( 'esc_attr', $additional_data ) ); |
| 129 | } |
| 130 | } |
| 131 | |
| 132 | /** |
| 133 | * Gets the chosen payment method ID from the request. |
| 134 | * |
| 135 | * @throws RouteException On error. |
| 136 | * @param \WP_REST_Request $request Request object. |
| 137 | * @return string |
| 138 | */ |
| 139 | private function get_request_payment_method_id( \WP_REST_Request $request ) { |
| 140 | $payment_method = $this->get_request_payment_method( $request ); |
| 141 | return is_null( $payment_method ) ? '' : $payment_method->id; |
| 142 | } |
| 143 | |
| 144 | /** |
| 145 | * Gets and formats payment request data. |
| 146 | * |
| 147 | * @param \WP_REST_Request $request Request object. |
| 148 | * @return array |
| 149 | */ |
| 150 | private function get_request_payment_data( \WP_REST_Request $request ) { |
| 151 | static $payment_data = []; |
| 152 | if ( ! empty( $payment_data ) ) { |
| 153 | return $payment_data; |
| 154 | } |
| 155 | if ( ! empty( $request['payment_data'] ) ) { |
| 156 | foreach ( $request['payment_data'] as $data ) { |
| 157 | $payment_data[ sanitize_key( $data['key'] ) ] = wc_clean( $data['value'] ); |
| 158 | } |
| 159 | } |
| 160 | |
| 161 | return $payment_data; |
| 162 | } |
| 163 | |
| 164 | /** |
| 165 | * Update the current order using the posted values from the request. |
| 166 | * |
| 167 | * Called only with a real, persisted order — either the place-order POST flow or |
| 168 | * the rare failed-payment PATCH retry flow where `get_draft_order()` resolved to |
| 169 | * an existing `pending`/`failed` order from the customer's session. Fresh-session |
| 170 | * PATCHes never call this method; they go through the no-order draft path. |
| 171 | * |
| 172 | * @param \WP_REST_Request $request Full details about the request. |
| 173 | * @param bool $persist Whether to persist the changes right away (defaults to true). |
| 174 | * @throws RouteException If the order is missing, or if the order requires a payment method on POST and none was supplied. |
| 175 | */ |
| 176 | private function update_order_from_request( \WP_REST_Request $request, bool $persist = true ) { |
| 177 | $order = $this->get_order_or_throw(); |
| 178 | |
| 179 | $order->set_customer_note( wc_sanitize_textarea( $request['customer_note'] ) ?? '' ); |
| 180 | $payment_method = $this->get_request_payment_method( $request ); |
| 181 | if ( null !== $payment_method ) { |
| 182 | WC()->session->set( 'chosen_payment_method', $payment_method->id ); |
| 183 | $order->set_payment_method( $payment_method->id ); |
| 184 | $order->set_payment_method_title( $payment_method->title ); |
| 185 | } else { |
| 186 | $order_needs_payment = $order->needs_payment(); |
| 187 | if ( $order_needs_payment && 'POST' === $request->get_method() ) { |
| 188 | throw new RouteException( |
| 189 | 'woocommerce_rest_checkout_missing_payment_method', |
| 190 | esc_html__( 'No payment method provided.', 'woocommerce' ), |
| 191 | 400 |
| 192 | ); |
| 193 | } |
| 194 | if ( ! $order_needs_payment ) { |
| 195 | $order->set_payment_method( '' ); |
| 196 | } |
| 197 | } |
| 198 | wc_log_order_step( |
| 199 | '[Store API #5::update_order_from_request] Set customer note and payment method', |
| 200 | array( |
| 201 | 'order_id' => $order->get_id(), |
| 202 | 'payment' => $order->get_payment_method_title(), |
| 203 | ) |
| 204 | ); |
| 205 | $this->persist_additional_fields_for_order( $request ); |
| 206 | wc_log_order_step( |
| 207 | '[Store API #5::update_order_from_request] Persisted additional fields', |
| 208 | array( |
| 209 | 'order_id' => $order->get_id(), |
| 210 | 'payment' => $order->get_payment_method_title(), |
| 211 | ) |
| 212 | ); |
| 213 | |
| 214 | wc_do_deprecated_action( |
| 215 | '__experimental_woocommerce_blocks_checkout_update_order_from_request', |
| 216 | array( |
| 217 | $order, |
| 218 | $request, |
| 219 | ), |
| 220 | '6.3.0', |
| 221 | 'woocommerce_store_api_checkout_update_order_from_request', |
| 222 | 'This action was deprecated in WooCommerce Blocks version 6.3.0. Please use woocommerce_store_api_checkout_update_order_from_request instead.' |
| 223 | ); |
| 224 | |
| 225 | wc_do_deprecated_action( |
| 226 | 'woocommerce_blocks_checkout_update_order_from_request', |
| 227 | array( |
| 228 | $order, |
| 229 | $request, |
| 230 | ), |
| 231 | '7.2.0', |
| 232 | 'woocommerce_store_api_checkout_update_order_from_request', |
| 233 | 'This action was deprecated in WooCommerce Blocks version 7.2.0. Please use woocommerce_store_api_checkout_update_order_from_request instead.' |
| 234 | ); |
| 235 | |
| 236 | /** |
| 237 | * Fires when the Checkout Block/Store API updates an order's from the API request data. |
| 238 | * |
| 239 | * This hook gives extensions the chance to update orders based on the data in the request. This can be used in |
| 240 | * conjunction with the ExtendSchema class to post custom data and then process it. |
| 241 | * |
| 242 | * @since 7.2.0 |
| 243 | * |
| 244 | * @param \WC_Order $order Order object. |
| 245 | * @param \WP_REST_Request $request Full details about the request. |
| 246 | */ |
| 247 | do_action( 'woocommerce_store_api_checkout_update_order_from_request', $order, $request ); |
| 248 | |
| 249 | if ( $persist ) { |
| 250 | $order->save(); |
| 251 | } |
| 252 | } |
| 253 | |
| 254 | /** |
| 255 | * Gets the chosen payment method title from the request. |
| 256 | * |
| 257 | * @throws RouteException On error. |
| 258 | * @param \WP_REST_Request $request Request object. |
| 259 | * @return string |
| 260 | */ |
| 261 | private function get_request_payment_method_title( \WP_REST_Request $request ) { |
| 262 | $payment_method = $this->get_request_payment_method( $request ); |
| 263 | return is_null( $payment_method ) ? '' : $payment_method->get_title(); |
| 264 | } |
| 265 | |
| 266 | /** |
| 267 | * Persist additional fields for the order after validating them. |
| 268 | * |
| 269 | * @throws RouteException If the order is missing. |
| 270 | * |
| 271 | * @param \WP_REST_Request $request Full details about the request. |
| 272 | */ |
| 273 | private function persist_additional_fields_for_order( \WP_REST_Request $request ) { |
| 274 | // Local alias so the closure and downstream calls keep a non-null `WC_Order`. |
| 275 | $order = $this->get_order_or_throw(); |
| 276 | |
| 277 | $this->resolve_and_persist_additional_fields( |
| 278 | $request, |
| 279 | function ( string $key, $value ) use ( $order ) { |
| 280 | $this->additional_fields_controller->persist_field_for_order( $key, $value, $order, 'other', false ); |
| 281 | } |
| 282 | ); |
| 283 | |
| 284 | if ( 0 !== $order->get_customer_id() && get_current_user_id() === $order->get_customer_id() ) { |
| 285 | $this->additional_fields_controller->sync_customer_additional_fields_with_order( $order, wc()->customer ); |
| 286 | } |
| 287 | } |
| 288 | |
| 289 | /** |
| 290 | * Persist additional fields for the customer session. |
| 291 | * |
| 292 | * Counterpart to `persist_additional_fields_for_order` for routes that operate |
| 293 | * without a persisted order (e.g. the deferred-draft PATCH path). |
| 294 | * |
| 295 | * @phpstan-param \WP_REST_Request<array<string, mixed>> $request |
| 296 | * |
| 297 | * @param \WP_REST_Request $request Full details about the request. |
| 298 | */ |
| 299 | private function persist_additional_fields_for_customer( \WP_REST_Request $request ): void { |
| 300 | $customer = wc()->customer; |
| 301 | |
| 302 | $this->resolve_and_persist_additional_fields( |
| 303 | $request, |
| 304 | function ( string $key, $value ) use ( $customer ) { |
| 305 | $this->additional_fields_controller->persist_field_for_customer( $key, $value, $customer, 'other' ); |
| 306 | } |
| 307 | ); |
| 308 | |
| 309 | $customer->save(); |
| 310 | } |
| 311 | |
| 312 | /** |
| 313 | * Resolve the additional checkout fields from the request and persist each one |
| 314 | * via the supplied callback. Fields hidden by conditional logic that were still |
| 315 | * posted are cleared (passed with an empty value). |
| 316 | * |
| 317 | * @phpstan-param \WP_REST_Request<array<string, mixed>> $request |
| 318 | * |
| 319 | * @param \WP_REST_Request $request Full details about the request. |
| 320 | * @param callable $persist Callback invoked as `$persist( string $key, mixed $value )` for each field. |
| 321 | */ |
| 322 | private function resolve_and_persist_additional_fields( \WP_REST_Request $request, callable $persist ): void { |
| 323 | if ( Features::is_enabled( 'experimental-blocks' ) ) { |
| 324 | $document_object = $this->get_document_object_from_rest_request( $request ); |
| 325 | $document_object->set_context( 'order' ); |
| 326 | $additional_fields = array_merge( |
| 327 | $this->additional_fields_controller->get_contextual_fields_for_location( 'order', $document_object ), |
| 328 | $this->additional_fields_controller->get_contextual_fields_for_location( 'contact', $document_object ) |
| 329 | ); |
| 330 | } else { |
| 331 | $additional_fields = array_merge( |
| 332 | $this->additional_fields_controller->get_fields_for_location( 'order' ), |
| 333 | $this->additional_fields_controller->get_fields_for_location( 'contact' ) |
| 334 | ); |
| 335 | } |
| 336 | |
| 337 | $field_values = isset( $request['additional_fields'] ) ? (array) $request['additional_fields'] : array(); |
| 338 | |
| 339 | foreach ( $additional_fields as $key => $field ) { |
| 340 | if ( isset( $field_values[ $key ] ) ) { |
| 341 | $persist( $key, $field_values[ $key ] ); |
| 342 | } |
| 343 | } |
| 344 | |
| 345 | $hidden_posted_field_values = array_diff_key( $field_values, $additional_fields ); |
| 346 | foreach ( $hidden_posted_field_values as $key => $value ) { |
| 347 | if ( $this->additional_fields_controller->is_field( $key ) ) { |
| 348 | $persist( $key, '' ); |
| 349 | } |
| 350 | } |
| 351 | } |
| 352 | |
| 353 | /** |
| 354 | * Returns a document object from a REST request. |
| 355 | * |
| 356 | * @param \WP_REST_Request $request The REST request. |
| 357 | * @return DocumentObject The document object or null if experimental blocks are not enabled. |
| 358 | */ |
| 359 | public function get_document_object_from_rest_request( \WP_REST_Request $request ) { |
| 360 | return new DocumentObject( |
| 361 | [ |
| 362 | 'customer' => [ |
| 363 | 'billing_address' => $request['billing_address'], |
| 364 | 'shipping_address' => $request['shipping_address'], |
| 365 | 'additional_fields' => array_intersect_key( |
| 366 | $request['additional_fields'] ?? [], |
| 367 | array_flip( $this->additional_fields_controller->get_contact_fields_keys() ) |
| 368 | ), |
| 369 | ], |
| 370 | 'checkout' => [ |
| 371 | 'payment_method' => $request['payment_method'], |
| 372 | 'create_account' => $request['create_account'], |
| 373 | 'customer_note' => $request['customer_note'], |
| 374 | 'additional_fields' => array_intersect_key( |
| 375 | $request['additional_fields'] ?? [], |
| 376 | array_flip( $this->additional_fields_controller->get_order_fields_keys() ) |
| 377 | ), |
| 378 | ], |
| 379 | ] |
| 380 | ); |
| 381 | } |
| 382 | } |
| 383 |