Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update Transferwise (now Wise) to support SCA #100

Merged
merged 10 commits into from
Aug 8, 2023
36 changes: 33 additions & 3 deletions docs/importers.rst
Original file line number Diff line number Diff line change
Expand Up @@ -88,10 +88,26 @@ Import CSV from `Revolut <https://www.revolut.com/>`__
CONFIG = [revolutimp.Importer("/Revolut-CHF.*\.csv", "Assets:Revolut:CHF", "CHF")]


Transferwise
------------
Wise (formerly Transferwise)
----------------------------

Import from `Wise <https://www.wise.com/>`__ 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 <https://www.transferwise.com/>`__ using their api
You can then create an import config for beancount, or add Wise to your existing one.

.. code-block:: python

Expand All @@ -105,8 +121,22 @@ Create a file called transferwise.yaml in your import location (e.g. download fo

token: <your api token>
baseAccount: <Assets:Transferwise:>
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: <your api token>
baseAccount:
SEK: "Assets:MySwedishWiseAccount"
GBP: "Assets:MyUKWiseAccount"
privateKeyPath: /path/to/wise_traditional.pem

TrueLayer
---------

Expand Down
134 changes: 111 additions & 23 deletions src/tariochbctools/importers/transferwise/importer.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
import base64
import json
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
from beancount.core import amount, data
from beancount.core.number import D
from beancount.ingest import importer
from dateutil.relativedelta import relativedelta

http = urllib3.PoolManager()


class Importer(importer.ImporterProtocol):
"""An importer for Transferwise using the API."""
Expand All @@ -19,45 +26,126 @@ 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,
currency,
base_url,
statement_type="FLAT",
):
params = urlencode(
{
"currency": currency,
"type": statement_type,
"intervalStart": self.startDate,
"intervalEnd": self.endDate,
}
)

url = (
base_url
+ "/v3/profiles/"
+ str(self.profileId)
+ "/borderless-accounts/"
+ str(self.accountId)
+ "/statement.json?"
+ params
)

headers = {
"Authorization": "Bearer " + self.api_token,
"User-Agent": "tw-statements-sca",
"Content-Type": "application/json",
}
if hasattr(self, "one_time_token"):
headers["x-2fa-approval"] = self.one_time_token
headers["X-Signature"] = self.signature

r = http.request("GET", url, headers=headers, retries=False)

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:
self.one_time_token = r.getheader("x-2fa-approval")
self.signature = self._do_sca_challenge()
return self._get_statement(
currency=currency,
base_url=base_url,
statement_type=statement_type,
)
else:
raise Exception("Failed to get transactions.")

def _do_sca_challenge(self):
# Read the private key file as bytes.
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(
self.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"]
self.api_token = config["token"]
baseAccount = config["baseAccount"]
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,
if isinstance(baseAccount, dict):
account_name = baseAccount[accountCcy]
else:
account_name = baseAccount + accountCcy
transactions = self._get_statement(
currency=accountCcy,
base_url=base_url,
statement_type="FLAT",
)
transactions = r.json()

for transaction in transactions["transactions"]:
metakv = {
Expand All @@ -74,7 +162,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"],
Expand Down
Loading