1738 lines
57 KiB
PHP
1738 lines
57 KiB
PHP
<?php
|
|
/**
|
|
* Class MultiCurrency
|
|
*
|
|
* @package WooCommerce\Payments\MultiCurrency
|
|
*/
|
|
|
|
namespace WCPay\MultiCurrency;
|
|
|
|
use WCPay\MultiCurrency\Exceptions\InvalidCurrencyException;
|
|
use WCPay\MultiCurrency\Exceptions\InvalidCurrencyRateException;
|
|
use WCPay\MultiCurrency\Interfaces\MultiCurrencyAccountInterface;
|
|
use WCPay\MultiCurrency\Interfaces\MultiCurrencyApiClientInterface;
|
|
use WCPay\MultiCurrency\Interfaces\MultiCurrencyCacheInterface;
|
|
use WCPay\MultiCurrency\Interfaces\MultiCurrencyLocalizationInterface;
|
|
use WCPay\MultiCurrency\Interfaces\MultiCurrencySettingsInterface;
|
|
use WCPay\MultiCurrency\Logger;
|
|
use WCPay\MultiCurrency\Notes\NoteMultiCurrencyAvailable;
|
|
use WCPay\MultiCurrency\Utils;
|
|
use WC_Payments_Features;
|
|
|
|
defined( 'ABSPATH' ) || exit;
|
|
|
|
/**
|
|
* Class that controls Multi-Currency functionality.
|
|
*/
|
|
class MultiCurrency {
|
|
|
|
const CURRENCY_SESSION_KEY = 'wcpay_currency';
|
|
const CURRENCY_META_KEY = 'wcpay_currency';
|
|
const FILTER_PREFIX = 'wcpay_multi_currency_';
|
|
const CUSTOMER_CURRENCIES_KEY = 'wcpay_multi_currency_stored_customer_currencies';
|
|
|
|
/**
|
|
* The plugin's ID.
|
|
*
|
|
* @var string
|
|
*/
|
|
public $id = 'wcpay_multi_currency';
|
|
|
|
/**
|
|
* Static flag to show if the currencies initialization has been completed
|
|
*
|
|
* @var bool
|
|
*/
|
|
protected static $is_initialized = false;
|
|
|
|
/**
|
|
* Compatibility instance.
|
|
*
|
|
* @var Compatibility
|
|
*/
|
|
protected $compatibility;
|
|
|
|
/**
|
|
* Geolocation instance.
|
|
*
|
|
* @var Geolocation
|
|
*/
|
|
protected $geolocation;
|
|
|
|
/**
|
|
* The Currency Switcher Widget instance.
|
|
*
|
|
* @var null|CurrencySwitcherWidget
|
|
*/
|
|
protected $currency_switcher_widget;
|
|
|
|
/**
|
|
* Gutenberg Block implementation of the Currency Switcher Widget instance.
|
|
*
|
|
* @var CurrencySwitcherBlock
|
|
*/
|
|
protected $currency_switcher_block;
|
|
|
|
/**
|
|
* Utils instance.
|
|
*
|
|
* @var Utils
|
|
*/
|
|
protected $utils;
|
|
|
|
/**
|
|
* FrontendPrices instance.
|
|
*
|
|
* @var FrontendPrices
|
|
*/
|
|
protected $frontend_prices;
|
|
|
|
/**
|
|
* FrontendCurrencies instance.
|
|
*
|
|
* @var FrontendCurrencies
|
|
*/
|
|
protected $frontend_currencies;
|
|
|
|
/**
|
|
* StorefrontIntegration instance.
|
|
*
|
|
* @var StorefrontIntegration
|
|
*/
|
|
protected $storefront_integration;
|
|
|
|
/**
|
|
* The available currencies.
|
|
*
|
|
* @var Currency[]|null
|
|
*/
|
|
protected $available_currencies;
|
|
|
|
/**
|
|
* The default currency.
|
|
*
|
|
* @var Currency|null
|
|
*/
|
|
protected $default_currency;
|
|
|
|
/**
|
|
* The enabled currencies.
|
|
*
|
|
* @var Currency[]|null
|
|
*/
|
|
protected $enabled_currencies;
|
|
|
|
/**
|
|
* Instance of MultiCurrencySettingsInterface.
|
|
*
|
|
* @var MultiCurrencySettingsInterface
|
|
*/
|
|
private $settings_service;
|
|
|
|
/**
|
|
* Client for making requests to the API
|
|
*
|
|
* @var MultiCurrencyApiClientInterface
|
|
*/
|
|
private $payments_api_client;
|
|
|
|
/**
|
|
* Instance of MultiCurrencyAccountInterface.
|
|
*
|
|
* @var MultiCurrencyAccountInterface
|
|
*/
|
|
private $payments_account;
|
|
|
|
/**
|
|
* Instance of MultiCurrencyLocalizationInterface.
|
|
*
|
|
* @var MultiCurrencyLocalizationInterface
|
|
*/
|
|
private $localization_service;
|
|
|
|
/**
|
|
* Instance of MultiCurrencyCacheInterface.
|
|
*
|
|
* @var MultiCurrencyCacheInterface
|
|
*/
|
|
private $cache;
|
|
|
|
/**
|
|
* Tracking instance.
|
|
*
|
|
* @var Tracking
|
|
*/
|
|
protected $tracking;
|
|
|
|
/**
|
|
* Simulation variables array.
|
|
*
|
|
* @var array
|
|
*/
|
|
protected $simulation_params = [];
|
|
|
|
|
|
/**
|
|
* Class constructor.
|
|
*
|
|
* @param MultiCurrencySettingsInterface $settings_service Settings service.
|
|
* @param MultiCurrencyApiClientInterface $payments_api_client Payments API client.
|
|
* @param MultiCurrencyAccountInterface $payments_account Payments Account instance.
|
|
* @param MultiCurrencyLocalizationInterface $localization_service Localization Service instance.
|
|
* @param MultiCurrencyCacheInterface $cache Cache instance.
|
|
* @param Utils|null $utils Optional Utils instance.
|
|
*/
|
|
public function __construct( MultiCurrencySettingsInterface $settings_service, MultiCurrencyApiClientInterface $payments_api_client, MultiCurrencyAccountInterface $payments_account, MultiCurrencyLocalizationInterface $localization_service, MultiCurrencyCacheInterface $cache, ?Utils $utils = null ) {
|
|
$this->settings_service = $settings_service;
|
|
$this->payments_api_client = $payments_api_client;
|
|
$this->payments_account = $payments_account;
|
|
$this->localization_service = $localization_service;
|
|
$this->cache = $cache;
|
|
// If a Utils instance is not passed as argument, initialize it. This allows to mock it in tests.
|
|
$this->utils = $utils ?? new Utils();
|
|
$this->geolocation = new Geolocation( $this->localization_service );
|
|
$this->compatibility = new Compatibility( $this, $this->utils );
|
|
$this->currency_switcher_block = new CurrencySwitcherBlock( $this, $this->compatibility );
|
|
}
|
|
|
|
/**
|
|
* Backwards compatibility for the old `instance()` static method.
|
|
*
|
|
* We need to use this as some plugins still call `MultiCurrency::instance()` directly.
|
|
*
|
|
* @return null|MultiCurrency - Main instance.
|
|
*/
|
|
public static function instance() {
|
|
if ( function_exists( 'WC_Payments_Multi_Currency' ) ) {
|
|
return WC_Payments_Multi_Currency();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Initializes this class' WP hooks.
|
|
*
|
|
* @return void
|
|
*/
|
|
public function init_hooks() {
|
|
if ( is_admin() && current_user_can( 'manage_woocommerce' ) ) {
|
|
add_filter( 'woocommerce_get_settings_pages', [ $this, 'init_settings_pages' ] );
|
|
// Enqueue the scripts after the main WC_Payments_Admin does.
|
|
add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_admin_scripts' ], 20 );
|
|
}
|
|
|
|
add_action( 'init', [ $this, 'init' ] );
|
|
add_action( 'rest_api_init', [ $this, 'init_rest_api' ] );
|
|
add_action( 'widgets_init', [ $this, 'init_widgets' ] );
|
|
|
|
$is_frontend_request = ! is_admin() && ! defined( 'DOING_CRON' ) && ! Utils::is_admin_api_request();
|
|
|
|
if ( $is_frontend_request || 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 );
|
|
add_action( 'init', [ $this, 'possible_simulation_activation' ], 13 );
|
|
add_action( 'woocommerce_created_customer', [ $this, 'set_new_customer_currency_meta' ] );
|
|
}
|
|
|
|
if ( ! Utils::is_store_batch_request() && ! Utils::is_store_api_request() && WC()->is_rest_api_request() ) {
|
|
if ( isset( $_GET['currency'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification
|
|
$get_currency_from_query_param = function () {
|
|
$currency = sanitize_text_field( wp_unslash( $_GET['currency'] ) ); // phpcs:ignore WordPress.Security.NonceVerification
|
|
return strtoupper( $currency );
|
|
};
|
|
add_filter( self::FILTER_PREFIX . 'override_selected_currency', $get_currency_from_query_param );
|
|
} else {
|
|
// If the request is a REST API request, ensure we default to the store currency and leave price as-is.
|
|
add_filter( self::FILTER_PREFIX . 'should_return_store_currency', '__return_true' );
|
|
add_filter( self::FILTER_PREFIX . 'should_convert_product_price', '__return_false' );
|
|
$get_default_currency_code = function () {
|
|
return $this->get_default_currency()->get_code();
|
|
};
|
|
add_filter( self::FILTER_PREFIX . 'override_selected_currency', $get_default_currency_code );
|
|
}
|
|
}
|
|
|
|
add_filter( 'wcpay_payment_fields_js_config', [ $this, 'add_props_to_wcpay_js_config' ] );
|
|
|
|
$this->currency_switcher_block->init_hooks();
|
|
}
|
|
|
|
/**
|
|
* Called after the WooCommerce session has been initialized. Initialises the available currencies,
|
|
* default currency and enabled currencies for the Multi-Currency plugin.
|
|
*
|
|
* @return void
|
|
*/
|
|
public function init() {
|
|
$store_currency_updated = $this->check_store_currency_for_change();
|
|
|
|
$this->initialize_available_currencies();
|
|
$this->set_default_currency();
|
|
$this->initialize_enabled_currencies();
|
|
|
|
// If the store currency has been updated, we need to update the notice that will display any manual currencies.
|
|
if ( $store_currency_updated ) {
|
|
$this->update_manual_rate_currencies_notice_option();
|
|
}
|
|
|
|
$admin_notices = new AdminNotices();
|
|
$user_settings = new UserSettings( $this );
|
|
new Analytics( $this, $this->settings_service );
|
|
|
|
$this->frontend_prices = new FrontendPrices( $this, $this->compatibility );
|
|
$this->frontend_currencies = new FrontendCurrencies( $this, $this->localization_service, $this->utils, $this->compatibility );
|
|
$this->tracking = new Tracking( $this );
|
|
|
|
// Init all the hooks.
|
|
$admin_notices->init_hooks();
|
|
$user_settings->init_hooks();
|
|
$this->frontend_prices->init_hooks();
|
|
$this->frontend_currencies->init_hooks();
|
|
$this->tracking->init_hooks();
|
|
|
|
add_action( 'woocommerce_order_refunded', [ $this, 'add_order_meta_on_refund' ], 50, 2 );
|
|
|
|
// Check to make sure there are enabled currencies, then for Storefront being active, and then load the integration.
|
|
$theme = wp_get_theme();
|
|
if ( 'storefront' === $theme->get_stylesheet() || 'storefront' === $theme->get_template() ) {
|
|
$this->storefront_integration = new StorefrontIntegration( $this );
|
|
}
|
|
|
|
if ( is_admin() ) {
|
|
add_action( 'admin_init', [ $this, 'add_woo_admin_notes' ] );
|
|
}
|
|
|
|
// Update the customer currencies option after an order status change.
|
|
add_action( 'woocommerce_order_status_changed', [ $this, 'maybe_update_customer_currencies_option' ] );
|
|
|
|
static::$is_initialized = true;
|
|
}
|
|
|
|
/**
|
|
* Initialize the REST API controller.
|
|
*
|
|
* @return void
|
|
*/
|
|
public function init_rest_api() {
|
|
// Ensures we are not initializing our REST during `rest_preload_api_request`.
|
|
// When constructors signature changes, in manual update scenarios we were run into fatals.
|
|
// Those fatals are not critical, but it causes hickups in release process as catches unnecessary attention.
|
|
if ( function_exists( 'get_current_screen' ) && get_current_screen() ) {
|
|
return;
|
|
}
|
|
|
|
$api_controller = new RestController( $this );
|
|
$api_controller->register_routes();
|
|
}
|
|
|
|
/**
|
|
* Initialize the legacy widgets.
|
|
*
|
|
* @return void
|
|
*/
|
|
public function init_widgets() {
|
|
// Register the legacy widget.
|
|
$this->currency_switcher_widget = new CurrencySwitcherWidget( $this, $this->compatibility );
|
|
register_widget( $this->currency_switcher_widget );
|
|
}
|
|
|
|
/**
|
|
* Initialize the Settings Pages.
|
|
*
|
|
* @param array $settings_pages The settings pages.
|
|
*
|
|
* @return array The new settings pages.
|
|
*/
|
|
public function init_settings_pages( $settings_pages ): array {
|
|
// We don't need to check if the payment provider is connected for the
|
|
// Settings page generation on the incoming CLI and async job calls.
|
|
if ( ( defined( 'WP_CLI' ) && WP_CLI ) || ( defined( 'WPCOM_JOBS' ) && WPCOM_JOBS ) ) {
|
|
return $settings_pages;
|
|
}
|
|
|
|
// Due to autoloader limitations, we shouldn't initiate MCCY settings if the plugin was just upgraded:
|
|
// https://github.com/Automattic/woocommerce-payments/issues/9676.
|
|
if ( did_action( 'upgrader_process_complete' ) ) {
|
|
return $settings_pages;
|
|
}
|
|
|
|
if ( $this->payments_account->is_provider_connected() ) {
|
|
$settings = new Settings( $this );
|
|
$settings->init_hooks();
|
|
|
|
$settings_pages[] = $settings;
|
|
} else {
|
|
$settings_onboard_cta = new SettingsOnboardCta( $this, $this->payments_account );
|
|
$settings_onboard_cta->init_hooks();
|
|
|
|
$settings_pages[] = $settings_onboard_cta;
|
|
}
|
|
|
|
return $settings_pages;
|
|
}
|
|
|
|
/**
|
|
* Load the admin assets.
|
|
*
|
|
* @return void
|
|
*/
|
|
public function enqueue_admin_scripts() {
|
|
global $current_tab;
|
|
|
|
// Enqueue the settings JS and CSS only on the WCPay multi-currency settings page.
|
|
if ( 'wcpay_multi_currency' !== $current_tab ) {
|
|
return;
|
|
}
|
|
|
|
$this->register_admin_scripts();
|
|
|
|
wp_enqueue_script( 'WCPAY_MULTI_CURRENCY_SETTINGS' );
|
|
wp_enqueue_style( 'WCPAY_MULTI_CURRENCY_SETTINGS' );
|
|
}
|
|
|
|
/**
|
|
* Add multi-currency specific props to the WCPay JS config.
|
|
*
|
|
* @param array $config The JS config that will be loaded on the frontend.
|
|
*
|
|
* @return array The updated JS config.
|
|
*/
|
|
public function add_props_to_wcpay_js_config( $config ) {
|
|
$config['isMultiCurrencyEnabled'] = true;
|
|
|
|
return $config;
|
|
}
|
|
|
|
/**
|
|
* Gets and caches the data for the currency rates from the server.
|
|
* Will be returned as an array with two keys:
|
|
* - 'currencies' (the currencies)
|
|
* - 'updated' (when this data was fetched from the API).
|
|
*
|
|
* @return ?array
|
|
*/
|
|
public function get_cached_currencies() {
|
|
// If connection to server cannot be established, payment provider is not connected, or the account is rejected,
|
|
// return any data we have cached (expired or not) or null.
|
|
if ( ! $this->payments_api_client->is_server_connected() || ! $this->payments_account->is_provider_connected() || $this->payments_account->is_account_rejected() ) {
|
|
$cached_data = $this->cache->get( MultiCurrencyCacheInterface::CURRENCIES_KEY, true );
|
|
return $cached_data ?? null;
|
|
}
|
|
|
|
return $this->cache->get_or_add(
|
|
MultiCurrencyCacheInterface::CURRENCIES_KEY,
|
|
function () {
|
|
try {
|
|
$currency_data = $this->payments_api_client->get_currency_rates( strtolower( get_woocommerce_currency() ) );
|
|
return [
|
|
'currencies' => $currency_data,
|
|
'updated' => time(),
|
|
];
|
|
} catch ( \Exception $e ) {
|
|
return null;
|
|
}
|
|
},
|
|
function ( $data ) {
|
|
return is_array( $data ) && isset( $data['currencies'], $data['updated'] );
|
|
}
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Returns the Compatibility instance.
|
|
*
|
|
* @return Compatibility
|
|
*/
|
|
public function get_compatibility() {
|
|
return $this->compatibility;
|
|
}
|
|
|
|
/**
|
|
* Returns the Currency Switcher Widget instance.
|
|
*
|
|
* @return CurrencySwitcherWidget|null
|
|
*/
|
|
public function get_currency_switcher_widget() {
|
|
return $this->currency_switcher_widget;
|
|
}
|
|
|
|
/**
|
|
* Returns the FrontendPrices instance.
|
|
*
|
|
* @return FrontendPrices
|
|
*/
|
|
public function get_frontend_prices(): FrontendPrices {
|
|
return $this->frontend_prices;
|
|
}
|
|
|
|
/**
|
|
* Returns the FrontendCurrencies instance.
|
|
*
|
|
* @return FrontendCurrencies
|
|
*/
|
|
public function get_frontend_currencies(): FrontendCurrencies {
|
|
return $this->frontend_currencies;
|
|
}
|
|
|
|
/**
|
|
* Returns the StorefrontIntegration instance.
|
|
*
|
|
* @return StorefrontIntegration|null
|
|
*/
|
|
public function get_storefront_integration() {
|
|
return $this->storefront_integration;
|
|
}
|
|
|
|
/**
|
|
* Generates the switcher widget markup.
|
|
*
|
|
* @param array $instance The widget's instance settings.
|
|
* @param array $args The widget's arguments.
|
|
*
|
|
* @return string The widget markup.
|
|
*/
|
|
public function get_switcher_widget_markup( array $instance = [], array $args = [] ): string {
|
|
/**
|
|
* The spl_object_hash function is used here due to we register the widget with an instance of the widget and
|
|
* not the class name of the widget. WordPress core takes the instance and passes it through spl_object_hash
|
|
* to get a hash and adds that as the widget's name in the $wp_widget_factory->widgets[] array. In order to
|
|
* call the_widget, you need to have the name of the widget, so we get the instance and hash to use.
|
|
*/
|
|
ob_start();
|
|
|
|
$currency_switcher_widget = $this->get_currency_switcher_widget();
|
|
|
|
if ( ! is_object( $currency_switcher_widget ) ) {
|
|
Logger::notice(
|
|
sprintf(
|
|
'Invalid widget markup. Widget instance must be type object, %s given.',
|
|
gettype( $currency_switcher_widget )
|
|
)
|
|
);
|
|
|
|
return ob_get_clean();
|
|
}
|
|
|
|
the_widget(
|
|
spl_object_hash( $currency_switcher_widget ),
|
|
apply_filters( self::FILTER_PREFIX . 'theme_widget_instance', $instance ),
|
|
apply_filters( self::FILTER_PREFIX . 'theme_widget_args', $args )
|
|
);
|
|
return ob_get_clean();
|
|
}
|
|
|
|
/**
|
|
* Returns the store's current available, enabled, and default currencies.
|
|
*
|
|
* @return array
|
|
*/
|
|
public function get_store_currencies(): array {
|
|
return [
|
|
'available' => $this->get_available_currencies(),
|
|
'enabled' => $this->get_enabled_currencies(),
|
|
'default' => $this->get_default_currency(),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Gets the currency settings for a single currency.
|
|
*
|
|
* @param string $currency_code The currency code to get settings for.
|
|
*
|
|
* @return array The currency's settings.
|
|
*
|
|
* @throws InvalidCurrencyException
|
|
*/
|
|
public function get_single_currency_settings( string $currency_code ): array {
|
|
// Confirm the currency code is valid before trying to get the settings.
|
|
if ( ! array_key_exists( strtoupper( $currency_code ), $this->get_available_currencies() ) ) {
|
|
$this->log_and_throw_invalid_currency_exception( __FUNCTION__, $currency_code );
|
|
}
|
|
|
|
$currency_code = strtolower( $currency_code );
|
|
return [
|
|
'exchange_rate_type' => get_option( 'wcpay_multi_currency_exchange_rate_' . $currency_code, 'automatic' ),
|
|
'manual_rate' => get_option( 'wcpay_multi_currency_manual_rate_' . $currency_code, null ),
|
|
'price_rounding' => get_option( 'wcpay_multi_currency_price_rounding_' . $currency_code, null ),
|
|
'price_charm' => get_option( 'wcpay_multi_currency_price_charm_' . $currency_code, null ),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Updates the currency settings for a single currency.
|
|
*
|
|
* @param string $currency_code The single currency code to be updated.
|
|
* @param string $exchange_rate_type The exchange rate type setting.
|
|
* @param float $price_rounding The price rounding setting.
|
|
* @param float $price_charm The price charm setting.
|
|
* @param ?float $manual_rate The manual rate setting, or null.
|
|
*
|
|
* @return void
|
|
*
|
|
* @throws InvalidCurrencyException
|
|
* @throws InvalidCurrencyRateException
|
|
*/
|
|
public function update_single_currency_settings( string $currency_code, string $exchange_rate_type, float $price_rounding, float $price_charm, $manual_rate = null ) {
|
|
// Confirm the currency code is valid before trying to update the settings.
|
|
if ( ! array_key_exists( strtoupper( $currency_code ), $this->get_available_currencies() ) ) {
|
|
$this->log_and_throw_invalid_currency_exception( __FUNCTION__, $currency_code );
|
|
}
|
|
|
|
$currency_code = strtolower( $currency_code );
|
|
|
|
if ( 'manual' === $exchange_rate_type && ! is_null( $manual_rate ) ) {
|
|
if ( ! is_numeric( $manual_rate ) || 0 >= $manual_rate ) {
|
|
$message = 'Invalid manual currency rate passed to update_single_currency_settings: ' . $manual_rate;
|
|
Logger::error( $message );
|
|
throw new InvalidCurrencyRateException( esc_html( $message ), 500 );
|
|
}
|
|
update_option( 'wcpay_multi_currency_manual_rate_' . $currency_code, $manual_rate );
|
|
}
|
|
|
|
update_option( 'wcpay_multi_currency_price_rounding_' . $currency_code, $price_rounding );
|
|
update_option( 'wcpay_multi_currency_price_charm_' . $currency_code, $price_charm );
|
|
if ( in_array( $exchange_rate_type, [ 'automatic', 'manual' ], true ) ) {
|
|
update_option( 'wcpay_multi_currency_exchange_rate_' . $currency_code, esc_attr( $exchange_rate_type ) );
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Updates the customer currencies option.
|
|
*
|
|
* @param int $order_id The order ID.
|
|
*
|
|
* @return void
|
|
*/
|
|
public function maybe_update_customer_currencies_option( $order_id ) {
|
|
$order = wc_get_order( $order_id );
|
|
|
|
if ( ! $order ) {
|
|
return;
|
|
}
|
|
|
|
$currency = strtoupper( $order->get_currency() );
|
|
$currencies = self::get_all_customer_currencies();
|
|
|
|
// Skip if the currency is already in the list.
|
|
if ( in_array( $currency, $currencies, true ) ) {
|
|
return;
|
|
}
|
|
|
|
$currencies[] = $currency;
|
|
update_option( self::CUSTOMER_CURRENCIES_KEY, $currencies );
|
|
}
|
|
|
|
/**
|
|
* Gets the currencies available. Initializes it if needed.
|
|
*
|
|
* @return Currency[] Array of Currency objects.
|
|
*/
|
|
public function get_available_currencies(): array {
|
|
if ( null === $this->available_currencies ) {
|
|
$this->init();
|
|
}
|
|
|
|
return $this->available_currencies ?? [];
|
|
}
|
|
|
|
/**
|
|
* Gets the store base currency. Initializes it if needed.
|
|
*
|
|
* @return Currency The store base currency.
|
|
*/
|
|
public function get_default_currency(): Currency {
|
|
if ( null === $this->default_currency ) {
|
|
$this->init();
|
|
}
|
|
|
|
return $this->default_currency ?? new Currency( $this->localization_service, get_woocommerce_currency() );
|
|
}
|
|
|
|
/**
|
|
* Gets the currently enabled currencies. Initializes it if needed.
|
|
*
|
|
* @return Currency[] Array of Currency objects.
|
|
*/
|
|
public function get_enabled_currencies(): array {
|
|
if ( null === $this->enabled_currencies ) {
|
|
$this->init();
|
|
}
|
|
|
|
return $this->enabled_currencies ?? [];
|
|
}
|
|
|
|
/**
|
|
* Sets the enabled currencies for the store.
|
|
*
|
|
* @param string[] $currencies Array of currency codes to be enabled.
|
|
*
|
|
* @return void
|
|
*
|
|
* @throws InvalidCurrencyException
|
|
*/
|
|
public function set_enabled_currencies( $currencies = [] ) {
|
|
// If curriencies is not an array, or if there are no currencies, just exit.
|
|
if ( ! is_array( $currencies ) || 0 === count( $currencies ) ) {
|
|
return;
|
|
}
|
|
|
|
// Confirm the currencies submitted are available/valid currencies.
|
|
$invalid_currencies = array_diff( $currencies, array_keys( $this->get_available_currencies() ) );
|
|
if ( 0 < count( $invalid_currencies ) ) {
|
|
$this->log_and_throw_invalid_currency_exception( __FUNCTION__, implode( ', ', $invalid_currencies ) );
|
|
}
|
|
|
|
// Get the currencies that were removed before they are updated.
|
|
$removed_currencies = array_diff( array_keys( $this->get_enabled_currencies() ), $currencies );
|
|
|
|
// Update the enabled currencies and reinitialize.
|
|
update_option( $this->id . '_enabled_currencies', $currencies );
|
|
$this->initialize_enabled_currencies();
|
|
|
|
Logger::debug(
|
|
'Enabled currencies updated: '
|
|
. var_export( $currencies, true ) // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_var_export
|
|
);
|
|
|
|
// Now remove the removed currencies settings.
|
|
if ( 0 < count( $removed_currencies ) ) {
|
|
$this->remove_currencies_settings( $removed_currencies );
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gets the user selected currency, or `$default_currency` if is not set.
|
|
*
|
|
* @return Currency
|
|
*/
|
|
public function get_selected_currency(): Currency {
|
|
$multi_currency_code = $this->compatibility->override_selected_currency();
|
|
$currency_code = $multi_currency_code ? $multi_currency_code : $this->get_stored_currency_code();
|
|
|
|
return $this->get_enabled_currencies()[ $currency_code ] ?? $this->get_default_currency();
|
|
}
|
|
|
|
/**
|
|
* Update the selected currency from a currency code.
|
|
*
|
|
* @param string $currency_code Three letter currency code.
|
|
* @param bool $persist_change Set true to store the change in the session cookie if it doesn't exist yet.
|
|
*
|
|
* @return void
|
|
*/
|
|
public function update_selected_currency( string $currency_code, bool $persist_change = true ) {
|
|
$code = strtoupper( $currency_code );
|
|
$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();
|
|
|
|
// 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 ( $this->get_stored_currency_code() !== $code && $persist_change ) {
|
|
$this->frontend_currencies->clear_url_price_params();
|
|
}
|
|
|
|
if ( 0 === $user_id && WC()->session ) {
|
|
WC()->session->set( self::CURRENCY_SESSION_KEY, $currency->get_code() );
|
|
// Set the session cookie if is not yet to persist the selected currency.
|
|
if ( ! WC()->session->has_session() && ! headers_sent() && $persist_change ) {
|
|
$this->utils->set_customer_session_cookie( true );
|
|
}
|
|
} elseif ( $user_id ) {
|
|
update_user_meta( $user_id, self::CURRENCY_META_KEY, $currency->get_code() );
|
|
}
|
|
|
|
// Recalculate cart when currency changes.
|
|
if ( did_action( 'wp_loaded' ) ) {
|
|
$this->recalculate_cart();
|
|
} else {
|
|
add_action( 'wp_loaded', [ $this, 'recalculate_cart' ] );
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update the selected currency from url param `currency`.
|
|
*
|
|
* @return void
|
|
*/
|
|
public function update_selected_currency_by_url() {
|
|
if ( ! isset( $_GET['currency'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification
|
|
return;
|
|
}
|
|
|
|
$this->update_selected_currency( sanitize_text_field( wp_unslash( $_GET['currency'] ) ) ); // phpcs:ignore WordPress.Security.NonceVerification
|
|
}
|
|
|
|
/**
|
|
* Update the selected currency from the user's geolocation country.
|
|
*
|
|
* @return void
|
|
*/
|
|
public function update_selected_currency_by_geolocation() {
|
|
// We only want to automatically set the currency if the option is enabled and it shouldn't be disabled for any reason.
|
|
if ( ! $this->is_using_auto_currency_switching() || $this->compatibility->should_disable_currency_switching() ) {
|
|
return;
|
|
}
|
|
|
|
// Display notice, prevent duplicates in simulation.
|
|
if ( ! has_action( 'wp_footer', [ $this, 'display_geolocation_currency_update_notice' ] ) ) {
|
|
add_action( 'wp_footer', [ $this, 'display_geolocation_currency_update_notice' ] );
|
|
}
|
|
|
|
// Update currency only if it's already not set.
|
|
if ( $this->get_stored_currency_code() ) {
|
|
return;
|
|
}
|
|
|
|
$currency = $this->geolocation->get_currency_by_customer_location();
|
|
|
|
if ( empty( $this->get_enabled_currencies()[ $currency ] ) ) {
|
|
return;
|
|
}
|
|
|
|
$this->update_selected_currency( $currency, false );
|
|
}
|
|
|
|
/**
|
|
* Gets the configured value for apply charm pricing only to products.
|
|
*
|
|
* @return mixed The configured value.
|
|
*/
|
|
public function get_apply_charm_only_to_products() {
|
|
return apply_filters( self::FILTER_PREFIX . 'apply_charm_only_to_products', true );
|
|
}
|
|
|
|
/**
|
|
* Gets the converted price using the current currency with the rounding and charm pricing settings.
|
|
*
|
|
* @param mixed $price The price to be converted.
|
|
* @param string $type The type of price being converted. One of 'product', 'shipping', 'tax', 'coupon', or 'exchange_rate'.
|
|
*
|
|
* @return float The converted price.
|
|
*/
|
|
public function get_price( $price, string $type ): float {
|
|
$supported_types = [ 'product', 'shipping', 'tax', 'coupon', 'exchange_rate' ];
|
|
$currency = $this->get_selected_currency();
|
|
|
|
if ( ! in_array( $type, $supported_types, true ) || $currency->get_is_default() ) {
|
|
return (float) $price;
|
|
}
|
|
|
|
$converted_price = ( (float) $price ) * $currency->get_rate();
|
|
|
|
if ( 'tax' === $type || 'coupon' === $type || 'exchange_rate' === $type ) {
|
|
// We must make sure the price is rounded properly before returning it, otherwise we
|
|
// may end up with inconsistent prices in the cart.
|
|
$num_decimals = absint(
|
|
$this->localization_service->get_currency_format(
|
|
$currency->get_code()
|
|
)['num_decimals']
|
|
);
|
|
return round( $converted_price, $num_decimals );
|
|
}
|
|
|
|
$charm_compatible_types = [ 'product', 'shipping' ];
|
|
$apply_charm_pricing = $this->get_apply_charm_only_to_products()
|
|
? 'product' === $type
|
|
: in_array( $type, $charm_compatible_types, true );
|
|
|
|
return $this->get_adjusted_price( $converted_price, $apply_charm_pricing, $currency );
|
|
}
|
|
|
|
/**
|
|
* Gets a raw converted amount based on the amount and currency codes passed.
|
|
* This is a helper method for external conversions, if needed.
|
|
*
|
|
* @param float $amount The amount to be converted.
|
|
* @param string $to_currency The 3 letter currency code to convert the amount to.
|
|
* @param string $from_currency The 3 letter currency code to convert the amount from.
|
|
*
|
|
* @return float The converted amount.
|
|
*
|
|
* @throws InvalidCurrencyException
|
|
* @throws InvalidCurrencyRateException
|
|
*/
|
|
public function get_raw_conversion( float $amount, string $to_currency, string $from_currency = '' ): float {
|
|
$enabled_currencies = $this->get_enabled_currencies();
|
|
|
|
// If the from_currency is not set, use the store currency.
|
|
if ( '' === $from_currency ) {
|
|
$from_currency = $this->get_default_currency()->get_code();
|
|
}
|
|
|
|
// We throw an exception if either of the currencies are not enabled.
|
|
$to_currency = strtoupper( $to_currency );
|
|
$from_currency = strtoupper( $from_currency );
|
|
foreach ( [ $to_currency, $from_currency ] as $code ) {
|
|
if ( ! isset( $enabled_currencies[ $code ] ) ) {
|
|
$this->log_and_throw_invalid_currency_exception( __FUNCTION__, $code );
|
|
}
|
|
}
|
|
|
|
// Get the rates.
|
|
$to_currency_rate = $enabled_currencies[ $to_currency ]->get_rate();
|
|
$from_currency_rate = $enabled_currencies[ $from_currency ]->get_rate();
|
|
|
|
// Throw an exception in case from_currency_rate is less than or equal to 0.
|
|
if ( 0 >= $from_currency_rate ) {
|
|
$message = 'Invalid rate for from_currency in get_raw_conversion: ' . $from_currency_rate;
|
|
Logger::error( $message );
|
|
throw new InvalidCurrencyRateException( esc_html( $message ), 500 );
|
|
}
|
|
|
|
$amount = $amount * ( $to_currency_rate / $from_currency_rate );
|
|
|
|
return (float) $amount;
|
|
}
|
|
|
|
/**
|
|
* Recalculates WooCommerce cart totals.
|
|
*
|
|
* @return void
|
|
*/
|
|
public function recalculate_cart() {
|
|
if ( WC()->cart ) {
|
|
WC()->cart->calculate_totals();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* When an order is refunded, a new psuedo order is created to represent the refund.
|
|
* We want to check if the original order was a multi-currency order, and if so, copy the meta data
|
|
* to the new order.
|
|
*
|
|
* @param int $order_id The order ID.
|
|
* @param int $refund_id The refund order ID.
|
|
*/
|
|
public function add_order_meta_on_refund( $order_id, $refund_id ) {
|
|
$default_currency = $this->get_default_currency();
|
|
|
|
$order = wc_get_order( $order_id );
|
|
$refund = wc_get_order( $refund_id );
|
|
|
|
// Do not add exchange rate if order was made in the store's default currency.
|
|
if ( ! $order || ! $refund || $default_currency->get_code() === $order->get_currency() ) {
|
|
return;
|
|
}
|
|
|
|
$order_exchange_rate = $order->get_meta( '_wcpay_multi_currency_order_exchange_rate', true );
|
|
$stripe_exchange_rate = $order->get_meta( '_wcpay_multi_currency_stripe_exchange_rate', true );
|
|
$order_default_currency = $order->get_meta( '_wcpay_multi_currency_order_default_currency', true );
|
|
|
|
$refund->update_meta_data( '_wcpay_multi_currency_order_exchange_rate', $order_exchange_rate );
|
|
$refund->update_meta_data( '_wcpay_multi_currency_order_default_currency', $order_default_currency );
|
|
if ( $stripe_exchange_rate ) {
|
|
$refund->update_meta_data( '_wcpay_multi_currency_stripe_exchange_rate', $stripe_exchange_rate );
|
|
}
|
|
|
|
$refund->save_meta_data();
|
|
}
|
|
|
|
/**
|
|
* Displays a notice on the frontend informing the customer of the
|
|
* automatic currency switch.
|
|
*/
|
|
public function display_geolocation_currency_update_notice() {
|
|
$current_currency = $this->get_selected_currency();
|
|
$store_currency = get_option( 'woocommerce_currency' );
|
|
$country = $this->geolocation->get_country_by_customer_location();
|
|
$geolocated_currency = $this->geolocation->get_currency_by_customer_location();
|
|
$currencies = get_woocommerce_currencies();
|
|
|
|
// Don't run next checks if simulation is enabled.
|
|
if ( ! $this->is_simulation_enabled() ) {
|
|
// Do not display notice if using the store's default currency.
|
|
if ( $store_currency === $current_currency->get_code() ) {
|
|
return;
|
|
}
|
|
|
|
// Do not display notice for other currencies than geolocated.
|
|
if ( $current_currency->get_code() !== $geolocated_currency ) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
$message = sprintf(
|
|
/* translators: %1 User's country, %2 Selected currency name, %3 Default store currency name, %4 Link to switch currency */
|
|
__( 'We noticed you\'re visiting from %1$s. We\'ve updated our prices to %2$s for your shopping convenience. <a href="%4$s">Use %3$s instead.</a>', 'woocommerce-payments' ),
|
|
apply_filters( self::FILTER_PREFIX . 'override_notice_country', WC()->countries->countries[ $country ] ),
|
|
apply_filters( self::FILTER_PREFIX . 'override_notice_currency_name', $current_currency->get_name() ),
|
|
esc_html( $currencies[ $store_currency ] ),
|
|
esc_url( '?currency=' . $store_currency )
|
|
);
|
|
|
|
$notice_id = md5( $message );
|
|
|
|
echo '<p class="woocommerce-store-notice demo_store" data-notice-id="' . esc_attr( $notice_id . 2 ) . '" style="display:none;">';
|
|
// No need to escape here as the contents of $message is already escaped.
|
|
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
|
echo $message;
|
|
echo ' <a href="#" class="woocommerce-store-notice__dismiss-link">' . esc_html__( 'Dismiss', 'woocommerce-payments' ) . '</a></p>';
|
|
}
|
|
|
|
/**
|
|
* Sets a new customer's currency meta to what's in their session.
|
|
* This is needed for when a new user/customer is created during the checkout process.
|
|
*
|
|
* @param int $customer_id The user/customer id.
|
|
*
|
|
* @return void
|
|
*/
|
|
public function set_new_customer_currency_meta( $customer_id ) {
|
|
$code = 0 !== $customer_id && WC()->session ? WC()->session->get( self::CURRENCY_SESSION_KEY ) : false;
|
|
if ( $code ) {
|
|
update_user_meta( $customer_id, self::CURRENCY_META_KEY, $code );
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Adds Multi-Currency notes to the WC-Admin inbox.
|
|
*
|
|
* @return void
|
|
*/
|
|
public function add_woo_admin_notes() {
|
|
// Do not try to add notes on ajax requests to improve their performance.
|
|
if ( wp_doing_ajax() ) {
|
|
return;
|
|
}
|
|
|
|
if ( defined( 'WC_VERSION' ) && version_compare( WC_VERSION, '4.4.0', '>=' ) ) {
|
|
NoteMultiCurrencyAvailable::set_account( $this->payments_account );
|
|
NoteMultiCurrencyAvailable::possibly_add_note();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Removes Multi-Currency notes from the WC-Admin inbox.
|
|
*
|
|
* @return void
|
|
*/
|
|
public static function remove_woo_admin_notes() {
|
|
if ( defined( 'WC_VERSION' ) && version_compare( WC_VERSION, '4.4.0', '>=' ) ) {
|
|
NoteMultiCurrencyAvailable::possibly_delete_note();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Checks if the merchant has enabled automatic currency switching and geolocation.
|
|
*
|
|
* @return bool
|
|
*/
|
|
public function is_using_auto_currency_switching(): bool {
|
|
return 'yes' === get_option( $this->id . '_enable_auto_currency', 'no' );
|
|
}
|
|
|
|
/**
|
|
* Checks if the merchant has enabled the currency switcher widget.
|
|
*
|
|
* @return bool
|
|
*/
|
|
public function is_using_storefront_switcher(): bool {
|
|
return 'yes' === get_option( $this->id . '_enable_storefront_switcher', 'no' );
|
|
}
|
|
|
|
/**
|
|
* Gets the store settings.
|
|
*
|
|
* @return array The store settings.
|
|
*/
|
|
public function get_settings() {
|
|
return [
|
|
$this->id . '_enable_auto_currency' => $this->is_using_auto_currency_switching(),
|
|
$this->id . '_enable_storefront_switcher' => $this->is_using_storefront_switcher(),
|
|
'site_theme' => wp_get_theme()->get( 'Name' ),
|
|
'date_format' => esc_attr( get_option( 'date_format', 'F j, Y' ) ),
|
|
'time_format' => esc_attr( get_option( 'time_format', 'g:i a' ) ),
|
|
'store_url' => esc_attr( get_page_uri( wc_get_page_id( 'shop' ) ) ),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Updates the store settings
|
|
*
|
|
* @param array $params Update requested values.
|
|
*
|
|
* @return void
|
|
*/
|
|
public function update_settings( $params ) {
|
|
$updateable_options = [
|
|
'wcpay_multi_currency_enable_auto_currency',
|
|
'wcpay_multi_currency_enable_storefront_switcher',
|
|
];
|
|
|
|
foreach ( $updateable_options as $key ) {
|
|
if ( isset( $params[ $key ] ) ) {
|
|
update_option( $key, sanitize_text_field( $params[ $key ] ) );
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load script with all required dependencies.
|
|
*
|
|
* @param string $handler Script handler.
|
|
* @param string $script Script name relative to the plugin root.
|
|
* @param array $additional_dependencies Additional dependencies.
|
|
*
|
|
* @return void
|
|
*/
|
|
public function register_script_with_dependencies( string $handler, string $script, array $additional_dependencies = [] ) {
|
|
$script_file = $script . '.js';
|
|
$script_src_url = plugins_url( $script_file, $this->settings_service->get_plugin_file_path() );
|
|
$script_asset_path = plugin_dir_path( $this->settings_service->get_plugin_file_path() ) . $script . '.asset.php';
|
|
$script_asset = file_exists( $script_asset_path ) ? require $script_asset_path : [ 'dependencies' => [] ]; // nosemgrep: audit.php.lang.security.file.inclusion-arg -- server generated path is used.
|
|
$all_dependencies = array_merge( $script_asset['dependencies'], $additional_dependencies );
|
|
|
|
wp_register_script(
|
|
$handler,
|
|
$script_src_url,
|
|
$all_dependencies,
|
|
$this->get_file_version( $script_file ),
|
|
true
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Get the file modified time as a cache buster if we're in dev mode.
|
|
*
|
|
* @param string $file Local path to the file.
|
|
*
|
|
* @return string
|
|
*/
|
|
public function get_file_version( $file ) {
|
|
$plugin_path = plugin_dir_path( $this->settings_service->get_plugin_file_path() );
|
|
|
|
if ( defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG && file_exists( $plugin_path . $file ) ) {
|
|
return (string) filemtime( $plugin_path . trim( $file, '/' ) );
|
|
}
|
|
|
|
return $this->settings_service->get_plugin_version();
|
|
}
|
|
|
|
/**
|
|
* Validates the given currency code.
|
|
*
|
|
* @param string $currency_code The currency code to check validity.
|
|
*
|
|
* @return string|false Returns back the currency code in uppercase letters if it's valid, or `false` if not.
|
|
*/
|
|
public function validate_currency_code( $currency_code ) {
|
|
return array_key_exists( strtoupper( $currency_code ), $this->available_currencies )
|
|
? strtoupper( $currency_code )
|
|
: false;
|
|
}
|
|
|
|
/**
|
|
* Get simulation params from querystring and activate when needed
|
|
*
|
|
* @return void
|
|
*/
|
|
public function possible_simulation_activation() {
|
|
// This is required in the MC onboarding simulation iframe.
|
|
$this->simulation_params = $this->get_multi_currency_onboarding_simulation_variables();
|
|
if ( ! $this->is_simulation_enabled() ) {
|
|
return;
|
|
}
|
|
// Modify the page links to deliver required params in the simulation.
|
|
$this->add_simulation_params_to_preview_urls();
|
|
$this->simulate_client_currency();
|
|
}
|
|
|
|
/**
|
|
* Returns whether the simulation querystring param is set and active
|
|
*
|
|
* @return bool Whether the simulation is enabled or not
|
|
*/
|
|
public function is_simulation_enabled() {
|
|
return 0 < count( $this->simulation_params );
|
|
}
|
|
|
|
/**
|
|
* Gets the Multi-Currency onboarding preview overrides from the querystring.
|
|
*
|
|
* @return array Override variables
|
|
*/
|
|
public function get_multi_currency_onboarding_simulation_variables() {
|
|
|
|
$parameters = $_GET; // phpcs:ignore WordPress.Security.NonceVerification
|
|
// Check if we are in a preview session, don't interfere with the main session.
|
|
if ( ! isset( $parameters['is_mc_onboarding_simulation'] ) || ! (bool) $parameters['is_mc_onboarding_simulation'] ) {
|
|
// Check if the page referer has the variables.
|
|
$server = $_SERVER; // phpcs:ignore WordPress.Security.NonceVerification
|
|
// Check if we are coming from a simulation session (if we don't have the necessary query strings).
|
|
if ( isset( $server['HTTP_REFERER'] ) && 0 < strpos( $server['HTTP_REFERER'], 'is_mc_onboarding_simulation' ) ) {
|
|
wp_parse_str( wp_parse_url( $server['HTTP_REFERER'], PHP_URL_QUERY ), $parameters );
|
|
if ( ! isset( $parameters['is_mc_onboarding_simulation'] ) || ! (bool) $parameters['is_mc_onboarding_simulation'] ) {
|
|
return [];
|
|
}
|
|
} else {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
// Define variables which can be overridden inside the preview session, with their sanitization methods.
|
|
$possible_variables = [
|
|
'enable_storefront_switcher' => 'wp_validate_boolean',
|
|
'enable_auto_currency' => 'wp_validate_boolean',
|
|
];
|
|
|
|
// Define the defaults if the parameter is missing in the request.
|
|
$defaults = [
|
|
'enable_storefront_switcher' => false,
|
|
'enable_auto_currency' => false,
|
|
];
|
|
|
|
// Prepare the params array.
|
|
$values = [];
|
|
|
|
// Walk through the querystring parameter possibilities, and prepare the params.
|
|
foreach ( $possible_variables as $possible_variable => $sanitization_callback ) {
|
|
// phpcs:disable WordPress.Security.NonceVerification
|
|
if ( isset( $parameters[ $possible_variable ] ) ) {
|
|
$values[ $possible_variable ] = $sanitization_callback( $parameters[ $possible_variable ] );
|
|
} else {
|
|
// Append the default, the param is missing in the querystring.
|
|
$values [ $possible_variable ] = $defaults[ $possible_variable ];
|
|
}
|
|
}
|
|
|
|
return $values;
|
|
}
|
|
|
|
/**
|
|
* Checks if the currently displayed page is the WooCommerce Payments
|
|
* settings page for the Multi-Currency settings.
|
|
*
|
|
* @return bool
|
|
*/
|
|
public function is_multi_currency_settings_page(): bool {
|
|
global $current_screen, $current_tab;
|
|
return (
|
|
is_admin()
|
|
&& $current_tab && $current_screen
|
|
&& 'wcpay_multi_currency' === $current_tab
|
|
&& 'woocommerce_page_wc-settings' === $current_screen->base
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Get all the currencies that have been used in the store.
|
|
*
|
|
* @return array
|
|
*/
|
|
public function get_all_customer_currencies(): array {
|
|
global $wpdb;
|
|
|
|
$currencies = get_option( self::CUSTOMER_CURRENCIES_KEY );
|
|
|
|
if ( self::is_customer_currencies_data_valid( $currencies ) ) {
|
|
return array_map( 'strtoupper', $currencies );
|
|
}
|
|
|
|
$currencies = $this->get_available_currencies();
|
|
$query_union = [];
|
|
|
|
if ( class_exists( 'Automattic\WooCommerce\Utilities\OrderUtil' ) &&
|
|
\Automattic\WooCommerce\Utilities\OrderUtil::custom_orders_table_usage_is_enabled() ) {
|
|
foreach ( $currencies as $currency ) {
|
|
$query_union[] = $wpdb->prepare(
|
|
"SELECT %s AS currency_code, EXISTS(SELECT currency FROM {$wpdb->prefix}wc_orders WHERE currency=%s LIMIT 1) AS exists_in_orders",
|
|
$currency->code,
|
|
$currency->code
|
|
);
|
|
}
|
|
} else {
|
|
foreach ( $currencies as $currency ) {
|
|
$query_union[] = $wpdb->prepare(
|
|
"SELECT %s AS currency_code, EXISTS(SELECT meta_value FROM {$wpdb->postmeta} WHERE meta_key=%s AND meta_value=%s LIMIT 1) AS exists_in_orders",
|
|
$currency->code,
|
|
'_order_currency',
|
|
$currency->code
|
|
);
|
|
}
|
|
}
|
|
|
|
$sub_query = implode( ' UNION ALL ', $query_union );
|
|
$query = "SELECT currency_code FROM ( $sub_query ) as subquery WHERE subquery.exists_in_orders=1 ORDER BY currency_code ASC";
|
|
$currencies = $wpdb->get_col( $query ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
|
|
|
|
if ( self::is_customer_currencies_data_valid( $currencies ) ) {
|
|
update_option( self::CUSTOMER_CURRENCIES_KEY, $currencies );
|
|
return array_map( 'strtoupper', $currencies );
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* Checks if there are additional currencies enabled beyond the store's default one.
|
|
*
|
|
* @return bool
|
|
*/
|
|
public function has_additional_currencies_enabled(): bool {
|
|
$enabled_currencies = $this->get_enabled_currencies();
|
|
return count( $enabled_currencies ) > 1;
|
|
}
|
|
|
|
/**
|
|
* Returns if the currency initializations are completed.
|
|
*
|
|
* @return bool If the initializations have been completed.
|
|
*/
|
|
public function is_initialized(): bool {
|
|
return static::$is_initialized;
|
|
}
|
|
|
|
/**
|
|
* Adjusts the given amount for the currently selected currency.
|
|
*
|
|
* Applies charm pricing if specified, and adjusts the amount according to
|
|
* the selected currency's conversion rate.
|
|
*
|
|
* @param float $amount The original amount to adjust.
|
|
* @param bool $apply_charm_pricing Optional. Whether to apply charm pricing to the adjusted amount. Default true.
|
|
* @return float The amount adjusted for the selected currency.
|
|
*/
|
|
public function adjust_amount_for_selected_currency( $amount, $apply_charm_pricing = true ) {
|
|
return $this->get_adjusted_price( $amount, $apply_charm_pricing, $this->get_selected_currency() );
|
|
}
|
|
|
|
/**
|
|
* Returns the amount with the backend format.
|
|
*
|
|
* @param float $amount The amount to format.
|
|
* @param array $args The arguments to pass to wc_price.
|
|
*
|
|
* @return string The formatted amount.
|
|
*/
|
|
public function get_backend_formatted_wc_price( float $amount, array $args = [] ): string {
|
|
return wc_price( $amount, $args );
|
|
}
|
|
|
|
/**
|
|
* Gets the price after adjusting it with the rounding and charm settings.
|
|
*
|
|
* @param float $price The price to be adjusted.
|
|
* @param bool $apply_charm_pricing Whether charm pricing should be applied.
|
|
* @param Currency $currency The currency to be used when adjusting.
|
|
*
|
|
* @return float The adjusted price.
|
|
*/
|
|
protected function get_adjusted_price( $price, $apply_charm_pricing, $currency ): float {
|
|
$rounding = (float) $currency->get_rounding();
|
|
|
|
// If rounding is configured to be `0.00` we still need to round to the nearest lowest
|
|
// currency denomination.
|
|
// Otherwise we ceil the price to the configured rounding option.
|
|
// NOTE: We don't round if currency rounding is > 0.00 because in those cases we want to
|
|
// ceil the amount. For example: if $price = 1.251 and currency rounding = 0.25 we
|
|
// want that amount ceiled to 1.50. If we round( 1.251 ) to 1.25 before ceiling the
|
|
// price to the nearest 0.25 amount the final amount will be 1.25, which is incorrect.
|
|
if ( 0.00 === $rounding ) {
|
|
$num_decimals = absint(
|
|
$this->localization_service->get_currency_format(
|
|
$currency->get_code()
|
|
)['num_decimals']
|
|
);
|
|
|
|
$price = round( $price, $num_decimals );
|
|
} else {
|
|
$price = $this->ceil_price( $price, $rounding );
|
|
}
|
|
|
|
if ( $apply_charm_pricing ) {
|
|
$price += (float) $currency->get_charm();
|
|
}
|
|
|
|
// Do not return negative prices (possible because of $currency->get_charm()).
|
|
return max( 0, $price );
|
|
}
|
|
|
|
/**
|
|
* Ceils the price to the next number based on the rounding value.
|
|
*
|
|
* @param float $price The price to be ceiled.
|
|
* @param float $rounding The rounding option.
|
|
*
|
|
* @return float The ceiled price.
|
|
*/
|
|
protected function ceil_price( float $price, float $rounding ): float {
|
|
if ( 0.00 === $rounding ) {
|
|
return $price;
|
|
}
|
|
return ceil( $price / $rounding ) * $rounding;
|
|
}
|
|
|
|
/**
|
|
* Sets up the available currencies, which are alphabetical by name.
|
|
*
|
|
* @return void
|
|
*/
|
|
private function initialize_available_currencies() {
|
|
// Add default store currency with a rate of 1.0.
|
|
$woocommerce_currency = get_woocommerce_currency();
|
|
$this->available_currencies[ $woocommerce_currency ] = new Currency( $this->localization_service, $woocommerce_currency, 1.0 );
|
|
|
|
$available_currencies = [];
|
|
|
|
$currencies = $this->get_account_available_currencies();
|
|
if ( ! empty( $currencies ) ) {
|
|
$cache_data = $this->get_cached_currencies();
|
|
|
|
foreach ( $currencies as $currency_code ) {
|
|
$currency_rate = $cache_data['currencies'][ $currency_code ] ?? 1.0;
|
|
$update_time = $cache_data['updated'] ?? null;
|
|
$new_currency = new Currency( $this->localization_service, $currency_code, $currency_rate, $update_time );
|
|
|
|
// Add this to our list of available currencies.
|
|
$available_currencies[ $new_currency->get_name() ] = $new_currency;
|
|
}
|
|
}
|
|
|
|
ksort( $available_currencies );
|
|
|
|
foreach ( $available_currencies as $currency ) {
|
|
$this->available_currencies[ $currency->get_code() ] = $currency;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sets up the enabled currencies.
|
|
*
|
|
* @return void
|
|
*/
|
|
private function initialize_enabled_currencies() {
|
|
$available_currencies = $this->get_available_currencies();
|
|
$enabled_currency_codes = get_option( $this->id . '_enabled_currencies', [] );
|
|
$enabled_currency_codes = is_array( $enabled_currency_codes ) ? $enabled_currency_codes : [];
|
|
$default_code = $this->get_default_currency()->get_code();
|
|
$default = [];
|
|
$enabled_currency_codes[] = $default_code;
|
|
|
|
// This allows to keep the alphabetical sorting by name.
|
|
$enabled_currencies = array_filter(
|
|
$available_currencies,
|
|
function ( $currency ) use ( $enabled_currency_codes ) {
|
|
return in_array( $currency->get_code(), $enabled_currency_codes, true );
|
|
}
|
|
);
|
|
|
|
$this->enabled_currencies = [];
|
|
|
|
foreach ( $enabled_currencies as $enabled_currency ) {
|
|
// Get the charm and rounding for each enabled currency and add the currencies to the object property.
|
|
$currency = clone $enabled_currency;
|
|
$charm = get_option( $this->id . '_price_charm_' . $currency->get_id(), 0.00 );
|
|
$rounding = get_option( $this->id . '_price_rounding_' . $currency->get_id(), $currency->get_is_zero_decimal() ? '100' : '1.00' );
|
|
$currency->set_charm( $charm );
|
|
$currency->set_rounding( $rounding );
|
|
|
|
// If the currency is set to be manual, set the rate to the stored manual rate.
|
|
$type = get_option( $this->id . '_exchange_rate_' . $currency->get_id(), 'automatic' );
|
|
if ( 'manual' === $type ) {
|
|
$manual_rate = get_option( $this->id . '_manual_rate_' . $currency->get_id(), $currency->get_rate() );
|
|
$currency->set_rate( $manual_rate );
|
|
}
|
|
|
|
$this->enabled_currencies[ $currency->get_code() ] = $currency;
|
|
}
|
|
|
|
// Set default currency to the top of the list.
|
|
$default[ $default_code ] = $this->enabled_currencies[ $default_code ];
|
|
unset( $this->enabled_currencies[ $default_code ] );
|
|
$this->enabled_currencies = array_merge( $default, $this->enabled_currencies );
|
|
}
|
|
|
|
/**
|
|
* Sets the default currency.
|
|
*
|
|
* @return void
|
|
*/
|
|
private function set_default_currency() {
|
|
$available_currencies = $this->get_available_currencies();
|
|
$this->default_currency = $available_currencies[ get_woocommerce_currency() ] ?? null;
|
|
}
|
|
|
|
/**
|
|
* Returns the currency code stored for the user or in the session.
|
|
*
|
|
* @return string|null Currency code.
|
|
*/
|
|
private function get_stored_currency_code() {
|
|
$user_id = get_current_user_id();
|
|
|
|
if ( $user_id ) {
|
|
return get_user_meta( $user_id, self::CURRENCY_META_KEY, true );
|
|
}
|
|
|
|
WC()->initialize_session();
|
|
$currency_code = WC()->session->get( self::CURRENCY_SESSION_KEY );
|
|
|
|
return is_string( $currency_code ) ? $currency_code : null;
|
|
}
|
|
|
|
/**
|
|
* Checks to see if the store currency has changed. If it has, this will
|
|
* also update the option containing the store currency.
|
|
*
|
|
* @return bool
|
|
*/
|
|
private function check_store_currency_for_change(): bool {
|
|
$last_known_currency = get_option( $this->id . '_store_currency', false );
|
|
$woocommerce_currency = get_woocommerce_currency();
|
|
|
|
// If the last known currency was not set, update the option to set it and return false.
|
|
if ( ! $last_known_currency ) {
|
|
update_option( $this->id . '_store_currency', $woocommerce_currency );
|
|
return false;
|
|
}
|
|
|
|
if ( $last_known_currency !== $woocommerce_currency ) {
|
|
update_option( $this->id . '_store_currency', $woocommerce_currency );
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Called when the store currency has changed. Puts any manual rate currencies into an option for a notice to display.
|
|
*
|
|
* @return void
|
|
*/
|
|
private function update_manual_rate_currencies_notice_option() {
|
|
$enabled_currencies = $this->get_enabled_currencies();
|
|
$manual_currencies = [];
|
|
|
|
// Check enabled currencies for manual rates.
|
|
foreach ( $enabled_currencies as $currency ) {
|
|
$rate_type = get_option( $this->id . '_exchange_rate_' . $currency->get_id(), false );
|
|
if ( 'manual' === $rate_type ) {
|
|
$manual_currencies[] = $currency->get_name();
|
|
}
|
|
}
|
|
|
|
if ( 0 < count( $manual_currencies ) ) {
|
|
update_option( $this->id . '_show_store_currency_changed_notice', $manual_currencies );
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Accepts an array of currencies that should have their settings removed.
|
|
*
|
|
* @param array $currencies Array of Currency objects or 3 letter currency codes.
|
|
*
|
|
* @return void
|
|
*/
|
|
private function remove_currencies_settings( array $currencies ) {
|
|
|
|
foreach ( $currencies as $currency ) {
|
|
$this->remove_currency_settings( $currency );
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Will remove a currency's settings if it is not enabled.
|
|
*
|
|
* @param mixed $currency Currency object or 3 letter currency code.
|
|
*
|
|
* @return void
|
|
*/
|
|
private function remove_currency_settings( $currency ) {
|
|
$code = is_a( $currency, Currency::class ) ? $currency->get_code() : strtoupper( $currency );
|
|
|
|
// Bail if the currency code passed is not 3 characters, or if the currency is presently enabled.
|
|
if ( 3 !== strlen( $code ) || isset( $this->get_enabled_currencies()[ $code ] ) ) {
|
|
return;
|
|
}
|
|
|
|
$settings = [
|
|
'price_charm',
|
|
'price_rounding',
|
|
'manual_rate',
|
|
'exchange_rate',
|
|
];
|
|
|
|
// Go through each setting and remove them.
|
|
foreach ( $settings as $setting ) {
|
|
delete_option( $this->id . '_' . $setting . '_' . strtolower( $code ) );
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns the currencies enabled for the payment provider account that are
|
|
* also available in WC.
|
|
*
|
|
* Can be filtered with the 'wcpay_multi_currency_available_currencies' hook.
|
|
*
|
|
* @return array Array with the available currencies' codes.
|
|
*/
|
|
private function get_account_available_currencies(): array {
|
|
// If the payment provider is not connected, return an empty array. This prevents using MC without being connected to the payment provider.
|
|
if ( ! $this->payments_account->is_provider_connected() ) {
|
|
return [];
|
|
}
|
|
|
|
$wc_currencies = array_keys( get_woocommerce_currencies() );
|
|
$account_currencies = $wc_currencies;
|
|
|
|
$account = $this->payments_account->get_cached_account_data();
|
|
$supported_currencies = $this->payments_account->get_account_customer_supported_currencies();
|
|
if ( $account && ! empty( $supported_currencies ) ) {
|
|
$account_currencies = array_map( 'strtoupper', $supported_currencies );
|
|
}
|
|
|
|
/**
|
|
* Filter the available currencies for WooCommerce Multi-Currency.
|
|
*
|
|
* This filter can be used to modify the currencies available for WC Pay
|
|
* Multi-Currency. Currencies have to be added in uppercase and should
|
|
* also be available in `get_woocommerce_currencies` for them to work.
|
|
*
|
|
* @since 2.8.0
|
|
*
|
|
* @param array $available_currencies Current available currencies. Calculated based on
|
|
* WC Pay's account currencies and WC currencies.
|
|
*/
|
|
return apply_filters( self::FILTER_PREFIX . 'available_currencies', array_intersect( $account_currencies, $wc_currencies ) );
|
|
}
|
|
|
|
/**
|
|
* Register the CSS and JS admin scripts.
|
|
*
|
|
* @return void
|
|
*/
|
|
private function register_admin_scripts() {
|
|
$this->register_script_with_dependencies( 'WCPAY_MULTI_CURRENCY_SETTINGS', 'dist/multi-currency', [ 'WCPAY_ADMIN_SETTINGS', 'wp-components' ] );
|
|
|
|
wp_register_style(
|
|
'WCPAY_MULTI_CURRENCY_SETTINGS',
|
|
plugins_url( 'dist/multi-currency.css', $this->settings_service->get_plugin_file_path() ),
|
|
[ 'wc-components', 'WCPAY_ADMIN_SETTINGS' ],
|
|
$this->get_file_version( 'dist/multi-currency.css' ),
|
|
'all'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Enables simulation of client browser currency.
|
|
*
|
|
* @return void
|
|
*/
|
|
private function simulate_client_currency() {
|
|
if ( ! $this->simulation_params['enable_auto_currency'] ) {
|
|
return;
|
|
}
|
|
|
|
$countries = $this->payments_account->get_supported_countries();
|
|
|
|
$predefined_simulation_currencies = [
|
|
'USD' => $countries['US'],
|
|
'GBP' => $countries['GB'],
|
|
];
|
|
|
|
$simulation_currency = 'USD' === get_option( 'woocommerce_currency', 'USD' ) ? 'GBP' : 'USD';
|
|
$simulation_currency_name = $this->available_currencies[ $simulation_currency ]->get_name();
|
|
$simulation_country = $predefined_simulation_currencies[ $simulation_currency ];
|
|
|
|
// Simulate client currency from geolocation.
|
|
add_filter(
|
|
'wcpay_multi_currency_override_notice_currency_name',
|
|
function ( $selected_currency_name ) use ( $simulation_currency_name ) {
|
|
return $simulation_currency_name;
|
|
}
|
|
);
|
|
|
|
// Simulate client country from geolocation.
|
|
add_filter(
|
|
'wcpay_multi_currency_override_notice_country',
|
|
function ( $selected_country ) use ( $simulation_country ) {
|
|
return $simulation_country;
|
|
}
|
|
);
|
|
|
|
// Always display the notice on simulation screen, prevent duplicate hooks.
|
|
if ( ! has_action( 'wp_footer', [ $this, 'display_geolocation_currency_update_notice' ] ) ) {
|
|
add_action( 'wp_footer', [ $this, 'display_geolocation_currency_update_notice' ] );
|
|
}
|
|
|
|
// Skip recalculating the cart to prevent infinite loop in simulation.
|
|
remove_action( 'wp_loaded', [ $this, 'recalculate_cart' ] );
|
|
}
|
|
|
|
/**
|
|
* Adds the required querystring parameters to all urls in preview pages.
|
|
*
|
|
* @return void
|
|
*/
|
|
private function add_simulation_params_to_preview_urls() {
|
|
$params = $this->simulation_params;
|
|
add_filter(
|
|
'wp_footer',
|
|
function () use ( $params ) {
|
|
?>
|
|
<script type="text/javascript" id="wcpay_multi_currency-simulation-script">
|
|
// Add simulation overrides to all links.
|
|
document.querySelectorAll('a').forEach((link) => {
|
|
const parsedURL = new URL(link.href);
|
|
if (
|
|
false === parsedURL.searchParams.has( 'is_mc_onboarding_simulation' )
|
|
) {
|
|
parsedURL.searchParams.set('is_mc_onboarding_simulation', true);
|
|
parsedURL.searchParams.set('enable_auto_currency', <?php echo esc_attr( $params['enable_auto_currency'] ? 'true' : 'false' ); ?>);
|
|
parsedURL.searchParams.set('enable_storefront_switcher', <?php echo esc_attr( $params['enable_storefront_switcher'] ? 'true' : 'false' ); ?>);
|
|
link.href = parsedURL.toString();
|
|
}
|
|
});
|
|
|
|
// Unhide the store notice in simulation mode.
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
const noticeElement = document.querySelector('.woocommerce-store-notice.demo_store')
|
|
if( noticeElement ) {
|
|
const noticeId = noticeElement.getAttribute('data-notice-id');
|
|
cookieStore.delete( 'store_notice' + noticeId );
|
|
}
|
|
});
|
|
</script>
|
|
<?php
|
|
}
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Logs a message and throws InvalidCurrencyException.
|
|
*
|
|
* @param string $method The method that's actually throwing the exception.
|
|
* @param string $currency_code The currency code that was invalid.
|
|
* @param int $code The exception code.
|
|
*
|
|
* @throws InvalidCurrencyException
|
|
*/
|
|
private function log_and_throw_invalid_currency_exception( $method, $currency_code, $code = 500 ) {
|
|
$message = 'Invalid currency passed to ' . $method . ': ' . $currency_code;
|
|
Logger::error( $message );
|
|
throw new InvalidCurrencyException( esc_html( $message ), esc_html( $code ) );
|
|
}
|
|
|
|
/**
|
|
* Checks if the customer currencies data is valid.
|
|
*
|
|
* @param mixed $currencies The currencies to check.
|
|
*
|
|
* @return bool
|
|
*/
|
|
private function is_customer_currencies_data_valid( $currencies ) {
|
|
return ! empty( $currencies ) && is_array( $currencies );
|
|
}
|
|
}
|