From 1b3a7c3bd2960ecb6cb506fd7957cef5fe3cc47e Mon Sep 17 00:00:00 2001 From: Mike Jolley Date: Fri, 20 Dec 2019 15:42:06 +0000 Subject: [PATCH 01/28] Checkout/order WIP schema --- src/RestApi.php | 1 + src/RestApi/StoreApi/Controllers/Checkout.php | 161 +++++ src/RestApi/StoreApi/Schemas/CartSchema.php | 10 +- .../StoreApi/Schemas/OrderItemSchema.php | 278 ++++++++ src/RestApi/StoreApi/Schemas/OrderSchema.php | 650 ++++++++++++++++++ 5 files changed, 1095 insertions(+), 5 deletions(-) create mode 100644 src/RestApi/StoreApi/Controllers/Checkout.php create mode 100644 src/RestApi/StoreApi/Schemas/OrderItemSchema.php create mode 100644 src/RestApi/StoreApi/Schemas/OrderSchema.php diff --git a/src/RestApi.php b/src/RestApi.php index 5aab1958172..0f2c18b5757 100644 --- a/src/RestApi.php +++ b/src/RestApi.php @@ -97,6 +97,7 @@ protected static function get_controllers() { 'store-cart-coupons' => __NAMESPACE__ . '\RestApi\StoreApi\Controllers\CartCoupons', 'store-cart-shipping-rates' => __NAMESPACE__ . '\RestApi\StoreApi\Controllers\CartShippingRates', 'store-customer' => __NAMESPACE__ . '\RestApi\StoreApi\Controllers\Customer', + 'store-checkout' => __NAMESPACE__ . '\RestApi\StoreApi\Controllers\Checkout', 'store-products' => __NAMESPACE__ . '\RestApi\StoreApi\Controllers\Products', 'store-product-collection-data' => __NAMESPACE__ . '\RestApi\StoreApi\Controllers\ProductCollectionData', 'store-product-attributes' => __NAMESPACE__ . '\RestApi\StoreApi\Controllers\ProductAttributes', diff --git a/src/RestApi/StoreApi/Controllers/Checkout.php b/src/RestApi/StoreApi/Controllers/Checkout.php new file mode 100644 index 00000000000..c7dcc57784f --- /dev/null +++ b/src/RestApi/StoreApi/Controllers/Checkout.php @@ -0,0 +1,161 @@ +order_schema = new OrderSchema(); + } + + /** + * Register routes. + */ + public function register_routes() { + register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/order', + [ + [ + 'methods' => RestServer::CREATABLE, + 'callback' => array( $this, 'create_order_from_cart' ), + 'args' => $this->get_endpoint_args_for_item_schema( RestServer::CREATABLE ), + ], + '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? + * @todo set payment method + * @todo set customer note + * @todo handle cart hash + * + * Based on WC_Checkout::create_order. + */ + public function create_order_from_cart() { + $cart_hash = WC()->cart->get_cart_hash(); + + try { + $order = new \WC_Order(); + $order->set_created_via( 'store-api' ); + + // Store customer data. + $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->add_meta_data( 'is_vat_exempt', WC()->cart->get_customer()->get_is_vat_exempt() ? 'yes' : 'no' ); + + // Store totals. + $order->set_currency( get_woocommerce_currency() ); + $order->set_prices_include_tax( 'yes' === get_option( 'woocommerce_prices_include_tax' ) ); + $order->set_shipping_total( WC()->cart->get_shipping_total() ); + $order->set_discount_total( WC()->cart->get_discount_total() ); + $order->set_discount_tax( WC()->cart->get_discount_tax() ); + $order->set_cart_tax( WC()->cart->get_cart_contents_tax() + WC()->cart->get_fee_tax() ); + $order->set_shipping_tax( WC()->cart->get_shipping_tax() ); + $order->set_total( WC()->cart->get_total( 'edit' ) ); + + // Store line items. + WC()->checkout->create_order_line_items( $order, WC()->cart ); + WC()->checkout->create_order_fee_lines( $order, WC()->cart ); + WC()->checkout->create_order_shipping_lines( $order, WC()->session->get( 'chosen_shipping_methods' ), WC()->shipping()->get_packages() ); + WC()->checkout->create_order_tax_lines( $order, WC()->cart ); + WC()->checkout->create_order_coupon_lines( $order, WC()->cart ); + + $order->save(); + + return $this->prepare_item_for_response( $order, $request ); + } catch ( Exception $e ) { + return new RestError( 'checkout-error', $e->getMessage() ); + } + } + + /** + * Get the cart. + * + * @param RestRequest $request Full details about the request. + * @return RestError|RestResponse + */ + public function get_item( $request ) { + /* + $controller = new CartController(); + $cart = $controller->get_cart_instance(); + + if ( ! $cart || ! $cart instanceof \WC_Cart ) { + return new RestError( 'woocommerce_rest_cart_error', __( 'Unable to retrieve cart.', 'woo-gutenberg-products-block' ), array( 'status' => 500 ) ); + }*/ + + return $this->prepare_item_for_response( [], $request ); + } + + /** + * Cart item schema. + * + * @return array + */ + public function get_item_schema() { + return $this->order_schema->get_item_schema(); + } + + /** + * Prepares a single item output for response. + * + * @param mixed $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->order_schema->get_item_response( $object ); + } + return rest_ensure_response( $response ); + } +} 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/OrderItemSchema.php b/src/RestApi/StoreApi/Schemas/OrderItemSchema.php new file mode 100644 index 00000000000..5859e23efc2 --- /dev/null +++ b/src/RestApi/StoreApi/Schemas/OrderItemSchema.php @@ -0,0 +1,278 @@ + [ + 'description' => __( 'Unique identifier for the item within the order.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'context' => [ 'view', 'edit' ], + 'readonly' => true, + ], + 'id' => [ + '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( $line_item ) { + $product = $line_item->get_product(); + + return [ + 'item_id' => $line_item->get_id(), + 'id' => $line_item->get_variation_id() ? $line_item->get_variation_id() : $line_item->get_product_id(), + 'quantity' => $line_item->get_quantity(), + 'name' => $product ? $product->get_title() : null, + 'sku' => $product ? $product->get_sku() : null, + 'permalink' => $product ? $product->get_permalink() : null, + 'images' => $product ? ( new ProductImages() )->images_to_array( $product ) : null, + // 'variation' => $this->format_variation_data( $cart_item['variation'], $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 example convert slugs such as attribute_pa_size to Size. + * + * @param array $variation_data Array of data from the cart. + * @param \WC_Product $product Product data. + * @return array + */ + protected function format_variation_data( $variation_data, $product ) { + $return = []; + + foreach ( $variation_data as $key => $value ) { + $taxonomy = wc_attribute_taxonomy_name( str_replace( 'attribute_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( str_replace( 'attribute_', '', $name ), $product ); + } + + $return[ $label ] = $value; + } + + return $return; + } + + /** + * Get product attribute taxonomy name. + * + * @param string $slug Taxonomy name. + * @param \WC_Product $object Product data. + * @return string + */ + protected function get_attribute_taxonomy_name( $slug, $object ) { + // Format slug so it matches attributes of the product. + $slug = wc_attribute_taxonomy_slug( $slug ); + $attributes = $object->get_attributes(); + $attribute = false; + + // pa_ attributes. + if ( isset( $attributes[ wc_attribute_taxonomy_name( $slug ) ] ) ) { + $attribute = $attributes[ wc_attribute_taxonomy_name( $slug ) ]; + } elseif ( isset( $attributes[ $slug ] ) ) { + $attribute = $attributes[ $slug ]; + } + + if ( ! $attribute ) { + return $slug; + } + + // Taxonomy attribute name. + if ( $attribute->is_taxonomy() ) { + $taxonomy = $attribute->get_taxonomy_object(); + return $taxonomy->attribute_label; + } + + // Custom product attribute name. + return $attribute->get_name(); + } +} diff --git a/src/RestApi/StoreApi/Schemas/OrderSchema.php b/src/RestApi/StoreApi/Schemas/OrderSchema.php new file mode 100644 index 00000000000..6c92f1813f0 --- /dev/null +++ b/src/RestApi/StoreApi/Schemas/OrderSchema.php @@ -0,0 +1,650 @@ + [ + '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' ], + ], + ], + ], + 'payment' => [ + 'description' => __( 'Payment provider and transaction information.', 'woo-gutenberg-products-block' ), + 'type' => 'object', + 'context' => [ 'view', 'edit' ], + 'readonly' => true, + 'properties' => [ + 'payment_method' => [ + 'description' => __( 'Payment method ID.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'context' => [ 'view', 'edit' ], + ], + 'payment_method_title' => [ + 'description' => __( 'Payment method title.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'context' => [ 'view', 'edit' ], + ], + 'transaction_id' => [ + 'description' => __( 'Unique transaction ID.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'context' => [ 'view', 'edit' ], + 'readonly' => true, + ], + ], + ], + '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' ], + ], + 'method_id' => [ + 'description' => __( 'Shipping method ID.', 'woo-gutenberg-products-block' ), + 'type' => 'mixed', + 'context' => [ 'view', 'edit' ], + ], + 'instance_id' => [ + 'description' => __( 'Shipping instance ID.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'context' => [ 'view', 'edit' ], + ], + 'total' => [ + 'description' => __( 'Line total (after discounts).', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'context' => [ 'view', 'edit' ], + ], + '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 $object Order class instance. + * @return array + */ + public function get_item_response( $object ) { + $order_item_schema = new OrderItemSchema(); + + return [ + 'id' => $object->get_id(), + 'number' => $object->get_order_number(), + 'status' => $object->get_status(), + 'order_key' => $object->get_order_key(), + 'created_via' => $object->get_created_via(), + 'prices_include_tax' => $object->get_prices_include_tax(), + 'events' => $this->get_events( $object ), + 'customer' => [ + 'customer_id' => $object->get_customer_id(), + 'customer_ip_address' => $object->get_customer_ip_address(), + 'customer_user_agent' => $object->get_customer_user_agent(), + 'customer_note' => $object->get_customer_note(), + ], + 'billing_address' => [ + 'first_name' => $object->get_billing_first_name(), + 'last_name' => $object->get_billing_last_name(), + 'company' => $object->get_billing_company(), + 'address_1' => $object->get_billing_address_1(), + 'address_2' => $object->get_billing_address_2(), + 'city' => $object->get_billing_city(), + 'state' => $object->get_billing_state(), + 'postcode' => $object->get_billing_postcode(), + 'country' => $object->get_billing_country(), + 'email' => $object->get_billing_email(), + 'phone' => $object->get_billing_phone(), + ], + 'shipping_address' => [ + 'first_name' => $object->get_shipping_first_name(), + 'last_name' => $object->get_shipping_last_name(), + 'company' => $object->get_shipping_company(), + 'address_1' => $object->get_shipping_address_1(), + 'address_2' => $object->get_shipping_address_2(), + 'city' => $object->get_shipping_city(), + 'state' => $object->get_shipping_state(), + 'postcode' => $object->get_shipping_postcode(), + 'country' => $object->get_shipping_country(), + ], + 'payment' => [ + 'payment_method' => $object->get_payment_method(), + 'payment_method_title' => $object->get_payment_method_title(), + 'transaction_id' => $object->get_transaction_id(), + ], + 'items' => array_values( array_map( [ $order_item_schema, 'get_item_response' ], $object->get_items( 'line_item' ) ) ), + 'totals' => array_merge( + $this->get_store_currency_response(), + [ + 'total_items' => $this->prepare_money_response( $object->get_subtotal(), wc_get_price_decimals() ), + 'total_items_tax' => $this->prepare_money_response( $this->get_subtotal_tax( $object ), wc_get_price_decimals() ), + 'total_fees' => $this->prepare_money_response( $this->get_fee_total( $object ), wc_get_price_decimals() ), + 'total_fees_tax' => $this->prepare_money_response( $this->get_fee_tax( $object ), wc_get_price_decimals() ), + 'total_discount' => $this->prepare_money_response( $object->get_discount_total(), wc_get_price_decimals() ), + 'total_discount_tax' => $this->prepare_money_response( $object->get_discount_tax(), wc_get_price_decimals() ), + 'total_shipping' => $this->prepare_money_response( $object->get_shipping_total(), wc_get_price_decimals() ), + 'total_shipping_tax' => $this->prepare_money_response( $object->get_shipping_tax(), wc_get_price_decimals() ), + 'total_price' => $this->prepare_money_response( $object->get_total(), wc_get_price_decimals() ), + 'total_tax' => $this->prepare_money_response( $object->get_total_tax(), wc_get_price_decimals() ), + 'tax_lines' => $this->get_tax_lines( $object ), + ] + ), + ]; + } + + /** + * 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 $object Order class instance. + * @return array + */ + protected function get_events( $object ) { + $events = []; + $props = [ 'date_created', 'date_modified', 'date_completed', 'date_paid' ]; + + foreach ( $props as $prop ) { + $datetime = $object->{"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 $object Order class instance. + * @return array + */ + protected function get_tax_lines( $object ) { + $tax_totals = $object->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 This could be added to the orders class to match the cart class. + * + * @param \WC_Order $object Order class instance. + * @return float + */ + protected function get_subtotal_tax( $object ) { + $total = 0; + foreach ( $object->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 This could be added to the orders class to match the cart class. + * + * @param \WC_Order $object Order class instance. + * @return float + */ + protected function get_fee_total( $object ) { + $total = 0; + foreach ( $object->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 This could be added to the orders class to match the cart class. + * + * @param \WC_Order $object Order class instance. + * @return float + */ + protected function get_fee_tax( $object ) { + $total = 0; + foreach ( $object->get_fees() as $item ) { + $total += $item->get_total_tax(); + } + return $total; + } +} From 182dec513726f0709613a1d78c18582f9f67f04a Mon Sep 17 00:00:00 2001 From: Mike Jolley Date: Mon, 6 Jan 2020 16:04:47 +0000 Subject: [PATCH 02/28] Add _address suffix for billing/shipping --- src/RestApi/StoreApi/Controllers/Customer.php | 8 +-- .../StoreApi/Schemas/CustomerSchema.php | 12 ++-- .../RestApi/StoreApi/Controllers/Customer.php | 58 +++++++++---------- 3 files changed, 39 insertions(+), 39 deletions(-) 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/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/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'] ); } } From 8a881a0e176d10a7cd3ba7f655047b3feac1761b Mon Sep 17 00:00:00 2001 From: Mike Jolley Date: Mon, 6 Jan 2020 16:28:38 +0000 Subject: [PATCH 03/28] Rename schema, update endpoints, create tests --- src/RestApi.php | 2 +- .../StoreApi/Controllers/CartOrder.php | 200 ++++++++++++++++++ src/RestApi/StoreApi/Controllers/Checkout.php | 161 -------------- .../StoreApi/Schemas/OrderItemSchema.php | 34 +-- src/RestApi/StoreApi/Schemas/OrderSchema.php | 45 +--- .../StoreApi/Controllers/CartOrder.php | 154 ++++++++++++++ 6 files changed, 383 insertions(+), 213 deletions(-) create mode 100644 src/RestApi/StoreApi/Controllers/CartOrder.php delete mode 100644 src/RestApi/StoreApi/Controllers/Checkout.php create mode 100644 tests/php/RestApi/StoreApi/Controllers/CartOrder.php diff --git a/src/RestApi.php b/src/RestApi.php index 0f2c18b5757..0841805ea24 100644 --- a/src/RestApi.php +++ b/src/RestApi.php @@ -96,8 +96,8 @@ 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-checkout' => __NAMESPACE__ . '\RestApi\StoreApi\Controllers\Checkout', 'store-products' => __NAMESPACE__ . '\RestApi\StoreApi\Controllers\Products', 'store-product-collection-data' => __NAMESPACE__ . '\RestApi\StoreApi\Controllers\ProductCollectionData', 'store-product-attributes' => __NAMESPACE__ . '\RestApi\StoreApi\Controllers\ProductAttributes', diff --git a/src/RestApi/StoreApi/Controllers/CartOrder.php b/src/RestApi/StoreApi/Controllers/CartOrder.php new file mode 100644 index 00000000000..f5c20f7e089 --- /dev/null +++ b/src/RestApi/StoreApi/Controllers/CartOrder.php @@ -0,0 +1,200 @@ +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' => $this->get_endpoint_args_for_item_schema( RestServer::CREATABLE ), + ], + '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? + * @todo set payment method + * @todo set customer note + * + * Based on WC_Checkout::create_order. + * + * @param RestRequest $request Full details about the request. + * @return RestError|RestResponse + */ + public function create_item( $request ) { + $schema = $this->get_item_schema(); + + try { + $order = $this->create_order_from_cart( $request ); + + // Store data sent with the request. + 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'] ); + } + + $order->save(); + + // Store Order ID in session so we can look it up later. + WC()->session->set( 'order_awaiting_payment', $order->get_id() ); + + $response = $this->prepare_item_for_response( $order, $request ); + $response->set_status( 201 ); + + return $response; + } catch ( Exception $e ) { + return new RestError( 'checkout-error', $e->getMessage() ); + } + } + + /** + * 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( $request ) { + $order = new \WC_Order(); + $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() ); + + $this->set_props_from_cart( $order, $request ); + $this->create_line_items_from_cart( $order, $request ); + + return $order; + } + + /** + * Set order totals based on those calculated by the cart. + * + * @param \WC_Order $order Object to prepare for the response. + * @param RestRequest $request Full details about the request. + */ + protected function set_props_from_cart( &$order, $request ) { + $order->set_cart_hash( WC()->cart->get_cart_hash() ); + $order->set_shipping_total( WC()->cart->get_shipping_total() ); + $order->set_discount_total( WC()->cart->get_discount_total() ); + $order->set_discount_tax( WC()->cart->get_discount_tax() ); + $order->set_cart_tax( WC()->cart->get_cart_contents_tax() + WC()->cart->get_fee_tax() ); + $order->set_shipping_tax( WC()->cart->get_shipping_tax() ); + $order->set_total( WC()->cart->get_total( 'edit' ) ); + $order->add_meta_data( 'is_vat_exempt', WC()->cart->get_customer()->get_is_vat_exempt() ? 'yes' : 'no' ); + } + + /** + * Create order line items. + * + * @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( &$order, $request ) { + WC()->checkout->create_order_line_items( $order, WC()->cart ); + WC()->checkout->create_order_fee_lines( $order, WC()->cart ); + WC()->checkout->create_order_shipping_lines( $order, WC()->session->get( 'chosen_shipping_methods' ), WC()->shipping()->get_packages() ); + WC()->checkout->create_order_tax_lines( $order, WC()->cart ); + WC()->checkout->create_order_coupon_lines( $order, WC()->cart ); + } + + /** + * 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/Checkout.php b/src/RestApi/StoreApi/Controllers/Checkout.php deleted file mode 100644 index c7dcc57784f..00000000000 --- a/src/RestApi/StoreApi/Controllers/Checkout.php +++ /dev/null @@ -1,161 +0,0 @@ -order_schema = new OrderSchema(); - } - - /** - * Register routes. - */ - public function register_routes() { - register_rest_route( - $this->namespace, - '/' . $this->rest_base . '/order', - [ - [ - 'methods' => RestServer::CREATABLE, - 'callback' => array( $this, 'create_order_from_cart' ), - 'args' => $this->get_endpoint_args_for_item_schema( RestServer::CREATABLE ), - ], - '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? - * @todo set payment method - * @todo set customer note - * @todo handle cart hash - * - * Based on WC_Checkout::create_order. - */ - public function create_order_from_cart() { - $cart_hash = WC()->cart->get_cart_hash(); - - try { - $order = new \WC_Order(); - $order->set_created_via( 'store-api' ); - - // Store customer data. - $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->add_meta_data( 'is_vat_exempt', WC()->cart->get_customer()->get_is_vat_exempt() ? 'yes' : 'no' ); - - // Store totals. - $order->set_currency( get_woocommerce_currency() ); - $order->set_prices_include_tax( 'yes' === get_option( 'woocommerce_prices_include_tax' ) ); - $order->set_shipping_total( WC()->cart->get_shipping_total() ); - $order->set_discount_total( WC()->cart->get_discount_total() ); - $order->set_discount_tax( WC()->cart->get_discount_tax() ); - $order->set_cart_tax( WC()->cart->get_cart_contents_tax() + WC()->cart->get_fee_tax() ); - $order->set_shipping_tax( WC()->cart->get_shipping_tax() ); - $order->set_total( WC()->cart->get_total( 'edit' ) ); - - // Store line items. - WC()->checkout->create_order_line_items( $order, WC()->cart ); - WC()->checkout->create_order_fee_lines( $order, WC()->cart ); - WC()->checkout->create_order_shipping_lines( $order, WC()->session->get( 'chosen_shipping_methods' ), WC()->shipping()->get_packages() ); - WC()->checkout->create_order_tax_lines( $order, WC()->cart ); - WC()->checkout->create_order_coupon_lines( $order, WC()->cart ); - - $order->save(); - - return $this->prepare_item_for_response( $order, $request ); - } catch ( Exception $e ) { - return new RestError( 'checkout-error', $e->getMessage() ); - } - } - - /** - * Get the cart. - * - * @param RestRequest $request Full details about the request. - * @return RestError|RestResponse - */ - public function get_item( $request ) { - /* - $controller = new CartController(); - $cart = $controller->get_cart_instance(); - - if ( ! $cart || ! $cart instanceof \WC_Cart ) { - return new RestError( 'woocommerce_rest_cart_error', __( 'Unable to retrieve cart.', 'woo-gutenberg-products-block' ), array( 'status' => 500 ) ); - }*/ - - return $this->prepare_item_for_response( [], $request ); - } - - /** - * Cart item schema. - * - * @return array - */ - public function get_item_schema() { - return $this->order_schema->get_item_schema(); - } - - /** - * Prepares a single item output for response. - * - * @param mixed $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->order_schema->get_item_response( $object ); - } - return rest_ensure_response( $response ); - } -} diff --git a/src/RestApi/StoreApi/Schemas/OrderItemSchema.php b/src/RestApi/StoreApi/Schemas/OrderItemSchema.php index 5859e23efc2..e42abe5a4ba 100644 --- a/src/RestApi/StoreApi/Schemas/OrderItemSchema.php +++ b/src/RestApi/StoreApi/Schemas/OrderItemSchema.php @@ -29,12 +29,6 @@ class OrderItemSchema extends AbstractSchema { */ protected function get_properties() { return [ - 'item_id' => [ - 'description' => __( 'Unique identifier for the item within the order.', 'woo-gutenberg-products-block' ), - 'type' => 'string', - 'context' => [ 'view', 'edit' ], - 'readonly' => true, - ], 'id' => [ 'description' => __( 'The item product or variation ID.', 'woo-gutenberg-products-block' ), 'type' => 'integer', @@ -190,14 +184,13 @@ public function get_item_response( $line_item ) { $product = $line_item->get_product(); return [ - 'item_id' => $line_item->get_id(), 'id' => $line_item->get_variation_id() ? $line_item->get_variation_id() : $line_item->get_product_id(), 'quantity' => $line_item->get_quantity(), 'name' => $product ? $product->get_title() : null, 'sku' => $product ? $product->get_sku() : null, 'permalink' => $product ? $product->get_permalink() : null, 'images' => $product ? ( new ProductImages() )->images_to_array( $product ) : null, - // 'variation' => $this->format_variation_data( $cart_item['variation'], $product ), + 'variation' => $this->format_variation_data( $line_item, $product ), 'totals' => array_merge( $this->get_store_currency_response(), [ @@ -211,17 +204,26 @@ public function get_item_response( $line_item ) { } /** - * Format variation data, for example convert slugs such as attribute_pa_size to Size. + * Format variation data. For line items we get meta data and format it. * - * @param array $variation_data Array of data from the cart. - * @param \WC_Product $product Product data. + * @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( $variation_data, $product ) { - $return = []; + protected function format_variation_data( $line_item, $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; + } - foreach ( $variation_data as $key => $value ) { - $taxonomy = wc_attribute_taxonomy_name( str_replace( 'attribute_pa_', '', urldecode( $key ) ) ); + $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. @@ -233,7 +235,7 @@ protected function format_variation_data( $variation_data, $product ) { } 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( str_replace( 'attribute_', '', $name ), $product ); + $label = wc_attribute_label( $name, $product ); } $return[ $label ] = $value; diff --git a/src/RestApi/StoreApi/Schemas/OrderSchema.php b/src/RestApi/StoreApi/Schemas/OrderSchema.php index 6c92f1813f0..ec265bebcf6 100644 --- a/src/RestApi/StoreApi/Schemas/OrderSchema.php +++ b/src/RestApi/StoreApi/Schemas/OrderSchema.php @@ -145,13 +145,13 @@ protected function get_properties() { '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' ], - ], ], ], + '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', @@ -267,30 +267,6 @@ protected function get_properties() { ], ], ], - 'payment' => [ - 'description' => __( 'Payment provider and transaction information.', 'woo-gutenberg-products-block' ), - 'type' => 'object', - 'context' => [ 'view', 'edit' ], - 'readonly' => true, - 'properties' => [ - 'payment_method' => [ - 'description' => __( 'Payment method ID.', 'woo-gutenberg-products-block' ), - 'type' => 'string', - 'context' => [ 'view', 'edit' ], - ], - 'payment_method_title' => [ - 'description' => __( 'Payment method title.', 'woo-gutenberg-products-block' ), - 'type' => 'string', - 'context' => [ 'view', 'edit' ], - ], - 'transaction_id' => [ - 'description' => __( 'Unique transaction ID.', 'woo-gutenberg-products-block' ), - 'type' => 'string', - 'context' => [ 'view', 'edit' ], - 'readonly' => true, - ], - ], - ], 'coupons' => [ 'description' => __( 'List of applied cart coupons.', 'woo-gutenberg-products-block' ), 'type' => 'array', @@ -329,21 +305,25 @@ protected function get_properties() { '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' ), @@ -495,8 +475,8 @@ public function get_item_response( $object ) { 'customer_id' => $object->get_customer_id(), 'customer_ip_address' => $object->get_customer_ip_address(), 'customer_user_agent' => $object->get_customer_user_agent(), - 'customer_note' => $object->get_customer_note(), ], + 'customer_note' => $object->get_customer_note(), 'billing_address' => [ 'first_name' => $object->get_billing_first_name(), 'last_name' => $object->get_billing_last_name(), @@ -521,11 +501,6 @@ public function get_item_response( $object ) { 'postcode' => $object->get_shipping_postcode(), 'country' => $object->get_shipping_country(), ], - 'payment' => [ - 'payment_method' => $object->get_payment_method(), - 'payment_method_title' => $object->get_payment_method_title(), - 'transaction_id' => $object->get_transaction_id(), - ], 'items' => array_values( array_map( [ $order_item_schema, 'get_item_response' ], $object->get_items( 'line_item' ) ) ), 'totals' => array_merge( $this->get_store_currency_response(), diff --git a/tests/php/RestApi/StoreApi/Controllers/CartOrder.php b/tests/php/RestApi/StoreApi/Controllers/CartOrder.php new file mode 100644 index 00000000000..b298f337ef4 --- /dev/null +++ b/tests/php/RestApi/StoreApi/Controllers/CartOrder.php @@ -0,0 +1,154 @@ +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( 'GET', '/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[0] ); + $this->assertArrayHasKey( 'number', $data[0] ); + $this->assertArrayHasKey( 'status', $data[0] ); + $this->assertArrayHasKey( 'order_key', $data[0] ); + $this->assertArrayHasKey( 'created_via', $data[0] ); + $this->assertArrayHasKey( 'prices_include_tax', $data[0] ); + $this->assertArrayHasKey( 'events', $data[0] ); + $this->assertArrayHasKey( 'customer', $data[0] ); + $this->assertArrayHasKey( 'billing_address', $data[0] ); + $this->assertArrayHasKey( 'shipping_address', $data[0] ); + $this->assertArrayHasKey( 'customer_note', $data[0] ); + $this->assertArrayHasKey( 'items', $data[0] ); + $this->assertArrayHasKey( 'totals', $data[0] ); + + $this->assertEquals( 'Margaret', $data[0]['billing_address']['first_name'] ); + $this->assertEquals( 'Thatchcroft', $data[0]['billing_address']['last_name'] ); + $this->assertEquals( '123 South Street', $data[0]['billing_address']['address_1'] ); + $this->assertEquals( 'Apt 1', $data[0]['billing_address']['address_2'] ); + $this->assertEquals( 'Philadelphia', $data[0]['billing_address']['city'] ); + $this->assertEquals( 'PA', $data[0]['billing_address']['state'] ); + $this->assertEquals( '19123', $data[0]['billing_address']['postcode'] ); + $this->assertEquals( 'US', $data[0]['billing_address']['country'] ); + $this->assertEquals( 'test@test.com', $data[0]['billing_address']['email'] ); + $this->assertEquals( '', $data[0]['billing_address']['phone'] ); + + $this->assertEquals( 'pending', $data[0]['status'] ); + $this->assertEquals( 2, count( $data[0]['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() ); + } +} From a08735701ea055958ffe21cc85e8abe52a517f6b Mon Sep 17 00:00:00 2001 From: Mike Jolley Date: Mon, 6 Jan 2020 16:31:03 +0000 Subject: [PATCH 04/28] Fix POST in test --- tests/php/RestApi/StoreApi/Controllers/CartOrder.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/php/RestApi/StoreApi/Controllers/CartOrder.php b/tests/php/RestApi/StoreApi/Controllers/CartOrder.php index b298f337ef4..f3cc9901d03 100644 --- a/tests/php/RestApi/StoreApi/Controllers/CartOrder.php +++ b/tests/php/RestApi/StoreApi/Controllers/CartOrder.php @@ -60,7 +60,7 @@ public function test_register_routes() { * Test order creation from cart data. */ public function test_create_item() { - $request = new WP_REST_Request( 'GET', '/wc/store/cart/order' ); + $request = new WP_REST_Request( 'POST', '/wc/store/cart/order' ); $request->set_param( 'billing_address', [ "first_name" => "Margaret", "last_name" => "Thatchcroft", From bfbcdfa94c865cedbbadb5b2096a330211296e3d Mon Sep 17 00:00:00 2001 From: Mike Jolley Date: Mon, 6 Jan 2020 16:32:37 +0000 Subject: [PATCH 05/28] Fix test response checks --- .../StoreApi/Controllers/CartOrder.php | 54 +++++++++---------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/tests/php/RestApi/StoreApi/Controllers/CartOrder.php b/tests/php/RestApi/StoreApi/Controllers/CartOrder.php index f3cc9901d03..9fc65d2a64b 100644 --- a/tests/php/RestApi/StoreApi/Controllers/CartOrder.php +++ b/tests/php/RestApi/StoreApi/Controllers/CartOrder.php @@ -78,33 +78,33 @@ public function test_create_item() { $this->assertEquals( 201, $response->get_status() ); - $this->assertArrayHasKey( 'id', $data[0] ); - $this->assertArrayHasKey( 'number', $data[0] ); - $this->assertArrayHasKey( 'status', $data[0] ); - $this->assertArrayHasKey( 'order_key', $data[0] ); - $this->assertArrayHasKey( 'created_via', $data[0] ); - $this->assertArrayHasKey( 'prices_include_tax', $data[0] ); - $this->assertArrayHasKey( 'events', $data[0] ); - $this->assertArrayHasKey( 'customer', $data[0] ); - $this->assertArrayHasKey( 'billing_address', $data[0] ); - $this->assertArrayHasKey( 'shipping_address', $data[0] ); - $this->assertArrayHasKey( 'customer_note', $data[0] ); - $this->assertArrayHasKey( 'items', $data[0] ); - $this->assertArrayHasKey( 'totals', $data[0] ); - - $this->assertEquals( 'Margaret', $data[0]['billing_address']['first_name'] ); - $this->assertEquals( 'Thatchcroft', $data[0]['billing_address']['last_name'] ); - $this->assertEquals( '123 South Street', $data[0]['billing_address']['address_1'] ); - $this->assertEquals( 'Apt 1', $data[0]['billing_address']['address_2'] ); - $this->assertEquals( 'Philadelphia', $data[0]['billing_address']['city'] ); - $this->assertEquals( 'PA', $data[0]['billing_address']['state'] ); - $this->assertEquals( '19123', $data[0]['billing_address']['postcode'] ); - $this->assertEquals( 'US', $data[0]['billing_address']['country'] ); - $this->assertEquals( 'test@test.com', $data[0]['billing_address']['email'] ); - $this->assertEquals( '', $data[0]['billing_address']['phone'] ); - - $this->assertEquals( 'pending', $data[0]['status'] ); - $this->assertEquals( 2, count( $data[0]['items'] ) ); + $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( 'pending', $data['status'] ); + $this->assertEquals( 2, count( $data['items'] ) ); } /** From 9840d7b19e1bf602d514076b7a3ceb848e67cbb9 Mon Sep 17 00:00:00 2001 From: Mike Jolley Date: Tue, 7 Jan 2020 15:03:17 +0000 Subject: [PATCH 06/28] Stock reservation and draft order status --- src/Library.php | 69 ++++++ .../StoreApi/Controllers/CartOrder.php | 210 +++++++++++++++--- 2 files changed, 253 insertions(+), 26 deletions(-) diff --git a/src/Library.php b/src/Library.php index 7ac6b9e58d0..bf1438bedb1 100644 --- a/src/Library.php +++ b/src/Library.php @@ -19,6 +19,62 @@ 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' ) ); + } + + /** + * 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 +115,17 @@ 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( $statuses ) { + $statuses['wc-draft'] = _x( 'Draft', 'Order status', 'woo-gutenberg-products-block' ); + return $statuses; + } } diff --git a/src/RestApi/StoreApi/Controllers/CartOrder.php b/src/RestApi/StoreApi/Controllers/CartOrder.php index f5c20f7e089..8bac189394a 100644 --- a/src/RestApi/StoreApi/Controllers/CartOrder.php +++ b/src/RestApi/StoreApi/Controllers/CartOrder.php @@ -83,42 +83,129 @@ public function register_routes() { * @return RestError|RestResponse */ public function create_item( $request ) { - $schema = $this->get_item_schema(); + add_filter( 'woocommerce_default_order_status', array( $this, 'default_order_status' ) ); try { - $order = $this->create_order_from_cart( $request ); + // Create or retrieve the draft order for the current cart. + $order_object = $this->create_order_from_cart( $request ); + $reserve_stock = $this->reserve_stock_for_draft_order( $order_object ); - // Store data sent with the request. - 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 ( is_wp_error( $reserve_stock ) ) { + // Something went wrong - return error. + $response = $reserve_stock; + } else { + $response = $this->prepare_item_for_response( $order_object, $request ); + $response->set_status( 201 ); } + } catch ( Exception $e ) { + $response = new RestError( 'checkout-error', $e->getMessage() ); + } + + remove_filter( 'woocommerce_default_order_status', array( $this, 'default_order_status' ) ); + + return $response; + } + + /** + * Put a temporary hold on stock for this order. + * + * @throws RestException Exception when stock cannot be reserved. + * @param \WC_Order $order Order object. + * @return bool|RestError + */ + protected function reserve_stock_for_draft_order( $order ) { + global $wpdb; + + try { + // Remove any holds that already exist for this order. + $wpdb->delete( $wpdb->wc_reserved_stock, [ 'order_id' => $order->get_id() ] ); + + $hold_stock_minutes = (int) get_option( 'woocommerce_hold_stock_minutes', 0 ); + $stock_to_reserve = []; - 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 ); + // Loop over line items and check each item may be purchased. + foreach ( $order->get_items() as $item ) { + if ( ! $item->is_type( 'line_item' ) ) { + continue; + } + + $product = $item->get_product(); + + if ( ! $product->is_in_stock() ) { + throw new RestException( + 'woocommerce_rest_cart_order_product_not_in_stock', + sprintf( + /* translators: %s: product name */ + __( '%s is out of stock and cannot be purchased.', 'woo-gutenberg-products-block' ), + $product->get_name() + ), + 403 + ); } - } - if ( isset( $request['customer_note'] ) ) { - $order->set_customer_note( $request['customer_note'] ); + // If stock management if off, no need to reserve any stock here. + if ( ! $product->managing_stock() || $product->backorders_allowed() ) { + continue; + } + + $stocked_product_id = $product->get_stock_managed_by_id(); + + if ( ! isset( $stock_to_reserve[ $stocked_product_id ] ) ) { + $stock_to_reserve[ $stocked_product_id ] = 0; + } + + // Query for any existing holds on stock for this item. @todo join for post status. + $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-draft' + ", + $stocked_product_id + ) + ); + + // Deals with legacy stock reservation which the core Woo checkout performs. + $reserved_stock += ( $hold_stock_minutes > 0 ) ? wc_get_held_stock_quantity( $product ) : 0; + + if ( ( $product->get_stock_quantity() - $reserved_stock - $stock_to_reserve[ $stocked_product_id ] ) < $item->get_quantity() ) { + throw new RestException( + 'woocommerce_rest_cart_order_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() + ), + 403 + ); + } + + // Queue this reservation for later DB insertion. + $stock_to_reserve[ $stocked_product_id ] += $item->get_quantity(); } - $order->save(); + $stock_to_reserve = array_filter( $stock_to_reserve ); - // Store Order ID in session so we can look it up later. - WC()->session->set( 'order_awaiting_payment', $order->get_id() ); + if ( $stock_to_reserve ) { + $stock_to_reserve_rows = []; - $response = $this->prepare_item_for_response( $order, $request ); - $response->set_status( 201 ); + foreach ( $stock_to_reserve as $product_id => $stock_quantity ) { + $stock_to_reserve_rows[] = '(' . esc_sql( $order->get_id() ) . ',"' . esc_sql( $product_id ) . '","' . esc_sql( $stock_quantity ) . '")'; + } - return $response; - } catch ( Exception $e ) { - return new RestError( 'checkout-error', $e->getMessage() ); + $values = implode( ',', $stock_to_reserve_rows ); + + $wpdb->query( + "INSERT INTO {$wpdb->wc_reserved_stock} ( order_id, product_id, stock_quantity ) VALUES {$values};" // phpcs:ignore + ); + } + } catch ( RestException $e ) { + return new RestError( $e->getErrorCode(), $e->getMessage(), array( 'status' => $e->getCode() ) ); } + + return true; } /** @@ -128,7 +215,8 @@ public function create_item( $request ) { * @return \WC_Order A new order object. */ protected function create_order_from_cart( $request ) { - $order = new \WC_Order(); + $order = $this->get_order_object(); + $order->set_status( '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' ) ); @@ -138,10 +226,41 @@ protected function create_order_from_cart( $request ) { $this->set_props_from_cart( $order, $request ); $this->create_line_items_from_cart( $order, $request ); + $this->set_props_from_request( $order, $request ); + + $order->save(); + + // Store Order ID in session so we can look it up later. + WC()->session->set( 'draft_order_id', $order->get_id() ); return $order; } + /** + * 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_id = WC()->session->get( 'draft_order_id' ); + $draft_order = $draft_order_id ? wc_get_order( $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 'draft'; + } + /** * Set order totals based on those calculated by the cart. * @@ -149,14 +268,13 @@ protected function create_order_from_cart( $request ) { * @param RestRequest $request Full details about the request. */ protected function set_props_from_cart( &$order, $request ) { - $order->set_cart_hash( WC()->cart->get_cart_hash() ); $order->set_shipping_total( WC()->cart->get_shipping_total() ); $order->set_discount_total( WC()->cart->get_discount_total() ); $order->set_discount_tax( WC()->cart->get_discount_tax() ); $order->set_cart_tax( WC()->cart->get_cart_contents_tax() + WC()->cart->get_fee_tax() ); $order->set_shipping_tax( WC()->cart->get_shipping_tax() ); $order->set_total( WC()->cart->get_total( 'edit' ) ); - $order->add_meta_data( 'is_vat_exempt', WC()->cart->get_customer()->get_is_vat_exempt() ? 'yes' : 'no' ); + $order->update_meta_data( 'is_vat_exempt', WC()->cart->get_customer()->get_is_vat_exempt() ? 'yes' : 'no' ); } /** @@ -166,11 +284,51 @@ protected function set_props_from_cart( &$order, $request ) { * @param RestRequest $request Full details about the request. */ protected function create_line_items_from_cart( &$order, $request ) { + $draft_order_cart_hash = $order->get_cart_hash(); + + if ( WC()->cart->get_cart_hash() === $draft_order_cart_hash ) { + return; + } + + if ( $draft_order_cart_hash ) { + $order->remove_order_items(); + } + WC()->checkout->create_order_line_items( $order, WC()->cart ); WC()->checkout->create_order_fee_lines( $order, WC()->cart ); WC()->checkout->create_order_shipping_lines( $order, WC()->session->get( 'chosen_shipping_methods' ), WC()->shipping()->get_packages() ); WC()->checkout->create_order_tax_lines( $order, WC()->cart ); WC()->checkout->create_order_coupon_lines( $order, WC()->cart ); + + $order->set_cart_hash( WC()->cart->get_cart_hash() ); + } + + /** + * 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( &$order, $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'] ); + } } /** From fa8c9c2d05081bbb407668ef63bd4dec10da6fbe Mon Sep 17 00:00:00 2001 From: Mike Jolley Date: Tue, 7 Jan 2020 15:13:42 +0000 Subject: [PATCH 07/28] Add todo for shipping lines --- src/RestApi/StoreApi/Controllers/CartOrder.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/RestApi/StoreApi/Controllers/CartOrder.php b/src/RestApi/StoreApi/Controllers/CartOrder.php index 8bac189394a..41e940b6e14 100644 --- a/src/RestApi/StoreApi/Controllers/CartOrder.php +++ b/src/RestApi/StoreApi/Controllers/CartOrder.php @@ -296,6 +296,7 @@ protected function create_line_items_from_cart( &$order, $request ) { WC()->checkout->create_order_line_items( $order, WC()->cart ); WC()->checkout->create_order_fee_lines( $order, WC()->cart ); + // @todo This may need revision during checkout implementation in order to store chosen shipping options rather than those from the cart. WC()->checkout->create_order_shipping_lines( $order, WC()->session->get( 'chosen_shipping_methods' ), WC()->shipping()->get_packages() ); WC()->checkout->create_order_tax_lines( $order, WC()->cart ); WC()->checkout->create_order_coupon_lines( $order, WC()->cart ); From 1804ad434fca80c4a8a9ed84734cab931cdedf3e Mon Sep 17 00:00:00 2001 From: Mike Jolley Date: Tue, 7 Jan 2020 15:48:37 +0000 Subject: [PATCH 08/28] Readme --- src/RestApi/StoreApi/README.md | 130 +++++++++++++++++++++++++++++++++ 1 file changed, 130 insertions(+) diff --git a/src/RestApi/StoreApi/README.md b/src/RestApi/StoreApi/README.md index dfd2e5476b5..5165d0bc94d 100644 --- a/src/RestApi/StoreApi/README.md +++ b/src/RestApi/StoreApi/README.md @@ -1011,6 +1011,136 @@ 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. | + +```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 From 82298810a1a90adbb5639e12277ed0ccd3a6ffad Mon Sep 17 00:00:00 2001 From: Mike Jolley Date: Tue, 7 Jan 2020 16:00:06 +0000 Subject: [PATCH 09/28] Rename address fields in readme --- src/RestApi/StoreApi/README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/RestApi/StoreApi/README.md b/src/RestApi/StoreApi/README.md index 5165d0bc94d..6134a0256b8 100644 --- a/src/RestApi/StoreApi/README.md +++ b/src/RestApi/StoreApi/README.md @@ -1160,7 +1160,7 @@ Example response: ```json { "id": 0, - "billing": { + "billing_address": { "first_name": "Margaret", "last_name": "Thatchcroft", "company": "", @@ -1173,7 +1173,7 @@ Example response: "email": "test@test.com", "phone": "" }, - "shipping": { + "shipping_address": { "first_name": "Margaret", "last_name": "Thatchcroft", "company": "", @@ -1209,7 +1209,7 @@ Example response: ```json { "id": 0, - "billing": { + "billing_address": { "first_name": "Margaret", "last_name": "Thatchcroft", "company": "Test", @@ -1222,7 +1222,7 @@ Example response: "email": "test@test.com", "phone": "" }, - "shipping": { + "shipping_address": { "first_name": "Margaret", "last_name": "Thatchcroft", "company": "", From 2bf5ac291d791be73bc100fd5330e05ce58315b0 Mon Sep 17 00:00:00 2001 From: Mike Jolley Date: Tue, 7 Jan 2020 16:19:17 +0000 Subject: [PATCH 10/28] 10 min timeout of stock --- src/RestApi/StoreApi/Controllers/CartOrder.php | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/RestApi/StoreApi/Controllers/CartOrder.php b/src/RestApi/StoreApi/Controllers/CartOrder.php index 41e940b6e14..8f20bd838e6 100644 --- a/src/RestApi/StoreApi/Controllers/CartOrder.php +++ b/src/RestApi/StoreApi/Controllers/CartOrder.php @@ -117,9 +117,6 @@ protected function reserve_stock_for_draft_order( $order ) { global $wpdb; try { - // Remove any holds that already exist for this order. - $wpdb->delete( $wpdb->wc_reserved_stock, [ 'order_id' => $order->get_id() ] ); - $hold_stock_minutes = (int) get_option( 'woocommerce_hold_stock_minutes', 0 ); $stock_to_reserve = []; @@ -154,7 +151,10 @@ protected function reserve_stock_for_draft_order( $order ) { $stock_to_reserve[ $stocked_product_id ] = 0; } - // Query for any existing holds on stock for this item. @todo join for post status. + // Query for any existing holds on stock for this item. + // Ignores reserved stock already made for this order. + // Ignores stock for orders which are no longer drafts (assuming real stock reduction was performed). + // Ignores stock reserved over 10 mins ago. Client can call this endpoint to renew holds on stock. $reserved_stock = $wpdb->get_var( $wpdb->prepare( " @@ -162,8 +162,11 @@ protected function reserve_stock_for_draft_order( $order ) { LEFT JOIN $wpdb->posts posts ON stock_table.`order_id` = posts.ID WHERE stock_table.`product_id` = %d AND posts.post_status = 'wc-draft' + AND stock_table.`order_id` != %d + AND stock_table.`timestamp` > ( NOW() - INTERVAL 10 MINUTE ) ", - $stocked_product_id + $stocked_product_id, + $order->get_id() ) ); @@ -198,7 +201,7 @@ protected function reserve_stock_for_draft_order( $order ) { $values = implode( ',', $stock_to_reserve_rows ); $wpdb->query( - "INSERT INTO {$wpdb->wc_reserved_stock} ( order_id, product_id, stock_quantity ) VALUES {$values};" // phpcs:ignore + "REPLACE INTO {$wpdb->wc_reserved_stock} ( order_id, product_id, stock_quantity ) VALUES {$values};" // phpcs:ignore ); } } catch ( RestException $e ) { From a6fd544414a1c23064e22bb78bc589e022316800 Mon Sep 17 00:00:00 2001 From: Mike Jolley Date: Wed, 8 Jan 2020 11:31:47 +0000 Subject: [PATCH 11/28] Fix broken test --- tests/php/RestApi/StoreApi/Controllers/CartOrder.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/php/RestApi/StoreApi/Controllers/CartOrder.php b/tests/php/RestApi/StoreApi/Controllers/CartOrder.php index 9fc65d2a64b..6ae097607c0 100644 --- a/tests/php/RestApi/StoreApi/Controllers/CartOrder.php +++ b/tests/php/RestApi/StoreApi/Controllers/CartOrder.php @@ -103,7 +103,7 @@ public function test_create_item() { $this->assertEquals( 'test@test.com', $data['billing_address']['email'] ); $this->assertEquals( '', $data['billing_address']['phone'] ); - $this->assertEquals( 'pending', $data['status'] ); + $this->assertEquals( 'draft', $data['status'] ); $this->assertEquals( 2, count( $data['items'] ) ); } From 46bbe7000b4bae4f77a15f26dff567c5560aabd3 Mon Sep 17 00:00:00 2001 From: Mike Jolley Date: Thu, 9 Jan 2020 11:42:24 +0000 Subject: [PATCH 12/28] Update src/RestApi/StoreApi/Controllers/CartOrder.php Co-Authored-By: Darren Ethier --- src/RestApi/StoreApi/Controllers/CartOrder.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/RestApi/StoreApi/Controllers/CartOrder.php b/src/RestApi/StoreApi/Controllers/CartOrder.php index 8f20bd838e6..1b32de5a9a3 100644 --- a/src/RestApi/StoreApi/Controllers/CartOrder.php +++ b/src/RestApi/StoreApi/Controllers/CartOrder.php @@ -140,7 +140,7 @@ protected function reserve_stock_for_draft_order( $order ) { ); } - // If stock management if off, no need to reserve any stock here. + // If stock management is off, no need to reserve any stock here. if ( ! $product->managing_stock() || $product->backorders_allowed() ) { continue; } From 9cce54b520002f0a39e01c2a2cc2abf3e779a916 Mon Sep 17 00:00:00 2001 From: Mike Jolley Date: Thu, 9 Jan 2020 12:04:35 +0000 Subject: [PATCH 13/28] Add typehinting where possible --- src/Library.php | 2 +- .../StoreApi/Controllers/CartOrder.php | 10 +- .../StoreApi/Schemas/OrderItemSchema.php | 53 ++------ src/RestApi/StoreApi/Schemas/OrderSchema.php | 124 +++++++++--------- 4 files changed, 82 insertions(+), 107 deletions(-) diff --git a/src/Library.php b/src/Library.php index bf1438bedb1..8d5c9d752a2 100644 --- a/src/Library.php +++ b/src/Library.php @@ -124,7 +124,7 @@ public static function register_blocks() { * @param array $statuses Array of statuses. * @return array */ - public static function register_draft_order_status( $statuses ) { + public static function register_draft_order_status( array $statuses ) { $statuses['wc-draft'] = _x( 'Draft', 'Order status', 'woo-gutenberg-products-block' ); return $statuses; } diff --git a/src/RestApi/StoreApi/Controllers/CartOrder.php b/src/RestApi/StoreApi/Controllers/CartOrder.php index 8f20bd838e6..9460321b027 100644 --- a/src/RestApi/StoreApi/Controllers/CartOrder.php +++ b/src/RestApi/StoreApi/Controllers/CartOrder.php @@ -113,7 +113,7 @@ public function create_item( $request ) { * @param \WC_Order $order Order object. * @return bool|RestError */ - protected function reserve_stock_for_draft_order( $order ) { + protected function reserve_stock_for_draft_order( \WC_Order $order ) { global $wpdb; try { @@ -217,7 +217,7 @@ protected function reserve_stock_for_draft_order( $order ) { * @param RestRequest $request Full details about the request. * @return \WC_Order A new order object. */ - protected function create_order_from_cart( $request ) { + protected function create_order_from_cart( RestRequest $request ) { $order = $this->get_order_object(); $order->set_status( 'draft' ); $order->set_created_via( 'store-api' ); @@ -270,7 +270,7 @@ public function default_order_status() { * @param \WC_Order $order Object to prepare for the response. * @param RestRequest $request Full details about the request. */ - protected function set_props_from_cart( &$order, $request ) { + protected function set_props_from_cart( \WC_Order &$order, RestRequest $request ) { $order->set_shipping_total( WC()->cart->get_shipping_total() ); $order->set_discount_total( WC()->cart->get_discount_total() ); $order->set_discount_tax( WC()->cart->get_discount_tax() ); @@ -286,7 +286,7 @@ protected function set_props_from_cart( &$order, $request ) { * @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( &$order, $request ) { + protected function create_line_items_from_cart( \WC_Order &$order, RestRequest $request ) { $draft_order_cart_hash = $order->get_cart_hash(); if ( WC()->cart->get_cart_hash() === $draft_order_cart_hash ) { @@ -313,7 +313,7 @@ protected function create_line_items_from_cart( &$order, $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( &$order, $request ) { + protected function set_props_from_request( \WC_Order &$order, RestRequest $request ) { $schema = $this->get_item_schema(); if ( isset( $request['billing_address'] ) ) { diff --git a/src/RestApi/StoreApi/Schemas/OrderItemSchema.php b/src/RestApi/StoreApi/Schemas/OrderItemSchema.php index e42abe5a4ba..ee24f5624c9 100644 --- a/src/RestApi/StoreApi/Schemas/OrderItemSchema.php +++ b/src/RestApi/StoreApi/Schemas/OrderItemSchema.php @@ -181,16 +181,21 @@ public function product_id_exists( $product_id ) { * @return array */ public function get_item_response( $line_item ) { - $product = $line_item->get_product(); + if ( ! ( $line_item instanceof \WC_Order_Item_Product ) ) { + return []; + } + + $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' => $product ? $product->get_title() : null, - 'sku' => $product ? $product->get_sku() : null, - 'permalink' => $product ? $product->get_permalink() : null, - 'images' => $product ? ( new ProductImages() )->images_to_array( $product ) : null, - 'variation' => $this->format_variation_data( $line_item, $product ), + '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(), [ @@ -210,7 +215,7 @@ public function get_item_response( $line_item ) { * @param \WC_Product $product Product data. * @return array */ - protected function format_variation_data( $line_item, $product ) { + 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() ); @@ -243,38 +248,4 @@ protected function format_variation_data( $line_item, $product ) { return $return; } - - /** - * Get product attribute taxonomy name. - * - * @param string $slug Taxonomy name. - * @param \WC_Product $object Product data. - * @return string - */ - protected function get_attribute_taxonomy_name( $slug, $object ) { - // Format slug so it matches attributes of the product. - $slug = wc_attribute_taxonomy_slug( $slug ); - $attributes = $object->get_attributes(); - $attribute = false; - - // pa_ attributes. - if ( isset( $attributes[ wc_attribute_taxonomy_name( $slug ) ] ) ) { - $attribute = $attributes[ wc_attribute_taxonomy_name( $slug ) ]; - } elseif ( isset( $attributes[ $slug ] ) ) { - $attribute = $attributes[ $slug ]; - } - - if ( ! $attribute ) { - return $slug; - } - - // Taxonomy attribute name. - if ( $attribute->is_taxonomy() ) { - $taxonomy = $attribute->get_taxonomy_object(); - return $taxonomy->attribute_label; - } - - // Custom product attribute name. - return $attribute->get_name(); - } } diff --git a/src/RestApi/StoreApi/Schemas/OrderSchema.php b/src/RestApi/StoreApi/Schemas/OrderSchema.php index ec265bebcf6..055a0056e18 100644 --- a/src/RestApi/StoreApi/Schemas/OrderSchema.php +++ b/src/RestApi/StoreApi/Schemas/OrderSchema.php @@ -457,65 +457,69 @@ protected function get_properties() { /** * Convert a woo order into an object suitable for the response. * - * @param \WC_Order $object Order class instance. + * @param \WC_Order $order Order class instance. * @return array */ - public function get_item_response( $object ) { + public function get_item_response( $order ) { + if ( ! ( $order instanceof \WC_Order ) ) { + return []; + } + $order_item_schema = new OrderItemSchema(); return [ - 'id' => $object->get_id(), - 'number' => $object->get_order_number(), - 'status' => $object->get_status(), - 'order_key' => $object->get_order_key(), - 'created_via' => $object->get_created_via(), - 'prices_include_tax' => $object->get_prices_include_tax(), - 'events' => $this->get_events( $object ), + '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' => $object->get_customer_id(), - 'customer_ip_address' => $object->get_customer_ip_address(), - 'customer_user_agent' => $object->get_customer_user_agent(), + 'customer_id' => $order->get_customer_id(), + 'customer_ip_address' => $order->get_customer_ip_address(), + 'customer_user_agent' => $order->get_customer_user_agent(), ], - 'customer_note' => $object->get_customer_note(), + 'customer_note' => $order->get_customer_note(), 'billing_address' => [ - 'first_name' => $object->get_billing_first_name(), - 'last_name' => $object->get_billing_last_name(), - 'company' => $object->get_billing_company(), - 'address_1' => $object->get_billing_address_1(), - 'address_2' => $object->get_billing_address_2(), - 'city' => $object->get_billing_city(), - 'state' => $object->get_billing_state(), - 'postcode' => $object->get_billing_postcode(), - 'country' => $object->get_billing_country(), - 'email' => $object->get_billing_email(), - 'phone' => $object->get_billing_phone(), + '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' => $object->get_shipping_first_name(), - 'last_name' => $object->get_shipping_last_name(), - 'company' => $object->get_shipping_company(), - 'address_1' => $object->get_shipping_address_1(), - 'address_2' => $object->get_shipping_address_2(), - 'city' => $object->get_shipping_city(), - 'state' => $object->get_shipping_state(), - 'postcode' => $object->get_shipping_postcode(), - 'country' => $object->get_shipping_country(), + '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' ], $object->get_items( 'line_item' ) ) ), + '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( $object->get_subtotal(), wc_get_price_decimals() ), - 'total_items_tax' => $this->prepare_money_response( $this->get_subtotal_tax( $object ), wc_get_price_decimals() ), - 'total_fees' => $this->prepare_money_response( $this->get_fee_total( $object ), wc_get_price_decimals() ), - 'total_fees_tax' => $this->prepare_money_response( $this->get_fee_tax( $object ), wc_get_price_decimals() ), - 'total_discount' => $this->prepare_money_response( $object->get_discount_total(), wc_get_price_decimals() ), - 'total_discount_tax' => $this->prepare_money_response( $object->get_discount_tax(), wc_get_price_decimals() ), - 'total_shipping' => $this->prepare_money_response( $object->get_shipping_total(), wc_get_price_decimals() ), - 'total_shipping_tax' => $this->prepare_money_response( $object->get_shipping_tax(), wc_get_price_decimals() ), - 'total_price' => $this->prepare_money_response( $object->get_total(), wc_get_price_decimals() ), - 'total_tax' => $this->prepare_money_response( $object->get_total_tax(), wc_get_price_decimals() ), - 'tax_lines' => $this->get_tax_lines( $object ), + '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 ), ] ), ]; @@ -534,15 +538,15 @@ protected function remove_status_prefix( $status ) { /** * Get event dates from an order, formatting both local and GMT values. * - * @param \WC_Order $object Order class instance. + * @param \WC_Order $order Order class instance. * @return array */ - protected function get_events( $object ) { + protected function get_events( \WC_Order $order ) { $events = []; $props = [ 'date_created', 'date_modified', 'date_completed', 'date_paid' ]; foreach ( $props as $prop ) { - $datetime = $object->{"get_$prop"}(); + $datetime = $order->{"get_$prop"}(); $events[ $prop ] = wc_rest_prepare_date_response( $datetime, false ); $events[ $prop . '_gmt' ] = wc_rest_prepare_date_response( $datetime ); } @@ -553,11 +557,11 @@ protected function get_events( $object ) { /** * Get tax lines from the order and format to match schema. * - * @param \WC_Order $object Order class instance. + * @param \WC_Order $order Order class instance. * @return array */ - protected function get_tax_lines( $object ) { - $tax_totals = $object->get_tax_totals(); + protected function get_tax_lines( \WC_Order $order ) { + $tax_totals = $order->get_tax_totals(); $tax_lines = []; foreach ( $tax_totals as $tax_total ) { @@ -577,12 +581,12 @@ protected function get_tax_lines( $object ) { * * @todo This could be added to the orders class to match the cart class. * - * @param \WC_Order $object Order class instance. + * @param \WC_Order $order Order class instance. * @return float */ - protected function get_subtotal_tax( $object ) { + protected function get_subtotal_tax( \WC_Order $order ) { $total = 0; - foreach ( $object->get_items() as $item ) { + foreach ( $order->get_items() as $item ) { $total += $item->get_subtotal_tax(); } return $total; @@ -594,12 +598,12 @@ protected function get_subtotal_tax( $object ) { * * @todo This could be added to the orders class to match the cart class. * - * @param \WC_Order $object Order class instance. + * @param \WC_Order $order Order class instance. * @return float */ - protected function get_fee_total( $object ) { + protected function get_fee_total( \WC_Order $order ) { $total = 0; - foreach ( $object->get_fees() as $item ) { + foreach ( $order->get_fees() as $item ) { $total += $item->get_total(); } return $total; @@ -612,12 +616,12 @@ protected function get_fee_total( $object ) { * * @todo This could be added to the orders class to match the cart class. * - * @param \WC_Order $object Order class instance. + * @param \WC_Order $order Order class instance. * @return float */ - protected function get_fee_tax( $object ) { + protected function get_fee_tax( \WC_Order $order ) { $total = 0; - foreach ( $object->get_fees() as $item ) { + foreach ( $order->get_fees() as $item ) { $total += $item->get_total_tax(); } return $total; From 7348af6290e600f59da8949b4a8d489ecb466e39 Mon Sep 17 00:00:00 2001 From: Mike Jolley Date: Thu, 9 Jan 2020 12:05:44 +0000 Subject: [PATCH 14/28] Remove explicit pass by reference --- src/RestApi/StoreApi/Controllers/CartOrder.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/RestApi/StoreApi/Controllers/CartOrder.php b/src/RestApi/StoreApi/Controllers/CartOrder.php index 9460321b027..76dc38b75fa 100644 --- a/src/RestApi/StoreApi/Controllers/CartOrder.php +++ b/src/RestApi/StoreApi/Controllers/CartOrder.php @@ -270,7 +270,7 @@ public function default_order_status() { * @param \WC_Order $order Object to prepare for the response. * @param RestRequest $request Full details about the request. */ - protected function set_props_from_cart( \WC_Order &$order, RestRequest $request ) { + protected function set_props_from_cart( \WC_Order $order, RestRequest $request ) { $order->set_shipping_total( WC()->cart->get_shipping_total() ); $order->set_discount_total( WC()->cart->get_discount_total() ); $order->set_discount_tax( WC()->cart->get_discount_tax() ); @@ -286,7 +286,7 @@ protected function set_props_from_cart( \WC_Order &$order, RestRequest $request * @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 ) { + protected function create_line_items_from_cart( \WC_Order $order, RestRequest $request ) { $draft_order_cart_hash = $order->get_cart_hash(); if ( WC()->cart->get_cart_hash() === $draft_order_cart_hash ) { @@ -313,7 +313,7 @@ protected function create_line_items_from_cart( \WC_Order &$order, RestRequest $ * @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 ) { + protected function set_props_from_request( \WC_Order $order, RestRequest $request ) { $schema = $this->get_item_schema(); if ( isset( $request['billing_address'] ) ) { From 9548eeaa990d7eccef6b5ddd03a1981a625f91d8 Mon Sep 17 00:00:00 2001 From: Mike Jolley Date: Thu, 9 Jan 2020 12:09:46 +0000 Subject: [PATCH 15/28] Further typehinting --- src/RestApi/StoreApi/Schemas/OrderItemSchema.php | 6 +----- src/RestApi/StoreApi/Schemas/OrderSchema.php | 6 +----- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/src/RestApi/StoreApi/Schemas/OrderItemSchema.php b/src/RestApi/StoreApi/Schemas/OrderItemSchema.php index ee24f5624c9..3312316d007 100644 --- a/src/RestApi/StoreApi/Schemas/OrderItemSchema.php +++ b/src/RestApi/StoreApi/Schemas/OrderItemSchema.php @@ -180,11 +180,7 @@ public function product_id_exists( $product_id ) { * @param \WC_Order_Item_Product $line_item Order line item array. * @return array */ - public function get_item_response( $line_item ) { - if ( ! ( $line_item instanceof \WC_Order_Item_Product ) ) { - return []; - } - + public function get_item_response( \WC_Order_Item_Product $line_item ) { $product = $line_item->get_product(); $has_product = $product instanceof \WC_Product; diff --git a/src/RestApi/StoreApi/Schemas/OrderSchema.php b/src/RestApi/StoreApi/Schemas/OrderSchema.php index 055a0056e18..d18b21cde2f 100644 --- a/src/RestApi/StoreApi/Schemas/OrderSchema.php +++ b/src/RestApi/StoreApi/Schemas/OrderSchema.php @@ -460,11 +460,7 @@ protected function get_properties() { * @param \WC_Order $order Order class instance. * @return array */ - public function get_item_response( $order ) { - if ( ! ( $order instanceof \WC_Order ) ) { - return []; - } - + public function get_item_response( \WC_Order $order ) { $order_item_schema = new OrderItemSchema(); return [ From c3f2836e29e98d4b6a340ecb8da4141b3750de53 Mon Sep 17 00:00:00 2001 From: Mike Jolley Date: Thu, 9 Jan 2020 12:10:52 +0000 Subject: [PATCH 16/28] Clarify todo comment --- src/RestApi/StoreApi/Schemas/OrderSchema.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/RestApi/StoreApi/Schemas/OrderSchema.php b/src/RestApi/StoreApi/Schemas/OrderSchema.php index d18b21cde2f..93eee3fe921 100644 --- a/src/RestApi/StoreApi/Schemas/OrderSchema.php +++ b/src/RestApi/StoreApi/Schemas/OrderSchema.php @@ -575,7 +575,7 @@ protected function get_tax_lines( \WC_Order $order ) { * * Needed because orders do not hold this total like carts. * - * @todo This could be added to the orders class to match the cart class. + * @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 @@ -592,7 +592,7 @@ protected function get_subtotal_tax( \WC_Order $order ) { * * Needed because orders do not hold this total like carts. * - * @todo This could be added to the orders class to match the cart class. + * @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 @@ -610,7 +610,7 @@ protected function get_fee_total( \WC_Order $order ) { * * Needed because orders do not hold this total like carts. * - * @todo This could be added to the orders class to match the cart class. + * @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 From 0573ae85f9c7b217e1bd73764ab2632a89caff5e Mon Sep 17 00:00:00 2001 From: Mike Jolley Date: Thu, 9 Jan 2020 12:12:15 +0000 Subject: [PATCH 17/28] Validate product instances --- src/RestApi/StoreApi/Controllers/CartOrder.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/RestApi/StoreApi/Controllers/CartOrder.php b/src/RestApi/StoreApi/Controllers/CartOrder.php index 0d251a8d295..52590307d9d 100644 --- a/src/RestApi/StoreApi/Controllers/CartOrder.php +++ b/src/RestApi/StoreApi/Controllers/CartOrder.php @@ -128,6 +128,10 @@ protected function reserve_stock_for_draft_order( \WC_Order $order ) { $product = $item->get_product(); + if ( ! ( $product instanceof \WC_Product ) ) { + continue; + } + if ( ! $product->is_in_stock() ) { throw new RestException( 'woocommerce_rest_cart_order_product_not_in_stock', From b7263fb54f2dacfcfc2ff607d74b248ceac57aa8 Mon Sep 17 00:00:00 2001 From: Mike Jolley Date: Thu, 9 Jan 2020 12:16:58 +0000 Subject: [PATCH 18/28] Specific phpcs exclusion rule --- src/RestApi/StoreApi/Controllers/CartOrder.php | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/RestApi/StoreApi/Controllers/CartOrder.php b/src/RestApi/StoreApi/Controllers/CartOrder.php index 52590307d9d..fce9be0b124 100644 --- a/src/RestApi/StoreApi/Controllers/CartOrder.php +++ b/src/RestApi/StoreApi/Controllers/CartOrder.php @@ -204,9 +204,8 @@ protected function reserve_stock_for_draft_order( \WC_Order $order ) { $values = implode( ',', $stock_to_reserve_rows ); - $wpdb->query( - "REPLACE INTO {$wpdb->wc_reserved_stock} ( order_id, product_id, stock_quantity ) VALUES {$values};" // phpcs:ignore - ); + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $wpdb->query( "REPLACE INTO {$wpdb->wc_reserved_stock} ( order_id, product_id, stock_quantity ) VALUES {$values};" ); } } catch ( RestException $e ) { return new RestError( $e->getErrorCode(), $e->getMessage(), array( 'status' => $e->getCode() ) ); From f2390a289f45b4b4dbd077231091501f4a711d01 Mon Sep 17 00:00:00 2001 From: Mike Jolley Date: Thu, 9 Jan 2020 12:40:57 +0000 Subject: [PATCH 19/28] Exclusion rule --- src/RestApi/StoreApi/Controllers/CartOrder.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/RestApi/StoreApi/Controllers/CartOrder.php b/src/RestApi/StoreApi/Controllers/CartOrder.php index fce9be0b124..ef7752428c3 100644 --- a/src/RestApi/StoreApi/Controllers/CartOrder.php +++ b/src/RestApi/StoreApi/Controllers/CartOrder.php @@ -204,7 +204,7 @@ protected function reserve_stock_for_draft_order( \WC_Order $order ) { $values = implode( ',', $stock_to_reserve_rows ); - // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + // 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};" ); } } catch ( RestException $e ) { From ee616dbc8c737abb3073f2cf1b07ddfd85439c6a Mon Sep 17 00:00:00 2001 From: Mike Jolley Date: Thu, 9 Jan 2020 14:29:31 +0000 Subject: [PATCH 20/28] Move ReserveStock code to class --- .../StoreApi/Controllers/CartOrder.php | 136 +++-------------- .../StoreApi/Utilities/ReserveStock.php | 141 ++++++++++++++++++ 2 files changed, 160 insertions(+), 117 deletions(-) create mode 100644 src/RestApi/StoreApi/Utilities/ReserveStock.php diff --git a/src/RestApi/StoreApi/Controllers/CartOrder.php b/src/RestApi/StoreApi/Controllers/CartOrder.php index ef7752428c3..d343d75d6af 100644 --- a/src/RestApi/StoreApi/Controllers/CartOrder.php +++ b/src/RestApi/StoreApi/Controllers/CartOrder.php @@ -10,13 +10,14 @@ defined( 'ABSPATH' ) || exit; -use \WP_Error as RestError; +use \WP_Error; use \WP_REST_Server as RestServer; use \WP_REST_Controller as RestController; use \WP_REST_Response as RestResponse; use \WP_REST_Request as RestRequest; use \WC_REST_Exception as RestException; use Automattic\WooCommerce\Blocks\RestApi\StoreApi\Schemas\OrderSchema; +use Automattic\WooCommerce\Blocks\RestApi\StoreApi\Utilities\ReserveStock; /** * Cart Order API. @@ -74,36 +75,29 @@ public function register_routes() { * 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? - * @todo set payment method - * @todo set customer note * * Based on WC_Checkout::create_order. * * @param RestRequest $request Full details about the request. - * @return RestError|RestResponse + * @return WP_Error|RestResponse */ public function create_item( $request ) { - add_filter( 'woocommerce_default_order_status', array( $this, 'default_order_status' ) ); - try { // Create or retrieve the draft order for the current cart. $order_object = $this->create_order_from_cart( $request ); - $reserve_stock = $this->reserve_stock_for_draft_order( $order_object ); + $reserve_stock = $this->reserve_stock( $order_object ); if ( is_wp_error( $reserve_stock ) ) { - // Something went wrong - return error. - $response = $reserve_stock; - } else { - $response = $this->prepare_item_for_response( $order_object, $request ); - $response->set_status( 201 ); + return $reserve_stock; } + + $response = $this->prepare_item_for_response( $order_object, $request ); + $response->set_status( 201 ); + return $response; } catch ( Exception $e ) { - $response = new RestError( 'checkout-error', $e->getMessage() ); + remove_filter( 'woocommerce_default_order_status', array( $this, 'default_order_status' ) ); + return new WP_Error( 'create-order-error', $e->getMessage() ); } - - remove_filter( 'woocommerce_default_order_status', array( $this, 'default_order_status' ) ); - - return $response; } /** @@ -111,107 +105,11 @@ public function create_item( $request ) { * * @throws RestException Exception when stock cannot be reserved. * @param \WC_Order $order Order object. - * @return bool|RestError + * @return bool|WP_Error */ - protected function reserve_stock_for_draft_order( \WC_Order $order ) { - global $wpdb; - - try { - $hold_stock_minutes = (int) get_option( 'woocommerce_hold_stock_minutes', 0 ); - $stock_to_reserve = []; - - // Loop over line items and check each item may be purchased. - foreach ( $order->get_items() as $item ) { - if ( ! $item->is_type( 'line_item' ) ) { - continue; - } - - $product = $item->get_product(); - - if ( ! ( $product instanceof \WC_Product ) ) { - continue; - } - - if ( ! $product->is_in_stock() ) { - throw new RestException( - 'woocommerce_rest_cart_order_product_not_in_stock', - sprintf( - /* translators: %s: product name */ - __( '%s is out of stock and cannot be purchased.', 'woo-gutenberg-products-block' ), - $product->get_name() - ), - 403 - ); - } - - // If stock management is off, no need to reserve any stock here. - if ( ! $product->managing_stock() || $product->backorders_allowed() ) { - continue; - } - - $stocked_product_id = $product->get_stock_managed_by_id(); - - if ( ! isset( $stock_to_reserve[ $stocked_product_id ] ) ) { - $stock_to_reserve[ $stocked_product_id ] = 0; - } - - // Query for any existing holds on stock for this item. - // Ignores reserved stock already made for this order. - // Ignores stock for orders which are no longer drafts (assuming real stock reduction was performed). - // Ignores stock reserved over 10 mins ago. Client can call this endpoint to renew holds on stock. - $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-draft' - AND stock_table.`order_id` != %d - AND stock_table.`timestamp` > ( NOW() - INTERVAL 10 MINUTE ) - ", - $stocked_product_id, - $order->get_id() - ) - ); - - // Deals with legacy stock reservation which the core Woo checkout performs. - $reserved_stock += ( $hold_stock_minutes > 0 ) ? wc_get_held_stock_quantity( $product ) : 0; - - if ( ( $product->get_stock_quantity() - $reserved_stock - $stock_to_reserve[ $stocked_product_id ] ) < $item->get_quantity() ) { - throw new RestException( - 'woocommerce_rest_cart_order_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() - ), - 403 - ); - } - - // Queue this reservation for later DB insertion. - $stock_to_reserve[ $stocked_product_id ] += $item->get_quantity(); - } - - $stock_to_reserve = array_filter( $stock_to_reserve ); - - if ( $stock_to_reserve ) { - $stock_to_reserve_rows = []; - - foreach ( $stock_to_reserve as $product_id => $stock_quantity ) { - $stock_to_reserve_rows[] = '(' . esc_sql( $order->get_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};" ); - } - } catch ( RestException $e ) { - return new RestError( $e->getErrorCode(), $e->getMessage(), array( 'status' => $e->getCode() ) ); - } - - return true; + protected function reserve_stock( \WC_Order $order ) { + $reserve_stock_helper = new ReserveStock(); + return $reserve_stock_helper->reserve_stock_for_order( $order ); } /** @@ -221,6 +119,8 @@ protected function reserve_stock_for_draft_order( \WC_Order $order ) { * @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( 'draft' ); $order->set_created_via( 'store-api' ); @@ -236,6 +136,8 @@ protected function create_order_from_cart( RestRequest $request ) { $order->save(); + remove_filter( 'woocommerce_default_order_status', array( $this, 'default_order_status' ) ); + // Store Order ID in session so we can look it up later. WC()->session->set( 'draft_order_id', $order->get_id() ); diff --git a/src/RestApi/StoreApi/Utilities/ReserveStock.php b/src/RestApi/StoreApi/Utilities/ReserveStock.php new file mode 100644 index 00000000000..e0a6591e7f3 --- /dev/null +++ b/src/RestApi/StoreApi/Utilities/ReserveStock.php @@ -0,0 +1,141 @@ +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. + */ + protected 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-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; + } +} From a8a937948839cd72beaf7a78aab5681126226334 Mon Sep 17 00:00:00 2001 From: Mike Jolley Date: Thu, 9 Jan 2020 14:44:06 +0000 Subject: [PATCH 21/28] Correct shipping-rates schema to shipping_rates --- src/RestApi/StoreApi/README.md | 2 +- .../Schemas/CartShippingRateSchema.php | 4 ++-- .../Controllers/CartShippingRates.php | 24 +++++++++---------- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/RestApi/StoreApi/README.md b/src/RestApi/StoreApi/README.md index 6134a0256b8..63a9ccaaea6 100644 --- a/src/RestApi/StoreApi/README.md +++ b/src/RestApi/StoreApi/README.md @@ -990,7 +990,7 @@ Example response: "country": "US" }, "items": [ "6512bd43d9caa6e02c990b0a82652dca" ], - "shipping-rates": [ + "shipping_rates": [ { "name": "International", "description": "", 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/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() ); } /** From ec62913e669a28ea3c52d42ebd11177dde0a7f2c Mon Sep 17 00:00:00 2001 From: Mike Jolley Date: Thu, 9 Jan 2020 15:44:44 +0000 Subject: [PATCH 22/28] Save shipping rates and lines if included with request --- src/RestApi.php | 2 +- .../StoreApi/Controllers/CartOrder.php | 99 +++++++++++++++---- 2 files changed, 80 insertions(+), 21 deletions(-) diff --git a/src/RestApi.php b/src/RestApi.php index 0841805ea24..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. * diff --git a/src/RestApi/StoreApi/Controllers/CartOrder.php b/src/RestApi/StoreApi/Controllers/CartOrder.php index d343d75d6af..cd910136078 100644 --- a/src/RestApi/StoreApi/Controllers/CartOrder.php +++ b/src/RestApi/StoreApi/Controllers/CartOrder.php @@ -64,7 +64,25 @@ public function register_routes() { [ 'methods' => RestServer::CREATABLE, 'callback' => array( $this, 'create_item' ), - 'args' => $this->get_endpoint_args_for_item_schema( RestServer::CREATABLE ), + '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' => true, + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'rate_id' => [ + 'description' => __( 'ID of the shipping rate.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + ], + ], + ], + ), + ) + ), ], 'schema' => [ $this, 'get_public_item_schema' ], ] @@ -83,21 +101,48 @@ public function register_routes() { */ public function create_item( $request ) { try { + // If part of the request, first update shipping selections so that cart totals are correct. + $this->select_shipping_rates( $request ); + // Create or retrieve the draft order for the current cart. - $order_object = $this->create_order_from_cart( $request ); - $reserve_stock = $this->reserve_stock( $order_object ); + $order_object = $this->create_order_from_cart( $request ); - if ( is_wp_error( $reserve_stock ) ) { - return $reserve_stock; - } + // 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 ) { - remove_filter( 'woocommerce_default_order_status', array( $this, 'default_order_status' ) ); - return new WP_Error( 'create-order-error', $e->getMessage() ); + return new WP_Error( 'create-order-error', $e->getMessage(), [ 'status' => 500 ] ); + } + } + + /** + * Select shipping rates and store in session. + * + * @throws RestException Exception when shipping is invalid. + * @param RestRequest $request Full details about the request. + */ + protected function select_shipping_rates( RestRequest $request ) { + if ( ! isset( $request['shipping_rates'] ) ) { + return; + } + + $chosen_shipping_methods = []; + + foreach ( $request['shipping_rates'] as $shipping_rate ) { + if ( ! isset( $shipping_rate['rate_id'] ) ) { + continue; + } + $chosen_shipping_methods[] = $shipping_rate['rate_id']; } + + WC()->session->set( 'chosen_shipping_methods', $chosen_shipping_methods ); + WC()->cart->calculate_shipping(); + WC()->cart->calculate_totals(); } /** @@ -105,11 +150,14 @@ public function create_item( $request ) { * * @throws RestException Exception when stock cannot be reserved. * @param \WC_Order $order Order object. - * @return bool|WP_Error */ protected function reserve_stock( \WC_Order $order ) { $reserve_stock_helper = new ReserveStock(); - return $reserve_stock_helper->reserve_stock_for_order( $order ); + $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' ) ); + } } /** @@ -188,28 +236,39 @@ protected function set_props_from_cart( \WC_Order $order, RestRequest $request ) /** * 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. + * * @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 ) { - $draft_order_cart_hash = $order->get_cart_hash(); - - if ( WC()->cart->get_cart_hash() === $draft_order_cart_hash ) { - return; + // We only need to update line items if the cart changed. + if ( WC()->cart->get_cart_hash() !== $order->get_cart_hash() ) { + $order->remove_order_items( 'line_item' ); + $order->set_cart_hash( WC()->cart->get_cart_hash() ); + WC()->checkout->create_order_line_items( $order, WC()->cart ); } - if ( $draft_order_cart_hash ) { - $order->remove_order_items(); + // This checks to see if coupons have changed between the cart and the order. + if ( array_diff( $order->get_coupon_codes(), WC()->cart->get_applied_coupons() ) ) { + $order->remove_order_items( 'coupon' ); + WC()->checkout->create_order_coupon_lines( $order, WC()->cart ); } - WC()->checkout->create_order_line_items( $order, WC()->cart ); + // There is no way to know if fees changed, so recreate them here. + $order->remove_order_items( 'fee' ); WC()->checkout->create_order_fee_lines( $order, WC()->cart ); - // @todo This may need revision during checkout implementation in order to store chosen shipping options rather than those from the cart. + + // There is no way to know if shipping changed, so recreate it here. + $order->remove_order_items( 'shipping' ); WC()->checkout->create_order_shipping_lines( $order, WC()->session->get( 'chosen_shipping_methods' ), WC()->shipping()->get_packages() ); + + // There is no way to know if taxes changed, so recreate it here. + $order->remove_order_items( 'tax' ); WC()->checkout->create_order_tax_lines( $order, WC()->cart ); - WC()->checkout->create_order_coupon_lines( $order, WC()->cart ); - $order->set_cart_hash( WC()->cart->get_cart_hash() ); } /** From ad95bf3eaeb276059095b1448c9239a2ad3d4f71 Mon Sep 17 00:00:00 2001 From: Mike Jolley Date: Thu, 9 Jan 2020 15:46:01 +0000 Subject: [PATCH 23/28] Insert todo for shipping rate code --- src/RestApi/StoreApi/Controllers/CartOrder.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/RestApi/StoreApi/Controllers/CartOrder.php b/src/RestApi/StoreApi/Controllers/CartOrder.php index cd910136078..c5370eeabea 100644 --- a/src/RestApi/StoreApi/Controllers/CartOrder.php +++ b/src/RestApi/StoreApi/Controllers/CartOrder.php @@ -123,6 +123,9 @@ public function create_item( $request ) { /** * Select shipping rates and store in session. * + * @todo Might be useful to included validation here. However right now it will just default to the default method + * if the chosen rate id is invalid. + * * @throws RestException Exception when shipping is invalid. * @param RestRequest $request Full details about the request. */ From 75cd337547c28da2a67cbf123354ba5572dd1bbb Mon Sep 17 00:00:00 2001 From: Mike Jolley Date: Thu, 9 Jan 2020 17:24:16 +0000 Subject: [PATCH 24/28] Calculate shipping and selected shipping from order properties, not global cart properties --- .../StoreApi/Controllers/CartOrder.php | 230 +++++++++++++----- 1 file changed, 174 insertions(+), 56 deletions(-) diff --git a/src/RestApi/StoreApi/Controllers/CartOrder.php b/src/RestApi/StoreApi/Controllers/CartOrder.php index c5370eeabea..f5afb32a987 100644 --- a/src/RestApi/StoreApi/Controllers/CartOrder.php +++ b/src/RestApi/StoreApi/Controllers/CartOrder.php @@ -46,6 +46,13 @@ class CartOrder extends RestController { */ protected $schema; + /** + * Draft order details, if applicable. + * + * @var array + */ + protected $draft_order; + /** * Setup API class. */ @@ -101,8 +108,21 @@ public function register_routes() { */ public function create_item( $request ) { try { - // If part of the request, first update shipping selections so that cart totals are correct. - $this->select_shipping_rates( $request ); + $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 ); @@ -121,31 +141,30 @@ public function create_item( $request ) { } /** - * Select shipping rates and store in session. + * 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. * - * @todo Might be useful to included validation here. However right now it will just default to the default method - * if the chosen rate id is invalid. - * - * @throws RestException Exception when shipping is invalid. * @param RestRequest $request Full details about the request. + * @return void */ - protected function select_shipping_rates( RestRequest $request ) { - if ( ! isset( $request['shipping_rates'] ) ) { - return; - } + protected function update_session( RestRequest $request ) { + $schema = $this->get_item_schema(); - $chosen_shipping_methods = []; + 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 ); + } + } - foreach ( $request['shipping_rates'] as $shipping_rate ) { - if ( ! isset( $shipping_rate['rate_id'] ) ) { - continue; + 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 ); } - $chosen_shipping_methods[] = $shipping_rate['rate_id']; } - WC()->session->set( 'chosen_shipping_methods', $chosen_shipping_methods ); - WC()->cart->calculate_shipping(); - WC()->cart->calculate_totals(); + WC()->customer->save(); } /** @@ -180,29 +199,53 @@ protected function create_order_from_cart( RestRequest $request ) { $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_cart( $order, $request ); - $this->create_line_items_from_cart( $order, $request ); $this->set_props_from_request( $order, $request ); + $this->create_line_items_from_cart( $order, $request ); + $this->select_shipping_rates( $order, $request ); - $order->save(); + // Calc totals, taxes, and save. + $order->calculate_totals(); remove_filter( 'woocommerce_default_order_status', array( $this, 'default_order_status' ) ); - // Store Order ID in session so we can look it up later. - WC()->session->set( 'draft_order_id', $order->get_id() ); + // 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_id = WC()->session->get( 'draft_order_id' ); - $draft_order = $draft_order_id ? wc_get_order( $draft_order_id ) : false; + $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; @@ -220,58 +263,133 @@ public function default_order_status() { return 'draft'; } - /** - * Set order totals based on those calculated by the cart. - * - * @param \WC_Order $order Object to prepare for the response. - * @param RestRequest $request Full details about the request. - */ - protected function set_props_from_cart( \WC_Order $order, RestRequest $request ) { - $order->set_shipping_total( WC()->cart->get_shipping_total() ); - $order->set_discount_total( WC()->cart->get_discount_total() ); - $order->set_discount_tax( WC()->cart->get_discount_tax() ); - $order->set_cart_tax( WC()->cart->get_cart_contents_tax() + WC()->cart->get_fee_tax() ); - $order->set_shipping_tax( WC()->cart->get_shipping_tax() ); - $order->set_total( WC()->cart->get_total( 'edit' ) ); - $order->update_meta_data( 'is_vat_exempt', WC()->cart->get_customer()->get_is_vat_exempt() ? 'yes' : 'no' ); - } - /** * 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. + * 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 ) { - // We only need to update line items if the cart changed. - if ( WC()->cart->get_cart_hash() !== $order->get_cart_hash() ) { + $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' ); - $order->set_cart_hash( WC()->cart->get_cart_hash() ); WC()->checkout->create_order_line_items( $order, WC()->cart ); } - // This checks to see if coupons have changed between the cart and the order. - if ( array_diff( $order->get_coupon_codes(), WC()->cart->get_applied_coupons() ) ) { + if ( $new_hashes['coupons'] !== $old_hashes['coupons'] ) { $order->remove_order_items( 'coupon' ); WC()->checkout->create_order_coupon_lines( $order, WC()->cart ); } - // There is no way to know if fees changed, so recreate them here. - $order->remove_order_items( 'fee' ); - WC()->checkout->create_order_fee_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; + } - // There is no way to know if shipping changed, so recreate it here. $order->remove_order_items( 'shipping' ); - WC()->checkout->create_order_shipping_lines( $order, WC()->session->get( 'chosen_shipping_methods' ), WC()->shipping()->get_packages() ); - // There is no way to know if taxes changed, so recreate it here. - $order->remove_order_items( 'tax' ); - WC()->checkout->create_order_tax_lines( $order, WC()->cart ); + foreach ( $packages as $package_key => $package ) { + $fallback_rate_id = current( array_keys( $package['rates'] ) ); + $selected_rate_id = isset( $selected_rates[ $package_key ] ) ? $selected_rates[ $package_key ] : $fallback_rate_id; + $selected_rate = isset( $package['rates'][ $selected_rate_id ] ) ? $package['rates'][ $selected_rate_id ] : false; + + 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( $package['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 cart items + */ + protected function get_shipping_packages( \WC_Order $order, RestRequest $request ) { + $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; } /** From aba04bf70e2fbd626e0047f530c3c8176037413c Mon Sep 17 00:00:00 2001 From: Mike Jolley Date: Fri, 10 Jan 2020 11:25:10 +0000 Subject: [PATCH 25/28] Prevent error when shipping is not needed --- .../StoreApi/Controllers/CartOrder.php | 23 +++++++++++--- .../StoreApi/Controllers/CartOrder.php | 31 ++++++++++--------- 2 files changed, 35 insertions(+), 19 deletions(-) diff --git a/src/RestApi/StoreApi/Controllers/CartOrder.php b/src/RestApi/StoreApi/Controllers/CartOrder.php index f5afb32a987..ec3724f5409 100644 --- a/src/RestApi/StoreApi/Controllers/CartOrder.php +++ b/src/RestApi/StoreApi/Controllers/CartOrder.php @@ -77,7 +77,7 @@ public function register_routes() { 'shipping_rates' => array( 'description' => __( 'Selected shipping rates to apply to the order.', 'woo-gutenberg-products-block' ), 'type' => 'array', - 'required' => true, + 'required' => false, 'items' => [ 'type' => 'object', 'properties' => [ @@ -319,9 +319,18 @@ protected function select_shipping_rates( \WC_Order $order, RestRequest $request $order->remove_order_items( 'shipping' ); foreach ( $packages as $package_key => $package ) { - $fallback_rate_id = current( array_keys( $package['rates'] ) ); + $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( $package['rates'][ $selected_rate_id ] ) ? $package['rates'][ $selected_rate_id ] : false; + $selected_rate = isset( $rates[ $selected_rate_id ] ) ? $rates[ $selected_rate_id ] : false; + + if ( ! $rates ) { + throw new RestException( + 'no-shipping-rates-found', + __( 'No shipping rates found. Please check your shipping address.', 'woo-gutenberg-products-block' ), + 403 + ); + } if ( ! $selected_rate ) { throw new RestException( @@ -330,7 +339,7 @@ protected function select_shipping_rates( \WC_Order $order, RestRequest $request /* 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( $package['rates'] ) ) + implode( ', ', array_keys( $rates ) ) ), 403 ); @@ -371,9 +380,13 @@ protected function select_shipping_rates( \WC_Order $order, RestRequest $request * * @param \WC_Order $order Object to prepare for the response. * @param RestRequest $request Full details about the request. - * @return array of cart items + * @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 ) { diff --git a/tests/php/RestApi/StoreApi/Controllers/CartOrder.php b/tests/php/RestApi/StoreApi/Controllers/CartOrder.php index 6ae097607c0..02427c20ed3 100644 --- a/tests/php/RestApi/StoreApi/Controllers/CartOrder.php +++ b/tests/php/RestApi/StoreApi/Controllers/CartOrder.php @@ -39,7 +39,7 @@ public function setUp() { wc_empty_cart(); - $this->keys = []; + $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 ); } @@ -51,7 +51,7 @@ 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' ); + $request = new WP_REST_Request( 'GET', '/wc/store/cart/order' ); $response = $this->server->dispatch( $request ); $this->assertEquals( 404, $response->get_status() ); } @@ -61,18 +61,21 @@ public function test_register_routes() { */ 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" => "" - ] ); + $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(); From 8e71d1893bc8f29602a2c01e41a37efc61f77f00 Mon Sep 17 00:00:00 2001 From: Mike Jolley Date: Fri, 10 Jan 2020 11:34:23 +0000 Subject: [PATCH 26/28] Update API readme --- src/RestApi/StoreApi/README.md | 429 +++++++++++++++++---------------- 1 file changed, 215 insertions(+), 214 deletions(-) diff --git a/src/RestApi/StoreApi/README.md b/src/RestApi/StoreApi/README.md index 63a9ccaaea6..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" + } } ``` @@ -1019,11 +1019,12 @@ Create a new order from the items in the cart. 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. | +| 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 @@ -1033,111 +1034,111 @@ 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" - } - ] - } + "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" + } + ] + } } ``` @@ -1159,31 +1160,31 @@ Example response: ```json { - "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" - } + "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" + } } ``` @@ -1195,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 @@ -1208,31 +1209,31 @@ Example response: ```json { - "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" - } + "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" + } } ``` From 1ed586539214d21643ee0452c6e1b69f9e80e4a0 Mon Sep 17 00:00:00 2001 From: Mike Jolley Date: Fri, 10 Jan 2020 12:48:09 +0000 Subject: [PATCH 27/28] Added tests for stock reserve class --- .../StoreApi/Utilities/ReserveStock.php | 3 +- .../StoreApi/Utilities/ReserveStock.php | 72 +++++++++++++++++++ 2 files changed, 73 insertions(+), 2 deletions(-) create mode 100644 tests/php/RestApi/StoreApi/Utilities/ReserveStock.php diff --git a/src/RestApi/StoreApi/Utilities/ReserveStock.php b/src/RestApi/StoreApi/Utilities/ReserveStock.php index e0a6591e7f3..b27505d858a 100644 --- a/src/RestApi/StoreApi/Utilities/ReserveStock.php +++ b/src/RestApi/StoreApi/Utilities/ReserveStock.php @@ -18,7 +18,6 @@ class ReserveStock { /** * Put a temporary hold on stock for an order if enough is available. * - * @throws RestException Exception when stock cannot be reserved. * @param \WC_Order $order Order object. * @return bool|WP_Error */ @@ -114,7 +113,7 @@ protected function reserve_stock( $stock_to_reserve, $order_id ) { * @param integer $exclude_order_id Optional order to exclude from the results. * @return integer Amount of stock already reserved. */ - protected function get_reserved_stock( \WC_Product $product, $exclude_order_id = 0 ) { + public function get_reserved_stock( \WC_Product $product, $exclude_order_id = 0 ) { global $wpdb; $reserved_stock = $wpdb->get_var( diff --git a/tests/php/RestApi/StoreApi/Utilities/ReserveStock.php b/tests/php/RestApi/StoreApi/Utilities/ReserveStock.php new file mode 100644 index 00000000000..6a439246bcb --- /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( '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( '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( 'draft' ); + $order->save(); + + $result = $class->reserve_stock_for_order( $order ); + $this->assertTrue( $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 + ) + ); + } +} From 250a5eeccec961bae91be760099253e3ae4d6f49 Mon Sep 17 00:00:00 2001 From: Mike Jolley Date: Fri, 10 Jan 2020 13:46:25 +0000 Subject: [PATCH 28/28] Fixes conflicts with draft statuses --- src/Library.php | 22 ++++++++++++++++++- .../StoreApi/Controllers/CartOrder.php | 10 +++------ .../StoreApi/Utilities/ReserveStock.php | 2 +- .../StoreApi/Controllers/CartOrder.php | 2 +- .../StoreApi/Utilities/ReserveStock.php | 8 +++---- 5 files changed, 30 insertions(+), 14 deletions(-) diff --git a/src/Library.php b/src/Library.php index 8d5c9d752a2..8fb39667ce4 100644 --- a/src/Library.php +++ b/src/Library.php @@ -22,6 +22,7 @@ public static function init() { 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' ) ); } /** @@ -125,7 +126,26 @@ public static function register_blocks() { * @return array */ public static function register_draft_order_status( array $statuses ) { - $statuses['wc-draft'] = _x( 'Draft', 'Order status', 'woo-gutenberg-products-block' ); + $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/StoreApi/Controllers/CartOrder.php b/src/RestApi/StoreApi/Controllers/CartOrder.php index ec3724f5409..7fc952016be 100644 --- a/src/RestApi/StoreApi/Controllers/CartOrder.php +++ b/src/RestApi/StoreApi/Controllers/CartOrder.php @@ -192,7 +192,7 @@ 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( 'draft' ); + $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' ) ); @@ -260,7 +260,7 @@ protected function get_order_object() { * @return string */ public function default_order_status() { - return 'draft'; + return 'checkout-draft'; } /** @@ -325,11 +325,7 @@ protected function select_shipping_rates( \WC_Order $order, RestRequest $request $selected_rate = isset( $rates[ $selected_rate_id ] ) ? $rates[ $selected_rate_id ] : false; if ( ! $rates ) { - throw new RestException( - 'no-shipping-rates-found', - __( 'No shipping rates found. Please check your shipping address.', 'woo-gutenberg-products-block' ), - 403 - ); + continue; } if ( ! $selected_rate ) { diff --git a/src/RestApi/StoreApi/Utilities/ReserveStock.php b/src/RestApi/StoreApi/Utilities/ReserveStock.php index b27505d858a..31569b36bd4 100644 --- a/src/RestApi/StoreApi/Utilities/ReserveStock.php +++ b/src/RestApi/StoreApi/Utilities/ReserveStock.php @@ -122,7 +122,7 @@ public function get_reserved_stock( \WC_Product $product, $exclude_order_id = 0 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-draft' + AND posts.post_status = 'wc-checkout-draft' AND stock_table.`order_id` != %d AND stock_table.`timestamp` > ( NOW() - INTERVAL 10 MINUTE ) ", diff --git a/tests/php/RestApi/StoreApi/Controllers/CartOrder.php b/tests/php/RestApi/StoreApi/Controllers/CartOrder.php index 02427c20ed3..599c7716f23 100644 --- a/tests/php/RestApi/StoreApi/Controllers/CartOrder.php +++ b/tests/php/RestApi/StoreApi/Controllers/CartOrder.php @@ -106,7 +106,7 @@ public function test_create_item() { $this->assertEquals( 'test@test.com', $data['billing_address']['email'] ); $this->assertEquals( '', $data['billing_address']['phone'] ); - $this->assertEquals( 'draft', $data['status'] ); + $this->assertEquals( 'checkout-draft', $data['status'] ); $this->assertEquals( 2, count( $data['items'] ) ); } diff --git a/tests/php/RestApi/StoreApi/Utilities/ReserveStock.php b/tests/php/RestApi/StoreApi/Utilities/ReserveStock.php index 6a439246bcb..387252d46b3 100644 --- a/tests/php/RestApi/StoreApi/Utilities/ReserveStock.php +++ b/tests/php/RestApi/StoreApi/Utilities/ReserveStock.php @@ -29,7 +29,7 @@ public function test_reserve_stock_for_order() { $product->save(); $order = OrderHelper::create_order( 1, $product ); // Note this adds 4 to the order. - $order->set_status( 'draft' ); + $order->set_status( 'checkout-draft' ); $order->save(); $result = $class->reserve_stock_for_order( $order ); @@ -38,7 +38,7 @@ public function test_reserve_stock_for_order() { // Repeat. $order = OrderHelper::create_order( 1, $product ); - $order->set_status( 'draft' ); + $order->set_status( 'checkout-draft' ); $order->save(); $result = $class->reserve_stock_for_order( $order ); @@ -47,11 +47,11 @@ public function test_reserve_stock_for_order() { // Repeat again - should not be enough stock for this. $order = OrderHelper::create_order( 1, $product ); - $order->set_status( 'draft' ); + $order->set_status( 'checkout-draft' ); $order->save(); $result = $class->reserve_stock_for_order( $order ); - $this->assertTrue( $result ); + $this->assertTrue( is_wp_error( $result ) ); } /**