diff --git a/plugins/woocommerce-blocks/assets/js/base/context/cart-checkout/billing/constants.js b/plugins/woocommerce-blocks/assets/js/base/context/cart-checkout/billing/constants.js index 035adaad8a8..9a2e6d682ea 100644 --- a/plugins/woocommerce-blocks/assets/js/base/context/cart-checkout/billing/constants.js +++ b/plugins/woocommerce-blocks/assets/js/base/context/cart-checkout/billing/constants.js @@ -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, }; /** diff --git a/plugins/woocommerce-blocks/assets/js/base/context/cart-checkout/checkout/actions.js b/plugins/woocommerce-blocks/assets/js/base/context/cart-checkout/checkout/actions.js index 1697aea7ed8..c2c8f3c3bd3 100644 --- a/plugins/woocommerce-blocks/assets/js/base/context/cart-checkout/checkout/actions.js +++ b/plugins/woocommerce-blocks/assets/js/base/context/cart-checkout/checkout/actions.js @@ -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, + } ), }; diff --git a/plugins/woocommerce-blocks/assets/js/base/context/cart-checkout/checkout/constants.js b/plugins/woocommerce-blocks/assets/js/base/context/cart-checkout/checkout/constants.js index 7868e6235ec..9135e4528d3 100644 --- a/plugins/woocommerce-blocks/assets/js/base/context/cart-checkout/checkout/constants.js +++ b/plugins/woocommerce-blocks/assets/js/base/context/cart-checkout/checkout/constants.js @@ -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', }; diff --git a/plugins/woocommerce-blocks/assets/js/base/context/cart-checkout/checkout/index.js b/plugins/woocommerce-blocks/assets/js/base/context/cart-checkout/checkout/index.js index 9546c6b812a..eaa95f82322 100644 --- a/plugins/woocommerce-blocks/assets/js/base/context/cart-checkout/checkout/index.js +++ b/plugins/woocommerce-blocks/assets/js/base/context/cart-checkout/checkout/index.js @@ -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 ( diff --git a/plugins/woocommerce-blocks/assets/js/base/context/cart-checkout/checkout/processor/index.js b/plugins/woocommerce-blocks/assets/js/base/context/cart-checkout/checkout/processor/index.js index e87240e9652..f68aadf7e91 100644 --- a/plugins/woocommerce-blocks/assets/js/base/context/cart-checkout/checkout/processor/index.js +++ b/plugins/woocommerce-blocks/assets/js/base/context/cart-checkout/checkout/processor/index.js @@ -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; }; diff --git a/plugins/woocommerce-blocks/assets/js/base/context/cart-checkout/checkout/reducer.js b/plugins/woocommerce-blocks/assets/js/base/context/cart-checkout/checkout/reducer.js index b50c32a0a8f..1566f7fbee8 100644 --- a/plugins/woocommerce-blocks/assets/js/base/context/cart-checkout/checkout/reducer.js +++ b/plugins/woocommerce-blocks/assets/js/base/context/cart-checkout/checkout/reducer.js @@ -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. diff --git a/plugins/woocommerce-blocks/assets/js/base/hooks/use-store-notices.js b/plugins/woocommerce-blocks/assets/js/base/hooks/use-store-notices.js index 11f3f49f2e2..1771ee67cfd 100644 --- a/plugins/woocommerce-blocks/assets/js/base/hooks/use-store-notices.js +++ b/plugins/woocommerce-blocks/assets/js/base/hooks/use-store-notices.js @@ -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, diff --git a/plugins/woocommerce-blocks/assets/js/blocks/cart-checkout/checkout/block.js b/plugins/woocommerce-blocks/assets/js/blocks/cart-checkout/checkout/block.js index a6552e04ab9..1517643eb0d 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/cart-checkout/checkout/block.js +++ b/plugins/woocommerce-blocks/assets/js/blocks/cart-checkout/checkout/block.js @@ -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
No draft order - add error state.
; + } + return ( <> diff --git a/plugins/woocommerce-blocks/assets/js/type-defs/checkout.js b/plugins/woocommerce-blocks/assets/js/type-defs/checkout.js index e9942a3311d..cb6e9915ab6 100644 --- a/plugins/woocommerce-blocks/assets/js/type-defs/checkout.js +++ b/plugins/woocommerce-blocks/assets/js/type-defs/checkout.js @@ -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. */ /** diff --git a/plugins/woocommerce-blocks/assets/js/type-defs/contexts.js b/plugins/woocommerce-blocks/assets/js/type-defs/contexts.js index f3076eab03b..b227c46d15b 100644 --- a/plugins/woocommerce-blocks/assets/js/type-defs/contexts.js +++ b/plugins/woocommerce-blocks/assets/js/type-defs/contexts.js @@ -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. */ /** diff --git a/plugins/woocommerce-blocks/package-lock.json b/plugins/woocommerce-blocks/package-lock.json index 3ff07936b5c..b98dd8f0d4f 100644 --- a/plugins/woocommerce-blocks/package-lock.json +++ b/plugins/woocommerce-blocks/package-lock.json @@ -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": { diff --git a/plugins/woocommerce-blocks/src/BlockTypes/Cart.php b/plugins/woocommerce-blocks/src/BlockTypes/Cart.php index 7defc196edb..ba5e929628e 100644 --- a/plugins/woocommerce-blocks/src/BlockTypes/Cart.php +++ b/plugins/woocommerce-blocks/src/BlockTypes/Cart.php @@ -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' ) ) { diff --git a/plugins/woocommerce-blocks/src/BlockTypes/Checkout.php b/plugins/woocommerce-blocks/src/BlockTypes/Checkout.php index 258e02ac578..70511103792 100644 --- a/plugins/woocommerce-blocks/src/BlockTypes/Checkout.php +++ b/plugins/woocommerce-blocks/src/BlockTypes/Checkout.php @@ -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. diff --git a/plugins/woocommerce-blocks/src/Library.php b/plugins/woocommerce-blocks/src/Library.php index a427c38e121..2329fed7883 100644 --- a/plugins/woocommerce-blocks/src/Library.php +++ b/plugins/woocommerce-blocks/src/Library.php @@ -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'] ); } } diff --git a/plugins/woocommerce-blocks/src/PaymentMethodIntegrations/Stripe.php b/plugins/woocommerce-blocks/src/PaymentMethodIntegrations/Stripe.php index 8aca2fd4ea0..5ad412ff40a 100644 --- a/plugins/woocommerce-blocks/src/PaymentMethodIntegrations/Stripe.php +++ b/plugins/woocommerce-blocks/src/PaymentMethodIntegrations/Stripe.php @@ -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(), diff --git a/plugins/woocommerce-blocks/src/Payments/PaymentResult.php b/plugins/woocommerce-blocks/src/Payments/PaymentResult.php index 1fde2e1844f..c34fd5428f4 100644 --- a/plugins/woocommerce-blocks/src/Payments/PaymentResult.php +++ b/plugins/woocommerce-blocks/src/Payments/PaymentResult.php @@ -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 ); + } } diff --git a/plugins/woocommerce-blocks/src/RestApi/StoreApi/Routes/AbstractRoute.php b/plugins/woocommerce-blocks/src/RestApi/StoreApi/Routes/AbstractRoute.php index 60a4e271f5e..24cf65a0bef 100644 --- a/plugins/woocommerce-blocks/src/RestApi/StoreApi/Routes/AbstractRoute.php +++ b/plugins/woocommerce-blocks/src/RestApi/StoreApi/Routes/AbstractRoute.php @@ -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; } diff --git a/plugins/woocommerce-blocks/src/RestApi/StoreApi/Routes/CartCreateOrder.php b/plugins/woocommerce-blocks/src/RestApi/StoreApi/Routes/CartCreateOrder.php deleted file mode 100644 index 593234c297e..00000000000 --- a/plugins/woocommerce-blocks/src/RestApi/StoreApi/Routes/CartCreateOrder.php +++ /dev/null @@ -1,271 +0,0 @@ - \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'] ); - } - } -} diff --git a/plugins/woocommerce-blocks/src/RestApi/StoreApi/Routes/Checkout.php b/plugins/woocommerce-blocks/src/RestApi/StoreApi/Routes/Checkout.php index 736b024cc9e..486463b31e3 100644 --- a/plugins/woocommerce-blocks/src/RestApi/StoreApi/Routes/Checkout.php +++ b/plugins/woocommerce-blocks/src/RestApi/StoreApi/Routes/Checkout.php @@ -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', diff --git a/plugins/woocommerce-blocks/src/RestApi/StoreApi/RoutesController.php b/plugins/woocommerce-blocks/src/RestApi/StoreApi/RoutesController.php index 9bc0982b2e0..ae31e8a8608 100644 --- a/plugins/woocommerce-blocks/src/RestApi/StoreApi/RoutesController.php +++ b/plugins/woocommerce-blocks/src/RestApi/StoreApi/RoutesController.php @@ -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'] ), diff --git a/plugins/woocommerce-blocks/src/RestApi/StoreApi/Schemas/BillingAddressSchema.php b/plugins/woocommerce-blocks/src/RestApi/StoreApi/Schemas/BillingAddressSchema.php new file mode 100644 index 00000000000..e785f02ac59 --- /dev/null +++ b/plugins/woocommerce-blocks/src/RestApi/StoreApi/Schemas/BillingAddressSchema.php @@ -0,0 +1,131 @@ + [ + '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 + ); + } +} diff --git a/plugins/woocommerce-blocks/src/RestApi/StoreApi/Schemas/CartItemSchema.php b/plugins/woocommerce-blocks/src/RestApi/StoreApi/Schemas/CartItemSchema.php index 5e780ccd478..bfeb6c17244 100644 --- a/plugins/woocommerce-blocks/src/RestApi/StoreApi/Schemas/CartItemSchema.php +++ b/plugins/woocommerce-blocks/src/RestApi/StoreApi/Schemas/CartItemSchema.php @@ -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; } diff --git a/plugins/woocommerce-blocks/src/RestApi/StoreApi/Schemas/CartSchema.php b/plugins/woocommerce-blocks/src/RestApi/StoreApi/Schemas/CartSchema.php index 6cbe2f3c393..d70758dcc2f 100644 --- a/plugins/woocommerce-blocks/src/RestApi/StoreApi/Schemas/CartSchema.php +++ b/plugins/woocommerce-blocks/src/RestApi/StoreApi/Schemas/CartSchema.php @@ -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. * diff --git a/plugins/woocommerce-blocks/src/RestApi/StoreApi/Schemas/CheckoutSchema.php b/plugins/woocommerce-blocks/src/RestApi/StoreApi/Schemas/CheckoutSchema.php index a3e27516e9d..9e24a17deeb 100644 --- a/plugins/woocommerce-blocks/src/RestApi/StoreApi/Schemas/CheckoutSchema.php +++ b/plugins/woocommerce-blocks/src/RestApi/StoreApi/Schemas/CheckoutSchema.php @@ -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, + ], ]; } } diff --git a/plugins/woocommerce-blocks/src/RestApi/StoreApi/Schemas/ShippingAddressSchema.php b/plugins/woocommerce-blocks/src/RestApi/StoreApi/Schemas/ShippingAddressSchema.php index 2b1dfb6e4fc..6501a047a70 100644 --- a/plugins/woocommerce-blocks/src/RestApi/StoreApi/Schemas/ShippingAddressSchema.php +++ b/plugins/woocommerce-blocks/src/RestApi/StoreApi/Schemas/ShippingAddressSchema.php @@ -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' ), diff --git a/plugins/woocommerce-blocks/src/RestApi/StoreApi/Utilities/CartController.php b/plugins/woocommerce-blocks/src/RestApi/StoreApi/Utilities/CartController.php index d51b87e9736..510a035d558 100644 --- a/plugins/woocommerce-blocks/src/RestApi/StoreApi/Utilities/CartController.php +++ b/plugins/woocommerce-blocks/src/RestApi/StoreApi/Utilities/CartController.php @@ -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. */ diff --git a/plugins/woocommerce-blocks/src/RestApi/StoreApi/Utilities/OrderController.php b/plugins/woocommerce-blocks/src/RestApi/StoreApi/Utilities/OrderController.php new file mode 100644 index 00000000000..4a7e43cd8ec --- /dev/null +++ b/plugins/woocommerce-blocks/src/RestApi/StoreApi/Utilities/OrderController.php @@ -0,0 +1,161 @@ +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() ); + } +} diff --git a/plugins/woocommerce-blocks/tests/php/RestApi/StoreApi/Routes/CartCreateOrder.php b/plugins/woocommerce-blocks/tests/php/RestApi/StoreApi/Routes/CartCreateOrder.php deleted file mode 100644 index 0fac75cdb07..00000000000 --- a/plugins/woocommerce-blocks/tests/php/RestApi/StoreApi/Routes/CartCreateOrder.php +++ /dev/null @@ -1,159 +0,0 @@ -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 ) ); - } -}