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
+ )
+ );
+ }
+}