From f088b775b5aeaf1498e8126365e5049ec0046081 Mon Sep 17 00:00:00 2001 From: Alexey Pelykh Date: Fri, 17 Sep 2021 07:40:25 +0200 Subject: [PATCH] [IMP] account_bank_statement_import_online_transferwise: support SCA --- .../__manifest__.py | 9 +- ...ne_bank_statement_provider_transferwise.py | 138 +++++++++++-- .../readme/CONFIGURE.rst | 4 +- .../readme/DESCRIPTION.rst | 3 +- .../readme/USAGE.rst | 6 + ...nk_statement_import_online_transferwise.py | 195 +++++++++++++++++- .../views/online_bank_statement_provider.xml | 22 +- 7 files changed, 342 insertions(+), 35 deletions(-) diff --git a/account_bank_statement_import_online_transferwise/__manifest__.py b/account_bank_statement_import_online_transferwise/__manifest__.py index bea3522..fc18078 100644 --- a/account_bank_statement_import_online_transferwise/__manifest__.py +++ b/account_bank_statement_import_online_transferwise/__manifest__.py @@ -1,9 +1,9 @@ # Copyright 2019 Brainbean Apps (https://brainbeanapps.com) -# Copyright 2020 CorporateHub (https://corporatehub.eu) +# Copyright 2020-2021 CorporateHub (https://corporatehub.eu) # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). { - 'name': 'Online Bank Statements: TransferWise.com', + 'name': 'Online Bank Statements: Wise.com (TransferWise.com)', 'version': '12.0.1.0.3', 'author': 'CorporateHub, ' @@ -12,11 +12,14 @@ 'website': 'https://github.com/OCA/bank-statement-import/', 'license': 'AGPL-3', 'category': 'Accounting', - 'summary': 'Online bank statements for TransferWise.com', + 'summary': 'Online bank statements for Wise.com (TransferWise.com)', 'depends': [ 'account_bank_statement_import_online', 'web_widget_dropdown_dynamic', ], + 'external_dependencies': { + 'python': ['cryptography'], + }, 'data': [ 'views/online_bank_statement_provider.xml', ], diff --git a/account_bank_statement_import_online_transferwise/models/online_bank_statement_provider_transferwise.py b/account_bank_statement_import_online_transferwise/models/online_bank_statement_provider_transferwise.py index 2006d88..5c57172 100644 --- a/account_bank_statement_import_online_transferwise/models/online_bank_statement_provider_transferwise.py +++ b/account_bank_statement_import_online_transferwise/models/online_bank_statement_provider_transferwise.py @@ -1,7 +1,11 @@ # Copyright 2019 Brainbean Apps (https://brainbeanapps.com) -# Copyright 2020 CorporateHub (https://corporatehub.eu) +# Copyright 2020-2021 CorporateHub (https://corporatehub.eu) # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from base64 import b64encode +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa, padding from dateutil.relativedelta import relativedelta import dateutil.parser from decimal import Decimal @@ -10,8 +14,9 @@ import json import pytz import urllib.parse import urllib.request +from urllib.error import HTTPError -from odoo import models, api, _ +from odoo import api, fields, models, _ from odoo.exceptions import UserError import logging @@ -24,6 +29,14 @@ TRANSFERWISE_API_BASE = 'https://api.transferwise.com' class OnlineBankStatementProviderTransferwise(models.Model): _inherit = 'online.bank.statement.provider' + # NOTE: This is needed to workaround possible multiple 'origin' fields + # present in the same view, resulting in wrong field view configuraion + # if more than one is widget="dynamic_dropdown" + transferwise_profile = fields.Char( + related='origin', + readonly=False, + ) + @api.model def values_transferwise_profile(self): api_base = self.env.context.get('api_base') or TRANSFERWISE_API_BASE @@ -52,7 +65,7 @@ class OnlineBankStatementProviderTransferwise(models.Model): @api.model def _get_available_services(self): return super()._get_available_services() + [ - ('transferwise', 'TransferWise.com'), + ('transferwise', 'Wise.com (TransferWise.com)'), ] @api.multi @@ -66,6 +79,13 @@ class OnlineBankStatementProviderTransferwise(models.Model): api_base = self.api_base or TRANSFERWISE_API_BASE api_key = self.password + private_key = self.certificate_private_key + if private_key: + private_key = serialization.load_pem_private_key( + private_key.encode(), + password=None, + backend=default_backend(), + ) currency = ( self.currency_id or self.company_id.currency_id ).name @@ -79,7 +99,9 @@ class OnlineBankStatementProviderTransferwise(models.Model): url = api_base + '/v1/borderless-accounts?profileId=%s' % ( self.origin, ) - data = self._transferwise_retrieve(url, api_key) + data = self._transferwise_retrieve(url, api_key, private_key) + if not data: + return None borderless_account = data[0]['id'] balance = list(filter( lambda balance: balance['currency'] == currency, @@ -94,15 +116,16 @@ class OnlineBankStatementProviderTransferwise(models.Model): # Get starting balance starting_balance_timestamp = date_since.isoformat() + 'Z' url = api_base + ( - '/v1/borderless-accounts/%s/statement.json' + - '?currency=%s&intervalStart=%s&intervalEnd=%s' + '/v3/profiles/%s/borderless-accounts/%s/statement.json' + + '?currency=%s&intervalStart=%s&intervalEnd=%s&type=COMPACT' ) % ( + self.origin, borderless_account, currency, starting_balance_timestamp, starting_balance_timestamp, ) - data = self._transferwise_retrieve(url, api_key) + data = self._transferwise_retrieve(url, api_key, private_key) balance_start = data['endOfStatementBalance']['value'] # Get statements, using 469 days (around 1 year 3 month) as step. @@ -113,9 +136,10 @@ class OnlineBankStatementProviderTransferwise(models.Model): balance_end = None while interval_start < interval_end: url = api_base + ( - '/v1/borderless-accounts/%s/statement.json' + - '?currency=%s&intervalStart=%s&intervalEnd=%s' + '/v3/profiles/%s/borderless-accounts/%s/statement.json' + + '?currency=%s&intervalStart=%s&intervalEnd=%s&type=COMPACT' ) % ( + self.origin, borderless_account, currency, interval_start.isoformat() + 'Z', @@ -123,7 +147,7 @@ class OnlineBankStatementProviderTransferwise(models.Model): interval_start + interval_step, interval_end ).isoformat() + 'Z', ) - data = self._transferwise_retrieve(url, api_key) + data = self._transferwise_retrieve(url, api_key, private_key) transactions += data['transactions'] balance_end = data['endOfStatementBalance']['value'] interval_start += interval_step @@ -250,7 +274,7 @@ class OnlineBankStatementProviderTransferwise(models.Model): 'name': _('Fee for %s') % reference_number, 'amount': str(fees_value), 'date': date, - 'partner_name': 'TransferWise', + 'partner_name': 'Wise (former TransferWise)', 'unique_import_id': '%s-FEE' % unique_import_id, 'note': _('Transaction fee for %s') % reference_number, }] @@ -268,20 +292,94 @@ class OnlineBankStatementProviderTransferwise(models.Model): return content @api.model - def _transferwise_retrieve(self, url, api_key): - with self._transferwise_urlopen(url, api_key) as response: - content = response.read().decode( - response.headers.get_content_charset() or 'utf-8' + def _transferwise_retrieve(self, url, api_key, private_key=None): + try: + with self._transferwise_urlopen(url, api_key) as response: + content = response.read().decode( + response.headers.get_content_charset() or 'utf-8' + ) + except HTTPError as e: + if e.code != 403 or \ + e.headers.get('X-2FA-Approval-Result') != 'REJECTED': + raise e + if not private_key: + raise UserError(_( + 'Strong Customer Authentication is not configured' + )) + one_time_token = e.headers['X-2FA-Approval'] + signature = private_key.sign( + one_time_token.encode(), + padding.PKCS1v15(), + hashes.SHA256(), ) + + with self._transferwise_urlopen( + url, + api_key, + one_time_token, + b64encode(signature).decode(), + ) as response: + content = response.read().decode( + response.headers.get_content_charset() or 'utf-8' + ) + return self._transferwise_validate(content) @api.model - def _transferwise_urlopen(self, url, api_key): + def _transferwise_urlopen(self, url, api_key, ott=None, signature=None): if not api_key: raise UserError(_('No API key specified!')) request = urllib.request.Request(url) - request.add_header( - 'Authorization', - 'Bearer %s' % api_key - ) + request.add_header('Authorization', 'Bearer %s' % api_key) + if ott and signature: + request.add_header('X-2FA-Approval', ott) + request.add_header('X-Signature', signature) return urllib.request.urlopen(request) + + @api.onchange('certificate_private_key', 'service') + def _onchange_transferwise_certificate_private_key(self): + if self.service != 'transferwise': + return + + self.certificate_public_key = False + if not self.certificate_private_key: + return + + try: + private_key = serialization.load_pem_private_key( + self.certificate_private_key.encode(), + password=None, + backend=default_backend(), + ) + self.certificate_public_key = private_key.public_key().public_bytes( + serialization.Encoding.PEM, + serialization.PublicFormat.PKCS1, + ).decode() + except: + _logger.warning('Unable to parse key', exc_info=True) + raise UserError(_('Unable to parse key')) + + @api.multi + def _transferwise_generate_key(self): + self.ensure_one() + + private_key = rsa.generate_private_key( + public_exponent=65537, + key_size=2048, + backend=default_backend(), + ) + self.certificate_private_key = private_key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.TraditionalOpenSSL, # a.k.a. PKCS#1 + serialization.NoEncryption(), + ).decode() + + self.certificate_public_key = private_key.public_key().public_bytes( + serialization.Encoding.PEM, + serialization.PublicFormat.PKCS1, + ).decode() + + @api.multi + def button_transferwise_generate_key(self): + for provider in self: + provider._transferwise_generate_key() diff --git a/account_bank_statement_import_online_transferwise/readme/CONFIGURE.rst b/account_bank_statement_import_online_transferwise/readme/CONFIGURE.rst index 0a35ffe..cbd727f 100644 --- a/account_bank_statement_import_online_transferwise/readme/CONFIGURE.rst +++ b/account_bank_statement_import_online_transferwise/readme/CONFIGURE.rst @@ -3,7 +3,7 @@ To configure online bank statements provider: #. Go to *Invoicing > Configuration > Bank Accounts* #. Open bank account to configure and edit it #. Set *Bank Feeds* to *Online* -#. Select *TransferWise.com* as online bank statements provider in +#. Select *Wise.com (TransferWise.com)* as online bank statements provider in *Online Bank Statements (OCA)* section #. Save the bank account #. Click on provider and configure provider-specific settings. @@ -14,7 +14,7 @@ or, alternatively: #. Open settings of the corresponding journal account #. Switch to *Bank Account* tab #. Set *Bank Feeds* to *Online* -#. Select *TransferWise.com* as online bank statements provider in +#. Select *Wise.com (TransferWise.com)* as online bank statements provider in *Online Bank Statements (OCA)* section #. Save the bank account #. Click on provider and configure provider-specific settings. diff --git a/account_bank_statement_import_online_transferwise/readme/DESCRIPTION.rst b/account_bank_statement_import_online_transferwise/readme/DESCRIPTION.rst index 67f6825..a023a68 100644 --- a/account_bank_statement_import_online_transferwise/readme/DESCRIPTION.rst +++ b/account_bank_statement_import_online_transferwise/readme/DESCRIPTION.rst @@ -1,2 +1,3 @@ This module provides online bank statements from -`TransferWise.com `__. +`Wise.com `__. +(formely `TransferWise.com `__). diff --git a/account_bank_statement_import_online_transferwise/readme/USAGE.rst b/account_bank_statement_import_online_transferwise/readme/USAGE.rst index 03845f1..0792163 100644 --- a/account_bank_statement_import_online_transferwise/readme/USAGE.rst +++ b/account_bank_statement_import_online_transferwise/readme/USAGE.rst @@ -4,3 +4,9 @@ To pull historical bank statements: #. Select specific bank accounts #. Launch *Actions > Online Bank Statements Pull Wizard* #. Configure date interval and click *Pull* + +To configure Strong Customer Authentication: + +#. Go to provider-specific settings and either press *Generate Key* or paste +manually-generate private and public keys +#. Navigate to `Wise.com `__ and register the public key. diff --git a/account_bank_statement_import_online_transferwise/tests/test_account_bank_statement_import_online_transferwise.py b/account_bank_statement_import_online_transferwise/tests/test_account_bank_statement_import_online_transferwise.py index 85651ff..3d378b1 100644 --- a/account_bank_statement_import_online_transferwise/tests/test_account_bank_statement_import_online_transferwise.py +++ b/account_bank_statement_import_online_transferwise/tests/test_account_bank_statement_import_online_transferwise.py @@ -1,5 +1,5 @@ # Copyright 2019 Brainbean Apps (https://brainbeanapps.com) -# Copyright 2020 CorporateHub (https://corporatehub.eu) +# Copyright 2020-2021 CorporateHub (https://corporatehub.eu) # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). from datetime import datetime @@ -7,6 +7,7 @@ from dateutil.relativedelta import relativedelta from decimal import Decimal import json from unittest import mock +from urllib.error import HTTPError from odoo.tests import common from odoo import fields @@ -19,6 +20,29 @@ _provider_class = ( ) +class MockedResponse: + + class Headers(dict): + def get_content_charset(self): + return None + + def __init__(self, data=None, exception=None): + self.data = data + self.exception = exception + self.headers = MockedResponse.Headers() + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, traceback): + pass + + def read(self): + if self.exception is not None: + raise self.exception() + return self.data + + class TestAccountBankAccountStatementImportOnlineTransferwise( common.TransactionCase ): @@ -47,6 +71,33 @@ class TestAccountBankAccountStatementImportOnlineTransferwise( ) ) ) + self.response_balance = MockedResponse(data="""[ + { + "id": 42, + "balances": [ + { + "currency": "EUR" + } + ] + } + ]""".encode()) + self.response_ott = MockedResponse(exception=lambda: HTTPError( + 'https://wise.com/', + 403, + '403', + { + 'X-2FA-Approval-Result': 'REJECTED', + 'X-2FA-Approval': '0123456789', + }, + None, + )) + self.response_transactions = MockedResponse(data="""{ + "transactions": [], + "endOfStatementBalance": { + "value": 42.00, + "currency": "EUR" + } + }""".encode()) def test_values_transferwise_profile(self): mocked_response = json.loads( @@ -86,6 +137,28 @@ class TestAccountBankAccountStatementImportOnlineTransferwise( ] ) + def test_values_transferwise_profile_no_key(self): + values_transferwise_profile = ( + self.OnlineBankStatementProvider.with_context({ + 'api_base': 'https://example.com', + }).values_transferwise_profile() + ) + self.assertEqual(values_transferwise_profile, []) + + def test_values_transferwise_profile_error(self): + values_transferwise_profile = [] + with mock.patch( + _provider_class + '._transferwise_retrieve', + side_effect=lambda: Exception(), + ): + values_transferwise_profile = ( + self.OnlineBankStatementProvider.with_context({ + 'api_base': 'https://example.com', + 'api_key': 'dummy', + }).values_transferwise_profile() + ) + self.assertEqual(values_transferwise_profile, []) + def test_pull(self): journal = self.AccountJournal.create({ 'name': 'Bank', @@ -99,7 +172,7 @@ class TestAccountBankAccountStatementImportOnlineTransferwise( provider = journal.online_bank_statement_provider_id provider.origin = '1234567891' - def mock_response(url, api_key): + def mock_response(url, api_key, private_key=None): if '/borderless-accounts?profileId=1234567891' in url: payload = """[ { @@ -133,6 +206,112 @@ class TestAccountBankAccountStatementImportOnlineTransferwise( self.assertEqual(data[1]['balance_start'], 42.0) self.assertEqual(data[1]['balance_end_real'], 42.0) + def test_pull_no_data(self): + journal = self.AccountJournal.create({ + 'name': 'Bank', + 'type': 'bank', + 'code': 'BANK', + 'currency_id': self.currency_eur.id, + 'bank_statements_source': 'online', + 'online_bank_statement_provider': 'transferwise', + }) + + provider = journal.online_bank_statement_provider_id + provider.origin = '1234567891' + provider.password = 'API_KEY' + + with mock.patch( + _provider_class + '._transferwise_retrieve', + return_value=[], + ): + data = provider._obtain_statement_data( + self.now - relativedelta(hours=1), + self.now, + ) + + self.assertFalse(data) + + def test_update_public_key(self): + journal = self.AccountJournal.create({ + 'name': 'Bank', + 'type': 'bank', + 'code': 'BANK', + 'currency_id': self.currency_eur.id, + 'bank_statements_source': 'online', + 'online_bank_statement_provider': 'transferwise', + }) + + provider = journal.online_bank_statement_provider_id + provider.origin = '1234567891' + provider.password = 'API_KEY' + + with common.Form(provider) as provider_form: + provider_form.certificate_private_key = """ +-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEAxC7aYWigCwPIB4mfyLpsALYPnqDm3/IC8I/3GdEwfK8eqXoF +sU1BHnVytFycBDEObmJ2Acpxe8Dk61FnbWPrrl6rXVnXfIRqfFl94TvgwFsuwG7u +8crncD6gPfe1QGkEykHcfBURr74OcSE8590ngNJcKMGvac0cuyZ2/NEszTw7EFJg +obpMWjp0m5ItgZ/UNsPLR/D4gFE9vZz7a4+FYQMa9Wbv+xaVxUS6z9rQCJfUQx7N +iih4etIvAafbfAnX6rFv8PwPzz+XvexPWWJxnbS4iV1LN2atrPDxqw73g5hc3W88 +a0V2AVubtxhw9L2VK1VmRb/gnqsZpRXhDPSUIwIDAQABAoIBAQCMvnRLV80hudfC +mJh6YEvlgrfX/OVFmpFDVnVXHz2i5dugiHsXBS6HlIjzHlGLrEoHJTo19K/PscZJ +kEAcOYg2s5JLSY4PtcvTZDyr3tJSDdiPk8Z2zzOU0kkRy+lLyUv3cqKknlTu+PHR +daAFVCLoB4K4dqPKyq0nEuRgYgy7O42SPBY5DHgWYBKqkYGlTu+ImwpDD9unbv3e +mwvdcBCp9hAlYAArc1Ip/6aUkZdKxJYgVhovruaH309yuOmBfAEgguhsy3vR18t5 +IZXbAF3C6iXCQXi8l+S1NUu8XWPLEavldb+ZA2hI2L+NPSBVIYqhI4jDiI7lfs1c +HE8BRsRpAoGBAO6BnK3qD8sRvg6JsrBhppoIGsudOdpZ/KVp9ZpYCBJNJmfrkqLR +bWx1KF2UjAoYUmaKDTS2GP8JQd7X2n4T5LX8q+7iG9/wzdSWZYZuBOnjvWlNyJu4 +OiUKX4aEgdvZHiuEIin5xTP98/c5LTZXwM3bq8IrOXEz8LBLLPrTCGRvAoGBANKS +i3cn1jtVirJWbvhSIjjqhpfuZN0361FB6j1Aho+7z0WVd4NQjPQqA6cAqnWoa/kj +cX0X8Ncu5eHqf6CuW+HsQda3yp3bvCXi1Yc2nKBTHnWtMm721O4ZW6rbaALzBZYW +qeJr0m9pNlfCAL0INTcy7IVAtqcCJ/7CEN6Hjm2NAoGAIGSgKArDLFxziLvw9f29 +R+xT31WyVtKj+r9iaR0Ns5ag4bpgBxcUmodq/RLA1lopTt3vHzqgOHtEZATDGx6O +kJ0JqP8ys/6bpgTrMw/cQPv6bMPwvB2QYBmBkd6LWJWrgFOI5FSVEROrv+cXGetf +N1ZfhJakTZi1VuxO5p4k5KcCgYAZS9OHR/jbfeZAkFOabzt/POVYYSIq1SnmxBVg +sFy57aTzxgXqd4XHWzi/GjxgEBCQiGp8zaB4KUEih6o3YlrVZC1wnvmvRxNuNbbT +HINqWzHgjyLs46gmxlMVzm/LUuiL5EMaWTuZeLk3h63RB6hk7jAtvd1zaLXnS+b8 +5Kn+jQKBgQCDeMO6rvB2rbfqSbHvPPuTru1sPIsJBKm1YZpXTFI+VMjwtk7+meYb +UQnfZ1t5rjp9q4LEcRYuSa+PfifIkM6p+wMHVQhtltUCzXWWRYkLkmQrBWKu+qiP +edF6byMgXSzgOWYuRPXwmHpBQV0GiexQUAxVyUzaVWfil69LaFfXaw== +-----END RSA PRIVATE KEY----- + """ + + self.assertTrue(provider.certificate_public_key) + + def test_sca(self): + journal = self.AccountJournal.create({ + 'name': 'Bank', + 'type': 'bank', + 'code': 'BANK', + 'currency_id': self.currency_eur.id, + 'bank_statements_source': 'online', + 'online_bank_statement_provider': 'transferwise', + }) + + provider = journal.online_bank_statement_provider_id + provider.origin = '1234567891' + provider.password = 'API_KEY' + provider.button_transferwise_generate_key() + + with mock.patch( + 'urllib.request.urlopen', + side_effect=[ + self.response_balance, + self.response_ott, + self.response_transactions, + self.response_transactions, + self.response_transactions, + ], + ): + data = provider._obtain_statement_data( + self.now - relativedelta(hours=1), + self.now, + ) + + self.assertEqual(len(data[0]), 0) + self.assertEqual(data[1]['balance_start'], 42.0) + self.assertEqual(data[1]['balance_end_real'], 42.0) + def test_transaction_parse_1(self): lines = self.transferwise_parse_transaction("""{ "type": "CREDIT", @@ -216,7 +395,7 @@ class TestAccountBankAccountStatementImportOnlineTransferwise( 'amount': '-0.60', 'name': 'Fee for TRANSFER-123456789', 'note': 'Transaction fee for TRANSFER-123456789', - 'partner_name': 'TransferWise', + 'partner_name': 'Wise (former TransferWise)', 'unique_import_id': 'DEBIT-TRANSFER-123456789-946684800-FEE', }) @@ -342,7 +521,7 @@ class TestAccountBankAccountStatementImportOnlineTransferwise( 'amount': '-1.23', 'name': 'Fee for CARD-123456789', 'note': 'Transaction fee for CARD-123456789', - 'partner_name': 'TransferWise', + 'partner_name': 'Wise (former TransferWise)', 'unique_import_id': 'DEBIT-CARD-123456789-946684800-FEE', }) @@ -400,7 +579,7 @@ class TestAccountBankAccountStatementImportOnlineTransferwise( 'date': datetime(2000, 1, 1), 'name': 'Fee for TRANSFER-123456789', 'note': 'Transaction fee for TRANSFER-123456789', - 'partner_name': 'TransferWise', + 'partner_name': 'Wise (former TransferWise)', 'amount': '-5.21', 'unique_import_id': 'DEBIT-TRANSFER-123456789-946684800-FEE', }) @@ -547,7 +726,7 @@ class TestAccountBankAccountStatementImportOnlineTransferwise( 'name': 'Fee for BALANCE-123456789', 'note': 'Transaction fee for BALANCE-123456789', 'amount': '-0.05', - 'partner_name': 'TransferWise', + 'partner_name': 'Wise (former TransferWise)', 'unique_import_id': 'DEBIT-BALANCE-123456789-946684800-FEE', }) @@ -587,7 +766,7 @@ class TestAccountBankAccountStatementImportOnlineTransferwise( 'name': 'Fee for TRANSFER-123456789', 'note': 'Transaction fee for TRANSFER-123456789', 'amount': '-0.68', - 'partner_name': 'TransferWise', + 'partner_name': 'Wise (former TransferWise)', 'unique_import_id': 'CREDIT-TRANSFER-123456789-946684800-FEE', }) @@ -631,6 +810,6 @@ class TestAccountBankAccountStatementImportOnlineTransferwise( 'name': 'Fee for TRANSFER-123456789', 'note': 'Transaction fee for TRANSFER-123456789', 'amount': '4.33', - 'partner_name': 'TransferWise', + 'partner_name': 'Wise (former TransferWise)', 'unique_import_id': 'CREDIT-TRANSFER-123456789-946684800-FEE', }) diff --git a/account_bank_statement_import_online_transferwise/views/online_bank_statement_provider.xml b/account_bank_statement_import_online_transferwise/views/online_bank_statement_provider.xml index 013f15d..5ded19b 100644 --- a/account_bank_statement_import_online_transferwise/views/online_bank_statement_provider.xml +++ b/account_bank_statement_import_online_transferwise/views/online_bank_statement_provider.xml @@ -1,6 +1,7 @@ @@ -27,7 +28,7 @@ + + + +
+
+