* CheckoutProcessing work

add missing memoization and implement useRef strategically

This prevents effects from firing unnecessarily.

Tweak assets registration

Order hydration and checkout/ endpoint updates

Fix error handling

Error handling

* Missing isset in stripe

* Fedeback

* rename draft order ID action

* Todos
This commit is contained in:
Mike Jolley 2020-03-30 15:32:23 +01:00 committed by GitHub
parent 5e78c47e4d
commit 2bca9840c6
28 changed files with 885 additions and 727 deletions

View File

@ -8,10 +8,7 @@
*/
import { getSetting } from '@woocommerce/settings';
/**
* @type {BillingData}
*/
const HYDRATED_BILLING_DATA = getSetting( 'billingData' );
const checkoutData = getSetting( 'checkoutData', {} );
/**
* @type {BillingData}
@ -35,7 +32,7 @@ export const DEFAULT_BILLING_DATA = {
*/
export const DEFAULT_STATE = {
...DEFAULT_BILLING_DATA,
...HYDRATED_BILLING_DATA,
...checkoutData.billing_address,
};
/**

View File

@ -12,6 +12,7 @@ const {
SET_NO_ERROR,
INCREMENT_CALCULATING,
DECREMENT_CALCULATING,
SET_ORDER_ID,
} = TYPES;
/**
@ -43,4 +44,8 @@ export const actions = {
decrementCalculating: () => ( {
type: DECREMENT_CALCULATING,
} ),
setOrderId: ( orderId ) => ( {
type: SET_ORDER_ID,
orderId,
} ),
};

View File

@ -1,3 +1,8 @@
/**
* External dependencies
*/
import { getSetting } from '@woocommerce/settings';
/**
* @type {import("@woocommerce/type-defs/checkout").CheckoutStatusConstants}
*/
@ -9,6 +14,8 @@ export const STATUS = {
COMPLETE: 'complete',
};
const checkoutData = getSetting( 'checkoutData', { order_id: 0 } );
export const DEFAULT_STATE = {
redirectUrl: '',
status: STATUS.PRISTINE,
@ -17,6 +24,7 @@ export const DEFAULT_STATE = {
nextStatus: STATUS.IDLE,
hasError: false,
calculatingCount: 0,
orderId: checkoutData.order_id,
};
export const TYPES = {
@ -26,6 +34,7 @@ export const TYPES = {
SET_PROCESSING: 'set_checkout_is_processing',
SET_HAS_ERROR: 'set_checkout_has_error',
SET_NO_ERROR: 'set_checkout_no_error',
SET_ORDER_ID: 'set_checkout_order_id',
INCREMENT_CALCULATING: 'increment_calculating',
DECREMENT_CALCULATING: 'decrement_calculating',
};

View File

@ -43,6 +43,7 @@ const CheckoutContext = createContext( {
isProcessing: false,
hasError: false,
redirectUrl: '',
orderId: 0,
onCheckoutCompleteSuccess: () => void null,
onCheckoutCompleteError: () => void null,
onCheckoutProcessing: () => void null,
@ -53,6 +54,7 @@ const CheckoutContext = createContext( {
clearError: () => void null,
incrementCalculating: () => void null,
decrementCalculating: () => void null,
setOrderId: () => void null,
},
} );
@ -100,15 +102,15 @@ export const CheckoutProvider = ( {
}, [ observers ] );
const onCheckoutCompleteSuccess = useMemo(
() => emitterSubscribers( subscriber ).onCheckoutCompleteSuccess,
[]
[ subscriber ]
);
const onCheckoutCompleteError = useMemo(
() => emitterSubscribers( subscriber ).onCheckoutCompleteError,
[]
[ subscriber ]
);
const onCheckoutProcessing = useMemo(
() => emitterSubscribers( subscriber ).onCheckoutProcessing,
[]
[ subscriber ]
);
/**
@ -125,6 +127,8 @@ export const CheckoutProvider = ( {
void dispatch( actions.incrementCalculating() ),
decrementCalculating: () =>
void dispatch( actions.decrementCalculating() ),
setOrderId: ( orderId ) =>
void dispatch( actions.setOrderId( orderId ) ),
} ),
[]
);
@ -195,6 +199,8 @@ export const CheckoutProvider = ( {
onCheckoutProcessing,
dispatchActions,
isEditor,
orderId: checkoutState.orderId,
hasOrder: !! checkoutState.orderId,
};
return (
<CheckoutContext.Provider value={ checkoutData }>

View File

@ -1,12 +1,112 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import triggerFetch from '@wordpress/api-fetch';
import {
useCheckoutContext,
useShippingDataContext,
useBillingDataContext,
usePaymentMethodDataContext,
} from '@woocommerce/base-context';
import { useEffect, useRef, useCallback } from '@wordpress/element';
import { useStoreNotices } from '@woocommerce/base-hooks';
/**
* CheckoutProcessor component. @todo Needs to consume all contexts.
*
* Subscribes to checkout context and triggers processing when needed:
* 1. Create an order (draft) when email address is provided
* 2. Process an order when the place order button is pressed
* (checkout processing)
* Subscribes to checkout context and triggers processing via the API.
*/
const CheckoutProcessor = () => {
const { onCheckoutProcessing, dispatchActions } = useCheckoutContext();
const { shippingAddress } = useShippingDataContext();
const { billingData } = useBillingDataContext();
const { activePaymentMethod } = usePaymentMethodDataContext();
const { addErrorNotice } = useStoreNotices();
const currentBillingData = useRef( billingData );
const currentShippingAddress = useRef( shippingAddress );
useEffect( () => {
currentBillingData.current = billingData;
currentShippingAddress.current = shippingAddress;
}, [ billingData, shippingAddress ] );
/**
* Process an order via the /wc/store/checkout endpoint.
*
* @return {boolean} True if everything was successful.
*/
const processCheckout = useCallback( async () => {
await triggerFetch( {
path: '/wc/store/checkout',
method: 'POST',
data: {
payment_method: activePaymentMethod,
// @todo Hook this up to payment method data.
payment_data: [],
billing_address: currentBillingData.current,
shipping_address: currentShippingAddress.current,
customer_note: '',
},
cache: 'no-store',
parse: false,
} )
.then( ( fetchResponse ) => {
// Update nonce.
triggerFetch.setNonce( fetchResponse.headers );
// Handle response.
fetchResponse.json().then( function( response ) {
if ( ! fetchResponse.ok ) {
// We received an error response.
if ( response.body && response.body.message ) {
addErrorNotice( response.body.message, {
id: 'checkout',
} );
} else {
addErrorNotice(
__(
'Something went wrong. Please check your payment details and try again.',
'woo-gutenberg-products-block'
),
{
id: 'checkout',
}
);
}
}
dispatchActions.setRedirectUrl(
response.payment_result.redirect_url
);
// @todo do we need to trigger more handling here?
} );
} )
.catch( ( error ) => {
addErrorNotice( error.message, {
id: 'checkout',
} );
} );
return true;
}, [
addErrorNotice,
activePaymentMethod,
currentBillingData,
currentShippingAddress,
] );
/**
* When the checkout is processing, process the order.
*/
useEffect( () => {
const unsubscribeProcessing = onCheckoutProcessing( processCheckout );
return () => {
unsubscribeProcessing();
};
}, [ onCheckoutProcessing, processCheckout ] );
return null;
};

View File

@ -12,6 +12,7 @@ const {
SET_NO_ERROR,
INCREMENT_CALCULATING,
DECREMENT_CALCULATING,
SET_ORDER_ID,
} = TYPES;
const { PRISTINE, IDLE, CALCULATING, PROCESSING, COMPLETE } = STATUS;
@ -22,7 +23,7 @@ const { PRISTINE, IDLE, CALCULATING, PROCESSING, COMPLETE } = STATUS;
* @param {Object} state Current state.
* @param {Object} action Incoming action object.
*/
export const reducer = ( state = DEFAULT_STATE, { url, type } ) => {
export const reducer = ( state = DEFAULT_STATE, { url, type, orderId } ) => {
let status, nextStatus, newState;
switch ( type ) {
case SET_PRISTINE:
@ -120,6 +121,12 @@ export const reducer = ( state = DEFAULT_STATE, { url, type } ) => {
calculatingCount: Math.max( 0, state.calculatingCount - 1 ),
};
break;
case SET_ORDER_ID:
newState = {
...state,
orderId,
};
break;
}
// automatically update state to idle from pristine as soon as it
// initially changes.

View File

@ -2,6 +2,7 @@
* External dependencies
*/
import { useStoreNoticesContext } from '@woocommerce/base-context';
import { useMemo } from '@wordpress/element';
export const useStoreNotices = () => {
const {
@ -11,40 +12,43 @@ export const useStoreNotices = () => {
createSnackbarNotice,
} = useStoreNoticesContext();
const noticesApi = {
addDefaultNotice: ( text, noticeProps = {} ) =>
void createNotice( 'default', text, {
...noticeProps,
} ),
addErrorNotice: ( text, noticeProps = {} ) =>
void createNotice( 'error', text, {
...noticeProps,
} ),
addWarningNotice: ( text, noticeProps = {} ) =>
void createNotice( 'warning', text, {
...noticeProps,
} ),
addInfoNotice: ( text, noticeProps = {} ) =>
void createNotice( 'info', text, {
...noticeProps,
} ),
addSuccessNotice: ( text, noticeProps = {} ) =>
void createNotice( 'success', text, {
...noticeProps,
} ),
removeNotices: ( type = null ) => {
notices.map( ( notice ) => {
if ( type === null || notice.status === type ) {
removeNotice( notice.id );
}
return true;
} );
},
removeNotice,
addSnackbarNotice: ( text, noticeProps = {} ) => {
createSnackbarNotice( text, noticeProps );
},
};
const noticesApi = useMemo(
() => ( {
addDefaultNotice: ( text, noticeProps = {} ) =>
void createNotice( 'default', text, {
...noticeProps,
} ),
addErrorNotice: ( text, noticeProps = {} ) =>
void createNotice( 'error', text, {
...noticeProps,
} ),
addWarningNotice: ( text, noticeProps = {} ) =>
void createNotice( 'warning', text, {
...noticeProps,
} ),
addInfoNotice: ( text, noticeProps = {} ) =>
void createNotice( 'info', text, {
...noticeProps,
} ),
addSuccessNotice: ( text, noticeProps = {} ) =>
void createNotice( 'success', text, {
...noticeProps,
} ),
removeNotices: ( type = null ) => {
notices.map( ( notice ) => {
if ( type === null || notice.status === type ) {
removeNotice( notice.id );
}
return true;
} );
},
removeNotice,
addSnackbarNotice: ( text, noticeProps = {} ) => {
createSnackbarNotice( text, noticeProps );
},
} ),
[ createNotice, createSnackbarNotice ]
);
return {
notices,

View File

@ -59,7 +59,7 @@ const Checkout = ( {
shippingRates = [],
scrollToTop,
} ) => {
const { isEditor } = useCheckoutContext();
const { isEditor, hasOrder } = useCheckoutContext();
const {
shippingRatesLoading,
shippingAddress,
@ -126,6 +126,12 @@ const Checkout = ( {
setBillingData( shippingAddress );
}
}, [ shippingAsBilling, setBillingData ] );
if ( ! isEditor && ! hasOrder ) {
// @todo add state here to handle this type of error.
return <div>No draft order - add error state.</div>;
}
return (
<>
<SidebarLayout className="wc-block-checkout">

View File

@ -1,18 +1,20 @@
/**
* @typedef {Object} CheckoutDispatchActions
*
* @property {function()} resetCheckout Dispatches an action that resets
* the checkout to a pristine state.
* @property {function()} setRedirectUrl Dispatches an action that sets the
* redirectUrl to the given value.
* @property {function()} setHasError Dispatches an action that sets the
* checkout status to having an error.
* @property {function()} clearError Dispatches an action that clears the
* hasError status for the checkout.
* @property {function()} incrementCalculating Dispatches an action that increments
* the calculating state for checkout by one.
* @property {function()} decrementCalculating Dispatches an action that decrements
* the calculating state for checkout by one.
* @property {function()} resetCheckout Dispatches an action that resets
* the checkout to a pristine state.
* @property {function( string )} setRedirectUrl Dispatches an action that sets the
* redirectUrl to the given value.
* @property {function()} setHasError Dispatches an action that sets the
* checkout status to having an error.
* @property {function()} clearError Dispatches an action that clears the
* hasError status for the checkout.
* @property {function()} incrementCalculating Dispatches an action that increments
* the calculating state for checkout by one.
* @property {function()} decrementCalculating Dispatches an action that decrements
* the calculating state for checkout by one.
* @property {function( number, string )} setOrderId Dispatches an action that stores the draft
* order ID and key to state.
*/
/**

View File

@ -236,6 +236,10 @@
* @property {boolean} isEditor Indicates whether in
* the editor context
* (true) or not (false).
* @property {number} orderId This is the ID for the
* draft order if one exists.
* @property {boolean} hasOrder True when the checkout has
* a draft order from the API.
*/
/**

View File

@ -28548,7 +28548,7 @@
}
},
"woocommerce": {
"version": "git+https://github.com/woocommerce/woocommerce.git#1926a5042622980ce9b520e63b8cc30d917d4a07",
"version": "git+https://github.com/woocommerce/woocommerce.git#6f2b232fc7fa53417691a742a419147bb0134a5c",
"from": "git+https://github.com/woocommerce/woocommerce.git",
"dev": true,
"requires": {

View File

@ -52,7 +52,7 @@ class Cart extends AbstractBlock {
if ( ! empty( $attributes['checkoutPageId'] ) && ! $data_registry->exists( 'page-' . $attributes['checkoutPageId'] ) ) {
$permalink = get_permalink( $attributes['checkoutPageId'] );
if ( $permalink ) {
$data_registry->add( 'page-' . $attributes['checkoutPageId'], get_permalink( $attributes['checkoutPageId'] ) );
$data_registry->add( 'page-' . $attributes['checkoutPageId'], $permalink );
}
}
if ( ! $data_registry->exists( 'shippingCountries' ) ) {

View File

@ -8,6 +8,8 @@
namespace Automattic\WooCommerce\Blocks\BlockTypes;
use Automattic\WooCommerce\Blocks\Package;
use Automattic\WooCommerce\Blocks\Assets;
use Automattic\WooCommerce\Blocks\Assets\AssetDataRegistry;
defined( 'ABSPATH' ) || exit;
@ -48,63 +50,74 @@ class Checkout extends AbstractBlock {
*/
public function render( $attributes = array(), $content = '' ) {
$data_registry = Package::container()->get(
\Automattic\WooCommerce\Blocks\Assets\AssetDataRegistry::class
AssetDataRegistry::class
);
if ( ! empty( $attributes['cartPageId'] ) && ! $data_registry->exists( 'page-' . $attributes['cartPageId'] ) ) {
$permalink = get_permalink( $attributes['cartPageId'] );
if ( $permalink ) {
$data_registry->add( 'page-' . $attributes['cartPageId'], get_permalink( $attributes['cartPageId'] ) );
$block_data = [
'allowedCountries' => [ WC()->countries, 'get_allowed_countries' ],
'shippingCountries' => [ WC()->countries, 'get_shipping_countries' ],
'allowedStates' => [ WC()->countries, 'get_allowed_country_states' ],
'shippingStates' => [ WC()->countries, 'get_shipping_country_states' ],
];
foreach ( $block_data as $key => $callback ) {
if ( ! $data_registry->exists( $key ) ) {
$data_registry->add( $key, call_user_func( $callback ) );
}
}
if ( ! $data_registry->exists( 'allowedCountries' ) ) {
$data_registry->add( 'allowedCountries', WC()->countries->get_allowed_countries() );
}
if ( ! $data_registry->exists( 'shippingCountries' ) ) {
$data_registry->add( 'shippingCountries', WC()->countries->get_shipping_countries() );
}
if ( ! $data_registry->exists( 'allowedStates' ) ) {
$data_registry->add( 'allowedStates', WC()->countries->get_allowed_country_states() );
}
if ( ! $data_registry->exists( 'shippingStates' ) ) {
$data_registry->add( 'shippingStates', WC()->countries->get_shipping_country_states() );
}
if ( ! $data_registry->exists( 'cartData' ) ) {
$data_registry->add( 'cartData', WC()->api->get_endpoint_data( '/wc/store/cart' ) );
}
if ( ! $data_registry->exists( 'billingData' ) && WC()->customer instanceof \WC_Customer ) {
$data_registry->add( 'billingData', WC()->customer->get_billing() );
}
if ( function_exists( 'get_current_screen' ) ) {
$screen = get_current_screen();
if ( $screen && $screen->is_block_editor() && ! $data_registry->exists( 'shippingMethodsExist' ) ) {
$methods_exist = wc_get_shipping_method_count() > 0;
$data_registry->add( 'shippingMethodsExist', $methods_exist );
}
}
if ( is_user_logged_in() && ! $data_registry->exists( 'customerPaymentMethods' ) ) {
add_filter( 'woocommerce_payment_methods_list_item', [ $this, 'include_token_id_with_payment_methods' ], 10, 2 );
$data_registry->add(
'customerPaymentMethods',
wc_get_customer_saved_methods_list( get_current_user_id() )
);
remove_filter( 'woocommerce_payment_methods_list_item', [ $this, 'include_token_id_with_payment_methods' ], 10, 2 );
}
if ( is_user_logged_in() && ! $data_registry->exists( 'customerPaymentMethods' ) ) {
add_filter( 'woocommerce_payment_methods_list_item', [ $this, 'include_token_id_with_payment_methods' ], 10, 2 );
$data_registry->add(
'customerPaymentMethods',
wc_get_customer_saved_methods_list( get_current_user_id() )
);
remove_filter( 'woocommerce_payment_methods_list_item', [ $this, 'include_token_id_with_payment_methods' ], 10, 2 );
$this->hydrate_from_api( $data_registry );
$this->hydrate_customer_payment_methods( $data_registry );
$permalink = ! empty( $attributes['cartPageId'] ) ? get_permalink( $attributes['cartPageId'] ) : false;
if ( $permalink && ! $data_registry->exists( 'page-' . $attributes['cartPageId'] ) ) {
$data_registry->add( 'page-' . $attributes['cartPageId'], $permalink );
}
$screen = function_exists( 'get_current_screen' ) ? get_current_screen() : false;
if ( $screen && $screen->is_block_editor() && ! $data_registry->exists( 'shippingMethodsExist' ) ) {
$methods_exist = wc_get_shipping_method_count() > 0;
$data_registry->add( 'shippingMethodsExist', $methods_exist );
}
do_action( 'woocommerce_blocks_enqueue_checkout_block_scripts_before' );
\Automattic\WooCommerce\Blocks\Assets::register_block_script( $this->block_name . '-frontend', $this->block_name . '-block-frontend' );
Assets::register_block_script( $this->block_name . '-frontend', $this->block_name . '-block-frontend' );
do_action( 'woocommerce_blocks_enqueue_checkout_block_scripts_after' );
return $content . $this->get_skeleton();
}
/**
* Get customer payment methods for use in checkout.
*
* @param AssetDataRegistry $data_registry Data registry instance.
*/
protected function hydrate_customer_payment_methods( AssetDataRegistry $data_registry ) {
if ( ! is_user_logged_in() || $data_registry->exists( 'customerPaymentMethods' ) ) {
return;
}
add_filter( 'woocommerce_payment_methods_list_item', [ $this, 'include_token_id_with_payment_methods' ], 10, 2 );
$data_registry->add(
'customerPaymentMethods',
wc_get_customer_saved_methods_list( get_current_user_id() )
);
remove_filter( 'woocommerce_payment_methods_list_item', [ $this, 'include_token_id_with_payment_methods' ], 10, 2 );
}
/**
* Hydrate the checkout block with data from the API.
*
* @param AssetDataRegistry $data_registry Data registry instance.
*/
protected function hydrate_from_api( AssetDataRegistry $data_registry ) {
if ( ! $data_registry->exists( 'cartData' ) ) {
$data_registry->add( 'cartData', WC()->api->get_endpoint_data( '/wc/store/cart' ) );
}
if ( ! $data_registry->exists( 'checkoutData' ) ) {
$data_registry->add( 'checkoutData', WC()->api->get_endpoint_data( '/wc/store/checkout' ) );
}
}
/**
* Render skeleton markup for the checkout block.

View File

@ -243,10 +243,7 @@ class Library {
// Handle result.
$result->set_status( isset( $gateway_result['result'] ) && 'success' === $gateway_result['result'] ? 'success' : 'failure' );
$result->set_payment_details(
[
'redirect' => $gateway_result['redirect'],
]
);
$result->set_payment_details( [] );
$result->set_redirect_url( $gateway_result['redirect'] );
}
}

View File

@ -77,7 +77,7 @@ class Stripe {
*/
public function enqueue_data() {
$available_gateways = WC()->payment_gateways->get_available_payment_gateways();
$stripe_gateway = $available_gateways['stripe'] ? $available_gateways['stripe'] : null;
$stripe_gateway = isset( $available_gateways['stripe'] ) && is_a( $available_gateways['stripe'], '\WC_Gateway_Stripe' ) ? $available_gateways['stripe'] : null;
$data = [
'stripeTotalLabel' => $this->get_total_label(),
'publicKey' => $this->get_publishable_key(),

View File

@ -34,6 +34,13 @@ class PaymentResult {
*/
protected $payment_details = [];
/**
* Redirect URL for checkout.
*
* @var string
*/
protected $redirect_url = '';
/**
* Constructor.
*
@ -51,7 +58,7 @@ class PaymentResult {
* @param string $name Property name.
*/
public function __get( $name ) {
if ( in_array( $name, [ 'status', 'payment_details' ], true ) ) {
if ( in_array( $name, [ 'status', 'payment_details', 'redirect_url' ], true ) ) {
return $this->$name;
}
return null;
@ -83,4 +90,13 @@ class PaymentResult {
$this->payment_details[ (string) $key ] = (string) $value;
}
}
/**
* Set redirect URL.
*
* @param array $redirect_url URL to redirect the customer to after checkout.
*/
public function set_redirect_url( $redirect_url = [] ) {
$this->redirect_url = esc_url_raw( $redirect_url );
}
}

View File

@ -74,7 +74,7 @@ abstract class AbstractRoute implements RouteInterface {
} catch ( RouteException $error ) {
$response = new \WP_Error( $error->getErrorCode(), $error->getMessage(), [ 'status' => $error->getCode() ] );
} catch ( Exception $error ) {
$response = new WP_Error( 'unknown_server_error', $error->getMessage(), [ 'status' => '500' ] );
$response = new \WP_Error( 'unknown_server_error', $error->getMessage(), [ 'status' => '500' ] );
}
return $response;
}

View File

@ -1,271 +0,0 @@
<?php
/**
* Cart order route.
*
* @package WooCommerce/Blocks
*/
namespace Automattic\WooCommerce\Blocks\RestApi\StoreApi\Routes;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Blocks\RestApi\StoreApi\Utilities\CartController;
/**
* CartCreateOrder class.
*/
class CartCreateOrder extends AbstractRoute {
/**
* Get the namespace for this route.
*
* @return string
*/
public function get_namespace() {
return 'wc/store';
}
/**
* Get the path of this REST route.
*
* @return string
*/
public function get_path() {
return '/cart/create-order';
}
/**
* Get method arguments for this REST route.
*
* @return array An array of endpoints.
*/
public function get_args() {
return [
[
'methods' => \WP_REST_Server::CREATABLE,
'callback' => [ $this, 'get_response' ],
'args' => $this->schema->get_endpoint_args_for_item_schema( \WP_REST_Server::CREATABLE ),
],
'schema' => [ $this->schema, 'get_public_item_schema' ],
];
}
/**
* Convert the cart into a new draft order, or update an existing draft order, and return an updated cart response.
*
* @throws RouteException On error.
* @param \WP_REST_Request $request Request object.
* @return \WP_REST_Response
*/
protected function get_route_post_response( \WP_REST_Request $request ) {
$this->draft_order = WC()->session->get(
'store_api_draft_order',
[
'id' => 0,
'hashes' => [
'line_items' => false,
'shipping' => false,
'fees' => false,
'coupons' => false,
'taxes' => false,
],
]
);
// Update session based on posted data.
$this->update_session( $request );
// Create or retrieve the draft order for the current cart.
$order_object = $this->create_order_from_cart( $request );
// Try to reserve stock for 10 mins, if available.
// @todo Remove once min support for WC reaches 4.0.0.
if ( \class_exists( '\Automattic\WooCommerce\Checkout\Helpers\ReserveStock' ) ) {
$reserve_stock = new \Automattic\WooCommerce\Checkout\Helpers\ReserveStock();
} else {
$reserve_stock = new \Automattic\WooCommerce\Blocks\RestApi\StoreApi\Utilities\ReserveStock();
}
$reserve_stock->reserve_stock_for_order( $order_object, 10 );
$response = $this->prepare_item_for_response( $order_object, $request );
$response->set_status( 201 );
return $response;
}
/**
* Before creating anything, this method ensures the cart session is up to date and matches the data we're going
* to be adding to the order.
*
* @param \WP_REST_Request $request Full details about the request.
* @return void
*/
protected function update_session( \WP_REST_Request $request ) {
$schema = $this->get_item_schema();
if ( isset( $request['billing_address'] ) ) {
$allowed_billing_values = array_intersect_key( $request['billing_address'], $schema['properties']['billing_address']['properties'] );
foreach ( $allowed_billing_values as $key => $value ) {
WC()->customer->{"set_billing_$key"}( $value );
}
}
if ( isset( $request['shipping_address'] ) ) {
$allowed_shipping_values = array_intersect_key( $request['shipping_address'], $schema['properties']['shipping_address']['properties'] );
foreach ( $allowed_shipping_values as $key => $value ) {
WC()->customer->{"set_shipping_$key"}( $value );
}
}
WC()->customer->save();
WC()->cart->calculate_shipping();
WC()->cart->calculate_totals();
}
/**
* Create order and set props based on global settings.
*
* @param \WP_REST_Request $request Full details about the request.
* @return \WC_Order A new order object.
*/
protected function create_order_from_cart( \WP_REST_Request $request ) {
add_filter( 'woocommerce_default_order_status', array( $this, 'default_order_status' ) );
$order = $this->get_order_object();
$order->set_status( 'checkout-draft' );
$order->set_created_via( 'store-api' );
$order->set_currency( get_woocommerce_currency() );
$order->set_prices_include_tax( 'yes' === get_option( 'woocommerce_prices_include_tax' ) );
$order->set_customer_id( get_current_user_id() );
$order->set_customer_ip_address( \WC_Geolocation::get_ip_address() );
$order->set_customer_user_agent( wc_get_user_agent() );
$order->set_cart_hash( WC()->cart->get_cart_hash() );
$order->update_meta_data( 'is_vat_exempt', WC()->cart->get_customer()->get_is_vat_exempt() ? 'yes' : 'no' );
$this->set_props_from_request( $order, $request );
$this->create_line_items_from_cart( $order, $request );
// Calc totals, taxes, and save.
$order->calculate_totals();
remove_filter( 'woocommerce_default_order_status', array( $this, 'default_order_status' ) );
// Store Order details in session so we can look it up later.
WC()->session->set(
'store_api_draft_order',
[
'id' => $order->get_id(),
'hashes' => $this->get_cart_hashes(),
]
);
return $order;
}
/**
* Get hashes for items in the current cart. Useful for tracking changes.
*
* @return array
*/
protected function get_cart_hashes() {
return [
'line_items' => md5( wp_json_encode( WC()->cart->get_cart() ) ),
'shipping' => md5( wp_json_encode( WC()->cart->shipping_methods ) ),
'fees' => md5( wp_json_encode( WC()->cart->get_fees() ) ),
'coupons' => md5( wp_json_encode( WC()->cart->get_applied_coupons() ) ),
'taxes' => md5( wp_json_encode( WC()->cart->get_taxes() ) ),
];
}
/**
* Get an order object, either using a current draft order, or returning a new one.
*
* @return \WC_Order A new order object.
*/
protected function get_order_object() {
$draft_order = $this->draft_order['id'] ? wc_get_order( $this->draft_order['id'] ) : false;
if ( $draft_order && $draft_order->has_status( 'checkout-draft' ) && 'store-api' === $draft_order->get_created_via() ) {
return $draft_order;
}
return new \WC_Order();
}
/**
* Changes default order status to draft for orders created via this API.
*
* @return string
*/
public function default_order_status() {
return 'checkout-draft';
}
/**
* Create order line items.
*
* @internal 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 normalized set of data between cart and order, or
* additional hashes, would be useful in the future and to help refactor this code. In the meantime, we're relying
* on custom hashes in $this->draft_order to track if things changed.
*
* @param \WC_Order $order Object to prepare for the response.
* @param \WP_REST_Request $request Full details about the request.
*/
protected function create_line_items_from_cart( \WC_Order $order, \WP_REST_Request $request ) {
$new_hashes = $this->get_cart_hashes();
$old_hashes = $this->draft_order['hashes'];
$force = $this->draft_order['id'] !== $order->get_id();
if ( $force || $new_hashes['line_items'] !== $old_hashes['line_items'] ) {
$order->remove_order_items( 'line_item' );
WC()->checkout->create_order_line_items( $order, WC()->cart );
}
if ( $force || $new_hashes['shipping'] !== $old_hashes['shipping'] ) {
$order->remove_order_items( 'shipping' );
WC()->checkout->create_order_shipping_lines( $order, WC()->session->get( 'chosen_shipping_methods' ), WC()->shipping()->get_packages() );
}
if ( $force || $new_hashes['coupons'] !== $old_hashes['coupons'] ) {
$order->remove_order_items( 'coupon' );
WC()->checkout->create_order_coupon_lines( $order, WC()->cart );
}
if ( $force || $new_hashes['fees'] !== $old_hashes['fees'] ) {
$order->remove_order_items( 'fee' );
WC()->checkout->create_order_fee_lines( $order, WC()->cart );
}
if ( $force || $new_hashes['taxes'] !== $old_hashes['taxes'] ) {
$order->remove_order_items( 'tax' );
WC()->checkout->create_order_tax_lines( $order, WC()->cart );
}
}
/**
* Set props from API request.
*
* @param \WC_Order $order Object to prepare for the response.
* @param \WP_REST_Request $request Full details about the request.
*/
protected function set_props_from_request( \WC_Order $order, \WP_REST_Request $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'] );
}
}
}

View File

@ -10,6 +10,8 @@ namespace Automattic\WooCommerce\Blocks\RestApi\StoreApi\Routes;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Blocks\RestApi\StoreApi\Utilities\CartController;
use Automattic\WooCommerce\Blocks\RestApi\StoreApi\Utilities\OrderController;
use Automattic\WooCommerce\Blocks\RestApi\StoreApi\Utilities\ReserveStock;
use Automattic\WooCommerce\Blocks\Payments\PaymentResult;
use Automattic\WooCommerce\Blocks\Payments\PaymentContext;
@ -43,63 +45,116 @@ class Checkout extends AbstractRoute {
public function get_args() {
return [
[
'methods' => \WP_REST_Server::CREATABLE,
'methods' => \WP_REST_Server::READABLE,
'callback' => [ $this, 'get_response' ],
'args' => [
'order_id' => [
'description' => __( 'The order ID being processed.', 'woo-gutenberg-products-block' ),
'type' => 'number',
],
'order_key' => [
'description' => __( 'The order key; used to validate the order is valid.', 'woo-gutenberg-products-block' ),
'type' => 'string',
],
'payment_method' => [
'description' => __( 'The ID of the payment method being used to process the payment.', 'woo-gutenberg-products-block' ),
'type' => 'string',
],
'payment_data' => [
'description' => __( 'Data needed to take payment via the chosen payment method. This is passed through to the gateway when processing the payment for the order.', 'woo-gutenberg-products-block' ),
'type' => 'array',
'items' => [
'type' => 'object',
'properties' => [
'key' => [
'type' => 'string',
],
'value' => [
'type' => 'string',
'context' => $this->get_context_param( [ 'default' => 'view' ] ),
],
],
[
'methods' => \WP_REST_Server::EDITABLE,
'callback' => array( $this, 'get_response' ),
'args' => $this->schema->get_endpoint_args_for_item_schema( \WP_REST_Server::EDITABLE ),
],
[
'methods' => \WP_REST_Server::CREATABLE,
'callback' => [ $this, 'get_response' ],
'args' => array_merge(
[
'payment_data' => [
'description' => __( 'Data to pass through to the payment method when processing payment.', 'woo-gutenberg-products-block' ),
'type' => 'array',
'items' => [
'type' => 'object',
'properties' => [
'key' => [
'type' => 'string',
],
'value' => [
'type' => 'string',
],
],
],
],
],
],
$this->schema->get_endpoint_args_for_item_schema( \WP_REST_Server::CREATABLE )
),
],
'schema' => [ $this->schema, 'get_public_item_schema' ],
];
}
/**
* Process a given order and generate a response for the endpoint.
* Convert the cart into a new draft order, or update an existing draft order, and return an updated cart response.
*
* @throws RouteException On error.
* @param \WP_REST_Request $request Request object.
* @return \WP_REST_Response
*/
protected function get_route_response( \WP_REST_Request $request ) {
$order_object = $this->create_or_update_draft_order();
return $this->prepare_item_for_response(
(object) [
'order' => $order_object,
'payment_result' => new PaymentResult(),
],
$request
);
}
/**
* Update the order.
*
* @throws RouteException On error.
* @param \WP_REST_Request $request Request object.
* @return \WP_REST_Response
*/
protected function get_route_update_response( \WP_REST_Request $request ) {
$order_object = $this->create_or_update_draft_order();
$this->update_order_from_request( $order_object, $request );
return $this->prepare_item_for_response(
(object) [
'order' => $order_object,
'payment_result' => new PaymentResult(),
],
$request
);
}
/**
* Update and process payment for the order.
*
* @throws RouteException On error.
* @param \WP_REST_Request $request Request object.
* @return \WP_REST_Response
*/
protected function get_route_post_response( \WP_REST_Request $request ) {
$order = $this->get_request_order_object( $request );
$order_controller = new OrderController();
$order_object = $this->get_draft_order_object( $this->get_draft_order_id() );
$this->update_order_before_payment( $order, $request );
if ( ! $order_object ) {
throw new RouteException(
'woocommerce_rest_checkout_invalid_order',
__( 'This session has no orders pending payment.', 'woo-gutenberg-products-block' ),
500
);
}
if ( ! $order->needs_payment() ) {
$payment_result = $this->process_without_payment( $order, $request );
$this->update_order_from_request( $order_object, $request );
$order_controller->sync_customer_data_with_order( $order_object );
if ( ! $order_object->needs_payment() ) {
$payment_result = $this->process_without_payment( $order_object, $request );
} else {
$payment_result = $this->process_payment( $order, $request );
$payment_result = $this->process_payment( $order_object, $request );
}
$response = $this->prepare_item_for_response(
[
'order_id' => $order->get_id(),
(object) [
'order' => wc_get_order( $order_object ),
'payment_result' => $payment_result,
],
$request
@ -123,6 +178,111 @@ class Checkout extends AbstractRoute {
return $response;
}
/**
* Gets draft order data from the customer session.
*
* @return array
*/
protected function get_draft_order_id() {
return WC()->session->get( 'store_api_draft_order', 0 );
}
/**
* Updates draft order data in the customer session.
*
* @param integer $order_id Draft order ID.
*/
protected function set_draft_order_id( $order_id ) {
WC()->session->set( 'store_api_draft_order', $order_id );
}
/**
* Get an order object, either using a current draft order, or returning a new one.
*
* @param integer $order_id Draft order ID.
* @return \WC_Order|boolean Either the draft order, or false if one has not yet been created.
*/
protected function get_draft_order_object( $order_id ) {
$draft_order_object = $order_id ? wc_get_order( $order_id ) : false;
if ( ! $draft_order_object ) {
return false;
}
// Draft orders are okay.
if ( $draft_order_object->has_status( 'checkout-draft' ) ) {
return $draft_order_object;
}
// Pending and failed orders can be retried if the cart hasn't changed.
if ( $draft_order_object->needs_payment() && $draft_order_object->has_cart_hash( WC()->cart->get_cart_hash() ) ) {
return $draft_order_object;
}
return false;
}
/**
* Create or update a draft order based on the cart.
*
* @return \WC_Order Order object.
*/
protected function create_or_update_draft_order() {
$order_controller = new OrderController();
$reserve_stock = \class_exists( '\Automattic\WooCommerce\Checkout\Helpers\ReserveStock' ) ? new \Automattic\WooCommerce\Checkout\Helpers\ReserveStock() : new ReserveStock();
$order_object = $this->get_draft_order_object( $this->get_draft_order_id() );
$created = false;
if ( ! $order_object ) {
$order_object = $order_controller->create_order_from_cart();
$created = true;
} else {
$order_controller->update_order_from_cart( $order_object );
}
// Store order ID to session.
$this->set_draft_order_id( $order_object->get_id() );
// Try to reserve stock for 10 mins, if available.
$reserve_stock->reserve_stock_for_order( $order_object, 10 );
return $order_object;
}
/**
* Update an order using the posted values from the request.
*
* @param \WC_Order $order Object to prepare for the response.
* @param \WP_REST_Request $request Full details about the request.
*/
protected function update_order_from_request( \WC_Order $order, \WP_REST_Request $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'] );
}
if ( isset( $request['payment_method'] ) ) {
$order->set_payment_method( $this->get_request_payment_method( $request ) );
}
$order->save();
}
/**
* For orders which do not require payment, just update status.
*
@ -148,6 +308,8 @@ class Checkout extends AbstractRoute {
$context = new PaymentContext();
$result = new PaymentResult();
$order->update_status( 'pending' );
$context->set_order( $order );
$context->set_payment_method( $this->get_request_payment_method_id( $request ) );
$context->set_payment_data( $this->get_request_payment_data( $request ) );
@ -164,7 +326,7 @@ class Checkout extends AbstractRoute {
* @param PaymentContext $context Holds context for the payment, including order ID and payment method.
* @param PaymentResult $result Result object for the transaction.
*/
do_action( 'woocommerce_rest_checkout_process_payment_with_context', $context, $result );
do_action_ref_array( 'woocommerce_rest_checkout_process_payment_with_context', [ $context, &$result ] );
if ( ! $result instanceof PaymentResult ) {
throw new RouteException( 'woocommerce_rest_checkout_invalid_payment_result', __( 'Invalid payment result received from payment method.', 'woo-gutenberg-products-block' ), 500 );
@ -176,93 +338,6 @@ class Checkout extends AbstractRoute {
}
}
/**
* Updates the order object before processing payment.
*
* @param \WC_Order $order Order object.
* @param \WP_REST_Request $request Request object.
*/
protected function update_order_before_payment( \WC_Order $order, \WP_REST_Request $request ) {
$this->update_customer_data_from_order( $order );
$order->set_payment_method( $this->get_request_payment_method( $request ) );
$order->set_status( 'pending' );
$order->save();
}
/**
* Copies order data to customer data, so values persist for future checkouts.
*
* @param \WC_Order $order Order object.
*/
protected function update_customer_data_from_order( \WC_Order $order ) {
if ( $order->get_customer_id() ) {
$customer = new \WC_Customer( $order->get_customer_id() );
$customer->set_props(
[
'billing_first_name' => $order->get_billing_first_name(),
'billing_last_name' => $order->get_billing_last_name(),
'billing_company' => $order->get_billing_company(),
'billing_address_1' => $order->get_billing_address_1(),
'billing_address_2' => $order->get_billing_address_2(),
'billing_city' => $order->get_billing_city(),
'billing_state' => $order->get_billing_state(),
'billing_postcode' => $order->get_billing_postcode(),
'billing_country' => $order->get_billing_country(),
'billing_email' => $order->get_billing_email(),
'billing_phone' => $order->get_billing_phone(),
'shipping_address_1' => $order->get_shipping_address_1(),
'shipping_address_2' => $order->get_shipping_address_2(),
'shipping_city' => $order->get_shipping_city(),
'shipping_state' => $order->get_shipping_state(),
'shipping_postcode' => $order->get_shipping_postcode(),
'shipping_country' => $order->get_shipping_country(),
]
);
$customer->save();
};
}
/**
* Gets the order object for the request, or throws an exception if invalid.
*
* @throws RouteException On error.
* @param \WP_REST_Request $request Request object.
* @return \WC_Order
*/
protected function get_request_order_object( \WP_REST_Request $request ) {
$order_id = absint( $request['order_id'] );
if ( ! $order_id ) {
throw new RouteException( 'woocommerce_rest_checkout_missing_order_id', __( 'An order ID is required.', 'woo-gutenberg-products-block' ), 404 );
}
$order = wc_get_order( $order_id );
$order_key = wp_unslash( $request['order_key'] );
$order_key_is_valid = $order && hash_equals( $order->get_order_key(), $order_key );
if ( ! $order_key_is_valid ) {
throw new RouteException( 'woocommerce_rest_checkout_invalid_order', __( 'Invalid order. Please provide a valid order ID and key.', 'woo-gutenberg-products-block' ), 400 );
}
$statuses_for_payment = array_unique( apply_filters( 'woocommerce_valid_order_statuses_for_payment', [ 'checkout-draft', 'pending', 'failed' ] ) );
if ( ! $order->has_status( $statuses_for_payment ) ) {
throw new RouteException(
'woocommerce_rest_checkout_invalid_order',
sprintf(
// Translators: %1$s list of order stati. %2$s Current order status.
__( 'Only orders with status %1$s can be paid for. This order is %2$s.', 'woo-gutenberg-products-block' ),
'`' . implode( '`, `', $statuses_for_payment ) . '`',
$order->get_status()
),
400
);
}
return $order;
}
/**
* Gets the chosen payment method ID from the request.
*
@ -274,6 +349,14 @@ class Checkout extends AbstractRoute {
$payment_method = wc_clean( wp_unslash( $request['payment_method'] ) );
$valid_methods = WC()->payment_gateways->get_payment_gateway_ids();
if ( empty( $payment_method ) ) {
throw new RouteException(
'woocommerce_rest_checkout_missing_payment_method',
__( 'No payment method provided.', 'woo-gutenberg-products-block' ),
400
);
}
if ( ! in_array( $payment_method, $valid_methods, true ) ) {
throw new RouteException(
'woocommerce_rest_checkout_invalid_payment_method',

View File

@ -35,7 +35,6 @@ class RoutesController {
new Routes\CartApplyCoupon( $schemas['cart'] ),
new Routes\CartCoupons( $schemas['coupon'] ),
new Routes\CartCouponsByCode( $schemas['coupon'] ),
new Routes\CartCreateOrder( $schemas['order'] ),
new Routes\CartItems( $schemas['cart-item'] ),
new Routes\CartItemsByKey( $schemas['cart-item'] ),
new Routes\CartRemoveCoupon( $schemas['cart'] ),

View File

@ -0,0 +1,131 @@
<?php
/**
* Billing Address Schema.
*
* @package WooCommerce/Blocks
*/
namespace Automattic\WooCommerce\Blocks\RestApi\StoreApi\Schemas;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Blocks\RestApi\Routes;
/**
* BillingAddressSchema class.
*
* Provides a generic billing address schema for composition in other schemas.
*/
class BillingAddressSchema extends AbstractSchema {
/**
* The schema item name.
*
* @var string
*/
protected $title = 'billing_address';
/**
* Term properties.
*
* @return array
*/
public function get_properties() {
return [
'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', 'woo-gutenberg-products-block' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
],
'address_1' => [
'description' => __( 'Address', 'woo-gutenberg-products-block' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
],
'address_2' => [
'description' => __( 'Apartment, suite, etc.', 'woo-gutenberg-products-block' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
],
'city' => [
'description' => __( 'City', 'woo-gutenberg-products-block' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
],
'state' => [
'description' => __( 'State/County code, or name of the state, county, 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/Region code in ISO 3166-1 alpha-2 format.', 'woo-gutenberg-products-block' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
],
'email' => [
'description' => __( 'Email', 'woo-gutenberg-products-block' ),
'type' => 'string',
'format' => 'email',
'context' => [ 'view', 'edit' ],
],
'phone' => [
'description' => __( 'Phone', 'woo-gutenberg-products-block' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
],
];
}
/**
* Convert a term object into an object suitable for the response.
*
* @param \WC_Order|\WC_Customer $address An object with billing address.
*
* @throws RouteException When the invalid object types are provided.
* @return stdClass
*/
public function get_item_response( $address ) {
if ( ( $address instanceof \WC_Customer || $address instanceof \WC_Order ) ) {
return (object) $this->prepare_html_response(
[
'first_name' => $address->get_billing_first_name(),
'last_name' => $address->get_billing_last_name(),
'company' => $address->get_billing_company(),
'address_1' => $address->get_billing_address_1(),
'address_2' => $address->get_billing_address_2(),
'city' => $address->get_billing_city(),
'state' => $address->get_billing_state(),
'postcode' => $address->get_billing_postcode(),
'country' => $address->get_billing_country(),
'email' => $address->get_billing_email(),
'phone' => $address->get_billing_phone(),
]
);
}
throw new RouteException(
'invalid_object_type',
sprintf(
/* translators: Placeholders are class and method names */
__( '%1$s requires an instance of %2$s or %3$s for the address', 'woo-gutenberg-products-block' ),
'BillingAddressSchema::get_item_response',
'WC_Customer',
'WC_Order'
),
500
);
}
}

View File

@ -396,7 +396,7 @@ class CartItemSchema extends AbstractSchema {
return null;
}
$draft_order = WC()->session->get( 'store_api_draft_order' );
$draft_order = WC()->session->get( 'store_api_draft_order', 0 );
// @todo Remove once min support for WC reaches 4.0.0.
if ( \class_exists( '\Automattic\WooCommerce\Checkout\Helpers\ReserveStock' ) ) {
@ -405,7 +405,7 @@ class CartItemSchema extends AbstractSchema {
$reserve_stock = new \Automattic\WooCommerce\Blocks\RestApi\StoreApi\Utilities\ReserveStock();
}
$reserved_stock = $reserve_stock->get_reserved_stock( $product, isset( $draft_order['id'] ) ? $draft_order['id'] : 0 );
$reserved_stock = $reserve_stock->get_reserved_stock( $product, $draft_order );
return $product->get_stock_quantity() - $reserved_stock;
}

View File

@ -31,12 +31,6 @@ class CartSchema extends AbstractSchema {
*/
public function get_properties() {
return [
'order_id' => [
'description' => __( 'The draft order ID associated with this cart if one has been created. 0 if no draft exists.', 'woo-gutenberg-products-block' ),
'type' => 'integer',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'coupons' => [
'description' => __( 'List of applied cart coupons.', 'woo-gutenberg-products-block' ),
'type' => 'array',
@ -207,7 +201,6 @@ class CartSchema extends AbstractSchema {
$context = 'edit';
return [
'order_id' => $this->get_order_id(),
'coupons' => array_values( array_map( [ $cart_coupon_schema, 'get_item_response' ], array_filter( $cart->get_applied_coupons() ) ) ),
'shipping_rates' => array_values( array_map( [ $shipping_rate_schema, 'get_item_response' ], $controller->get_shipping_packages() ) ),
'shipping_address' => $shipping_address_schema,
@ -236,23 +229,6 @@ class CartSchema extends AbstractSchema {
];
}
/**
* Get a draft order ID from the session for current cart.
*
* @return int Draft order ID, or 0 if there isn't one yet.
*/
protected function get_order_id() {
$draft_order_session = WC()->session->get( 'store_api_draft_order' );
$draft_order_id = isset( $draft_order_session['id'] ) ? absint( $draft_order_session['id'] ) : 0;
$draft_order = $draft_order_id ? wc_get_order( $draft_order_id ) : false;
if ( $draft_order && $draft_order->has_status( 'checkout-draft' ) && 'store-api' === $draft_order->get_created_via() ) {
return $draft_order_id;
}
return 0;
}
/**
* Get tax lines from the cart and format to match schema.
*

View File

@ -29,33 +29,79 @@ class CheckoutSchema extends AbstractSchema {
*/
public function get_properties() {
return [
'payment_status' => [
'description' => __( 'Status of the payment returned by the gateway. One of success, pending, failure, error.', 'woo-gutenberg-products-block' ),
'order_id' => [
'description' => __( 'The order ID to process during checkout.', 'woo-gutenberg-products-block' ),
'type' => 'integer',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'status' => [
'description' => __( 'Order status. Payment providers will update this value after payment.', 'woo-gutenberg-products-block' ),
'type' => 'string',
],
'payment_details' => [
'description' => __( 'An array of data being returned from the payment gateway.', 'woo-gutenberg-products-block' ),
'context' => [ 'view', 'edit' ],
'readonly' => true,
'type' => 'array',
'items' => [
'type' => 'object',
'properties' => [
'key' => [
'type' => 'string',
],
'value' => [
'type' => 'string',
],
],
],
],
'order' => [
'description' => __( 'The order that was processed.', 'woo-gutenberg-products-block' ),
'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,
],
'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' => ( new BillingAddressSchema() )->get_properties(),
],
'shipping_address' => [
'description' => __( 'Shipping address.', 'woo-gutenberg-products-block' ),
'type' => 'object',
'context' => [ 'view', 'edit' ],
'properties' => ( new ShippingAddressSchema() )->get_properties(),
],
'payment_method' => [
'description' => __( 'The ID of the payment method being used to process the payment.', 'woo-gutenberg-products-block' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
],
'payment_result' => [
'description' => __( 'Result of payment processing, or false if not yet processed.', 'woo-gutenberg-products-block' ),
'type' => 'object',
'context' => [ 'view', 'edit' ],
'readonly' => true,
'properties' => $this->force_schema_readonly( ( new OrderSchema() )->get_properties() ),
'properties' => [
'payment_status' => [
'description' => __( 'Status of the payment returned by the gateway. One of success, pending, failure, error.', 'woo-gutenberg-products-block' ),
'readonly' => true,
'type' => 'string',
],
'payment_details' => [
'description' => __( 'An array of data being returned from the payment gateway.', 'woo-gutenberg-products-block' ),
'readonly' => true,
'type' => 'array',
'items' => [
'type' => 'object',
'properties' => [
'key' => [
'type' => 'string',
],
'value' => [
'type' => 'string',
],
],
],
],
'redirect_url' => [
'description' => __( 'A URL to redirect the customer after checkout. This could be, for example, a link to the payment processors website.', 'woo-gutenberg-products-block' ),
'readonly' => true,
'type' => 'string',
],
],
],
];
}
@ -63,23 +109,34 @@ class CheckoutSchema extends AbstractSchema {
/**
* Return the response for checkout.
*
* @throws Exception On invalid response.
*
* @param array $item Results from checkout action.
* @param object $item Results from checkout action.
* @return array
*/
public function get_item_response( $item ) {
$order = wc_get_order( absint( $item['order_id'] ) );
$payment_result = $item['payment_result'] instanceof PaymentResult ? $item['payment_result'] : false;
if ( ! $order || ! $payment_result ) {
throw new Exception( 'Invalid response.' );
}
return $this->get_checkout_response( $item->order, $item->payment_result );
}
/**
* Get the checkout response based on the current order and any payments.
*
* @param \WC_Order $order Order object.
* @param PaymentResult $payment_result Payment result object.
* @return array
*/
protected function get_checkout_response( \WC_Order $order, PaymentResult $payment_result = null ) {
return [
'payment_status' => $payment_result->status,
'payment_details' => $payment_result->payment_details,
'order' => ( new OrderSchema() )->get_item_response( $order ),
'order_id' => $order->get_id(),
'status' => $order->get_status(),
'order_key' => $order->get_order_key(),
'customer_note' => $order->get_customer_note(),
'billing_address' => ( new BillingAddressSchema() )->get_item_response( $order ),
'shipping_address' => ( new ShippingAddressSchema() )->get_item_response( $order ),
'payment_method' => $order->get_payment_method(),
'payment_result' => [
'payment_status' => $payment_result->status,
'payment_details' => $payment_result->payment_details,
'redirect_url' => $payment_result->redirect_url,
],
];
}
}

View File

@ -110,7 +110,7 @@ class ShippingAddressSchema extends AbstractSchema {
sprintf(
/* translators: Placeholders are class and method names */
__( '%1$s requires an instance of %2$s or %3$s for the address', 'woo-gutenberg-products-block' ),
'ShippingAddress::get_item_response',
'ShippingAddressSchema::get_item_response',
'WC_Customer',
'WC_Order'
),

View File

@ -184,6 +184,21 @@ class CartController {
return $callback ? array_filter( wc()->cart->get_cart(), $callback ) : array_filter( wc()->cart->get_cart() );
}
/**
* Get hashes for items in the current cart. Useful for tracking changes.
*
* @return array
*/
public function get_cart_hashes() {
return [
'line_items' => WC()->cart->get_cart_hash(),
'shipping' => md5( wp_json_encode( WC()->cart->shipping_methods ) ),
'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() ) ),
];
}
/**
* Empty cart contents.
*/

View File

@ -0,0 +1,161 @@
<?php
/**
* Helper class which creates and syncs orders with the cart.
*
* @package WooCommerce/Blocks
*/
namespace Automattic\WooCommerce\Blocks\RestApi\StoreApi\Utilities;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Blocks\RestApi\StoreApi\Routes\RouteException;
/**
* OrderController class.
*/
class OrderController {
/**
* Create order and set props based on global settings.
*
* @throws RouteException Exception if invalid data is detected.
*
* @return \WC_Order A new order object.
*/
public function create_order_from_cart() {
if ( WC()->cart->is_empty() ) {
throw new RouteException(
'woocommerce_rest_cart_empty',
__( 'Cannot create order from empty cart.', 'woo-gutenberg-products-block' ),
400
);
}
add_filter( 'woocommerce_default_order_status', array( $this, 'default_order_status' ) );
$order = new \WC_Order();
$order->set_status( 'checkout-draft' );
$order->set_created_via( 'store-api' );
$this->update_order_from_cart( $order );
remove_filter( 'woocommerce_default_order_status', array( $this, 'default_order_status' ) );
return $order;
}
/**
* Update an order using data from the current cart.
*
* @param \WC_Order $order The order object to update.
*/
public function update_order_from_cart( \WC_Order $order ) {
$this->update_line_items_from_cart( $order );
$this->update_addresses_from_cart( $order );
$order->set_currency( get_woocommerce_currency() );
$order->set_prices_include_tax( 'yes' === get_option( 'woocommerce_prices_include_tax' ) );
$order->set_customer_id( get_current_user_id() );
$order->set_customer_ip_address( \WC_Geolocation::get_ip_address() );
$order->set_customer_user_agent( wc_get_user_agent() );
$order->update_meta_data( 'is_vat_exempt', WC()->cart->get_customer()->get_is_vat_exempt() ? 'yes' : 'no' );
$order->calculate_totals();
}
/**
* Copies order data to customer object (not the session), so values persist for future checkouts.
*
* @param \WC_Order $order Order object.
*/
public function sync_customer_data_with_order( \WC_Order $order ) {
if ( $order->get_customer_id() ) {
$customer = new \WC_Customer( $order->get_customer_id() );
$customer->set_props(
[
'billing_first_name' => $order->get_billing_first_name(),
'billing_last_name' => $order->get_billing_last_name(),
'billing_company' => $order->get_billing_company(),
'billing_address_1' => $order->get_billing_address_1(),
'billing_address_2' => $order->get_billing_address_2(),
'billing_city' => $order->get_billing_city(),
'billing_state' => $order->get_billing_state(),
'billing_postcode' => $order->get_billing_postcode(),
'billing_country' => $order->get_billing_country(),
'billing_email' => $order->get_billing_email(),
'billing_phone' => $order->get_billing_phone(),
'shipping_address_1' => $order->get_shipping_address_1(),
'shipping_address_2' => $order->get_shipping_address_2(),
'shipping_city' => $order->get_shipping_city(),
'shipping_state' => $order->get_shipping_state(),
'shipping_postcode' => $order->get_shipping_postcode(),
'shipping_country' => $order->get_shipping_country(),
]
);
$customer->save();
};
}
/**
* Changes default order status to draft for orders created via this API.
*
* @return string
*/
public function default_order_status() {
return 'checkout-draft';
}
/**
* Create order line items.
*
* @internal 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 normalized set of data between cart and order, or
* additional hashes, would be useful in the future and to help refactor this code. In the meantime, we're relying
* on custom hashes in $this->draft_order to track if things changed.
*
* @param \WC_Order $order The order object to update.
*/
protected function update_line_items_from_cart( \WC_Order $order ) {
$cart_controller = new CartController();
$cart = $cart_controller->get_cart_instance();
$cart_hashes = $cart_controller->get_cart_hashes();
if ( $order->get_cart_hash() !== $cart_hashes['line_items'] ) {
$order->remove_order_items( 'line_item' );
$order->set_cart_hash( $cart_hashes['line_items'] );
WC()->checkout->create_order_line_items( $order, $cart );
}
if ( $order->get_meta_data( 'shipping_hash' ) !== $cart_hashes['shipping'] ) {
$order->remove_order_items( 'shipping' );
$order->update_meta_data( 'shipping_hash', $cart_hashes['shipping'] );
WC()->checkout->create_order_shipping_lines( $order, WC()->session->get( 'chosen_shipping_methods' ), WC()->shipping()->get_packages() );
}
if ( $order->get_meta_data( 'coupons_hash' ) !== $cart_hashes['coupons'] ) {
$order->remove_order_items( 'coupon' );
$order->update_meta_data( 'coupons_hash', $cart_hashes['coupons'] );
WC()->checkout->create_order_coupon_lines( $order, $cart );
}
if ( $order->get_meta_data( 'fees_hash' ) !== $cart_hashes['fees'] ) {
$order->remove_order_items( 'fee' );
$order->update_meta_data( 'fees_hash', $cart_hashes['fees'] );
WC()->checkout->create_order_fee_lines( $order, $cart );
}
if ( $order->get_meta_data( 'taxes_hash' ) !== $cart_hashes['taxes'] ) {
$order->remove_order_items( 'tax' );
$order->update_meta_data( 'taxes_hash', $cart_hashes['taxes'] );
WC()->checkout->create_order_tax_lines( $order, $cart );
}
}
/**
* Update address data from cart and/or customer session data.
*
* @param \WC_Order $order The order object to update.
*/
protected function update_addresses_from_cart( \WC_Order $order ) {
$order->set_props( WC()->customer->get_billing() );
$order->set_props( WC()->customer->get_shipping() );
}
}

View File

@ -1,159 +0,0 @@
<?php
/**
* Create Order Tests.
*
* @package WooCommerce\Blocks\Tests
*/
namespace Automattic\WooCommerce\Blocks\Tests\RestApi\StoreApi\Controllers;
use \WP_REST_Request;
use \WC_REST_Unit_Test_Case as TestCase;
use \WC_Helper_Product as ProductHelper;
use \WC_Helper_Order as OrderHelper;
use \WC_Helper_Coupon as CouponHelper;
use Automattic\WooCommerce\Blocks\Tests\Helpers\ValidateSchema;
/**
* CartCreateOrder tests.
*/
class CartCreateOrder extends TestCase {
/**
* Setup test data. Called before every test.
*/
public function setUp() {
parent::setUp();
wp_set_current_user( 0 );
$this->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/create-order', $routes );
$request = new WP_REST_Request( 'GET', '/wc/store/cart/create-order' );
$response = $this->server->dispatch( $request );
$this->assertEquals( 404, $response->get_status() );
}
/**
* Test order creation from cart data.
*/
public function test_create_item() {
$request = new WP_REST_Request( 'POST', '/wc/store/cart/create-order' );
$request->set_header( 'X-WC-Store-API-Nonce', wp_create_nonce( 'wc_store_api' ) );
$request->set_param(
'billing_address',
[
'first_name' => 'Margaret',
'last_name' => 'Thatchcroft',
'address_1' => '123 South Street',
'address_2' => 'Apt 1',
'city' => 'Philadelphia',
'state' => 'PA',
'postcode' => '19123',
'country' => 'US',
'email' => 'test@test.com',
'phone' => '',
]
);
$response = $this->server->dispatch( $request );
$data = $response->get_data();
$this->assertEquals( 201, $response->get_status() );
$this->assertArrayHasKey( 'id', $data );
$this->assertArrayHasKey( 'number', $data );
$this->assertArrayHasKey( 'status', $data );
$this->assertArrayHasKey( 'order_key', $data );
$this->assertArrayHasKey( 'created_via', $data );
$this->assertArrayHasKey( 'prices_include_tax', $data );
$this->assertArrayHasKey( 'events', $data );
$this->assertArrayHasKey( 'customer', $data );
$this->assertArrayHasKey( 'billing_address', $data );
$this->assertArrayHasKey( 'shipping_address', $data );
$this->assertArrayHasKey( 'customer_note', $data );
$this->assertArrayHasKey( 'items', $data );
$this->assertArrayHasKey( 'totals', $data );
$this->assertEquals( 'Margaret', $data['billing_address']->first_name );
$this->assertEquals( 'Thatchcroft', $data['billing_address']->last_name );
$this->assertEquals( '123 South Street', $data['billing_address']->address_1 );
$this->assertEquals( 'Apt 1', $data['billing_address']->address_2 );
$this->assertEquals( 'Philadelphia', $data['billing_address']->city );
$this->assertEquals( 'PA', $data['billing_address']->state );
$this->assertEquals( '19123', $data['billing_address']->postcode );
$this->assertEquals( 'US', $data['billing_address']->country );
$this->assertEquals( 'test@test.com', $data['billing_address']->email );
$this->assertEquals( '', $data['billing_address']->phone );
$this->assertEquals( 'checkout-draft', $data['status'] );
$this->assertEquals( 2, count( $data['items'] ) );
}
/**
* Test conversion of cart item to rest response.
*/
public function test_prepare_item_for_response() {
$schema = new \Automattic\WooCommerce\Blocks\RestApi\StoreApi\Schemas\OrderSchema();
$controller = new \Automattic\WooCommerce\Blocks\RestApi\StoreApi\Routes\CartCreateOrder( $schema );
$order = OrderHelper::create_order();
$response = $controller->prepare_item_for_response( $order, new \WP_REST_Request() );
$data = $response->get_data();
$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 );
}
/**
* Test schema matches responses.
*/
public function test_schema_matches_response() {
$schema = new \Automattic\WooCommerce\Blocks\RestApi\StoreApi\Schemas\OrderSchema();
$controller = new \Automattic\WooCommerce\Blocks\RestApi\StoreApi\Routes\CartCreateOrder( $schema );
$order = OrderHelper::create_order();
$coupon = CouponHelper::create_coupon();
$order->apply_coupon( $coupon );
$response = $controller->prepare_item_for_response( $order, new \WP_REST_Request() );
$schema = $controller->get_item_schema();
$validate = new ValidateSchema( $schema );
$diff = $validate->get_diff_from_object( $response->get_data() );
$this->assertEmpty( $diff, print_r( $diff, true ) );
}
}