From ae4998d701afd537cb3d91cb8e4f98b4b1355756 Mon Sep 17 00:00:00 2001 From: Claude Perrin Date: Sat, 20 Jun 2026 20:45:08 +0200 Subject: [PATCH 1/2] [19.0][MIG] currency_rate_update_wise: migration to 19.0 Forward-port of the Wise exchange-rate provider to 19.0 (the module is not maintained upstream beyond 14.0). Framework migration only, no functional change vs. the 18.0 module. Signed-off-by: Claude Perrin --- currency_rate_update_wise/README.rst | 125 +++++ currency_rate_update_wise/__init__.py | 3 + currency_rate_update_wise/__manifest__.py | 18 + .../i18n/currency_rate_update_wise.pot | 89 ++++ currency_rate_update_wise/i18n/es.po | 94 ++++ currency_rate_update_wise/i18n/fr.po | 92 ++++ currency_rate_update_wise/i18n/it.po | 90 ++++ currency_rate_update_wise/models/__init__.py | 5 + .../models/res_company.py | 14 + .../models/res_config_settings.py | 16 + .../models/res_currency_rate_provider_Wise.py | 153 ++++++ currency_rate_update_wise/pyproject.toml | 3 + currency_rate_update_wise/readme/CONFIGURE.md | 11 + .../readme/CONTRIBUTORS.md | 4 + .../readme/DESCRIPTION.md | 23 + .../static/description/icon.png | Bin 0 -> 9455 bytes .../static/description/index.html | 472 ++++++++++++++++++ currency_rate_update_wise/tests/__init__.py | 3 + .../tests/test_currency_rate_update_wise.py | 139 ++++++ .../views/res_config_settings.xml | 45 ++ 20 files changed, 1399 insertions(+) create mode 100644 currency_rate_update_wise/README.rst create mode 100644 currency_rate_update_wise/__init__.py create mode 100644 currency_rate_update_wise/__manifest__.py create mode 100644 currency_rate_update_wise/i18n/currency_rate_update_wise.pot create mode 100644 currency_rate_update_wise/i18n/es.po create mode 100644 currency_rate_update_wise/i18n/fr.po create mode 100644 currency_rate_update_wise/i18n/it.po create mode 100644 currency_rate_update_wise/models/__init__.py create mode 100644 currency_rate_update_wise/models/res_company.py create mode 100644 currency_rate_update_wise/models/res_config_settings.py create mode 100644 currency_rate_update_wise/models/res_currency_rate_provider_Wise.py create mode 100644 currency_rate_update_wise/pyproject.toml create mode 100644 currency_rate_update_wise/readme/CONFIGURE.md create mode 100644 currency_rate_update_wise/readme/CONTRIBUTORS.md create mode 100644 currency_rate_update_wise/readme/DESCRIPTION.md create mode 100644 currency_rate_update_wise/static/description/icon.png create mode 100644 currency_rate_update_wise/static/description/index.html create mode 100644 currency_rate_update_wise/tests/__init__.py create mode 100644 currency_rate_update_wise/tests/test_currency_rate_update_wise.py create mode 100644 currency_rate_update_wise/views/res_config_settings.xml 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..5c61289e --- /dev/null +++ b/currency_rate_update_wise/models/res_currency_rate_provider_Wise.py @@ -0,0 +1,153 @@ +# 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: + params = { + "source": base_currency, + "target": currency, + "from": str(since), + "to": str(min(until, date_to)), + "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 0000000000000000000000000000000000000000..3a0328b516c4980e8e44cdb63fd945757ddd132d GIT binary patch literal 9455 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 z z?n?eq1XP>p-IM$Z^C;2L3itnbJZAip*Zo0aw2bs8@(s^~*8T9go!%dHcAz2lM;`yp zD=7&xjFV$S&5uDaiScyD?B-i1ze`+CoRtz`Wn+Zl&#s4&}MO{@N!ufrzjG$B79)Y2d3tBk&)TxUTw@QS0TEL_?njX|@vq?Uz(nBFK5Pq7*xj#u*R&i|?7+6# z+|r_n#SW&LXhtheZdah{ZVoqwyT{D>MC3nkFF#N)xLi{p7J1jXlmVeb;cP5?e(=f# zuT7fvjSbjS781v?7{)-X3*?>tq?)Yd)~|1{BDS(pqC zC}~H#WXlkUW*H5CDOo<)#x7%RY)A;ShGhI5s*#cRDA8YgqG(HeKDx+#(ZQ?386dv! zlXCO)w91~Vw4AmOcATuV653fa9R$fyK8ul%rG z-wfS zihugoZyr38Im?Zuh6@RcF~t1anQu7>#lPpb#}4cOA!EM11`%f*07RqOVkmX{p~KJ9 z^zP;K#|)$`^Rb{rnHGH{~>1(fawV0*Z#)}M`m8-?ZJV<+e}s9wE# z)l&az?w^5{)`S(%MRzxdNqrs1n*-=jS^_jqE*5XDrA0+VE`5^*p3CuM<&dZEeCjoz zR;uu_H9ZPZV|fQq`Cyw4nscrVwi!fE6ciMmX$!_hN7uF;jjKG)d2@aC4ropY)8etW=xJvni)8eHi`H$%#zn^WJ5NLc-rqk|u&&4Z6fD_m&JfSI1Bvb?b<*n&sfl0^t z=HnmRl`XrFvMKB%9}>PaA`m-fK6a0(8=qPkWS5bb4=v?XcWi&hRY?O5HdulRi4?fN zlsJ*N-0Qw+Yic@s0(2uy%F@ib;GjXt01Fmx5XbRo6+n|pP(&nodMoap^z{~q ziEeaUT@Mxe3vJSfI6?uLND(CNr=#^W<1b}jzW58bIfyWTDle$mmS(|x-0|2UlX+9k zQ^EX7Nw}?EzVoBfT(-LT|=9N@^hcn-_p&sqG z&*oVs2JSU+N4ZD`FhCAWaS;>|wH2G*Id|?pa#@>tyxX`+4HyIArWDvVrX)2WAOQff z0qyHu&-S@i^MS-+j--!pr4fPBj~_8({~e1bfcl0wI1kaoN>mJL6KUPQm5N7lB(ui1 zE-o%kq)&djzWJ}ob<-GfDlkB;F31j-VHKvQUGQ3sp`CwyGJk_i!y^sD0fqC@$9|jO zOqN!r!8-p==F@ZVP=U$qSpY(gQ0)59P1&t@y?5rvg<}E+GB}26NYPp4f2YFQrQtot5mn3wu_qprZ=>Ig-$ zbW26Ws~IgY>}^5w`vTB(G`PTZaDiGBo5o(tp)qli|NeV( z@H_=R8V39rt5J5YB2Ky?4eJJ#b`_iBe2ot~6%7mLt5t8Vwi^Jy7|jWXqa3amOIoRb zOr}WVFP--DsS`1WpN%~)t3R!arKF^Q$e12KEqU36AWwnCBICpH4XCsfnyrHr>$I$4 z!DpKX$OKLWarN7nv@!uIA+~RNO)l$$w}p(;b>mx8pwYvu;dD_unryX_NhT8*Tj>BTrTTL&!?O+%Rv;b?B??gSzdp?6Uug9{ zd@V08Z$BdI?fpoCS$)t4mg4rT8Q_I}h`0d-vYZ^|dOB*Q^S|xqTV*vIg?@fVFSmMpaw0qtTRbx} z({Pg?#{2`sc9)M5N$*N|4;^t$+QP?#mov zGVC@I*lBVrOU-%2y!7%)fAKjpEFsgQc4{amtiHb95KQEwvf<(3T<9-Zm$xIew#P22 zc2Ix|App^>v6(3L_MCU0d3W##AB0M~3D00EWoKZqsJYT(#@w$Y_H7G22M~ApVFTRHMI_3be)Lkn#0F*V8Pq zc}`Cjy$bE;FJ6H7p=0y#R>`}-m4(0F>%@P|?7fx{=R^uFdISRnZ2W_xQhD{YuR3t< z{6yxu=4~JkeA;|(J6_nv#>Nvs&FuLA&PW^he@t(UwFFE8)|a!R{`E`K`i^ZnyE4$k z;(749Ix|oi$c3QbEJ3b~D_kQsPz~fIUKym($a_7dJ?o+40*OLl^{=&oq$<#Q(yyrp z{J-FAniyAw9tPbe&IhQ|a`DqFTVQGQ&Gq3!C2==4x{6EJwiPZ8zub-iXoUtkJiG{} zPaR&}_fn8_z~(=;5lD-aPWD3z8PZS@AaUiomF!G8I}Mf>e~0g#BelA-5#`cj;O5>N Xviia!U7SGha1wx#SCgwmn*{w2TRX*I literal 0 HcmV?d00001 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..47279841 --- /dev/null +++ b/currency_rate_update_wise/tests/test_currency_rate_update_wise.py @@ -0,0 +1,139 @@ +# 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_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 +
+
+
+
+
+
+
+ + + + From f3dc17a94a0283c5e28d6b265c06fac45ebdcbea Mon Sep 17 00:00:00 2001 From: Claude Perrin Date: Wed, 5 Aug 2026 07:30:14 +0200 Subject: [PATCH 2/2] [19.0][FIX] currency_rate_update_wise: avoid a zero-length date window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wise answers HTTP 400 when the "from" and "to" query parameters are equal: GET /v1/rates?source=USD&target=EUR&from=2026-08-04&to=2026-08-04&group=day -> 400 Bad Request 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. The failure is therefore intermittent — it only shows up when no more than one day is missing, which is the steady state of a daily cron. Widen the window by one day when it would collapse. The endpoint only returns the days it actually has, so no spurious rate is created; asking for a "to" one day ahead of today answers 200 with today's rate only. The existing tests mock the HTTP layer and never inspect the outgoing query, which is why this went unnoticed. Add a test that captures the parameters and asserts the window is never zero-length. Signed-off-by: Claude Perrin (cherry picked from commit bac73b52449b52be4c9d9ce028727825f8a5b9e3) --- .../models/res_currency_rate_provider_Wise.py | 9 +++++++- .../tests/test_currency_rate_update_wise.py | 22 +++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) 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 index 5c61289e..1f14d854 100644 --- a/currency_rate_update_wise/models/res_currency_rate_provider_Wise.py +++ b/currency_rate_update_wise/models/res_currency_rate_provider_Wise.py @@ -82,11 +82,18 @@ def _obtain_rates(self, base_currency, currencies, date_from, date_to): 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(min(until, date_to)), + "to": str(window_end), "group": "day", } data = self._wise_provider_retrieve(WISE_API_URL, params) 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 index 47279841..c303ca7c 100644 --- a/currency_rate_update_wise/tests/test_currency_rate_update_wise.py +++ b/currency_rate_update_wise/tests/test_currency_rate_update_wise.py @@ -80,6 +80,28 @@ def test_update(self): 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):