A Wise.com API token is required (see Configuration).
@@ -390,13 +409,20 @@
To configure Wise.com currency rates provider credentials:
# Go to Invoicing > Configuration > Settings
# 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.
+
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.
+
feedback.
Do not contact contributors directly about support or help with technical issues.
This module is maintained by the OCA.
-

+
+
+
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.
+
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_transferwise/tests/__init__.py b/currency_rate_update_wise/tests/__init__.py
similarity index 57%
rename from currency_rate_update_transferwise/tests/__init__.py
rename to currency_rate_update_wise/tests/__init__.py
index ca97f3e1..91f11f9e 100644
--- a/currency_rate_update_transferwise/tests/__init__.py
+++ b/currency_rate_update_wise/tests/__init__.py
@@ -1,3 +1,3 @@
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
-from . import test_currency_rate_update_transferwise
+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..6fc944da
--- /dev/null
+++ b/currency_rate_update_wise/tests/test_currency_rate_update_wise.py
@@ -0,0 +1,147 @@
+# 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):
+ date = self.today - relativedelta(days=1)
+ mocked_response = [
+ {
+ "rate": 0.86995,
+ "source": "USD",
+ "target": "EUR",
+ "time": str(date) + "T00:00:00+0000",
+ }
+ ]
+ with mock.patch(_retrieve, return_value=mocked_response):
+ self.wise_provider._update(date, date)
+
+ rates = self.CurrencyRate.search(
+ [("currency_id", "=", self.eur_currency.id)], limit=1
+ )
+ self.assertTrue(rates)
+ self.CurrencyRate.search([("currency_id", "=", self.eur_currency.id)]).unlink()
+
+ 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_transferwise/views/res_config_settings.xml b/currency_rate_update_wise/views/res_config_settings.xml
similarity index 79%
rename from currency_rate_update_transferwise/views/res_config_settings.xml
rename to currency_rate_update_wise/views/res_config_settings.xml
index 2e605a4d..894efdc5 100644
--- a/currency_rate_update_transferwise/views/res_config_settings.xml
+++ b/currency_rate_update_wise/views/res_config_settings.xml
@@ -1,6 +1,7 @@