* 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); } }