diff --git a/currency_rate_update_wise/README.rst b/currency_rate_update_wise/README.rst new file mode 100644 index 00000000..7bd5f243 --- /dev/null +++ b/currency_rate_update_wise/README.rst @@ -0,0 +1,125 @@ +============================== +Currency Rate Update: Wise.com +============================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:2d06b7285657b548c0823662525d030d0925d9745747fc4acd748f575ad4d544 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fcurrency-lightgray.png?logo=github + :target: https://github.com/OCA/currency/tree/18.0/currency_rate_update_wise + :alt: OCA/currency +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/currency-18-0/currency-18-0-currency_rate_update_wise + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/currency&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module adds `Wise.com `__ as a currency exchange +rates provider for ``currency_rate_update``. + +It is meant as a *worldwide* complement to the built-in ECB provider: +the ECB provider gives the official European, EUR-centric daily +reference, while Wise provides a global rate source with the following +advantages: + +- **Rate quality**: Wise returns the interbank mid-market rate. On a + working day, aligned with the ECB fixing, it tracks the ECB reference + very closely (closer than xe.com in our measurements) and applies no + markup to the published rate. +- **Worldwide coverage**: Wise quotes around 164 currencies, against the + 29 of the ECB reference list, including currencies the ECB does not + publish (e.g. AED, COP). ECB stays the European reference; Wise + extends accurate rates to the rest of the world. +- **Stable API**: rates are fetched through an authenticated JSON API + rather than by scraping a web page, which keeps updates robust over + time. +- **Efficient and historical**: only the configured currency pairs are + requested (not a whole table), and past rates can be retrieved by + timestamp or date range (daily/hourly/minute granularity), useful for + backfill and corrections. + +A Wise.com API token is required (see Configuration). + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +To configure Wise.com currency rates provider credentials: + +1. Go to *Invoicing > Configuration > Settings* +2. Fill application credentials in *Currencies > Wise.com Provider* + section + +.. + + **Note** + + The API key is stored and sent as an opaque string of any length, so + both the current UUID-format personal tokens and the JWT format Wise + is migrating towards are supported. If a legacy UUID token is + configured, a warning is logged inviting you to regenerate it as JWT. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* CorporateHub +* Altixia + +Contributors +------------ + +- `CorporateHub `__ + + - Alexey Pelykh + +- `Altixia `__ + + - Claude Perrin + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/currency `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/currency_rate_update_wise/__init__.py b/currency_rate_update_wise/__init__.py new file mode 100644 index 00000000..4b76c7b2 --- /dev/null +++ b/currency_rate_update_wise/__init__.py @@ -0,0 +1,3 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from . import models diff --git a/currency_rate_update_wise/__manifest__.py b/currency_rate_update_wise/__manifest__.py new file mode 100644 index 00000000..6f761f78 --- /dev/null +++ b/currency_rate_update_wise/__manifest__.py @@ -0,0 +1,18 @@ +# Copyright 2019 Brainbean Apps (https://brainbeanapps.com) +# Copyright 2020 CorporateHub (https://corporatehub.eu) +# Copyright 2026 Altixia (https://altixia.com) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +{ + "name": "Currency Rate Update: Wise.com", + "version": "19.0.1.0.0", + "category": "Financial Management/Configuration", + "summary": "Update exchange rates using Wise.com", + "author": "CorporateHub, Altixia, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/currency", + "license": "AGPL-3", + "installable": True, + "application": False, + "depends": ["currency_rate_update"], + "data": ["views/res_config_settings.xml"], +} diff --git a/currency_rate_update_wise/i18n/currency_rate_update_wise.pot b/currency_rate_update_wise/i18n/currency_rate_update_wise.pot new file mode 100644 index 00000000..89e31246 --- /dev/null +++ b/currency_rate_update_wise/i18n/currency_rate_update_wise.pot @@ -0,0 +1,89 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * currency_rate_update_wise +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 14.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: currency_rate_update_wise +#: model_terms:ir.ui.view,arch_db:currency_rate_update_wise.res_config_settings_view_form +msgid "" +"Wise.com Provider\n" +" " +msgstr "" + +#. module: currency_rate_update_wise +#: model:ir.model.fields,field_description:currency_rate_update_wise.field_res_config_settings__wise_api_key +msgid "API Key" +msgstr "" + +#. module: currency_rate_update_wise +#: model:ir.model,name:currency_rate_update_wise.model_res_company +msgid "Companies" +msgstr "" + +#. module: currency_rate_update_wise +#: model:ir.model,name:currency_rate_update_wise.model_res_config_settings +msgid "Config Settings" +msgstr "" + +#. module: currency_rate_update_wise +#: model:ir.model,name:currency_rate_update_wise.model_res_currency_rate_provider +msgid "Currency Rates Provider" +msgstr "" + +#. module: currency_rate_update_wise +#: model:ir.model.fields,field_description:currency_rate_update_wise.field_res_company__display_name +#: model:ir.model.fields,field_description:currency_rate_update_wise.field_res_config_settings__display_name +#: model:ir.model.fields,field_description:currency_rate_update_wise.field_res_currency_rate_provider__display_name +msgid "Display Name" +msgstr "" + +#. module: currency_rate_update_wise +#: model:ir.model.fields,field_description:currency_rate_update_wise.field_res_company__id +#: model:ir.model.fields,field_description:currency_rate_update_wise.field_res_config_settings__id +#: model:ir.model.fields,field_description:currency_rate_update_wise.field_res_currency_rate_provider__id +msgid "ID" +msgstr "" + +#. module: currency_rate_update_wise +#: model:ir.model.fields,field_description:currency_rate_update_wise.field_res_company____last_update +#: model:ir.model.fields,field_description:currency_rate_update_wise.field_res_config_settings____last_update +#: model:ir.model.fields,field_description:currency_rate_update_wise.field_res_currency_rate_provider____last_update +msgid "Last Modified on" +msgstr "" + +#. module: currency_rate_update_wise +#: code:addons/currency_rate_update_wise/models/res_currency_rate_provider_Wise.py:0 +#, python-format +msgid "No Wise.com credentials specified!" +msgstr "" + +#. module: currency_rate_update_wise +#: model:ir.model.fields,field_description:currency_rate_update_wise.field_res_currency_rate_provider__service +msgid "Source Service" +msgstr "" + +#. module: currency_rate_update_wise +#: model_terms:ir.ui.view,arch_db:currency_rate_update_wise.res_config_settings_view_form +#: model:ir.model.fields.selection,name:currency_rate_update_wise.selection__res_currency_rate_provider__service__wise +msgid "Wise.com" +msgstr "" + +#. module: currency_rate_update_wise +#: model:ir.model.fields,field_description:currency_rate_update_wise.field_res_company__wise_api_key +msgid "Wise.com API Key" +msgstr "" + +#. module: currency_rate_update_wise +#: model_terms:ir.ui.view,arch_db:currency_rate_update_wise.res_config_settings_view_form +msgid "credentials" +msgstr "" diff --git a/currency_rate_update_wise/i18n/es.po b/currency_rate_update_wise/i18n/es.po new file mode 100644 index 00000000..c9c396db --- /dev/null +++ b/currency_rate_update_wise/i18n/es.po @@ -0,0 +1,94 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * currency_rate_update_wise +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 14.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2023-12-19 14:36+0000\n" +"Last-Translator: Ivorra78 \n" +"Language-Team: none\n" +"Language: es\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Weblate 4.17\n" + +#. module: currency_rate_update_wise +#: model_terms:ir.ui.view,arch_db:currency_rate_update_wise.res_config_settings_view_form +msgid "" +"Wise.com Provider\n" +" " +msgstr "" +"Proveedor de Wise.com\n" +" " + +#. module: currency_rate_update_wise +#: model:ir.model.fields,field_description:currency_rate_update_wise.field_res_config_settings__wise_api_key +msgid "API Key" +msgstr "Clave API" + +#. module: currency_rate_update_wise +#: model:ir.model,name:currency_rate_update_wise.model_res_company +msgid "Companies" +msgstr "Compañías" + +#. module: currency_rate_update_wise +#: model:ir.model,name:currency_rate_update_wise.model_res_config_settings +msgid "Config Settings" +msgstr "Ajustes de Config" + +#. module: currency_rate_update_wise +#: model:ir.model,name:currency_rate_update_wise.model_res_currency_rate_provider +msgid "Currency Rates Provider" +msgstr "Proveedor de Tipos de Cambio" + +#. module: currency_rate_update_wise +#: model:ir.model.fields,field_description:currency_rate_update_wise.field_res_company__display_name +#: model:ir.model.fields,field_description:currency_rate_update_wise.field_res_config_settings__display_name +#: model:ir.model.fields,field_description:currency_rate_update_wise.field_res_currency_rate_provider__display_name +msgid "Display Name" +msgstr "Mostrar Nombre" + +#. module: currency_rate_update_wise +#: model:ir.model.fields,field_description:currency_rate_update_wise.field_res_company__id +#: model:ir.model.fields,field_description:currency_rate_update_wise.field_res_config_settings__id +#: model:ir.model.fields,field_description:currency_rate_update_wise.field_res_currency_rate_provider__id +msgid "ID" +msgstr "ID (identificación)" + +#. module: currency_rate_update_wise +#: model:ir.model.fields,field_description:currency_rate_update_wise.field_res_company____last_update +#: model:ir.model.fields,field_description:currency_rate_update_wise.field_res_config_settings____last_update +#: model:ir.model.fields,field_description:currency_rate_update_wise.field_res_currency_rate_provider____last_update +msgid "Last Modified on" +msgstr "Última Modificación el" + +#. module: currency_rate_update_wise +#: code:addons/currency_rate_update_wise/models/res_currency_rate_provider_Wise.py:0 +#, python-format +msgid "No Wise.com credentials specified!" +msgstr "¡No se han especificado credenciales de Wise.com!" + +#. module: currency_rate_update_wise +#: model:ir.model.fields,field_description:currency_rate_update_wise.field_res_currency_rate_provider__service +msgid "Source Service" +msgstr "Servicio Fuente" + +#. module: currency_rate_update_wise +#: model_terms:ir.ui.view,arch_db:currency_rate_update_wise.res_config_settings_view_form +#: model:ir.model.fields.selection,name:currency_rate_update_wise.selection__res_currency_rate_provider__service__wise +msgid "Wise.com" +msgstr "Wise.com" + +#. module: currency_rate_update_wise +#: model:ir.model.fields,field_description:currency_rate_update_wise.field_res_company__wise_api_key +msgid "Wise.com API Key" +msgstr "Wise.com Clave API" + +#. module: currency_rate_update_wise +#: model_terms:ir.ui.view,arch_db:currency_rate_update_wise.res_config_settings_view_form +msgid "credentials" +msgstr "Credenciales" diff --git a/currency_rate_update_wise/i18n/fr.po b/currency_rate_update_wise/i18n/fr.po new file mode 100644 index 00000000..e2003f90 --- /dev/null +++ b/currency_rate_update_wise/i18n/fr.po @@ -0,0 +1,92 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * currency_rate_update_wise +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 13.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: \n" +"PO-Revision-Date: \n" +"Last-Translator: Alpis Traduction et Interprétation \n" +"Language-Team: \n" +"Language: fr\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" +"X-Generator: Poedit 2.4.3\n" + +#. module: currency_rate_update_wise +#: model:ir.model.fields,field_description:currency_rate_update_wise.field_res_config_settings__wise_api_key +msgid "API Key" +msgstr "Clé API" + +#. module: currency_rate_update_wise +#: model:ir.model,name:currency_rate_update_wise.model_res_company +msgid "Companies" +msgstr "Sociétés" + +#. module: currency_rate_update_wise +#: model:ir.model,name:currency_rate_update_wise.model_res_config_settings +msgid "Config Settings" +msgstr "Configuration" + +#. module: currency_rate_update_wise +#: model:ir.model,name:currency_rate_update_wise.model_res_currency_rate_provider +msgid "Currency Rates Provider" +msgstr "Fournisseur de taux de change" + +#. module: currency_rate_update_wise +#: model:ir.model.fields,field_description:currency_rate_update_wise.field_res_company__display_name +#: model:ir.model.fields,field_description:currency_rate_update_wise.field_res_config_settings__display_name +#: model:ir.model.fields,field_description:currency_rate_update_wise.field_res_currency_rate_provider__display_name +msgid "Display Name" +msgstr "" + +#. module: currency_rate_update_wise +#: model:ir.model.fields,field_description:currency_rate_update_wise.field_res_company__id +#: model:ir.model.fields,field_description:currency_rate_update_wise.field_res_config_settings__id +#: model:ir.model.fields,field_description:currency_rate_update_wise.field_res_currency_rate_provider__id +msgid "ID" +msgstr "" + +#. module: currency_rate_update_wise +#: model:ir.model.fields,field_description:currency_rate_update_wise.field_res_company____last_update +#: model:ir.model.fields,field_description:currency_rate_update_wise.field_res_config_settings____last_update +#: model:ir.model.fields,field_description:currency_rate_update_wise.field_res_currency_rate_provider____last_update +msgid "Last Modified on" +msgstr "" + +#. module: currency_rate_update_wise +#: code:addons/currency_rate_update_wise/models/res_currency_rate_provider_Wise.py:0 +#, python-format +msgid "No Wise.com credentials specified!" +msgstr "Aucun identifiant Wise.com fourni !" + +#. module: currency_rate_update_wise +#: model:ir.model.fields,field_description:currency_rate_update_wise.field_res_currency_rate_provider__service +msgid "Source Service" +msgstr "Fournisseur" + +#. module: currency_rate_update_wise +#: model:ir.model.fields.selection,name:currency_rate_update_wise.selection__res_currency_rate_provider__service__wise +msgid "Wise.com" +msgstr "Wise.com" + +#. module: currency_rate_update_wise +#: model:ir.model.fields,field_description:currency_rate_update_wise.field_res_company__wise_api_key +msgid "Wise.com API Key" +msgstr "Clé API Wise.com" + +#. module: currency_rate_update_wise +#: model_terms:ir.ui.view,arch_db:currency_rate_update_wise.res_config_settings_view_form +msgid "credentials" +msgstr "identifiants" + +#~ msgid "" +#~ "Wise.com Provider\n" +#~ " " +#~ msgstr "" +#~ "Fournisseur Wise.com \n" +#~ " " diff --git a/currency_rate_update_wise/i18n/it.po b/currency_rate_update_wise/i18n/it.po new file mode 100644 index 00000000..b8b5ee19 --- /dev/null +++ b/currency_rate_update_wise/i18n/it.po @@ -0,0 +1,90 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * currency_rate_update_wise +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 14.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: Automatically generated\n" +"Language-Team: none\n" +"Language: it\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" + +#. module: currency_rate_update_wise +#: model_terms:ir.ui.view,arch_db:currency_rate_update_wise.res_config_settings_view_form +msgid "" +"Wise.com Provider\n" +" " +msgstr "" + +#. module: currency_rate_update_wise +#: model:ir.model.fields,field_description:currency_rate_update_wise.field_res_config_settings__wise_api_key +msgid "API Key" +msgstr "" + +#. module: currency_rate_update_wise +#: model:ir.model,name:currency_rate_update_wise.model_res_company +msgid "Companies" +msgstr "" + +#. module: currency_rate_update_wise +#: model:ir.model,name:currency_rate_update_wise.model_res_config_settings +msgid "Config Settings" +msgstr "" + +#. module: currency_rate_update_wise +#: model:ir.model,name:currency_rate_update_wise.model_res_currency_rate_provider +msgid "Currency Rates Provider" +msgstr "" + +#. module: currency_rate_update_wise +#: model:ir.model.fields,field_description:currency_rate_update_wise.field_res_company__display_name +#: model:ir.model.fields,field_description:currency_rate_update_wise.field_res_config_settings__display_name +#: model:ir.model.fields,field_description:currency_rate_update_wise.field_res_currency_rate_provider__display_name +msgid "Display Name" +msgstr "" + +#. module: currency_rate_update_wise +#: model:ir.model.fields,field_description:currency_rate_update_wise.field_res_company__id +#: model:ir.model.fields,field_description:currency_rate_update_wise.field_res_config_settings__id +#: model:ir.model.fields,field_description:currency_rate_update_wise.field_res_currency_rate_provider__id +msgid "ID" +msgstr "" + +#. module: currency_rate_update_wise +#: model:ir.model.fields,field_description:currency_rate_update_wise.field_res_company____last_update +#: model:ir.model.fields,field_description:currency_rate_update_wise.field_res_config_settings____last_update +#: model:ir.model.fields,field_description:currency_rate_update_wise.field_res_currency_rate_provider____last_update +msgid "Last Modified on" +msgstr "" + +#. module: currency_rate_update_wise +#: code:addons/currency_rate_update_wise/models/res_currency_rate_provider_Wise.py:0 +#, python-format +msgid "No Wise.com credentials specified!" +msgstr "" + +#. module: currency_rate_update_wise +#: model:ir.model.fields,field_description:currency_rate_update_wise.field_res_currency_rate_provider__service +msgid "Source Service" +msgstr "" + +#. module: currency_rate_update_wise +#: model_terms:ir.ui.view,arch_db:currency_rate_update_wise.res_config_settings_view_form +#: model:ir.model.fields.selection,name:currency_rate_update_wise.selection__res_currency_rate_provider__service__wise +msgid "Wise.com" +msgstr "" + +#. module: currency_rate_update_wise +#: model:ir.model.fields,field_description:currency_rate_update_wise.field_res_company__wise_api_key +msgid "Wise.com API Key" +msgstr "" + +#. module: currency_rate_update_wise +#: model_terms:ir.ui.view,arch_db:currency_rate_update_wise.res_config_settings_view_form +msgid "credentials" +msgstr "" diff --git a/currency_rate_update_wise/models/__init__.py b/currency_rate_update_wise/models/__init__.py new file mode 100644 index 00000000..0c9baa21 --- /dev/null +++ b/currency_rate_update_wise/models/__init__.py @@ -0,0 +1,5 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from . import res_company +from . import res_config_settings +from . import res_currency_rate_provider_Wise diff --git a/currency_rate_update_wise/models/res_company.py b/currency_rate_update_wise/models/res_company.py new file mode 100644 index 00000000..68c03bdb --- /dev/null +++ b/currency_rate_update_wise/models/res_company.py @@ -0,0 +1,14 @@ +# Copyright 2019 Brainbean Apps (https://brainbeanapps.com) +# Copyright 2026 Altixia (https://altixia.com) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class ResCompany(models.Model): + _inherit = "res.company" + + wise_api_key = fields.Char( + string="Wise.com API Key", + groups="base.group_system", + ) diff --git a/currency_rate_update_wise/models/res_config_settings.py b/currency_rate_update_wise/models/res_config_settings.py new file mode 100644 index 00000000..3679924e --- /dev/null +++ b/currency_rate_update_wise/models/res_config_settings.py @@ -0,0 +1,16 @@ +# Copyright 2019 Brainbean Apps (https://brainbeanapps.com) +# Copyright 2026 Altixia (https://altixia.com) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo import fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = "res.config.settings" + + wise_api_key = fields.Char( + string="API Key", + related="company_id.wise_api_key", + readonly=False, + groups="base.group_system", + ) diff --git a/currency_rate_update_wise/models/res_currency_rate_provider_Wise.py b/currency_rate_update_wise/models/res_currency_rate_provider_Wise.py new file mode 100644 index 00000000..1f14d854 --- /dev/null +++ b/currency_rate_update_wise/models/res_currency_rate_provider_Wise.py @@ -0,0 +1,160 @@ +# Copyright 2019 Brainbean Apps (https://brainbeanapps.com) +# Copyright 2020 CorporateHub (https://corporatehub.eu) +# Copyright 2026 Altixia (https://altixia.com) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +import itertools +import logging +import re +from collections import defaultdict +from datetime import date, timedelta + +import dateutil.parser +import requests + +from odoo import fields, models +from odoo.exceptions import UserError + +_logger = logging.getLogger(__name__) + +WISE_API_URL = "https://api.wise.com/v1/rates" +# Network timeout (connect, read) in seconds: a scheduled cron must never hang +# indefinitely on an unresponsive endpoint. +TIMEOUT = (10, 30) +# Legacy UUID token format. Wise is migrating API tokens to JWT; a UUID token +# is still valid today but should eventually be regenerated as JWT. +_UUID_RE = re.compile( + r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", + re.IGNORECASE, +) +# Warn only during the migration window. Wise starts issuing JWT tokens in +# production with the transition period beginning end of July 2026, so warning +# before then is not actionable (UUID is the only available format). A year +# later the migration is over, so we stop the check to avoid nagging for years. +_LEGACY_WARN_FROM = date(2026, 8, 1) +_LEGACY_WARN_UNTIL = date(2027, 8, 1) + + +class ResCurrencyRateProviderWise(models.Model): + _inherit = "res.currency.rate.provider" + + service = fields.Selection( + selection_add=[("Wise", "Wise.com")], + ondelete={"Wise": "set default"}, + ) + + def _register_hook(self): + # Emit the legacy-token warning at Odoo load time. + res = super()._register_hook() + self.search([("service", "=", "Wise")])._wise_warn_legacy_tokens() + return res + + def _get_supported_currencies(self): + self.ensure_one() + if self.service != "Wise": + return super()._get_supported_currencies() # pragma: no cover + + data = self._wise_provider_retrieve(WISE_API_URL) + self._wise_check_error(data) + + return list( + set( + itertools.chain.from_iterable( + [entry["source"], entry["target"]] for entry in data + ) + ) + ) + + def _obtain_rates(self, base_currency, currencies, date_from, date_to): + self.ensure_one() + if self.service != "Wise": + return super()._obtain_rates( + base_currency, currencies, date_from, date_to + ) # pragma: no cover + + # Also warn on each scheduled update run (once per run). + self._wise_warn_legacy_tokens() + content = defaultdict(dict) + + # NOTE: Step in 10 days is selected to reduce memory impact + step = timedelta(days=10) + for currency in currencies: + since = date_from + until = since + step + while since <= date_to: + # Wise answers HTTP 400 when "from" equals "to". That is + # exactly the shape of a scheduled run once the rates are up + # to date: the provider then asks for the single missing day. + # Widening the window by one day is enough — the endpoint only + # ever returns the days it actually has, so no spurious rate + # is created. + window_end = max(min(until, date_to), since + timedelta(days=1)) + params = { + "source": base_currency, + "target": currency, + "from": str(since), + "to": str(window_end), + "group": "day", + } + data = self._wise_provider_retrieve(WISE_API_URL, params) + self._wise_check_error(data) + for entry in data: + date = dateutil.parser.parse(entry["time"]).date() + date_content = content[date.isoformat()] + date_content[currency] = entry["rate"] + + since += step + until += step + + return content + + def _wise_warn_legacy_tokens(self): + """Warn for each Wise provider whose token still uses the UUID format. + + Wise is migrating API tokens from the legacy UUID format to JWT. A UUID + token is still valid today but should eventually be regenerated as JWT + in the Wise Developer Hub. Nothing is logged when no key is set, and the + token storage already accepts both formats. + """ + today = fields.Date.today() + if not (_LEGACY_WARN_FROM <= today < _LEGACY_WARN_UNTIL): + return + for provider in self: + key = provider.company_id.wise_api_key + if key and _UUID_RE.match(key): + _logger.warning( + "Wise.com API key for company %s is a legacy UUID-format " + "token. Wise is migrating API tokens to JWT; consider " + "regenerating the token in the Wise Developer Hub.", + provider.company_id.name, + ) + + def _wise_check_error(self, data): + """Raise a clear error if the API returned an error payload.""" + if isinstance(data, dict) and data.get("error"): + raise UserError( + data.get("error_description") or self.env._("Unknown error") + ) + + def _wise_provider_retrieve(self, url, params=None): + self.ensure_one() + if not self.company_id.wise_api_key: + raise UserError(self.env._("No Wise.com credentials specified!")) + + try: + response = requests.get( + url, + params=params, + headers={"Authorization": f"Bearer {self.company_id.wise_api_key}"}, + timeout=TIMEOUT, + ) + response.raise_for_status() + return response.json() + except requests.exceptions.HTTPError: + raise + except requests.exceptions.RequestException as e: + raise UserError(self.env._("Could not reach Wise.com: %s", e)) from e + except ValueError as e: # invalid JSON + raise UserError( + self.env._("Unexpected (non-JSON) response from Wise.com") + ) from e diff --git a/currency_rate_update_wise/pyproject.toml b/currency_rate_update_wise/pyproject.toml new file mode 100644 index 00000000..4231d0cc --- /dev/null +++ b/currency_rate_update_wise/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/currency_rate_update_wise/readme/CONFIGURE.md b/currency_rate_update_wise/readme/CONFIGURE.md new file mode 100644 index 00000000..74ef81cc --- /dev/null +++ b/currency_rate_update_wise/readme/CONFIGURE.md @@ -0,0 +1,11 @@ +To configure Wise.com currency rates provider credentials: + +1. Go to *Invoicing > Configuration > Settings* +2. Fill application credentials in *Currencies > Wise.com Provider* section + +> **Note** +> +> The API key is stored and sent as an opaque string of any length, so both the +> current UUID-format personal tokens and the JWT format Wise is migrating +> towards are supported. If a legacy UUID token is configured, a warning is +> logged inviting you to regenerate it as JWT. diff --git a/currency_rate_update_wise/readme/CONTRIBUTORS.md b/currency_rate_update_wise/readme/CONTRIBUTORS.md new file mode 100644 index 00000000..69e97475 --- /dev/null +++ b/currency_rate_update_wise/readme/CONTRIBUTORS.md @@ -0,0 +1,4 @@ +- [CorporateHub](https://corporatehub.eu/) + - Alexey Pelykh \ +- [Altixia](https://altixia.com) + - Claude Perrin \ diff --git a/currency_rate_update_wise/readme/DESCRIPTION.md b/currency_rate_update_wise/readme/DESCRIPTION.md new file mode 100644 index 00000000..7fcbf0a0 --- /dev/null +++ b/currency_rate_update_wise/readme/DESCRIPTION.md @@ -0,0 +1,23 @@ +This module adds [Wise.com](https://wise.com/) as a currency exchange rates +provider for `currency_rate_update`. + +It is meant as a *worldwide* complement to the built-in ECB provider: the ECB +provider gives the official European, EUR-centric daily reference, while Wise +provides a global rate source with the following advantages: + +- **Rate quality**: Wise returns the interbank mid-market rate. On a working + day, aligned with the ECB fixing, it tracks the ECB reference very closely + (closer than xe.com in our measurements) and applies no markup to the + published rate. +- **Worldwide coverage**: Wise quotes around 164 currencies, against the 29 of + the ECB reference list, including currencies the ECB does not publish (e.g. + AED, COP). ECB stays the European reference; Wise extends accurate rates to + the rest of the world. +- **Stable API**: rates are fetched through an authenticated JSON API rather + than by scraping a web page, which keeps updates robust over time. +- **Efficient and historical**: only the configured currency pairs are + requested (not a whole table), and past rates can be retrieved by timestamp + or date range (daily/hourly/minute granularity), useful for backfill and + corrections. + +A Wise.com API token is required (see Configuration). diff --git a/currency_rate_update_wise/static/description/icon.png b/currency_rate_update_wise/static/description/icon.png new file mode 100644 index 00000000..3a0328b5 Binary files /dev/null and b/currency_rate_update_wise/static/description/icon.png differ diff --git a/currency_rate_update_wise/static/description/index.html b/currency_rate_update_wise/static/description/index.html new file mode 100644 index 00000000..9a8fda7a --- /dev/null +++ b/currency_rate_update_wise/static/description/index.html @@ -0,0 +1,472 @@ + + + + + +Currency Rate Update: Wise.com + + + +
+

Currency Rate Update: Wise.com

+ + +

Beta License: AGPL-3 OCA/currency Translate me on Weblate Try me on Runboat

+

This module adds Wise.com as a currency exchange +rates provider for currency_rate_update.

+

It is meant as a worldwide complement to the built-in ECB provider: +the ECB provider gives the official European, EUR-centric daily +reference, while Wise provides a global rate source with the following +advantages:

+
    +
  • Rate quality: Wise returns the interbank mid-market rate. On a +working day, aligned with the ECB fixing, it tracks the ECB reference +very closely (closer than xe.com in our measurements) and applies no +markup to the published rate.
  • +
  • Worldwide coverage: Wise quotes around 164 currencies, against the +29 of the ECB reference list, including currencies the ECB does not +publish (e.g. AED, COP). ECB stays the European reference; Wise +extends accurate rates to the rest of the world.
  • +
  • Stable API: rates are fetched through an authenticated JSON API +rather than by scraping a web page, which keeps updates robust over +time.
  • +
  • Efficient and historical: only the configured currency pairs are +requested (not a whole table), and past rates can be retrieved by +timestamp or date range (daily/hourly/minute granularity), useful for +backfill and corrections.
  • +
+

A Wise.com API token is required (see Configuration).

+

Table of contents

+ +
+

Configuration

+

To configure Wise.com currency rates provider credentials:

+
    +
  1. Go to Invoicing > Configuration > Settings
  2. +
  3. Fill application credentials in Currencies > Wise.com Provider +section
  4. +
+ +
+

Note

+

The API key is stored and sent as an opaque string of any length, so +both the current UUID-format personal tokens and the JWT format Wise +is migrating towards are supported. If a legacy UUID token is +configured, a warning is logged inviting you to regenerate it as JWT.

+
+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • CorporateHub
  • +
  • Altixia
  • +
+
+ +
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/currency project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/currency_rate_update_wise/tests/__init__.py b/currency_rate_update_wise/tests/__init__.py new file mode 100644 index 00000000..91f11f9e --- /dev/null +++ b/currency_rate_update_wise/tests/__init__.py @@ -0,0 +1,3 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from . import test_currency_rate_update_wise diff --git a/currency_rate_update_wise/tests/test_currency_rate_update_wise.py b/currency_rate_update_wise/tests/test_currency_rate_update_wise.py new file mode 100644 index 00000000..c303ca7c --- /dev/null +++ b/currency_rate_update_wise/tests/test_currency_rate_update_wise.py @@ -0,0 +1,161 @@ +# Copyright 2019 Brainbean Apps (https://brainbeanapps.com) +# Copyright 2026 Altixia (https://altixia.com) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from datetime import date +from unittest import mock + +import requests +from dateutil.relativedelta import relativedelta + +from odoo import fields +from odoo.exceptions import UserError +from odoo.tests import common + +from ..models import res_currency_rate_provider_Wise as wise_mod + +_module_ns = "odoo.addons.currency_rate_update_wise" +_provider_class = ( + _module_ns + + ".models.res_currency_rate_provider_Wise" + + ".ResCurrencyRateProviderWise" +) +_retrieve = _provider_class + "._wise_provider_retrieve" + + +class TestResCurrencyRateProviderWise(common.TransactionCase): + def setUp(self): + super().setUp() + + self.Company = self.env["res.company"] + self.CurrencyRate = self.env["res.currency.rate"] + self.CurrencyRateProvider = self.env["res.currency.rate.provider"] + + self.today = fields.Date.today() + self.eur_currency = self.env.ref("base.EUR") + self.wise_provider = self.CurrencyRateProvider.create( + {"service": "Wise", "currency_ids": [(4, self.eur_currency.id)]} + ) + self.env.user.company_id.wise_api_key = "test-token" + self.CurrencyRate.search([]).unlink() + + def test_supported_currencies(self): + mocked_response = [ + { + "rate": 1.0, + "source": "EUR", + "target": "EUR", + "time": "2019-01-01T00:00:00+0000", + } + ] + with mock.patch(_retrieve, return_value=mocked_response): + supported_currencies = self.wise_provider._get_supported_currencies() + self.assertEqual(len(supported_currencies), 1) + + def test_update(self): + # Odoo never stores a rate for the company's own currency. Pin an EUR + # company and fetch a foreign currency (USD) so a rate row is actually + # created, regardless of the default company currency of the series. + usd_currency = self.env.ref("base.USD") + company = self.Company.create( + {"name": "Wise Test Co", "currency_id": self.eur_currency.id} + ) + self.env.user.company_ids += company + company.wise_api_key = "test-token" + provider = self.CurrencyRateProvider.with_company(company).create( + {"service": "Wise", "currency_ids": [(4, usd_currency.id)]} + ) + date = self.today - relativedelta(days=1) + mocked_response = [ + { + "rate": 1.15, + "source": "EUR", + "target": "USD", + "time": str(date) + "T00:00:00+0000", + } + ] + with mock.patch(_retrieve, return_value=mocked_response): + provider.with_company(company)._update(date, date) + + rates = self.CurrencyRate.search([("currency_id", "=", usd_currency.id)]) + self.assertTrue(rates) + + def test_single_day_window_is_widened(self): + # Wise answers HTTP 400 when "from" equals "to", which is exactly what + # a scheduled run asks for once the rates are up to date. The provider + # must widen the window instead of sending a zero-length range. + captured = [] + + def _capture(_self, _url, params=None): + captured.append(params) + return [] + + with mock.patch(_retrieve, _capture): + self.wise_provider._obtain_rates("USD", ["EUR"], self.today, self.today) + + self.assertTrue(captured, "no request was issued") + for params in captured: + self.assertNotEqual( + params["from"], + params["to"], + "Wise rejects a zero-length window with HTTP 400", + ) + self.assertEqual(params["from"], str(self.today)) + + def test_no_credentials(self): + self.env.user.company_id.wise_api_key = None + with self.assertRaises(UserError): + self.wise_provider._get_supported_currencies() + + def test_bad_credentials(self): + # Hermetic: simulate Wise returning an HTTP error, no real network call. + with ( + mock.patch(_retrieve, side_effect=requests.exceptions.HTTPError("401")), + self.assertRaises(requests.exceptions.HTTPError), + ): + self.wise_provider._obtain_rates("USD", ["EUR"], self.today, self.today) + + def test_error_response(self): + # An API error payload must raise a clean UserError. + with ( + mock.patch( + _retrieve, + return_value={"error": True, "error_description": "boom"}, + ), + self.assertRaises(UserError), + ): + self.wise_provider._get_supported_currencies() + + def _warn_calls(self, today): + # Run the legacy-token check at a given date, return the logger mock. + with ( + mock.patch.object(wise_mod.fields.Date, "today", return_value=today), + mock.patch.object(wise_mod._logger, "warning") as warn, + ): + self.wise_provider._wise_warn_legacy_tokens() + return warn + + def test_legacy_uuid_token_warning(self): + # UUID token, on/after JWT availability -> warning. + self.env.user.company_id.wise_api_key = "ca44a17e-9b28-4fb7-bcf7-9ba09340c26f" + self.assertTrue(self._warn_calls(date(2026, 8, 1)).called) + + def test_no_warning_before_jwt_availability(self): + # UUID token, before JWT is available -> no warning (not actionable). + self.env.user.company_id.wise_api_key = "ca44a17e-9b28-4fb7-bcf7-9ba09340c26f" + self.assertFalse(self._warn_calls(date(2026, 7, 15)).called) + + def test_no_warning_after_window(self): + # UUID token, after the migration window closed -> no warning. + self.env.user.company_id.wise_api_key = "ca44a17e-9b28-4fb7-bcf7-9ba09340c26f" + self.assertFalse(self._warn_calls(date(2027, 8, 1)).called) + + def test_no_warning_without_token(self): + # No key configured -> no warning. + self.env.user.company_id.wise_api_key = False + self.assertFalse(self._warn_calls(date(2026, 8, 1)).called) + + def test_no_warning_for_jwt_token(self): + # A JWT-format token is the target format -> no warning. + self.env.user.company_id.wise_api_key = "eyJhbGci.eyJzdWIi.sig" + self.assertFalse(self._warn_calls(date(2026, 8, 1)).called) diff --git a/currency_rate_update_wise/views/res_config_settings.xml b/currency_rate_update_wise/views/res_config_settings.xml new file mode 100644 index 00000000..c11f6eee --- /dev/null +++ b/currency_rate_update_wise/views/res_config_settings.xml @@ -0,0 +1,45 @@ + + + + + res.config.settings.view.form + res.config.settings + + + +
+
+
+ Wise.com Provider + +
+ Wise.com credentials +
+
+
+
+
+
+
+ + + +