This commit is contained in:
emmymayo
2025-02-05 23:15:46 +01:00
commit 7269c99357
16995 changed files with 3389680 additions and 0 deletions
@@ -0,0 +1,115 @@
<?php
/**
* Admin notices for Multi-Currency.
*
* @package WooCommerce\Payments\MultiCurrency
*/
namespace WCPay\MultiCurrency;
defined( 'ABSPATH' ) || exit;
/**
* Class that will display admin notices.
*/
class AdminNotices {
/**
* Notices.
*
* @var array
*/
private $notices = [];
/**
* Initializes this class' WP hooks.
*
* @return void
*/
public function init_hooks() {
add_action( 'admin_notices', [ $this, 'admin_notices' ] );
add_action( 'wp_loaded', [ $this, 'hide_notices' ] );
}
/**
* Display any notices we've collected thus far.
*/
public function admin_notices() {
if ( ! current_user_can( 'manage_woocommerce' ) ) {
return;
}
$this->check_for_notices();
foreach ( $this->notices as $notice_key => $notice ) {
echo '<div class="' . esc_attr( $notice['class'] ) . '" style="position:relative;">';
if ( $notice['dismissible'] ) {
?>
<a href="<?php echo esc_url( wp_nonce_url( add_query_arg( 'wcpay-multi-currency-hide-notice', $notice_key ), 'wcpay_multi_currency_hide_notices_nonce', '_wcpay_multi_currency_notice_nonce' ) ); ?>" class="woocommerce-message-close notice-dismiss" style="position:relative;float:right;padding:9px 0 9px 9px;text-decoration:none;"></a>
<?php
}
echo '<p>';
echo wp_kses(
$notice['message'],
[
'a' => [
'href' => [],
'target' => [],
],
]
);
echo '</p></div>';
}
}
/**
* Hides any admin notices.
*/
public function hide_notices() {
if ( isset( $_GET['wcpay-multi-currency-hide-notice'] ) && isset( $_GET['_wcpay_multi_currency_notice_nonce'] ) ) {
if ( ! wp_verify_nonce( wc_clean( wp_unslash( $_GET['_wcpay_multi_currency_notice_nonce'] ) ), 'wcpay_multi_currency_hide_notices_nonce' ) ) {
wp_die( esc_html__( 'Action failed. Please refresh the page and retry.', 'woocommerce-payments' ) );
}
if ( ! current_user_can( 'manage_woocommerce' ) ) {
wp_die( esc_html__( 'Cheatin&#8217; huh?', 'woocommerce-payments' ) );
}
$notice = wc_clean( wp_unslash( $_GET['wcpay-multi-currency-hide-notice'] ) );
if ( 'currency_changed' === $notice ) {
update_option( 'wcpay_multi_currency_show_store_currency_changed_notice', 'no' );
}
}
}
/**
* Adds admin notice to be displayed.
*
* @param string $slug Slug for the notice.
* @param string $class Class(es) for the notice.
* @param string $message Message in the notice.
* @param bool $dismissible Whether the notice can be dismissed or not.
*/
private function add_admin_notice( $slug, $class, $message, $dismissible = false ) {
$this->notices[ $slug ] = [
'class' => $class,
'message' => $message,
'dismissible' => $dismissible,
];
}
/**
* Checks for notices and add them.
*/
private function check_for_notices() {
$manual_currencies = get_option( 'wcpay_multi_currency_show_store_currency_changed_notice', false );
if ( is_array( $manual_currencies ) ) {
$currencies = implode( ', ', $manual_currencies );
// translators: %s List of currencies that are already translated in WooCommerce core.
$this->add_admin_notice( 'currency_changed', 'notice notice-warning', sprintf( __( 'The store currency was recently changed. The following currencies are set to manual rates and may need updates: %s', 'woocommerce-payments' ), $currencies ), true );
}
}
}
@@ -0,0 +1,640 @@
<?php
/**
* Class Compatibility
*
* @package WooCommerce\Payments\Compatibility
*/
namespace WCPay\MultiCurrency;
use Automattic\WooCommerce\Blocks\Package;
use Automattic\WooCommerce\Blocks\Assets\AssetDataRegistry;
use Automattic\WooCommerce\Utilities\OrderUtil;
use WC_Order;
use WC_Order_Refund;
use WCPay\MultiCurrency\Interfaces\MultiCurrencySettingsInterface;
defined( 'ABSPATH' ) || exit;
/**
* Class that contains Multi-Currency related support for WooCommerce analytics.
*/
class Analytics {
const PRIORITY_EARLY = 1;
const PRIORITY_DEFAULT = 10;
const PRIORITY_LATE = 20;
const PRIORITY_LATEST = 99999;
const SCRIPT_NAME = 'WCPAY_MULTI_CURRENCY_ANALYTICS';
const SUPPORTED_CONTEXTS = [ 'orders', 'products', 'variations', 'categories', 'coupons', 'taxes' ];
/**
* SQL string replacements made by the analytics Multi-Currency extension.
*
* @var array
*/
protected $sql_replacements = [];
/**
* Instance of MultiCurrency.
*
* @var MultiCurrency $multi_currency
*/
private $multi_currency;
/**
* Instance of MultiCurrencySettingsInterface.
*
* @var MultiCurrencySettingsInterface $settings_service
*/
private $settings_service;
/**
* Constructor
*
* @param MultiCurrency $multi_currency Instance of MultiCurrency.
* @param MultiCurrencySettingsInterface $settings_service Instance of MultiCurrencySettingsInterface.
*/
public function __construct( MultiCurrency $multi_currency, MultiCurrencySettingsInterface $settings_service ) {
$this->multi_currency = $multi_currency;
$this->settings_service = $settings_service;
$this->init();
}
/**
* Initialise all actions, filters and hooks related to analytics support.
*
* @return void
*/
public function init() {
if ( is_admin() && current_user_can( 'manage_woocommerce' ) ) {
add_filter( 'admin_enqueue_scripts', [ $this, 'enqueue_admin_scripts' ] );
$this->register_customer_currencies();
}
if ( $this->settings_service->is_dev_mode() ) {
add_filter( 'woocommerce_analytics_report_should_use_cache', [ $this, 'disable_report_caching' ] );
}
// Add a filter when the order stats table is updated.
add_filter( 'woocommerce_analytics_update_order_stats_data', [ $this, 'update_order_stats_data' ], self::PRIORITY_LATEST, 2 );
// Add filters when the query args are updated.
add_filter( 'woocommerce_analytics_orders_query_args', [ $this, 'apply_customer_currency_args' ] );
add_filter( 'woocommerce_analytics_orders_stats_query_args', [ $this, 'apply_customer_currency_args' ] );
// If we aren't making a REST request, or no multi currency orders exist in the merchant's store,
// return before adding these filters.
if ( ! WC()->is_rest_api_request() || ! $this->has_multi_currency_orders() ) {
return;
}
$this->set_sql_replacements();
// Add the filters that are applied in each analytics query.
add_filter( 'woocommerce_analytics_clauses_select', [ $this, 'filter_select_clauses' ], self::PRIORITY_LATE, 2 );
add_filter( 'woocommerce_analytics_clauses_join', [ $this, 'filter_join_clauses' ], self::PRIORITY_LATE, 2 );
// Add the WHERE clause filter which is applied only on Order related queries.
add_filter( 'woocommerce_analytics_clauses_where_orders_subquery', [ $this, 'filter_where_clauses' ] );
add_filter( 'woocommerce_analytics_clauses_where_orders_stats_total', [ $this, 'filter_where_clauses' ] );
add_filter( 'woocommerce_analytics_clauses_where_orders_stats_interval', [ $this, 'filter_where_clauses' ] );
// phpcs:disable WordPress.Security.NonceVerification.Recommended
if ( ! empty( $_GET['currency'] ) && $_GET['currency'] !== $this->multi_currency->get_default_currency()->get_code() ) {
add_filter( 'woocommerce_analytics_clauses_select_orders_subquery', [ $this, 'filter_select_orders_clauses' ] );
add_filter( 'woocommerce_analytics_clauses_select_orders_stats_total', [ $this, 'filter_select_orders_clauses' ] );
}
}
/**
* Register the CSS and JS scripts.
*
* @return void
*/
public function register_admin_scripts() {
$this->multi_currency->register_script_with_dependencies( self::SCRIPT_NAME, 'dist/multi-currency-analytics' );
}
/**
* Add the list of currencies used on the store to the wcSettings to allow it to be accessed by the front-end JS script.
*
* @return void
*/
public function register_customer_currencies() {
$data_registry = Package::container()->get( AssetDataRegistry::class );
if ( $data_registry->exists( 'customerCurrencies' ) ) {
return;
}
$currencies = $this->multi_currency->get_all_customer_currencies();
$available_currencies = $this->multi_currency->get_available_currencies();
$currency_options = [];
$default_currency = $this->multi_currency->get_default_currency();
// Add default currency to the list if it does not exist.
if ( ! in_array( $default_currency->get_code(), $currencies, true ) ) {
$currencies[] = $default_currency->get_code();
}
foreach ( $currencies as $currency ) {
if ( ! isset( $available_currencies[ $currency ] ) ) {
continue;
}
$currency_details = $available_currencies[ $currency ];
$currency_options[] = [
'label' => html_entity_decode( $currency_details->get_name() ),
'value' => $currency_details->get_code(),
];
}
$data_registry->add( 'customerCurrencies', $currency_options );
}
/**
* Enqueue scripts used on the analytics WP Admin pages.
*
* @return void
*/
public function enqueue_admin_scripts() {
$this->register_admin_scripts();
wp_enqueue_script( self::SCRIPT_NAME );
}
/**
* Disables report caching. Used for development of analytics related functionality.
* To disable report caching
*
* @param array $args Filter arguments.
*
* @return boolean
*/
public function disable_report_caching( $args ): bool {
return false;
}
/**
* If the customer currency is set, add it as a query parameter to requests to the data store.
* This ensures that the cache is updated when this value is changed between requests.
*
* @param array $args The arguments passed in via the GET request.
*
* @return array
*/
public function apply_customer_currency_args( $args ): array {
$currency_args = $this->get_customer_currency_args_from_request();
return array_merge( $args, $currency_args );
}
/**
* When an order is updated in the stats table, perform a check to see if it is a Multi-Currency order
* and convert the information into the store's default currency if it is.
*
* @param array $args - An array of the arguments to be inserted into the order stats table.
* @param WC_Order $order - The order itself.
*
* @return array
*/
public function update_order_stats_data( array $args, $order ): array {
if ( ! $this->should_convert_order_stats( $order ) ) {
return $args;
}
$stripe_exchange_rate = $order->get_meta( '_wcpay_multi_currency_stripe_exchange_rate', true )
? (float) $order->get_meta( '_wcpay_multi_currency_stripe_exchange_rate', true )
: null;
$order_exchange_rate = ( 1 / (float) $order->get_meta( '_wcpay_multi_currency_order_exchange_rate', true ) );
$exchange_rate = $stripe_exchange_rate ?? $order_exchange_rate;
$dp = wc_get_price_decimals();
$args['net_total'] = round( $this->convert_amount( (float) $args['net_total'], $exchange_rate ), $dp );
$args['shipping_total'] = round( $this->convert_amount( (float) $args['shipping_total'], $exchange_rate ), $dp );
$args['tax_total'] = round( $this->convert_amount( (float) $args['tax_total'], $exchange_rate ), $dp );
$args['total_sales'] = $args['net_total'] + $args['shipping_total'] + $args['tax_total'];
return $args;
}
/**
* Add columns to get the currency and converted amount (if required).
*
* @param string[] $clauses - An array containing the SELECT clauses to be applied.
* @param string $context - The context in which this SELECT clause is being called.
*
* @return array
*/
public function filter_select_clauses( array $clauses, $context ): array {
// If we are unable to identify a context, just return the clauses as is.
if ( is_null( $context ) ) {
return $clauses;
}
if ( apply_filters( MultiCurrency::FILTER_PREFIX . 'disable_filter_select_clauses', false ) ) {
return $clauses;
}
$context_parts = explode( '_', $context );
$context_page = $context_parts[0] ?? 'generic';
$context_type = $context_parts[1] ?? null;
// If we can't identify the type of context we are running in (stats or subquery), then return the clauses as is.
if ( ! in_array( $context_type, [ 'stats', 'subquery' ], true ) ) {
return $clauses;
}
$new_clauses = [];
$sql_replacements = $this->get_sql_replacements();
foreach ( $clauses as $clause ) {
if ( ! array_key_exists( $context_page, $sql_replacements ) ) {
$replacements_array = $sql_replacements['generic'] ?? [];
} else {
$replacements_array = $sql_replacements[ $context_page ] ?? [];
}
foreach ( $replacements_array as $find => $replace ) {
if ( strpos( $clause, $find ) !== false ) {
$clause = str_replace(
$find,
$replace,
$clause
);
}
}
$new_clauses[] = $clause;
}
if ( $this->is_supported_context( $context ) && ( in_array( $context_page, self::SUPPORTED_CONTEXTS, true ) || $this->is_order_stats_table_used_in_clauses( $clauses ) ) ) {
if ( $this->is_cot_enabled() ) {
$new_clauses[] = ', wcpay_multicurrency_order_currency.currency AS order_currency';
} else {
$new_clauses[] = ', wcpay_multicurrency_currency_meta.meta_value AS order_currency';
}
$new_clauses[] = ', wcpay_multicurrency_default_currency_meta.meta_value AS order_default_currency';
$new_clauses[] = ', wcpay_multicurrency_exchange_rate_meta.meta_value AS exchange_rate';
$new_clauses[] = ', wcpay_multicurrency_stripe_exchange_rate_meta.meta_value AS stripe_exchange_rate';
}
return apply_filters( MultiCurrency::FILTER_PREFIX . 'filter_select_clauses', $new_clauses );
}
/**
* Add a JOIN so that we can get the currency information.
*
* @param string[] $clauses - An array containing the JOIN clauses to be applied.
* @param string $context - The context in which this SELECT clause is being called.
*
* @return array
*/
public function filter_join_clauses( array $clauses, $context ): array {
global $wpdb;
if ( apply_filters( MultiCurrency::FILTER_PREFIX . 'disable_filter_join_clauses', false ) ) {
return $clauses;
}
$context_parts = explode( '_', $context, 2 );
$context_page = $context_parts[0] ?? 'generic';
$prefix = 'wcpay_multicurrency_';
$currency_tbl = $prefix . 'currency_meta';
$default_currency_tbl = $prefix . 'default_currency_meta';
$exchange_rate_tbl = $prefix . 'exchange_rate_meta';
$stripe_exchange_rate_tbl = $prefix . 'stripe_exchange_rate_meta';
// Allow this to work with custom order tables as well.
if ( $this->is_cot_enabled() ) {
$meta_table = $wpdb->prefix . 'wc_orders_meta';
$id_field = 'order_id';
$currency_tbl = $prefix . 'order_currency';
} else {
$meta_table = $wpdb->postmeta;
$id_field = 'post_id';
}
// If this is a supported context, add the joins. If this is an unsupported context, see if we can add the joins.
if ( $this->is_supported_context( $context ) && ( in_array( $context_page, self::SUPPORTED_CONTEXTS, true ) || $this->is_order_stats_table_used_in_clauses( $clauses ) ) ) {
if ( $this->is_cot_enabled() ) {
$clauses[] = "LEFT JOIN {$wpdb->prefix}wc_orders {$currency_tbl} ON {$wpdb->prefix}wc_order_stats.order_id = {$currency_tbl}.id";
} else {
$clauses[] = "LEFT JOIN {$meta_table} {$currency_tbl} ON {$wpdb->prefix}wc_order_stats.order_id = {$currency_tbl}.{$id_field} AND {$currency_tbl}.meta_key = '_order_currency'";
}
$clauses[] = "LEFT JOIN {$meta_table} {$default_currency_tbl} ON {$wpdb->prefix}wc_order_stats.order_id = {$default_currency_tbl}.{$id_field} AND {$default_currency_tbl}.meta_key = '_wcpay_multi_currency_order_default_currency'";
$clauses[] = "LEFT JOIN {$meta_table} {$exchange_rate_tbl} ON {$wpdb->prefix}wc_order_stats.order_id = {$exchange_rate_tbl}.{$id_field} AND {$exchange_rate_tbl}.meta_key = '_wcpay_multi_currency_order_exchange_rate'";
$clauses[] = "LEFT JOIN {$meta_table} {$stripe_exchange_rate_tbl} ON {$wpdb->prefix}wc_order_stats.order_id = {$stripe_exchange_rate_tbl}.{$id_field} AND {$stripe_exchange_rate_tbl}.meta_key = '_wcpay_multi_currency_stripe_exchange_rate'";
}
return apply_filters( MultiCurrency::FILTER_PREFIX . 'filter_join_clauses', $clauses );
}
/**
* Add the WHERE clauses (if a customer currency has been selected).
*
* @param string[] $clauses - An array containing the JOIN clauses to be applied.
*
* @return array
*/
public function filter_where_clauses( array $clauses ): array {
if ( apply_filters( MultiCurrency::FILTER_PREFIX . 'disable_filter_where_clauses', false ) ) {
return $clauses;
}
$prefix = 'wcpay_multicurrency_';
if ( $this->is_cot_enabled() ) {
$currency_field = $prefix . 'order_currency.currency';
} else {
$currency_field = $prefix . 'currency_meta.meta_value';
}
$currency_args = $this->get_customer_currency_args_from_request();
if ( ! empty( $currency_args['currency_is'] ) ) {
/**
* Skip implode complaining array_map as wrong argument.
*
* @psalm-suppress InvalidArgument
*/
$currency_is = sprintf( "'%s'", implode( "', '", array_map( 'esc_sql', $currency_args['currency_is'] ) ) );
$clauses[] = "AND {$currency_field} IN ({$currency_is})";
}
if ( ! empty( $currency_args['currency_is_not'] ) ) {
/**
* Skip implode complaining array_map as wrong argument.
*
* @psalm-suppress InvalidArgument
*/
$currency_is_not = sprintf( "'%s'", implode( "', '", array_map( 'esc_sql', $currency_args['currency_is_not'] ) ) );
$clauses[] = "AND {$currency_field} NOT IN ({$currency_is_not})";
}
if ( ! empty( $currency_args['currency'] ) ) {
global $wpdb;
$expression = "AND {$currency_field} = '%s'";
$clauses[] = $wpdb->prepare( $expression, $currency_args['currency'] ); //phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
}
return apply_filters( MultiCurrency::FILTER_PREFIX . 'filter_where_clauses', $clauses );
}
/**
* Convert amounts back to order currency (if a currency has been selected).
* Skipping it for default currency.
*
* @param string[] $clauses - An array containing the SELECT orders clauses to be applied.
* @return array
*/
public function filter_select_orders_clauses( array $clauses ): array {
if ( apply_filters( MultiCurrency::FILTER_PREFIX . 'disable_filter_select_orders_clauses', false ) ) {
return $clauses;
}
global $wpdb;
$exchange_rate = 'wcpay_multicurrency_exchange_rate_meta.meta_value';
$stripe_exchange_rate = 'wcpay_multicurrency_stripe_exchange_rate_meta.meta_value';
$net_total = "{$wpdb->prefix}wc_order_stats.net_total";
foreach ( $clauses as $k => $clause ) {
if ( strpos( $clause, $net_total ) !== false ) {
$is_orders_subquery = strpos( $clause, $net_total . ',' ) !== false;
$variable = $is_orders_subquery ? "$net_total," : $net_total;
$alias = $is_orders_subquery ? ' as net_total,' : '';
$dp = wc_get_price_decimals();
$clauses[ $k ] = str_replace(
$variable,
$this->generate_case_when(
$stripe_exchange_rate,
"ROUND($net_total / $stripe_exchange_rate, $dp)",
"ROUND($net_total * $exchange_rate, $dp)"
) . $alias,
$clause
);
}
}
return apply_filters( MultiCurrency::FILTER_PREFIX . 'filter_select_orders_clauses', $clauses );
}
/**
* Check to see whether we should convert an order to store in the order stats table.
*
* @param WC_Order|WC_Order_Refund $order The order.
*
* @return boolean
*/
private function should_convert_order_stats( $order ): bool {
$default_currency = $this->multi_currency->get_default_currency();
// If this order was in the default currency, or the meta information isn't set on the order, return false.
if ( ! $order ||
$order->get_currency() === $default_currency->get_code() ||
! $order->get_meta( '_wcpay_multi_currency_order_exchange_rate', true ) ||
$order->get_meta( '_wcpay_multi_currency_order_default_currency', true ) !== $default_currency->get_code()
) {
return false;
}
return true;
}
/**
* Convert an amount to the store's default currency in order to store in the stats table.
*
* @param float $amount The amount to convert into the store's default currency.
* @param float $exchange_rate The exchange rate to use for the conversion.
*
* @return float The converted amount.
*/
private function convert_amount( float $amount, float $exchange_rate ): float {
return $amount * $exchange_rate;
}
/**
* Check whether the order stats table is referenced in the clauses, to work out whether
* to add the JOIN columns for Multi-Currency.
*
* @param array $clauses The array containing the clauses used.
*
* @return boolean Whether the order stats table is referenced.
*/
private function is_order_stats_table_used_in_clauses( array $clauses ): bool {
global $wpdb;
foreach ( $clauses as $clause ) {
if ( strpos( $clause, "{$wpdb->prefix}wc_order_stats" ) !== false ) {
return true;
}
}
return false;
}
/**
* There are some queries which are made in the analytics which are actually sub-queries
* which are used to join on an individual item/coupon/tax code. In these cases, rather than
* the context being the expected format e.g. product_stats_total, it will simply be 'product'.
* In these cases, we don't want to add the join columns or select them.
*
* @param string $context The context the query was made in.
*
* @return boolean
*/
private function is_supported_context( string $context ): bool {
$unsupported_contexts = [ 'products', 'coupons', 'taxes', 'variations', 'categories' ];
if ( in_array( $context, $unsupported_contexts, true ) ) {
return false;
}
return true;
}
/**
* Generate a case when statement using the provided variables.
*
* @param string $variable The SQL variable we want to check for NULL.
* @param string $then The THEN clause.
* @param string $else The ELSE clause.
*
* @return string
*/
private function generate_case_when( string $variable, string $then, string $else ): string {
return "CASE WHEN {$variable} IS NOT NULL THEN {$then} ELSE {$else} END";
}
/**
* Perform an SQL query to determine whether Multi Currency has ever been used on this store,
* by checking how many orders are in the database where an exchange currency rate has been stored.
*
* @return bool
*/
private function has_multi_currency_orders() {
global $wpdb;
// Using full SQL instead of variables to keep WPCS happy.
if ( $this->is_cot_enabled() ) {
$result = $wpdb->get_var(
"SELECT EXISTS(
SELECT 1
FROM {$wpdb->prefix}wc_orders_meta
WHERE meta_key = '_wcpay_multi_currency_order_exchange_rate'
LIMIT 1)
AS count;"
);
} else {
$result = $wpdb->get_var(
"SELECT EXISTS(
SELECT 1
FROM {$wpdb->postmeta}
WHERE meta_key = '_wcpay_multi_currency_order_exchange_rate'
LIMIT 1)
AS count;"
);
}
return intval( $result ) === 1;
}
/**
* Get the SQL replacements variable.
*
* @return array
*/
private function get_sql_replacements(): array {
return $this->sql_replacements;
}
/**
* Check the passed in query params to see if currency has been passed in.
* Will return null if no currency variable was passed in, otherwise will
* return the currency.
*
* @return array
*/
private function get_customer_currency_args_from_request(): array {
$args = [
'currency_is' => [],
'currency_is_not' => [],
'currency' => null,
];
/* phpcs:disable WordPress.Security.NonceVerification */
if ( isset( $_GET['currency_is'] ) && is_array( $_GET['currency_is'] ) ) {
$args['currency_is'] = array_map( 'sanitize_text_field', wp_unslash( $_GET['currency_is'] ) );
}
if ( isset( $_GET['currency_is_not'] ) ) {
$args['currency_is_not'] = array_map( 'sanitize_text_field', wp_unslash( $_GET['currency_is_not'] ) );
}
if ( isset( $_GET['currency'] ) ) {
$args['currency'] = sanitize_text_field( wp_unslash( $_GET['currency'] ) );
}
/* phpcs:enable WordPress.Security.NonceVerification */
return $args;
}
/**
* Set the SQL replacements variable.
*
* @return void
*/
private function set_sql_replacements() {
$default_currency = 'wcpay_multicurrency_default_currency_meta.meta_value';
$exchange_rate = 'wcpay_multicurrency_exchange_rate_meta.meta_value';
$stripe_exchange_rate = 'wcpay_multicurrency_stripe_exchange_rate_meta.meta_value';
$discount_amount = $this->generate_case_when( $default_currency, $this->generate_case_when( $stripe_exchange_rate, "ROUND(discount_amount * {$stripe_exchange_rate}, 2)", "ROUND(discount_amount * (1 / {$exchange_rate} ), 2)" ), 'discount_amount' );
$product_net_revenue = $this->generate_case_when( $default_currency, $this->generate_case_when( $stripe_exchange_rate, "ROUND(product_net_revenue * {$stripe_exchange_rate}, 2)", "ROUND(product_net_revenue * (1 / {$exchange_rate} ), 2)" ), 'product_net_revenue' );
$product_gross_revenue = $this->generate_case_when( $default_currency, $this->generate_case_when( $stripe_exchange_rate, "ROUND(product_gross_revenue * {$stripe_exchange_rate}, 2)", "ROUND(product_gross_revenue * (1 / {$exchange_rate} ), 2)" ), 'product_gross_revenue' );
$this->sql_replacements = [
'generic' => [
'discount_amount' => $discount_amount,
'product_net_revenue' => $product_net_revenue,
'product_gross_revenue' => $product_gross_revenue,
],
'orders' => [
'discount_amount' => $discount_amount,
],
'products' => [
'product_net_revenue' => $product_net_revenue,
'product_gross_revenue' => $product_gross_revenue,
],
'variations' => [
'product_net_revenue' => $product_net_revenue,
'product_gross_revenue' => $product_gross_revenue,
],
'categories' => [
'product_net_revenue' => $product_net_revenue,
'product_gross_revenue' => $product_gross_revenue,
],
'taxes' => [
'SUM(total_tax)' => 'SUM(' . $this->generate_case_when( $default_currency, $this->generate_case_when( $stripe_exchange_rate, "ROUND(total_tax * {$stripe_exchange_rate}, 2)", "ROUND(total_tax * (1 / {$exchange_rate} ), 2)" ), 'total_tax' ) . ')',
'SUM(order_tax)' => 'SUM(' . $this->generate_case_when( $default_currency, $this->generate_case_when( $stripe_exchange_rate, "ROUND(order_tax * {$stripe_exchange_rate}, 2)", "ROUND(order_tax * (1 / {$exchange_rate} ), 2)" ), 'order_tax' ) . ')',
'SUM(shipping_tax)' => 'SUM(' . $this->generate_case_when( $default_currency, $this->generate_case_when( $stripe_exchange_rate, "ROUND(shipping_tax * {$stripe_exchange_rate}, 2)", "ROUND(shipping_tax * (1 / {$exchange_rate} ), 2)" ), 'shipping_tax' ) . ')',
],
'coupons' => [
'discount_amount' => $discount_amount,
],
];
}
/**
* Checks whether Custom Order Tables are enabled.
*
* @return bool
*/
private function is_cot_enabled(): bool {
return class_exists( OrderUtil::class ) && OrderUtil::custom_orders_table_usage_is_enabled();
}
}
@@ -0,0 +1,182 @@
<?php
/**
* WooCommerce Payments Multi-Currency Backend Currencies
*
* @package WooCommerce\Payments
*/
namespace WCPay\MultiCurrency;
use WCPay\MultiCurrency\Interfaces\MultiCurrencyLocalizationInterface;
defined( 'ABSPATH' ) || exit;
/**
* Class that formats Multi-Currency currencies on the backend.
*/
class BackendCurrencies {
/**
* Multi-Currency instance.
*
* @var MultiCurrency
*/
protected $multi_currency;
/**
* MultiCurrencyLocalizationInterface instance.
*
* @var MultiCurrencyLocalizationInterface
*/
protected $localization_service;
/**
* Multi-Currency currency formatting map.
*
* @var array
*/
protected $currency_format = [];
/**
* Constructor.
*
* @param MultiCurrency $multi_currency The MultiCurrency instance.
* @param MultiCurrencyLocalizationInterface $localization_service The Localization Service instance.
*/
public function __construct( MultiCurrency $multi_currency, MultiCurrencyLocalizationInterface $localization_service ) {
$this->multi_currency = $multi_currency;
$this->localization_service = $localization_service;
}
/**
* Initializes this class' WP hooks.
*
* @return void
*/
public function init_hooks() {
// Skip if no additional currencies are enabled.
if ( ! $this->multi_currency->has_additional_currencies_enabled() ) {
return;
}
// We need to check first if it's a request coming from the backend, frontend REST requests shouldn't be
// affected by this.
$is_backend_request = 0 === stripos( wp_get_referer(), admin_url() );
// Add the filter if it's an admin request or a REST request from the admin side.
if ( ( is_admin() || ( WC()->is_rest_api_request() && $is_backend_request ) ) && ! defined( 'DOING_CRON' ) ) {
// Currency hooks. Be aware that this should not run after Explicit Price hook, its priority should be less
// than explicit price hooks to run before them.
add_filter( 'wc_price_args', [ $this, 'build_wc_price_args' ], 50 );
}
}
/**
* Returns the store currency code.
*
* @return string The store currency code
*/
public function get_store_currency(): string {
return get_woocommerce_currency();
}
/**
* Returns the currency code to be used by the formatter.
*
* @param array $args The arguments containing the currency code.
*
* @return string The code of the currency to be used.
*/
public function get_price_currency( $args ): string {
return $args['currency'] ?? $this->get_store_currency();
}
/**
* Returns the number of decimals to be used by the formatter.
*
* @param string $currency_code The currency code to fetch formatting specs.
*
* @return int The number of decimals.
*/
public function get_price_decimals( $currency_code ): int {
return absint( $this->localization_service->get_currency_format( $currency_code )['num_decimals'] );
}
/**
* Returns if the currency is a zero decimal point currency.
*
* @param string $currency_code The currency code to fetch formatting specs.
*
* @return bool Whether the currency is a zero decimal point currency or not.
*/
public function is_zero_decimal_currency( $currency_code ): bool {
return 0 === $this->get_price_decimals( $currency_code );
}
/**
* Returns the decimal separator to be used by the formatter.
*
* @param string $currency_code The currency code to fetch formatting specs.
*
* @return string The decimal separator.
*/
public function get_price_decimal_separator( $currency_code ): string {
return $this->localization_service->get_currency_format( $currency_code )['decimal_sep'];
}
/**
* Returns the thousand separator to be used by the formatter.
*
* @param string $currency_code The currency code to fetch formatting specs.
*
* @return string The thousand separator.
*/
public function get_price_thousand_separator( $currency_code ): string {
return $this->localization_service->get_currency_format( $currency_code )['thousand_sep'];
}
/**
* Returns the currency format to be used by the formatter.
*
* @param string $currency_code The currency code to fetch formatting specs.
*
* @return string The currency format.
*/
public function get_woocommerce_price_format( $currency_code ): string {
$currency_pos = $this->localization_service->get_currency_format( $currency_code )['currency_pos'];
switch ( $currency_pos ) {
case 'left':
return '%1$s%2$s';
case 'right':
return '%2$s%1$s';
case 'left_space':
return '%1$s&nbsp;%2$s';
case 'right_space':
return '%2$s&nbsp;%1$s';
default:
return '%1$s%2$s';
}
}
/**
* Returns the formatting arguments for the given currency using the locale-info
*
* @param array $args Currency formatter original arguments.
*
* @return array New arguments matching with the locale-info
*/
public function build_wc_price_args( $args ) {
$currency_code = $this->get_price_currency( $args );
return wp_parse_args(
[
'currency' => $currency_code,
'decimal_separator' => $this->get_price_decimal_separator( $this->get_store_currency() ),
'thousand_separator' => $this->get_price_thousand_separator( $this->get_store_currency() ),
'decimals' => $this->is_zero_decimal_currency( $currency_code ) ? 0 : $this->get_price_decimals( $currency_code ),
'price_format' => $this->get_woocommerce_price_format( $currency_code ),
],
$args
);
}
}
@@ -0,0 +1,222 @@
<?php
/**
* Class Compatibility
*
* @package WooCommerce\Payments\Compatibility
*/
namespace WCPay\MultiCurrency;
use WC_Order;
use WC_Order_Refund;
use WCPay\MultiCurrency\Compatibility\BaseCompatibility;
use WCPay\MultiCurrency\Compatibility\WooCommerceBookings;
use WCPay\MultiCurrency\Compatibility\WooCommerceFedEx;
use WCPay\MultiCurrency\Compatibility\WooCommerceNameYourPrice;
use WCPay\MultiCurrency\Compatibility\WooCommercePreOrders;
use WCPay\MultiCurrency\Compatibility\WooCommerceProductAddOns;
use WCPay\MultiCurrency\Compatibility\WooCommerceSubscriptions;
use WCPay\MultiCurrency\Compatibility\WooCommerceUPS;
use WCPay\MultiCurrency\Compatibility\WooCommerceDeposits;
use WCPay\MultiCurrency\Compatibility\WooCommercePointsAndRewards;
defined( 'ABSPATH' ) || exit;
/**
* Class that controls Multi-Currency Compatibility.
*/
class Compatibility extends BaseCompatibility {
/**
* Compatibility classes.
*
* @var array
*/
protected $compatibility_classes = [];
/**
* Init the class.
*
* @return void
*/
public function init() {
add_action( 'init', [ $this, 'init_compatibility_classes' ], 11 );
if ( defined( 'DOING_CRON' ) ) {
add_filter( 'woocommerce_admin_sales_record_milestone_enabled', [ $this, 'attach_order_modifier' ] );
}
}
/**
* Initializes our compatibility classes.
*
* @return void
*/
public function init_compatibility_classes() {
if ( 1 < count( $this->multi_currency->get_enabled_currencies() ) ) {
$this->compatibility_classes[] = new WooCommerceBookings( $this->multi_currency, $this->utils, $this->multi_currency->get_frontend_currencies() );
$this->compatibility_classes[] = new WooCommerceFedEx( $this->multi_currency, $this->utils );
$this->compatibility_classes[] = new WooCommerceNameYourPrice( $this->multi_currency, $this->utils );
$this->compatibility_classes[] = new WooCommercePreOrders( $this->multi_currency, $this->utils );
$this->compatibility_classes[] = new WooCommerceProductAddOns( $this->multi_currency, $this->utils );
$this->compatibility_classes[] = new WooCommerceSubscriptions( $this->multi_currency, $this->utils );
$this->compatibility_classes[] = new WooCommerceUPS( $this->multi_currency, $this->utils );
$this->compatibility_classes[] = new WooCommerceDeposits( $this->multi_currency, $this->utils );
$this->compatibility_classes[] = new WooCommercePointsAndRewards( $this->multi_currency, $this->utils );
}
}
/**
* Returns the compatibility classes.
*
* @return array
*/
public function get_compatibility_classes(): array {
return $this->compatibility_classes;
}
/**
* Checks to see if the selected currency needs to be overridden.
*
* @return mixed Three letter currency code or false if not.
*/
public function override_selected_currency() {
return apply_filters( MultiCurrency::FILTER_PREFIX . 'override_selected_currency', false );
}
/**
* Deprecated method, please use should_disable_currency_switching.
*
* @return bool False if it shouldn't be hidden, true if it should.
*/
public function should_hide_widgets(): bool {
wc_deprecated_function( __FUNCTION__, '6.5.0', 'Compatibility::should_disable_currency_switching' );
return $this->should_disable_currency_switching();
}
/**
* Checks to see if currency switching should be disabled, such as the widgets and the automatic geolocation switching.
*
* @return bool False if no, true if yes.
*/
public function should_disable_currency_switching(): bool {
$return = false;
/**
* If the pay_for_order parameter is set, we disable currency switching.
*
* WooCommerce itself handles all the heavy lifting and verification on the Order Pay page, we just need to
* make sure the currency switchers are not displayed. This is due to once the order is created, the currency
* itself should remain static.
*/
if ( isset( $_GET['pay_for_order'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
$return = true;
}
// If someone has hooked into the deprecated filter, throw a notice and then apply the filtering.
if ( has_action( MultiCurrency::FILTER_PREFIX . 'should_hide_widgets' ) ) {
wc_deprecated_hook( MultiCurrency::FILTER_PREFIX . 'should_hide_widgets', '6.5.0', MultiCurrency::FILTER_PREFIX . 'should_disable_currency_switching' );
$return = apply_filters( MultiCurrency::FILTER_PREFIX . 'should_hide_widgets', $return );
}
return apply_filters( MultiCurrency::FILTER_PREFIX . 'should_disable_currency_switching', $return );
}
/**
* Checks to see if the coupon's amount should be converted.
*
* @param object $coupon Coupon object to test.
*
* @return bool True if it should be converted.
*/
public function should_convert_coupon_amount( $coupon = null ): bool {
if ( ! $coupon ) {
return true;
}
return apply_filters( MultiCurrency::FILTER_PREFIX . 'should_convert_coupon_amount', true, $coupon );
}
/**
* Checks to see if the product's price should be converted.
*
* @param object $product Product object to test.
*
* @return bool True if it should be converted.
*/
public function should_convert_product_price( $product = null ): bool {
if ( ! $product ) {
return true;
}
return apply_filters( MultiCurrency::FILTER_PREFIX . 'should_convert_product_price', true, $product );
}
/**
* Determines if the store currency should be returned or not.
*
* @return bool
*/
public function should_return_store_currency(): bool {
return apply_filters( MultiCurrency::FILTER_PREFIX . 'should_return_store_currency', false );
}
/**
* This filter is called when the best sales day logic is called. We use it to add another filter which will
* convert the order prices used in this inbox notification.
*
* @param bool $arg Whether or not the best sales day logic should execute. We will just return this as is to
* respect the existing behaviour.
*
* @return bool
*/
public function attach_order_modifier( $arg ) {
// Attach our filter to modify the order prices.
add_filter( 'woocommerce_order_query', [ $this, 'convert_order_prices' ] );
// This will be a bool value indication whether the best day logic should be run. Let's just return it as is.
return $arg;
}
/**
* When a request is made by the "Best Sales Day" Inbox notification, we want to hook into this and convert
* the order totals to the store default currency.
*
* @param WC_Order[]|WC_Order_Refund[] $results The results returned by the orders query.
*
* @return array|object of WC_Order objects
*/
public function convert_order_prices( $results ) {
$backtrace_calls = [
'Automattic\WooCommerce\Admin\Notes\NewSalesRecord::sum_sales_for_date',
'Automattic\WooCommerce\Admin\Notes\NewSalesRecord::possibly_add_note',
];
// If the results are not an array, or if the call we're expecting isn't in the backtrace, then just do nothing and return the results.
if ( ! is_array( $results ) || ! $this->utils->is_call_in_backtrace( $backtrace_calls ) ) {
return $results;
}
$default_currency = $this->multi_currency->get_default_currency();
if ( ! $default_currency ) {
return $results;
}
foreach ( $results as $order ) {
if ( ! $order ||
$order->get_currency() === $default_currency->get_code() ||
! $order->get_meta( '_wcpay_multi_currency_order_exchange_rate', true ) ||
$order->get_meta( '_wcpay_multi_currency_order_default_currency', true ) !== $default_currency->get_code()
) {
continue;
}
$exchange_rate = $order->get_meta( '_wcpay_multi_currency_order_exchange_rate', true );
$order->set_total( number_format( $order->get_total() * ( 1 / $exchange_rate ), wc_get_price_decimals() ) );
}
remove_filter( 'woocommerce_order_query', [ $this, 'convert_order_prices' ] );
return $results;
}
}
@@ -0,0 +1,50 @@
<?php
/**
* Class BaseCompatibility
*
* @package WCPay\MultiCurrency\Compatibility
*/
namespace WCPay\MultiCurrency\Compatibility;
use WCPay\MultiCurrency\MultiCurrency;
use WCPay\MultiCurrency\Utils;
/**
* Class that sets up base options for compatibility classes.
*/
abstract class BaseCompatibility {
/**
* MultiCurrency class.
*
* @var MultiCurrency
*/
protected $multi_currency;
/**
* Utils class.
*
* @var Utils
*/
protected $utils;
/**
* Constructor.
*
* @param MultiCurrency $multi_currency MultiCurrency class.
* @param Utils $utils Utils class.
*/
public function __construct( MultiCurrency $multi_currency, Utils $utils ) {
$this->multi_currency = $multi_currency;
$this->utils = $utils;
$this->init();
}
/**
* Init the class.
*
* @return void
*/
abstract public function init();
}
@@ -0,0 +1,175 @@
<?php
/**
* Class WooCommerceBookings
*
* @package WCPay\MultiCurrency\Compatibility
*/
namespace WCPay\MultiCurrency\Compatibility;
use WCPay\MultiCurrency\FrontendCurrencies;
use WCPay\MultiCurrency\MultiCurrency;
use WCPay\MultiCurrency\Utils;
use WC_Product;
/**
* Class that controls Multi Currency Compatibility with WooCommerce Bookings Plugin.
*/
class WooCommerceBookings extends BaseCompatibility {
/**
* Front-end currencies.
*
* @var FrontendCurrencies
*/
private $frontend_currencies;
/**
* Constructor.
*
* @param MultiCurrency $multi_currency MultiCurrency class.
* @param Utils $utils Utils class.
* @param FrontendCurrencies $frontend_currencies FrontendCurrencies class.
*/
public function __construct( MultiCurrency $multi_currency, Utils $utils, FrontendCurrencies $frontend_currencies ) {
parent::__construct( $multi_currency, $utils );
$this->frontend_currencies = $frontend_currencies;
}
/**
* Init the class.
*
* @return void
*/
public function init() {
// Add needed actions and filters if Bookings is active.
if ( class_exists( 'WC_Bookings' ) ) {
if ( ! is_admin() || wp_doing_ajax() ) {
add_filter( 'woocommerce_bookings_calculated_booking_cost', [ $this, 'adjust_amount_for_calculated_booking_cost' ], 50, 1 );
add_filter( 'woocommerce_product_get_block_cost', [ $this, 'get_price' ], 50, 1 );
add_filter( 'woocommerce_product_get_cost', [ $this, 'get_price' ], 50, 1 );
add_filter( 'woocommerce_product_get_display_cost', [ $this, 'get_price' ], 50, 1 );
add_filter( 'woocommerce_product_booking_person_type_get_block_cost', [ $this, 'get_price' ], 50, 1 );
add_filter( 'woocommerce_product_booking_person_type_get_cost', [ $this, 'get_price' ], 50, 1 );
add_filter( 'woocommerce_product_get_resource_base_costs', [ $this, 'get_resource_prices' ], 50, 1 );
add_filter( 'woocommerce_product_get_resource_block_costs', [ $this, 'get_resource_prices' ], 50, 1 );
add_filter( MultiCurrency::FILTER_PREFIX . 'should_convert_product_price', [ $this, 'should_convert_product_price' ], 50, 2 );
add_action( 'wp_ajax_wc_bookings_calculate_costs', [ $this, 'add_wc_price_args_filter_for_ajax' ], 9 );
add_action( 'wp_ajax_nopriv_wc_bookings_calculate_costs', [ $this, 'add_wc_price_args_filter_for_ajax' ], 9 );
}
}
}
/**
* Adjusts the calculated booking cost for the selected currency, applying rounding and charm pricing as necessary.
*
* @param mixed $costs The original calculated booking costs.
* @return mixed The booking cost adjusted for the selected currency.
*/
public function adjust_amount_for_calculated_booking_cost( $costs ) {
/**
* Prevents adjustment of the calculated booking cost during cart addition.
*
* When a booking is added to the cart, the Booking plugin calculates the booking cost and
* overrides the cart item price with this calculated amount. To avoid interfering with this process,
* this function skips any additional adjustments at this stage.
*/
if ( $this->utils->is_call_in_backtrace( [ 'WC_Cart->add_to_cart' ] ) ) {
return $costs;
}
return $this->multi_currency->adjust_amount_for_selected_currency( $costs );
}
/**
* Retrieves the price for an item, converting it based on the selected currency and context.
*
* @param mixed $price The item's price.
*
* @return mixed The converted item's price.
*/
public function get_price( $price ) {
if ( ! $price ) {
return $price;
}
// Skip conversion during specific booking cost calculations to avoid double conversion.
if ( $this->utils->is_call_in_backtrace( [ 'WC_Cart->add_to_cart' ] ) && $this->utils->is_call_in_backtrace( [ 'WC_Bookings_Cost_Calculation::calculate_booking_cost' ] ) ) {
return $price;
}
/**
* When showing the price in HTML, the function applies currency conversion, charm pricing,
* and rounding. For internal calculations, it uses the raw exchange rate, with charm pricing
* and rounding adjustments applied only to the final calculated amount (handled in
* adjust_amount_for_calculated_booking_cost).
*/
return $this->multi_currency->get_price( $price, $this->utils->is_call_in_backtrace( [ 'WC_Product_Booking->get_price_html' ] ) ? 'product' : 'exchange_rate' );
}
/**
* Returns the prices for a resource.
*
* @param mixed $prices The resource's prices in array format.
*
* @return mixed The converted resource's prices.
*/
public function get_resource_prices( $prices ) {
if ( is_array( $prices ) ) {
foreach ( $prices as $key => $price ) {
$prices[ $key ] = $this->get_price( $price );
}
}
return $prices;
}
/**
* Checks to see if the product's price should be converted.
*
* @param bool $return Whether to convert the product's price or not. Default is true.
* @param WC_Product $product The product instance being checked.
*
* @return bool True if it should be converted.
*/
public function should_convert_product_price( bool $return, WC_Product $product ): bool {
// If it's already false, or the product is not a booking, ignore it.
if ( ! $return || $product->get_type() !== 'booking' ) {
return $return;
}
// Fixes price display on product page and in shop.
if ( $this->utils->is_call_in_backtrace( [ 'WC_Product_Booking->get_price_html' ] ) ) {
return false;
}
return $return;
}
/**
* Adds a filter for when there is an ajax call to calculate the booking cost.
*
* @return void
*/
public function add_wc_price_args_filter_for_ajax() {
add_filter( 'wc_price_args', [ $this, 'filter_wc_price_args' ], 100 );
}
/**
* Returns the formatting arguments to use when a booking price is calculated on the product.
*
* @param array $args Original args from wc_price().
*
* @return array New arguments matching the selected currency.
*/
public function filter_wc_price_args( $args ): array {
return wp_parse_args(
[
'currency' => $this->multi_currency->get_selected_currency()->get_code(),
'decimal_separator' => $this->frontend_currencies->get_price_decimal_separator( $args['decimal_separator'] ),
'thousand_separator' => $this->frontend_currencies->get_price_thousand_separator( $args['thousand_separator'] ),
'decimals' => $this->frontend_currencies->get_price_decimals( $args['decimals'] ),
'price_format' => $this->frontend_currencies->get_woocommerce_price_format( $args['price_format'] ),
],
$args
);
}
}
@@ -0,0 +1,148 @@
<?php
/**
* Class WooCommerceDeposits
*
* @package WCPay\MultiCurrency\Compatibility
*/
namespace WCPay\MultiCurrency\Compatibility;
use WC_Product;
use WCPay\MultiCurrency\MultiCurrency;
/**
* Class that controls Multi Currency Compatibility with WooCommerce Deposits Plugin.
*/
class WooCommerceDeposits extends BaseCompatibility {
/**
* Init the class.
*
* @return void
*/
public function init() {
if ( class_exists( 'WC_Deposits' ) ) {
/*
* Multi-currency support was added to WooCommerce Deposits in version 2.0.1.
*
* This prevents the loading of the compatibility class for Deposits in versions
* of Deposits that support multi-currency.
*
* @see https://github.com/woocommerce/woocommerce-deposits/pull/425
* @see https://github.com/woocommerce/woocommerce-deposits/issues/506
*/
if ( version_compare( WC_DEPOSITS_VERSION, '2.0.1', '>=' ) ) {
return;
}
// Add compatibility filters here.
add_action( 'woocommerce_deposits_create_order', [ $this, 'modify_order_currency' ] );
add_filter( 'woocommerce_get_cart_contents', [ $this, 'modify_cart_item_deposit_amounts' ] );
add_filter( 'woocommerce_product_get__wc_deposit_amount', [ $this, 'modify_cart_item_deposit_amount_meta' ], 10, 2 );
add_filter( MultiCurrency::FILTER_PREFIX . 'should_convert_product_price', [ $this, 'maybe_convert_product_prices_for_deposits' ], 10, 2 );
}
}
/**
* Converts the currency for deposit amounts of each cart item, only applies if deposits are enabled on the product.
*
* @param array $cart_contents The items on the cart.
*
* @return array
*/
public function modify_cart_item_deposit_amounts( $cart_contents ) {
foreach ( $cart_contents as $cart_item_key => $cart_item ) {
if ( ! empty( $cart_item['is_deposit'] ) && isset( $cart_item['deposit_amount'] ) ) {
$deposit_amount = (float) $cart_item['deposit_amount'];
$cart_contents[ $cart_item_key ]['deposit_amount'] = $this->multi_currency->get_price( $deposit_amount, 'product' );
}
}
return $cart_contents;
}
/**
* Converts the currency for deposit amounts of each cart item, only applies if deposits are enabled on the product.
*
* @param float $amount The amount to convert.
* @param \WC_Product $product The product to check for.
*
* @return float
*/
public function modify_cart_item_deposit_amount_meta( $amount, $product ) {
if ( 'percent' === $this->get_product_deposit_type( $product ) && $this->utils->is_call_in_backtrace( [ 'WC_Deposits_Cart_Manager->deposits_form_output' ] ) ) {
return $this->multi_currency->get_price( $amount, 'product' );
}
return $amount;
}
/**
* Defines if the product prices need to be converted when calculating totals,
* if the product's deposit type is a payment plan, then it shouldn't convert it.
*
* @param bool $result The previous flag for converting the price.
* @param \WC_Product $product The product to check for.
*
* @return bool Whether the price should be converted or not.
*/
public function maybe_convert_product_prices_for_deposits( $result, $product ) {
if ( 'plan' === $this->get_product_deposit_type( $product ) && $this->utils->is_call_in_backtrace( [ 'WC_Cart->calculate_totals' ] ) ) {
return false;
}
return $result;
}
/**
* When creating a new order for the remaining amount, forces the new order currency
* to be the same with the deposited order currency.
*
* @param integer $order_id The created order ID.
*
* @return void
*/
public function modify_order_currency( $order_id ) {
// We need to get the original order from the first item meta.
$order = wc_get_order( $order_id );
$order_items = $order->get_items();
$first_order_item = 0 < ( is_countable( $order_items ) ? count( $order_items ) : 0 ) ? reset( $order_items ) : null;
if ( ! $first_order_item ) {
return;
}
// Check if the original order ID is attached to the order item.
$original_order_id = wc_get_order_item_meta( $first_order_item->get_id(), '_original_order_id', true );
if ( ! $original_order_id ) {
return;
}
// Check if the original order still exists.
$original_order = wc_get_order( $original_order_id );
if ( ! $original_order ) {
return;
}
// Get the new and old order currencies, and match them if unmatched.
$saved_currency = $order->get_currency( 'view' );
$original_currency = $original_order->get_currency( 'view' );
if ( $saved_currency !== $original_currency ) {
$order->set_currency( $original_currency );
$order->save();
}
}
/**
* Gets the deposit type of a product if deposits are enabled for the product.
*
* @param \WC_Product $product The product to check.
*
* @return string|false The product deposit type if deposits are enabled on it, or false.
*/
private function get_product_deposit_type( $product ) {
$product_has_deposit = class_exists( 'WC_Deposits_Product_Manager' ) && call_user_func( [ 'WC_Deposits_Product_Manager', 'deposits_enabled' ], $product );
if ( $product_has_deposit ) {
return call_user_func( [ 'WC_Deposits_Product_Manager', 'get_deposit_type' ], $product );
}
return false;
}
}
@@ -0,0 +1,82 @@
<?php
/**
* Class WooCommerceFedEx
*
* @package WCPay\MultiCurrency\Compatibility
*/
namespace WCPay\MultiCurrency\Compatibility;
use WCPay\MultiCurrency\MultiCurrency;
/**
* Class that controls Multi Currency Compatibility with WooCommerce FedEx Plugin.
*/
class WooCommerceFedEx extends BaseCompatibility {
/**
* Calls to look for in the backtrace when determining whether
* to return store currency or skip converting product prices.
*/
private const WC_SHIPPING_FEDEX_CALLS = [
'WC_Shipping_Fedex->set_settings',
'WC_Shipping_Fedex->per_item_shipping',
'WC_Shipping_Fedex->box_shipping',
'WC_Shipping_Fedex->get_fedex_api_request',
'WC_Shipping_Fedex->get_fedex_requests',
'WC_Shipping_Fedex->process_result',
];
/**
* Init the class.
*
* @return void
*/
public function init() {
// Add needed actions and filters if FedEx is active.
if ( class_exists( 'WC_Shipping_Fedex_Init' ) ) {
add_filter( MultiCurrency::FILTER_PREFIX . 'should_convert_product_price', [ $this, 'should_convert_product_price' ] );
add_filter( MultiCurrency::FILTER_PREFIX . 'should_return_store_currency', [ $this, 'should_return_store_currency' ] );
}
}
/**
* Checks to see if the product's price should be converted.
*
* @param bool $return Whether to convert the product's price or not. Default is true.
*
* @return bool True if it should be converted.
*/
public function should_convert_product_price( bool $return ): bool {
// If it's already false, return it.
if ( ! $return ) {
return $return;
}
if ( $this->utils->is_call_in_backtrace( self::WC_SHIPPING_FEDEX_CALLS ) ) {
return false;
}
return $return;
}
/**
* Determine whether to return the store currency or not.
*
* @param bool $return Whether to return the store currency or not.
*
* @return bool
*/
public function should_return_store_currency( bool $return ): bool {
// If it's already true, return it.
if ( $return ) {
return $return;
}
if ( $this->utils->is_call_in_backtrace( self::WC_SHIPPING_FEDEX_CALLS ) ) {
return true;
}
return $return;
}
}
@@ -0,0 +1,179 @@
<?php
/**
* Class WooCommerceNameYourPrice
*
* @package WCPay\MultiCurrency\Compatibility
*/
namespace WCPay\MultiCurrency\Compatibility;
use WCPay\MultiCurrency\MultiCurrency;
/**
* Class that controls Multi Currency Compatibility with WooCommerce Name Your Price Plugin.
*/
class WooCommerceNameYourPrice extends BaseCompatibility {
const NYP_CURRENCY = '_wcpay_multi_currency_nyp_currency';
/**
* Init the class.
*
* @return void
*/
public function init() {
// Add needed actions and filters if Name Your Price is active.
if ( class_exists( 'WC_Name_Your_Price' ) ) {
// Convert meta prices.
add_filter( 'wc_nyp_raw_minimum_price', [ $this, 'get_nyp_prices' ] );
add_filter( 'wc_nyp_raw_maximum_price', [ $this, 'get_nyp_prices' ] );
add_filter( 'wc_nyp_raw_suggested_price', [ $this, 'get_nyp_prices' ] );
// Maybe translate cart prices.
add_action( 'woocommerce_add_cart_item_data', [ $this, 'add_initial_currency' ], 20, 3 );
add_filter( 'woocommerce_get_cart_item_from_session', [ $this, 'convert_cart_currency' ], 20, 2 );
add_filter( MultiCurrency::FILTER_PREFIX . 'should_convert_product_price', [ $this, 'should_convert_product_price' ], 50, 2 );
// Convert cart editing price.
add_filter( 'wc_nyp_edit_in_cart_args', [ $this, 'edit_in_cart_args' ], 10, 2 );
add_filter( 'wc_nyp_get_initial_price', [ $this, 'get_initial_price' ], 10, 3 );
}
}
/**
* Converts the min/max/suggested prices of Name Your Price extension.
*
* @param mixed $price The price to be filtered.
* @return mixed The price as a string or float.
*/
public function get_nyp_prices( $price ) {
return ! $price ? $price : $this->multi_currency->get_price( $price, 'product' );
}
/**
* Store the inintial currency when item is added.
*
* @param array $cart_item Extra cart item data being passed to the cart item.
* @param int $product_id The id of the product being added to the cart.
* @param int $variation_id The id of the variation being added to the cart.
*
* @return array
*/
public function add_initial_currency( $cart_item, $product_id, $variation_id ) {
$nyp_id = $variation_id ? $variation_id : $product_id;
if ( class_exists( '\WC_Name_Your_Price_Helpers' ) && \WC_Name_Your_Price_Helpers::is_nyp( $nyp_id ) && isset( $cart_item['nyp'] ) ) {
$currency = $this->multi_currency->get_selected_currency();
$cart_item['nyp_currency'] = $currency->get_code();
$cart_item['nyp_original'] = $cart_item['nyp'];
}
return $cart_item;
}
/**
* Switch the cart price when currency changes.
* Do not convert price if in the same currency as when added to the cart.
* This prevevents USD > EUR > USD style conversions and potential rounding problems.
*
* @param array $cart_item Cart item array.
* @param array $values Cart item values e.g. quantity and product_id.
*
* @return array
*/
public function convert_cart_currency( $cart_item, $values ) {
if ( isset( $cart_item['nyp_original'] ) && isset( $cart_item['nyp_currency'] ) ) {
// Store the original currency in $product meta.
$cart_item['data']->update_meta_data( self::NYP_CURRENCY, $cart_item['nyp_currency'] );
$selected_currency = $this->multi_currency->get_selected_currency();
// If the currency is currently the same as at time price entered, restore NYP to original value.
if ( $cart_item['nyp_currency'] === $selected_currency->get_code() ) {
$cart_item['nyp'] = $cart_item['nyp_original'];
} else {
$from_currency = $cart_item['nyp_currency'];
$raw_price = $cart_item['nyp_original'];
$cart_item['nyp'] = $this->multi_currency->get_raw_conversion( $raw_price, $selected_currency->get_code(), $from_currency );
}
// @phpstan-ignore-next-line.
$cart_item = WC_Name_Your_Price()->cart->set_cart_item( $cart_item );
}
return $cart_item;
}
/**
* Checks to see if the product's price should be converted.
*
* @param bool $return Whether to convert the product's price or not. Default is true.
* @param object $product Product object to test.
*
* @return bool True if it should be converted.
*/
public function should_convert_product_price( bool $return, $product ): bool {
// If it's already false, return it.
if ( ! $return ) {
return $return;
}
$currency = $this->multi_currency->get_selected_currency();
// Check for cart items to see if they are in the original currency.
if ( $currency->get_code() === $product->get_meta( self::NYP_CURRENCY ) ) {
$return = false;
}
// Check to see if the product is a NYP product.
if ( class_exists( '\WC_Name_Your_Price_Helpers' ) && \WC_Name_Your_Price_Helpers::is_nyp( $product ) ) {
return false;
}
return $return;
}
/**
* Add currency to cart edit link.
*
* @param array $args The cart args.
* @param array $cart_item The current cart item.
*
* @return array
*/
public function edit_in_cart_args( $args, $cart_item ) {
$args['nyp_currency'] = $this->multi_currency->get_selected_currency()->get_code();
return $args;
}
/**
* Maybe convert any prices being edited from the cart
*
* @param string $initial_price The initial price.
* @param mixed $product The product being queried.
* @param string $suffix The suffix needed for composites and bundles.
*
* @return float|string
*/
public function get_initial_price( $initial_price, $product, $suffix ) {
if ( isset( $_REQUEST[ 'nyp_raw' . $suffix ] ) && isset( $_REQUEST['nyp_currency'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
$from_currency = wc_clean( wp_unslash( $_REQUEST['nyp_currency'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
$raw_price = (float) wc_clean( wp_unslash( $_REQUEST[ 'nyp_raw' . $suffix ] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
$selected_currency = $this->multi_currency->get_selected_currency();
if ( $from_currency !== $selected_currency->get_code() ) {
$initial_price = $this->multi_currency->get_raw_conversion( $raw_price, $selected_currency->get_code(), $from_currency );
}
}
return $initial_price;
}
}
@@ -0,0 +1,88 @@
<?php
/**
* Class WooCommercePointsAndRewards
*
* @package WCPay\MultiCurrency\Compatibility
*/
namespace WCPay\MultiCurrency\Compatibility;
use WCPay\MultiCurrency\Currency;
/**
* Class that controls Multi Currency Compatibility with WooCommerce Points & Rewards Plugin.
*/
class WooCommercePointsAndRewards extends BaseCompatibility {
/**
* Default Currency Code.
*
* @var string
*/
private $default_currency_code;
/**
* Selected Currency Code.
*
* @var Currency
*/
private $selected_currency;
/**
* Init the class.
*
* @return void
*/
public function init() {
// Add needed filters if Points & Rewards is active and it's not an admin request.
if ( is_admin() || ! class_exists( 'WC_Points_Rewards' ) ) {
return;
}
add_filter( 'option_wc_points_rewards_earn_points_ratio', [ $this, 'convert_points_ratio' ], 50 );
add_filter( 'option_wc_points_rewards_redeem_points_ratio', [ $this, 'convert_points_ratio' ], 50 );
}
/**
* Converts points ratio applying selected currency rate to the monetary value.
*
* @param string $ratio Store currency points ratio.
* @return string Converted points ratio.
*/
public function convert_points_ratio( string $ratio = '' ): string {
// Skip conversion if selected and default currencies are the same.
if ( $this->selected_and_default_currency_match() ) {
return $ratio;
}
// Skip conversion on discount to avoid doing it twice.
if ( $this->utils->is_call_in_backtrace( [ 'WC_Points_Rewards_Discount->get_discount_data' ] ) ) {
return $ratio;
}
$ratio = explode( ':', $ratio );
$points = (float) ( $ratio[0] ?? 0 );
$value = (float) ( $ratio[1] ?? 0 );
$rate = $this->selected_currency->get_rate();
$value = $value * $rate;
return "$points:$value";
}
/**
* Whether the selected and default currency are the same.
*
* @return boolean
*/
private function selected_and_default_currency_match(): bool {
if ( empty( $this->default_currency_code ) ) {
$this->default_currency_code = $this->multi_currency->get_default_currency()->get_code();
}
if ( empty( $this->selected_currency ) ) {
$this->selected_currency = $this->multi_currency->get_selected_currency();
}
return $this->default_currency_code === $this->selected_currency->get_code();
}
}
@@ -0,0 +1,41 @@
<?php
/**
* Class WooCommercePreOrders
*
* @package WCPay\MultiCurrency\Compatibility
*/
namespace WCPay\MultiCurrency\Compatibility;
use WCPay\MultiCurrency\MultiCurrency;
use WCPay\MultiCurrency\Utils;
/**
* Class that controls Multi Currency Compatibility with WooCommerce Pre-Orders Plugin.
*/
class WooCommercePreOrders extends BaseCompatibility {
/**
* Init the class.
*
* @return void
*/
public function init() {
// Add needed actions and filters if Pre-Orders is active.
if ( class_exists( 'WC_Pre_Orders' ) ) {
add_filter( 'wc_pre_orders_fee', [ $this, 'wc_pre_orders_fee' ] );
}
}
/**
* Convert the Pre-Orders fee.
*
* @param array $args Array of args for the Pre-Orders fee.
*
* @return array
*/
public function wc_pre_orders_fee( array $args ): array {
$args['amount'] = $this->multi_currency->get_price( $args['amount'], 'product' );
return $args;
}
}
@@ -0,0 +1,331 @@
<?php
/**
* Class WooCommerceProductAddOns
*
* @package WCPay\MultiCurrency\Compatibility
*/
namespace WCPay\MultiCurrency\Compatibility;
use WC_Product;
use WCPay\MultiCurrency\MultiCurrency;
use WCPay\MultiCurrency\Utils;
/**
* Class that controls Multi Currency Compatibility with WooCommerce Product Add Ons Plugin.
*/
class WooCommerceProductAddOns extends BaseCompatibility {
const ADDONS_CONVERTED_META_KEY = '_wcpay_multi_currency_addons_converted';
/**
* Init the class.
*
* @return void
*/
public function init() {
// Add needed actions and filters if Product Add Ons is active.
if ( class_exists( 'WC_Product_Addons' ) ) {
if ( ! is_admin() && ! defined( 'DOING_CRON' ) ) {
add_filter( 'woocommerce_product_addons_option_price_raw', [ $this, 'get_addons_price' ], 50, 2 );
add_filter( 'woocommerce_product_addons_price_raw', [ $this, 'get_addons_price' ], 50, 2 );
add_filter( 'woocommerce_product_addons_params', [ $this, 'product_addons_params' ], 50, 1 );
add_filter( 'woocommerce_product_addons_get_item_data', [ $this, 'get_item_data' ], 50, 3 );
add_filter( 'woocommerce_product_addons_update_product_price', [ $this, 'update_product_price' ], 50, 4 );
add_filter( 'woocommerce_product_addons_order_line_item_meta', [ $this, 'order_line_item_meta' ], 50, 4 );
add_filter( MultiCurrency::FILTER_PREFIX . 'should_convert_product_price', [ $this, 'should_convert_product_price' ], 50, 2 );
}
if ( wp_doing_ajax() ) {
add_filter( 'woocommerce_product_addons_ajax_get_product_price_including_tax', [ $this, 'get_product_calculation_price' ], 50, 3 );
add_filter( 'woocommerce_product_addons_ajax_get_product_price_excluding_tax', [ $this, 'get_product_calculation_price' ], 50, 3 );
}
}
}
/**
* Checks to see if the product's price should be converted.
*
* @param bool $return Whether to convert the product's price or not. Default is true.
* @param object $product Product object to test.
*
* @return bool True if it should be converted.
*/
public function should_convert_product_price( bool $return, $product ): bool {
// If it's already false, return it.
if ( ! $return ) {
return $return;
}
// Check for cart items to see if they have already been converted.
if ( 1 === $product->get_meta( self::ADDONS_CONVERTED_META_KEY ) ) {
return false;
}
return $return;
}
/**
* Converts the price of an addon from WooCommerce Products Add-on extension.
*
* @param mixed $price The price to be filtered.
* @param array $type The type of the addon.
* @return mixed The price as a string or float.
*/
public function get_addons_price( $price, $type ) {
if ( 'percentage_based' === $type['price_type'] ) {
// If the addon is a percentage_based type $price is actually a percentage
// and doesn't need any conversion.
return $price;
}
return $this->multi_currency->get_price( $price, 'product' );
}
/**
* Fixes currency formatting issues in Product Add-Ons. PAO gets these values directly from the db options,
* so those values aren't filtered. Luckily, there's a filter.
*
* @param array $params Product Add-Ons global parameters.
*
* @return array Adjust parameters.
*/
public function product_addons_params( array $params ): array {
$params['currency_format_num_decimals'] = wc_get_price_decimals();
$params['currency_format_decimal_sep'] = wc_get_price_decimal_separator();
$params['currency_format_thousand_sep'] = wc_get_price_thousand_separator();
return $params;
}
/**
* Filters the cart item data meta so we can provide the proper name with converted add on price.
*
* @param array $addon_data The addon data we are filtering/replacing.
* @param array $addon The addon being processed.
* @param array $cart_item The cart item being processed.
*
* @return array
*/
public function get_item_data( $addon_data, $addon, $cart_item ): array {
$price = isset( $cart_item['addons_price_before_calc'] ) ? $cart_item['addons_price_before_calc'] : $addon['price'];
$value = $addon['value'];
/*
* 'woocommerce_addons_add_cart_price_to_value'
*
* Use this filter to display the price next to each selected add-on option.
* By default, add-on prices show up only next to flat fee add-ons.
*
* @param boolean
*/
$add_price_to_value = apply_filters( 'woocommerce_addons_add_cart_price_to_value', false, $cart_item );
if ( 0.0 === (float) $addon['price'] ) {
$value .= '';
} elseif ( 'percentage_based' === $addon['price_type'] && 0.0 === (float) $price ) {
$value .= '';
} elseif ( 'custom_price' === $addon['field_type'] && $addon['price'] ) {
if ( class_exists( 'WC_Product_Addons_Helper' ) ) {
$addon_price = wc_price( \WC_Product_Addons_Helper::get_product_addon_price_for_display( $addon['price'], $cart_item['data'] ) );
/* translators: %1$s custom addon price in cart */
$value .= sprintf( _x( ' (%1$s)', 'custom price addon price in cart', 'woocommerce-payments' ), $addon_price );
$addon['display'] = $value;
}
} elseif ( 'flat_fee' === $addon['price_type'] && $addon['price'] ) {
if ( class_exists( 'WC_Product_Addons_Helper' ) ) {
$addon_price = $this->multi_currency->get_price( $addon['price'], 'product' );
if ( 'input_multiplier' === $addon['field_type'] ) {
// Quantity/multiplier add on needs to be split, calculated, then multiplied by input value.
$addon_price = $this->multi_currency->get_price( $addon['price'] / $addon['value'], 'product' ) * $addon['value'];
}
$addon_price = wc_price( \WC_Product_Addons_Helper::get_product_addon_price_for_display( $addon_price, $cart_item['data'] ) );
/* translators: %1$s flat fee addon price in order */
$value .= sprintf( _x( ' (+ %1$s)', 'flat fee addon price in cart', 'woocommerce-payments' ), $addon_price );
}
} elseif ( 'quantity_based' === $addon['price_type'] && $addon['price'] && $add_price_to_value ) {
if ( class_exists( 'WC_Product_Addons_Helper' ) ) {
$addon_price = $this->multi_currency->get_price( $addon['price'], 'product' );
if ( 'input_multiplier' === $addon['field_type'] ) {
// Quantity/multiplier add on needs to be split, calculated, then multiplied by input value.
$addon_price = $this->multi_currency->get_price( $addon['price'] / $addon['value'], 'product' ) * $addon['value'];
}
$addon_price = wc_price( \WC_Product_Addons_Helper::get_product_addon_price_for_display( $addon_price, $cart_item['data'] ) );
/* translators: %1$s addon price in order */
$value .= sprintf( _x( ' (%1$s)', 'quantity based addon price in cart', 'woocommerce-payments' ), $addon_price );
}
} elseif ( 'percentage_based' === $addon['price_type'] && $addon['price'] && $add_price_to_value ) {
// Get the percentage cost in the currency in use, and set the meta data on the product that the value was converted.
$_product = wc_get_product( $cart_item['product_id'] );
$price = $this->multi_currency->get_price( $price, 'product' );
$_product->set_price( $price * ( $addon['price'] / 100 ) );
$_product->update_meta_data( self::ADDONS_CONVERTED_META_KEY, 1 );
/* translators: %1$s addon price in order */
$value .= sprintf( _x( ' (%1$s)', 'percentage based addon price in cart', 'woocommerce-payments' ), WC()->cart->get_product_price( $_product ) );
}
return [
'name' => $addon['name'],
'value' => $value,
'display' => isset( $addon['display'] ) ? $addon['display'] : '',
];
}
/**
* Updates the product price according to converted add on values.
*
* @param array $updated_prices Prices updated by Product Add-Ons (unused).
* @param array $cart_item Cart item meta data.
* @param array $prices Original prices passed to Product Add-Ons for calculations.
*
* @return array
*/
public function update_product_price( $updated_prices, $cart_item, $prices ): array {
$price = $this->multi_currency->get_price( $prices['price'], 'product' );
$regular_price = $this->multi_currency->get_price( $prices['regular_price'], 'product' );
$sale_price = $this->multi_currency->get_price( $prices['sale_price'], 'product' );
$flat_fees = 0;
$quantity = $cart_item['quantity'];
$price_before_addons = $price;
$regular_price_before_addons = $regular_price;
$sale_price_before_addons = $sale_price;
// TODO: Check compat with Smart Coupons.
// Compatibility with Smart Coupons self declared gift amount purchase.
$credit_called = ! empty( $_POST['credit_called'] ) ? $_POST['credit_called'] : null; // phpcs:ignore
if ( empty( $price ) && ! empty( $credit_called ) ) {
// Variable $_POST['credit_called'] is an array.
if ( isset( $credit_called[ $cart_item['data']->get_id() ] ) ) {
$price = (float) $credit_called[ $cart_item['data']->get_id() ];
$regular_price = $price;
$sale_price = $price;
}
}
if ( empty( $price ) && ! empty( $cart_item['credit_amount'] ) ) {
$price = (float) $cart_item['credit_amount'];
$regular_price = $price;
$sale_price = $price;
}
foreach ( $cart_item['addons'] as $addon ) {
// Percentage based and custom defined addon prices do not get converted, all others do.
if ( 'percentage_based' === $addon['price_type'] || 'custom_price' === $addon['field_type'] ) {
$addon_price = $addon['price'];
} elseif ( 'input_multiplier' === $addon['field_type'] ) {
// Quantity/multiplier add on needs to be split, calculated, then multiplied by input value.
$addon_price = $this->multi_currency->get_price( $addon['price'] / $addon['value'], 'product' ) * $addon['value'];
} else {
$addon_price = $this->multi_currency->get_price( $addon['price'], 'product' );
}
switch ( $addon['price_type'] ) {
case 'percentage_based':
$price += (float) ( $price_before_addons * ( $addon_price / 100 ) );
$regular_price += (float) ( $regular_price_before_addons * ( $addon_price / 100 ) );
$sale_price += (float) ( $sale_price_before_addons * ( $addon_price / 100 ) );
break;
case 'flat_fee':
$flat_fee = $quantity > 0 ? (float) ( $addon_price / $quantity ) : 0;
$price += $flat_fee;
$regular_price += $flat_fee;
$sale_price += $flat_fee;
$flat_fees += $flat_fee;
break;
default:
$price += (float) $addon_price;
$regular_price += (float) $addon_price;
$sale_price += (float) $addon_price;
break;
}
}
// Let ourselves know this item has had add ons converted.
$cart_item['data']->update_meta_data( self::ADDONS_CONVERTED_META_KEY, 1 );
return [
'price' => $price,
'regular_price' => $regular_price,
'sale_price' => $sale_price,
'addons_flat_fees_sum' => $flat_fees,
];
}
/**
* Filters the meta data for order line items so that we can properly set values in the names.
*
* @param array $meta_data A key/value for the meta data to be inserted for the line item.
* @param array $addon The addon being processed.
* @param \WC_Order_Item_Product $item Order item data.
* @param array $values Order item values.
*
* @return array A key/value for the meta data to be inserted for the line item.
*/
public function order_line_item_meta( array $meta_data, array $addon, \WC_Order_Item_Product $item, array $values ): array {
$add_price_to_value = apply_filters( 'woocommerce_addons_add_order_price_to_value', false, $item );
$value = $addon['value'];
// Pass the timestamp as the add-on value in order to save the timestamp to the DB.
if ( isset( $addon['timestamp'] ) ) {
$value = $addon['timestamp'];
}
// If there is an add-on price, add the price of the add-on to the label name.
if ( $addon['price'] && $add_price_to_value ) {
$product = $item->get_product();
if ( 'percentage_based' === $addon['price_type'] && 0.0 !== (float) $product->get_price() ) {
// Calculate the percentage price.
$addon_price = $product->get_price() * ( $addon['price'] / 100 );
} elseif ( 'custom_price' === $addon['field_type'] ) {
// Custom prices do not get converted.
$addon_price = $addon['price'];
} elseif ( 'input_multiplier' === $addon['field_type'] ) {
// Quantity/multiplier add on needs to be split, calculated, then multiplied by input value.
$addon_price = $this->multi_currency->get_price( $addon['price'] / $addon['value'], 'product' ) * $addon['value'];
} else {
// Convert all others.
$addon_price = $this->multi_currency->get_price( $addon['price'], 'product' );
}
if ( class_exists( 'WC_Product_Addons_Helper' ) ) {
$price = html_entity_decode(
wp_strip_all_tags( wc_price( \WC_Product_Addons_Helper::get_product_addon_price_for_display( $addon_price, $values['data'] ) ) ),
ENT_QUOTES,
get_bloginfo( 'charset' )
);
}
if ( 'flat_fee' === $addon['price_type'] && $addon['price'] && $add_price_to_value ) {
/* translators: %1$s flat fee addon price in order */
$value .= sprintf( _x( ' (+ %1$s)', 'flat fee addon price in order', 'woocommerce-payments' ), $price );
} elseif ( ( 'quantity_based' === $addon['price_type'] || 'percentage_based' === $addon['price_type'] ) && $addon['price'] && $add_price_to_value ) {
/* translators: %1$s addon price in order */
$value .= sprintf( _x( ' (%1$s)', 'addon price in order', 'woocommerce-payments' ), $price );
} elseif ( 'custom_price' === $addon['field_type'] ) {
/* translators: %1$s custom addon price in order */
$value = sprintf( _x( ' (%1$s)', 'custom addon price in order', 'woocommerce-payments' ), $price );
}
$meta_data['raw_price'] = $this->multi_currency->get_price( $addon['price'], 'product' );
}
$meta_data['value'] = $value;
return $meta_data;
}
/**
* Gets the product price during ajax requests from the product page.
*
* @param float $price Price to get converted.
* @param int $quantity Quantity of the product selected.
* @param \WC_Product $product WC_Product related to the price.
*
* @return float Adjusted price.
*/
public function get_product_calculation_price( float $price, int $quantity, \WC_Product $product ): float {
return $this->multi_currency->get_price( $price / $quantity, 'product' ) * $quantity;
}
}
@@ -0,0 +1,566 @@
<?php
/**
* Class WooCommerceSubscriptions
*
* @package WCPay\MultiCurrency\Compatibility
*/
namespace WCPay\MultiCurrency\Compatibility;
use WC_Subscription;
use WCPay\MultiCurrency\Logger;
use WCPay\MultiCurrency\FrontendCurrencies;
use WCPay\MultiCurrency\MultiCurrency;
/**
* Class that controls Multi Currency Compatibility with WooCommerce Subscriptions Plugin and WCPay Subscriptions.
*/
class WooCommerceSubscriptions extends BaseCompatibility {
/**
* Our allowed subscription types.
*/
const SUBSCRIPTION_TYPES = [ 'renewal', 'resubscribe', 'switch' ];
/**
* Subscription switch cart item.
*
* @var string
*/
public $switch_cart_item = '';
/**
* The current subscription being iterated through on the My Account > Subscriptions page.
*
* Tell Psalm to ignore the WC_Subscription class, this class is only loaded if Subscriptions is active.
*
* @psalm-suppress UndefinedDocblockClass
*
* @var WC_Subscription|null
*/
public $current_my_account_subscription = null;
/**
* The FrontendCurrencies object.
*
* @var FrontendCurrencies
*/
public $frontend_currencies;
/**
* If we are running through our filters.
*
* @var bool
*/
private $running_override_selected_currency_filters = false;
/**
* Init the class.
*
* @return void
*/
public function init() {
// Add needed actions and filters if WC Subscriptions or WCPay Subscriptions are active.
if ( class_exists( 'WC_Subscriptions' ) || class_exists( 'WC_Payments_Subscriptions' ) ) {
if ( ! is_admin() && ! defined( 'DOING_CRON' ) ) {
$this->frontend_currencies = $this->multi_currency->get_frontend_currencies();
add_filter( 'woocommerce_subscriptions_product_price', [ $this, 'get_subscription_product_price' ], 50, 2 );
add_filter( 'woocommerce_product_get__subscription_sign_up_fee', [ $this, 'get_subscription_product_signup_fee' ], 50, 2 );
add_filter( 'woocommerce_product_variation_get__subscription_sign_up_fee', [ $this, 'get_subscription_product_signup_fee' ], 50, 2 );
add_filter( 'option_woocommerce_subscriptions_multiple_purchase', [ $this, 'maybe_disable_mixed_cart' ], 50 );
add_filter( MultiCurrency::FILTER_PREFIX . 'override_selected_currency', [ $this, 'override_selected_currency' ], 50 );
add_filter( MultiCurrency::FILTER_PREFIX . 'should_convert_product_price', [ $this, 'should_convert_product_price' ], 50, 2 );
add_filter( MultiCurrency::FILTER_PREFIX . 'should_convert_coupon_amount', [ $this, 'should_convert_coupon_amount' ], 50, 2 );
add_filter( MultiCurrency::FILTER_PREFIX . 'should_disable_currency_switching', [ $this, 'should_disable_currency_switching' ], 50 );
add_filter( 'woocommerce_subscription_price_string_details', [ $this, 'maybe_set_current_my_account_subscription' ], 50, 2 );
add_filter( 'woocommerce_get_formatted_subscription_total', [ $this, 'maybe_clear_current_my_account_subscription' ], 50, 2 );
add_filter( 'wc_price', [ $this, 'maybe_get_explicit_format_for_subscription_total' ], 50, 5 );
}
}
}
/**
* Converts subscription prices, if needed.
*
* @param mixed $price The price to be filtered.
* @param object $product The product that will have a filtered price.
*
* @return mixed The price as a string or float.
*/
public function get_subscription_product_price( $price, $product ) {
if ( ! $price || ! $this->should_convert_product_price( true, $product ) ) {
return $price;
}
return $this->multi_currency->get_price( $price, 'product' );
}
/**
* Converts subscription sign up prices, if needed.
*
* @param mixed $price The price to be filtered.
* @param object $product The product that will have a filtered price.
*
* @return mixed The price as a string or float.
*/
public function get_subscription_product_signup_fee( $price, $product ) {
if ( ! $price ) {
return $price;
}
$item = $this->get_subscription_type_from_cart( 'switch' );
if ( $item ) {
$item_id = ! empty( $item['variation_id'] ) ? $item['variation_id'] : $item['product_id'];
$switch_cart_item = $this->switch_cart_item;
$this->switch_cart_item = $item['key'];
if ( $product->get_id() === $item_id ) {
/**
* These tests get mildly complex due to, when switching, the sign up fee is queried
* several times to determine prorated costs. This means we have to test to see when
* the fee actually needs be converted.
*/
if ( $this->utils->is_call_in_backtrace( [ 'WC_Subscriptions_Cart::set_subscription_prices_for_calculation' ] ) ) {
return $price;
}
// Check to see if it's currently determining prorated prices.
if ( $this->utils->is_call_in_backtrace( [ 'WC_Subscriptions_Product::get_sign_up_fee' ] )
&& $this->utils->is_call_in_backtrace( [ 'WC_Cart->calculate_totals' ] )
&& $item['key'] === $switch_cart_item
&& ! $this->utils->is_call_in_backtrace( [ 'WCS_Switch_Totals_Calculator->apportion_sign_up_fees' ] ) ) {
return $price;
}
// Check to see if the _subscription_sign_up_fee meta for the product has already been updated.
if ( $item['key'] === $switch_cart_item ) {
foreach ( $product->get_meta_data() as $meta ) {
if ( '_subscription_sign_up_fee' === $meta->get_data()['key'] && ! empty( $meta->get_changes() ) ) {
return $price;
}
}
}
}
}
return $this->multi_currency->get_price( $price, 'product' );
}
/**
* Disables the mixed cart if needed.
*
* @param string|bool $value Option from the database, or false.
*
* @return mixed False, yes, or no.
*/
public function maybe_disable_mixed_cart( $value ) {
// If there's a subscription switch in the cart, disable multiple items in the cart.
// This is so that subscriptions with different currencies cannot be added to the cart.
if ( $this->get_subscription_type_from_cart( 'switch' ) ) {
return 'no';
}
return $value;
}
/**
* Checks to see if the if the selected currency needs to be overridden.
*
* The running_override_selected_currency_filters property is used here to avoid infinite loops.
*
* @param mixed $return Default is false, but could be three letter currency code.
*
* @return mixed Three letter currency code or false if not.
*/
public function override_selected_currency( $return ) {
// If it's not false, or we are already running filters, exit.
if ( $return || $this->running_override_selected_currency_filters ) {
return $return;
}
// If we have a subscription in $current_my_account_subscription, we want to use the currency from that subscription.
if ( $this->is_current_my_account_subscription_set() ) {
/**
* Tell Psalm to ignore the WC_Subscription class, this class is only loaded if Subscriptions is active.
*
* @psalm-suppress UndefinedDocblockClass
*/
return $this->current_my_account_subscription->get_currency();
}
// Loop through subscription types and check for cart items.
foreach ( self::SUBSCRIPTION_TYPES as $type ) {
$cart_item = $this->get_subscription_type_from_cart( $type );
if ( $cart_item ) {
$this->running_override_selected_currency_filters = true;
// If we have a cart item, then we can get the order or subscription to pull the currency from.
$subscription_type = 'subscription_' . $type;
$subscription = $this->get_subscription( $cart_item[ $subscription_type ]['subscription_id'] );
$this->running_override_selected_currency_filters = false;
/**
* Tell Psalm to ignore the WC_Subscription class, this class is only loaded if Subscriptions is active.
*
* @psalm-suppress UndefinedDocblockClass
*/
return $subscription ? $subscription->get_currency() : $return;
}
}
// This instance is for when the customer lands on the product page to choose a new subscription tier.
$switch_subscription = $this->get_subscription_from_superglobal_switch_id();
/**
* Tell Psalm to ignore the WC_Subscription class, this class is only loaded if Subscriptions is active.
*
* @psalm-suppress UndefinedDocblockClass
*/
return $switch_subscription ? $switch_subscription->get_currency() : $return;
}
/**
* Checks to see if the product's price should be converted.
*
* @param bool $return Whether to convert the product's price or not. Default is true.
* @param object $product Product object to test.
*
* @return bool True if it should be converted.
*/
public function should_convert_product_price( bool $return, $product ): bool {
// If it's already false, return it.
if ( ! $return ) {
return $return;
}
$calls = [
'WC_Cart_Totals->calculate_item_totals',
'WC_Cart->get_product_subtotal',
'wc_get_price_excluding_tax',
'wc_get_price_including_tax',
];
// Check for renewal.
if ( $this->get_subscription_type_from_cart( 'renewal' ) ) {
// When WCPay Subs programmatically sets up the cart, we need to return the
// converted price so the user lands at the checkout with the correct price.
if ( $this->utils->is_call_in_backtrace( [ 'WCS_Cart_Renewal->setup_cart' ] ) ) {
return $return;
}
if ( $this->utils->is_call_in_backtrace( $calls ) ) {
return false;
}
}
// Check for resubscribe.
if ( $this->get_subscription_type_from_cart( 'resubscribe' )
&& $this->utils->is_call_in_backtrace( $calls ) ) {
return false;
}
// WCPay Subs does a check against the product price and the total, we need to return the actual product price for this check.
if ( $this->utils->is_call_in_backtrace( [ 'WC_Payments_Subscription_Service->get_recurring_item_data_for_subscription' ] )
&& $this->utils->is_call_in_backtrace( [ 'WC_Product->get_price' ] ) ) {
return false;
}
return $return;
}
/**
* Checks to see if the coupon's amount should be converted.
*
* @param bool $return Whether to convert the coupon's price or not. Default is true.
* @param object $coupon Coupon object to test.
*
* @return bool True if it should be converted.
*/
public function should_convert_coupon_amount( bool $return, $coupon ): bool {
// If it's already false, return it.
if ( ! $return ) {
return $return;
}
// We do not need to convert percentage coupons.
if ( $this->is_coupon_type( $coupon, 'subscription_percent' ) ) {
return false;
}
// If there's not a renewal in the cart, we can convert.
$subscription_renewal = $this->get_subscription_type_from_cart( 'renewal' );
if ( ! $subscription_renewal ) {
return true;
}
/**
* We need to allow the early renewal to convert the cost, as it pulls the original value of the coupon.
* Subsequent queries for the amount use the first converted amount.
* This also works for normal manual renewals.
*/
if ( ! $this->utils->is_call_in_backtrace( [ 'WCS_Cart_Early_Renewal->setup_cart' ] )
&& $this->utils->is_call_in_backtrace( [ 'WC_Discounts->apply_coupon' ] )
&& $this->is_coupon_type( $coupon, 'subscription_recurring' ) ) {
return false;
}
return $return;
}
/**
* Checks to see if currency switching should be disabled.
*
* @param bool $return Whether widgets should be hidden or not. Default is false.
*
* @return bool
*/
public function should_disable_currency_switching( bool $return ): bool {
// If it's already true, return it.
if ( $return ) {
return $return;
}
if ( $this->get_subscription_type_from_cart( 'renewal' )
|| $this->get_subscription_type_from_cart( 'resubscribe' )
|| $this->get_subscription_type_from_cart( 'switch' )
|| $this->get_subscription_from_superglobal_switch_id() ) {
return true;
}
return $return;
}
/**
* Maybe sets the current_my_account_subscription var and clears the FrontendCurrencies cache.
*
* The my-subscriptions.php template file calls $subscription->get_formatted_order_total(), which then calls $subscription->get_price_string_details().
* At that point in time, if we have certain calls in the backtrace, we need to add the subscription into $current_my_account_subscription so that we
* are able to use it later on in the maybe_get_explicit_format_for_subscription_total filter.
*
* Tell Psalm to ignore the WC_Subscription class, this class is only loaded if Subscriptions is active.
*
* @psalm-suppress UndefinedDocblockClass
*
* @param array $subscription_details The details related to the subscription.
* @param WC_Subscription $subscription The subscription being acted on.
*
* @return array The unmodified subscription details.
*/
public function maybe_set_current_my_account_subscription( $subscription_details, $subscription ): array {
$calls = [
'WCS_Template_Loader::get_my_subscriptions ',
'WC_Subscription->get_formatted_order_total',
];
if ( $this->utils->is_call_in_backtrace( $calls ) ) {
// If we have our calls in the backtrace, we set our cached sub and clear the FrontendCurrencies cache.
$this->current_my_account_subscription = $subscription;
$this->frontend_currencies->selected_currency_changed();
}
return $subscription_details;
}
/**
* Maybe clears the current_my_account_subscription var and clears the FrontendCurrencies cache.
*
* During the $subscription->get_formatted_order_total() call we may set the current_my_account_subscription var, and we want to clear it as soon as
* we no longer need it. The woocommerce_get_formatted_subscription_total filter is at the end of that call, so we check to see if the var is set,
* and if it is, we clear it, and we also clear the FrontendCurrencies cache.
*
* Tell Psalm to ignore the WC_Subscription class, this class is only loaded if Subscriptions is active.
*
* @psalm-suppress UndefinedDocblockClass
*
* @param string $formatted The subscription formatted total.
* @param WC_Subscription $subscription The subscription being acted on.
*
* @return string The unmodified subscription formatted total.
*/
public function maybe_clear_current_my_account_subscription( $formatted, $subscription ): string {
if ( $this->is_current_my_account_subscription_set() ) {
$this->current_my_account_subscription = null;
$this->frontend_currencies->selected_currency_changed();
}
return $formatted;
}
/**
* If the current_my_account_subscription var is set, then we use that subscription's currency in order to set the explicit total.
*
* @param string $html_price Price HTML markup.
* @param string $price Formatted price.
* @param array $args Pass on the args.
* @param float $unformatted_price Price as float to allow plugins custom formatting. Since 3.2.0.
* @param float|string $original_price Original price as float, or empty string. Since 5.0.0.
*
* @return string The wc_price with HTML wrapping, possibly with the currency code added for explicit formatting.
*/
public function maybe_get_explicit_format_for_subscription_total( $html_price, $price, $args, $unformatted_price, $original_price ): string {
if ( ! $this->is_current_my_account_subscription_set() ) {
return $html_price;
}
if ( ! $this->multi_currency->has_additional_currencies_enabled() ) {
return $html_price;
}
/**
* Get the currency code from the subscription, then return the explicit price.
* Tell Psalm to ignore the WC_Subscription class, this class is only loaded if Subscriptions is active.
*
* @psalm-suppress UndefinedDocblockClass
*/
$currency_code = $this->current_my_account_subscription->get_currency() ?? get_woocommerce_currency();
// This is sourced from WC_Payments_Explicit_Price_Formatter::get_explicit_price_with_currency.
$price_to_check = html_entity_decode( wp_strip_all_tags( $html_price ) );
if ( false === strpos( $price_to_check, trim( $currency_code ) ) ) {
return $html_price . ' ' . $currency_code;
}
return $html_price;
}
/**
* Simple check to see if the current_my_account_subscription is a WC_Subscription object.
*
* @return bool
*/
private function is_current_my_account_subscription_set(): bool {
/**
* Tell Psalm to ignore the WC_Subscription class, this class is only loaded if Subscriptions is active.
*
* @psalm-suppress UndefinedClass
*/
return is_a( $this->current_my_account_subscription, 'WC_Subscription' );
}
/**
* Checks the cart values to see if there are subscriptions with specific types present.
*
* This checks both the cart itself and the session. This is due to there are times when an item may be present in
* one place and not the other. We need to make sure that if an item is in either we are not creating double conversions.
*
* @param string $type The type of subscription to look for in the cart.
*
* @return mixed False if none found, or the subscription cart item as an array.
*/
private function get_subscription_type_from_cart( $type ) {
// Make sure we're looking for allowed types.
if ( ! in_array( $type, self::SUBSCRIPTION_TYPES, true ) ) {
return false;
}
// Set the sub type cart key.
$subscription_type = 'subscription_' . $type;
// Go through each cart item and if it matches the type, return that item.
if ( isset( WC()->cart ) && is_array( WC()->cart->cart_contents ) && ! empty( WC()->cart->cart_contents ) ) {
foreach ( WC()->cart->cart_contents as $cart_item ) {
if ( isset( $cart_item[ $subscription_type ] ) ) {
return $cart_item;
}
}
}
// Go through each session cart item and if it matches the type, return that item.
if ( isset( WC()->session ) && is_array( WC()->session->get( 'cart' ) ) && ! empty( WC()->session->get( 'cart' ) ) ) {
foreach ( WC()->session->get( 'cart' ) as $cart_item ) {
if ( isset( $cart_item[ $subscription_type ] ) ) {
return $cart_item;
}
}
}
return false;
}
/**
* Getter for subscription objects.
*
* @param mixed $the_subscription Post object or post ID of the order.
*
* Tell Psalm to ignore the WC_Subscription class, this class is only loaded if Subscriptions is active.
*
* @psalm-suppress UndefinedDocblockClass
*
* @return WC_Subscription|bool The subscription object, or false if it cannot be found.
*/
private function get_subscription( $the_subscription ) {
if ( ! function_exists( 'wcs_get_subscription' ) ) {
return false;
}
return wcs_get_subscription( $the_subscription );
}
/**
* Checks $_GET superglobal for a switch ID from the `switch-subscription` param if it exists.
* This `switch-subscription` param is added to the URL when a customer
* has initiated a switch from the My Account → Subscription page.
*
* Tell Psalm to ignore the WC_Subscription class, this class is only loaded if Subscriptions is active.
*
* @psalm-suppress UndefinedDocblockClass
*
* @return WC_Subscription|bool The subscription object, or false if it cannot be found.
*/
private function get_subscription_from_superglobal_switch_id() {
// Return false if there's no nonce, or if it fails.
if ( ! isset( $_GET['_wcsnonce'] ) || ! wp_verify_nonce( sanitize_key( $_GET['_wcsnonce'] ), 'wcs_switch_request' ) ) {
return false;
}
// Return false if the param isn't set, or if it isn't numeric.
if ( ! isset( $_GET['switch-subscription'] ) || ! is_numeric( $_GET['switch-subscription'] ) ) {
return false;
}
// Get the switch ID from the param.
$switch_id = (int) sanitize_key( $_GET['switch-subscription'] );
// Get the sub, preventing an infinite loop with running_override_selected_currency_filters.
$this->running_override_selected_currency_filters = true;
$switch_subscription = $this->get_subscription( $switch_id );
$this->running_override_selected_currency_filters = false;
// Confirm the sub user matches current user, and return the sub.
if ( $switch_subscription && $switch_subscription->get_customer_id() === get_current_user_id() ) {
return $switch_subscription;
} else {
Logger::notice( 'User (' . get_current_user_id() . ') attempted to switch a subscription (' . $switch_subscription->get_id() . ') not assigned to them.' );
}
return false;
}
/**
* Checks to see if the coupon passed is of a specified type.
*
* @param \WC_Coupon $coupon Coupon to test.
* @param string $type Type of coupon to test for.
*
* @return bool True on match.
*/
private function is_coupon_type( $coupon, string $type ) {
$types = null;
switch ( $type ) {
case 'subscription_percent':
$types = [ 'recurring_percent', 'sign_up_fee_percent', 'renewal_percent' ];
break;
case 'subscription_recurring':
$types = [ 'recurring_fee', 'recurring_percent', 'renewal_fee', 'renewal_percent', 'renewal_cart' ];
break;
}
if ( in_array( $coupon->get_discount_type(), $types, true ) ) {
return true;
}
return false;
}
}
@@ -0,0 +1,54 @@
<?php
/**
* Class WooCommerceUPS
*
* @package WCPay\MultiCurrency\Compatibility
*/
namespace WCPay\MultiCurrency\Compatibility;
use WCPay\MultiCurrency\MultiCurrency;
use WCPay\MultiCurrency\Utils;
/**
* Class that controls Multi Currency Compatibility with WooCommerce UPS Plugin.
*/
class WooCommerceUPS extends BaseCompatibility {
/**
* Init the class.
*
* @return void
*/
public function init() {
// Add needed actions and filters if UPS is active.
if ( class_exists( 'WC_Shipping_UPS_Init' ) ) {
add_filter( MultiCurrency::FILTER_PREFIX . 'should_return_store_currency', [ $this, 'should_return_store_currency' ] );
}
}
/**
* Determine whether to return the store currency or not.
*
* @param bool $return Whether to return the store currency or not.
*
* @return bool
*/
public function should_return_store_currency( bool $return ): bool {
// If it's already true, return it.
if ( $return ) {
return $return;
}
$calls = [
'WC_Shipping_UPS->per_item_shipping',
'WC_Shipping_UPS->box_shipping',
'WC_Shipping_UPS->calculate_shipping',
];
if ( $this->utils->is_call_in_backtrace( $calls ) ) {
return true;
}
return $return;
}
}
@@ -0,0 +1,301 @@
<?php
/**
* Class CountryFlags
*
* @package WooCommerce\Payments
*/
namespace WCPay\MultiCurrency;
defined( 'ABSPATH' ) || exit;
/**
* Class that bring flags per country/currency.
*/
class CountryFlags {
const EMOJI_COUNTRIES_FLAGS = [
'AD' => '🇦🇩',
'AE' => '🇦🇪',
'AF' => '🇦🇫',
'AG' => '🇦🇬',
'AI' => '🇦🇮',
'AL' => '🇦🇱',
'AM' => '🇦🇲',
'AO' => '🇦🇴',
'AQ' => '🇦🇶',
'AR' => '🇦🇷',
'AS' => '🇦🇸',
'AT' => '🇦🇹',
'AU' => '🇦🇺',
'AW' => '🇦🇼',
'AX' => '🇦🇽',
'AZ' => '🇦🇿',
'BA' => '🇧🇦',
'BB' => '🇧🇧',
'BD' => '🇧🇩',
'BE' => '🇧🇪',
'BF' => '🇧🇫',
'BG' => '🇧🇬',
'BH' => '🇧🇭',
'BI' => '🇧🇮',
'BJ' => '🇧🇯',
'BL' => '🇧🇱',
'BM' => '🇧🇲',
'BN' => '🇧🇳',
'BO' => '🇧🇴',
'BQ' => '🇧🇶',
'BR' => '🇧🇷',
'BS' => '🇧🇸',
'BT' => '🇧🇹',
'BV' => '🇧🇻',
'BW' => '🇧🇼',
'BY' => '🇧🇾',
'BZ' => '🇧🇿',
'CA' => '🇨🇦',
'CC' => '🇨🇨',
'CD' => '🇨🇩',
'CF' => '🇨🇫',
'CG' => '🇨🇬',
'CH' => '🇨🇭',
'CI' => '🇨🇮',
'CK' => '🇨🇰',
'CL' => '🇨🇱',
'CM' => '🇨🇲',
'CN' => '🇨🇳',
'CO' => '🇨🇴',
'CR' => '🇨🇷',
'CU' => '🇨🇺',
'CV' => '🇨🇻',
'CW' => '🇨🇼',
'CX' => '🇨🇽',
'CY' => '🇨🇾',
'CZ' => '🇨🇿',
'DE' => '🇩🇪',
'DJ' => '🇩🇯',
'DK' => '🇩🇰',
'DM' => '🇩🇲',
'DO' => '🇩🇴',
'DZ' => '🇩🇿',
'EC' => '🇪🇨',
'EE' => '🇪🇪',
'EG' => '🇪🇬',
'EH' => '🇪🇭',
'ER' => '🇪🇷',
'ES' => '🇪🇸',
'ET' => '🇪🇹',
'EU' => '🇪🇺',
'FI' => '🇫🇮',
'FJ' => '🇫🇯',
'FK' => '🇫🇰',
'FM' => '🇫🇲',
'FO' => '🇫🇴',
'FR' => '🇫🇷',
'GA' => '🇬🇦',
'GB' => '🇬🇧',
'GD' => '🇬🇩',
'GE' => '🇬🇪',
'GF' => '🇬🇫',
'GG' => '🇬🇬',
'GH' => '🇬🇭',
'GI' => '🇬🇮',
'GL' => '🇬🇱',
'GM' => '🇬🇲',
'GN' => '🇬🇳',
'GP' => '🇬🇵',
'GQ' => '🇬🇶',
'GR' => '🇬🇷',
'GS' => '🇬🇸',
'GT' => '🇬🇹',
'GU' => '🇬🇺',
'GW' => '🇬🇼',
'GY' => '🇬🇾',
'HK' => '🇭🇰',
'HM' => '🇭🇲',
'HN' => '🇭🇳',
'HR' => '🇭🇷',
'HT' => '🇭🇹',
'HU' => '🇭🇺',
'ID' => '🇮🇩',
'IE' => '🇮🇪',
'IL' => '🇮🇱',
'IM' => '🇮🇲',
'IN' => '🇮🇳',
'IO' => '🇮🇴',
'IQ' => '🇮🇶',
'IR' => '🇮🇷',
'IS' => '🇮🇸',
'IT' => '🇮🇹',
'JE' => '🇯🇪',
'JM' => '🇯🇲',
'JO' => '🇯🇴',
'JP' => '🇯🇵',
'KE' => '🇰🇪',
'KG' => '🇰🇬',
'KH' => '🇰🇭',
'KI' => '🇰🇮',
'KM' => '🇰🇲',
'KN' => '🇰🇳',
'KP' => '🇰🇵',
'KR' => '🇰🇷',
'KW' => '🇰🇼',
'KY' => '🇰🇾',
'KZ' => '🇰🇿',
'LA' => '🇱🇦',
'LB' => '🇱🇧',
'LC' => '🇱🇨',
'LI' => '🇱🇮',
'LK' => '🇱🇰',
'LR' => '🇱🇷',
'LS' => '🇱🇸',
'LT' => '🇱🇹',
'LU' => '🇱🇺',
'LV' => '🇱🇻',
'LY' => '🇱🇾',
'MA' => '🇲🇦',
'MC' => '🇲🇨',
'MD' => '🇲🇩',
'ME' => '🇲🇪',
'MF' => '🇲🇫',
'MG' => '🇲🇬',
'MH' => '🇲🇭',
'MK' => '🇲🇰',
'ML' => '🇲🇱',
'MM' => '🇲🇲',
'MN' => '🇲🇳',
'MO' => '🇲🇴',
'MP' => '🇲🇵',
'MQ' => '🇲🇶',
'MR' => '🇲🇷',
'MS' => '🇲🇸',
'MT' => '🇲🇹',
'MU' => '🇲🇺',
'MV' => '🇲🇻',
'MW' => '🇲🇼',
'MX' => '🇲🇽',
'MY' => '🇲🇾',
'MZ' => '🇲🇿',
'NA' => '🇳🇦',
'NC' => '🇳🇨',
'NE' => '🇳🇪',
'NF' => '🇳🇫',
'NG' => '🇳🇬',
'NI' => '🇳🇮',
'NL' => '🇳🇱',
'NO' => '🇳🇴',
'NP' => '🇳🇵',
'NR' => '🇳🇷',
'NU' => '🇳🇺',
'NZ' => '🇳🇿',
'OM' => '🇴🇲',
'PA' => '🇵🇦',
'PE' => '🇵🇪',
'PF' => '🇵🇫',
'PG' => '🇵🇬',
'PH' => '🇵🇭',
'PK' => '🇵🇰',
'PL' => '🇵🇱',
'PM' => '🇵🇲',
'PN' => '🇵🇳',
'PR' => '🇵🇷',
'PS' => '🇵🇸',
'PT' => '🇵🇹',
'PW' => '🇵🇼',
'PY' => '🇵🇾',
'QA' => '🇶🇦',
'RE' => '🇷🇪',
'RO' => '🇷🇴',
'RS' => '🇷🇸',
'RU' => '🇷🇺',
'RW' => '🇷🇼',
'SA' => '🇸🇦',
'SB' => '🇸🇧',
'SC' => '🇸🇨',
'SD' => '🇸🇩',
'SE' => '🇸🇪',
'SG' => '🇸🇬',
'SH' => '🇸🇭',
'SI' => '🇸🇮',
'SJ' => '🇸🇯',
'SK' => '🇸🇰',
'SL' => '🇸🇱',
'SM' => '🇸🇲',
'SN' => '🇸🇳',
'SO' => '🇸🇴',
'SR' => '🇸🇷',
'SS' => '🇸🇸',
'ST' => '🇸🇹',
'SV' => '🇸🇻',
'SX' => '🇸🇽',
'SY' => '🇸🇾',
'SZ' => '🇸🇿',
'TC' => '🇹🇨',
'TD' => '🇹🇩',
'TF' => '🇹🇫',
'TG' => '🇹🇬',
'TH' => '🇹🇭',
'TJ' => '🇹🇯',
'TK' => '🇹🇰',
'TL' => '🇹🇱',
'TM' => '🇹🇲',
'TN' => '🇹🇳',
'TO' => '🇹🇴',
'TR' => '🇹🇷',
'TT' => '🇹🇹',
'TV' => '🇹🇻',
'TW' => '🇹🇼',
'TZ' => '🇹🇿',
'UA' => '🇺🇦',
'UG' => '🇺🇬',
'UM' => '🇺🇲',
'US' => '🇺🇸',
'UY' => '🇺🇾',
'UZ' => '🇺🇿',
'VA' => '🇻🇦',
'VC' => '🇻🇨',
'VE' => '🇻🇪',
'VG' => '🇻🇬',
'VI' => '🇻🇮',
'VN' => '🇻🇳',
'VU' => '🇻🇺',
'WF' => '🇼🇫',
'WS' => '🇼🇸',
'XK' => '🇽🇰',
'YE' => '🇾🇪',
'YT' => '🇾🇹',
'ZA' => '🇿🇦',
'ZM' => '🇿🇲',
'ZW' => '🇿🇼',
];
/**
* Retrieves a flag by country code.
*
* @param string $country country alpha-2 code (ISO 3166) like US.
* @return string
*/
public static function get_by_country( string $country ): string {
return self::EMOJI_COUNTRIES_FLAGS[ $country ] ?? '';
}
/**
* Retrieves a flag by currency code.
*
* @param string $currency currency code (ISO 4217) like USD.
* @return string
*/
public static function get_by_currency( string $currency ): string {
$exceptions = [
'ANG' => '',
'BTC' => '',
'XAF' => '',
'XCD' => '',
'XOF' => '',
'XPF' => '',
];
$flag = $exceptions[ $currency ] ?? self::get_by_country( substr( $currency, 0, -1 ) );
return $flag;
}
}
@@ -0,0 +1,278 @@
<?php
/**
* Class Currency
*
* @package WooCommerce\Payments
*/
namespace WCPay\MultiCurrency;
use WCPay\MultiCurrency\Interfaces\MultiCurrencyLocalizationInterface;
defined( 'ABSPATH' ) || exit;
/**
* Multi-Currency Currency object.
*/
class Currency implements \JsonSerializable {
/**
* Three letter currency code.
*
* @var string
*/
public $code;
/**
* Currency conversion rate.
*
* @var float
*/
public $rate;
/**
* Currency charm rate after conversion and rounding.
*
* @var float|null
*/
private $charm;
/**
* Is currency default for store?
*
* @var bool
*/
private $is_default = false;
/**
* Currency rounding rate after conversion.
*
* @var string|null
*/
private $rounding;
/**
* Is currency zero decimal?
*
* @var bool
*/
private $is_zero_decimal = false;
/**
* A timestamp representing the time this currency was last fetched successfully from the server.
*
* @var int|null
*/
private $last_updated;
/**
* Instance of MultiCurrencyLocalizationInterface.
*
* @var MultiCurrencyLocalizationInterface
*/
private $localization_service;
/**
* Constructor.
*
* @param MultiCurrencyLocalizationInterface $localization_service Localization service instance.
* @param string $code Three letter currency code.
* @param float $rate The conversion rate.
* @param int|null $last_updated The time this currency was last updated.
*/
public function __construct( MultiCurrencyLocalizationInterface $localization_service, $code = '', float $rate = 1.0, $last_updated = null ) {
$this->localization_service = $localization_service;
$this->code = $code;
$this->rate = $rate;
if ( get_woocommerce_currency() === $code ) {
$this->is_default = true;
}
// Set zero-decimal style based on WC locale information.
$this->is_zero_decimal = 0 === $this->localization_service->get_currency_format( $code )['num_decimals'];
if ( ! is_null( $last_updated ) ) {
$this->last_updated = $last_updated;
}
}
/**
* Retrieves the currency's translated name from WooCommerce core.
*
* @param string $code The currency code.
*/
public function get_currency_name_from_code( $code ): string {
$wc_currencies = get_woocommerce_currencies();
return $wc_currencies[ $code ];
}
/**
* Retrieves the currency's code.
*
* @return string Three letter currency code.
*/
public function get_code(): string {
return $this->code;
}
/**
* Retrieves the currency's charm rate.
*
* @return float Charm rate.
*/
public function get_charm(): float {
return is_null( $this->charm ) ? 0.00 : $this->charm;
}
/**
* Retrieves the currency's flag.
*
* @return string Currency flag.
*/
public function get_flag(): string {
// Maybe add param img/emoji to return which you want?
return CountryFlags::get_by_currency( $this->code );
}
/**
* Retrieves the currency code lowercased.
*
* @return string Currency code lowercased.
*/
public function get_id(): string {
return strtolower( $this->code );
}
/**
* Retrieves if the currency is default for the store.
*
* @return bool
*/
public function get_is_default(): bool {
return $this->is_default;
}
/**
* Retrieves the currency's name from WooCommerce core.
*
* @return string Currency name.
*/
public function get_name(): string {
$wc_currencies = get_woocommerce_currencies();
return $wc_currencies[ $this->code ];
}
/**
* Retrieves the currency's conversion rate.
*
* @return float The conversion rate.
*/
public function get_rate(): float {
return $this->rate;
}
/**
* Retrieves the currency's rounding rate.
*
* @return string Rounding rate.
*/
public function get_rounding(): string {
return (string) ( $this->rounding ?? '0' );
}
/**
* Retrieves the currency's symbol from WooCommerce core.
*
* @return string Currency symbol.
*/
public function get_symbol(): string {
return get_woocommerce_currency_symbol( $this->code );
}
/**
* Retrieves the currency's symbol position from Localization Service
*
* @return string Currency position (left/right).
*/
public function get_symbol_position(): string {
return $this->localization_service->get_currency_format( $this->code )['currency_pos'];
}
/**
* Retrieves if the currency is zero decimal.
*
* @return bool
*/
public function get_is_zero_decimal(): bool {
return $this->is_zero_decimal;
}
/**
* Get the timestamp reprenting when the currency was last updated.
*
* @return int|null A timestamp representing when the currency was last updated.
*/
public function get_last_updated() {
return $this->last_updated;
}
/**
* Sets the currency's charm rate.
*
* @param float $charm Charm rate.
*/
public function set_charm( $charm ) {
$this->charm = $charm;
}
/**
* Sets the currency's conversion rate.
*
* @param float $rate Conversion rate.
*/
public function set_rate( $rate ) {
$this->rate = $rate;
}
/**
* Sets the currency's rounding rate.
*
* @param string $rounding Rounding rate.
* @return void
*/
public function set_rounding( $rounding ) {
$this->rounding = $rounding;
}
/**
* Set the currency's last updated timestamp.
*
* @param int $last_updated A timestamp representing when the currency was last updated.
*
* @return void
*/
public function set_last_updated( int $last_updated ) {
$this->last_updated = $last_updated;
}
/**
* Specify the data that should be serialized to JSON.
*
* @return array Serialized Currency object.
*/
public function jsonSerialize(): array {
return [
'code' => $this->code,
'rate' => $this->get_rate(),
'name' => html_entity_decode( $this->get_name() ),
'id' => $this->get_id(),
'is_default' => $this->get_is_default(),
'flag' => $this->get_flag(),
'symbol' => html_entity_decode( $this->get_symbol() ),
'symbol_position' => $this->get_symbol_position(),
'is_zero_decimal' => $this->get_is_zero_decimal(),
'last_updated' => $this->get_last_updated(),
];
}
}
@@ -0,0 +1,245 @@
<?php
/**
* WooCommerce Payments Currency Switcher Widget
*
* @package WooCommerce\Payments
*/
namespace WCPay\MultiCurrency;
use function http_build_query;
use function urldecode;
defined( 'ABSPATH' ) || exit;
/**
* Currency Switcher Gutenberg Block.
*/
class CurrencySwitcherBlock {
/**
* Instance of Multi-Currency.
*
* @var MultiCurrency $multi_currency
*/
protected $multi_currency;
/**
* Instance of Compatibility.
*
* @var Compatibility $compatibility
*/
protected $compatibility;
/**
* Constructor.
*
* @param MultiCurrency $multi_currency Instance of Multi-Currency.
* @param Compatibility $compatibility Instance of Compatibility.
*/
public function __construct( MultiCurrency $multi_currency, Compatibility $compatibility ) {
$this->multi_currency = $multi_currency;
$this->compatibility = $compatibility;
}
/**
* Initializes this class' WP hooks.
*
* @return void
*/
public function init_hooks() {
add_action( 'init', [ $this, 'init_block_widget' ] );
}
/**
* Initialize the block currency switcher widget.
*
* @return void
*/
public function init_block_widget() {
// Automatically load dependencies and version.
$this->multi_currency->register_script_with_dependencies( 'woocommerce-payments/multi-currency-switcher', 'dist/multi-currency-switcher-block' );
register_block_type(
'woocommerce-payments/multi-currency-switcher',
[
'api_version' => '2',
'editor_script' => 'woocommerce-payments/multi-currency-switcher',
'render_callback' => [ $this, 'render_block_widget' ],
'attributes' => [
'symbol' => [
'type' => 'boolean',
'default' => true,
],
'flag' => [
'type' => 'boolean',
'default' => false,
],
'fontSize' => [
'type' => 'integer',
'default' => 14,
],
'fontLineHeight' => [
'type' => 'number',
'default' => 1.5,
],
'fontColor' => [
'type' => 'string',
'default' => '#000000',
],
'border' => [
'type' => 'boolean',
'default' => true,
],
'borderRadius' => [
'type' => 'integer',
'default' => 3,
],
'borderColor' => [
'type' => 'string',
'default' => '#000000',
],
'backgroundColor' => [
'type' => 'string',
'default' => 'transparent',
],
],
]
);
}
/**
* Render the content of the block widget. Called by the render_callback callback.
* Normally, this could be all done on the JS side, however we need to use a dynamic
* block here because the currencies enabled on a site could change, and this would not update
* properly on the Gutenberg block, because it is cached.
*
* @param array $block_attributes The attributes (settings) applicable to this block. We expect this will contain
* the widget title, and whether or not we should render both flags and symbols.
*
* @return string The content to be displayed inside the block widget.
*/
public function render_block_widget( $block_attributes ): string {
if ( $this->compatibility->should_disable_currency_switching() ) {
return '';
}
$enabled_currencies = $this->multi_currency->get_enabled_currencies();
if ( 1 === count( $enabled_currencies ) ) {
return '';
}
$with_symbol = $block_attributes['symbol'] ?? true;
$with_flag = $block_attributes['flag'] ?? false;
$styles = $this->get_widget_styles( $block_attributes );
$div_styles = $this->implode_styles_array( $styles['div'] );
$select_styles = $this->implode_styles_array( $styles['select'] );
$widget_content = '<form>';
$widget_content .= $this->get_get_params();
$widget_content .= '<div class="currency-switcher-holder" style="' . esc_attr( $div_styles ) . '">';
$widget_content .= '<select name="currency" onchange="this.form.submit()" style="' . esc_attr( $select_styles ) . '">';
foreach ( $enabled_currencies as $currency ) {
$widget_content .= $this->render_currency_option( $currency, $with_symbol, $with_flag );
}
$widget_content .= '</select></div></form>';
// Silence XSS warning because we are manually constructing the content and escaping everything above.
// nosemgrep: audit.php.wp.security.xss.block-attr -- reason: we are manually constructing the content and escaping everything above.
return $widget_content;
}
/**
* Create an <option> element with provided currency. With symbol and flag if requested.
*
* @param Currency $currency Currency to use for <option> element.
* @param boolean $with_symbol Whether to show the currency symbol.
* @param boolean $with_flag Whether to show the currency flag.
*
* @return string Display HTML of currency <option>, as a string.
*/
private function render_currency_option( Currency $currency, bool $with_symbol, bool $with_flag ): string {
$code = $currency->get_code();
$same_symbol = html_entity_decode( $currency->get_symbol() ) === $code;
$text = $code;
$selected = $this->multi_currency->get_selected_currency()->code === $code ? 'selected' : '';
if ( $with_symbol && ! $same_symbol ) {
$text = $currency->get_symbol() . ' ' . $text;
}
if ( $with_flag ) {
$text = $currency->get_flag() . ' ' . $text;
}
return '<option value="' . esc_attr( $code ) . '" ' . $selected . '>' . esc_html( $text ) . '</option>';
}
/**
* Given an array of styling rules, output them as a string containing valid CSS.
*
* @param array $styles An array of CSS styles.
*
* @return string
*/
private function implode_styles_array( array $styles ): string {
$return_str = '';
foreach ( $styles as $key => $value ) {
$return_str .= $key . ': ' . $value . '; ';
}
return $return_str;
}
/**
* Generate the styles that need to be applied to the widget based on the block attributes.
*
* @param array $block_attributes The block attributes.
*
* @return array
*/
private function get_widget_styles( array $block_attributes ): array {
return [
'div' => [
'line-height' => $block_attributes['fontLineHeight'] ?? 1.2,
],
'select' => [
'padding' => '2px',
'border' => ! empty( $block_attributes['border'] ) ? '1px solid' : '0px solid',
'border-radius' => isset( $block_attributes['borderRadius'] ) ? $block_attributes['borderRadius'] . 'px' : '3px',
'border-color' => $block_attributes['borderColor'] ?? '#000000',
'font-size' => isset( $block_attributes['fontSize'] ) ? $block_attributes['fontSize'] . 'px' : '11px',
'color' => $block_attributes['fontColor'] ?? '#000000',
'background-color' => $block_attributes['backgroundColor'] ?? '#000000',
],
];
}
/**
* Get hidden inputs for every $_GET param.
* This prevents the switcher form to remove them on submit.
*
* @return string|null
*/
private function get_get_params() {
if ( empty( $_GET ) ) { // phpcs:disable WordPress.Security.NonceVerification
return null;
}
$params = explode( '&', urldecode( http_build_query( $_GET ) ) );
$return = '';
foreach ( $params as $param ) {
$name_value = explode( '=', $param );
$name = $name_value[0];
$value = $name_value[1];
if ( 'currency' === $name ) {
continue;
}
$return .= sprintf( '<input type="hidden" name="%s" value="%s" />', esc_attr( $name ), esc_attr( $value ) );
}
return $return;
}
}
@@ -0,0 +1,170 @@
<?php
/**
* WooCommerce Payments Currency Switcher Widget
*
* @package WooCommerce\Payments
*/
namespace WCPay\MultiCurrency;
use WC_Widget;
defined( 'ABSPATH' ) || exit;
/**
* Currency Switcher Widget Class
*/
class CurrencySwitcherWidget extends WC_Widget {
const DEFAULT_SETTINGS = [
'title' => '',
'symbol' => true,
'flag' => false,
];
/**
* Compatibility instance.
*
* @var Compatibility
*/
protected $compatibility;
/**
* Multi-Currency instance.
*
* @var MultiCurrency
*/
protected $multi_currency;
/**
* Register widget with WordPress.
*
* @param MultiCurrency $multi_currency The MultiCurrency instance.
* @param Compatibility $compatibility The Compatibility instance.
*/
public function __construct( MultiCurrency $multi_currency, Compatibility $compatibility ) {
$this->multi_currency = $multi_currency;
$this->compatibility = $compatibility;
$this->widget_id = 'currency_switcher_widget';
$this->widget_name = __( 'Currency Switcher Widget', 'woocommerce-payments' );
$this->widget_description = __( 'Let your customers switch between your enabled currencies', 'woocommerce-payments' );
$this->settings = [
'title' => [
'type' => 'text',
'std' => '',
'label' => __( 'Title', 'woocommerce-payments' ),
],
'symbol' => [
'type' => 'checkbox',
'std' => true,
'label' => __( 'Display currency symbols', 'woocommerce-payments' ),
],
'flag' => [
'type' => 'checkbox',
'std' => false,
'label' => __( 'Display flags in supported devices', 'woocommerce-payments' ),
],
];
parent::__construct();
}
/**
* Front-end display of widget.
*
* @param array $args Widget arguments.
* @param array $instance Saved values from database.
*/
public function widget( $args, $instance ) {
if ( $this->compatibility->should_disable_currency_switching() ) {
return;
}
$enabled_currencies = $this->multi_currency->get_enabled_currencies();
if ( 1 === count( $enabled_currencies ) ) {
return;
}
$instance = wp_parse_args(
$instance,
self::DEFAULT_SETTINGS
);
$title = apply_filters( 'widget_title', $instance['title'], $instance, $this->id_base );
echo $args['before_widget']; // phpcs:ignore WordPress.Security.EscapeOutput
if ( ! empty( $title ) ) {
echo $args['before_title'] . $title . $args['after_title']; // phpcs:ignore WordPress.Security.EscapeOutput
}
?>
<form>
<?php $this->output_get_params(); ?>
<select
name="currency"
aria-label="<?php echo esc_attr( $title ); ?>"
onchange="this.form.submit()"
>
<?php
foreach ( $enabled_currencies as $currency ) {
$this->display_currency_option( $currency, $instance['symbol'], $instance['flag'] );
}
?>
</select>
</form>
<?php
echo $args['after_widget']; // phpcs:ignore WordPress.Security.EscapeOutput
}
/**
* Create an <option> element with provided currency. With symbol and flag if requested.
*
* @param Currency $currency Currency to use for <option> element.
* @param boolean $with_symbol Whether to show the currency symbol.
* @param boolean $with_flag Whether to show the currency flag.
*
* @return void Displays HTML of currency <option>
*/
private function display_currency_option( Currency $currency, bool $with_symbol, bool $with_flag ) {
$code = $currency->get_code();
$same_symbol = html_entity_decode( $currency->get_symbol() ) === $code;
$text = $code;
$selected = $this->multi_currency->get_selected_currency()->code === $code ? ' selected' : '';
if ( $with_symbol && ! $same_symbol ) {
$text = $currency->get_symbol() . ' ' . $text;
}
if ( $with_flag ) {
$text = $currency->get_flag() . ' ' . $text;
}
echo "<option value=\"$code\"$selected>$text</option>"; // phpcs:ignore WordPress.Security.EscapeOutput
}
/**
* Output hidden inputs for every $_GET param.
* This prevent the switcher form to remove them on submit.
*
* @return void
*/
private function output_get_params() {
// phpcs:disable WordPress.Security.NonceVerification
if ( empty( $_GET ) ) {
return;
}
$params = explode( '&', urldecode( http_build_query( $_GET ) ) );
foreach ( $params as $param ) {
$name_value = explode( '=', $param );
$name = $name_value[0];
$value = $name_value[1];
if ( 'currency' === $name ) {
continue;
}
echo '<input type="hidden" name="' . esc_attr( $name ) . '" value="' . esc_attr( $value ) . '" />';
}
// phpcs:enable WordPress.Security.NonceVerification
}
}
@@ -0,0 +1,17 @@
<?php
/**
* Class InvalidCurrencyException
*
* @package WooCommerce\Payments
*/
namespace WCPay\MultiCurrency\Exceptions;
use Exception;
defined( 'ABSPATH' ) || exit;
/**
* Exception for throwing errors when an invalid currency is used.
*/
class InvalidCurrencyException extends Exception {}
@@ -0,0 +1,17 @@
<?php
/**
* Class InvalidCurrencyRateException
*
* @package WooCommerce\Payments
*/
namespace WCPay\MultiCurrency\Exceptions;
use Exception;
defined( 'ABSPATH' ) || exit;
/**
* Exception for throwing errors when an invalid currency rate is used.
*/
class InvalidCurrencyRateException extends Exception {}
@@ -0,0 +1,440 @@
<?php
/**
* WooCommerce Payments Multi-Currency Frontend Currencies
*
* @package WooCommerce\Payments
*/
namespace WCPay\MultiCurrency;
use WC_Order;
use WCPay\MultiCurrency\Interfaces\MultiCurrencyLocalizationInterface;
defined( 'ABSPATH' ) || exit;
/**
* Class that formats Multi-Currency currencies on the frontend.
*/
class FrontendCurrencies {
/**
* Multi-Currency instance.
*
* @var MultiCurrency
*/
protected $multi_currency;
/**
* MultiCurrencyLocalizationInterface instance.
*
* @var MultiCurrencyLocalizationInterface
*/
protected $localization_service;
/**
* Multi-currency utils instance.
*
* @var Utils
*/
protected $utils;
/**
* Multi-currency compatibility instance.
*
* @var Compatibility
*/
protected $compatibility;
/**
* Multi-Currency currency formatting map.
*
* @var array
*/
protected $currency_format = [];
/**
* Order currency code.
*
* @var string|null
*/
protected $order_currency;
/**
* WOO currency cache.
*
* @var string
*/
private $woocommerce_currency;
/**
* Price Decimal Separator cache.
*
* @var array
*/
private $price_decimal_separators = [];
/**
* Selected Currency Code cache.
*
* @var string
*/
private $selected_currency_code;
/**
* Store Currency cache.
*
* @var Currency
*/
private $store_currency;
/**
* Constructor.
*
* @param MultiCurrency $multi_currency The MultiCurrency instance.
* @param MultiCurrencyLocalizationInterface $localization_service The Localization Service instance.
* @param Utils $utils Utils instance.
* @param Compatibility $compatibility Compatibility instance.
*/
public function __construct( MultiCurrency $multi_currency, MultiCurrencyLocalizationInterface $localization_service, Utils $utils, Compatibility $compatibility ) {
$this->multi_currency = $multi_currency;
$this->localization_service = $localization_service;
$this->utils = $utils;
$this->compatibility = $compatibility;
}
/**
* Initializes this class' WP hooks.
*
* @return void
*/
public function init_hooks() {
if ( ! is_admin() && ! defined( 'DOING_CRON' ) && ! Utils::is_admin_api_request() ) {
// Currency hooks.
add_filter( 'woocommerce_currency', [ $this, 'get_woocommerce_currency' ], 900 );
add_filter( 'wc_get_price_decimals', [ $this, 'get_price_decimals' ], 900 );
add_filter( 'wc_get_price_decimal_separator', [ $this, 'get_price_decimal_separator' ], 900 );
add_filter( 'wc_get_price_thousand_separator', [ $this, 'get_price_thousand_separator' ], 900 );
add_filter( 'woocommerce_price_format', [ $this, 'get_woocommerce_price_format' ], 900 );
add_action( 'before_woocommerce_pay', [ $this, 'init_order_currency_from_query_vars' ] );
add_action( 'woocommerce_order_get_total', [ $this, 'maybe_init_order_currency_from_order_total_prop' ], 900, 2 );
add_action( 'woocommerce_get_formatted_order_total', [ $this, 'maybe_clear_order_currency_after_formatted_order_total' ], 900, 4 );
}
add_filter( 'woocommerce_thankyou_order_id', [ $this, 'init_order_currency' ] );
add_action( 'woocommerce_account_view-order_endpoint', [ $this, 'init_order_currency' ], 9 );
add_filter( 'woocommerce_cart_hash', [ $this, 'add_currency_to_cart_hash' ], 900 );
add_filter( 'woocommerce_shipping_method_add_rate_args', [ $this, 'fix_price_decimals_for_shipping_rates' ], 900, 2 );
}
/**
* The selected currency changed. We discard some cache.
*
* @return void
*/
public function selected_currency_changed() {
$this->selected_currency_code = null;
$this->price_decimal_separators = [];
$this->woocommerce_currency = null;
$this->store_currency = null;
}
/**
* Removes 'min_price' and 'max_price' from the URL query parameters.
*
* Clears existing price filters when the currency is changed to prevent inconsistencies.
*
* @return void
*/
public function clear_url_price_params() {
if ( isset( $_GET['min_price'] ) || isset( $_GET['max_price'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification
$url = remove_query_arg( [ 'min_price', 'max_price' ] );
wp_safe_redirect( $url );
exit;
}
}
/**
* Gets the store currency.
*
* @return Currency The store currency wrapped as a Currency object
*/
public function get_store_currency(): Currency {
if ( empty( $this->store_currency ) ) {
$this->store_currency = $this->multi_currency->get_default_currency();
}
return $this->store_currency;
}
/**
* Returns the currency code to be used by WooCommerce.
*
* @return string The code of the currency to be used.
*/
public function get_woocommerce_currency(): string {
if ( $this->compatibility->should_return_store_currency() ) {
return $this->get_store_currency()->get_code();
}
if ( empty( $this->woocommerce_currency ) ) {
$this->woocommerce_currency = $this->get_selected_currency_code();
}
return $this->woocommerce_currency;
}
/**
* Returns the number of decimals to be used by WooCommerce.
*
* @param int $decimals The original decimal count.
*
* @return int The number of decimals.
*/
public function get_price_decimals( $decimals ): int {
$currency_code = $this->get_currency_code();
if ( $currency_code !== $this->get_store_currency()->get_code() ) {
return absint( $this->localization_service->get_currency_format( $currency_code )['num_decimals'] );
}
return $decimals;
}
/**
* Returns the decimal separator to be used by WooCommerce.
*
* @param string $separator The original separator.
*
* @return string The decimal separator.
*/
public function get_price_decimal_separator( $separator ): string {
$currency_code = $this->get_currency_code();
$store_currency_code = $this->get_store_currency()->get_code();
if ( $currency_code === $store_currency_code ) {
$currency_code = $store_currency_code;
$this->price_decimal_separators[ $currency_code ] = $separator;
}
if ( empty( $this->price_decimal_separators[ $currency_code ] ) ) {
$this->price_decimal_separators[ $currency_code ] = $this->localization_service->get_currency_format( $currency_code )['decimal_sep'];
}
return $this->price_decimal_separators[ $currency_code ];
}
/**
* Returns the thousand separator to be used by WooCommerce.
*
* @param string $separator The original separator.
*
* @return string The thousand separator.
*/
public function get_price_thousand_separator( $separator ): string {
$currency_code = $this->get_currency_code();
if ( $currency_code !== $this->get_store_currency()->get_code() ) {
return $this->localization_service->get_currency_format( $currency_code )['thousand_sep'];
}
return $separator;
}
/**
* Returns the currency format to be used by WooCommerce.
*
* @param string $format The original currency format.
*
* @return string The currency format.
*/
public function get_woocommerce_price_format( $format ): string {
$currency_code = $this->get_currency_code();
if ( $currency_code !== $this->get_store_currency()->get_code() ) {
$currency_pos = $this->localization_service->get_currency_format( $currency_code )['currency_pos'];
switch ( $currency_pos ) {
case 'left':
return '%1$s%2$s';
case 'right':
return '%2$s%1$s';
case 'left_space':
return '%1$s&nbsp;%2$s';
case 'right_space':
return '%2$s&nbsp;%1$s';
default:
return '%1$s%2$s';
}
}
return $format;
}
/**
* Adds the currency and exchange rate to the cart hash so it's recalculated properly.
*
* @param string $hash The cart hash.
*
* @return string The adjusted cart hash.
*/
public function add_currency_to_cart_hash( $hash ): string {
$currency = $this->multi_currency->get_selected_currency();
return md5( $hash . $currency->get_code() . $currency->get_rate() );
}
/**
* Inits order currency code.
*
* @param mixed $arg Either WC_Order or the id of an order are expected, but can be empty.
*
* @return int|mixed The order id or what was passed as $arg.
*/
public function init_order_currency( $arg ) {
if ( null !== $this->order_currency ) {
return $arg;
}
// We remove these filters here because 'wc_get_order'
// can trigger them, leading to an infinitely recursive call.
remove_filter( 'woocommerce_price_format', [ $this, 'get_woocommerce_price_format' ], 900 );
remove_filter( 'wc_get_price_thousand_separator', [ $this, 'get_price_thousand_separator' ], 900 );
remove_filter( 'wc_get_price_decimal_separator', [ $this, 'get_price_decimal_separator' ], 900 );
remove_filter( 'wc_get_price_decimals', [ $this, 'get_price_decimals' ], 900 );
$order = ! $arg instanceof WC_Order ? wc_get_order( $arg ) : $arg;
add_filter( 'wc_get_price_decimals', [ $this, 'get_price_decimals' ], 900 );
add_filter( 'wc_get_price_decimal_separator', [ $this, 'get_price_decimal_separator' ], 900 );
add_filter( 'wc_get_price_thousand_separator', [ $this, 'get_price_thousand_separator' ], 900 );
add_filter( 'woocommerce_price_format', [ $this, 'get_woocommerce_price_format' ], 900 );
if ( $order ) {
$this->order_currency = $order->get_currency();
return $order->get_id();
}
$this->order_currency = $this->multi_currency->get_selected_currency()->get_code();
return $arg;
}
/**
* Gets the order id from the wp query_vars and then calls init_order_currency.
*
* @return void
*/
public function init_order_currency_from_query_vars() {
global $wp;
if ( ! empty( $wp->query_vars['order-pay'] ) ) {
$this->init_order_currency( $wp->query_vars['order-pay'] );
} elseif ( ! empty( $wp->query_vars['order-received'] ) ) {
$this->init_order_currency( $wp->query_vars['order-received'] );
} elseif ( ! empty( $wp->query_vars['view-order'] ) ) {
$this->init_order_currency( $wp->query_vars['view-order'] );
}
}
/**
* Fixes the decimals for the store currency when shipping rates are being determined.
* Our `wc_get_price_decimals` filter returns the decimals for the selected currency during this calculation, which leads to incorrect results.
*
* @param array $args The argument array to be filtered.
* @param object $method The shipping method being calculated.
*
* @return array
*/
public function fix_price_decimals_for_shipping_rates( array $args, $method ): array {
$args['price_decimals'] = absint( $this->localization_service->get_currency_format( $this->get_store_currency()->get_code() )['num_decimals'] );
return $args;
}
/**
* Returns the current value of order_currency.
*
* @return ?string The currency code or null.
*/
public function get_order_currency() {
return $this->order_currency;
}
/**
* Maybe init the order_currency when the order total is queried if we should_use_order_currency.
*
* This works off of filtering during WC_Abstract_Order->get_total, which states it returns a float, however, in the instances of orders with negative
* amounts, such as refund orders, it will return a string.
*
* @param mixed $total The order total.
* @param WC_Order $order The order being worked on.
*
* @return mixed The unmodified total.
*/
public function maybe_init_order_currency_from_order_total_prop( $total, $order ) {
if ( $this->should_use_order_currency() ) {
$this->init_order_currency( $order );
}
return $total;
}
/**
* If the order_currency is set and we should be using the order currency, clear it.
*
* This should only be happening on the instances tested for in should_use_order_currency. We would need to clear the order currency once the total
* filter is run so that if another total comes up, like in the order list, we use the next order's currency.
*
* @param string $formatted_total Total to display.
* @param WC_Order $order Order data.
* @param string $tax_display Type of tax display.
* @param bool $display_refunded If should include refunded value.
*
* @return string The unmodified formatted total.
*/
public function maybe_clear_order_currency_after_formatted_order_total( $formatted_total, $order, $tax_display, $display_refunded ): string {
if ( null !== $this->order_currency && $this->should_use_order_currency() ) {
$this->order_currency = null;
}
return $formatted_total;
}
/**
* Gets the currency code for us to use.
*
* @return string|null Three letter currency code.
*/
private function get_currency_code() {
if ( $this->should_use_order_currency() ) {
$this->init_order_currency_from_query_vars();
return $this->order_currency;
}
$this->selected_currency_code = $this->get_selected_currency_code();
return $this->selected_currency_code;
}
/**
* Helper function to "cache" the selected currency.
*
* @return string
*/
private function get_selected_currency_code(): string {
if ( empty( $this->selected_currency_code ) ) {
$this->selected_currency_code = $this->multi_currency->get_selected_currency()->get_code();
}
return $this->selected_currency_code;
}
/**
* Checks whether currency code used for formatting should be overridden.
*
* @return bool
*/
private function should_use_order_currency(): bool {
$pages = [ 'my-account', 'checkout' ];
$vars = [ 'order-received', 'order-pay', 'orders', 'view-order' ];
if ( $this->utils->is_page_with_vars( $pages, $vars ) ) {
return $this->utils->is_call_in_backtrace(
[
'WC_Shortcode_My_Account::view_order',
'WC_Shortcode_Checkout::order_received',
'WC_Shortcode_Checkout::order_pay',
'WC_Order->get_formatted_order_total',
]
);
}
return false;
}
}
@@ -0,0 +1,384 @@
<?php
/**
* WooCommerce Payments Multi-Currency Frontend Prices
*
* @package WooCommerce\Payments
*/
namespace WCPay\MultiCurrency;
use WC_Order;
defined( 'ABSPATH' ) || exit;
/**
* Class that applies Multi-Currency prices on the frontend.
*/
class FrontendPrices {
/**
* Compatibility instance.
*
* @var Compatibility
*/
protected $compatibility;
/**
* Multi-Currency instance.
*
* @var MultiCurrency
*/
protected $multi_currency;
/**
* Constructor.
*
* @param MultiCurrency $multi_currency The MultiCurrency instance.
* @param Compatibility $compatibility The Compatibility instance.
*/
public function __construct( MultiCurrency $multi_currency, Compatibility $compatibility ) {
$this->multi_currency = $multi_currency;
$this->compatibility = $compatibility;
}
/**
* Initializes this class' WP hooks.
*
* @return void
*/
public function init_hooks() {
if ( ! is_admin() && ! defined( 'DOING_CRON' ) && ! Utils::is_admin_api_request() ) {
// Simple product price hooks.
add_filter( 'woocommerce_product_get_price', [ $this, 'get_product_price_string' ], 99, 2 );
add_filter( 'woocommerce_product_get_regular_price', [ $this, 'get_product_price_string' ], 99, 2 );
add_filter( 'woocommerce_product_get_sale_price', [ $this, 'get_product_price_string' ], 99, 2 );
// Variation price hooks.
add_filter( 'woocommerce_product_variation_get_price', [ $this, 'get_product_price_string' ], 99, 2 );
add_filter( 'woocommerce_product_variation_get_regular_price', [ $this, 'get_product_price_string' ], 99, 2 );
add_filter( 'woocommerce_product_variation_get_sale_price', [ $this, 'get_product_price_string' ], 99, 2 );
// Variation price range hooks.
add_filter( 'woocommerce_variation_prices', [ $this, 'get_variation_price_range' ], 99 );
add_filter( 'woocommerce_get_variation_prices_hash', [ $this, 'add_exchange_rate_to_variation_prices_hash' ], 99 );
// Shipping methods hooks.
add_action( 'init', [ $this, 'register_free_shipping_filters' ], 99 );
add_filter( 'woocommerce_shipping_method_add_rate_args', [ $this, 'convert_shipping_method_rate_cost' ], 99 );
// Coupon hooks.
add_filter( 'woocommerce_coupon_get_amount', [ $this, 'get_coupon_amount' ], 99, 2 );
add_filter( 'woocommerce_coupon_get_minimum_amount', [ $this, 'get_coupon_min_max_amount' ], 99 );
add_filter( 'woocommerce_coupon_get_maximum_amount', [ $this, 'get_coupon_min_max_amount' ], 99 );
// Order hooks.
add_filter( 'woocommerce_new_order', [ $this, 'add_order_meta' ], 99, 2 );
// Price Filter Hooks.
add_filter( 'rest_post_dispatch', [ $this, 'maybe_modify_price_ranges_rest_response' ], 10, 3 );
add_filter( 'query_loop_block_query_vars', [ $this, 'maybe_modify_price_ranges_query_var' ], 10, 3 );
}
}
/**
* Modifies the price range query parameters when the selected currency is not the same as the store currency.
*
* This method converts the '_price' parameters based on the selected currency.
*
* @param array $query The current query variables.
* @param \WP_Block $block The current block instance.
* @param int $page The current page number.
*
* @return array The modified query variables.
*/
public function maybe_modify_price_ranges_query_var( $query, $block, $page ) {
if ( 'product' !== $query['post_type'] ) {
return $query;
}
if ( empty( $query['meta_query'] ) || ! is_array( $query['meta_query'] ) ) {
return $query;
}
$store_currency = $this->multi_currency->get_default_currency()->get_code();
$selected_currency = $this->multi_currency->get_selected_currency()->get_code();
// If currencies are the same, no need to convert prices in the query.
if ( $store_currency === $selected_currency ) {
return $query;
}
// Traverse and modify the meta_query array.
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
$query['meta_query'] = $this->convert_meta_query_price_filters( $query['meta_query'], $store_currency, $selected_currency );
return $query;
}
/**
* Recursively traverses and modifies the meta_query array to adjust '_price' values
* from the 'from_currency' to the 'target_currency'.
*
* @param array $meta_query The meta_query array to traverse.
* @param string $from_currency The from currency code.
* @param string $target_currency The target currency code.
* @param int $depth The current depth of the recursion.
*
* @return array The modified meta_query array.
*/
private function convert_meta_query_price_filters( $meta_query, $from_currency, $target_currency, $depth = 0 ) {
// Prevent infinite recursion in a malformed meta_query.
if ( $depth > 4 ) {
return $meta_query;
}
foreach ( $meta_query as &$mq ) {
// If the current element is a nested meta_query with a relation.
if ( isset( $mq['relation'] ) && is_array( $mq ) ) {
// Recursively modify the nested meta_query.
if ( isset( $mq['relation'] ) ) {
// Extract the relation and the nested queries.
$relation = $mq['relation'];
$modified_nested = $this->convert_meta_query_price_filters( $mq, $from_currency, $target_currency, $depth + 1 );
// Reconstruct the meta_query with the modified nested queries.
$mq = array_merge( [ 'relation' => $relation ], $modified_nested );
}
} elseif ( isset( $mq['key'] ) && '_price' === $mq['key'] && isset( $mq['value'] ) && is_numeric( $mq['value'] ) ) {
$converted_price = $this->multi_currency->get_raw_conversion( $mq['value'], $from_currency, $target_currency );
if ( is_numeric( $converted_price ) ) {
// Apply floor or ceil based on the 'compare' operator.
if ( isset( $mq['compare'] ) ) {
if ( '<=' === $mq['compare'] ) {
$mq['value'] = (string) ceil( $converted_price ); // max_price.
} elseif ( '>=' === $mq['compare'] ) {
$mq['value'] = (string) floor( $converted_price ); // min_price.
}
}
}
}
}
unset( $mq );
return $meta_query;
}
/**
* Modify the products/collection-data REST API response to include converted price ranges.
*
* @param \WP_REST_Response $response The original REST response.
* @param \WP_REST_Server $server The REST server instance.
* @param \WP_REST_Request $request The REST request instance.
*
* @return \WP_REST_Response The modified REST response.
*/
public function maybe_modify_price_ranges_rest_response( $response, $server, $request ) {
if ( '/wc/store/v1/products/collection-data' !== $request->get_route() ) {
return $response;
}
$data = $response->get_data();
if ( empty( $data['price_range'] ) || ! is_object( $data['price_range'] ) ) {
return $response;
}
$store_currency = $this->multi_currency->get_default_currency()->get_code();
$selected_currency = $this->multi_currency->get_selected_currency()->get_code();
if ( $store_currency === $selected_currency ) {
return $response;
}
$price_fields = [ 'min_price', 'max_price' ];
foreach ( $price_fields as $field ) {
if ( property_exists( $data['price_range'], $field ) && is_numeric( $data['price_range']->$field ) ) {
$converted_price = $this->multi_currency->get_price( $data['price_range']->$field, 'product' );
if ( is_numeric( $converted_price ) ) {
$data['price_range']->$field = (string) $converted_price;
}
}
}
$response->set_data( $data );
return $response;
}
/**
* Returns the price for a product.
*
* @param mixed $price The product's price.
* @param mixed $product WC_Product or null.
*
* @return mixed The converted product's price.
*/
public function get_product_price( $price, $product = null ) {
if ( ! $price || ! $this->compatibility->should_convert_product_price( $product ) ) {
return $price;
}
return $this->multi_currency->get_price( $price, 'product' );
}
/**
* Returns the stringified price for a product.
*
* @param mixed $price The product's price.
* @param mixed $product WC_Product or null.
*
* @return string The converted product's price.
*/
public function get_product_price_string( $price, $product = null ): string {
return (string) $this->get_product_price( $price, $product );
}
/**
* Returns the price range for a variation.
*
* @param array $variation_prices The variation's prices.
*
* @return array The converted variation's prices.
*/
public function get_variation_price_range( $variation_prices ) {
foreach ( $variation_prices as $price_type => $prices ) {
foreach ( $prices as $variation_id => $price ) {
$variation_prices[ $price_type ][ $variation_id ] = $this->get_product_price_string( $price );
}
}
return $variation_prices;
}
/**
* Add the exchange rate into account for the variation prices hash.
* This is used to recalculate the variation price range when the exchange
* rate changes, otherwise the old prices will be cached.
*
* @param array $prices_hash The variation prices hash.
*
* @return array The variation prices hash with the current exchange rate.
*/
public function add_exchange_rate_to_variation_prices_hash( $prices_hash ) {
$prices_hash[] = $this->get_product_price( 1 );
return $prices_hash;
}
/**
* Returns the shipping add rate args with cost converted.
*
* @param array $args Shipping rate args.
*
* @return array Shipping rate args with converted cost.
*/
public function convert_shipping_method_rate_cost( $args ) {
$cost = is_array( $args['cost'] ) ? array_sum( $args['cost'] ) : $args['cost'];
$args = wp_parse_args(
[
'cost' => $this->multi_currency->get_price( $cost, 'shipping' ),
],
$args
);
return $args;
}
/**
* Returns the amount for a coupon.
*
* @param mixed $amount The coupon's amount.
* @param object $coupon The coupon object.
*
* @return mixed The converted coupon's amount.
*/
public function get_coupon_amount( $amount, $coupon ) {
$percent_coupon_types = [ 'percent' ];
if ( ! $amount
|| $coupon->is_type( $percent_coupon_types )
|| ! $this->compatibility->should_convert_coupon_amount( $coupon ) ) {
return $amount;
}
return $this->multi_currency->get_price( $amount, 'coupon' );
}
/**
* Returns the min or max amount for a coupon.
*
* @param mixed $amount The coupon's min or max amount.
*
* @return mixed The converted coupon's min or max amount.
*/
public function get_coupon_min_max_amount( $amount ) {
if ( ! $amount ) {
return $amount;
}
// Coupon mix/max prices are treated as products to avoid inconsistencies with charm pricing
// making a coupon invalid when the coupon min/max amount is the same as the product's price.
return $this->multi_currency->get_price( $amount, 'product' );
}
/**
* Returns the free shipping zone settings with converted min_amount.
*
* @param array $data The shipping zone settings.
*
* @return array The shipping zone settings with converted min_amount.
*/
public function get_free_shipping_min_amount( $data ) {
if ( empty( $data['min_amount'] ) ) {
return $data;
}
// Free shipping min amount is treated as products to avoid inconsistencies with charm pricing
// making a method invalid when its min amount is the same as the product's price.
$data['min_amount'] = $this->multi_currency->get_price( $data['min_amount'], 'product' );
return $data;
}
/**
* Register the hooks to set the min amount for free shipping methods.
*/
public function register_free_shipping_filters() {
$shipping_zones = \WC_Shipping_Zones::get_zones();
$default_zone = \WC_Shipping_Zones::get_zone( 0 );
if ( $default_zone ) {
$shipping_zones[] = [ 'shipping_methods' => $default_zone->get_shipping_methods() ];
}
foreach ( $shipping_zones as $shipping_zone ) {
foreach ( $shipping_zone['shipping_methods'] as $shipping_method ) {
if ( 'free_shipping' === $shipping_method->id ) {
$option_name = 'option_woocommerce_' . trim( $shipping_method->id ) . '_' . (int) $shipping_method->instance_id . '_settings';
add_filter( $option_name, [ $this, 'get_free_shipping_min_amount' ], 99 );
}
}
}
}
/**
* Adds the exchange rate and default currency to the order's meta if prices have been converted.
*
* @param int $order_id The order ID.
* @param WC_Order $order The order object.
*/
public function add_order_meta( $order_id, $order ) {
$default_currency = $this->multi_currency->get_default_currency();
// Do not add exchange rate if order was made in the store's default currency.
if ( $default_currency->get_code() === $order->get_currency() ) {
return;
}
$exchange_rate = $this->multi_currency->get_price( 1, 'exchange_rate' );
$order->update_meta_data( '_wcpay_multi_currency_order_exchange_rate', $exchange_rate );
$order->update_meta_data( '_wcpay_multi_currency_order_default_currency', $default_currency->get_code() );
$order->save_meta_data();
}
}
@@ -0,0 +1,85 @@
<?php
/**
* WooCommerce Payments Multi-Currency Geolocation Class
*
* @package WooCommerce\Payments
*/
namespace WCPay\MultiCurrency;
use WCPay\MultiCurrency\Interfaces\MultiCurrencyLocalizationInterface;
defined( 'ABSPATH' ) || exit;
/**
* Geolocation.
*/
class Geolocation {
/**
* MultiCurrencyLocalizationInterface instance.
*
* @var MultiCurrencyLocalizationInterface
*/
protected $localization_service;
/**
* Constructor.
*
* @param MultiCurrencyLocalizationInterface $localization_service The Localization Service instance.
*/
public function __construct( MultiCurrencyLocalizationInterface $localization_service ) {
$this->localization_service = $localization_service;
}
/**
* Gets the customer's currency based on their location.
*
* @return string|null Currency code or null if not found.
*/
public function get_currency_by_customer_location() {
$country = $this->get_country_by_customer_location();
return $this->localization_service->get_country_locale_data( $country )['currency_code'] ?? null;
}
/**
* Gets the customer's country based on their location.
*
* @return string Country code.
*/
public function get_country_by_customer_location() {
$country = $this->geolocate_customer();
if ( $country ) {
// Once we have a location, ensure it's valid, otherwise fallback to the default country.
$allowed_country_codes = WC()->countries->get_allowed_countries();
if ( ! array_key_exists( $country, $allowed_country_codes ) ) {
$country = null;
}
}
if ( ! $country ) {
$default_location = get_option( 'woocommerce_default_country', '' );
$location = wc_format_country_state_string( apply_filters( 'woocommerce_customer_default_location', $default_location ) );
$country = $location['country'];
}
return $country;
}
/**
* Attempts to guess the customer's country based on their IP.
*
* @return string|null Country code, or NULL if it couldn't be determined.
*/
private function geolocate_customer() {
// Exclude common bots from geolocation by user agent.
$ua = wc_get_user_agent();
if ( stripos( $ua, 'bot' ) !== false || stripos( $ua, 'spider' ) !== false || stripos( $ua, 'crawl' ) !== false ) {
return null;
}
$geolocation = \WC_Geolocation::geolocate_ip( '', true, true );
return $geolocation['country'] ?? null;
}
}
@@ -0,0 +1,61 @@
<?php
/**
* Interface MultiCurrencyAccountInterface
*
* @package WooCommerce\Payments\MultiCurrency\Interfaces
*/
namespace WCPay\MultiCurrency\Interfaces;
defined( 'ABSPATH' ) || exit;
interface MultiCurrencyAccountInterface {
/**
* Checks if the account is connected to the payment provider.
*
* @param bool $on_error Value to return on server error, defaults to false.
*
* @return bool True if the account is connected, false otherwise, $on_error on error.
*/
public function is_provider_connected( bool $on_error = false ): bool;
/**
* Checks if the account has been rejected, assumes the value of false on any account retrieval error.
* Returns false if the account is not connected.
*
* Note: We might want to use a more generic method to check if the account is in an enabled or
* disabled state for better compatibility between V1 and V2.
*
* @return bool True if the account is connected and rejected, false otherwise or on error.
*/
public function is_account_rejected(): bool;
/**
* Gets and caches the data for the account connected to this site.
*
* @param bool $force_refresh Forces data to be fetched from the server, rather than using the cache.
*
* @return array|bool Account data or false if failed to retrieve account data.
*/
public function get_cached_account_data( bool $force_refresh = false );
/**
* Gets the customer currencies supported for the account.
*
* @return array Currencies.
*/
public function get_account_customer_supported_currencies(): array;
/**
* Get list of countries supported by the provider.
*/
public function get_supported_countries(): array;
/**
* Get provider onboarding page url.
*
* @return string
*/
public function get_provider_onboarding_page_url(): string;
}
@@ -0,0 +1,30 @@
<?php
/**
* Interface MultiCurrencyApiClientInterface
*
* @package WooCommerce\Payments\MultiCurrency\Interfaces
*/
namespace WCPay\MultiCurrency\Interfaces;
defined( 'ABSPATH' ) || exit;
interface MultiCurrencyApiClientInterface {
/**
* Whether the API client is connected to the server.
*
* @return bool
*/
public function is_server_connected(): bool;
/**
* Get currency rates from the server.
*
* @param string $currency_from - The currency to convert from.
* @param ?array $currencies_to - An array of the currencies we want to convert into. If left empty, will get all supported currencies.
*
* @return array
*/
public function get_currency_rates( string $currency_from, $currencies_to = null ): array;
}
@@ -0,0 +1,46 @@
<?php
/**
* Interface MultiCurrencyCacheInterface
*
* @package WooCommerce\Payments\MultiCurrency\Interfaces
*/
namespace WCPay\MultiCurrency\Interfaces;
defined( 'ABSPATH' ) || exit;
interface MultiCurrencyCacheInterface {
const CURRENCIES_KEY = 'wcpay_multi_currency_cached_currencies';
/**
* Gets a value from the cache.
*
* @param string $key The key to look for.
* @param bool $force If set, return from the cache without checking for expiry.
*
* @return mixed The cache contents.
*/
public function get( string $key, bool $force = false );
/**
* Gets a value from cache or regenerates and adds it to the cache.
*
* @param string $key The options key to cache the data under.
* @param callable $generator Function/callable regenerating the missing value. If null or false is returned, it will be treated as an error.
* @param callable $validate_data Function/callable validating the data after it is retrieved from the cache. If it returns false, the cache will be refreshed.
* @param boolean $force_refresh Regenerates the cache regardless of its state if true.
* @param boolean $refreshed Is set to true if the cache has been refreshed without errors and with a non-empty value.
*
* @return mixed|null The cache contents. NULL on failure
*/
public function get_or_add( string $key, callable $generator, callable $validate_data, bool $force_refresh = false, bool &$refreshed = false );
/**
* Deletes a value from the cache.
*
* @param string $key The key to delete.
*
* @return void
*/
public function delete( string $key );
}
@@ -0,0 +1,31 @@
<?php
/**
* Interface MultiCurrencyLocalizationInterface
*
* @package WooCommerce\Payments\MultiCurrency\Interfaces
*/
namespace WCPay\MultiCurrency\Interfaces;
defined( 'ABSPATH' ) || exit;
interface MultiCurrencyLocalizationInterface {
/**
* Retrieves the currency's format from mapped data.
*
* @param string $currency_code The currency code.
*
* @return array The currency's format.
*/
public function get_currency_format( $currency_code ): array;
/**
* Returns the locale data for a country.
*
* @param string $country Country code.
*
* @return array Array with the country's locale data. Empty array if country not found.
*/
public function get_country_locale_data( $country ): array;
}
@@ -0,0 +1,34 @@
<?php
/**
* Interface MultiCurrencySettingsInterface
*
* @package WooCommerce\Payments\MultiCurrency\Interfaces
*/
namespace WCPay\MultiCurrency\Interfaces;
defined( 'ABSPATH' ) || exit;
interface MultiCurrencySettingsInterface {
/**
* Checks if dev mode is enabled.
*
* @return bool
*/
public function is_dev_mode(): bool;
/**
* Gets the plugin file path.
*
* @return string
*/
public function get_plugin_file_path(): string;
/**
* Gets the plugin version.
*
* @return string
*/
public function get_plugin_version(): string;
}
@@ -0,0 +1,68 @@
<?php
/**
* Class Logger
*
* @package WooCommerce\Payments\MultiCurrency
*/
namespace WCPay\MultiCurrency;
/**
* Logger for multi-currency.
*/
class Logger {
/**
* Log source identifier.
*/
const LOG_FILE = 'woopayments-multi-currency';
/**
* The WooCommerce logger instance.
*
* @var \WC_Logger|null
*/
private $logger;
/**
* Log a debug message.
*
* @param string $message The message to log.
*/
public static function debug( $message ) {
self::log( 'debug', $message );
}
/**
* Log an error message.
*
* @param string $message The message to log.
*/
public static function error( $message ) {
self::log( 'error', $message );
}
/**
* Log a notice message.
*
* @param string $message The message to log.
*/
public static function notice( $message ) {
self::log( 'notice', $message );
}
/**
* Log a message with a specific level.
*
* @param string $level The log level (debug, error, notice, etc.).
* @param string $message The message to log.
*/
private static function log( $level, $message ) {
if ( ! function_exists( 'wc_get_logger' ) ) {
return;
}
$logger = wc_get_logger();
$logger->log( $level, $message, [ 'source' => self::LOG_FILE ] );
}
}
@@ -0,0 +1,88 @@
<?php
/**
* Notify merchant that Multi-Currency is available.
*
* @package WooCommerce\Payments\MultiCurrency
*/
namespace WCPay\MultiCurrency\Notes;
use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;
use WCPay\MultiCurrency\Interfaces\MultiCurrencyAccountInterface;
defined( 'ABSPATH' ) || exit;
/**
* Class NoteMultiCurrencyAvailable
*/
class NoteMultiCurrencyAvailable {
use NoteTraits;
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-payments-notes-multi-currency-available';
/**
* Url to start the setup process. Now redirects to the wizard page.
*/
// TODO: Proper url needed for setup process.
const NOTE_SETUP_URL = 'admin.php?page=wc-admin&path=/payments/multi-currency-setup';
/**
* The account service instance.
*
* @var MultiCurrencyAccountInterface
*/
private static $account;
/**
* Get the note.
*/
public static function get_note() {
$note = new Note();
$note->set_title( __( 'Sell worldwide in multiple currencies', 'woocommerce-payments' ) );
$note->set_content( __( 'Boost your international sales by allowing your customers to shop and pay in their local currency.', 'woocommerce-payments' ) );
$note->set_content_data( (object) [] );
$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
$note->set_name( self::NOTE_NAME );
$note->set_source( 'woocommerce-payments' );
$note->add_action(
self::NOTE_NAME,
__( 'Set up now', 'woocommerce-payments' ),
self::NOTE_SETUP_URL,
'unactioned',
true
);
return $note;
}
/**
* Add the note if it passes predefined conditions.
*/
public static function possibly_add_note() {
// Don't add the note if the merchant didn't create a WCPay account yet.
if ( is_null( self::$account ) || ! self::$account->is_provider_connected() ) {
return;
}
if ( ! self::can_be_added() ) {
return;
}
$note = self::get_note();
$note->save();
}
/**
* Sets the account service instance reference on the class.
*
* @param MultiCurrencyAccountInterface $account account service instance.
*/
public static function set_account( MultiCurrencyAccountInterface $account ) {
self::$account = $account;
}
}
@@ -0,0 +1,5 @@
# WooPayments multi-currency directory
This directory contains the multi-currency module, which has been decoupled and extracted from the gateway code.
The module is responsible for handling all multi-currency functionality, both back-end and front-end.
@@ -0,0 +1,264 @@
<?php
/**
* Class RestController
*
* @package WooCommerce\Payments\MultiCurrency
*/
namespace WCPay\MultiCurrency;
use Exception;
use WCPay\MultiCurrency\Exceptions\InvalidCurrencyException;
use WCPay\MultiCurrency\MultiCurrency;
defined( 'ABSPATH' ) || exit;
/**
* REST controller for multi-currency.
*/
class RestController extends \WP_REST_Controller {
/**
* Endpoint namespace.
*
* @var string
*/
protected $namespace = 'wc/v3';
/**
* Endpoint path.
*
* @var string
*/
protected $rest_base = 'payments/multi-currency';
/**
* MultiCurrency instance.
*
* @var MultiCurrency
*/
protected $multi_currency;
/**
* Constructor.
*
* @param MultiCurrency $multi_currency MultiCurrency instance.
*/
public function __construct( MultiCurrency $multi_currency ) {
$this->multi_currency = $multi_currency;
}
/**
* Configure REST API routes.
*/
public function register_routes() {
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/currencies',
[
'methods' => \WP_REST_Server::READABLE,
'callback' => [ $this, 'get_store_currencies' ],
'permission_callback' => [ $this, 'check_permission' ],
]
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/update-enabled-currencies',
[
'methods' => \WP_REST_Server::CREATABLE,
'args' => [
'enabled' => [
'type' => 'array',
'required' => true,
],
],
'callback' => [ $this, 'update_enabled_currencies' ],
'permission_callback' => [ $this, 'check_permission' ],
]
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/currencies/(?P<currency_code>[A-Za-z]{3})',
[
'methods' => \WP_REST_Server::READABLE,
'args' => [
'currency_code' => [
'type' => 'string',
'format' => 'text-field',
'required' => true,
],
],
'callback' => [ $this, 'get_single_currency_settings' ],
'permission_callback' => [ $this, 'check_permission' ],
]
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/get-settings',
[
'methods' => \WP_REST_Server::READABLE,
'callback' => [ $this, 'get_settings' ],
'permission_callback' => [ $this, 'check_permission' ],
]
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/currencies/(?P<currency_code>[A-Za-z]{3})',
[
'methods' => \WP_REST_Server::CREATABLE,
'args' => [
'currency_code' => [
'type' => 'string',
'format' => 'text-field',
'required' => true,
],
'exchange_rate_type' => [
'type' => 'string',
'format' => 'text-field',
'required' => true,
],
'manual_rate' => [
'type' => 'number',
'required' => false,
],
'price_rounding' => [
'type' => 'number',
'required' => true,
],
'price_charm' => [
'type' => 'number',
'required' => true,
],
],
'callback' => [ $this, 'update_single_currency_settings' ],
'permission_callback' => [ $this, 'check_permission' ],
]
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/update-settings',
[
'methods' => \WP_REST_Server::CREATABLE,
'args' => [
'wcpay_multi_currency_enable_auto_currency' => [
'type' => 'string',
'format' => 'text-field',
'required' => true,
],
'wcpay_multi_currency_enable_storefront_switcher' => [
'type' => 'string',
'format' => 'text-field',
'required' => true,
],
],
'callback' => [ $this, 'update_settings' ],
'permission_callback' => [ $this, 'check_permission' ],
]
);
}
/**
* Retrieve currencies for the store.
*
* @return \WP_REST_Response|\WP_Error Array of the store currencies structure.
*/
public function get_store_currencies() {
return rest_ensure_response( $this->multi_currency->get_store_currencies() );
}
/**
* Update enabled currencies based on posted data.
*
* @param \WP_REST_Request $request Full data about the request.
*
* @return \WP_REST_Response|\WP_Error The store currencies structure or WP_Error.
*/
public function update_enabled_currencies( $request ) {
$enabled = $request->get_param( 'enabled' );
try {
$this->multi_currency->set_enabled_currencies( $enabled );
$response = $this->get_store_currencies();
} catch ( InvalidCurrencyException $e ) {
$response = new \WP_Error( $e->getCode(), $e->getMessage() );
}
return rest_ensure_response( $response );
}
/**
* Gets the currency settings for a single currency.
*
* @param \WP_REST_Request $request Full data about the request.
*
* @return \WP_REST_Response|\WP_Error The single currency settings as an array.
*/
public function get_single_currency_settings( $request ) {
$currency_code = $request->get_param( 'currency_code' );
try {
$response = $this->multi_currency->get_single_currency_settings( $currency_code );
} catch ( InvalidCurrencyException $e ) {
$response = new \WP_Error( $e->getCode(), $e->getMessage() );
}
return rest_ensure_response( $response );
}
/**
* Updates the currency settings for a single currency.
*
* @param \WP_REST_Request $request Full data about the request.
*
* @return \WP_REST_Response|\WP_Error The single currency settings as an array.
*/
public function update_single_currency_settings( $request ) {
$currency_code = $request->get_param( 'currency_code' );
$exchange_rate_type = $request->get_param( 'exchange_rate_type' );
$price_rounding = $request->get_param( 'price_rounding' );
$price_charm = $request->get_param( 'price_charm' );
$manual_rate = $request->get_param( 'manual_rate' ) ?? null;
try {
$this->multi_currency->update_single_currency_settings( $currency_code, $exchange_rate_type, $price_rounding, $price_charm, $manual_rate );
$response = $this->multi_currency->get_single_currency_settings( $currency_code );
} catch ( Exception $e ) {
$response = new \WP_Error( $e->getCode(), $e->getMessage() );
}
return rest_ensure_response( $response );
}
/**
* Gets the store settings for Multi-Currency.
*
* @return \WP_REST_Response|\WP_Error The store settings as an array.
*/
public function get_settings() {
return rest_ensure_response( $this->multi_currency->get_settings() );
}
/**
* Updates Multi-Currency store settings parameters.
*
* @param \WP_REST_Request $request Full data about the request.
*
* @return \WP_REST_Response|\WP_Error The store settings as an array.
*/
public function update_settings( $request ) {
$params = $request->get_params();
$this->multi_currency->update_settings( $params );
return rest_ensure_response( $this->multi_currency->get_settings() );
}
/**
* Verify access.
*/
public function check_permission() {
return current_user_can( 'manage_woocommerce' );
}
}
@@ -0,0 +1,95 @@
<?php
/**
* WooCommerce Payments Multi-currency Settings
*
* @package WooCommerce\Admin
*/
namespace WCPay\MultiCurrency;
defined( 'ABSPATH' ) || exit;
/**
* Settings.
*/
class Settings extends \WC_Settings_Page {
/**
* The id of the plugin.
*
* @var string
*/
public $id;
/**
* The tab label.
*
* @var string
*/
public $label;
/**
* Instance of MultiCurrency class.
*
* @var MultiCurrency
*/
protected $multi_currency;
/**
* Constructor.
*
* @param MultiCurrency $multi_currency The MultiCurrency instance.
*/
public function __construct( MultiCurrency $multi_currency ) {
$this->multi_currency = $multi_currency;
$this->id = $this->multi_currency->id;
$this->label = _x( 'Multi-currency', 'Settings tab label', 'woocommerce-payments' );
parent::__construct();
}
/**
* Initializes this class' WP hooks.
*
* @return void
*/
public function init_hooks() {
// TODO: Only register emoji script in settings page. Until WC Admin decide if they will enable it too: https://github.com/woocommerce/woocommerce-admin/issues/6388.
add_action( 'admin_print_scripts', [ $this, 'maybe_add_print_emoji_detection_script' ] );
add_action( 'woocommerce_admin_field_wcpay_multi_currency_settings_page', [ $this, 'wcpay_multi_currency_settings_page' ] );
}
/**
* Get settings array.
*
* @param string $current_section Section being shown.
* @return array
*/
public function get_settings( $current_section = '' ) {
return [
[
'type' => 'wcpay_multi_currency_settings_page',
],
];
}
/**
* Output container for enabled currencies list.
*/
public function wcpay_multi_currency_settings_page() {
// Hide original save button.
$GLOBALS['hide_save_button'] = true;
?>
<div id="wcpay_multi_currency_settings_container" aria-describedby="wcpay_multi_currency_settings_container-description"></div>
<?php
}
/**
* Load inline Emoji detection script on multi-currency settings page
*/
public function maybe_add_print_emoji_detection_script() {
if ( $this->multi_currency->is_multi_currency_settings_page() ) {
print_emoji_detection_script();
}
}
}
@@ -0,0 +1,117 @@
<?php
/**
* WooCommerce Payments Multi-Currency Settings
*
* @package WooCommerce\Admin
*/
namespace WCPay\MultiCurrency;
use WCPay\MultiCurrency\Interfaces\MultiCurrencyAccountInterface;
defined( 'ABSPATH' ) || exit;
/**
* MultiCurrency settings placeholder containing a CTA to connect the account.
*/
class SettingsOnboardCta extends \WC_Settings_Page {
/**
* Link to the Multi-Currency documentation page.
*
* @var string
*/
const LEARN_MORE_URL = 'https://woocommerce.com/document/woopayments/currencies/multi-currency-setup/';
/**
* MultiCurrency instance.
*
* @var MultiCurrency
*/
private $multi_currency;
/**
* Instance of MultiCurrencyAccountInterface.
*
* @var MultiCurrencyAccountInterface
*/
private $payments_account;
/**
* Constructor.
*
* @param MultiCurrency $multi_currency The MultiCurrency instance.
* @param MultiCurrencyAccountInterface $payments_account Payments Account instance.
*/
public function __construct( MultiCurrency $multi_currency, MultiCurrencyAccountInterface $payments_account ) {
$this->multi_currency = $multi_currency;
$this->payments_account = $payments_account;
$this->id = $this->multi_currency->id;
$this->label = _x( 'Multi-currency', 'Settings tab label', 'woocommerce-payments' );
parent::__construct();
}
/**
* Initializes this class' WP hooks.
*
* @return void
*/
public function init_hooks() {
add_action( 'woocommerce_admin_field_wcpay_currencies_settings_onboarding_cta', [ $this, 'currencies_settings_onboarding_cta' ] );
}
/**
* Output the call to action button if needing to onboard.
*/
public function currencies_settings_onboarding_cta() {
$href = $this->payments_account->get_provider_onboarding_page_url();
?>
<div>
<p>
<?php
printf(
/* translators: %s: WooPayments */
esc_html__( 'To add new currencies to your store, please finish setting up %s.', 'woocommerce-payments' ),
'WooPayments'
);
?>
</p>
<a href="<?php echo esc_url( $href ); ?>" id="wcpay_enabled_currencies_onboarding_cta" type="button" class="button-primary">
<?php esc_html_e( 'Get started', 'woocommerce-payments' ); ?>
</a>
</div>
<?php
}
/**
* Get settings array.
*
* @param string $current_section Section being shown.
* @return array
*/
public function get_settings( $current_section = '' ) {
// Hide the save button because there are no settings to save.
global $hide_save_button;
$hide_save_button = true;
return [
[
'title' => __( 'Enabled currencies', 'woocommerce-payments' ),
'desc' => sprintf(
/* translators: %s: url to documentation. */
__( 'Accept payments in multiple currencies. Prices are converted based on exchange rates and rounding rules. <a href="%s">Learn more</a>', 'woocommerce-payments' ),
self::LEARN_MORE_URL
),
'type' => 'title',
'id' => $this->id . '_enabled_currencies',
],
[
'type' => 'wcpay_currencies_settings_onboarding_cta',
],
[
'type' => 'sectionend',
'id' => $this->id . '_enabled_currencies',
],
];
}
}
@@ -0,0 +1,119 @@
<?php
/**
* Class StorefrontIntegration
*
* @package WooCommerce\Payments\StorefrontIntegration
*/
namespace WCPay\MultiCurrency;
defined( 'ABSPATH' ) || exit;
/**
* Class that controls Multi-Currency Storefront Integration.
*/
class StorefrontIntegration {
/**
* Multi-Currency instance.
*
* @var MultiCurrency
*/
protected $multi_currency;
/**
* Constructor.
*
* @param MultiCurrency $multi_currency The MultiCurrency instance.
*/
public function __construct( MultiCurrency $multi_currency ) {
$this->multi_currency = $multi_currency;
$this->init_actions_and_filters();
}
/**
* Adds the CSS to the head of the page.
*
* @return void
*/
public function add_inline_css() {
$css = '
#woocommerce-payments-multi-currency-storefront-widget {
float: right;
}
#woocommerce-payments-multi-currency-storefront-widget form {
margin: 0;
}
';
wp_add_inline_style(
'storefront-style',
apply_filters( MultiCurrency::FILTER_PREFIX . 'storefront_widget_css', $css )
);
}
/**
* This modifies the breadcrumb defaults for us to be able to place the widget.
*
* @param array $defaults The defaults breadcrumb properties.
*
* @return array The modified defaults properties.
*/
public function modify_breadcrumb_defaults( array $defaults ): array {
// Set the instance and args arrays for the widget.
$instance = apply_filters( MultiCurrency::FILTER_PREFIX . 'storefront_widget_instance', [] );
$args = apply_filters(
MultiCurrency::FILTER_PREFIX . 'storefront_widget_args',
[
'before_widget' => '<div id="woocommerce-payments-multi-currency-storefront-widget" class="woocommerce-breadcrumb">',
'after_widget' => '</div>',
]
);
/**
* Some storefront child themes use different wrappers and styles. We need to place the widget before
* the <nav> to display it properly.
*/
$defaults['wrap_before'] = str_replace( '<nav', $this->multi_currency->get_switcher_widget_markup( $instance, $args ) . '<nav', $defaults['wrap_before'] );
return $defaults;
}
/**
* Adds the actions and filters.
*
* @return void
*/
private function init_actions_and_filters() {
// Do not enable the breadcrumb widget if there's only one currency active.
if ( 1 >= count( $this->multi_currency->get_enabled_currencies() ) ) {
return;
}
// Simulation overrides for Multi-Currency onboarding preview.
$simulation_variables = $this->multi_currency->get_multi_currency_onboarding_simulation_variables() ?? [];
$simulation_enabled = false;
$simulation_hide_switcher = false;
if ( 0 < count( $simulation_variables ) && isset( $simulation_variables['enable_storefront_switcher'] ) ) {
// We have a incoming override request! Simulate the flag.
$simulation_enabled = true;
$enable_storefront_switcher = (bool) $simulation_variables['enable_storefront_switcher'];
// If the Storefront switcher is not enabled on the onboarding page, hide it.
if ( ! $enable_storefront_switcher ) {
$simulation_hide_switcher = true;
}
}
// We want this enabled by default, so we default the option to 'yes'.
if ( ! $simulation_hide_switcher
&& (
$simulation_enabled
|| $this->multi_currency->is_using_storefront_switcher()
)
) {
add_filter( 'woocommerce_breadcrumb_defaults', [ $this, 'modify_breadcrumb_defaults' ], 9999 );
add_action( 'wp_enqueue_scripts', [ $this, 'add_inline_css' ], 50 );
}
}
}
@@ -0,0 +1,205 @@
<?php
/**
* Class Tracking
*
* @package WooCommerce\Payments\MultiCurrency
*/
namespace WCPay\MultiCurrency;
defined( 'ABSPATH' ) || exit;
/**
* Class that controls Multi-Currency Tracking functionality.
*/
class Tracking {
/**
* MultiCurrency class.
*
* @var MultiCurrency
*/
private $multi_currency;
/**
* Class constructor.
*
* @param MultiCurrency $multi_currency MultiCurrency class.
*/
public function __construct( MultiCurrency $multi_currency ) {
$this->multi_currency = $multi_currency;
}
/**
* Initializes this class' WP hooks.
*
* @return void
*/
public function init_hooks() {
add_filter( 'woocommerce_tracker_data', [ $this, 'add_tracker_data' ], 50 );
}
/**
* Add our data to the tracking data from WC core.
*
* @param array $data The array of data WC core has already built.
*
* @return array Our modified data.
*/
public function add_tracker_data( array $data ): array {
$data[ $this->multi_currency->id ] = [
'enabled_currencies' => $this->get_enabled_currencies(),
'default_currency' => $this->get_currency_data_array( $this->multi_currency->get_default_currency() ),
'order_counts' => $this->get_mc_order_count(),
];
return $data;
}
/**
* Returns an assoc array of the data we want from a Currency object.
*
* @param Currency $currency The Currency object we want data from.
*
* @return array Assoc array with the Currency data.
*/
private function get_currency_data_array( Currency $currency ): array {
$data = [
'code' => $currency->get_code(),
'name' => html_entity_decode( $currency->get_name() ),
];
// Return early if it's the default currency.
if ( $currency->get_code() === $this->multi_currency->get_default_currency()->get_code() ) {
return $data;
}
// Is it a zero decimal currency?
$is_zero_decimal = $currency->get_is_zero_decimal();
// Is it using a custom or automatic rate?
$rate_type = get_option( $this->multi_currency->id . '_exchange_rate_' . $currency->get_id(), 'automatic' );
$rate_type = 'automatic' === $rate_type ? $rate_type . ' (default)' : $rate_type;
// What is the price rounding setting?
$price_rounding_default = $is_zero_decimal ? '100' : '1.00';
$price_rounding = $currency->get_rounding();
$price_rounding = $price_rounding_default === $price_rounding ? $price_rounding . ' (default)' : $price_rounding;
// What is the price charm setting?
$price_charm = $currency->get_charm();
$price_charm = 0.00 === $price_charm ? '0.00 (default)' : $price_charm;
$additional_data = [
'is_zero_decimal' => $is_zero_decimal,
'rate_type' => $rate_type,
'price_rounding' => $price_rounding,
'price_charm' => $price_charm,
];
return array_merge( $data, $additional_data );
}
/**
* Gets the enabled currencies as an associative array. Excludes the store/default currency.
*
* @return array Array of currencies, or empty array if none found.
*/
private function get_enabled_currencies(): array {
$enabled_currencies = $this->multi_currency->get_enabled_currencies();
$default_currency = $this->multi_currency->get_default_currency();
unset( $enabled_currencies[ $default_currency->get_code() ] );
$enabled_array = [];
foreach ( $enabled_currencies as $currency ) {
$enabled_array[ $currency->get_code() ] = $this->get_currency_data_array( $currency );
}
return $enabled_array;
}
/**
* Queries the database to see how many orders have been made using Multi-Currency.
*
* @return array Result count.
*/
private function get_mc_order_count(): array {
global $wpdb;
$query_on_orders = "
SELECT
gateway, currency, SUM(total) AS totals, COUNT(order_id) AS counts
FROM (
SELECT
orders.id AS order_id, orders.payment_method as gateway, orders.total_amount as total, orders.currency as currency
FROM
{$wpdb->prefix}wc_orders orders
LEFT JOIN
{$wpdb->prefix}wc_orders_meta order_meta ON order_meta.order_id = orders.id
INNER JOIN
{$wpdb->prefix}wc_orders_meta mc_meta ON mc_meta.order_id = orders.id
AND mc_meta.meta_key = '_wcpay_multi_currency_order_exchange_rate'
WHERE orders.type = 'shop_order'
AND orders.status in ( 'wc-completed', 'wc-processing', 'wc-refunded' )
GROUP BY orders.id
) order_gateways
GROUP BY currency, gateway
";
$query_on_posts = "
SELECT
gateway, currency, SUM(total) AS totals, COUNT(order_id) AS counts
FROM (
SELECT
orders.id AS order_id,
MAX(CASE WHEN order_meta.meta_key = '_payment_method' THEN order_meta.meta_value END) gateway,
MAX(CASE WHEN order_meta.meta_key = '_order_total' THEN order_meta.meta_value END) total,
MAX(CASE WHEN order_meta.meta_key = '_order_currency' THEN order_meta.meta_value END) currency
FROM
{$wpdb->prefix}posts orders
LEFT JOIN
{$wpdb->prefix}postmeta order_meta ON order_meta.post_id = orders.id
INNER JOIN
{$wpdb->prefix}postmeta mc_meta ON mc_meta.post_id = orders.id
AND mc_meta.meta_key = '_wcpay_multi_currency_order_exchange_rate'
WHERE orders.post_type = 'shop_order'
AND orders.post_status in ( 'wc-completed', 'wc-processing', 'wc-refunded' )
AND order_meta.meta_key in ( '_payment_method', '_order_total', '_order_currency' )
GROUP BY orders.id
) order_gateways
GROUP BY currency, gateway
";
if ( class_exists( 'Automattic\WooCommerce\Utilities\OrderUtil' ) &&
\Automattic\WooCommerce\Utilities\OrderUtil::custom_orders_table_usage_is_enabled() ) {
$orders_by_currency = $wpdb->get_results( $query_on_orders ); //phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
} else {
$orders_by_currency = $wpdb->get_results( $query_on_posts ); //phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
}
$currencies = [];
$total_count = 0;
foreach ( $orders_by_currency as $group ) {
// Get current counts and totals.
$counts = $currencies[ $group->currency ]['counts'] ?? 0;
$totals = $currencies[ $group->currency ]['totals'] ?? 0;
// Update the counts and totals for the currency.
$currencies[ $group->currency ]['counts'] = $counts + $group->counts;
$currencies[ $group->currency ]['totals'] = $totals + $group->totals;
// If something provides a 100% discount, the payment method is null. This could be coupons, gift cards, etc.
$gateway = $group->gateway ?? 'unknown';
// Update the counts and totals per gateway for the currency.
$currencies[ $group->currency ]['gateways'][ $gateway ] = [
'counts' => $group->counts,
'totals' => $group->totals,
];
// Update the total count.
$total_count += $group->counts;
}
return [
'counts' => $total_count,
'currencies' => $currencies,
];
}
}
@@ -0,0 +1,86 @@
<?php
/**
* WooCommerce Payments Multi-Currency User Settings
*
* @package WooCommerce\Payments
*/
namespace WCPay\MultiCurrency;
defined( 'ABSPATH' ) || exit;
/**
* Class that add Multi-Currency settings to user my account page.
*/
class UserSettings {
/**
* Multi-Currency instance.
*
* @var MultiCurrency
*/
protected $multi_currency;
/**
* Constructor.
*
* @param MultiCurrency $multi_currency The MultiCurrency instance.
*/
public function __construct( MultiCurrency $multi_currency ) {
$this->multi_currency = $multi_currency;
}
/**
* Initializes this class' WP hooks.
*
* @return void
*/
public function init_hooks() {
// Only show currency selector if more than one currency is enabled.
if ( 1 < count( $this->multi_currency->get_enabled_currencies() ) ) {
add_action( 'woocommerce_edit_account_form', [ $this, 'add_presentment_currency_switch' ] );
add_action( 'woocommerce_save_account_details', [ $this, 'save_presentment_currency' ] );
}
}
/**
* Add a select to allow user choose default currency in `my account > account details`.
*
* @return void
*/
public function add_presentment_currency_switch() {
?>
<p class="woocommerce-form-row woocommerce-form-row--first form-row form-row-first">
<label for="wcpay_selected_currency"><?php esc_html_e( 'Default currency', 'woocommerce-payments' ); ?></label>
<select
name="wcpay_selected_currency"
id="wcpay_selected_currency"
>
<?php
foreach ( $this->multi_currency->get_enabled_currencies() as $currency ) {
$code = $currency->get_code();
$symbol = $currency->get_symbol();
$selected = $this->multi_currency->get_selected_currency()->code === $code ? ' selected' : '';
echo "<option value=\"$code\"$selected>$symbol $code</option>"; // phpcs:ignore WordPress.Security.EscapeOutput
}
?>
</select>
<span><em><?php esc_html_e( 'Select your preferred currency for shopping and payments.', 'woocommerce-payments' ); ?></em></span>
</p>
<div class="clear"></div>
<?php
}
/**
* Hook into save account details to capture the new value `wcpay_selected_currency` and persist it.
*
* @return void
*/
public function save_presentment_currency() {
if ( isset( $_POST['wcpay_selected_currency'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification
$currency_code = wc_clean( wp_unslash( $_POST['wcpay_selected_currency'] ) ); // phpcs:ignore WordPress.Security.NonceVerification
$this->multi_currency->update_selected_currency( $currency_code );
}
}
}
@@ -0,0 +1,116 @@
<?php
/**
* Class Utils
*
* @package WooCommerce\Payments\Utils
*/
namespace WCPay\MultiCurrency;
defined( 'ABSPATH' ) || exit;
/**
* Class that controls Multi-Currency Utils.
*/
class Utils {
/**
* Checks backtrace calls to see if a certain call has been made.
*
* @param array $calls Array of the calls to check for.
*
* @return bool True if found, false if not.
*/
public function is_call_in_backtrace( array $calls ): bool {
$backtrace = wp_debug_backtrace_summary( null, 0, false ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions
foreach ( $calls as $call ) {
if ( in_array( $call, $backtrace, true ) ) {
return true;
}
}
return false;
}
/**
* Checks the query_vars array for a particular pagename and variable to be set.
*
* @param array $pages Array of the pagenames to check for.
* @param array $vars Array of the vars to check for.
*
* @return bool True if found, false if not.
*/
public function is_page_with_vars( array $pages, array $vars ): bool {
global $wp;
if ( $wp->query_vars && isset( $wp->query_vars['pagename'] ) && in_array( $wp->query_vars['pagename'], $pages, true ) ) {
foreach ( $vars as $var ) {
if ( isset( $wp->query_vars[ $var ] ) ) {
return true;
}
}
}
return false;
}
/**
* Checks if is a REST API request and the HTTP referer matches admin url.
*
* @return boolean
*/
public static function is_admin_api_request(): bool {
return 0 === stripos( wp_get_referer(), admin_url() ) && WC()->is_rest_api_request() && ! self::is_store_api_request();
}
/**
* Writes the session into the client cookie.
*
* @param bool $set Should the session cookie be set.
*
* @return void
*/
public static function set_customer_session_cookie( bool $set ) {
WC()->session->set_customer_session_cookie( $set );
}
/**
* Returns true if the request is a store REST API request.
*
* @return bool
*/
public static function is_store_api_request() {
if ( function_exists( 'WC' ) && method_exists( WC(), 'is_store_api_request' ) ) {
return WC()->is_store_api_request();
}
// The logic below is sourced from `WC()->is_store_api_request()`.
if ( empty( $_SERVER['REQUEST_URI'] ) ) {
return false;
}
// phpcs:disable WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
return false !== strpos( $_SERVER['REQUEST_URI'], trailingslashit( rest_get_url_prefix() ) . 'wc/store/' );
}
/**
* Determine if the request that's currently being processed is a Store API batch request.
*
* @return bool True if the request is a Store API batch request, false otherwise.
*/
public static function is_store_batch_request(): bool {
// @TODO We should move to a more robust way of getting to the route, like WC is doing in the StoreAPI library. https://github.com/woocommerce/woocommerce/blob/9ac48232a944baa2dbfaa7dd47edf9027cca9519/plugins/woocommerce/src/StoreApi/Authentication.php#L15-L15
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 {
// Extract the request path from the request URL.
$url_parts = wp_parse_url( esc_url_raw( $_SERVER['REQUEST_URI'] ?? '' ) ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash
$request_path = ! empty( $url_parts['path'] ) ? rtrim( $url_parts['path'], '/' ) : '';
// Remove the REST API prefix from the request path to end up with the route.
$rest_route = str_replace( trailingslashit( rest_get_url_prefix() ), '', $request_path );
}
// Bail early if the rest route is empty.
if ( empty( $rest_route ) ) {
return false;
}
return 1 === preg_match( '@^\/wc\/store(\/v[\d]+)?\/batch@', $rest_route );
}
}