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
|
from django import forms
from django.core.validators import MinValueValidator
from django.contrib import messages
from django.shortcuts import render
from django.conf import settings
import re
import uuid
from io import StringIO
from postgresqleu.util.payment.banktransfer import BaseManagedBankPayment
from postgresqleu.util.payment.banktransfer import BaseManagedBankPaymentForm
from postgresqleu.util.forms import SubmitButtonField
from postgresqleu.util.widgets import MonospaceTextarea, StaticTextWidget
from postgresqleu.util.crypto import validate_pem_public_key, validate_pem_private_key
from postgresqleu.util.crypto import generate_rsa_keypair
from postgresqleu.accounting.util import get_account_choices
from postgresqleu.transferwise.api import TransferwiseApi
from postgresqleu.transferwise.models import TransferwiseTransaction, TransferwisePayout
class BackendTransferwiseForm(BaseManagedBankPaymentForm):
apikey = forms.CharField(required=True, widget=forms.widgets.PasswordInput(render_value=True))
public_key = forms.CharField(required=False, widget=MonospaceTextarea, validators=[validate_pem_public_key, ])
private_key = forms.CharField(required=False, widget=MonospaceTextarea, validators=[validate_pem_private_key, ])
generatekey = SubmitButtonField(label="Generate new keypair", required=False)
canrefund = forms.BooleanField(required=False, label='Can refund',
help_text='Process automatic refunds. This requires an API key with full access to make transfers to any accounts')
autopayout = forms.BooleanField(required=False, label='Automatic payouts',
help_text='Issue automatic payouts when account balances goes above a specified level.')
autopayouttrigger = forms.IntegerField(required=False, label='Payout trigger',
validators=[MinValueValidator(1), ],
help_text='Trigger automatic payouts when balance goes above this')
autopayoutlimit = forms.IntegerField(required=False, label='Payout limit',
validators=[MinValueValidator(0), ],
help_text='When issuing automatic payouts, keep this amount in the account after the payout is done')
autopayoutname = forms.CharField(required=False, max_length=64, label='Recipent name',
help_text='Name of recipient to make IBAN payouts to')
autopayoutiban = forms.CharField(required=False, max_length=64, label='Recipient IBAN',
help_text='IBAN number of account to make payouts to')
notification_receiver = forms.EmailField(required=True)
send_statements = forms.BooleanField(required=False, label="Send statements",
help_text="Send monthly PDF statements by email")
accounting_payout = forms.ChoiceField(required=False, choices=[(None, '---')] + get_account_choices(),
label="Payout account")
accounting_cashback = forms.ChoiceField(required=False, choices=[(None, '---')] + get_account_choices(),
label="Cashback account",
help_text="Accounting account that any cashback payments are booked against")
webhookurl = forms.CharField(label="Webhook URL", widget=StaticTextWidget)
exclude_fields_from_validation = ('generatekey', )
config_readonly = ['webhookurl', ]
managed_fields = ['apikey', 'canrefund', 'notification_receiver', 'autopayout', 'autopayouttrigger',
'autopayoutlimit', 'autopayoutname', 'autopayoutiban', 'accounting_payout', 'accounting_cashback',
'send_statements', 'public_key', 'private_key', 'generatekey', ]
managed_fieldsets = [
{
'id': 'tw',
'legend': 'TransferWise',
'fields': ['notification_receiver', 'send_statements', 'canrefund', 'apikey', 'generatekey', 'public_key', 'private_key'],
},
{
'id': 'twautopayout',
'legend': 'Automatic Payouts',
'fields': ['autopayout', 'autopayouttrigger', 'autopayoutlimit',
'autopayoutname', 'autopayoutiban', 'accounting_payout'],
},
{
'id': 'twconf',
'legend': 'TransferWise configuration',
'fields': ['webhookurl', ],
},
]
@property
def config_fieldsets(self):
fss = super().config_fieldsets
for fs in fss:
if fs['id'] == 'accounting':
fs['fields'].append('accounting_cashback')
return fss
def fix_fields(self):
super().fix_fields()
self.fields['generatekey'].callback = self.generate_keypair
self.initial['webhookurl'] = """
On the TransferWise account, go into <i>Settings</i> and click
<i>Create new webhook</i>. Give it a reasonable name, set it to
receive <i>Balance deposit events</i>, and specify the URL
<code>{}/wh/tw/{}/balance/</code>.""".format(
settings.SITEBASE,
self.instance.id,
)
def generate_keypair(self, request):
(private, public) = generate_rsa_keypair()
self.instance.config['public_key'] = public
self.instance.config['private_key'] = private
self.instance.save(update_fields=['config'])
messages.info(request, "New RSA keypair generated")
return True
def clean(self):
cleaned_data = super(BackendTransferwiseForm, self).clean()
if cleaned_data['autopayout']:
if not cleaned_data.get('canrefund', None):
self.add_error('autopayout', 'Automatic payouts can only be enabled if refunds are enabled')
# If auto payouts are enabled, a number of fields become mandateory
for fn in ('autopayouttrigger', 'autopayoutlimit', 'autopayoutname', 'autopayoutiban', 'accounting_payout'):
if not cleaned_data.get(fn, None):
self.add_error(fn, 'This field is required when automatic payouts are enabled')
if cleaned_data['autopayoutlimit'] >= cleaned_data['autopayouttrigger']:
self.add_error('autopayoutlimit', 'This value must be lower than the trigger value')
# Actually make an API call to validate the IBAN
if 'autopayoutiban' in cleaned_data and cleaned_data['autopayoutiban']:
pm = self.instance.get_implementation()
api = pm.get_api()
try:
if not api.validate_iban(cleaned_data['autopayoutiban']):
self.add_error('autopayoutiban', 'IBAN number could not be validated')
except Exception as e:
self.add_error('autopayoutiban', 'IBAN number could not be validated: {}'.format(e))
return cleaned_data
@classmethod
def validate_data_for(self, instance):
pm = instance.get_implementation()
api = pm.get_api()
try:
address = api.get_account_details()
return """Successfully retreived information:
<pre>{0}</pre>
""".format(address)
except Exception as e:
return "Verification failed: {}".format(e)
class Transferwise(BaseManagedBankPayment):
backend_form_class = BackendTransferwiseForm
description = """
Pay using a direct IBAN bank transfer in {}. We
<strong>strongly advise</strong> not using this method if
making a payment from an account in a different currency,
as amounts must be exact and all fees covered by sender.
""".format(settings.CURRENCY_ABBREV)
def render_page(self, request, invoice):
return render(request, 'invoices/genericbankpayment.html', {
'invoice': invoice,
'bankinfo': self.config('bankinfo'),
})
def get_api(self):
return TransferwiseApi(self)
def _find_invoice_transaction(self, invoice):
r = re.compile(r'Bank transfer from method {} with id (\d+)'.format(self.method.id))
m = r.match(invoice.paymentdetails)
if m:
try:
return (TransferwiseTransaction.objects.get(pk=m.groups(1)[0], paymentmethod=self.method), None)
except TransferwiseTransaction.DoesNotExist:
return (None, "not found")
else:
return (None, "unknown format")
def can_autorefund(self, invoice):
if not self.config('canrefund'):
return False
(trans, reason) = self._find_invoice_transaction(invoice)
if trans:
if not trans.counterpart_valid_iban:
# If there is no valid IBAN on the counterpart, we won't be able to
# refund this invoice.
return False
return True
return False
def autorefund(self, refund):
if not self.config('canrefund'):
raise Exception("Cannot process automatic refunds. Configuration has changed?")
(trans, reason) = self._find_invoice_transaction(refund.invoice)
if not trans:
raise Exception(reason)
api = self.get_api()
refund.payment_reference = api.refund_transaction(
trans,
refund.id,
refund.fullamount,
'{0} refund {1}'.format(settings.ORG_SHORTNAME, refund.id),
)
# At this point, we succeeded. Anything that failed will bubble
# up as an exception.
return True
def return_payment(self, trans):
# Return a payment that is *not* attached to an invoice
if not self.config('canrefund'):
raise Exception("Cannot process automatic refunds. Configuration has changed?")
twtrans = TransferwiseTransaction.objects.get(
paymentmethod=trans.method,
id=trans.methodidentifier,
)
if not twtrans.counterpart_valid_iban:
raise Exception("Cannot return payment without a valid IBAN")
payout = TransferwisePayout(
paymentmethod=trans.method,
amount=twtrans.amount,
reference='{0} returned payment {1}'.format(settings.ORG_SHORTNAME, twtrans.id),
counterpart_name=twtrans.counterpart_name,
counterpart_account=twtrans.counterpart_account,
uuid=uuid.uuid4(),
)
payout.save()
|