From 1109dfe92e50d0b6f1c0c3519b44c71499ae8cb7 Mon Sep 17 00:00:00 2001 From: Edmund Hood Highcock Date: Tue, 8 Aug 2023 13:10:47 +0200 Subject: [PATCH 01/10] Support SCA for Wise (TransferWise) --- .../importers/transferwise/importer.py | 102 ++++++++++++++++-- 1 file changed, 92 insertions(+), 10 deletions(-) diff --git a/src/tariochbctools/importers/transferwise/importer.py b/src/tariochbctools/importers/transferwise/importer.py index e54f2e2..b548e67 100644 --- a/src/tariochbctools/importers/transferwise/importer.py +++ b/src/tariochbctools/importers/transferwise/importer.py @@ -4,10 +4,16 @@ import dateutil.parser import requests import yaml +import base64 from beancount.core import amount, data from beancount.core.number import D from beancount.ingest import importer from dateutil.relativedelta import relativedelta +import urllib3 +from urllib.parse import urlencode +import rsa +import json +http = urllib3.PoolManager() class Importer(importer.ImporterProtocol): @@ -19,11 +25,85 @@ def identify(self, file): def file_account(self, file): return "" + # Based on the Transferwise official example provided under the + # MIT license + def _get_statement(self, api_token, interval_start, interval_end, currency, base_url, private_key_path, profile_id, account_id, one_time_token="", signature="", statement_type='FLAT'): + + params = urlencode({ + 'currency': currency, 'type': statement_type, + 'intervalStart': interval_start, + 'intervalEnd': interval_end}) + + url = ( + base_url + '/v3/profiles/' + profile_id + '/borderless-accounts/' + + account_id + '/statement.json?' + params) + + headers = { + 'Authorization': 'Bearer ' + api_token, + 'User-Agent': 'tw-statements-sca', + 'Content-Type': 'application/json'} + if one_time_token != "": + headers['x-2fa-approval'] = one_time_token + headers['X-Signature'] = signature + print(headers['x-2fa-approval'], headers['X-Signature']) + + print('GET', url) + + r = http.request('GET', url, headers=headers, retries=False) + + print('status:', r.status) + + if r.status == 200 or r.status == 201: + return json.loads(r.data) + elif r.status == 403 and r.getheader('x-2fa-approval') is not None: + one_time_token = r.getheader('x-2fa-approval') + signature = Importer._do_sca_challenge(one_time_token=one_time_token, private_key_path=private_key_path) + return self._get_statement( + api_token=api_token, + one_time_token=one_time_token, + signature=signature, + interval_start=interval_start, + interval_end=interval_end, + currency=currency, + base_url=base_url, + private_key_path=private_key_path, + account_id=account_id, + profile_id=profile_id, + statement_type=statement_type) + else: + print('failed: ', r.status) + print(r.data) + sys.exit(0) + + @staticmethod + def _do_sca_challenge(one_time_token, private_key_path): + print('doing sca challenge') + + # Read the private key file as bytes. + with open(private_key_path, 'rb') as f: + private_key_data = f.read() + + private_key = rsa.PrivateKey.load_pkcs1(private_key_data, 'PEM') + + # Use the private key to sign the one-time-token that was returned + # in the x-2fa-approval header of the HTTP 403. + signed_token = rsa.sign( + one_time_token.encode('ascii'), + private_key, + 'SHA-256') + + # Encode the signed message as friendly base64 format for HTTP + # headers. + signature = base64.b64encode(signed_token).decode('ascii') + + return signature + def extract(self, file, existing_entries): with open(file.name, "r") as f: config = yaml.safe_load(f) token = config["token"] baseAccount = config["baseAccount"] + private_key_path = config["privateKeyPath"] startDate = datetime.combine( date.today() + relativedelta(months=-3), datetime.min.time(), timezone.utc ).isoformat() @@ -45,19 +125,21 @@ def extract(self, file, existing_entries): accountId = accounts[0]["id"] entries = [] + base_url = 'https://api.transferwise.com' for account in accounts[0]["balances"]: accountCcy = account["currency"] - r = requests.get( - f"https://api.transferwise.com/v3/profiles/{profileId}/borderless-accounts/{accountId}/statement.json", - params={ - "currency": accountCcy, - "intervalStart": startDate, - "intervalEnd": endDate, - }, - headers=headers, - ) - transactions = r.json() +# r = requests.get( +# f"https://api.transferwise.com/v3/profiles/{profileId}/borderless-accounts/{accountId}/statement.json", +# params={ +# "currency": accountCcy, +# "intervalStart": startDate, +# "intervalEnd": endDate, +# }, +# headers=headers, +# ) +# transactions = r.json() + transactions = self._get_statement(api_token=token, interval_start=startDate, interval_end=endDate, currency=accountCcy, base_url=base_url, private_key_path=private_key_path, profile_id=str(profileId), account_id=str(accountId), statement_type='FLAT') for transaction in transactions["transactions"]: metakv = { From a3636c290d0ee34c921795b36f2d45307f51a20a Mon Sep 17 00:00:00 2001 From: Edmund Hood Highcock Date: Tue, 8 Aug 2023 13:21:42 +0200 Subject: [PATCH 02/10] Added documentation --- docs/importers.rst | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/docs/importers.rst b/docs/importers.rst index ca72b6c..aea9ed5 100644 --- a/docs/importers.rst +++ b/docs/importers.rst @@ -88,10 +88,25 @@ Import CSV from `Revolut `__ CONFIG = [revolutimp.Importer("/Revolut-CHF.*\.csv", "Assets:Revolut:CHF", "CHF")] -Transferwise ------------- +Wise (formerly Transferwise) +---------------------------- + +Import from `Wise `__ using their api. + +First, generate a personal API token by logging on and going to settings. +Next, you need to generate a public/private key pair and then upload the public +key part to your account. To generate the keys, execute (e.g. in your ``.ssh`` folder) + +.. code-block:: bash + + openssl genrsa -out wise.pem + openssl rsa -pubout -in wise.pem -out wise_public.pem + openssl pkey -in wise.pem -traditional > wise_traditional.pem + +The final command makes a traditional private key for compatibility with the python rsa library. This may stop being necessary at some point. See `this page https://github.com/sybrenstuvel/python-rsa/issues/80` for details. + +Now upload the *public* key part to your Wise account. -Import from `Transferwise `__ using their api .. code-block:: python @@ -105,6 +120,7 @@ Create a file called transferwise.yaml in your import location (e.g. download fo token: baseAccount: + privateKeyPath: /path/to/wise_traditional.pem TrueLayer From 14796b3a02456802d0b07a53d0201de972edd708 Mon Sep 17 00:00:00 2001 From: Edmund Hood Highcock Date: Tue, 8 Aug 2023 13:28:03 +0200 Subject: [PATCH 03/10] Black formatting --- .../importers/transferwise/importer.py | 124 +++++++++++------- 1 file changed, 79 insertions(+), 45 deletions(-) diff --git a/src/tariochbctools/importers/transferwise/importer.py b/src/tariochbctools/importers/transferwise/importer.py index b548e67..11610b7 100644 --- a/src/tariochbctools/importers/transferwise/importer.py +++ b/src/tariochbctools/importers/transferwise/importer.py @@ -13,6 +13,7 @@ from urllib.parse import urlencode import rsa import json + http = urllib3.PoolManager() @@ -27,37 +28,62 @@ def file_account(self, file): # Based on the Transferwise official example provided under the # MIT license - def _get_statement(self, api_token, interval_start, interval_end, currency, base_url, private_key_path, profile_id, account_id, one_time_token="", signature="", statement_type='FLAT'): - - params = urlencode({ - 'currency': currency, 'type': statement_type, - 'intervalStart': interval_start, - 'intervalEnd': interval_end}) + def _get_statement( + self, + api_token, + interval_start, + interval_end, + currency, + base_url, + private_key_path, + profile_id, + account_id, + one_time_token="", + signature="", + statement_type="FLAT", + ): + params = urlencode( + { + "currency": currency, + "type": statement_type, + "intervalStart": interval_start, + "intervalEnd": interval_end, + } + ) url = ( - base_url + '/v3/profiles/' + profile_id + '/borderless-accounts/' - + account_id + '/statement.json?' + params) + base_url + + "/v3/profiles/" + + profile_id + + "/borderless-accounts/" + + account_id + + "/statement.json?" + + params + ) headers = { - 'Authorization': 'Bearer ' + api_token, - 'User-Agent': 'tw-statements-sca', - 'Content-Type': 'application/json'} + "Authorization": "Bearer " + api_token, + "User-Agent": "tw-statements-sca", + "Content-Type": "application/json", + } if one_time_token != "": - headers['x-2fa-approval'] = one_time_token - headers['X-Signature'] = signature - print(headers['x-2fa-approval'], headers['X-Signature']) + headers["x-2fa-approval"] = one_time_token + headers["X-Signature"] = signature + print(headers["x-2fa-approval"], headers["X-Signature"]) - print('GET', url) + print("GET", url) - r = http.request('GET', url, headers=headers, retries=False) + r = http.request("GET", url, headers=headers, retries=False) + + print("status:", r.status) - print('status:', r.status) - if r.status == 200 or r.status == 201: return json.loads(r.data) - elif r.status == 403 and r.getheader('x-2fa-approval') is not None: - one_time_token = r.getheader('x-2fa-approval') - signature = Importer._do_sca_challenge(one_time_token=one_time_token, private_key_path=private_key_path) + elif r.status == 403 and r.getheader("x-2fa-approval") is not None: + one_time_token = r.getheader("x-2fa-approval") + signature = Importer._do_sca_challenge( + one_time_token=one_time_token, private_key_path=private_key_path + ) return self._get_statement( api_token=api_token, one_time_token=one_time_token, @@ -69,32 +95,30 @@ def _get_statement(self, api_token, interval_start, interval_end, currency, base private_key_path=private_key_path, account_id=account_id, profile_id=profile_id, - statement_type=statement_type) + statement_type=statement_type, + ) else: - print('failed: ', r.status) + print("failed: ", r.status) print(r.data) sys.exit(0) @staticmethod def _do_sca_challenge(one_time_token, private_key_path): - print('doing sca challenge') + print("doing sca challenge") # Read the private key file as bytes. - with open(private_key_path, 'rb') as f: + with open(private_key_path, "rb") as f: private_key_data = f.read() - private_key = rsa.PrivateKey.load_pkcs1(private_key_data, 'PEM') + private_key = rsa.PrivateKey.load_pkcs1(private_key_data, "PEM") - # Use the private key to sign the one-time-token that was returned + # Use the private key to sign the one-time-token that was returned # in the x-2fa-approval header of the HTTP 403. - signed_token = rsa.sign( - one_time_token.encode('ascii'), - private_key, - 'SHA-256') + signed_token = rsa.sign(one_time_token.encode("ascii"), private_key, "SHA-256") - # Encode the signed message as friendly base64 format for HTTP + # Encode the signed message as friendly base64 format for HTTP # headers. - signature = base64.b64encode(signed_token).decode('ascii') + signature = base64.b64encode(signed_token).decode("ascii") return signature @@ -125,21 +149,31 @@ def extract(self, file, existing_entries): accountId = accounts[0]["id"] entries = [] - base_url = 'https://api.transferwise.com' + base_url = "https://api.transferwise.com" for account in accounts[0]["balances"]: accountCcy = account["currency"] -# r = requests.get( -# f"https://api.transferwise.com/v3/profiles/{profileId}/borderless-accounts/{accountId}/statement.json", -# params={ -# "currency": accountCcy, -# "intervalStart": startDate, -# "intervalEnd": endDate, -# }, -# headers=headers, -# ) -# transactions = r.json() - transactions = self._get_statement(api_token=token, interval_start=startDate, interval_end=endDate, currency=accountCcy, base_url=base_url, private_key_path=private_key_path, profile_id=str(profileId), account_id=str(accountId), statement_type='FLAT') + # r = requests.get( + # f"https://api.transferwise.com/v3/profiles/{profileId}/borderless-accounts/{accountId}/statement.json", + # params={ + # "currency": accountCcy, + # "intervalStart": startDate, + # "intervalEnd": endDate, + # }, + # headers=headers, + # ) + # transactions = r.json() + transactions = self._get_statement( + api_token=token, + interval_start=startDate, + interval_end=endDate, + currency=accountCcy, + base_url=base_url, + private_key_path=private_key_path, + profile_id=str(profileId), + account_id=str(accountId), + statement_type="FLAT", + ) for transaction in transactions["transactions"]: metakv = { From decd517ebad36a0e4aa0c2e9220a88aaef89bc67 Mon Sep 17 00:00:00 2001 From: Edmund Hood Highcock Date: Tue, 8 Aug 2023 15:11:32 +0200 Subject: [PATCH 04/10] Save signature for multiple account queries. --- .../importers/transferwise/importer.py | 101 +++++++----------- 1 file changed, 39 insertions(+), 62 deletions(-) diff --git a/src/tariochbctools/importers/transferwise/importer.py b/src/tariochbctools/importers/transferwise/importer.py index 11610b7..cffda5f 100644 --- a/src/tariochbctools/importers/transferwise/importer.py +++ b/src/tariochbctools/importers/transferwise/importer.py @@ -26,49 +26,58 @@ def identify(self, file): def file_account(self, file): return "" + def __init__(self, *args, **kwargs): + if "startDate" in kwargs: + self.startDate = kwargs["startDate"] + else: + self.startDate = datetime.combine( + date.today() + relativedelta(months=-3), + datetime.min.time(), + timezone.utc, + ).isoformat() + if "endDate" in kwargs: + self.endDate = kwargs["endDate"] + else: + self.endDate = datetime.combine( + date.today(), datetime.max.time(), timezone.utc + ).isoformat() + super().__init__(*args, **kwargs) + # Based on the Transferwise official example provided under the # MIT license def _get_statement( self, - api_token, - interval_start, - interval_end, currency, base_url, - private_key_path, - profile_id, - account_id, - one_time_token="", - signature="", statement_type="FLAT", ): params = urlencode( { "currency": currency, "type": statement_type, - "intervalStart": interval_start, - "intervalEnd": interval_end, + "intervalStart": self.startDate, + "intervalEnd": self.endDate, } ) url = ( base_url + "/v3/profiles/" - + profile_id + + str(self.profileId) + "/borderless-accounts/" - + account_id + + str(self.accountId) + "/statement.json?" + params ) headers = { - "Authorization": "Bearer " + api_token, + "Authorization": "Bearer " + self.api_token, "User-Agent": "tw-statements-sca", "Content-Type": "application/json", } - if one_time_token != "": - headers["x-2fa-approval"] = one_time_token - headers["X-Signature"] = signature + if hasattr(self, "one_time_token"): + headers["x-2fa-approval"] = self.one_time_token + headers["X-Signature"] = self.signature print(headers["x-2fa-approval"], headers["X-Signature"]) print("GET", url) @@ -80,21 +89,11 @@ def _get_statement( if r.status == 200 or r.status == 201: return json.loads(r.data) elif r.status == 403 and r.getheader("x-2fa-approval") is not None: - one_time_token = r.getheader("x-2fa-approval") - signature = Importer._do_sca_challenge( - one_time_token=one_time_token, private_key_path=private_key_path - ) + self.one_time_token = r.getheader("x-2fa-approval") + self.signature = self._do_sca_challenge() return self._get_statement( - api_token=api_token, - one_time_token=one_time_token, - signature=signature, - interval_start=interval_start, - interval_end=interval_end, currency=currency, base_url=base_url, - private_key_path=private_key_path, - account_id=account_id, - profile_id=profile_id, statement_type=statement_type, ) else: @@ -102,19 +101,20 @@ def _get_statement( print(r.data) sys.exit(0) - @staticmethod - def _do_sca_challenge(one_time_token, private_key_path): + def _do_sca_challenge(self): print("doing sca challenge") # Read the private key file as bytes. - with open(private_key_path, "rb") as f: + with open(self.private_key_path, "rb") as f: private_key_data = f.read() private_key = rsa.PrivateKey.load_pkcs1(private_key_data, "PEM") # Use the private key to sign the one-time-token that was returned # in the x-2fa-approval header of the HTTP 403. - signed_token = rsa.sign(one_time_token.encode("ascii"), private_key, "SHA-256") + signed_token = rsa.sign( + self.one_time_token.encode("ascii"), private_key, "SHA-256" + ) # Encode the signed message as friendly base64 format for HTTP # headers. @@ -125,53 +125,30 @@ def _do_sca_challenge(one_time_token, private_key_path): def extract(self, file, existing_entries): with open(file.name, "r") as f: config = yaml.safe_load(f) - token = config["token"] + self.api_token = config["token"] baseAccount = config["baseAccount"] - private_key_path = config["privateKeyPath"] - startDate = datetime.combine( - date.today() + relativedelta(months=-3), datetime.min.time(), timezone.utc - ).isoformat() - endDate = datetime.combine( - date.today(), datetime.max.time(), timezone.utc - ).isoformat() - - headers = {"Authorization": "Bearer " + token} + self.private_key_path = config["privateKeyPath"] + + headers = {"Authorization": "Bearer " + self.api_token} r = requests.get("https://api.transferwise.com/v1/profiles", headers=headers) profiles = r.json() - profileId = profiles[0]["id"] + self.profileId = profiles[0]["id"] r = requests.get( "https://api.transferwise.com/v1/borderless-accounts", - params={"profileId": profileId}, + params={"profileId": self.profileId}, headers=headers, ) accounts = r.json() - accountId = accounts[0]["id"] + self.accountId = accounts[0]["id"] entries = [] base_url = "https://api.transferwise.com" for account in accounts[0]["balances"]: accountCcy = account["currency"] - - # r = requests.get( - # f"https://api.transferwise.com/v3/profiles/{profileId}/borderless-accounts/{accountId}/statement.json", - # params={ - # "currency": accountCcy, - # "intervalStart": startDate, - # "intervalEnd": endDate, - # }, - # headers=headers, - # ) - # transactions = r.json() transactions = self._get_statement( - api_token=token, - interval_start=startDate, - interval_end=endDate, currency=accountCcy, base_url=base_url, - private_key_path=private_key_path, - profile_id=str(profileId), - account_id=str(accountId), statement_type="FLAT", ) From 77369108b07b5119905f7354e517be0d013fd5fe Mon Sep 17 00:00:00 2001 From: Edmund Hood Highcock Date: Tue, 8 Aug 2023 15:22:01 +0200 Subject: [PATCH 05/10] Allow baseAccount to be a dictionary for Wise (TransferWise) --- src/tariochbctools/importers/transferwise/importer.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/tariochbctools/importers/transferwise/importer.py b/src/tariochbctools/importers/transferwise/importer.py index cffda5f..0b76206 100644 --- a/src/tariochbctools/importers/transferwise/importer.py +++ b/src/tariochbctools/importers/transferwise/importer.py @@ -146,6 +146,10 @@ def extract(self, file, existing_entries): base_url = "https://api.transferwise.com" for account in accounts[0]["balances"]: accountCcy = account["currency"] + if isinstance(baseAccount, dict): + account_name = baseAccount[accountCcy] + else: + account_name = baseAccount + accountCcy transactions = self._get_statement( currency=accountCcy, base_url=base_url, @@ -167,7 +171,7 @@ def extract(self, file, existing_entries): data.EMPTY_SET, [ data.Posting( - baseAccount + accountCcy, + account_name, amount.Amount( D(str(transaction["amount"]["value"])), transaction["amount"]["currency"], From 6a522b58e7d253b0e2f522cb15ee4afe569e66b8 Mon Sep 17 00:00:00 2001 From: Edmund Hood Highcock Date: Tue, 8 Aug 2023 16:02:37 +0200 Subject: [PATCH 06/10] Added documentation for baseAccount dict option --- docs/importers.rst | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/importers.rst b/docs/importers.rst index aea9ed5..33ccefc 100644 --- a/docs/importers.rst +++ b/docs/importers.rst @@ -123,6 +123,19 @@ Create a file called transferwise.yaml in your import location (e.g. download fo privateKeyPath: /path/to/wise_traditional.pem +Optionally, you can provide a dictionary of account names mapped by currency. In this case +you must provide a name for every currency in your Wise account, otherwise the import will +fail. + + +.. code-block:: yaml + + token: + baseAccount: + SEK: "Assets:MySwedishWiseAccount" + GBP: "Assets:MyUKWiseAccount" + privateKeyPath: /path/to/wise_traditional.pem + TrueLayer --------- From de3ab2edfd06ed35b67ae290735a74becacd77c0 Mon Sep 17 00:00:00 2001 From: Edmund Hood Highcock Date: Tue, 8 Aug 2023 16:02:55 +0200 Subject: [PATCH 07/10] Black formatting --- src/tariochbctools/importers/transferwise/importer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tariochbctools/importers/transferwise/importer.py b/src/tariochbctools/importers/transferwise/importer.py index 0b76206..0175ba4 100644 --- a/src/tariochbctools/importers/transferwise/importer.py +++ b/src/tariochbctools/importers/transferwise/importer.py @@ -148,7 +148,7 @@ def extract(self, file, existing_entries): accountCcy = account["currency"] if isinstance(baseAccount, dict): account_name = baseAccount[accountCcy] - else: + else: account_name = baseAccount + accountCcy transactions = self._get_statement( currency=accountCcy, From 0631a2a9da8679f3981100daa0950f25afa1e48d Mon Sep 17 00:00:00 2001 From: Edmund Hood Highcock Date: Tue, 8 Aug 2023 19:51:00 +0200 Subject: [PATCH 08/10] Linting Wise importer --- src/tariochbctools/importers/transferwise/importer.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/tariochbctools/importers/transferwise/importer.py b/src/tariochbctools/importers/transferwise/importer.py index 0175ba4..5ae3984 100644 --- a/src/tariochbctools/importers/transferwise/importer.py +++ b/src/tariochbctools/importers/transferwise/importer.py @@ -1,18 +1,19 @@ +import base64 +import json +import sys from datetime import date, datetime, timezone from os import path +from urllib.parse import urlencode import dateutil.parser import requests +import rsa +import urllib3 import yaml -import base64 from beancount.core import amount, data from beancount.core.number import D from beancount.ingest import importer from dateutil.relativedelta import relativedelta -import urllib3 -from urllib.parse import urlencode -import rsa -import json http = urllib3.PoolManager() From b44f2b8149d928fdcefe523958e5bd18e012ca47 Mon Sep 17 00:00:00 2001 From: Edmund Hood Highcock Date: Tue, 8 Aug 2023 20:41:08 +0200 Subject: [PATCH 09/10] Removed print statements --- .../importers/transferwise/importer.py | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/src/tariochbctools/importers/transferwise/importer.py b/src/tariochbctools/importers/transferwise/importer.py index 5ae3984..b22b31b 100644 --- a/src/tariochbctools/importers/transferwise/importer.py +++ b/src/tariochbctools/importers/transferwise/importer.py @@ -1,6 +1,5 @@ import base64 import json -import sys from datetime import date, datetime, timezone from os import path from urllib.parse import urlencode @@ -79,14 +78,9 @@ def _get_statement( if hasattr(self, "one_time_token"): headers["x-2fa-approval"] = self.one_time_token headers["X-Signature"] = self.signature - print(headers["x-2fa-approval"], headers["X-Signature"]) - - print("GET", url) r = http.request("GET", url, headers=headers, retries=False) - print("status:", r.status) - if r.status == 200 or r.status == 201: return json.loads(r.data) elif r.status == 403 and r.getheader("x-2fa-approval") is not None: @@ -98,13 +92,9 @@ def _get_statement( statement_type=statement_type, ) else: - print("failed: ", r.status) - print(r.data) - sys.exit(0) + raise Exception("Failed to get transactions.") def _do_sca_challenge(self): - print("doing sca challenge") - # Read the private key file as bytes. with open(self.private_key_path, "rb") as f: private_key_data = f.read() From ac4d412b51c7a367f0ab64837f20cc8d335ae4b9 Mon Sep 17 00:00:00 2001 From: Edmund Hood Highcock Date: Tue, 8 Aug 2023 20:47:05 +0200 Subject: [PATCH 10/10] Added missing instruction to docs --- docs/importers.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/importers.rst b/docs/importers.rst index 33ccefc..90bbaba 100644 --- a/docs/importers.rst +++ b/docs/importers.rst @@ -107,6 +107,7 @@ The final command makes a traditional private key for compatibility with the pyt Now upload the *public* key part to your Wise account. +You can then create an import config for beancount, or add Wise to your existing one. .. code-block:: python