Skip to content

Commit

Permalink
feat: add Store API multi-currency support (#8816)
Browse files Browse the repository at this point in the history
  • Loading branch information
frosso authored May 17, 2024
1 parent 3bcea2b commit 5c07c30
Show file tree
Hide file tree
Showing 9 changed files with 137 additions and 79 deletions.
4 changes: 4 additions & 0 deletions changelog/feat-add-multi-currency-support-to-store-api
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: patch
Type: add

feat: add multi-currency support to Store API
57 changes: 34 additions & 23 deletions client/tokenized-payment-request/cart-api.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
*/
import apiFetch from '@wordpress/api-fetch';
import { applyFilters } from '@wordpress/hooks';
import { addQueryArgs } from '@wordpress/url';

/**
* Internal dependencies
Expand All @@ -18,6 +19,29 @@ export default class PaymentRequestCartApi {
// for compatibility scenarios with other plugins (like WC Bookings, Product Add-Ons, WC Deposits, etc.).
cartRequestHeaders = {};

/**
* Makes a request to the API.
*
* @param {Object} options The options to pass to `apiFetch`.
* @return {Promise} Result from `apiFetch`.
*/
async _request( options ) {
return await apiFetch( {
...options,
path: addQueryArgs( options.path, {
// `wcpayPaymentRequestParams` will always be defined if this file is needed.
// If there's an issue with it, ask yourself why this file is queued and `wcpayPaymentRequestParams` isn't present.
currency: getPaymentRequestData(
'checkout'
).currency_code.toUpperCase(),
} ),
headers: {
...this.cartRequestHeaders,
...options.headers,
},
} );
}

/**
* Creates an order from the cart object.
* See https://github.com/woocommerce/woocommerce/blob/trunk/plugins/woocommerce/src/StoreApi/docs/checkout.md#process-order-and-payment
Expand All @@ -32,12 +56,13 @@ export default class PaymentRequestCartApi {
* @return {Promise} Result of the order creation request.
*/
async placeOrder( paymentData ) {
return await apiFetch( {
return await this._request( {
method: 'POST',
path: '/wc/store/v1/checkout',
credentials: 'omit',
headers: {
'X-WooPayments-Express-Payment-Request': true,
// either using the global nonce or the one cached from the anonymous cart (with the anonymous cart one taking precedence).
'X-WooPayments-Express-Payment-Request-Nonce':
getPaymentRequestData( 'nonce' ).tokenized_cart_nonce ||
undefined,
Expand All @@ -54,12 +79,9 @@ export default class PaymentRequestCartApi {
* @return {Promise} Cart response object.
*/
async getCart() {
return await apiFetch( {
return await this._request( {
method: 'GET',
path: '/wc/store/v1/cart',
headers: {
...this.cartRequestHeaders,
},
} );
}

Expand All @@ -69,7 +91,7 @@ export default class PaymentRequestCartApi {
* @return {Promise} Cart response object.
*/
async createAnonymousCart() {
const response = await apiFetch( {
const response = await this._request( {
method: 'GET',
path: '/wc/store/v1/cart',
// omitting credentials, to create a new cart object separate from the user's cart.
Expand Down Expand Up @@ -98,12 +120,13 @@ export default class PaymentRequestCartApi {
* @return {Promise} Cart Response on success, or an Error Response on failure.
*/
async updateCustomer( customerData ) {
return await apiFetch( {
return await this._request( {
method: 'POST',
path: '/wc/store/v1/cart/update-customer',
credentials: 'omit',
headers: {
'X-WooPayments-Express-Payment-Request': true,
// either using the global nonce or the one cached from the anonymous cart (with the anonymous cart one taking precedence).
'X-WooPayments-Express-Payment-Request-Nonce':
getPaymentRequestData( 'nonce' ).tokenized_cart_nonce ||
undefined,
Expand All @@ -121,13 +144,10 @@ export default class PaymentRequestCartApi {
* @return {Promise} Cart Response on success, or an Error Response on failure.
*/
async selectShippingRate( shippingRate ) {
return await apiFetch( {
return await this._request( {
method: 'POST',
path: '/wc/store/v1/cart/select-shipping-rate',
credentials: 'omit',
headers: {
...this.cartRequestHeaders,
},
data: shippingRate,
} );
}
Expand All @@ -147,13 +167,10 @@ export default class PaymentRequestCartApi {
variation: [],
};

return await apiFetch( {
return await this._request( {
method: 'POST',
path: '/wc/store/v1/cart/add-item',
credentials: 'omit',
headers: {
...this.cartRequestHeaders,
},
data: applyFilters(
'wcpay.payment-request.cart-add-item',
productData
Expand All @@ -169,23 +186,17 @@ export default class PaymentRequestCartApi {
*/
async emptyCart() {
try {
const cartData = await apiFetch( {
const cartData = await this._request( {
method: 'GET',
path: '/wc/store/v1/cart',
credentials: 'omit',
headers: {
...this.cartRequestHeaders,
},
} );

const removeItemsPromises = cartData.items.map( ( item ) => {
return apiFetch( {
return this._request( {
method: 'POST',
path: '/wc/store/v1/cart/remove-item',
credentials: 'omit',
headers: {
...this.cartRequestHeaders,
},
data: {
key: item.key,
},
Expand Down
16 changes: 12 additions & 4 deletions client/tokenized-payment-request/test/cart-api.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ global.wcpayPaymentRequestParams = {};
global.wcpayPaymentRequestParams.nonce = {};
global.wcpayPaymentRequestParams.nonce.tokenized_cart_nonce =
'global_tokenized_cart_nonce';
global.wcpayPaymentRequestParams.checkout = {};
global.wcpayPaymentRequestParams.checkout.currency_code = 'USD';

describe( 'PaymentRequestCartApi', () => {
afterEach( () => {
Expand All @@ -40,7 +42,7 @@ describe( 'PaymentRequestCartApi', () => {
expect( apiFetch ).toHaveBeenCalledWith(
expect.objectContaining( {
method: 'GET',
path: '/wc/store/v1/cart',
path: expect.stringContaining( '/wc/store/v1/cart' ),
credentials: 'omit',
parse: false,
} )
Expand All @@ -55,7 +57,9 @@ describe( 'PaymentRequestCartApi', () => {
expect( apiFetch ).toHaveBeenCalledWith(
expect.objectContaining( {
method: 'POST',
path: '/wc/store/v1/cart/update-customer',
path: expect.stringContaining(
'/wc/store/v1/cart/update-customer'
),
credentials: 'omit',
headers: expect.objectContaining( {
'X-WooPayments-Express-Payment-Request': true,
Expand All @@ -77,7 +81,9 @@ describe( 'PaymentRequestCartApi', () => {
expect( apiFetch ).toHaveBeenCalledWith(
expect.objectContaining( {
method: 'POST',
path: '/wc/store/v1/cart/update-customer',
path: expect.stringContaining(
'/wc/store/v1/cart/update-customer'
),
credentials: 'omit',
// in this case, no additional headers should have been submitted.
headers: expect.objectContaining( {
Expand All @@ -101,7 +107,9 @@ describe( 'PaymentRequestCartApi', () => {
expect( apiFetch ).toHaveBeenCalledWith(
expect.objectContaining( {
method: 'POST',
path: '/wc/store/v1/cart/update-customer',
path: expect.stringContaining(
'/wc/store/v1/cart/update-customer'
),
credentials: 'omit',
// in this case, no additional headers should have been submitted.
headers: expect.objectContaining( {
Expand Down
43 changes: 43 additions & 0 deletions includes/class-wc-payments-utils.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,25 @@ class WC_Payments_Utils {
*/
const FORCE_DISCONNECTED_FLAG_NAME = 'wcpaydev_force_disconnected';

/**
* The Store API route patterns that should be handled by the WooPay session handler.
*/
const STORE_API_ROUTE_PATTERNS = [
'@^\/wc\/store(\/v[\d]+)?\/cart$@',
'@^\/wc\/store(\/v[\d]+)?\/cart\/apply-coupon$@',
'@^\/wc\/store(\/v[\d]+)?\/cart\/remove-coupon$@',
'@^\/wc\/store(\/v[\d]+)?\/cart\/select-shipping-rate$@',
'@^\/wc\/store(\/v[\d]+)?\/cart\/update-customer$@',
'@^\/wc\/store(\/v[\d]+)?\/cart\/update-item$@',
'@^\/wc\/store(\/v[\d]+)?\/cart\/extensions$@',
'@^\/wc\/store(\/v[\d]+)?\/checkout\/(?P<id>[\d]+)@',
'@^\/wc\/store(\/v[\d]+)?\/checkout$@',
'@^\/wc\/store(\/v[\d]+)?\/order\/(?P<id>[\d]+)@',
// The route below is not a Store API route. However, this REST endpoint is used by WooPay to indirectly reach the Store API.
// By adding it to this list, we're able to identify the user and load the correct session for this route.
'@^\/wc\/v3\/woopay\/session$@',
];

/**
* Mirrors JS's createInterpolateElement functionality.
* Returns a string where angle brackets expressions are replaced with unescaped html while the rest is escaped.
Expand Down Expand Up @@ -1073,6 +1092,30 @@ public static function is_cart_block(): bool {
return has_block( 'woocommerce/cart' ) || ( wp_is_block_theme() && is_cart() );
}

/**
* Returns true if the request that's currently being processed is a Store API request, false
* otherwise.
*
* @return bool True if request is a Store API request, false otherwise.
*/
public static function is_store_api_request(): bool {
if ( isset( $_REQUEST['rest_route'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification
$rest_route = sanitize_text_field( $_REQUEST['rest_route'] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.NonceVerification
} else {
$url_parts = wp_parse_url( esc_url_raw( $_SERVER['REQUEST_URI'] ?? '' ) ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash
$request_path = rtrim( $url_parts['path'], '/' );
$rest_route = str_replace( trailingslashit( rest_get_url_prefix() ), '', $request_path );
}

foreach ( self::STORE_API_ROUTE_PATTERNS as $pattern ) {
if ( 1 === preg_match( $pattern, $rest_route ) ) {
return true;
}
}

return false;
}

/**
* Gets the current active theme transient for a given location
* Falls back to 'stripe' if no transients are set.
Expand Down
16 changes: 12 additions & 4 deletions includes/multi-currency/MultiCurrency.php
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,7 @@ public function init_hooks() {

$is_frontend_request = ! is_admin() && ! defined( 'DOING_CRON' ) && ! WC()->is_rest_api_request();

if ( $is_frontend_request ) {
if ( $is_frontend_request || \WC_Payments_Utils::is_store_api_request() ) {
// Make sure that this runs after the main init function.
add_action( 'init', [ $this, 'update_selected_currency_by_url' ], 11 );
add_action( 'init', [ $this, 'update_selected_currency_by_geolocation' ], 12 );
Expand Down Expand Up @@ -805,11 +805,17 @@ public function update_selected_currency( string $currency_code, bool $persist_c
$user_id = get_current_user_id();
$currency = $this->get_enabled_currencies()[ $code ] ?? null;

if ( null === $currency ) {
return;
}

// We discard the cache for the front-end.
$this->frontend_currencies->selected_currency_changed();

if ( null === $currency ) {
return;
// initializing the session (useful for Store API),
// so that the selected currency (set as query string parameter) can be correctly set.
if ( ! isset( WC()->session ) ) {
WC()->initialize_session();
}

if ( 0 === $user_id && WC()->session ) {
Expand Down Expand Up @@ -964,7 +970,9 @@ public function get_raw_conversion( float $amount, string $to_currency, string $
* @return void
*/
public function recalculate_cart() {
WC()->cart->calculate_totals();
if ( WC()->cart ) {
WC()->cart->calculate_totals();
}
}

/**
Expand Down
2 changes: 1 addition & 1 deletion includes/multi-currency/Utils.php
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ public function is_page_with_vars( array $pages, array $vars ): bool {
* @return boolean
*/
public static function is_admin_api_request(): bool {
return 0 === stripos( wp_get_referer(), admin_url() ) && WC()->is_rest_api_request();
return 0 === stripos( wp_get_referer(), admin_url() ) && WC()->is_rest_api_request() && ! \WC_Payments_Utils::is_store_api_request();
}


Expand Down
Loading

0 comments on commit 5c07c30

Please sign in to comment.