1514 lines
64 KiB
Python
1514 lines
64 KiB
Python
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
|
|
import datetime
|
|
import logging
|
|
import re
|
|
from itertools import islice
|
|
from urllib.parse import quote, urlencode
|
|
|
|
import requests
|
|
from dateutil.relativedelta import relativedelta
|
|
from lxml import etree
|
|
from pytz import timezone
|
|
|
|
from odoo import api, fields, models
|
|
from odoo.addons.account.tools import LegacyHTTPAdapter
|
|
from odoo.addons.iap.tools.iap_tools import iap_jsonrpc
|
|
from odoo.tools.zeep import Client
|
|
from odoo.tools.zeep.helpers import serialize_object
|
|
from odoo.exceptions import UserError
|
|
from odoo.tools import DEFAULT_SERVER_DATE_FORMAT
|
|
from odoo.tools.translate import _
|
|
|
|
BANXICO_DATE_FORMAT = '%d/%m/%Y'
|
|
PROXY_URL = 'https://iap-services.odoo.com'
|
|
CBUAE_URL = "https://centralbank.ae/umbraco/Surface/Exchange/GetExchangeRateAllCurrency"
|
|
CBEGY_URL = "https://www.cbe.org.eg/en/economic-research/statistics/cbe-exchange-rates"
|
|
MAP_CURRENCIES = {
|
|
'US Dollar': 'USD',
|
|
'UAE Dirham': 'AED',
|
|
'Argentine Peso': 'ARS',
|
|
'Australian Dollar': 'AUD',
|
|
'Azerbaijan manat': 'AZN',
|
|
'Bangladesh Taka': 'BDT',
|
|
'Bulgarian lev': 'BGN',
|
|
'Bahrani Dinar': 'BHD',
|
|
'Bahraini Dinar': 'BHD',
|
|
'Brunei Dollar': 'BND',
|
|
'Brazilian Real': 'BRL',
|
|
'Botswana Pula': 'BWP',
|
|
'Belarus Rouble': 'BYN',
|
|
'Canadian Dollar': 'CAD',
|
|
'Swiss Franc': 'CHF',
|
|
'Chilean Peso': 'CLP',
|
|
'Chinese Yuan - Offshore': 'CNH',
|
|
'Chinese Yuan': 'CNY',
|
|
'Colombian Peso': 'COP',
|
|
'Cuban Peso': 'CUP',
|
|
'Czech Koruna': 'CZK',
|
|
'Danish Krone': 'DKK',
|
|
'Algerian Dinar': 'DZD',
|
|
'Egypt Pound': 'EGP',
|
|
'Ethiopian birr': 'ETB',
|
|
'Euro': 'EUR',
|
|
'GB Pound': 'GBP',
|
|
'Pound Sterling': 'GBP',
|
|
'Hongkong Dollar': 'HKD',
|
|
'Croatian kuna': 'HRK',
|
|
'Hungarian Forint': 'HUF',
|
|
'Indonesia Rupiah': 'IDR',
|
|
'Israeli new shekel': 'ILS',
|
|
'Indian Rupee': 'INR',
|
|
'Iraqi dinar': 'IQD',
|
|
'Iceland Krona': 'ISK',
|
|
'Jordan Dinar': 'JOD',
|
|
'Jordanian Dinar': 'JOD',
|
|
'Japanese Yen': 'JPY',
|
|
'Japanese Yen 100': 'JPY',
|
|
'Kenya Shilling': 'KES',
|
|
'Korean Won': 'KRW',
|
|
'Kuwaiti Dinar': 'KWD',
|
|
'Kazakhstan Tenge': 'KZT',
|
|
'Lebanon Pound': 'LBP',
|
|
'Sri Lanka Rupee': 'LKR',
|
|
'Libyan dinar': 'LYD',
|
|
'Moroccan Dirham': 'MAD',
|
|
'Macedonia Denar': 'MKD',
|
|
'Mauritian rupee': 'MUR',
|
|
'Mexican Peso': 'MXN',
|
|
'Malaysia Ringgit': 'MYR',
|
|
'Nigerian Naira': 'NGN',
|
|
'Norwegian Krone': 'NOK',
|
|
'NewZealand Dollar': 'NZD',
|
|
'Omani Rial': 'OMR',
|
|
'Omani Riyal': 'OMR',
|
|
'Peru Sol': 'PEN',
|
|
'Philippine Piso': 'PHP',
|
|
'Pakistan Rupee': 'PKR',
|
|
'Polish Zloty': 'PLN',
|
|
'Qatari Riyal': 'QAR',
|
|
'Romanian leu': 'RON',
|
|
'Serbian Dinar': 'RSD',
|
|
'Russia Rouble': 'RUB',
|
|
'Saudi Riyal': 'SAR',
|
|
'Sudanese Pound': 'SDG',
|
|
'Singapore Dollar': 'SGD',
|
|
'Swedish Krona': 'SEK',
|
|
'Syrian pound': 'SYP',
|
|
'Thai Baht': 'THB',
|
|
'Turkmen manat': 'TMT',
|
|
'Tunisian Dinar': 'TND',
|
|
'Turkish Lira': 'TRY',
|
|
'Trin Tob Dollar': 'TTD',
|
|
'Taiwan Dollar': 'TWD',
|
|
'Tanzania Shilling': 'TZS',
|
|
'Uganda Shilling': 'UGX',
|
|
'Uruguayan Peso': 'UYU',
|
|
'Uzbekistani som': 'UZS',
|
|
'Vietnam Dong': 'VND',
|
|
'Caribbean Guilder': 'XCG',
|
|
'Yemen Rial': 'YER',
|
|
'South Africa Rand': 'ZAR',
|
|
'Zambian Kwacha': 'ZMW',
|
|
}
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
def xml2json_from_elementtree(el, preserve_whitespaces=False):
|
|
""" xml2json-direct
|
|
Simple and straightforward XML-to-JSON converter in Python
|
|
New BSD Licensed
|
|
http://code.google.com/p/xml2json-direct/
|
|
"""
|
|
res = {}
|
|
if el.tag[0] == "{":
|
|
ns, name = el.tag.rsplit("}", 1)
|
|
res["tag"] = name
|
|
res["namespace"] = ns[1:]
|
|
else:
|
|
res["tag"] = el.tag
|
|
res["attrs"] = {}
|
|
for k, v in el.items():
|
|
res["attrs"][k] = v
|
|
kids = []
|
|
if el.text and (preserve_whitespaces or el.text.strip() != ''):
|
|
kids.append(el.text)
|
|
for kid in el:
|
|
kids.append(xml2json_from_elementtree(kid, preserve_whitespaces))
|
|
if kid.tail and (preserve_whitespaces or kid.tail.strip() != ''):
|
|
kids.append(kid.tail)
|
|
res["children"] = kids
|
|
return res
|
|
|
|
|
|
# countries, provider_code, description
|
|
CURRENCY_PROVIDER_SELECTION = [
|
|
([], 'ecb', 'European Central Bank'),
|
|
(['IN', 'SA'], 'xe_com', 'xe.com'),
|
|
(['AE'], 'cbuae', '[AE] Central Bank of the UAE'),
|
|
(['AZ'], 'cbar', '[AZ] Central Bank of Azerbaijan'),
|
|
(['BG'], 'bnb', '[BG] Bulgaria National Bank'),
|
|
(['BR'], 'bbr', '[BR] Central Bank of Brazil'),
|
|
(['CA'], 'boc', '[CA] Bank of Canada'),
|
|
(['CH'], 'fta', '[CH] Federal Tax Administration of Switzerland'),
|
|
(['CL'], 'mindicador', '[CL] Central Bank of Chile via mindicador.cl'),
|
|
(['CO'], 'banrepco', '[CO] Bank of the Republic'),
|
|
(['CU'], 'cu', '[CU] Central Bank of Cuba'),
|
|
(['CZ'], 'cnb', '[CZ] Czech National Bank'),
|
|
(['EG'], 'cbegy', '[EG] Central Bank of Egypt'),
|
|
(['GE'], 'nbg', '[GE] National Bank of Georgia'),
|
|
(['GT'], 'banguat', '[GT] Bank of Guatemala'),
|
|
(['HU'], 'mnb', '[HU] Magyar Nemzeti Bank'),
|
|
(['ID'], 'bi', '[ID] Bank Indonesia'),
|
|
(['IT'], 'boi', '[IT] Bank of Italy'),
|
|
(['KZ'], 'nbkz', '[KZ] National Bank of Kazakhstan'),
|
|
(['MX'], 'banxico', '[MX] Bank of Mexico'),
|
|
(['MY'], 'bnm', '[MY] Bank Negara Malaysia'),
|
|
(['PE'], 'bcrp', '[PE] SUNAT (replaces Bank of Peru)'),
|
|
(['PL'], 'nbp', '[PL] National Bank of Poland'),
|
|
(['RO'], 'bnr', '[RO] National Bank of Romania'),
|
|
(['SE'], 'srb', '[SE] Sveriges Riksbank'),
|
|
(['SI'], 'bsi', '[SI] Bank of Slovenia'),
|
|
(['TH'], 'bot', '[TH] Bank of Thailand'),
|
|
(['TR'], 'tcmb', '[TR] Central Bank of the Republic of Türkiye'),
|
|
(['UK'], 'hmrc', '[UK] HM Revenue & Customs'),
|
|
(['UY'], 'bcu', '[UY] Uruguayan Central Bank'),
|
|
(['UZ'], 'cbu', '[UZ] Central Bank of Uzbekistan'),
|
|
]
|
|
|
|
|
|
class ResCompany(models.Model):
|
|
_inherit = 'res.company'
|
|
|
|
currency_interval_unit = fields.Selection(
|
|
selection=[
|
|
('manually', 'Manually'),
|
|
('daily', 'Daily'),
|
|
('weekly', 'Weekly'),
|
|
('monthly', 'Monthly')
|
|
],
|
|
default='manually',
|
|
required=True,
|
|
string='Interval Unit',
|
|
)
|
|
currency_next_execution_date = fields.Date(string="Next Execution Date")
|
|
currency_provider = fields.Selection(
|
|
selection=[(provider_code, desc) for dummy, provider_code, desc in CURRENCY_PROVIDER_SELECTION],
|
|
string='Service Provider',
|
|
compute='_compute_currency_provider',
|
|
readonly=False,
|
|
store=True,
|
|
)
|
|
|
|
@api.depends('country_id')
|
|
def _compute_currency_provider(self):
|
|
code_providers = {
|
|
country: provider_code
|
|
for countries, provider_code, dummy in CURRENCY_PROVIDER_SELECTION
|
|
for country in countries
|
|
}
|
|
for record in self:
|
|
record.currency_provider = code_providers.get(record.country_id.code, 'ecb')
|
|
|
|
def update_currency_rates(self):
|
|
''' This method is used to update all currencies given by the provider.
|
|
It calls the parse_function of the selected exchange rates provider automatically.
|
|
|
|
For this, all those functions must be called _parse_xxx_data, where xxx
|
|
is the technical name of the provider in the selection field. Each of them
|
|
must also be such as:
|
|
|
|
- It takes as its only parameter the recordset of the currencies
|
|
we want to get the rates of
|
|
- It returns a dictionary containing currency codes as keys, and
|
|
the corresponding exchange rates as its values. These rates must all
|
|
be based on the same currency, whatever it is. This dictionary must
|
|
also include a rate for the base currencies of the companies we are
|
|
updating rates from, otherwise this will result in an error
|
|
asking the user to choose another provider.
|
|
|
|
:return: True if the rates of all the records in self were updated
|
|
successfully, False if at least one wasn't.
|
|
'''
|
|
active_currencies = self.env['res.currency'].search([])
|
|
rslt = True
|
|
for (currency_provider, companies) in self._group_by_provider().items():
|
|
parse_function = getattr(companies, '_parse_' + currency_provider + '_data')
|
|
try:
|
|
parse_results = parse_function(active_currencies)
|
|
companies._generate_currency_rates(parse_results)
|
|
except Exception as error:
|
|
if self.env.context.get('suppress_errors'):
|
|
_logger.warning(error)
|
|
_logger.warning('Unable to connect to the online exchange rate platform %s. The web service may be temporarily down. Please try again in a moment.', currency_provider)
|
|
rslt = False
|
|
elif isinstance(error, UserError):
|
|
raise error
|
|
else:
|
|
raise UserError(_('Unable to connect to the online exchange rate platform %s. The web service may be temporarily down. Please try again in a moment.', currency_provider))
|
|
return rslt
|
|
|
|
def _group_by_provider(self):
|
|
""" Returns a dictionnary grouping the companies in self by currency
|
|
rate provider. Companies with no provider defined will be ignored."""
|
|
rslt = {}
|
|
for company in self:
|
|
if not company.currency_provider:
|
|
continue
|
|
|
|
if rslt.get(company.currency_provider):
|
|
rslt[company.currency_provider] += company
|
|
else:
|
|
rslt[company.currency_provider] = company
|
|
return rslt
|
|
|
|
def _generate_currency_rates(self, parsed_data):
|
|
""" Generate the currency rate entries for each of the companies, using the
|
|
result of a parsing function, given as parameter, to get the rates data.
|
|
|
|
This function ensures the currency rates of each company are computed,
|
|
based on parsed_data, so that the currency of this company receives rate=1.
|
|
This is done so because a lot of users find it convenient to have the
|
|
exchange rate of their main currency equal to one in Odoo.
|
|
"""
|
|
Currency = self.env['res.currency']
|
|
CurrencyRate = self.env['res.currency.rate']
|
|
|
|
for company in self:
|
|
rate_info = parsed_data.get(company.currency_id.name, None)
|
|
|
|
if not rate_info:
|
|
msg = _("Your main currency (%s) is not supported by this exchange rate provider. Please choose another one.", company.currency_id.name)
|
|
if self.env.context.get('suppress_errors'):
|
|
_logger.warning(msg)
|
|
continue
|
|
else:
|
|
raise UserError(msg)
|
|
|
|
base_currency_rate = rate_info[0]
|
|
|
|
for currency, (rate, date_rate) in parsed_data.items():
|
|
rate_value = rate / base_currency_rate
|
|
|
|
currency_object = Currency.search([('name', '=', currency)])
|
|
if currency_object: # if rate provider base currency is not active, it will be present in parsed_data
|
|
already_existing_rate = CurrencyRate.search([('currency_id', '=', currency_object.id), ('name', '=', date_rate), ('company_id', '=', company.id)])
|
|
if already_existing_rate:
|
|
already_existing_rate.rate = rate_value
|
|
else:
|
|
CurrencyRate.create({'currency_id': currency_object.id, 'rate': rate_value, 'name': date_rate, 'company_id': company.id})
|
|
|
|
@api.model
|
|
def _parse_bsi_data(self, available_currencies):
|
|
"""
|
|
Fetches exchange rates from the Bank of Slovenia API and parses the JSON response.
|
|
Rates are provided against EUR and include various currencies.
|
|
"""
|
|
|
|
bsi_url = 'https://api.bsi.si/'
|
|
endpoints = ['exchange/daily', 'exchange/exotic']
|
|
available_currency_names = available_currencies.mapped('name')
|
|
currency_rates_entries = []
|
|
rates = {}
|
|
|
|
# Using a session since we're doing requests from multiple endpoints.
|
|
session = requests.Session()
|
|
for endpoint in endpoints:
|
|
try:
|
|
response = session.get(f"{bsi_url}{endpoint}", timeout=30)
|
|
response.raise_for_status()
|
|
currency_rates_entries.extend(response.json().get('data', []))
|
|
except requests.RequestException as e:
|
|
_logger.error(e)
|
|
|
|
for currency_rate_entry in currency_rates_entries:
|
|
currency_code = currency_rate_entry.get('code')
|
|
rate = currency_rate_entry.get('value')
|
|
date = currency_rate_entry.get('date')
|
|
|
|
# Validate values
|
|
if not (currency_code and rate and date):
|
|
continue
|
|
date_obj = datetime.datetime.strptime(date, '%Y-%m-%d').date()
|
|
|
|
if currency_code in available_currency_names and rate:
|
|
rates[currency_code] = (float(rate), date_obj)
|
|
|
|
# Include EUR with a fixed rate of 1.0
|
|
rates['EUR'] = (1.0, datetime.datetime.now().date())
|
|
return rates
|
|
|
|
def _parse_fta_data(self, available_currencies):
|
|
''' Parses the data returned in xml by FTA servers and returns it in a more
|
|
Python-usable form.'''
|
|
request_url = 'https://www.backend-rates.bazg.admin.ch/api/xmldaily?d=today&locale=en'
|
|
response = requests.get(request_url, timeout=30)
|
|
response.raise_for_status()
|
|
|
|
rates_dict = {}
|
|
available_currency_names = available_currencies.mapped('name')
|
|
xml_tree = etree.fromstring(response.content)
|
|
data = xml2json_from_elementtree(xml_tree)
|
|
date_elem = xml_tree.xpath("//*[local-name() = 'datum']")[0]
|
|
date_rate = datetime.datetime.strptime(date_elem.text.split(',')[0], '%d.%m.%Y').date()
|
|
for child_node in data['children']:
|
|
if child_node['tag'] == 'devise':
|
|
currency_code = child_node['attrs']['code'].upper()
|
|
|
|
if currency_code in available_currency_names:
|
|
currency_xml = None
|
|
rate_xml = None
|
|
|
|
for sub_child in child_node['children']:
|
|
if sub_child['tag'] == 'waehrung':
|
|
currency_xml = sub_child['children'][0]
|
|
elif sub_child['tag'] == 'kurs':
|
|
rate_xml = sub_child['children'][0]
|
|
if currency_xml and rate_xml:
|
|
#avoid iterating for nothing on children
|
|
break
|
|
|
|
rates_dict[currency_code] = (float(re.search(r'\d+', currency_xml).group()) / float(rate_xml), date_rate)
|
|
|
|
if 'CHF' in available_currency_names:
|
|
rates_dict['CHF'] = (1.0, date_rate)
|
|
|
|
return rates_dict
|
|
|
|
def _parse_ecb_data(self, available_currencies):
|
|
''' This method is used to update the currencies by using ECB service provider.
|
|
Rates are given against EURO
|
|
'''
|
|
request_url = "http://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml"
|
|
response = requests.get(request_url, timeout=30)
|
|
response.raise_for_status()
|
|
|
|
xmlstr = etree.fromstring(response.content)
|
|
data = xml2json_from_elementtree(xmlstr)
|
|
node = data['children'][2]['children'][0]
|
|
xmldate = fields.Date.to_date(node['attrs']['time'])
|
|
available_currency_names = available_currencies.mapped('name')
|
|
rslt = {x['attrs']['currency']:(float(x['attrs']['rate']), xmldate) for x in node['children'] if x['attrs']['currency'] in available_currency_names}
|
|
|
|
if rslt and 'EUR' in available_currency_names:
|
|
rslt['EUR'] = (1.0, xmldate)
|
|
|
|
return rslt
|
|
|
|
def _parse_cbuae_data(self, available_currencies):
|
|
''' This method is used to update the currencies by using UAE Central Bank service provider.
|
|
Exchange rates are expressed as 1 unit of the foreign currency converted into AED
|
|
'''
|
|
headers = {
|
|
'Accept-Language': 'en-US,en;q=0.9',
|
|
'Referer': 'https://www.centralbank.ae/en/forex-eibor/exchange-rates/'
|
|
}
|
|
|
|
response = requests.get(CBUAE_URL, headers=headers, timeout=30)
|
|
response.raise_for_status()
|
|
|
|
htmlelem = etree.fromstring(response.content, etree.HTMLParser(encoding='utf-8'))
|
|
rates_entries = htmlelem.xpath("//table/tbody//tr")
|
|
date_elem = htmlelem.xpath("//div[@class='row mb-4']/div/p[last()]")[0]
|
|
date_rate = datetime.datetime.strptime(
|
|
date_elem.text.strip(),
|
|
'Last updated:\r\n\r\n%A %d %B %Y %I:%M:%S %p').date()
|
|
available_currency_names = set(available_currencies.mapped('name'))
|
|
rslt = {}
|
|
for rate_entry in rates_entries:
|
|
# line structure is <td>Currency Description</td><td>rate</td>
|
|
currency_code = MAP_CURRENCIES.get(rate_entry[1].text)
|
|
rate = float(rate_entry[2].text)
|
|
if currency_code in available_currency_names:
|
|
rslt[currency_code] = (1.0/rate, date_rate)
|
|
|
|
if 'AED' in available_currency_names:
|
|
rslt['AED'] = (1.0, date_rate)
|
|
return rslt
|
|
|
|
def _parse_cbar_data(self, available_currencies):
|
|
""" This method is used to update the currencies by using the Central Bank of Azerbaijan service provider.
|
|
Exchange rates are expressed as 'quantity' units of the available currencies converted into AZN.
|
|
"""
|
|
|
|
today_date = fields.Date.context_today(self.with_context(tz='Asia/Baku'))
|
|
date_str = today_date.strftime('%d.%m.%Y')
|
|
request_url = f"https://cbar.az/currencies/{date_str}.xml"
|
|
response = requests.get(request_url, timeout=30)
|
|
response.raise_for_status()
|
|
xml_tree = etree.fromstring(response.content)
|
|
|
|
rslt = {}
|
|
date_rate = datetime.datetime.strptime(xml_tree.get('Date'), '%d.%m.%Y').date()
|
|
available_currency_names = set(available_currencies.mapped('name'))
|
|
for currency in xml_tree.xpath(".//ValType[@Type='Xarici valyutalar']/Valute"):
|
|
code = currency.get('Code')
|
|
quantity = float(currency.findtext('Nominal'))
|
|
rate = float(currency.findtext('Value'))
|
|
|
|
if code in available_currency_names:
|
|
# Normalize the rate using the Quantity / Rate formula
|
|
rslt[code] = (quantity / rate, date_rate)
|
|
|
|
if 'AZN' in available_currency_names:
|
|
rslt['AZN'] = (1.0, date_rate)
|
|
return rslt
|
|
|
|
def _parse_cbegy_data(self, available_currencies):
|
|
''' This method is used to update the currencies by using the Central Bank of Egypt service provider.
|
|
Exchange rates are expressed as 1 unit of the foreign currency converted into EGP
|
|
'''
|
|
headers = {
|
|
'user-agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.4951.41 Safari/537.36',
|
|
}
|
|
fetched_data = requests.get(CBEGY_URL, headers=headers, timeout=30)
|
|
fetched_data.raise_for_status()
|
|
|
|
htmlelem = etree.fromstring(fetched_data.content, etree.HTMLParser())
|
|
rates_entries = htmlelem.xpath("//table/tbody/tr")
|
|
date_text = htmlelem.xpath("//p[contains(.,'Rates for Date')]/text()")[1]
|
|
date_rate = datetime.datetime.strptime(date_text.strip(), 'Rates for Date: %d/%m/%Y').date()
|
|
available_currency_names = set(available_currencies.mapped('name'))
|
|
rslt = {}
|
|
for rate_entry in rates_entries:
|
|
currency_code = MAP_CURRENCIES.get(rate_entry[0].text.strip())
|
|
# line structure is <td>Currency Description</td><td>BUY RATE</td><td>SELL RATE</td>
|
|
# we use the average of SELL and BUY rates
|
|
rate = (float(rate_entry[1].text) + float(rate_entry[2].text)) / 2
|
|
if currency_code in available_currency_names:
|
|
rslt[currency_code] = (1.0/rate, date_rate)
|
|
|
|
if 'EGP' in available_currency_names:
|
|
rslt['EGP'] = (1.0, date_rate)
|
|
return rslt
|
|
|
|
def _parse_nbg_data(self, available_currencies):
|
|
"""This method is used to update the currencies by using the National Bank of Georgia service provider.
|
|
Exchange rates are expressed as 'quantity' units of the available currencies converted into GEL.
|
|
"""
|
|
|
|
rslt = {}
|
|
request_url = "https://nbg.gov.ge/gw/api/ct/monetarypolicy/currencies/en/json/"
|
|
response = requests.get(request_url, headers={"Accept": "application/json"}, timeout=10)
|
|
response.raise_for_status()
|
|
data = response.json()
|
|
|
|
response_date = data[0].get('date')
|
|
date_rate = datetime.datetime.strptime(response_date, "%Y-%m-%dT%H:%M:%S.%fZ").date()
|
|
currencies = data[0].get('currencies', [])
|
|
available_currency_names = set(available_currencies.mapped('name'))
|
|
for currency in currencies:
|
|
code = currency.get('code')
|
|
rate = currency.get('rate')
|
|
quantity = currency.get('quantity')
|
|
|
|
if code in available_currency_names:
|
|
# NBG rates are expressed for a specific quantity of the available
|
|
# currency (e.g., 1 USD, 10 DKK, 100 JPY).
|
|
# We normalize this to find the value of 1 GEL in the available currency.
|
|
# Formula: quantity / rate
|
|
rslt[code] = (quantity / float(rate), date_rate)
|
|
|
|
if 'GEL' in available_currency_names:
|
|
rslt['GEL'] = (1.0, date_rate)
|
|
return rslt
|
|
|
|
def _parse_banguat_data(self, available_currencies):
|
|
""" Bank of Guatemala
|
|
Info: https://banguat.gob.gt/tipo_cambio/
|
|
* SOAP URL: https://www.banguat.gob.gt/variables/ws/TipoCambio.asmx
|
|
* Exchange rate is expressed as 1 unit of USD converted into GTQ
|
|
"""
|
|
available_currency_names = available_currencies.mapped('name')
|
|
if 'GTQ' not in available_currency_names or 'USD' not in available_currency_names:
|
|
raise UserError(_('The selected exchange rate provider requires the GTQ and USD currencies to be active.'))
|
|
|
|
headers = {
|
|
'Content-Type': 'application/soap+xml; charset=utf-8',
|
|
}
|
|
body = """<?xml version="1.0" encoding="utf-8"?>
|
|
<soap12:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:soap12="http://www.w3.org/2003/05/soap-envelope">
|
|
<soap12:Body>
|
|
<TipoCambioDia xmlns="http://www.banguat.gob.gt/variables/ws/"/>
|
|
</soap12:Body>
|
|
</soap12:Envelope>
|
|
"""
|
|
res = requests.post(
|
|
'https://www.banguat.gob.gt/variables/ws/TipoCambio.asmx',
|
|
data=body,
|
|
headers=headers,
|
|
timeout=10
|
|
)
|
|
res.raise_for_status()
|
|
|
|
xml_tree = etree.fromstring(res.content)
|
|
|
|
rslt = {}
|
|
date_rate = xml_tree.xpath(".//*[local-name()='VarDolar']/*[local-name()='fecha']/text()")[0]
|
|
if date_rate:
|
|
date_rate = datetime.datetime.strptime(date_rate, '%d/%m/%Y').date()
|
|
rslt['GTQ'] = (1.0, date_rate)
|
|
rate = xml_tree.xpath(".//*[local-name()='VarDolar']/*[local-name()='referencia']/text()")[0] or 0.0
|
|
if rate:
|
|
rate = 1.0 / float(rate)
|
|
rslt['USD'] = (rate, date_rate)
|
|
return rslt
|
|
|
|
def _parse_hmrc_data(self, available_currencies):
|
|
''' This method is used to update the currencies by using HMRC service provider.
|
|
Rates are given against GBP.
|
|
'''
|
|
# Date is the first of the current month since rates are given monthly.
|
|
first_of_month = fields.Date.context_today(self.with_context(tz='Europe/London')).replace(day=1)
|
|
formatted_date = first_of_month.strftime("%Y-%m")
|
|
|
|
request_url = f"https://www.trade-tariff.service.gov.uk/api/v2/exchange_rates/files/monthly_xml_{formatted_date}.xml"
|
|
response = requests.get(request_url, timeout=10)
|
|
response.raise_for_status()
|
|
|
|
xml_tree = etree.fromstring(response.content)
|
|
available_currency_names = available_currencies.mapped('name')
|
|
rslt = {
|
|
node.find('currencyCode').text: (
|
|
float(node.find('rateNew').text),
|
|
first_of_month,
|
|
)
|
|
for node in xml_tree.iterfind('exchangeRate')
|
|
if node.find('currencyCode').text in available_currency_names}
|
|
|
|
if rslt and 'GBP' in available_currency_names:
|
|
rslt['GBP'] = (1.0, first_of_month)
|
|
|
|
return rslt
|
|
|
|
def _parse_bbr_data(self, available_currencies):
|
|
''' This method is used to update the currencies by using the Central Bank of Brazil service provider.
|
|
Exchange rates are expressed as 1 unit of the foreign currency converted into BRL.
|
|
'''
|
|
def _get_currency_exchange_rate(session, cur, date):
|
|
'''Returns the rate for the given day and currency if found, None if there were no currency changes that day.
|
|
'''
|
|
query_params = {
|
|
"@moeda": ("'%s'" % cur),
|
|
"@dataCotacao": ("'%s'" % date),
|
|
"$top": "1",
|
|
"$orderby": "dataHoraCotacao desc",
|
|
"$format": "json",
|
|
"$select": "cotacaoCompra",
|
|
}
|
|
encoded_params = urlencode(query_params, safe="@$'", quote_via=quote)
|
|
request_url = "https://olinda.bcb.gov.br/olinda/service/PTAX/version/v1/odata/ExchangeRateDate(moeda=@moeda,dataCotacao=@dataCotacao)"
|
|
response = session.get(request_url, params=encoded_params, timeout=10)
|
|
response.raise_for_status()
|
|
if 'application/json' not in response.headers.get('Content-Type', ''):
|
|
raise ValueError('Should be json')
|
|
data = response.json()
|
|
|
|
# If there were no currency changes that day, return None.
|
|
if not data['value']:
|
|
return None
|
|
bid_rate = data['value'][0]['cotacaoCompra']
|
|
return bid_rate
|
|
|
|
# Using a session since we're doing multiple requests.
|
|
session = requests.Session()
|
|
# Get the currencies from the bank.
|
|
request_url = "https://olinda.bcb.gov.br/olinda/service/PTAX/version/v1/odata/Currencies?$top=100&$format=json"
|
|
response = session.get(request_url, timeout=10)
|
|
response.raise_for_status()
|
|
if 'application/json' not in response.headers.get('Content-Type', ''):
|
|
raise ValueError('Should be json')
|
|
data = response.json()
|
|
available_currency_names = available_currencies.mapped('name')
|
|
currencies = [val['simbolo'] for val in data['value'] if val['simbolo'] in available_currency_names]
|
|
|
|
date_rate = datetime.datetime.now(timezone('America/Sao_Paulo'))
|
|
|
|
# For every available currency in the returned currencies, if it's in the
|
|
# available currencies, get its exchange rate.
|
|
rslt = {}
|
|
for currency in currencies:
|
|
# As there are days where there are no currency changes, we start by calling
|
|
# the api with the current day, and keep decrementing the date by one day until
|
|
# we reach a day with currency changes.
|
|
rate = None
|
|
while not rate:
|
|
rate = _get_currency_exchange_rate(session, currency, date_rate.strftime("%m-%d-%Y"))
|
|
if not rate:
|
|
date_rate = date_rate - datetime.timedelta(days=1)
|
|
|
|
rslt[currency] = (1.0/rate, date_rate)
|
|
|
|
if 'BRL' in available_currency_names:
|
|
rslt['BRL'] = (1.0, date_rate)
|
|
|
|
return rslt
|
|
|
|
def _parse_boc_data(self, available_currencies):
|
|
"""This method is used to update currencies exchange rate by using Bank
|
|
Of Canada daily exchange rate service.
|
|
Exchange rates are expressed as 1 unit of the foreign currency converted into Canadian dollars.
|
|
Keys are in this format: 'FX{CODE}CAD' e.g.: 'FXEURCAD'
|
|
"""
|
|
available_currency_names = available_currencies.mapped('name')
|
|
|
|
request_url = "http://www.bankofcanada.ca/valet/observations/group/FX_RATES_DAILY/json"
|
|
response = requests.get(request_url, timeout=30)
|
|
response.raise_for_status()
|
|
if not 'application/json' in response.headers.get('Content-Type', ''):
|
|
raise ValueError('Should be json')
|
|
data = response.json()
|
|
|
|
# 'observations' key contains rates observations by date
|
|
last_observation_date = sorted([obs['d'] for obs in data['observations']])[-1]
|
|
last_obs = [obs for obs in data['observations'] if obs['d'] == last_observation_date][0]
|
|
last_obs.update({'FXCADCAD': {'v': '1'}})
|
|
date_rate = datetime.datetime.strptime(last_observation_date, "%Y-%m-%d").date()
|
|
rslt = {}
|
|
if 'CAD' in available_currency_names:
|
|
rslt['CAD'] = (1, date_rate)
|
|
|
|
for currency_name in available_currency_names:
|
|
currency_obs = last_obs.get('FX{}CAD'.format(currency_name), None)
|
|
if currency_obs is not None:
|
|
rslt[currency_name] = (1.0/float(currency_obs['v']), date_rate)
|
|
|
|
return rslt
|
|
|
|
def _parse_banxico_data(self, available_currencies):
|
|
"""Parse function for Banxico provider.
|
|
|
|
* With basement in legal topics in Mexico the rate must be **one** per day and it is equal to the rate known the
|
|
day immediate before the rate is gotten, it means the rate for 02/Feb is the one at 31/jan.
|
|
* The base currency is always MXN but with the inverse 1/rate.
|
|
* The official institution is Banxico.
|
|
* The webservice returns the following currency rates:
|
|
|
|
- SF46410 EUR
|
|
- SF60632 CAD
|
|
- SF43718 USD Fixed
|
|
- SF46407 GBP
|
|
- SF46406 JPY
|
|
- SF60653 USD SAT - Officially used from SAT institution
|
|
|
|
Source: http://www.banxico.org.mx/portal-mercado-cambiario/
|
|
"""
|
|
try:
|
|
payload = {
|
|
'jsonrpc': '2.0',
|
|
'method': 'call',
|
|
'params': {'provider': 'banxico'},
|
|
}
|
|
# Send request to Odoo proxy
|
|
response = requests.get(
|
|
f'{PROXY_URL}/api/currency_rate/1/get_currency_rates',
|
|
json=payload,
|
|
headers={'content-type': 'application/json'},
|
|
timeout=30,
|
|
).json()
|
|
|
|
if response.get('error'):
|
|
return False
|
|
series = response['result']
|
|
except requests.RequestException as e:
|
|
_logger.error(e)
|
|
return False
|
|
|
|
available_currency_names = available_currencies.mapped('name')
|
|
rslt = {
|
|
'MXN': (1.0, fields.Date.today().strftime(DEFAULT_SERVER_DATE_FORMAT)),
|
|
}
|
|
foreigns = {
|
|
# position order of the rates from webservices
|
|
'SF46410': 'EUR',
|
|
'SF60632': 'CAD',
|
|
'SF46406': 'JPY',
|
|
'SF46407': 'GBP',
|
|
'SF60653': 'USD',
|
|
'SF290383': 'CNY',
|
|
}
|
|
for index, currency in foreigns.items():
|
|
if not series.get(index, False):
|
|
continue
|
|
if currency not in available_currency_names:
|
|
continue
|
|
|
|
serie = series[index]
|
|
for rate in serie:
|
|
try:
|
|
foreign_mxn_rate = float(serie[rate])
|
|
except (ValueError, TypeError):
|
|
continue
|
|
foreign_rate_date = datetime.datetime.strptime(rate, BANXICO_DATE_FORMAT).strftime(DEFAULT_SERVER_DATE_FORMAT)
|
|
rslt[currency] = (1.0/foreign_mxn_rate, foreign_rate_date)
|
|
return rslt
|
|
|
|
def _parse_xe_com_data(self, available_currencies):
|
|
"""
|
|
Parse currency exchange rate data retrieved from the XE.com API.
|
|
|
|
This function parses exchange rate data for available currencies from the XE.com provider
|
|
via a JSON-RPC request and processes the data into a structured format. It filters the rates
|
|
to include only those matching the given ``available_currencies``.
|
|
|
|
:param available_currencies: A recordset containing available currency objects. Each object
|
|
is expected to have a `name` attribute representing the currency code (e.g., 'USD', 'EUR').
|
|
:returns: A dictionary where keys are currency codes (strings) and values are tuples:
|
|
|
|
- The exchange rate (float).
|
|
- The timestamp of the rate, formatted as a string in ``DEFAULT_SERVER_DATE_FORMAT``.
|
|
:rtype: dict
|
|
|
|
Example
|
|
|
|
If the XE.com API returns the following data::
|
|
|
|
{
|
|
'2025-01-21T00:00:00Z': {
|
|
'EUR': 1.2345,
|
|
'USD': 1.0,
|
|
},
|
|
}
|
|
|
|
and ``available_currencies`` contains 'USD' and 'EUR', the function
|
|
will return::
|
|
|
|
{
|
|
'EUR': (1.2345, '2025-01-20'),
|
|
'USD': (1.0, '2025-01-20'),
|
|
}
|
|
"""
|
|
iap_proxy_url = self.env['ir.config_parameter'].sudo().get_param('currency_rate_live.iap_proxy_url', PROXY_URL)
|
|
currency_data = iap_jsonrpc(f'{iap_proxy_url}/api/currency_rate/1/get_currency_rates', params={'provider': 'xe_com'})
|
|
available_currency_names = available_currencies.mapped('name')
|
|
rslt = {}
|
|
|
|
for timestamp, rates in currency_data.items():
|
|
for currency, rate in rates.items():
|
|
if currency in available_currency_names and rate:
|
|
date_rate = datetime.datetime.strptime(timestamp, "%Y-%m-%dT%H:%M:%SZ")
|
|
rslt[currency] = (rate, date_rate.strftime(DEFAULT_SERVER_DATE_FORMAT))
|
|
|
|
return rslt
|
|
|
|
def _parse_bnr_data(self, available_currencies):
|
|
''' This method is used to update the currencies by using
|
|
BNR service provider. Rates are given against RON
|
|
'''
|
|
request_url = "https://curs.bnr.ro/nbrfxrates.xml"
|
|
response = requests.get(request_url, timeout=30)
|
|
response.raise_for_status()
|
|
|
|
xmlstr = etree.fromstring(response.content)
|
|
data = xml2json_from_elementtree(xmlstr)
|
|
available_currency_names = available_currencies.mapped('name')
|
|
rate_date = fields.Date.today()
|
|
rslt = {}
|
|
rates_node = data['children'][1]['children'][2]
|
|
if rates_node:
|
|
# Rates are valid for the next day, refer:
|
|
# https://lege5.ro/Gratuit/ha4tomrvge/cursul-de-schimb-valutar-norma-metodologica?dp=ha3tgmzwgu2dk
|
|
rate_date = (datetime.datetime.strptime(
|
|
rates_node['attrs']['date'], DEFAULT_SERVER_DATE_FORMAT
|
|
) + datetime.timedelta(days=1)).strftime(DEFAULT_SERVER_DATE_FORMAT)
|
|
for x in rates_node['children']:
|
|
if x['attrs']['currency'] in available_currency_names:
|
|
rslt[x['attrs']['currency']] = (
|
|
float(x['attrs'].get('multiplier', '1')) / float(x['children'][0]),
|
|
rate_date
|
|
)
|
|
if rslt and 'RON' in available_currency_names:
|
|
rslt['RON'] = (1.0, rate_date)
|
|
return rslt
|
|
|
|
def _parse_srb_data(self, available_currencies):
|
|
""" This method is used to update the currencies by using
|
|
Svenska Riksbanken (SRB) service provider. Rates are given
|
|
against SEK.
|
|
"""
|
|
response = requests.get("https://api.riksbank.se/swea/v1/Observations/Latest/ByGroup/130", timeout=30)
|
|
response.raise_for_status()
|
|
|
|
# Verify that the response is in JSON format.
|
|
if 'application/json' not in response.headers.get('Content-Type', ''):
|
|
raise ValueError('Response should be in JSON format')
|
|
|
|
data_json = response.json()
|
|
available_currency_names = set(available_currencies.mapped('name'))
|
|
rslt = {}
|
|
|
|
# Create a lookup dictionary for series data
|
|
series_data = {item['seriesId']: item for item in data_json}
|
|
|
|
for currency in available_currency_names:
|
|
if currency == 'SEK':
|
|
rslt[currency] = (1.0, datetime.datetime.now(timezone('Europe/Stockholm')).strftime(DEFAULT_SERVER_DATE_FORMAT))
|
|
continue
|
|
|
|
line_json = series_data.get(f'SEK{currency}PMI')
|
|
|
|
# Ensure that the data exists and is valid
|
|
if not line_json or not isinstance(line_json['value'], (int, float)) or line_json['value'] == 0:
|
|
continue
|
|
date = datetime.datetime.strptime(line_json['date'], '%Y-%m-%d').strftime(DEFAULT_SERVER_DATE_FORMAT)
|
|
rslt[currency] = (1.0 / line_json['value'], date)
|
|
|
|
return rslt
|
|
|
|
def _parse_bcrp_data(self, available_currencies):
|
|
"""Sunat
|
|
Source: https://www.sunat.gob.pe/descarga/TipoCambio.txt
|
|
* The value of the rate is the "official" rate
|
|
* The base currency is always PEN but with the inverse 1/rate.
|
|
"""
|
|
|
|
result = {}
|
|
available_currency_names = available_currencies.mapped('name')
|
|
if 'PEN' not in available_currency_names or "USD" not in available_currency_names:
|
|
return result
|
|
result['PEN'] = (1.0, fields.Date.context_today(self.with_context(tz='America/Lima')))
|
|
url_format = "https://www.sunat.gob.pe/a/txt/tipoCambio.txt"
|
|
try:
|
|
res = requests.get(url_format, timeout=10)
|
|
res.raise_for_status()
|
|
line = res.text.splitlines()[0] or ""
|
|
except Exception as e:
|
|
_logger.error(e)
|
|
return result
|
|
sunat_value = line.split("|")
|
|
try:
|
|
rate = float(sunat_value[2])
|
|
except ValueError as e:
|
|
_logger.error(e)
|
|
return result
|
|
rate = 1.0 / rate if rate else 0
|
|
date_rate_str = sunat_value[0]
|
|
date_rate = datetime.datetime.strptime(date_rate_str, '%d/%m/%Y').strftime(DEFAULT_SERVER_DATE_FORMAT)
|
|
result["USD"] = (rate, date_rate)
|
|
return result
|
|
|
|
def _parse_mindicador_data(self, available_currencies):
|
|
"""Parse function for mindicador.cl provider for Chile
|
|
|
|
* Regarding needs of rates in Chile there will be one rate per day, except for UTM index (one per month)
|
|
* The value of the rate is the "official" rate
|
|
* The base currency is always CLP but with the inverse 1/rate.
|
|
* The webservice returns the following currency rates:
|
|
|
|
- EUR
|
|
- USD (Dolar Observado)
|
|
- UF (Unidad de Fomento)
|
|
- UTM (Unidad Tributaria Mensual)
|
|
"""
|
|
logger = _logger.getChild('mindicador')
|
|
icp = self.env['ir.config_parameter'].sudo()
|
|
server_url = icp.get_param('mindicador_api_url')
|
|
if not server_url:
|
|
server_url = 'https://mindicador.cl/api'
|
|
icp.set_param('mindicador_api_url', server_url)
|
|
foreigns = {
|
|
"USD": "dolar",
|
|
"EUR": "euro",
|
|
"UF": "uf",
|
|
"UTM": "utm",
|
|
}
|
|
timeout = int(icp.get_param('mindicador_api_timeout', 30))
|
|
available_currency_names = available_currencies.mapped('name')
|
|
logger.debug('mindicador: available currency names: %s', available_currency_names)
|
|
today_date = fields.Date.context_today(self.with_context(tz='America/Santiago'))
|
|
rslt = {
|
|
'CLP': (1.0, fields.Date.to_string(today_date)),
|
|
}
|
|
request_date = today_date.strftime('%d-%m-%Y')
|
|
for index, currency in foreigns.items():
|
|
if index not in available_currency_names:
|
|
logger.debug('Index %s not in available currency name', index)
|
|
continue
|
|
url = server_url + '/%s/%s' % (currency, request_date)
|
|
res = requests.get(url, timeout=timeout)
|
|
res.raise_for_status()
|
|
if 'html' in res.text:
|
|
raise ValueError('Should be json')
|
|
data_json = res.json()
|
|
if not data_json['serie']:
|
|
continue
|
|
date = data_json['serie'][0]['fecha'][:10]
|
|
rate = data_json['serie'][0]['valor']
|
|
rslt[index] = (1.0 / rate, date)
|
|
return rslt
|
|
|
|
def _parse_banrepco_data(self, available_currencies):
|
|
"""
|
|
Parse function for the Banco de la Republica de Colombia
|
|
* the webservice returns the exchange rate between Colombian Peso (COP) and USD
|
|
"""
|
|
res = requests.get('https://totoro.banrep.gov.co/nsi-jax-ws/rest/data/ESTAT,DF_TRM_DAILY_LATEST,1.0/all/ALL', timeout=10)
|
|
res.raise_for_status()
|
|
tree = etree.fromstring(res.content)
|
|
|
|
generic_ns = 'http://www.sdmx.org/resources/sdmxml/schemas/v2_1/data/generic'
|
|
rate = float(tree.find(f'.//{{{generic_ns}}}ObsValue').get('value'))
|
|
|
|
time_period = tree.find(f'.//{{{generic_ns}}}ObsDimension').get('value')
|
|
date = fields.Date.to_string(datetime.datetime.strptime(time_period, '%Y%m%d'))
|
|
|
|
return {'COP': (1, date), 'USD': (1.0 / rate, date)}
|
|
|
|
def _parse_tcmb_data(self, available_currencies):
|
|
"""Parse function for Turkish Central bank provider
|
|
* The webservice returns the following currency rates:
|
|
- USD, AUD, DKK, EUR, GBP, CHF, SEK, CAD, KWD, NOK, SAR,
|
|
- JPY, BGN, RON, RUB, IRR, CNY, PKR, QAR, KRW, AZN, AED
|
|
"""
|
|
# For TCMB, the selling rate is used instead of the average of buying and selling rates.
|
|
server_url = 'https://www.tcmb.gov.tr/kurlar/today.xml'
|
|
available_currency_names = set(available_currencies.mapped('name'))
|
|
|
|
# LegacyHTTPAdapter is used as connecting to the url raises an SSL error "unsafe legacy renegotiation disabled".
|
|
# This happens with OpenSSL 3.0 when trying to connect to legacy websites that disable renegotiation without signaling it correctly.
|
|
session = requests.Session()
|
|
session.mount('https://', LegacyHTTPAdapter())
|
|
|
|
res = session.get(server_url, timeout=30)
|
|
res.raise_for_status()
|
|
|
|
root = etree.fromstring(res.text.encode())
|
|
rate_date = fields.Date.to_string(datetime.datetime.strptime(root.attrib['Date'], '%m/%d/%Y'))
|
|
rslt = {
|
|
currency.attrib['Kod']: (1 / float(currency.find('ForexSelling').text), rate_date)
|
|
for currency in root
|
|
if currency.attrib['Kod'] in available_currency_names
|
|
}
|
|
rslt['TRY'] = (1.0, rate_date)
|
|
|
|
return rslt
|
|
|
|
def _parse_nbp_data(self, available_currencies):
|
|
""" This method is used to update the currencies by using NBP (National Polish Bank) service API.
|
|
Rates are given against PLN.
|
|
Source: https://apps.odoo.com/apps/modules/14.0/trilab_live_currency_nbp/
|
|
Code is mostly from Trilab's app with Trilab's permission.
|
|
"""
|
|
|
|
# this is url to fetch active (at the moment of fetch) average currency exchange table
|
|
request_url = 'https://api.nbp.pl/api/exchangerates/tables/{}/?format=json'
|
|
requested_currency_codes = available_currencies.mapped('name')
|
|
result = {}
|
|
|
|
# there are 3 tables with currencies:
|
|
# A - most used ones average,
|
|
# B - exotic currencies average,
|
|
# C - common bid/sell
|
|
# we will parse first one and if there are unmatched currencies, proceed with second one
|
|
|
|
for table_type in ['A', 'B']:
|
|
if not requested_currency_codes:
|
|
break
|
|
|
|
response = requests.get(request_url.format(table_type), timeout=10)
|
|
response.raise_for_status()
|
|
response_data = response.json()
|
|
for exchange_table in response_data:
|
|
# there *should not be* be more than one table in response, but let's be on the safe side
|
|
# and parse this in a loop as response is a list
|
|
|
|
# effective date of this table
|
|
table_date = datetime.datetime.strptime(
|
|
exchange_table['effectiveDate'], '%Y-%m-%d'
|
|
).date()
|
|
|
|
# for tax purpose, polish companies must use rate of day before transaction
|
|
# this is achieved by offsetting the rate date by one day
|
|
table_date += relativedelta(days=1)
|
|
|
|
# add base currency
|
|
if 'PLN' not in result and 'PLN' in requested_currency_codes:
|
|
result['PLN'] = (1.0, table_date)
|
|
|
|
for rec in exchange_table['rates']:
|
|
if rec['code'] in requested_currency_codes:
|
|
result[rec['code']] = (1.0 / rec['mid'], table_date)
|
|
requested_currency_codes.remove(rec['code'])
|
|
|
|
return result
|
|
|
|
def _parse_cnb_data(self, available_currencies):
|
|
''' This method is used to update the currencies by using CNB service provider.
|
|
Rates are given against Czech Koruna
|
|
'''
|
|
request_url = "https://www.cnb.cz/cs/financni-trhy/devizovy-trh/kurzy-devizoveho-trhu/kurzy-devizoveho-trhu/denni_kurz.txt"
|
|
response = requests.get(request_url, timeout=3)
|
|
response.raise_for_status()
|
|
response = str(response.content, 'UTF-8')
|
|
|
|
last_update = fields.Date.to_date(datetime.datetime.strptime(response.split(' ')[0], "%d.%m.%Y"))
|
|
rates_lines = response.split('\n')[2:-1]
|
|
available_currency_names = available_currencies.mapped('name')
|
|
rslt = {}
|
|
for rate_line in rates_lines:
|
|
_country, _currency, amount, code, rate = rate_line.replace(',', '.').split('|')
|
|
if code in available_currency_names:
|
|
rslt[code] = (float(amount) / float(rate), last_update)
|
|
|
|
if rslt and 'CZK' in available_currency_names:
|
|
rslt['CZK'] = (1.0, last_update)
|
|
return rslt
|
|
|
|
def _get_bcu_currencies_mapping(self):
|
|
""" Return info about the currencies and corresponding BCU identifications needed for synchronization """
|
|
return {
|
|
'ARS': 501,
|
|
'AUD': 105,
|
|
'BRL': 1001,
|
|
'CAD': 2309,
|
|
'CHF': 5900,
|
|
'CLP': 1300,
|
|
'CNY': 4150,
|
|
'COP': 5500,
|
|
'DKK': 1800,
|
|
'EUR': 1111,
|
|
'GBP': 2700,
|
|
'HKD': 5100,
|
|
'HUF': 4300,
|
|
'INR': 5700,
|
|
'ISK': 4900,
|
|
'JPY': 3600,
|
|
'KRW': 5300,
|
|
'MXN': 4200,
|
|
'MYR': 5600,
|
|
'NOK': 4600,
|
|
'NZD': 1490,
|
|
'PEN': 4000,
|
|
'PYG': 4800,
|
|
'RUB': 5400,
|
|
'SEK': 5800,
|
|
'TRY': 4400,
|
|
'USD': 2225,
|
|
'UYI': 9800,
|
|
'VEF': 6200,
|
|
'ZAR': 1620,
|
|
}
|
|
|
|
def _parse_bcu_data(self, available_currencies):
|
|
""" This method is used to update the currencies by using BCU service provider.
|
|
They can be manually verified at:
|
|
https://www.bcu.gub.uy/Estadisticas-e-Indicadores/Paginas/Cotizaciones.aspx
|
|
"""
|
|
# Only sync currencies that have BCU code, UYU is not included
|
|
iso_to_moneda_map = self._get_bcu_currencies_mapping()
|
|
if not (to_sync_currencies := available_currencies.filtered(lambda c: c.name in iso_to_moneda_map)):
|
|
raise UserError(_("No available currency rate could be updated from the BCU."))
|
|
moneda_to_iso_map = {v: k for k, v in iso_to_moneda_map.items()}
|
|
|
|
wsdl = "https://cotizaciones.bcu.gub.uy/wscotizaciones/servlet/%s/service.asmx?WSDL"
|
|
date_api_client = Client(wsdl % 'awsultimocierre')
|
|
rate_api_client = Client(wsdl % 'awsbcucotizaciones')
|
|
|
|
_logger.info("Getting the date of the last currency rate update from the BCU.")
|
|
# On the closing date, it gives the rate for the next day
|
|
last_closing_date = date_api_client.service.Execute()
|
|
|
|
to_sync_codes = sorted(to_sync_currencies.mapped("name"))
|
|
_logger.info("Getting the currency rates for (%s) from the BCU.", ", ".join(to_sync_codes))
|
|
Entrada = rate_api_client.type_factory('ns0').wsbcucotizacionesin(
|
|
Moneda={'item': to_sync_currencies.mapped(lambda x: iso_to_moneda_map[x.name])},
|
|
FechaDesde=last_closing_date,
|
|
FechaHasta=last_closing_date,
|
|
Grupo=0,
|
|
)
|
|
response = rate_api_client.service.Execute(Entrada)
|
|
if response.respuestastatus.codigoerror:
|
|
error_message = response.respuestastatus.mensaje
|
|
raise UserError(_('Error updating the currency rates from the BCU: %s.', error_message))
|
|
|
|
rate_date = last_closing_date + relativedelta(days=1)
|
|
res = {'UYU': (1.0, rate_date)}
|
|
for rate_values in response.datoscotizaciones['datoscotizaciones.dato']:
|
|
iso_code = moneda_to_iso_map[rate_values.Moneda]
|
|
rate = 1.0 / serialize_object(rate_values.TCV)
|
|
res[iso_code] = (rate, rate_date)
|
|
|
|
_logger.info("Currency rates have been downloaded from the BCU.")
|
|
return res
|
|
|
|
def _parse_bnb_data(self, available_currencies):
|
|
""" This method is used to update the currencies by using BNB (Bulgaria National Bank) service API.
|
|
Rates are given against EUR in an XML file (since Bulgaria joined Eurozone in 2026).
|
|
Source: https://www.bnb.bg/AboutUs/AUFAQ/Contr_Exchange_Rates_FAQ?toLang=_EN
|
|
|
|
If a currency has no rate, it will be skipped.
|
|
"""
|
|
request_url = "https://www.bnb.bg/Statistics/StExternalSector/StExchangeRates/StERForeignCurrencies/index.htm?download=xml&search=&lang=EN"
|
|
|
|
try:
|
|
response = requests.get(request_url, timeout=10)
|
|
response.raise_for_status()
|
|
rowset = etree.fromstring(response.content)
|
|
except (requests.RequestException, etree.ParseError):
|
|
# connection error, the request wasn't successful or the content could not be parsed
|
|
return False
|
|
|
|
available_currency_names = available_currencies.mapped('name')
|
|
result = {}
|
|
|
|
# Skip the first ROW node that does not contain currency information
|
|
for row in islice(rowset.iterfind('.//ROW'), 1, None):
|
|
code = row.findtext('CODE')
|
|
rate = row.findtext('RATE')
|
|
curr_date = datetime.datetime.strptime(row.findtext('CURR_DATE'), '%d.%m.%Y').date()
|
|
|
|
if code in available_currency_names and rate:
|
|
result[code] = (float(rate), curr_date)
|
|
|
|
if result and 'EUR' in available_currency_names:
|
|
result['EUR'] = (1.0, curr_date)
|
|
return result
|
|
|
|
def _parse_bot_data(self, available_currencies):
|
|
""" Bank of Thailand provider.
|
|
Rates are given against Thai Baht.
|
|
"""
|
|
try:
|
|
payload = {
|
|
'jsonrpc': '2.0',
|
|
'method': 'call',
|
|
'params': {
|
|
'provider': 'Bank of Thailand',
|
|
},
|
|
}
|
|
response = requests.post(
|
|
f'{PROXY_URL}/api/currency_rate/1/get_currency_rates',
|
|
json=payload,
|
|
timeout=30,
|
|
).json()
|
|
|
|
if response.get('error'):
|
|
return False
|
|
series = response['result']
|
|
except requests.RequestException as e:
|
|
_logger.error(e)
|
|
return False
|
|
|
|
available_currency_names = available_currencies.mapped('name')
|
|
result = {}
|
|
|
|
for currency, data_detail in series.items():
|
|
period = data_detail['period']
|
|
rate = float(data_detail['rate'])
|
|
if currency in available_currency_names and rate:
|
|
result[currency] = (1.0 / rate, period)
|
|
|
|
if result and 'THB' in available_currency_names and period:
|
|
result['THB'] = (1.0, period)
|
|
|
|
return result
|
|
|
|
def _parse_boi_data(self, available_currencies):
|
|
''' This method is used to update the currencies by using Bank of Italy service provider.
|
|
Rates are given against EUR
|
|
'''
|
|
# Available languages: en, it
|
|
url = "https://tassidicambio.bancaditalia.it/terzevalute-wf-web/rest/v1.0/latestRates?lang=en"
|
|
result = {}
|
|
|
|
try:
|
|
response = requests.get(url, headers={"Accept": "application/json"}, timeout=10)
|
|
response.raise_for_status()
|
|
except Exception as e: # noqa: BLE001
|
|
_logger.error(e)
|
|
return result
|
|
|
|
curr_iso_codes = available_currencies.mapped("name")
|
|
curr_list = response.json()["latestRates"]
|
|
|
|
for curr in curr_list:
|
|
if curr["isoCode"] in curr_iso_codes:
|
|
date = fields.Date.to_date(curr["referenceDate"])
|
|
try:
|
|
# If the rate is not a number, skip it
|
|
result[curr["isoCode"]] = (float(curr["eurRate"]), date)
|
|
except ValueError:
|
|
continue
|
|
|
|
return result
|
|
|
|
@api.model
|
|
def _parse_bnm_data(self, available_currencies):
|
|
""" This method is used to update the currencies by using BNM (Bank Negara Malaysia) service API.
|
|
Rates are given against MYR as a JSON.
|
|
Source: https://apikijangportal.bnm.gov.my/openapi
|
|
|
|
If a currency has no rate, it will be skipped.
|
|
"""
|
|
request_url = "https://api.bnm.gov.my/public/exchange-rate"
|
|
request_headers = {
|
|
'accept': 'application/vnd.BNM.API.v1+json',
|
|
}
|
|
|
|
response = requests.get(request_url, headers=request_headers, timeout=10)
|
|
response.raise_for_status()
|
|
result = response.json()
|
|
|
|
data = result.get('data')
|
|
if not data:
|
|
return False
|
|
|
|
available_currency_names = available_currencies.mapped('name')
|
|
result = {}
|
|
|
|
date = datetime.datetime.now()
|
|
for currency in data:
|
|
currency_code = currency['currency_code']
|
|
if currency_code in available_currency_names:
|
|
date = datetime.datetime.strptime(currency['rate']['date'], '%Y-%m-%d').date()
|
|
rate = (1 / currency['rate']['middle_rate']) * currency['unit']
|
|
result[currency_code] = (float(rate), date)
|
|
|
|
if result and 'MYR' not in result:
|
|
result['MYR'] = (1.0, date)
|
|
|
|
return result
|
|
|
|
@api.model
|
|
def _parse_bi_data(self, available_currencies):
|
|
"""
|
|
This method is used to update the currencies by using BI (Bank Indonesia) service API.
|
|
Rates are given against IDR as a XML.
|
|
Source: https://www.bi.go.id/biwebservice/wskursbi.asmx
|
|
|
|
If a currency has no rate, it will be skipped.
|
|
"""
|
|
request_url = "https://www.bi.go.id/biwebservice/wskursbi.asmx/getSubKursLokal4"
|
|
headers = {
|
|
'user-agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36',
|
|
}
|
|
|
|
def _fetched_bi_currency_tables(start_date):
|
|
response = requests.get(request_url, headers=headers, params={
|
|
'startdate': start_date,
|
|
}, timeout=10)
|
|
response.raise_for_status()
|
|
xml_tree = etree.fromstring(response.content)
|
|
return xml_tree.xpath("//Table")
|
|
|
|
# The rates are updated once a day, at 8am. It was asked to try and get today's rate when possible.
|
|
# To avoid too many api calls, we will first check the current time. If it is > 8am, we will try to get
|
|
# today's rate. If it fails, we will fall back on yesterday's.
|
|
# This is to avoid issues where the cron would run before 8am every day and never find today's rates.
|
|
currency_tables = []
|
|
current_datetime = datetime.datetime.now(timezone('Asia/Jakarta'))
|
|
request_date = current_datetime.date()
|
|
|
|
if current_datetime.hour >= 8:
|
|
currency_tables = _fetched_bi_currency_tables(request_date.isoformat())
|
|
|
|
# If we couldn't find the current day's data (too early, ...) we fall back to yesterday's
|
|
if not currency_tables:
|
|
request_date = (current_datetime - relativedelta(days=1)).date()
|
|
currency_tables = _fetched_bi_currency_tables(request_date.isoformat())
|
|
|
|
result = {}
|
|
available_currency_names = available_currencies.mapped('name')
|
|
for table in currency_tables:
|
|
currency_code = table.xpath("normalize-space(.//mts_subkurslokal)")
|
|
if currency_code in available_currency_names:
|
|
selling_rate = table.xpath("number(.//jual_subkurslokal)")
|
|
buying_rate = table.xpath("number(.//beli_subkurslokal)")
|
|
middle_rate = (selling_rate + buying_rate) / 2
|
|
|
|
unit = table.xpath("number(.//nil_subkurslokal)")
|
|
|
|
rate = (1 / middle_rate) * unit
|
|
result[currency_code] = (rate, request_date)
|
|
|
|
# We will still add IDR even if there is no result, as it could happen during public holidays.
|
|
# It will work, but won't update any rates.
|
|
if 'IDR' not in result:
|
|
result['IDR'] = (1.0, request_date)
|
|
|
|
return result
|
|
|
|
@api.model
|
|
def _parse_mnb_data(self, available_currencies):
|
|
"""
|
|
This method is used to update the currencies by using MNB (Magyar Nemzeti Bank) service API.
|
|
The bank's latest rates are accessible using the SOAP service: http://www.mnb.hu/arfolyamok.asmx?wsdl
|
|
|
|
Source: https://www.mnb.hu/en/arfolyamok
|
|
|
|
If a currency has no rate, it will be skipped.
|
|
"""
|
|
request_url = "http://www.mnb.hu/arfolyamok.asmx?wsdl"
|
|
client = Client(request_url)
|
|
rates_el = etree.fromstring(client.service.GetCurrentExchangeRates())
|
|
|
|
result = {}
|
|
try:
|
|
date = datetime.datetime.strptime(rates_el.find('./Day').attrib['date'], '%Y-%m-%d')
|
|
rates = {
|
|
rate_el.attrib['curr']: int(rate_el.attrib['unit']) / float(rate_el.text.replace(',', '.'))
|
|
for rate_el in rates_el.findall('.//Rate')
|
|
}
|
|
except (AttributeError, ValueError):
|
|
return result
|
|
|
|
for curr in available_currencies.mapped('name') & rates.keys():
|
|
result[curr] = (rates[curr], date)
|
|
|
|
if 'HUF' not in result:
|
|
result['HUF'] = (1.0, date)
|
|
return result
|
|
|
|
def _parse_nbkz_data(self, available_currencies):
|
|
"""
|
|
This method is used to update the currencies by using National Bank of Kazakhstan.
|
|
Exchange rates are expressed as 1 unit of the foreign currency converted into KZT
|
|
"""
|
|
request_url = 'https://nationalbank.kz/rss/rates_all.xml'
|
|
response = requests.get(request_url, timeout=30)
|
|
response.raise_for_status()
|
|
|
|
rates_dict = {}
|
|
available_currency_names = available_currencies.mapped('name')
|
|
xml_tree = etree.fromstring(response.content)
|
|
data = xml2json_from_elementtree(xml_tree.find('channel'))
|
|
for currency_item in filter(lambda child: child['tag'] == 'item', data['children']):
|
|
for attr in currency_item['children']:
|
|
val = attr['children'] and attr['children'][0]
|
|
match attr['tag']:
|
|
case 'title':
|
|
code = val
|
|
case 'pubDate':
|
|
date_rate = val
|
|
case 'description':
|
|
rate = val
|
|
case 'quant':
|
|
quant = val
|
|
case _:
|
|
pass
|
|
|
|
if code in available_currency_names:
|
|
rates_dict[code] = (float(quant) / float(rate), datetime.datetime.strptime(date_rate, "%d.%m.%Y").date()) if float(rate) else 0
|
|
|
|
if 'KZT' in available_currency_names:
|
|
rates_dict['KZT'] = (1.0, fields.Date.today())
|
|
|
|
return rates_dict
|
|
|
|
@api.model
|
|
def _parse_cu_data(self, available_currencies):
|
|
""" This method is used to update the currencies by using CU service provider.
|
|
They can be manually verified at: https://www.bc.gob.cu/tasas-de-cambio
|
|
"""
|
|
url = "https://api.bc.gob.cu/v1/tasas-de-cambio/activas"
|
|
result = {}
|
|
|
|
response = requests.get(url, headers={"Accept": "application/json"}, timeout=30)
|
|
response.raise_for_status()
|
|
|
|
curr_iso_codes = available_currencies.mapped("name")
|
|
curr_list = response.json()["tasas"]
|
|
last_date = datetime.datetime.now()
|
|
|
|
rate_type = self.env['ir.config_parameter'].sudo().get_param('currency_rate_live.l10n_cu_currency_rate_type', 'tasaOficial')
|
|
|
|
for curr in curr_list:
|
|
code = curr["codigoMoneda"]
|
|
status = curr["estado"]
|
|
|
|
# Skip currencies not requested or not active
|
|
if code not in curr_iso_codes or status != "ACTIVA":
|
|
continue
|
|
|
|
last_date = fields.Date.to_date(curr["fechaDia"])
|
|
result[curr["codigoMoneda"]] = (float(1 / curr[rate_type]), last_date)
|
|
|
|
result['CUP'] = (1.0, last_date)
|
|
return result
|
|
|
|
@api.model
|
|
def _parse_cbu_data(self, available_currencies):
|
|
"""
|
|
This method is used to update the currencies by using CBU (Central Bank of Uzbekistan) service API.
|
|
The bank's latest rates are accessible using the API: cbu.uz/en/arkhiv-kursov-valyut/json
|
|
|
|
Source: https://cbu.uz/en/arkhiv-kursov-valyut/json
|
|
|
|
If a currency has no rate, it will be skipped.
|
|
"""
|
|
result = {}
|
|
available_currency_names = set(available_currencies.mapped('name'))
|
|
request_url = "https://cbu.uz/en/arkhiv-kursov-valyut/json"
|
|
try:
|
|
# 5 second connection timeout and 15 seconds read timeout
|
|
response = requests.get(request_url, timeout=(5, 15))
|
|
response.raise_for_status()
|
|
new_rates = response.json()
|
|
except (requests.RequestException, ValueError):
|
|
_logger.exception("Failed to fetch UZ currency rates from %s", request_url)
|
|
return result
|
|
|
|
for rate in new_rates:
|
|
code = rate.get('Ccy')
|
|
if code in available_currency_names:
|
|
rate_value, date = rate.get('Rate'), rate.get('Date')
|
|
if rate_value:
|
|
result[code] = (1.0 / float(rate_value), datetime.datetime.strptime(date, '%d.%m.%Y').date())
|
|
|
|
if result and 'UZS' in available_currency_names:
|
|
result['UZS'] = (1.0, fields.Date.today())
|
|
return result
|
|
|
|
@api.model
|
|
def run_update_currency(self):
|
|
""" This method is called from a cron job to update currency rates.
|
|
"""
|
|
records = self.search([
|
|
('currency_next_execution_date', '<=', fields.Date.today()),
|
|
('parent_id', '=', False),
|
|
])
|
|
if records:
|
|
to_update = self.env['res.company']
|
|
for record in records:
|
|
if record.currency_interval_unit == 'daily':
|
|
next_update = relativedelta(days=+1)
|
|
elif record.currency_interval_unit == 'weekly':
|
|
next_update = relativedelta(weeks=+1)
|
|
elif record.currency_interval_unit == 'monthly':
|
|
next_update = relativedelta(months=+1)
|
|
else:
|
|
record.currency_next_execution_date = False
|
|
continue
|
|
record.currency_next_execution_date = datetime.date.today() + next_update
|
|
to_update += record
|
|
to_update.with_context(suppress_errors=True).update_currency_rates()
|
|
|
|
|
|
class ResConfigSettings(models.TransientModel):
|
|
_inherit = 'res.config.settings'
|
|
|
|
currency_interval_unit = fields.Selection(related="company_id.currency_interval_unit", readonly=False)
|
|
currency_provider = fields.Selection(related="company_id.currency_provider", readonly=False)
|
|
currency_next_execution_date = fields.Date(related="company_id.currency_next_execution_date", readonly=False)
|
|
|
|
@api.onchange('currency_interval_unit')
|
|
def onchange_currency_interval_unit(self):
|
|
#as the onchange is called upon each opening of the settings, we avoid overwriting
|
|
#the next execution date if it has been already set
|
|
if self.company_id.currency_next_execution_date:
|
|
return
|
|
if self.currency_interval_unit == 'daily':
|
|
next_update = relativedelta(days=+1)
|
|
elif self.currency_interval_unit == 'weekly':
|
|
next_update = relativedelta(weeks=+1)
|
|
elif self.currency_interval_unit == 'monthly':
|
|
next_update = relativedelta(months=+1)
|
|
else:
|
|
self.currency_next_execution_date = False
|
|
return
|
|
self.currency_next_execution_date = datetime.date.today() + next_update
|
|
|
|
def update_currency_rates_manually(self):
|
|
self.ensure_one()
|
|
self.company_id.update_currency_rates()
|