1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
|
from django.core.serializers.json import DjangoJSONEncoder
from django.conf import settings
import requests
from datetime import datetime, timedelta
from decimal import Decimal
import json
import re
import sys
import uuid
from base64 import b64encode
from postgresqleu.util.time import today_global
from postgresqleu.util.crypto import rsa_sign_string_sha256
from postgresqleu.mailqueue.util import send_simple_mail
from .models import TransferwiseRefund
class TransferwiseApi(object):
def __init__(self, pm):
self.pm = pm
self.session = requests.session()
self.session.headers.update({
'Authorization': 'Bearer {}'.format(self.pm.config('apikey')),
})
self.privatekey = self.pm.config('private_key')
self.profile = self.balances = None
def format_date(self, dt):
return dt.strftime('%Y-%m-%dT00:00:00.000Z')
def parse_datetime(self, s):
try:
return datetime.strptime(s, '%Y-%m-%dT%H:%M:%S.%fZ')
except ValueError:
# Why would tw consistently have just one way to write timestamps? That would be silly!
return datetime.strptime(s, '%Y-%m-%d %H:%M:%S')
def _get(self, suburl, params=None, stream=False, version='v1'):
fullurl = 'https://api.transferwise.com/{}/{}'.format(version, suburl)
r = self.session.get(
fullurl,
params=params,
stream=stream,
)
if r.status_code != 200:
# Print the content of the error as well, so this can be picked up in a log
sys.stderr.write("API returned status {}. Body:\n{}\n".format(r.status_code, r.text[:2000]))
r.raise_for_status()
return r
def get(self, suburl, params=None, version='v1'):
return self._get(suburl, params, False, version).json()
def get_binary(self, suburl, params=None, version='v1'):
r = self._get(suburl, params, True, version)
r.raw.decode_content = True
return r.raw
def post(self, suburl, params, version='v1'):
j = json.dumps(params, cls=DjangoJSONEncoder)
fullurl = 'https://api.transferwise.com/{}/{}'.format(version, suburl)
r = self.session.post(
fullurl,
data=j,
headers={
'Content-Type': 'application/json',
},
)
r.raise_for_status()
return r.json()
def get_profile(self):
if not self.profile:
try:
self.profile = next((p['id'] for p in self.get('profiles') if p['type'] == 'business'))
except Exception as e:
raise Exception("Failed to get profile: {}".format(e))
pass
return self.profile
def _get_balances(self):
if not self.balances:
self.balances = self.get('profiles/{}/balances'.format(self.get_profile()), params={'types': 'STANDARD'}, version='v4')
return self.balances
def get_account(self):
for a in self._get_balances():
if a['currency'] == settings.CURRENCY_ABBREV:
return a['id']
raise Exception("Failed to identify account based on currency")
def get_account_details(self):
for d in self.get('profiles/{}/account-details'.format(self.get_profile())):
if d['id'] and d['status'] == 'ACTIVE' and d['currency']['code'] == settings.CURRENCY_ABBREV:
for o in d['receiveOptions']:
if o['type'] == 'INTERNATIONAL':
return o['shareText']
raise Exception("Could not find account in returned structure")
def get_balance(self):
for b in self._get_balances():
if b['currency'] == settings.CURRENCY_ABBREV:
return Decimal(b['amount']['value']).quantize(Decimal('0.01'))
return None
def get_transactions(self, startdate=None, enddate=None):
if not enddate:
enddate = today_global() + timedelta(days=1)
if not startdate:
startdate = enddate - timedelta(days=60)
cursor = None
while True:
params = {'since': self.format_date(startdate), 'until': self.format_date(enddate), 'status': 'COMPLETED', 'size': 100}
if cursor:
params['nextCursor'] = cursor
r = self.get('profiles/{}/activities'.format(self.get_profile()), params)
if not r['activities']:
# No more activities!
return
for activity in r['activities']:
if activity['type'] == 'TRANSFER' or \
(activity['type'] == 'BALANCE_DEPOSIT' and activity['resource']['type'] == 'TRANSFER'):
try:
details = self.get('transfers/{}'.format(activity['resource']['id']))
except requests.exceptions.HTTPError as e:
if e.response.status_code == 403:
print("No permissions to access transaction {} from {}, ignoring".format(
activity['resource']['id'],
activity['updatedOn'],
))
continue
raise
if details['sourceCurrency'] != settings.CURRENCY_ABBREV:
continue
amount = Decimal(details['targetValue']).quantize(Decimal('0.01'))
# Yes, the transfer will actually have a positive amount even if it's a withdrawal.
# No, this is not indicated anywhere, since the "target account id" that would
# indicate it, points to the wrong account for incoming payments.
# Oh, and the status is *always* set to `outgoing_payment_sent`, even for incoming
# payments. I guess all payments are outgoing from *something*.
# Let's do a wild gamble and assume the description is always this...
if activity.get('description', '').startswith('Sent by '):
negatizer = -1
else:
negatizer = 1
# We also need to look at the amount in the activity, as it might be different
# if there are fees.
primaryAmount, primaryCurrency = self.parse_transferwise_amount(activity['primaryAmount'])
if activity.get('secondaryAmount', None):
secondaryAmount, secondaryCurrency = self.parse_transferwise_amount(activity['secondaryAmount'])
else:
secondaryAmount = 0
secondaryCurrency = primaryCurrency
if primaryCurrency != secondaryCurrency:
# This is (preasumably) an outgoing payment in a non-primary currency. In this case, the EUR numbers are in
# the secondaryCurrency fields.
amount = secondaryAmount
elif primaryCurrency != settings.CURRENCY_ABBREV:
print(activity)
raise Exception("Primary currency is not our primarycurrency!")
yield {
'id': 'TRANSFER-{}'.format(activity['resource']['id']),
'datetime': details['created'],
'amount': amount * negatizer,
'feeamount': 0, # XXX!
'transtype': 'TRANSFER',
'paymentref': details['reference'],
'fulldescription': details['details']['reference'],
}
elif activity['type'] == 'BALANCE_CASHBACK':
# No API endpoint to get this so we have to parse it out of
# a ridiculously formatted field.
parsed_amount, currency = self.parse_transferwise_amount(activity['primaryAmount'])
if currency != settings.CURRENCY_ABBREV:
# This is cashback in a different currency, so ignore it
continue
yield {
'id': 'BALANCE_CASHBACK-{}'.format(activity['resource']['id']),
'datetime': activity['updatedOn'],
'amount': parsed_amount,
'feeamount': 0,
'transtype': 'BALANCE_CASHBACK',
'paymentref': '',
'fulldescription': 'Balance Cashback',
}
elif activity['type'] == 'CARD_PAYMENT':
# For card payments, normal tokens appear not to have permissions
# to view the details, so try to parse it out of the activity.
parsed_amount, currency = self.parse_transferwise_amount(activity['primaryAmount'])
if currency != settings.CURRENCY_ABBREV:
# This is cashback in a different currency, so ignore it
continue
yield {
'id': 'CARD-{}'.format(activity['resource']['id']),
'datetime': activity['updatedOn'],
'amount': -parsed_amount,
'feeamount': 0,
'transtype': 'CARD',
'paymentref': '',
'fulldescription': 'Card payment: {}'.format(self.strip_tw_tags(activity['title']),),
}
elif activity['type'] == 'INTERBALANCE':
yield {
'id': None,
'message': "Received INTERBALANCE transaction, details are not fully parsable so please handle manually. Contents: {}".format(activity),
}
elif activity['type'] == 'CARD_CHECK':
# This is just a check that the card is OK, no money in the transaction
continue
else:
print(activity)
raise Exception("Unhandled activity type {}".format(activity['type']))
cursor = r.get('cursor', None)
if not cursor:
return
def parse_transferwise_amount(self, amount):
# Try to parse the really weird strings that they use as amount
# Example: 'primaryAmount': '<positive>+ 5.10 EUR</positive>',
m = re.match(r'^<positive>\+\s+([\d\.]+)\s+(\w+)</positive>$', amount.replace(',', ''))
if m:
return Decimal(m.group(1)).quantize(Decimal('0.01')), m.group(2)
# Sometimes <positive> isn't there... Because.. Well it's not.
m = re.match(r'^([\d\.]+)\s+(\w+)$', amount.replace(',', ''))
if m:
return Decimal(m.group(1)).quantize(Decimal('0.01')), m.group(2)
raise Exception("Failed to parse transferwise amount from '{}'".format(amount))
def strip_tw_tags(self, s):
return re.subn('</?(strong|positive|negative|strikethrough)>', '', s)
def validate_iban(self, iban):
try:
return self.get('validators/iban?iban={}'.format(iban))['validation'] == 'success'
except requests.exceptions.HTTPError as e:
# API returns http 400 on (some?) failed validations that are just not validating.
# In those cases, just set it to not being valid.
if e.response.status_code == 400:
return False
# Bubble any other exceptions
raise
def refund_transaction(self, origtrans, refundid, refundamount, refundstr):
if not origtrans.counterpart_valid_iban:
raise Exception("Cannot refund transaction without valid counterpart IBAN!")
# This is a many-step process, unfortunately complicated.
twr = TransferwiseRefund(origtransaction=origtrans, uuid=uuid.uuid4(), refundid=refundid)
(accid, quoteid, transferid) = self.make_transfer(origtrans.counterpart_name,
origtrans.counterpart_account,
refundamount,
refundstr,
twr.uuid,
)
twr.accid = accid
twr.quoteid = quoteid
twr.transferid = transferid
twr.save()
return twr.id
def make_transfer(self, counterpart_name, counterpart_account, amount, reference, xuuid):
# Create a recipient account
name = re.sub(r'\d+', '', counterpart_name.replace(',', ' '))
if ' ' not in name:
# Transferwise requires at least a two part name. Since the recipient name
# isn't actually important, just duplicate it...
name = name + ' ' + name
acc = self.post(
'accounts',
{
'profile': self.get_profile(),
'currency': settings.CURRENCY_ABBREV,
'accountHolderName': name,
'type': 'iban',
'details': {
'IBAN': counterpart_account,
},
}
)
accid = acc['id']
# Create a quote (even though we're not doing currency exchange)
quote = self.post(
'quotes',
{
'profile': self.get_profile(),
'source': settings.CURRENCY_ABBREV,
'target': settings.CURRENCY_ABBREV,
'rateType': 'FIXED',
'targetAmount': amount,
'type': 'BALANCE_PAYOUT',
},
)
quoteid = quote['id']
# Create the actual transfer
transfer = self.post(
'transfers',
{
'targetAccount': accid,
'quote': quoteid,
'customerTransactionId': str(xuuid),
'details': {
'reference': reference,
},
},
)
transferid = transfer['id']
# We can no longer fund the transfer, because Wise decided it's not allowed to access our own money.
# So we have to tell the user to do it.
# Fund the transfer from our account
# fund = self.post(
# 'profiles/{}/transfers/{}/payments'.format(self.get_profile(), transferid),
# {
# 'type': 'BALANCE',
# },
# version='v3',
# )
send_simple_mail(settings.INVOICE_SENDER_EMAIL,
self.pm.config('notification_receiver'),
'TransferWise payout initiated!',
"""A TransferWise payout of {0} with reference {1}
has been initiated. Unfortunately, it can not be completed
through the API due to restrictions at TransferWise, so you need to
log into the account and confirm it manually.
OPlease do so as soon as possible.
""".format(amount, reference))
return (accid, quoteid, transferid)
|