Browse Source

[IMP] account_bank_statement_import_online_transferwise: support SCA

12.0
Alexey Pelykh 3 years ago
parent
commit
f088b775b5
  1. 9
      account_bank_statement_import_online_transferwise/__manifest__.py
  2. 138
      account_bank_statement_import_online_transferwise/models/online_bank_statement_provider_transferwise.py
  3. 4
      account_bank_statement_import_online_transferwise/readme/CONFIGURE.rst
  4. 3
      account_bank_statement_import_online_transferwise/readme/DESCRIPTION.rst
  5. 6
      account_bank_statement_import_online_transferwise/readme/USAGE.rst
  6. 195
      account_bank_statement_import_online_transferwise/tests/test_account_bank_statement_import_online_transferwise.py
  7. 22
      account_bank_statement_import_online_transferwise/views/online_bank_statement_provider.xml

9
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',
],

138
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()

4
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.

3
account_bank_statement_import_online_transferwise/readme/DESCRIPTION.rst

@ -1,2 +1,3 @@
This module provides online bank statements from
`TransferWise.com <https://transferwise.com/>`__.
`Wise.com <https://wise.com/>`__.
(formely `TransferWise.com <https://transferwise.com/>`__).

6
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 <https://wise.com/public-keys/>`__ and register the public key.

195
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',
})

22
account_bank_statement_import_online_transferwise/views/online_bank_statement_provider.xml

@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2019 Brainbean Apps (https://brainbeanapps.com)
Copyright 2021 CorporateHub (https://corporatehub.eu)
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
-->
<odoo>
@ -27,7 +28,7 @@
</group>
<group>
<field
name="origin"
name="transferwise_profile"
string="Profile"
attrs="{'required': [('service', '=', 'transferwise')]}"
widget="dynamic_dropdown"
@ -35,6 +36,25 @@
context="{'api_key': password, 'api_base': api_base}"
/>
</group>
<group string="Strong Customer Authentication" colspan="4">
<field
name="certificate_private_key"
string="Private key"
password="True"
/>
<field
name="certificate_public_key"
string="Public key"
/>
<div col="2" colspan="2">
<button
name="button_transferwise_generate_key"
string="Generate Key"
type="object"
class="oe_edit_only"
/>
</div>
</group>
</group>
</xpath>
</field>

Loading…
Cancel
Save