diff --git a/src/Library.php b/src/Library.php index 7ac6b9e58d0..8fb39667ce4 100644 --- a/src/Library.php +++ b/src/Library.php @@ -19,6 +19,63 @@ class Library { */ public static function init() { add_action( 'init', array( __CLASS__, 'register_blocks' ) ); + add_action( 'init', array( __CLASS__, 'define_tables' ) ); + add_action( 'init', array( __CLASS__, 'maybe_create_tables' ) ); + add_filter( 'wc_order_statuses', array( __CLASS__, 'register_draft_order_status' ) ); + add_filter( 'woocommerce_register_shop_order_post_statuses', array( __CLASS__, 'register_draft_order_post_status' ) ); + } + + /** + * Register custom tables within $wpdb object. + */ + public static function define_tables() { + global $wpdb; + + // List of tables without prefixes. + $tables = array( + 'wc_reserved_stock' => 'wc_reserved_stock', + ); + + foreach ( $tables as $name => $table ) { + $wpdb->$name = $wpdb->prefix . $table; + $wpdb->tables[] = $table; + } + } + + /** + * Set up the database tables which the plugin needs to function. + */ + public static function maybe_create_tables() { + $db_version = get_option( 'wc_blocks_db_version', 0 ); + + if ( version_compare( $db_version, \Automattic\WooCommerce\Blocks\Package::get_version(), '>=' ) ) { + return; + } + + global $wpdb; + + require_once ABSPATH . 'wp-admin/includes/upgrade.php'; + + $wpdb->hide_errors(); + $collate = ''; + + if ( $wpdb->has_cap( 'collation' ) ) { + $collate = $wpdb->get_charset_collate(); + } + + dbDelta( + " + CREATE TABLE {$wpdb->prefix}wc_reserved_stock ( + `order_id` bigint(20) NOT NULL, + `product_id` bigint(20) NOT NULL, + `stock_quantity` double NOT NULL DEFAULT 0, + `timestamp` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`order_id`, `product_id`) + ) $collate; + " + ); + + update_option( 'wc_blocks_db_version', \Automattic\WooCommerce\Blocks\Package::get_version() ); } /** @@ -59,4 +116,36 @@ public static function register_blocks() { $instance->register_block_type(); } } + + /** + * Register custom order status for orders created via the API during checkout. + * + * Draft order status is used before payment is attempted, during checkout, when a cart is converted to an order. + * + * @param array $statuses Array of statuses. + * @return array + */ + public static function register_draft_order_status( array $statuses ) { + $statuses['wc-checkout-draft'] = _x( 'Draft', 'Order status', 'woo-gutenberg-products-block' ); + return $statuses; + } + + /** + * Register custom order post status for orders created via the API during checkout. + * + * @param array $statuses Array of statuses. + * @return array + */ + public static function register_draft_order_post_status( array $statuses ) { + $statuses['wc-checkout-draft'] = [ + 'label' => _x( 'Draft', 'Order status', 'woo-gutenberg-products-block' ), + 'public' => false, + 'exclude_from_search' => false, + 'show_in_admin_all_list' => false, + 'show_in_admin_status_list' => true, + /* translators: %s: number of orders */ + 'label_count' => _n_noop( 'Drafts (%s)', 'Drafts (%s)', 'woo-gutenberg-products-block' ), + ]; + return $statuses; + } } diff --git a/src/RestApi.php b/src/RestApi.php index 5aab1958172..499be8905ee 100644 --- a/src/RestApi.php +++ b/src/RestApi.php @@ -55,7 +55,7 @@ public static function get_routes_from_namespace( $namespace ) { } /** - * If we're making a cart request, we may need to load some additonal classes from WC Core so we're ready to deal with requests. + * If we're making a cart request, we may need to load some additional classes from WC Core so we're ready to deal with requests. * * Note: We load the session here early so guest nonces are in place. * @@ -96,6 +96,7 @@ protected static function get_controllers() { 'store-cart-items' => __NAMESPACE__ . '\RestApi\StoreApi\Controllers\CartItems', 'store-cart-coupons' => __NAMESPACE__ . '\RestApi\StoreApi\Controllers\CartCoupons', 'store-cart-shipping-rates' => __NAMESPACE__ . '\RestApi\StoreApi\Controllers\CartShippingRates', + 'store-cart-order' => __NAMESPACE__ . '\RestApi\StoreApi\Controllers\CartOrder', 'store-customer' => __NAMESPACE__ . '\RestApi\StoreApi\Controllers\Customer', 'store-products' => __NAMESPACE__ . '\RestApi\StoreApi\Controllers\Products', 'store-product-collection-data' => __NAMESPACE__ . '\RestApi\StoreApi\Controllers\ProductCollectionData', diff --git a/src/RestApi/StoreApi/Controllers/CartOrder.php b/src/RestApi/StoreApi/Controllers/CartOrder.php new file mode 100644 index 00000000000..7fc952016be --- /dev/null +++ b/src/RestApi/StoreApi/Controllers/CartOrder.php @@ -0,0 +1,456 @@ +schema = new OrderSchema(); + } + + /** + * Register routes. + */ + public function register_routes() { + register_rest_route( + $this->namespace, + '/' . $this->rest_base, + [ + [ + 'methods' => RestServer::CREATABLE, + 'callback' => array( $this, 'create_item' ), + 'args' => array_merge( + $this->get_endpoint_args_for_item_schema( RestServer::CREATABLE ), + array( + 'shipping_rates' => array( + 'description' => __( 'Selected shipping rates to apply to the order.', 'woo-gutenberg-products-block' ), + 'type' => 'array', + 'required' => false, + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'rate_id' => [ + 'description' => __( 'ID of the shipping rate.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + ], + ], + ], + ), + ) + ), + ], + 'schema' => [ $this, 'get_public_item_schema' ], + ] + ); + } + + /** + * Converts the global cart to an order object. + * + * @todo Since this relies on the cart global so much, why doesn't the core cart class do this? + * + * Based on WC_Checkout::create_order. + * + * @param RestRequest $request Full details about the request. + * @return WP_Error|RestResponse + */ + public function create_item( $request ) { + try { + $this->draft_order = WC()->session->get( + 'store_api_draft_order', + [ + 'id' => 0, + 'hashes' => [ + 'line_items' => false, + 'fees' => false, + 'coupons' => false, + 'taxes' => false, + ], + ] + ); + + // Update session based on posted data. + $this->update_session( $request ); + + // Create or retrieve the draft order for the current cart. + $order_object = $this->create_order_from_cart( $request ); + + // Try to reserve stock, if available. + $this->reserve_stock( $order_object ); + + $response = $this->prepare_item_for_response( $order_object, $request ); + $response->set_status( 201 ); + return $response; + } catch ( RestException $e ) { + return new WP_Error( $e->getErrorCode(), $e->getMessage(), $e->getCode() ); + } catch ( Exception $e ) { + return new WP_Error( 'create-order-error', $e->getMessage(), [ 'status' => 500 ] ); + } + } + + /** + * Before creating anything, this method ensures the cart session is up to date and matches the data we're going + * to be adding to the order. + * + * @param RestRequest $request Full details about the request. + * @return void + */ + protected function update_session( RestRequest $request ) { + $schema = $this->get_item_schema(); + + if ( isset( $request['billing_address'] ) ) { + $allowed_billing_values = array_intersect_key( $request['billing_address'], $schema['properties']['billing_address']['properties'] ); + foreach ( $allowed_billing_values as $key => $value ) { + WC()->customer->{"set_billing_$key"}( $value ); + } + } + + if ( isset( $request['shipping_address'] ) ) { + $allowed_shipping_values = array_intersect_key( $request['shipping_address'], $schema['properties']['shipping_address']['properties'] ); + foreach ( $allowed_shipping_values as $key => $value ) { + WC()->customer->{"set_shipping_$key"}( $value ); + } + } + + WC()->customer->save(); + } + + /** + * Put a temporary hold on stock for this order. + * + * @throws RestException Exception when stock cannot be reserved. + * @param \WC_Order $order Order object. + */ + protected function reserve_stock( \WC_Order $order ) { + $reserve_stock_helper = new ReserveStock(); + $result = $reserve_stock_helper->reserve_stock_for_order( $order ); + + if ( is_wp_error( $result ) ) { + throw new RestException( $result->get_error_code(), $result->get_error_message(), $result->get_error_data( 'status' ) ); + } + } + + /** + * Create order and set props based on global settings. + * + * @param RestRequest $request Full details about the request. + * @return \WC_Order A new order object. + */ + protected function create_order_from_cart( RestRequest $request ) { + add_filter( 'woocommerce_default_order_status', array( $this, 'default_order_status' ) ); + + $order = $this->get_order_object(); + $order->set_status( 'checkout-draft' ); + $order->set_created_via( 'store-api' ); + $order->set_currency( get_woocommerce_currency() ); + $order->set_prices_include_tax( 'yes' === get_option( 'woocommerce_prices_include_tax' ) ); + $order->set_customer_id( get_current_user_id() ); + $order->set_customer_ip_address( \WC_Geolocation::get_ip_address() ); + $order->set_customer_user_agent( wc_get_user_agent() ); + $order->set_cart_hash( WC()->cart->get_cart_hash() ); + $order->update_meta_data( 'is_vat_exempt', WC()->cart->get_customer()->get_is_vat_exempt() ? 'yes' : 'no' ); + + $this->set_props_from_request( $order, $request ); + $this->create_line_items_from_cart( $order, $request ); + $this->select_shipping_rates( $order, $request ); + + // Calc totals, taxes, and save. + $order->calculate_totals(); + + remove_filter( 'woocommerce_default_order_status', array( $this, 'default_order_status' ) ); + + // Store Order details in session so we can look it up later. + WC()->session->set( + 'store_api_draft_order', + [ + 'id' => $order->get_id(), + 'hashes' => $this->get_cart_hashes( $order, $request ), + ] + ); + + return $order; + } + + /** + * Get hashes for items in the current cart. Useful for tracking changes. + * + * @param \WC_Order $order Object to prepare for the response. + * @param RestRequest $request Full details about the request. + * @return array + */ + protected function get_cart_hashes( \WC_Order $order, RestRequest $request ) { + return [ + 'line_items' => md5( wp_json_encode( WC()->cart->get_cart() ) ), + 'fees' => md5( wp_json_encode( WC()->cart->get_fees() ) ), + 'coupons' => md5( wp_json_encode( WC()->cart->get_applied_coupons() ) ), + 'taxes' => md5( wp_json_encode( WC()->cart->get_taxes() ) ), + ]; + } + + /** + * Get an order object, either using a current draft order, or returning a new one. + * + * @return \WC_Order A new order object. + */ + protected function get_order_object() { + $draft_order = $this->draft_order['id'] ? wc_get_order( $this->draft_order['id'] ) : false; + + if ( $draft_order && $draft_order->has_status( 'draft' ) && 'store-api' === $draft_order->get_created_via() ) { + return $draft_order; + } + + return new \WC_Order(); + } + + /** + * Changes default order status to draft for orders created via this API. + * + * @return string + */ + public function default_order_status() { + return 'checkout-draft'; + } + + /** + * Create order line items. + * + * @todo Knowing if items changed between the order and cart can be complex. Line items are ok because there is a + * hash, but no hash exists for other line item types. Having a normalised set of data between cart and order, or + * additional hashes, would be useful in the future and to help refactor this code. In the meantime, we're relying + * on custom hashes in $this->draft_order to track if things changed. + * + * @param \WC_Order $order Object to prepare for the response. + * @param RestRequest $request Full details about the request. + */ + protected function create_line_items_from_cart( \WC_Order $order, RestRequest $request ) { + $new_hashes = $this->get_cart_hashes( $order, $request ); + $old_hashes = $this->draft_order['hashes']; + + if ( $new_hashes['line_items'] !== $old_hashes['line_items'] ) { + $order->remove_order_items( 'line_item' ); + WC()->checkout->create_order_line_items( $order, WC()->cart ); + } + + if ( $new_hashes['coupons'] !== $old_hashes['coupons'] ) { + $order->remove_order_items( 'coupon' ); + WC()->checkout->create_order_coupon_lines( $order, WC()->cart ); + } + + if ( $new_hashes['fees'] !== $old_hashes['fees'] ) { + $order->remove_order_items( 'fee' ); + WC()->checkout->create_order_fee_lines( $order, WC()->cart ); + } + + if ( $new_hashes['taxes'] !== $old_hashes['taxes'] ) { + $order->remove_order_items( 'tax' ); + WC()->checkout->create_order_tax_lines( $order, WC()->cart ); + } + } + + /** + * Select shipping rates and store to order as line items. + * + * @throws RestException Exception when shipping is invalid. + * @param \WC_Order $order Object to prepare for the response. + * @param RestRequest $request Full details about the request. + */ + protected function select_shipping_rates( \WC_Order $order, RestRequest $request ) { + $packages = $this->get_shipping_packages( $order, $request ); + $selected_rates = isset( $request['shipping_rates'] ) ? wp_list_pluck( $request['shipping_rates'], 'rate_id' ) : []; + $shipping_hash = md5( wp_json_encode( $packages ) . wp_json_encode( $selected_rates ) ); + $stored_hash = WC()->session->get( 'store_api_shipping_hash' ); + + if ( $shipping_hash === $stored_hash ) { + return; + } + + $order->remove_order_items( 'shipping' ); + + foreach ( $packages as $package_key => $package ) { + $rates = $package['rates']; + $fallback_rate_id = current( array_keys( $rates ) ); + $selected_rate_id = isset( $selected_rates[ $package_key ] ) ? $selected_rates[ $package_key ] : $fallback_rate_id; + $selected_rate = isset( $rates[ $selected_rate_id ] ) ? $rates[ $selected_rate_id ] : false; + + if ( ! $rates ) { + continue; + } + + if ( ! $selected_rate ) { + throw new RestException( + 'invalid-shipping-rate-id', + sprintf( + /* translators: 1: Rate ID, 2: list of valid ids */ + __( '%1$s is not a valid shipping rate ID. Select one of the following: %2$s', 'woo-gutenberg-products-block' ), + $selected_rate_id, + implode( ', ', array_keys( $rates ) ) + ), + 403 + ); + } + + $item = new \WC_Order_Item_Shipping(); + $item->set_props( + array( + 'method_title' => $selected_rate->label, + 'method_id' => $selected_rate->method_id, + 'instance_id' => $selected_rate->instance_id, + 'total' => wc_format_decimal( $selected_rate->cost ), + 'taxes' => array( + 'total' => $selected_rate->taxes, + ), + ) + ); + + foreach ( $selected_rate->get_meta_data() as $key => $value ) { + $item->add_meta_data( $key, $value, true ); + } + + /** + * Action hook to adjust item before save. + */ + do_action( 'woocommerce_checkout_create_order_shipping_item', $item, $package_key, $package, $order ); + + // Add item to order and save. + $order->add_item( $item ); + } + } + + /** + * Get packages with calculated shipping. + * + * Based on WC_Cart::get_shipping_packages but allows the destination to be + * customised based on the address in the order. + * + * @param \WC_Order $order Object to prepare for the response. + * @param RestRequest $request Full details about the request. + * @return array of packages and shipping rates. + */ + protected function get_shipping_packages( \WC_Order $order, RestRequest $request ) { + if ( ! WC()->cart->needs_shipping() ) { + return []; + } + + $packages = WC()->cart->get_shipping_packages(); + + foreach ( $packages as $key => $package ) { + $packages[ $key ]['destination'] = [ + 'address_1' => $order->get_shipping_address_1(), + 'address_2' => $order->get_shipping_address_2(), + 'city' => $order->get_shipping_city(), + 'state' => $order->get_shipping_state(), + 'postcode' => $order->get_shipping_postcode(), + 'country' => $order->get_shipping_country(), + ]; + } + + $packages = WC()->shipping()->calculate_shipping( $packages ); + + return $packages; + } + + /** + * Set props from API request. + * + * @param \WC_Order $order Object to prepare for the response. + * @param RestRequest $request Full details about the request. + */ + protected function set_props_from_request( \WC_Order $order, RestRequest $request ) { + $schema = $this->get_item_schema(); + + if ( isset( $request['billing_address'] ) ) { + $allowed_billing_values = array_intersect_key( $request['billing_address'], $schema['properties']['billing_address']['properties'] ); + foreach ( $allowed_billing_values as $key => $value ) { + $order->{"set_billing_$key"}( $value ); + } + } + + if ( isset( $request['shipping_address'] ) ) { + $allowed_shipping_values = array_intersect_key( $request['shipping_address'], $schema['properties']['shipping_address']['properties'] ); + foreach ( $allowed_shipping_values as $key => $value ) { + $order->{"set_shipping_$key"}( $value ); + } + } + + if ( isset( $request['customer_note'] ) ) { + $order->set_customer_note( $request['customer_note'] ); + } + } + + /** + * Cart item schema. + * + * @return array + */ + public function get_item_schema() { + return $this->schema->get_item_schema(); + } + + /** + * Prepares a single item output for response. + * + * @param \WC_Order $object Object to prepare for the response. + * @param RestRequest $request Request object. + * @return RestResponse Response object. + */ + public function prepare_item_for_response( $object, $request ) { + $response = []; + + if ( $object instanceof \WC_Order ) { + $response = $this->schema->get_item_response( $object ); + } + return rest_ensure_response( $response ); + } +} diff --git a/src/RestApi/StoreApi/Controllers/Customer.php b/src/RestApi/StoreApi/Controllers/Customer.php index c2cc1f20863..fd7c30a2beb 100644 --- a/src/RestApi/StoreApi/Controllers/Customer.php +++ b/src/RestApi/StoreApi/Controllers/Customer.php @@ -114,15 +114,15 @@ public function update_item( $request ) { } try { - if ( isset( $request['billing'] ) ) { - $allowed_billing_values = array_intersect_key( $request['billing'], $schema['properties']['billing']['properties'] ); + if ( isset( $request['billing_address'] ) ) { + $allowed_billing_values = array_intersect_key( $request['billing_address'], $schema['properties']['billing_address']['properties'] ); foreach ( $allowed_billing_values as $key => $value ) { $customer->{"set_billing_$key"}( $value ); } } - if ( isset( $request['shipping'] ) ) { - $allowed_shipping_values = array_intersect_key( $request['shipping'], $schema['properties']['shipping']['properties'] ); + if ( isset( $request['shipping_address'] ) ) { + $allowed_shipping_values = array_intersect_key( $request['shipping_address'], $schema['properties']['shipping_address']['properties'] ); foreach ( $allowed_shipping_values as $key => $value ) { $customer->{"set_shipping_$key"}( $value ); } diff --git a/src/RestApi/StoreApi/README.md b/src/RestApi/StoreApi/README.md index dfd2e5476b5..a7e0e51467b 100644 --- a/src/RestApi/StoreApi/README.md +++ b/src/RestApi/StoreApi/README.md @@ -820,32 +820,32 @@ Example response: ```json [ - { - "code": "20off", - "totals": { - "currency_code": "GBP", - "currency_symbol": "£", - "currency_minor_unit": 2, - "currency_decimal_separator": ".", - "currency_thousand_separator": ",", - "currency_prefix": "£", - "currency_suffix": "", - "total_discount": "1667", - "total_discount_tax": "333" - }, - "_links": { - "self": [ - { - "href": "http:\/\/local.wordpress.test\/wp-json\/wc\/store\/cart\/coupons\/20off" - } - ], - "collection": [ - { - "href": "http:\/\/local.wordpress.test\/wp-json\/wc\/store\/cart\/coupons" - } - ] - } - } + { + "code": "20off", + "totals": { + "currency_code": "GBP", + "currency_symbol": "£", + "currency_minor_unit": 2, + "currency_decimal_separator": ".", + "currency_thousand_separator": ",", + "currency_prefix": "£", + "currency_suffix": "", + "total_discount": "1667", + "total_discount_tax": "333" + }, + "_links": { + "self": [ + { + "href": "http://local.wordpress.test/wp-json/wc/store/cart/coupons/20off" + } + ], + "collection": [ + { + "href": "http://local.wordpress.test/wp-json/wc/store/cart/coupons" + } + ] + } + } ] ``` @@ -869,18 +869,18 @@ Example response: ```json { - "code": "20off", - "totals": { - "currency_code": "GBP", - "currency_symbol": "£", - "currency_minor_unit": 2, - "currency_decimal_separator": ".", - "currency_thousand_separator": ",", - "currency_prefix": "£", - "currency_suffix": "", - "total_discount": "1667", - "total_discount_tax": "333" - } + "code": "20off", + "totals": { + "currency_code": "GBP", + "currency_symbol": "£", + "currency_minor_unit": 2, + "currency_decimal_separator": ".", + "currency_thousand_separator": ",", + "currency_prefix": "£", + "currency_suffix": "", + "total_discount": "1667", + "total_discount_tax": "333" + } } ``` @@ -904,18 +904,18 @@ Example response: ```json { - "code": "20off", - "totals": { - "currency_code": "GBP", - "currency_symbol": "£", - "currency_minor_unit": 2, - "currency_decimal_separator": ".", - "currency_thousand_separator": ",", - "currency_prefix": "£", - "currency_suffix": "", - "total_discount": "1667", - "total_discount_tax": "333" - } + "code": "20off", + "totals": { + "currency_code": "GBP", + "currency_symbol": "£", + "currency_minor_unit": 2, + "currency_decimal_separator": ".", + "currency_thousand_separator": ",", + "currency_prefix": "£", + "currency_suffix": "", + "total_discount": "1667", + "total_discount_tax": "333" + } } ``` @@ -990,7 +990,7 @@ Example response: "country": "US" }, "items": [ "6512bd43d9caa6e02c990b0a82652dca" ], - "shipping-rates": [ + "shipping_rates": [ { "name": "International", "description": "", @@ -1011,6 +1011,137 @@ Example response: ] ``` +## Cart Order API + +Create a new order from the items in the cart. + +```http +POST /cart/order/ +``` + +| Attribute | Type | Required | Description | +| :----------------- | :----- | :------: | :-------------------------------------------------------------------------------------- | +| `billing_address` | array | No | Billing address data to store to the new order. | +| `shipping_address` | array | No | Shipping address data to store to the new order. | +| `customer_note` | string | No | Customer note to store to the new order. | +| `shipping_rates` | array | No | Array of objects containing `rate_id` of selected shipping methods to add to the order. | + +```http +curl --request POST https://example-store.com/wp-json/wc/store/cart/order +``` + +Example response: + +```json +{ + "id": 149, + "number": "149", + "status": "draft", + "order_key": "wc_order_9falc306dOkWb", + "created_via": "store-api", + "prices_include_tax": true, + "events": { + "date_created": "2020-01-07T12:33:23", + "date_created_gmt": "2020-01-07T12:33:23", + "date_modified": "2020-01-07T12:33:23", + "date_modified_gmt": "2020-01-07T12:33:23", + "date_completed": null, + "date_completed_gmt": null, + "date_paid": null, + "date_paid_gmt": null + }, + "customer": { + "customer_id": 1, + "customer_ip_address": "192.168.50.1", + "customer_user_agent": "insomnia/7.0.5" + }, + "customer_note": "This is a customer note.", + "billing_address": { + "first_name": "Margaret", + "last_name": "Thatchcroft", + "company": "", + "address_1": "123 South Street", + "address_2": "Apt 1", + "city": "Philadelphia", + "state": "PA", + "postcode": "19123", + "country": "US", + "email": "test@test.com", + "phone": "" + }, + "shipping_address": { + "first_name": "Margaret", + "last_name": "Thatchcroft", + "company": "", + "address_1": "123 South Street", + "address_2": "Apt 1", + "city": "Philadelphia", + "state": "PA", + "postcode": "19123", + "country": "US" + }, + "items": [ + { + "id": 12, + "quantity": 1, + "name": "Belt", + "sku": "woo-belt", + "permalink": "http://local.wordpress.test/product/belt/", + "images": [ + { + "id": "41", + "src": "http://local.wordpress.test/wp-content/uploads/2019/12/belt-2.jpg", + "thumbnail": "http://local.wordpress.test/wp-content/uploads/2019/12/belt-2-300x300.jpg", + "srcset": "http://local.wordpress.test/wp-content/uploads/2019/12/belt-2.jpg 801w, http://local.wordpress.test/wp-content/uploads/2019/12/belt-2-300x300.jpg 300w, http://local.wordpress.test/wp-content/uploads/2019/12/belt-2-100x100.jpg 100w, http://local.wordpress.test/wp-content/uploads/2019/12/belt-2-450x450.jpg 450w, http://local.wordpress.test/wp-content/uploads/2019/12/belt-2-150x150.jpg 150w, http://local.wordpress.test/wp-content/uploads/2019/12/belt-2-768x768.jpg 768w", + "sizes": "(max-width: 801px) 100vw, 801px", + "name": "belt-2.jpg", + "alt": "" + } + ], + "variation": [], + "totals": { + "currency_code": "GBP", + "currency_symbol": "£", + "currency_minor_unit": 2, + "currency_decimal_separator": ".", + "currency_thousand_separator": ",", + "currency_prefix": "£", + "currency_suffix": "", + "line_subtotal": "4583", + "line_subtotal_tax": "917", + "line_total": "4583", + "line_total_tax": "917" + } + } + ], + "totals": { + "currency_code": "GBP", + "currency_symbol": "£", + "currency_minor_unit": 2, + "currency_decimal_separator": ".", + "currency_thousand_separator": ",", + "currency_prefix": "£", + "currency_suffix": "", + "total_items": "4583", + "total_items_tax": "917", + "total_fees": "0", + "total_fees_tax": "0", + "total_discount": "0", + "total_discount_tax": "0", + "total_shipping": "499", + "total_shipping_tax": "100", + "total_price": "6099", + "total_tax": "1017", + "tax_lines": [ + { + "name": "Tax", + "price": "1017" + } + ] + } +} +``` + ## Customer API ### Get data for the current customer @@ -1029,31 +1160,31 @@ Example response: ```json { - "id": 0, - "billing": { - "first_name": "Margaret", - "last_name": "Thatchcroft", - "company": "", - "address_1": "123 South Street", - "address_2": "Apt 1", - "city": "Philadelphia", - "state": "PA", - "postcode": "19123", - "country": "US", - "email": "test@test.com", - "phone": "" - }, - "shipping": { - "first_name": "Margaret", - "last_name": "Thatchcroft", - "company": "", - "address_1": "123 South Street", - "address_2": "Apt 1", - "city": "Philadelphia", - "state": "PA", - "postcode": "19123", - "country": "US" - } + "id": 0, + "billing_address": { + "first_name": "Margaret", + "last_name": "Thatchcroft", + "company": "", + "address_1": "123 South Street", + "address_2": "Apt 1", + "city": "Philadelphia", + "state": "PA", + "postcode": "19123", + "country": "US", + "email": "test@test.com", + "phone": "" + }, + "shipping_address": { + "first_name": "Margaret", + "last_name": "Thatchcroft", + "company": "", + "address_1": "123 South Street", + "address_2": "Apt 1", + "city": "Philadelphia", + "state": "PA", + "postcode": "19123", + "country": "US" + } } ``` @@ -1065,10 +1196,10 @@ Edit current customer data, such as billing and shipping addresses. PUT /cart/customer ``` -| Attribute | Type | Required | Description | -| :--------- | :------ | :------: | :--------------------------------- | -| `billing` | object | No | Billing address properties. | -| `shipping` | object | No | Shipping address properties. | +| Attribute | Type | Required | Description | +| :--------- | :----- | :------: | :--------------------------- | +| `billing` | object | No | Billing address properties. | +| `shipping` | object | No | Shipping address properties. | ```http curl --request PUT https://example-store.com/wp-json/wc/store/cart/customer?billing[company]=Test @@ -1078,31 +1209,31 @@ Example response: ```json { - "id": 0, - "billing": { - "first_name": "Margaret", - "last_name": "Thatchcroft", - "company": "Test", - "address_1": "123 South Street", - "address_2": "Apt 1", - "city": "Philadelphia", - "state": "PA", - "postcode": "19123", - "country": "US", - "email": "test@test.com", - "phone": "" - }, - "shipping": { - "first_name": "Margaret", - "last_name": "Thatchcroft", - "company": "", - "address_1": "123 South Street", - "address_2": "Apt 1", - "city": "Philadelphia", - "state": "PA", - "postcode": "19123", - "country": "US" - } + "id": 0, + "billing_address": { + "first_name": "Margaret", + "last_name": "Thatchcroft", + "company": "Test", + "address_1": "123 South Street", + "address_2": "Apt 1", + "city": "Philadelphia", + "state": "PA", + "postcode": "19123", + "country": "US", + "email": "test@test.com", + "phone": "" + }, + "shipping_address": { + "first_name": "Margaret", + "last_name": "Thatchcroft", + "company": "", + "address_1": "123 South Street", + "address_2": "Apt 1", + "city": "Philadelphia", + "state": "PA", + "postcode": "19123", + "country": "US" + } } ``` diff --git a/src/RestApi/StoreApi/Schemas/CartSchema.php b/src/RestApi/StoreApi/Schemas/CartSchema.php index b087fd535e5..4c45392262d 100644 --- a/src/RestApi/StoreApi/Schemas/CartSchema.php +++ b/src/RestApi/StoreApi/Schemas/CartSchema.php @@ -123,14 +123,14 @@ protected function get_properties() { 'context' => [ 'view', 'edit' ], 'readonly' => true, ], - 'total_tax' => [ - 'description' => __( 'Total tax applied to items and shipping.', 'woo-gutenberg-products-block' ), + 'total_price' => [ + 'description' => __( 'Total price the customer will pay.', 'woo-gutenberg-products-block' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], - 'total_price' => [ - 'description' => __( 'Total price the customer will pay.', 'woo-gutenberg-products-block' ), + 'total_tax' => [ + 'description' => __( 'Total tax applied to items and shipping.', 'woo-gutenberg-products-block' ), 'type' => 'string', 'context' => [ 'view', 'edit' ], 'readonly' => true, @@ -190,8 +190,8 @@ public function get_item_response( $cart ) { 'total_discount_tax' => $this->prepare_money_response( $cart->get_discount_tax(), wc_get_price_decimals() ), 'total_shipping' => $this->prepare_money_response( $cart->get_shipping_total(), wc_get_price_decimals() ), 'total_shipping_tax' => $this->prepare_money_response( $cart->get_shipping_tax(), wc_get_price_decimals() ), - 'total_tax' => $this->prepare_money_response( $cart->get_total_tax(), wc_get_price_decimals() ), 'total_price' => $this->prepare_money_response( $cart->get_total(), wc_get_price_decimals() ), + 'total_tax' => $this->prepare_money_response( $cart->get_total_tax(), wc_get_price_decimals() ), 'tax_lines' => $this->get_tax_lines( $cart ), ] ), diff --git a/src/RestApi/StoreApi/Schemas/CartShippingRateSchema.php b/src/RestApi/StoreApi/Schemas/CartShippingRateSchema.php index 4de515ecc32..7b4b4b24556 100644 --- a/src/RestApi/StoreApi/Schemas/CartShippingRateSchema.php +++ b/src/RestApi/StoreApi/Schemas/CartShippingRateSchema.php @@ -82,7 +82,7 @@ protected function get_properties() { 'type' => 'string', ], ], - 'shipping-rates' => [ + 'shipping_rates' => [ 'description' => __( 'List of shipping rates.', 'woo-gutenberg-products-block' ), 'type' => 'array', 'context' => [ 'view', 'edit' ], @@ -186,7 +186,7 @@ public function get_item_response( $package ) { 'country' => $package['destination']['country'], ], 'items' => array_values( wp_list_pluck( $package['contents'], 'key' ) ), - 'shipping-rates' => array_values( array_map( [ $this, 'get_rate_response' ], $package['rates'] ) ), + 'shipping_rates' => array_values( array_map( [ $this, 'get_rate_response' ], $package['rates'] ) ), ]; } diff --git a/src/RestApi/StoreApi/Schemas/CustomerSchema.php b/src/RestApi/StoreApi/Schemas/CustomerSchema.php index 294a5f3374b..1b0bed56376 100644 --- a/src/RestApi/StoreApi/Schemas/CustomerSchema.php +++ b/src/RestApi/StoreApi/Schemas/CustomerSchema.php @@ -29,13 +29,13 @@ class CustomerSchema extends AbstractSchema { */ protected function get_properties() { return [ - 'id' => [ + 'id' => [ 'description' => __( 'Customer ID. Will return 0 if the customer is logged out.', 'woo-gutenberg-products-block' ), 'type' => 'integer', 'context' => [ 'view', 'edit' ], 'readonly' => true, ], - 'billing' => [ + 'billing_address' => [ 'description' => __( 'List of billing address data.', 'woo-gutenberg-products-block' ), 'type' => 'object', 'context' => [ 'view', 'edit' ], @@ -102,7 +102,7 @@ protected function get_properties() { ], ], ], - 'shipping' => [ + 'shipping_address' => [ 'description' => __( 'List of shipping address data.', 'woo-gutenberg-products-block' ), 'type' => 'object', 'context' => [ 'view', 'edit' ], @@ -165,8 +165,8 @@ protected function get_properties() { */ public function get_item_response( $object ) { return [ - 'id' => $object->get_id(), - 'billing' => [ + 'id' => $object->get_id(), + 'billing_address' => [ 'first_name' => $object->get_billing_first_name(), 'last_name' => $object->get_billing_last_name(), 'company' => $object->get_billing_company(), @@ -179,7 +179,7 @@ public function get_item_response( $object ) { 'email' => $object->get_billing_email(), 'phone' => $object->get_billing_phone(), ], - 'shipping' => [ + 'shipping_address' => [ 'first_name' => $object->get_shipping_first_name(), 'last_name' => $object->get_shipping_last_name(), 'company' => $object->get_shipping_company(), diff --git a/src/RestApi/StoreApi/Schemas/OrderItemSchema.php b/src/RestApi/StoreApi/Schemas/OrderItemSchema.php new file mode 100644 index 00000000000..3312316d007 --- /dev/null +++ b/src/RestApi/StoreApi/Schemas/OrderItemSchema.php @@ -0,0 +1,247 @@ + [ + 'description' => __( 'The item product or variation ID.', 'woo-gutenberg-products-block' ), + 'type' => 'integer', + 'context' => [ 'view', 'edit' ], + 'required' => true, + 'arg_options' => [ + 'sanitize_callback' => 'absint', + 'validate_callback' => [ $this, 'product_id_exists' ], + ], + ], + 'quantity' => [ + 'description' => __( 'Quantity of this item in the cart.', 'woo-gutenberg-products-block' ), + 'type' => 'integer', + 'context' => [ 'view', 'edit' ], + 'required' => true, + 'arg_options' => [ + 'sanitize_callback' => 'wc_stock_amount', + ], + ], + 'name' => [ + 'description' => __( 'Product name.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'context' => [ 'view', 'edit' ], + 'readonly' => true, + ], + 'sku' => [ + 'description' => __( 'Stock keeping unit, if applicable.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'context' => [ 'view', 'edit' ], + 'readonly' => true, + ], + 'permalink' => [ + 'description' => __( 'Product URL.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'format' => 'uri', + 'context' => [ 'view', 'edit' ], + 'readonly' => true, + ], + 'images' => [ + 'description' => __( 'List of images.', 'woo-gutenberg-products-block' ), + 'type' => 'array', + 'context' => [ 'view', 'edit' ], + 'readonly' => true, + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'id' => [ + 'description' => __( 'Image ID.', 'woo-gutenberg-products-block' ), + 'type' => 'integer', + 'context' => [ 'view', 'edit' ], + 'readonly' => true, + ], + 'src' => [ + 'description' => __( 'Image URL.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'format' => 'uri', + 'context' => [ 'view', 'edit' ], + 'readonly' => true, + ], + 'name' => [ + 'description' => __( 'Image name.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'context' => [ 'view', 'edit' ], + 'readonly' => true, + ], + 'alt' => [ + 'description' => __( 'Image alternative text.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'context' => [ 'view', 'edit' ], + 'readonly' => true, + ], + ], + ], + ], + 'variation' => [ + 'description' => __( 'Chosen attributes (for variations).', 'woo-gutenberg-products-block' ), + 'type' => 'array', + 'context' => [ 'view', 'edit' ], + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'attribute' => [ + 'description' => __( 'Variation attribute name.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'context' => [ 'view', 'edit' ], + ], + 'value' => [ + 'description' => __( 'Variation attribute value.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'context' => [ 'view', 'edit' ], + ], + ], + ], + ], + 'totals' => [ + 'description' => __( 'Item total amounts provided using the smallest unit of the currency.', 'woo-gutenberg-products-block' ), + 'type' => 'object', + 'context' => [ 'view', 'edit' ], + 'readonly' => true, + 'properties' => array_merge( + $this->get_store_currency_properties(), + [ + 'line_subtotal' => [ + 'description' => __( 'Line price subtotal (excluding coupons and discounts).', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'context' => [ 'view', 'edit' ], + 'readonly' => true, + ], + 'line_subtotal_tax' => [ + 'description' => __( 'Line price subtotal tax.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'context' => [ 'view', 'edit' ], + 'readonly' => true, + ], + 'line_total' => [ + 'description' => __( 'Line price total (including coupons and discounts).', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'context' => [ 'view', 'edit' ], + 'readonly' => true, + ], + 'line_total_tax' => [ + 'description' => __( 'Line price total tax.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'context' => [ 'view', 'edit' ], + 'readonly' => true, + ], + ] + ), + ], + ]; + } + + /** + * Check given ID exists, + * + * @param integer $product_id Product ID. + * @return bool + */ + public function product_id_exists( $product_id ) { + $post = get_post( (int) $product_id ); + return $post && in_array( $post->post_type, [ 'product', 'product_variation' ], true ); + } + + /** + * Convert a WooCommerce cart item to an object suitable for the response. + * + * @todo Variation is stored to meta - how can we gather for response? + * + * @param \WC_Order_Item_Product $line_item Order line item array. + * @return array + */ + public function get_item_response( \WC_Order_Item_Product $line_item ) { + $product = $line_item->get_product(); + $has_product = $product instanceof \WC_Product; + + return [ + 'id' => $line_item->get_variation_id() ? $line_item->get_variation_id() : $line_item->get_product_id(), + 'quantity' => $line_item->get_quantity(), + 'name' => $has_product ? $product->get_title() : null, + 'sku' => $has_product ? $product->get_sku() : null, + 'permalink' => $has_product ? $product->get_permalink() : null, + 'images' => $has_product ? ( new ProductImages() )->images_to_array( $product ) : null, + 'variation' => $has_product ? $this->format_variation_data( $line_item, $product ) : [], + 'totals' => array_merge( + $this->get_store_currency_response(), + [ + 'line_subtotal' => $this->prepare_money_response( $line_item->get_subtotal(), wc_get_price_decimals() ), + 'line_subtotal_tax' => $this->prepare_money_response( $line_item->get_subtotal_tax(), wc_get_price_decimals() ), + 'line_total' => $this->prepare_money_response( $line_item->get_total(), wc_get_price_decimals() ), + 'line_total_tax' => $this->prepare_money_response( $line_item->get_total_tax(), wc_get_price_decimals() ), + ] + ), + ]; + } + + /** + * Format variation data. For line items we get meta data and format it. + * + * @param \WC_Order_Item_Product $line_item Line item from the order. + * @param \WC_Product $product Product data. + * @return array + */ + protected function format_variation_data( \WC_Order_Item_Product $line_item, \WC_Product $product ) { + $return = []; + $line_item_meta = $line_item->get_meta_data(); + $attribute_keys = array_keys( $product->get_attributes() ); + + foreach ( $line_item_meta as $meta ) { + $key = $meta->key; + $value = $meta->value; + + if ( ! in_array( $key, $attribute_keys, true ) ) { + continue; + } + + $taxonomy = wc_attribute_taxonomy_name( str_replace( 'pa_', '', urldecode( $key ) ) ); + + if ( taxonomy_exists( $taxonomy ) ) { + // If this is a term slug, get the term's nice name. + $term = get_term_by( 'slug', $value, $taxonomy ); + if ( ! is_wp_error( $term ) && $term && $term->name ) { + $value = $term->name; + } + $label = wc_attribute_label( $taxonomy ); + } else { + // If this is a custom option slug, get the options name. + $value = apply_filters( 'woocommerce_variation_option_name', $value, null, $taxonomy, $product ); + $label = wc_attribute_label( $name, $product ); + } + + $return[ $label ] = $value; + } + + return $return; + } +} diff --git a/src/RestApi/StoreApi/Schemas/OrderSchema.php b/src/RestApi/StoreApi/Schemas/OrderSchema.php new file mode 100644 index 00000000000..93eee3fe921 --- /dev/null +++ b/src/RestApi/StoreApi/Schemas/OrderSchema.php @@ -0,0 +1,625 @@ + [ + 'description' => __( 'Unique identifier for the resource.', 'woo-gutenberg-products-block' ), + 'type' => 'integer', + 'context' => [ 'view', 'edit' ], + 'readonly' => true, + ], + 'number' => [ + 'description' => __( 'Generated order number which may differ from the Order ID.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'context' => [ 'view', 'edit' ], + 'readonly' => true, + ], + 'status' => [ + 'description' => __( 'Order status. Payment providers will update this value after payment.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'context' => [ 'view', 'edit' ], + 'readonly' => true, + ], + 'order_key' => [ + 'description' => __( 'Order key used to check validity or protect access to certain order data.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'context' => [ 'view', 'edit' ], + 'readonly' => true, + ], + 'created_via' => [ + 'description' => __( 'Shows where the order was created.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'context' => [ 'view', 'edit' ], + 'readonly' => true, + ], + 'prices_include_tax' => [ + 'description' => __( 'True if the prices included tax when the order was created.', 'woo-gutenberg-products-block' ), + 'type' => 'boolean', + 'context' => [ 'view', 'edit' ], + 'readonly' => true, + ], + 'events' => [ + 'description' => __( 'List of events and dates such as creation date.', 'woo-gutenberg-products-block' ), + 'type' => 'object', + 'context' => [ 'view', 'edit' ], + 'readonly' => true, + 'properties' => [ + 'date_created' => [ + 'description' => __( "The date the order was created, in the site's timezone.", 'woo-gutenberg-products-block' ), + 'type' => 'date-time', + 'context' => [ 'view', 'edit' ], + 'readonly' => true, + ], + 'date_created_gmt' => [ + 'description' => __( 'The date the order was created, as GMT.', 'woo-gutenberg-products-block' ), + 'type' => 'date-time', + 'context' => [ 'view', 'edit' ], + 'readonly' => true, + ], + 'date_modified' => [ + 'description' => __( "The date the order was last modified, in the site's timezone.", 'woo-gutenberg-products-block' ), + 'type' => 'date-time', + 'context' => [ 'view', 'edit' ], + 'readonly' => true, + ], + 'date_modified_gmt' => [ + 'description' => __( 'The date the order was last modified, as GMT.', 'woo-gutenberg-products-block' ), + 'type' => 'date-time', + 'context' => [ 'view', 'edit' ], + 'readonly' => true, + ], + 'date_paid' => [ + 'description' => __( "The date the order was paid, in the site's timezone.", 'woo-gutenberg-products-block' ), + 'type' => 'date-time', + 'context' => [ 'view', 'edit' ], + 'readonly' => true, + ], + 'date_paid_gmt' => [ + 'description' => __( 'The date the order was paid, as GMT.', 'woo-gutenberg-products-block' ), + 'type' => 'date-time', + 'context' => [ 'view', 'edit' ], + 'readonly' => true, + ], + 'date_completed' => [ + 'description' => __( "The date the order was completed, in the site's timezone.", 'woo-gutenberg-products-block' ), + 'type' => 'date-time', + 'context' => [ 'view', 'edit' ], + 'readonly' => true, + ], + 'date_completed_gmt' => [ + 'description' => __( 'The date the order was completed, as GMT.', 'woo-gutenberg-products-block' ), + 'type' => 'date-time', + 'context' => [ 'view', 'edit' ], + 'readonly' => true, + ], + ], + ], + 'customer' => [ + 'description' => __( 'Information about the customer that placed the order.', 'woo-gutenberg-products-block' ), + 'type' => 'object', + 'context' => [ 'view', 'edit' ], + 'readonly' => true, + 'properties' => [ + 'customer_id' => [ + 'description' => __( 'Customer ID if registered. Will return 0 for guest orders.', 'woo-gutenberg-products-block' ), + 'type' => 'integer', + 'context' => [ 'view', 'edit' ], + 'readonly' => true, + ], + 'customer_ip_address' => [ + 'description' => __( 'Customer IP address.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'context' => [ 'view', 'edit' ], + 'readonly' => true, + ], + 'customer_user_agent' => [ + 'description' => __( 'Customer web browser identifier.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'context' => [ 'view', 'edit' ], + 'readonly' => true, + ], + ], + ], + 'customer_note' => [ + 'description' => __( 'Note added to the order by the customer during checkout.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'context' => [ 'view', 'edit' ], + ], + 'billing_address' => [ + 'description' => __( 'Billing address.', 'woo-gutenberg-products-block' ), + 'type' => 'object', + 'context' => [ 'view', 'edit' ], + 'properties' => [ + 'first_name' => [ + 'description' => __( 'First name.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'context' => [ 'view', 'edit' ], + ], + 'last_name' => [ + 'description' => __( 'Last name.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'context' => [ 'view', 'edit' ], + ], + 'company' => [ + 'description' => __( 'Company name.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'context' => [ 'view', 'edit' ], + ], + 'address_1' => [ + 'description' => __( 'Address line 1', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'context' => [ 'view', 'edit' ], + ], + 'address_2' => [ + 'description' => __( 'Address line 2', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'context' => [ 'view', 'edit' ], + ], + 'city' => [ + 'description' => __( 'City name.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'context' => [ 'view', 'edit' ], + ], + 'state' => [ + 'description' => __( 'ISO code or name of the state, province or district.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'context' => [ 'view', 'edit' ], + ], + 'postcode' => [ + 'description' => __( 'Postal code.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'context' => [ 'view', 'edit' ], + ], + 'country' => [ + 'description' => __( 'Country code in ISO 3166-1 alpha-2 format.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'context' => [ 'view', 'edit' ], + ], + 'email' => [ + 'description' => __( 'Email address.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'format' => 'email', + 'context' => [ 'view', 'edit' ], + ], + 'phone' => [ + 'description' => __( 'Phone number.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'context' => [ 'view', 'edit' ], + ], + ], + ], + 'shipping_address' => [ + 'description' => __( 'Shipping address.', 'woo-gutenberg-products-block' ), + 'type' => 'object', + 'context' => [ 'view', 'edit' ], + 'properties' => [ + 'first_name' => [ + 'description' => __( 'First name.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'context' => [ 'view', 'edit' ], + ], + 'last_name' => [ + 'description' => __( 'Last name.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'context' => [ 'view', 'edit' ], + ], + 'company' => [ + 'description' => __( 'Company name.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'context' => [ 'view', 'edit' ], + ], + 'address_1' => [ + 'description' => __( 'Address line 1', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'context' => [ 'view', 'edit' ], + ], + 'address_2' => [ + 'description' => __( 'Address line 2', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'context' => [ 'view', 'edit' ], + ], + 'city' => [ + 'description' => __( 'City name.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'context' => [ 'view', 'edit' ], + ], + 'state' => [ + 'description' => __( 'ISO code or name of the state, province or district.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'context' => [ 'view', 'edit' ], + ], + 'postcode' => [ + 'description' => __( 'Postal code.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'context' => [ 'view', 'edit' ], + ], + 'country' => [ + 'description' => __( 'Country code in ISO 3166-1 alpha-2 format.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'context' => [ 'view', 'edit' ], + ], + ], + ], + 'coupons' => [ + 'description' => __( 'List of applied cart coupons.', 'woo-gutenberg-products-block' ), + 'type' => 'array', + 'context' => [ 'view', 'edit' ], + 'readonly' => true, + 'items' => [ + 'type' => 'object', + 'properties' => $this->force_schema_readonly( ( new CartCouponSchema() )->get_properties() ), + ], + ], + 'items' => [ + 'description' => __( 'List of cart items.', 'woo-gutenberg-products-block' ), + 'type' => 'array', + 'context' => [ 'view', 'edit' ], + 'readonly' => true, + 'items' => [ + 'type' => 'object', + 'properties' => $this->force_schema_readonly( ( new OrderItemSchema() )->get_properties() ), + ], + ], + 'shipping_lines' => [ + 'description' => __( 'Shipping lines data.', 'woo-gutenberg-products-block' ), + 'type' => 'array', + 'context' => [ 'view', 'edit' ], + 'readonly' => true, + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'id' => [ + 'description' => __( 'Item ID.', 'woo-gutenberg-products-block' ), + 'type' => 'integer', + 'context' => [ 'view', 'edit' ], + 'readonly' => true, + ], + 'method_title' => [ + 'description' => __( 'Shipping method name.', 'woo-gutenberg-products-block' ), + 'type' => 'mixed', + 'context' => [ 'view', 'edit' ], + 'readonly' => true, + ], + 'method_id' => [ + 'description' => __( 'Shipping method ID.', 'woo-gutenberg-products-block' ), + 'type' => 'mixed', + 'context' => [ 'view', 'edit' ], + 'readonly' => true, + ], + 'instance_id' => [ + 'description' => __( 'Shipping instance ID.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'context' => [ 'view', 'edit' ], + 'readonly' => true, + ], + 'total' => [ + 'description' => __( 'Line total (after discounts).', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'context' => [ 'view', 'edit' ], + 'readonly' => true, + ], + 'total_tax' => [ + 'description' => __( 'Line total tax (after discounts).', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'context' => [ 'view', 'edit' ], + 'readonly' => true, + ], + 'taxes' => [ + 'description' => __( 'Line taxes.', 'woo-gutenberg-products-block' ), + 'type' => 'array', + 'context' => [ 'view', 'edit' ], + 'readonly' => true, + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'id' => [ + 'description' => __( 'Tax rate ID.', 'woo-gutenberg-products-block' ), + 'type' => 'integer', + 'context' => [ 'view', 'edit' ], + 'readonly' => true, + ], + 'total' => [ + 'description' => __( 'Tax total.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'context' => [ 'view', 'edit' ], + 'readonly' => true, + ], + ], + ], + ], + ], + ], + ], + 'totals' => [ + 'description' => __( 'Total amounts provided using the smallest unit of the currency.', 'woo-gutenberg-products-block' ), + 'type' => 'object', + 'context' => [ 'view', 'edit' ], + 'readonly' => true, + 'properties' => array_merge( + $this->get_store_currency_properties(), + [ + 'total_items' => [ + 'description' => __( 'Total price of items in the order.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'context' => [ 'view', 'edit' ], + 'readonly' => true, + ], + 'total_items_tax' => [ + 'description' => __( 'Total tax on items in the order.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'context' => [ 'view', 'edit' ], + 'readonly' => true, + ], + 'total_fees' => [ + 'description' => __( 'Total price of any applied fees.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'context' => [ 'view', 'edit' ], + 'readonly' => true, + ], + 'total_fees_tax' => [ + 'description' => __( 'Total tax on fees.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'context' => [ 'view', 'edit' ], + 'readonly' => true, + ], + 'total_discount' => [ + 'description' => __( 'Total discount from applied coupons.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'context' => [ 'view', 'edit' ], + 'readonly' => true, + ], + 'total_discount_tax' => [ + 'description' => __( 'Total tax removed due to discount from applied coupons.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'context' => [ 'view', 'edit' ], + 'readonly' => true, + ], + 'total_shipping' => [ + 'description' => __( 'Total price of shipping.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'context' => [ 'view', 'edit' ], + 'readonly' => true, + ], + 'total_shipping_tax' => [ + 'description' => __( 'Total tax on shipping.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'context' => [ 'view', 'edit' ], + 'readonly' => true, + ], + 'total_price' => [ + 'description' => __( 'Total price the customer will pay.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'context' => [ 'view', 'edit' ], + 'readonly' => true, + ], + 'total_tax' => [ + 'description' => __( 'Total tax applied to items and shipping.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'context' => [ 'view', 'edit' ], + 'readonly' => true, + ], + 'tax_lines' => [ + 'description' => __( 'Lines of taxes applied to items and shipping.', 'woo-gutenberg-products-block' ), + 'type' => 'array', + 'context' => [ 'view', 'edit' ], + 'readonly' => true, + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'name' => [ + 'description' => __( 'The name of the tax.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'context' => [ 'view', 'edit' ], + 'readonly' => true, + ], + 'price' => [ + 'description' => __( 'The amount of tax charged.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'context' => [ 'view', 'edit' ], + 'readonly' => true, + ], + ], + ], + ], + ] + ), + ], + ]; + } + + /** + * Convert a woo order into an object suitable for the response. + * + * @param \WC_Order $order Order class instance. + * @return array + */ + public function get_item_response( \WC_Order $order ) { + $order_item_schema = new OrderItemSchema(); + + return [ + 'id' => $order->get_id(), + 'number' => $order->get_order_number(), + 'status' => $order->get_status(), + 'order_key' => $order->get_order_key(), + 'created_via' => $order->get_created_via(), + 'prices_include_tax' => $order->get_prices_include_tax(), + 'events' => $this->get_events( $order ), + 'customer' => [ + 'customer_id' => $order->get_customer_id(), + 'customer_ip_address' => $order->get_customer_ip_address(), + 'customer_user_agent' => $order->get_customer_user_agent(), + ], + 'customer_note' => $order->get_customer_note(), + 'billing_address' => [ + 'first_name' => $order->get_billing_first_name(), + 'last_name' => $order->get_billing_last_name(), + 'company' => $order->get_billing_company(), + 'address_1' => $order->get_billing_address_1(), + 'address_2' => $order->get_billing_address_2(), + 'city' => $order->get_billing_city(), + 'state' => $order->get_billing_state(), + 'postcode' => $order->get_billing_postcode(), + 'country' => $order->get_billing_country(), + 'email' => $order->get_billing_email(), + 'phone' => $order->get_billing_phone(), + ], + 'shipping_address' => [ + 'first_name' => $order->get_shipping_first_name(), + 'last_name' => $order->get_shipping_last_name(), + 'company' => $order->get_shipping_company(), + 'address_1' => $order->get_shipping_address_1(), + 'address_2' => $order->get_shipping_address_2(), + 'city' => $order->get_shipping_city(), + 'state' => $order->get_shipping_state(), + 'postcode' => $order->get_shipping_postcode(), + 'country' => $order->get_shipping_country(), + ], + 'items' => array_values( array_map( [ $order_item_schema, 'get_item_response' ], $order->get_items( 'line_item' ) ) ), + 'totals' => array_merge( + $this->get_store_currency_response(), + [ + 'total_items' => $this->prepare_money_response( $order->get_subtotal(), wc_get_price_decimals() ), + 'total_items_tax' => $this->prepare_money_response( $this->get_subtotal_tax( $order ), wc_get_price_decimals() ), + 'total_fees' => $this->prepare_money_response( $this->get_fee_total( $order ), wc_get_price_decimals() ), + 'total_fees_tax' => $this->prepare_money_response( $this->get_fee_tax( $order ), wc_get_price_decimals() ), + 'total_discount' => $this->prepare_money_response( $order->get_discount_total(), wc_get_price_decimals() ), + 'total_discount_tax' => $this->prepare_money_response( $order->get_discount_tax(), wc_get_price_decimals() ), + 'total_shipping' => $this->prepare_money_response( $order->get_shipping_total(), wc_get_price_decimals() ), + 'total_shipping_tax' => $this->prepare_money_response( $order->get_shipping_tax(), wc_get_price_decimals() ), + 'total_price' => $this->prepare_money_response( $order->get_total(), wc_get_price_decimals() ), + 'total_tax' => $this->prepare_money_response( $order->get_total_tax(), wc_get_price_decimals() ), + 'tax_lines' => $this->get_tax_lines( $order ), + ] + ), + ]; + } + + /** + * Removes the wc- prefix from order statuses. + * + * @param string $status Status from the order. + * @return string + */ + protected function remove_status_prefix( $status ) { + return 'wc-' === substr( $status, 0, 3 ) ? substr( $status, 3 ) : $status; + } + + /** + * Get event dates from an order, formatting both local and GMT values. + * + * @param \WC_Order $order Order class instance. + * @return array + */ + protected function get_events( \WC_Order $order ) { + $events = []; + $props = [ 'date_created', 'date_modified', 'date_completed', 'date_paid' ]; + + foreach ( $props as $prop ) { + $datetime = $order->{"get_$prop"}(); + $events[ $prop ] = wc_rest_prepare_date_response( $datetime, false ); + $events[ $prop . '_gmt' ] = wc_rest_prepare_date_response( $datetime ); + } + + return $events; + } + + /** + * Get tax lines from the order and format to match schema. + * + * @param \WC_Order $order Order class instance. + * @return array + */ + protected function get_tax_lines( \WC_Order $order ) { + $tax_totals = $order->get_tax_totals(); + $tax_lines = []; + + foreach ( $tax_totals as $tax_total ) { + $tax_lines[] = array( + 'name' => $tax_total->label, + 'price' => $this->prepare_money_response( $tax_total->amount, wc_get_price_decimals() ), + ); + } + + return $tax_lines; + } + + /** + * Get the total amount of tax for line items. + * + * Needed because orders do not hold this total like carts. + * + * @todo In the future this could be added to the core WC_Order class to better match the WC_Cart class. + * + * @param \WC_Order $order Order class instance. + * @return float + */ + protected function get_subtotal_tax( \WC_Order $order ) { + $total = 0; + foreach ( $order->get_items() as $item ) { + $total += $item->get_subtotal_tax(); + } + return $total; + } + /** + * Get the total amount of fees. + * + * Needed because orders do not hold this total like carts. + * + * @todo In the future this could be added to the core WC_Order class to better match the WC_Cart class. + * + * @param \WC_Order $order Order class instance. + * @return float + */ + protected function get_fee_total( \WC_Order $order ) { + $total = 0; + foreach ( $order->get_fees() as $item ) { + $total += $item->get_total(); + } + return $total; + } + + /** + * Get the total tax of fees. + * + * Needed because orders do not hold this total like carts. + * + * @todo In the future this could be added to the core WC_Order class to better match the WC_Cart class. + * + * @param \WC_Order $order Order class instance. + * @return float + */ + protected function get_fee_tax( \WC_Order $order ) { + $total = 0; + foreach ( $order->get_fees() as $item ) { + $total += $item->get_total_tax(); + } + return $total; + } +} diff --git a/src/RestApi/StoreApi/Utilities/ReserveStock.php b/src/RestApi/StoreApi/Utilities/ReserveStock.php new file mode 100644 index 00000000000..31569b36bd4 --- /dev/null +++ b/src/RestApi/StoreApi/Utilities/ReserveStock.php @@ -0,0 +1,140 @@ +get_items(), + function( $item ) { + return $item->is_type( 'line_item' ) && $item->get_product() instanceof \WC_Product; + } + ); + + foreach ( $items as $item ) { + $product = $item->get_product(); + + if ( ! $product->is_in_stock() ) { + return new WP_Error( + 'product_out_of_stock', + sprintf( + /* translators: %s: product name */ + __( '%s is out of stock and cannot be purchased.', 'woo-gutenberg-products-block' ), + $product->get_name() + ), + [ 'status' => 403 ] + ); + } + + // If stock management is off, no need to reserve any stock here. + if ( ! $product->managing_stock() || $product->backorders_allowed() ) { + continue; + } + + $product_id = $product->get_stock_managed_by_id(); + $stock_to_reserve[ $product_id ] = isset( $stock_to_reserve[ $product_id ] ) ? $stock_to_reserve[ $product_id ] : 0; + $reserved_stock = $this->get_reserved_stock( $product, $order->get_id() ); + + if ( ( $product->get_stock_quantity() - $reserved_stock - $stock_to_reserve[ $product_id ] ) < $item->get_quantity() ) { + return new WP_Error( + 'product_not_enough_stock', + sprintf( + /* translators: %s: product name */ + __( 'Not enough units of %s are available in stock to fulfil this order.', 'woo-gutenberg-products-block' ), + $product->get_name() + ), + [ 'status' => 403 ] + ); + } + + // Queue for later DB insertion. + $stock_to_reserve[ $product_id ] += $item->get_quantity(); + } + + $this->reserve_stock( $stock_to_reserve, $order->get_id() ); + + return true; + } + + /** + * Reserve stock by inserting rows into the DB. + * + * @param array $stock_to_reserve Array of Product ID => Qty pairs. + * @param integer $order_id Order ID for which to reserve stock. + */ + protected function reserve_stock( $stock_to_reserve, $order_id ) { + global $wpdb; + + $stock_to_reserve = array_filter( $stock_to_reserve ); + + if ( ! $stock_to_reserve ) { + return; + } + + $stock_to_reserve_rows = []; + + foreach ( $stock_to_reserve as $product_id => $stock_quantity ) { + $stock_to_reserve_rows[] = '(' . esc_sql( $order_id ) . ',"' . esc_sql( $product_id ) . '","' . esc_sql( $stock_quantity ) . '")'; + } + + $values = implode( ',', $stock_to_reserve_rows ); + + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared + $wpdb->query( "REPLACE INTO {$wpdb->wc_reserved_stock} ( order_id, product_id, stock_quantity ) VALUES {$values};" ); + } + + /** + * Query for any existing holds on stock for this item. + * + * - Can ignore reserved stock for a specific order. + * - Ignores stock for orders which are no longer drafts (assuming real stock reduction was performed). + * - Ignores stock reserved over 10 mins ago. + * + * @param \WC_Product $product Product to get reserved stock for. + * @param integer $exclude_order_id Optional order to exclude from the results. + * @return integer Amount of stock already reserved. + */ + public function get_reserved_stock( \WC_Product $product, $exclude_order_id = 0 ) { + global $wpdb; + + $reserved_stock = $wpdb->get_var( + $wpdb->prepare( + " + SELECT SUM( stock_table.`stock_quantity` ) FROM $wpdb->wc_reserved_stock stock_table + LEFT JOIN $wpdb->posts posts ON stock_table.`order_id` = posts.ID + WHERE stock_table.`product_id` = %d + AND posts.post_status = 'wc-checkout-draft' + AND stock_table.`order_id` != %d + AND stock_table.`timestamp` > ( NOW() - INTERVAL 10 MINUTE ) + ", + $product->get_stock_managed_by_id(), + $exclude_order_id + ) + ); + + // Deals with legacy stock reservation which the core Woo checkout performs. + $hold_stock_minutes = (int) get_option( 'woocommerce_hold_stock_minutes', 0 ); + $reserved_stock += ( $hold_stock_minutes > 0 ) ? wc_get_held_stock_quantity( $product ) : 0; + + return $reserved_stock; + } +} diff --git a/tests/php/RestApi/StoreApi/Controllers/CartOrder.php b/tests/php/RestApi/StoreApi/Controllers/CartOrder.php new file mode 100644 index 00000000000..599c7716f23 --- /dev/null +++ b/tests/php/RestApi/StoreApi/Controllers/CartOrder.php @@ -0,0 +1,157 @@ +products = []; + + // Create some test products. + $this->products[0] = ProductHelper::create_simple_product( false ); + $this->products[0]->set_weight( 10 ); + $this->products[0]->set_regular_price( 10 ); + $this->products[0]->save(); + + $this->products[1] = ProductHelper::create_simple_product( false ); + $this->products[1]->set_weight( 10 ); + $this->products[1]->set_regular_price( 10 ); + $this->products[1]->save(); + + wc_empty_cart(); + + $this->keys = []; + $this->keys[] = wc()->cart->add_to_cart( $this->products[0]->get_id(), 2 ); + $this->keys[] = wc()->cart->add_to_cart( $this->products[1]->get_id(), 1 ); + } + + /** + * Test route registration. + */ + public function test_register_routes() { + $routes = $this->server->get_routes(); + $this->assertArrayHasKey( '/wc/store/cart/order', $routes ); + + $request = new WP_REST_Request( 'GET', '/wc/store/cart/order' ); + $response = $this->server->dispatch( $request ); + $this->assertEquals( 404, $response->get_status() ); + } + + /** + * Test order creation from cart data. + */ + public function test_create_item() { + $request = new WP_REST_Request( 'POST', '/wc/store/cart/order' ); + $request->set_param( + 'billing_address', + [ + 'first_name' => 'Margaret', + 'last_name' => 'Thatchcroft', + 'address_1' => '123 South Street', + 'address_2' => 'Apt 1', + 'city' => 'Philadelphia', + 'state' => 'PA', + 'postcode' => '19123', + 'country' => 'US', + 'email' => 'test@test.com', + 'phone' => '', + ] + ); + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( 201, $response->get_status() ); + + $this->assertArrayHasKey( 'id', $data ); + $this->assertArrayHasKey( 'number', $data ); + $this->assertArrayHasKey( 'status', $data ); + $this->assertArrayHasKey( 'order_key', $data ); + $this->assertArrayHasKey( 'created_via', $data ); + $this->assertArrayHasKey( 'prices_include_tax', $data ); + $this->assertArrayHasKey( 'events', $data ); + $this->assertArrayHasKey( 'customer', $data ); + $this->assertArrayHasKey( 'billing_address', $data ); + $this->assertArrayHasKey( 'shipping_address', $data ); + $this->assertArrayHasKey( 'customer_note', $data ); + $this->assertArrayHasKey( 'items', $data ); + $this->assertArrayHasKey( 'totals', $data ); + + $this->assertEquals( 'Margaret', $data['billing_address']['first_name'] ); + $this->assertEquals( 'Thatchcroft', $data['billing_address']['last_name'] ); + $this->assertEquals( '123 South Street', $data['billing_address']['address_1'] ); + $this->assertEquals( 'Apt 1', $data['billing_address']['address_2'] ); + $this->assertEquals( 'Philadelphia', $data['billing_address']['city'] ); + $this->assertEquals( 'PA', $data['billing_address']['state'] ); + $this->assertEquals( '19123', $data['billing_address']['postcode'] ); + $this->assertEquals( 'US', $data['billing_address']['country'] ); + $this->assertEquals( 'test@test.com', $data['billing_address']['email'] ); + $this->assertEquals( '', $data['billing_address']['phone'] ); + + $this->assertEquals( 'checkout-draft', $data['status'] ); + $this->assertEquals( 2, count( $data['items'] ) ); + } + + /** + * Test schema retrieval. + */ + public function test_get_item_schema() { + $controller = new \Automattic\WooCommerce\Blocks\RestApi\StoreApi\Controllers\CartOrder(); + $schema = $controller->get_item_schema(); + + $this->assertArrayHasKey( 'id', $schema['properties'] ); + $this->assertArrayHasKey( 'number', $schema['properties'] ); + $this->assertArrayHasKey( 'status', $schema['properties'] ); + $this->assertArrayHasKey( 'order_key', $schema['properties'] ); + $this->assertArrayHasKey( 'created_via', $schema['properties'] ); + $this->assertArrayHasKey( 'prices_include_tax', $schema['properties'] ); + $this->assertArrayHasKey( 'events', $schema['properties'] ); + $this->assertArrayHasKey( 'customer', $schema['properties'] ); + $this->assertArrayHasKey( 'billing_address', $schema['properties'] ); + $this->assertArrayHasKey( 'shipping_address', $schema['properties'] ); + $this->assertArrayHasKey( 'customer_note', $schema['properties'] ); + $this->assertArrayHasKey( 'items', $schema['properties'] ); + $this->assertArrayHasKey( 'totals', $schema['properties'] ); + } + + /** + * Test conversion of cart item to rest response. + */ + public function test_prepare_item_for_response() { + $controller = new \Automattic\WooCommerce\Blocks\RestApi\StoreApi\Controllers\CartOrder(); + $order = OrderHelper::create_order(); + $response = $controller->prepare_item_for_response( $order, [] ); + + $this->assertArrayHasKey( 'id', $response->get_data() ); + $this->assertArrayHasKey( 'number', $response->get_data() ); + $this->assertArrayHasKey( 'status', $response->get_data() ); + $this->assertArrayHasKey( 'order_key', $response->get_data() ); + $this->assertArrayHasKey( 'created_via', $response->get_data() ); + $this->assertArrayHasKey( 'prices_include_tax', $response->get_data() ); + $this->assertArrayHasKey( 'events', $response->get_data() ); + $this->assertArrayHasKey( 'customer', $response->get_data() ); + $this->assertArrayHasKey( 'billing_address', $response->get_data() ); + $this->assertArrayHasKey( 'shipping_address', $response->get_data() ); + $this->assertArrayHasKey( 'customer_note', $response->get_data() ); + $this->assertArrayHasKey( 'items', $response->get_data() ); + $this->assertArrayHasKey( 'totals', $response->get_data() ); + } +} diff --git a/tests/php/RestApi/StoreApi/Controllers/CartShippingRates.php b/tests/php/RestApi/StoreApi/Controllers/CartShippingRates.php index 34fb3dade72..c0aa1d4ec2a 100644 --- a/tests/php/RestApi/StoreApi/Controllers/CartShippingRates.php +++ b/tests/php/RestApi/StoreApi/Controllers/CartShippingRates.php @@ -59,7 +59,7 @@ public function test_get_items() { $this->assertArrayHasKey( 'destination', $data[0] ); $this->assertArrayHasKey( 'items', $data[0] ); - $this->assertArrayHasKey( 'shipping-rates', $data[0] ); + $this->assertArrayHasKey( 'shipping_rates', $data[0] ); $this->assertEquals( null, $data[0]['destination']['address_1'] ); $this->assertEquals( null, $data[0]['destination']['address_2'] ); @@ -73,7 +73,7 @@ public function test_get_items() { * Test getting shipping. */ public function test_get_items_missing_address() { - $request = new WP_REST_Request( 'GET', '/wc/store/cart/shipping-rates' ); + $request = new WP_REST_Request( 'GET', '/wc/store/cart/shipping-rates' ); $response = $this->server->dispatch( $request ); $this->assertEquals( 400, $response->get_status() ); } @@ -131,15 +131,15 @@ public function test_get_item_schema() { $this->assertArrayHasKey( 'destination', $schema['properties'] ); $this->assertArrayHasKey( 'items', $schema['properties'] ); - $this->assertArrayHasKey( 'shipping-rates', $schema['properties'] ); - $this->assertArrayHasKey( 'name', $schema['properties']['shipping-rates']['items']['properties'] ); - $this->assertArrayHasKey( 'description', $schema['properties']['shipping-rates']['items']['properties'] ); - $this->assertArrayHasKey( 'delivery_time', $schema['properties']['shipping-rates']['items']['properties'] ); - $this->assertArrayHasKey( 'price', $schema['properties']['shipping-rates']['items']['properties'] ); - $this->assertArrayHasKey( 'rate_id', $schema['properties']['shipping-rates']['items']['properties'] ); - $this->assertArrayHasKey( 'instance_id', $schema['properties']['shipping-rates']['items']['properties'] ); - $this->assertArrayHasKey( 'method_id', $schema['properties']['shipping-rates']['items']['properties'] ); - $this->assertArrayHasKey( 'meta_data', $schema['properties']['shipping-rates']['items']['properties'] ); + $this->assertArrayHasKey( 'shipping_rates', $schema['properties'] ); + $this->assertArrayHasKey( 'name', $schema['properties']['shipping_rates']['items']['properties'] ); + $this->assertArrayHasKey( 'description', $schema['properties']['shipping_rates']['items']['properties'] ); + $this->assertArrayHasKey( 'delivery_time', $schema['properties']['shipping_rates']['items']['properties'] ); + $this->assertArrayHasKey( 'price', $schema['properties']['shipping_rates']['items']['properties'] ); + $this->assertArrayHasKey( 'rate_id', $schema['properties']['shipping_rates']['items']['properties'] ); + $this->assertArrayHasKey( 'instance_id', $schema['properties']['shipping_rates']['items']['properties'] ); + $this->assertArrayHasKey( 'method_id', $schema['properties']['shipping_rates']['items']['properties'] ); + $this->assertArrayHasKey( 'meta_data', $schema['properties']['shipping_rates']['items']['properties'] ); } /** @@ -152,7 +152,7 @@ public function test_prepare_item_for_response() { $this->assertArrayHasKey( 'destination', $response->get_data() ); $this->assertArrayHasKey( 'items', $response->get_data() ); - $this->assertArrayHasKey( 'shipping-rates', $response->get_data() ); + $this->assertArrayHasKey( 'shipping_rates', $response->get_data() ); } /** diff --git a/tests/php/RestApi/StoreApi/Controllers/Customer.php b/tests/php/RestApi/StoreApi/Controllers/Customer.php index 1084e751dc8..8f433af0733 100644 --- a/tests/php/RestApi/StoreApi/Controllers/Customer.php +++ b/tests/php/RestApi/StoreApi/Controllers/Customer.php @@ -32,8 +32,8 @@ public function test_get_item() { $this->assertEquals( 200, $response->get_status() ); $this->assertArrayHasKey( 'id', $data ); - $this->assertArrayHasKey( 'billing', $data ); - $this->assertArrayHasKey( 'shipping', $data ); + $this->assertArrayHasKey( 'billing_address', $data ); + $this->assertArrayHasKey( 'shipping_address', $data ); } /** @@ -43,7 +43,7 @@ public function test_update_item() { $request = new WP_REST_Request( 'POST', '/wc/store/customer' ); $request->set_body_params( [ - 'billing' => [ + 'billing_address' => [ 'address_1' => '123 South Street', 'address_2' => 'Apt 1', 'city' => 'Philadelphia', @@ -57,18 +57,18 @@ public function test_update_item() { $data = $response->get_data(); $this->assertEquals( 200, $response->get_status() ); - $this->assertEquals( '123 South Street', $data['billing']['address_1'] ); - $this->assertEquals( 'Apt 1', $data['billing']['address_2'] ); - $this->assertEquals( 'Philadelphia', $data['billing']['city'] ); - $this->assertEquals( 'PA', $data['billing']['state'] ); - $this->assertEquals( '19123', $data['billing']['postcode'] ); - $this->assertEquals( 'US', $data['billing']['country'] ); + $this->assertEquals( '123 South Street', $data['billing_address']['address_1'] ); + $this->assertEquals( 'Apt 1', $data['billing_address']['address_2'] ); + $this->assertEquals( 'Philadelphia', $data['billing_address']['city'] ); + $this->assertEquals( 'PA', $data['billing_address']['state'] ); + $this->assertEquals( '19123', $data['billing_address']['postcode'] ); + $this->assertEquals( 'US', $data['billing_address']['country'] ); // Invalid email. $request = new WP_REST_Request( 'POST', '/wc/store/customer' ); $request->set_body_params( [ - 'billing' => [ + 'billing_address' => [ 'email' => 'not-an-email', ], ] @@ -87,8 +87,8 @@ public function test_get_item_schema() { $schema = $controller->get_item_schema(); $this->assertArrayHasKey( 'id', $schema['properties'] ); - $this->assertArrayHasKey( 'billing', $schema['properties'] ); - $this->assertArrayHasKey( 'shipping', $schema['properties'] ); + $this->assertArrayHasKey( 'billing_address', $schema['properties'] ); + $this->assertArrayHasKey( 'shipping_address', $schema['properties'] ); } /** @@ -121,22 +121,22 @@ public function test_prepare_item_for_response() { $response = $controller->prepare_item_for_response( $customer, [] ); $data = $response->get_data(); - $this->assertEquals( 'Name', $data['billing']['first_name'] ); - $this->assertEquals( 'Surname', $data['billing']['last_name'] ); - $this->assertEquals( '123 South Street', $data['billing']['address_1'] ); - $this->assertEquals( 'Apt 1', $data['billing']['address_2'] ); - $this->assertEquals( 'Philadelphia', $data['billing']['city'] ); - $this->assertEquals( 'PA', $data['billing']['state'] ); - $this->assertEquals( '19123', $data['billing']['postcode'] ); - $this->assertEquals( 'US', $data['billing']['country'] ); - - $this->assertEquals( 'Name', $data['shipping']['first_name'] ); - $this->assertEquals( 'Surname', $data['shipping']['last_name'] ); - $this->assertEquals( '123 South Street', $data['shipping']['address_1'] ); - $this->assertEquals( 'Apt 1', $data['shipping']['address_2'] ); - $this->assertEquals( 'Philadelphia', $data['shipping']['city'] ); - $this->assertEquals( 'PA', $data['shipping']['state'] ); - $this->assertEquals( '19123', $data['shipping']['postcode'] ); - $this->assertEquals( 'US', $data['shipping']['country'] ); + $this->assertEquals( 'Name', $data['billing_address']['first_name'] ); + $this->assertEquals( 'Surname', $data['billing_address']['last_name'] ); + $this->assertEquals( '123 South Street', $data['billing_address']['address_1'] ); + $this->assertEquals( 'Apt 1', $data['billing_address']['address_2'] ); + $this->assertEquals( 'Philadelphia', $data['billing_address']['city'] ); + $this->assertEquals( 'PA', $data['billing_address']['state'] ); + $this->assertEquals( '19123', $data['billing_address']['postcode'] ); + $this->assertEquals( 'US', $data['billing_address']['country'] ); + + $this->assertEquals( 'Name', $data['shipping_address']['first_name'] ); + $this->assertEquals( 'Surname', $data['shipping_address']['last_name'] ); + $this->assertEquals( '123 South Street', $data['shipping_address']['address_1'] ); + $this->assertEquals( 'Apt 1', $data['shipping_address']['address_2'] ); + $this->assertEquals( 'Philadelphia', $data['shipping_address']['city'] ); + $this->assertEquals( 'PA', $data['shipping_address']['state'] ); + $this->assertEquals( '19123', $data['shipping_address']['postcode'] ); + $this->assertEquals( 'US', $data['shipping_address']['country'] ); } } diff --git a/tests/php/RestApi/StoreApi/Utilities/ReserveStock.php b/tests/php/RestApi/StoreApi/Utilities/ReserveStock.php new file mode 100644 index 00000000000..387252d46b3 --- /dev/null +++ b/tests/php/RestApi/StoreApi/Utilities/ReserveStock.php @@ -0,0 +1,72 @@ +set_manage_stock( true ); + $product->set_stock( 10 ); + $product->save(); + + $order = OrderHelper::create_order( 1, $product ); // Note this adds 4 to the order. + $order->set_status( 'checkout-draft' ); + $order->save(); + + $result = $class->reserve_stock_for_order( $order ); + $this->assertTrue( $result ); + $this->assertEquals( 4, $this->get_reserved_stock_by_product_id( $product->get_stock_managed_by_id() ) ); + + // Repeat. + $order = OrderHelper::create_order( 1, $product ); + $order->set_status( 'checkout-draft' ); + $order->save(); + + $result = $class->reserve_stock_for_order( $order ); + $this->assertTrue( $result ); + $this->assertEquals( 8, $this->get_reserved_stock_by_product_id( $product->get_stock_managed_by_id() ) ); + + // Repeat again - should not be enough stock for this. + $order = OrderHelper::create_order( 1, $product ); + $order->set_status( 'checkout-draft' ); + $order->save(); + + $result = $class->reserve_stock_for_order( $order ); + $this->assertTrue( is_wp_error( $result ) ); + } + + /** + * Helper to get the count of reserved stock. + * + * @param integer $product_id + * @return integer + */ + protected function get_reserved_stock_by_product_id( $product_id ) { + global $wpdb; + return $wpdb->get_var( + $wpdb->prepare( + "SELECT SUM( stock_table.`stock_quantity` ) FROM $wpdb->wc_reserved_stock stock_table WHERE stock_table.`product_id` = %d", + $product_id + ) + ); + } +}