* rates.
*
* @return array An array with the next and last update of the exchange rates.
*/
public function get_exchange_rates_schedule_info() {
// Retrieve the timestamp of next scheduled exchange rates update
if(wp_get_schedule($this->_exchange_rates_update_hook) === false) {
$next_update_schedule = __('Not Scheduled', $this->textdomain);
}
else {
$next_update_schedule = date_i18n(get_datetime_format(), wp_next_scheduled($this->_exchange_rates_update_hook));
}
// Retrieve the timestamp of last update
if(($last_update_timestamp = $this->current_settings(self::FIELD_EXCHANGE_RATES_LAST_UPDATE)) != null) {
$last_update_timestamp_fmt = date_i18n(get_datetime_format(), $last_update_timestamp);
}
else {
$last_update_timestamp_fmt = __('Never updated', $this->textdomain);
}
return array(
'next_update' => $next_update_schedule,
'last_update' => $last_update_timestamp_fmt,
);
}
/**
* Updates the plugin settings received as an argument with the latest exchange
* rates, adding a settings error if the operation fails.
*
* @param array settings Current plugin settings.
* @return bool
*/
public function update_exchange_rates(array &$settings, &$errors = array()) {
// Keep track of the VAT currency, it will be used as a reference for conversion
$vat_currency = get_value(self::FIELD_VAT_CURRENCY, $settings, $this->vat_currency());
// Get the latest exchange rates from the provider
$exchange_rates_model = $this->get_exchange_rates_model(get_value(self::FIELD_EXCHANGE_RATES_PROVIDER, $settings, null));
$latest_exchange_rates = $exchange_rates_model->get_exchange_rates($vat_currency,
$this->enabled_currencies());
$exchange_rates_model_errors = $exchange_rates_model->get_errors();
if(($latest_exchange_rates === null) || !empty($exchange_rates_model_errors)) {
foreach($exchange_rates_model_errors as $code => $message) {
$errors['exchange-rates-error-' . $code] = $message;
}
return false;
}
$exchange_rates = get_value(self::FIELD_EXCHANGE_RATES, $settings, array());
// Update the exchange rates and add them to the settings to be saved
$settings[self::FIELD_EXCHANGE_RATES] = $this->merge_exchange_rates($exchange_rates,
$latest_exchange_rates,
$vat_currency);
return true;
}
/**
* Updates a list of exchange rates settings by replacing the rates with new
* ones passed as a parameter.
*
* @param array exchange_rates The list of exchange rate settings to be updated.
* @param array new_exchange_rates The new exchange rates as a simple list of
* currency => rate pairs.
* @param string vat_currency The currency used for VAT reports. It will have
* an exchange rate of "1".
* @return array The updated exchange rate settings.
*/
protected function merge_exchange_rates($exchange_rates, array $new_exchange_rates, $vat_currency) {
$exchange_rates = empty($exchange_rates) ? array() : $exchange_rates;
foreach($new_exchange_rates as $currency => $rate) {
// Base VAT currency has a fixed exchange rate of 1 (it doesn't need to be
// converted)
if($currency == $vat_currency) {
$exchange_rates[$currency]['rate'] = 1;
continue;
}
$currency_settings = get_value($currency, $exchange_rates, $this->default_currency_settings());
// Update the exchange rate unless the currency is set to "set manually"
// to prevent automatic updates
if(get_value('set_manually', $currency_settings, 0) == 0) {
$currency_settings['rate'] = $rate;
}
$exchange_rates[$currency] = $currency_settings;
}
return $exchange_rates;
}
/**
* Get the instance of the exchange rate model to use to retrieve the rates.
*
* @param string key The key identifying the exchange rate model class.
* @param array An array of settings that can be used to override the ones
* currently saved in the configuration.
* @param string default_class The exchange rates model class to use as a default.
* @return \Aelia\WC\ExchangeRatesModel.
*/
protected function get_exchange_rates_model_instance($key,
array $settings = null,
$default_class = self::DEFAULT_EXCHANGE_RATES_PROVIDER) {
$model_info = get_value($key, $this->_exchange_rates_models);
$model_class = get_value('class_name', $model_info, $default_class);
return new $model_class($settings);
}
/**
* Returns the label of the provider used to retrieve current exchange rates
* for VAT currency.
*
* @return string
*/
public function get_current_exchange_rates_provider_label() {
$model_info = get_value($this->get(self::FIELD_EXCHANGE_RATES_PROVIDER), $this->_exchange_rates_models);
return get_value('label', $model_info, __('Not available', $this->textdomain));
}
/**
* Returns the instance of the exchange rate model.
*
* @param string exchange_rates_model_key The key to retrieve the exchange
* rates model class.
* @param array settings The settings to pass to the exchange rates model instance.
* @return \Aelia\WC\ExchangeRatesModel.
*/
protected function get_exchange_rates_model($exchange_rates_model_key, $settings = null) {
if(empty($this->_exchange_rates_model)) {
$this->_exchange_rates_model = $this->get_exchange_rates_model_instance($exchange_rates_model_key,
$settings);
}
return $this->_exchange_rates_model;
}
/**
* Validates the settings specified via the Options page.
*
* @param array settings An array of settings.
*/
public function validate_settings($settings) {
// Merge the new settings with some defaults. This is especially important
// for multi-select options. If the user empties those fields (i.e. doesn't
// select anything), the fields are not passed with the $_POST data.
// Due to that, a multi-select option that was previously populated would
// not be emptied. By setting the default as an empty array, even if the
// field is missing we can save it as "nothing selected"
$settings = array_merge(array(
self::FIELD_SALE_DISALLOWED_COUNTRIES => array(),
self::FIELD_TAX_CLASSES_EXCLUDED_FROM_MOSS => array(),
), $settings);
$this->validation_errors = array();
$processed_settings = $this->current_settings();
// Save the schedule for automatic update of exchange rates
//
// IMPORTANT
// This step must be performed before the storing of the new settings
// in the $processed_settings variable. This is necessary because the
// old settings and the new settings have to be compared to determine
// if the schedule has to be updated
// @since 1.12.1.191217
$this->set_exchange_rates_update_schedule($processed_settings, $settings);
// Validate exchange rates
$exchange_rates = $settings[self::FIELD_EXCHANGE_RATES] ?? array();
$exchange_rates_to_update = array();
if($this->validate_exchange_rates($exchange_rates, $exchange_rates_to_update) === true) {
$settings[self::FIELD_EXCHANGE_RATES] = $exchange_rates;
}
// We can update exchange rates only if an exchange rates provider has been
// configured correctly
if($this->validate_exchange_rates_provider_settings($settings) === true) {
// Update exchange rates in three cases:
// - If none is present
// - If the list of exchange rates to update is not empty
// - If button "Save and update Exchange Rates" has been clicked
if(empty($settings[self::FIELD_EXCHANGE_RATES]) || !empty($exchange_rates_to_update) ||
(isset($_POST[self::SETTINGS_KEY]) && !empty($_POST[self::SETTINGS_KEY]['update_exchange_rates_button']))) {
// Fetch the latest exchange rates and merge them with the one entered manually
if($this->update_exchange_rates($settings, $errors) === false) {
$this->add_multiple_settings_errors($errors);
}
else {
$settings[self::FIELD_EXCHANGE_RATES_LAST_UPDATE] = current_time('timestamp');
// Only show the "Exchange rates have been updated" message if there are some
// exchange rates. In a single-currency environment, this message is misleading.
// @since 2.0.4.201231
if(!empty($settings[self::FIELD_EXCHANGE_RATES]) && (count($this->enabled_currencies()) > 1)) {
// This is not an "error", but a confirmation message. Unfortunately,
// WordPress only has "add_settings_error" to add messages of any type
add_settings_error(self::SETTINGS_KEY, 'exchange-rates-updated', __('Settings saved. Exchange rates have been updated.', $this->textdomain), 'updated');
}
}
}
}
// Save settings if they passed validation
// Allow 3rd parties to validate the settings as well.
// @since 2.0.0.201201
if(apply_filters('wc_aelia_eu_vat_assistant_settings_are_valid', true, $settings) && empty($this->validation_errors)) {
// Save the other settings that don't require validation
$processed_settings = array_merge($processed_settings, $settings);
}
// Validate the VAT number to be used for VIES requests.
// IMPORTANT
// This validation is deliberately performed after the "wc_aelia_eu_vat_assistant_settings_are_valid" filter. This is to allow
// the merchant to enter an invalid requester VAT number, without stopping the EU VAT Assistant from saving the other settings.
// @since 2.0.5.210102
$settings[self::FIELD_VIES_REQUESTER_VAT_NUMBER] = trim($settings[self::FIELD_VIES_REQUESTER_VAT_NUMBER] ?? '');
if($this->validate_vies_requester_vat_number($settings[self::FIELD_VIES_REQUESTER_VAT_COUNTRY], $settings[self::FIELD_VIES_REQUESTER_VAT_NUMBER])) {
// VIES validation settings
// @since 1.9.0.181022
$processed_settings[self::FIELD_VIES_REQUESTER_VAT_COUNTRY] = $settings[self::FIELD_VIES_REQUESTER_VAT_COUNTRY];
$processed_settings[self::FIELD_VIES_REQUESTER_VAT_NUMBER] = $settings[self::FIELD_VIES_REQUESTER_VAT_NUMBER];
}
// Show validation errors even if the settings are allegedly valid. This will allow to show "non blocking" errors,
// which allow the settings to be saved
// @since 2.0.3.201229
$this->show_validation_errors();
// Return the array processing any additional functions filtered by this action.
return apply_filters('wc_aelia_eu_vat_assistant_settings', $processed_settings, $settings);
}
/**
* Class constructor.
*/
public function __construct($settings_key = self::SETTINGS_KEY,
$textdomain = '',
\Aelia\WC\Settings_Renderer $renderer = null) {
if(empty($renderer)) {
// Instantiate the render to be used to generate the settings page
$renderer = new \Aelia\WC\Settings_Renderer();
}
parent::__construct($settings_key, $textdomain, $renderer);
// Register available exchange rates models
$this->register_exchange_rates_models();
add_action('admin_init', array($this, 'init_settings'));
// If no settings are registered, save the default ones
if($this->load() === null) {
$this->save();
}
}
/**
* Registers a model used to retrieve Exchange Rates.
*/
// TODO Refactor logic to share the exchange rates models with the ones provided by the Currency Switcher, when installed
protected function register_exchange_rates_model($class_name, $label) {
if(!class_exists($class_name) ||
!in_array('Aelia\WC\IExchangeRatesModel', class_implements($class_name))) {
throw new Exception(sprintf(__('Attempted to register class "%s" as an Exchange Rates ' .
'model, but the class does not exist, or does not implement '.
'Aelia\WC\IExchangeRatesModel interface.', $this->textdomain),
$class_name));
}
$model_id = md5($class_name);
$model_info = new stdClass();
$model_info->class_name = $class_name;
$model_info->label = $label;
$this->_exchange_rates_models[$model_id] = $model_info;
}
/**
* Registers all the available models to retrieve Exchange Rates.
*/
// TODO Refactor logic to share the exchange rates models with the ones provided by the Currency Switcher, when installed
protected function register_exchange_rates_models() {
$namespace_prefix = '\\' . __NAMESPACE__ . '\\';
// Allow 3rd parties to add their own models
$exchange_rates_models = apply_filters('aelia_wc_exchange_rates_models', array(
$namespace_prefix . 'Exchange_Rates_BitPay_Model' => __('BitPay', $this->textdomain),
$namespace_prefix . 'Exchange_Rates_ECB_Model' => __('ECB', $this->textdomain),
$namespace_prefix . 'Exchange_Rates_HMRC_Model' => __('HMRC (UK)', $this->textdomain),
$namespace_prefix . 'Exchange_Rates_IrishRevenueHTML_Model' => __('Irish Revenue (HTML) - WARNING: experimental, may not always work!', $this->textdomain),
$namespace_prefix . 'Exchange_Rates_DNB_Model' => __('Danish National Bank', $this->textdomain),
// The Exchange_Rates_ECB_Historical_Model is used by reports. It's added to this list, but
// commented out, so that it can enabled and used for testing as needed
//$namespace_prefix . 'Exchange_Rates_ECB_Historical_Model' => __('ECB - Historical', $this->textdomain),
));
asort($exchange_rates_models);
foreach($exchange_rates_models as $model_class => $model_lanel) {
$this->register_exchange_rates_model($model_class, $model_lanel);
}
}
/**
* Returns a list of the available exchange rates models.
*
* @return array
*/
public function exchange_rates_providers_options() {
$result = array();
foreach($this->_exchange_rates_models as $key => $properties) {
$result[$key] = __(get_value('label', $properties), $this->textdomain);
}
return $result;
}
/**
* Configures the schedule to automatically update the exchange rates.
*
* @param array current_settings An array containing current plugin settings.
* @param array new_settings An array containing new plugin settings.
*/
protected function set_exchange_rates_update_schedule(array $current_settings, array $new_settings) {
// Clear exchange rates update schedule, if it was disabled
$new_schedule_enabled = isset($new_settings[self::FIELD_EXCHANGE_RATES_UPDATE_ENABLE]) ? $new_settings[self::FIELD_EXCHANGE_RATES_UPDATE_ENABLE] : 0;
if($new_schedule_enabled != self::ENABLED_YES) {
wp_clear_scheduled_hook($this->_exchange_rates_update_hook);
}
else {
$current_schedule_enabled = isset($current_settings[self::FIELD_EXCHANGE_RATES_UPDATE_ENABLE]) ? $current_settings[self::FIELD_EXCHANGE_RATES_UPDATE_ENABLE] : 0;
$current_schedule = isset($current_settings[self::FIELD_EXCHANGE_RATES_UPDATE_SCHEDULE]) ? $current_settings[self::FIELD_EXCHANGE_RATES_UPDATE_SCHEDULE] : '';
$new_schedule = isset($new_settings[self::FIELD_EXCHANGE_RATES_UPDATE_SCHEDULE]) ? $new_settings[self::FIELD_EXCHANGE_RATES_UPDATE_SCHEDULE] : '';
// If exchange rates update is still scheduled, check if its schedule changed.
// If it changed, remove old schedule and set a new one.
if(($current_schedule != $new_schedule) ||
($current_schedule_enabled != $new_schedule_enabled)) {
wp_clear_scheduled_hook($this->_exchange_rates_update_hook);
wp_schedule_event(current_time('timestamp'), $new_schedule, $this->_exchange_rates_update_hook);
}
}
}
/**
* Displays the validation errors (if any).
*/
protected function show_validation_errors() {
// Allow 3rd party to add their own validation errors
// @since 2.0.0.201201
foreach(apply_filters('wc_aelia_euva_settings_validation_errors', $this->validation_errors) as $error_key => $error_message) {
add_settings_error(self::SETTINGS_KEY, $error_key, $error_message);
}
}
/**
* Updates the exchange rates. Triggered by a scheduled task.
*/
public function scheduled_update_exchange_rates() {
$settings = $this->current_settings();
if($this->update_exchange_rates($settings, $errors) === true) {
// Save the timestamp of last update
$settings[self::FIELD_EXCHANGE_RATES_LAST_UPDATE] = current_time('timestamp');
}
$this->save($settings);
}
/*** Validation methods ***/
/**
* Validates a list of exchange rates.
*
* @param array A list of exchange rates.
* @return bool True, if the validation succeeds, false otherwise.
*/
protected function validate_exchange_rates($exchange_rates, &$exchange_rates_to_update = array()) {
$currency_with_invalid_rates = array();
foreach($exchange_rates as $currency => $settings) {
$exchange_rate = get_value('rate', $settings);
if(!is_numeric($exchange_rate) || empty($exchange_rate)) {
if(get_value('set_manually', $settings, 0) === 1) {
// Exchange rate is invalid and it was set manually. Add it to the error list
$currency_with_invalid_rates[] = $currency;
}
else {
// Exchange rate is invalid and it was set to update automatically.
// Add it to the update list
$exchange_rates_to_update[] = $currency;
}
}
}
if(!empty($currency_with_invalid_rates)) {
$this->validation_errors['invalid-rate'] = sprintf(__('Some exchange rates entered manually are invalid. ' .
'Please review the rates for the following ' .
'currencies: %s.',
$this->textdomain),
implode(', ', $currency_with_invalid_rates));
}
return empty($currency_with_invalid_rates);
}
/**
* Validates settings for the selected exchange rates provider.
*
* @param array settings An array of settings.
* @return bool
*/
protected function validate_exchange_rates_provider_settings($settings) {
// TODO Implement validation as needed
return true;
}
/**
* Validates the VIES Requester VAT number, if one was entered.
*
* @param string $country
* @param string $vat_number
* @return string
* @since 1.9.0.181022
*/
protected function validate_vies_requester_vat_number($country, $vat_number) {
$result = true;
if(!empty($vat_number)) {
// Validate the requested VAT number
//
// IMPORTANT
// The validation must be performed WITHOUT passing the "requester VAT number" (last two argument),
// for the following reasons:
// - The VAT number we are validating IS the requester VAT number.
// - We don't need a consultation number, for this validation.
// - If the "original" requester VAT number is not valid, it would cause the validation of the new
// requester VAT number to fail. This would make it impossible to save the new number.
// @since 1.10.1.191108
$validation_response = apply_filters('wc_aelia_eu_vat_assistant_validate_vat_number', false, $country, $vat_number, true, '', '');
if($validation_response['euva_validation_result'] !== Definitions::VAT_NUMBER_VALIDATION_VALID) {
// Get the country name, to make the error message clearer
// @since 1.13.2.200319
$country_name = WC()->countries->countries[$country] ? WC()->countries->countries[$country] : $country;
$message = implode(' ', array(
__('Invalid requester VAT number entered in section Options > VIES Validation.', $this->textdomain),
__('The requester VAT number has been saved, but the remote VIES service will reject it, causing the VAT number validation at checkout to fail.', $this->textdomain),
__('If you do not have a valid EU VAT number, please leave the field empty.', $this->textdomain),
'
',
sprintf(__('Selected VAT Country: "%1$s".', $this->textdomain), $country_name),
sprintf(__('Entered VAT Number: "%1$s".', $this->textdomain), $vat_number),
sprintf(__('Raw validation response (JSON): %s', $this->textdomain), '
' . json_encode($validation_response, JSON_PRETTY_PRINT) . ''), )); $this->validation_errors['invalid-vies-requester-vat-number'] = $message; $result = false; } } return $result; } /** * Returns the requester VAT number to be used for a VAT number validation. * * @param string $target_vat_number_country The country to which the target (i.e. customer's) * VAT number belongs. The requester VAT number might change, depending on such country (e.g. for the UK). * @return array * @since 2.0.1.201215 */ public function get_requester_vat_number($target_vat_number_country = '') { $vat_number = $this->get(self::FIELD_VIES_REQUESTER_VAT_NUMBER); if(!empty($vat_number)) { $result = array( 'vat_country' => $this->get(self::FIELD_VIES_REQUESTER_VAT_COUNTRY), 'vat_number' => $vat_number, ); } else { $result = array( 'vat_country' => '', 'vat_number' => '', ); } return apply_filters('wc_aelia_euva_requester_vat_number', $result, $target_vat_number_country); } }