summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMagnus Hagander2019-03-27 14:40:33 +0000
committerMagnus Hagander2019-03-27 14:46:19 +0000
commitd59eb7566d27cbf7b19095f1d1afcec154304412 (patch)
treebd3d69494aae2eb28a5fdf2edbdbfa232521bcff
parent034c89afde2cb7625fa0ae71fac6c7c60524ed48 (diff)
Add payment provider for TransferWise
This uses the TransferWise REST API to get access to an IBAN account, allowing "traditional" bank paid invoices to be reasonably automated. The provider integrates with the "managed bank transfer" system, thereby handling automated payments using the payment reference. Since this reference is created by us it can be printed on the invoice, making it easier to deal with in traditional corporate environments. Payments that are incorrect in either amount or payment reference will now also show up in the regular "pending bank transactions" view and can be processed manually as necessary. For most SEPA transfers, TransferWise will be able to provide the IBAN number to the sending account. When this is the case, the provider also supports refunds, that will be issued as general IBAN transfers to tihs account. Note that refunds requires the API token to have "full access" as it's permissions in the TW system, meaning it can make arbitrary transfers of any funds. There is no way to specifically tie it to just refunds, as these are just transfers and not payments.
-rw-r--r--docs/invoices/payment.md21
-rw-r--r--postgresqleu/settings.py1
-rw-r--r--postgresqleu/transferwise/__init__.py0
-rw-r--r--postgresqleu/transferwise/admin.py15
-rw-r--r--postgresqleu/transferwise/api.py150
-rw-r--r--postgresqleu/transferwise/management/__init__.py0
-rw-r--r--postgresqleu/transferwise/management/commands/__init__.py0
-rw-r--r--postgresqleu/transferwise/management/commands/transferwise_fetch_transactions.py98
-rw-r--r--postgresqleu/transferwise/management/commands/transferwise_verify_balance.py58
-rw-r--r--postgresqleu/transferwise/migrations/0001_initial.py65
-rw-r--r--postgresqleu/transferwise/migrations/__init__.py0
-rw-r--r--postgresqleu/transferwise/models.py38
-rw-r--r--postgresqleu/util/payment/__init__.py1
-rw-r--r--postgresqleu/util/payment/transferwise.py121
-rw-r--r--template/transferwise/payment.html40
15 files changed, 608 insertions, 0 deletions
diff --git a/docs/invoices/payment.md b/docs/invoices/payment.md
index 80d451a1..73abd87a 100644
--- a/docs/invoices/payment.md
+++ b/docs/invoices/payment.md
@@ -219,6 +219,27 @@ payments without fees, there is currently no support for fees in the
system. If somebody without such a deal wants to use the provider,
this should be added.
+#### TransferWise
+
+This is a managed bank transfer method using the TransferWise
+system. Unlike many other banks, TransferWise provides a simple to use
+REST API to fetch and initiate transactions.
+
+Transactions are fetched on a regular basis by a scheduled job. These
+transactions are then matched in the same way as any other
+transactions.
+
+If the API token used to talk to TransferWise has *Full Access*, it
+will also be possible to issue refunds. If it only has Read Only
+access, payments can still be processed, but refunds will not work.
+
+Refunds also require that the sending bank included the IBAN
+information required to issue a transfer back. This information may or
+may not be included depending on sending bank, but the system will
+automatically validate this information when the transaction is
+received, and if the required information is not present, the refund
+function will not be available.
+
## Currencies
The system can only support one currency, globally, at any given
diff --git a/postgresqleu/settings.py b/postgresqleu/settings.py
index afb99ac1..36c643c5 100644
--- a/postgresqleu/settings.py
+++ b/postgresqleu/settings.py
@@ -108,6 +108,7 @@ INSTALLED_APPS = [
'postgresqleu.util',
'postgresqleu.trustlypayment',
'postgresqleu.braintreepayment',
+ 'postgresqleu.transferwise',
'postgresqleu.membership',
]
diff --git a/postgresqleu/transferwise/__init__.py b/postgresqleu/transferwise/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/postgresqleu/transferwise/__init__.py
diff --git a/postgresqleu/transferwise/admin.py b/postgresqleu/transferwise/admin.py
new file mode 100644
index 00000000..dab3c918
--- /dev/null
+++ b/postgresqleu/transferwise/admin.py
@@ -0,0 +1,15 @@
+from django.contrib import admin
+
+from .models import TransferwiseTransaction, TransferwiseRefund
+
+
+class TransferwiseTransactionAdmin(admin.ModelAdmin):
+ list_display = ('twreference', 'datetime', 'amount', 'feeamount', 'paymentref')
+
+
+class TransferwiseRefundAdmin(admin.ModelAdmin):
+ list_display = ('refundid', 'origtransaction', 'transferid', )
+
+
+admin.site.register(TransferwiseTransaction, TransferwiseTransactionAdmin)
+admin.site.register(TransferwiseRefund, TransferwiseRefundAdmin)
diff --git a/postgresqleu/transferwise/api.py b/postgresqleu/transferwise/api.py
new file mode 100644
index 00000000..1f26486e
--- /dev/null
+++ b/postgresqleu/transferwise/api.py
@@ -0,0 +1,150 @@
+from django.conf import settings
+
+import requests
+from datetime import datetime, timedelta
+from decimal import Decimal
+import uuid
+
+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.profile = self.account = None
+
+ def format_date(self, dt):
+ return dt.strftime('%Y-%m-%dT00:00:00.000Z')
+
+ def parse_datetime(self, s):
+ return datetime.strptime(s, '%Y-%m-%dT%H:%M:%S.%fZ')
+
+ def get(self, suburl, params=None):
+ r = self.session.get(
+ 'https://api.transferwise.com/v1/{}'.format(suburl),
+ params=params,
+ )
+ if r.status_code != 200:
+ r.raise_for_status()
+ return r.json()
+
+ def post(self, suburl, params):
+ r = self.session.post(
+ 'https://api.transferwise.com/v1/{}'.format(suburl),
+ json=params,
+ )
+ 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_account(self):
+ if not self.account:
+ try:
+ self.account = next((a for a in self.get('borderless-accounts', {'profileId': self.get_profile()}) if a['balances'][0]['currency'] == settings.CURRENCY_ABBREV))
+ except Exception as e:
+ raise Exception("Failed to get account: {}".format(e))
+ return self.account
+
+ def get_balance(self):
+ for b in self.get_account()['balances']:
+ if b['currency'] == settings.CURRENCY_ABBREV:
+ return b['amount']['value']
+ return None
+
+ def get_transactions(self, startdate=None, enddate=None):
+ if not enddate:
+ enddate = (datetime.today() + timedelta(days=1)).date()
+
+ if not startdate:
+ startdate = enddate - timedelta(days=60)
+
+ return self.get(
+ 'borderless-accounts/{0}/statement.json'.format(self.get_account()['id']),
+ {
+ 'currency': settings.CURRENCY_ABBREV,
+ 'intervalStart': self.format_date(startdate),
+ 'intervalEnd': self.format_date(enddate),
+ },
+ )['transactions']
+
+ def validate_iban(self, iban):
+ return self.get('validators/iban?iban={}'.format(iban))['validation'] == 'success'
+
+ def get_structured_amount(self, amount):
+ if amount['currency'] != settings.CURRENCY_ABBREV:
+ raise Exception("Invalid currency {} found, exepcted {}".format(amount['currency'], settings.CURRENCY_ABBREV))
+ return Decimal(amount['value']).quantize(Decimal('0.01'))
+
+ 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)
+
+ # Create a recipient account
+ acc = self.post(
+ 'accounts',
+ {
+ 'profile': self.get_profile(),
+ 'currency': settings.CURRENCY_ABBREV,
+ 'accountHolderName': origtrans.counterpart_name,
+ 'type': 'iban',
+ 'details': {
+ 'IBAN': origtrans.counterpart_account,
+ },
+ }
+ )
+ twr.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': refundamount,
+ 'type': 'BALANCE_PAYOUT',
+ },
+ )
+ twr.quoteid = quote['id']
+
+ # Create the actual transfer
+ transfer = self.post(
+ 'transfers',
+ {
+ 'targetAccount': twr.accid,
+ 'quote': twr.quoteid,
+ 'customerTransactionId': str(twr.uuid),
+ 'details': {
+ 'reference': refundstr,
+ },
+ },
+ )
+ twr.transferid = transfer['id']
+ twr.save()
+
+ # Fund the transfer from our account
+ fund = self.post(
+ 'transfers/{0}/payments'.format(twr.transferid),
+ {
+ 'type': 'BALANCE',
+ },
+ )
+
+ return twr.id
diff --git a/postgresqleu/transferwise/management/__init__.py b/postgresqleu/transferwise/management/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/postgresqleu/transferwise/management/__init__.py
diff --git a/postgresqleu/transferwise/management/commands/__init__.py b/postgresqleu/transferwise/management/commands/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/postgresqleu/transferwise/management/commands/__init__.py
diff --git a/postgresqleu/transferwise/management/commands/transferwise_fetch_transactions.py b/postgresqleu/transferwise/management/commands/transferwise_fetch_transactions.py
new file mode 100644
index 00000000..b0d5f9d8
--- /dev/null
+++ b/postgresqleu/transferwise/management/commands/transferwise_fetch_transactions.py
@@ -0,0 +1,98 @@
+# Fetch transaction list from TransferWise
+#
+# Copyright (C) 2019, PostgreSQL Europe
+#
+
+from django.core.management.base import BaseCommand, CommandError
+from django.db import transaction
+from django.conf import settings
+
+from postgresqleu.invoices.util import InvoiceManager, register_bank_transaction
+from postgresqleu.invoices.models import InvoicePaymentMethod
+from postgresqleu.transferwise.models import TransferwiseTransaction, TransferwiseRefund
+
+from datetime import datetime, timedelta
+import re
+
+
+class Command(BaseCommand):
+ help = 'Fetch TransferWise transactions'
+
+ class ScheduledJob:
+ scheduled_interval = timedelta(minutes=60)
+
+ @classmethod
+ def should_run(self):
+ return InvoicePaymentMethod.objects.filter(active=True, classname='postgresqleu.util.payment.transferwise.Transferwise').exists()
+
+ @transaction.atomic
+ def handle(self, *args, **options):
+ for method in InvoicePaymentMethod.objects.filter(active=True, classname='postgresqleu.util.payment.transferwise.Transferwise'):
+ self.handle_method(method)
+
+ def handle_method(self, method):
+ pm = method.get_implementation()
+
+ api = pm.get_api()
+
+ for t in api.get_transactions():
+ # We will re-fetch most transactions, so only create them if they are not
+ # already there.
+ trans, created = TransferwiseTransaction.objects.get_or_create(
+ paymentmethod=method,
+ twreference=t['referenceNumber'],
+ defaults={
+ 'datetime': api.parse_datetime(t['date']),
+ 'amount': api.get_structured_amount(t['amount']),
+ 'feeamount': api.get_structured_amount(t['totalFees']),
+ 'transtype': t['details']['type'],
+ 'paymentref': t['details']['paymentReference'],
+ 'fulldescription': t['details']['description'],
+ }
+ )
+ if created:
+ # Set optional fields
+ trans.counterpart_name = t['details'].get('senderName', '')
+ trans.counterpart_account = t['details'].get('senderAccount', '').replace(' ', '')
+ if trans.counterpart_account:
+ # If account is IBAN, then try to validate it!
+ trans.counterpart_valid_iban = api.validate_iban(trans.counterpart_account)
+ trans.save()
+
+ # If this is a refund transaction, process it as such
+ if trans.transtype == 'TRANSFER' and trans.paymentref.startswith('{0} refund'.format(settings.ORG_SHORTNAME)):
+ # Yes, this is one of our refunds. Can we find the corresponding transaction?
+ m = re.match('^TRANSFER-(\d+)$', t['referenceNumber'])
+ if not m:
+ raise Exception("Could not find TRANSFER info in transfer reference {0}".format(t['referenceNumber']))
+ transferid = m.groups(1)[0]
+ try:
+ twrefund = TransferwiseRefund.objects.get(transferid=transferid)
+ except TransferwiseRefund.DoesNotExist:
+ print("Could not find transferwise refund for id {0}, registering as manual bank transaction".format(transferid))
+ register_bank_transaction(method, trans.id, trans.amount, trans.paymentref, trans.fulldescription)
+ continue
+
+ if twrefund.refundtransaction or twrefund.completedat:
+ raise Exception("Transferwise refund for id {0} has already been processed!".format(transferid))
+
+ # Flag this one as done!
+ twrefund.refundtransaction = trans
+ twrefund.completedat = datetime.now()
+ twrefund.save()
+
+ invoicemanager = InvoiceManager()
+ invoicemanager.complete_refund(
+ twrefund.refundid,
+ trans.amount + trans.feeamount,
+ trans.feeamount,
+ pm.config('bankaccount'),
+ pm.config('feeaccount'),
+ [], # urls
+ method,
+ )
+
+ else:
+ # Else register a pending bank transaction. This may immediately match an invoice
+ # if it was an invoice payment, in which case the entire process will copmlete.
+ register_bank_transaction(method, trans.id, trans.amount, trans.paymentref, trans.fulldescription)
diff --git a/postgresqleu/transferwise/management/commands/transferwise_verify_balance.py b/postgresqleu/transferwise/management/commands/transferwise_verify_balance.py
new file mode 100644
index 00000000..3ad91fa5
--- /dev/null
+++ b/postgresqleu/transferwise/management/commands/transferwise_verify_balance.py
@@ -0,0 +1,58 @@
+#
+# This script compares the balance of a TransferWise account with the
+# one in the accounting system, raising an alert if they are different
+# (which indicates that something has been incorrectly processed).
+#
+# Copyright (C) 2019, PostgreSQL Europe
+#
+
+
+from django.core.management.base import BaseCommand
+from django.db import transaction
+from django.conf import settings
+
+from datetime import time
+
+from postgresqleu.invoices.models import InvoicePaymentMethod
+from postgresqleu.accounting.util import get_latest_account_balance
+from postgresqleu.mailqueue.util import send_simple_mail
+from postgresqleu.transferwise.api import TransferwiseApi
+
+
+class Command(BaseCommand):
+ help = 'Compare TransferWise balance to the accounting system'
+
+ class ScheduledJob:
+ scheduled_times = [time(3, 15), ]
+
+ @classmethod
+ def should_run(self):
+ return InvoicePaymentMethod.objects.filter(active=True, classname='postgresqleu.util.payment.transferwise.Transferwise').exists()
+
+ @transaction.atomic
+ def handle(self, *args, **options):
+ for method in InvoicePaymentMethod.objects.filter(active=True, classname='postgresqleu.util.payment.transferwise.Transferwise'):
+ self.verify_one_account(method)
+
+ def verify_one_account(self, method):
+ method = method
+ pm = method.get_implementation()
+
+ api = TransferwiseApi(pm)
+
+ tw_balance = api.get_balance()
+
+ accounting_balance = get_latest_account_balance(pm.config('bankaccount'))
+
+ if accounting_balance != tw_balance:
+ send_simple_mail(settings.INVOICE_SENDER_EMAIL,
+ settings.TREASURER_EMAIL,
+ 'TransferWise balance mismatch!',
+ """TransferWise balance ({0}) for {1} does not match the accounting system ({2})!
+
+This could be because some entry has been missed in the accounting
+(automatic or manual), or because of an ongoing booking of something
+that the system doesn't know about.
+
+Better go check manually!
+""".format(tw_balance, method.internaldescription, accounting_balance))
diff --git a/postgresqleu/transferwise/migrations/0001_initial.py b/postgresqleu/transferwise/migrations/0001_initial.py
new file mode 100644
index 00000000..6aa45c58
--- /dev/null
+++ b/postgresqleu/transferwise/migrations/0001_initial.py
@@ -0,0 +1,65 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.18 on 2019-03-26 15:41
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ('invoices', '0013_vatcache'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='TransferwiseRefund',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('refundid', models.BigIntegerField(unique=True)),
+ ('uuid', models.UUIDField(unique=True)),
+ ('transferid', models.BigIntegerField(unique=True)),
+ ('accid', models.BigIntegerField(null=True)),
+ ('quoteid', models.BigIntegerField(null=True)),
+ ('createdat', models.DateTimeField(auto_now_add=True)),
+ ('completedat', models.DateTimeField(blank=True, null=True)),
+ ],
+ ),
+ migrations.CreateModel(
+ name='TransferwiseTransaction',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('twreference', models.CharField(max_length=100)),
+ ('datetime', models.DateTimeField()),
+ ('amount', models.DecimalField(decimal_places=2, max_digits=20)),
+ ('feeamount', models.DecimalField(decimal_places=2, max_digits=20)),
+ ('transtype', models.CharField(max_length=32)),
+ ('paymentref', models.CharField(blank=True, max_length=200)),
+ ('fulldescription', models.CharField(blank=True, max_length=500)),
+ ('counterpart_name', models.CharField(blank=True, max_length=100)),
+ ('counterpart_account', models.CharField(blank=True, max_length=100)),
+ ('counterpart_valid_iban', models.BooleanField(default=False)),
+ ('paymentmethod', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='invoices.InvoicePaymentMethod')),
+ ],
+ options={
+ 'ordering': ('-datetime',),
+ },
+ ),
+ migrations.AddField(
+ model_name='transferwiserefund',
+ name='origtransaction',
+ field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='refund_orig', to='transferwise.TransferwiseTransaction'),
+ ),
+ migrations.AddField(
+ model_name='transferwiserefund',
+ name='refundtransaction',
+ field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='refund_refund', to='transferwise.TransferwiseTransaction'),
+ ),
+ migrations.AlterUniqueTogether(
+ name='transferwisetransaction',
+ unique_together=set([('twreference', 'paymentmethod')]),
+ ),
+ ]
diff --git a/postgresqleu/transferwise/migrations/__init__.py b/postgresqleu/transferwise/migrations/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/postgresqleu/transferwise/migrations/__init__.py
diff --git a/postgresqleu/transferwise/models.py b/postgresqleu/transferwise/models.py
new file mode 100644
index 00000000..8569f033
--- /dev/null
+++ b/postgresqleu/transferwise/models.py
@@ -0,0 +1,38 @@
+from django.db import models
+
+from postgresqleu.invoices.models import InvoicePaymentMethod
+
+
+class TransferwiseTransaction(models.Model):
+ paymentmethod = models.ForeignKey(InvoicePaymentMethod, blank=False, null=False)
+ twreference = models.CharField(max_length=100, blank=False, null=False)
+ datetime = models.DateTimeField(null=False, blank=False)
+ amount = models.DecimalField(decimal_places=2, max_digits=20, null=False)
+ feeamount = models.DecimalField(decimal_places=2, max_digits=20, null=False)
+ transtype = models.CharField(max_length=32, blank=False, null=False)
+ paymentref = models.CharField(max_length=200, blank=True, null=False)
+ fulldescription = models.CharField(max_length=500, blank=True, null=False)
+ counterpart_name = models.CharField(max_length=100, blank=True, null=False)
+ counterpart_account = models.CharField(max_length=100, blank=True, null=False)
+ counterpart_valid_iban = models.BooleanField(null=False, default=False)
+
+ class Meta:
+ unique_together = (
+ ('twreference', 'paymentmethod'),
+ )
+ ordering = ('-datetime', )
+
+ def __str__(self):
+ return self.twreference
+
+
+class TransferwiseRefund(models.Model):
+ origtransaction = models.ForeignKey(TransferwiseTransaction, blank=False, null=False, related_name='refund_orig')
+ refundtransaction = models.OneToOneField(TransferwiseTransaction, blank=True, null=True, related_name='refund_refund', unique=True)
+ refundid = models.BigIntegerField(null=False, unique=True)
+ uuid = models.UUIDField(blank=False, null=False, unique=True)
+ transferid = models.BigIntegerField(null=False, unique=True)
+ accid = models.BigIntegerField(null=True)
+ quoteid = models.BigIntegerField(null=True)
+ createdat = models.DateTimeField(null=False, blank=False, auto_now_add=True)
+ completedat = models.DateTimeField(null=True, blank=True)
diff --git a/postgresqleu/util/payment/__init__.py b/postgresqleu/util/payment/__init__.py
index ca64ed91..3c60eb3d 100644
--- a/postgresqleu/util/payment/__init__.py
+++ b/postgresqleu/util/payment/__init__.py
@@ -23,6 +23,7 @@ payment_implementations = [
'postgresqleu.util.payment.adyen.AdyenBanktransfer',
'postgresqleu.util.payment.trustly.TrustlyPayment',
'postgresqleu.util.payment.braintree.Braintree',
+ 'postgresqleu.util.payment.transferwise.Transferwise',
]
diff --git a/postgresqleu/util/payment/transferwise.py b/postgresqleu/util/payment/transferwise.py
new file mode 100644
index 00000000..d185a73b
--- /dev/null
+++ b/postgresqleu/util/payment/transferwise.py
@@ -0,0 +1,121 @@
+from django import forms
+from django.shortcuts import render
+from django.conf import settings
+
+import re
+from io import StringIO
+
+from postgresqleu.util.payment.banktransfer import BaseManagedBankPayment
+from postgresqleu.util.payment.banktransfer import BaseManagedBankPaymentForm
+from postgresqleu.transferwise.api import TransferwiseApi
+
+from postgresqleu.invoices.models import Invoice
+from postgresqleu.transferwise.models import TransferwiseTransaction
+
+
+class BackendTransferwiseForm(BaseManagedBankPaymentForm):
+ apikey = forms.CharField(required=True, widget=forms.widgets.PasswordInput(render_value=True))
+ 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')
+
+ managed_fields = ['apikey', 'canrefund', ]
+ managed_fieldsets = [
+ {
+ 'id': 'tw',
+ 'legend': 'TransferWise',
+ 'fields': ['canrefund', 'apikey', ],
+ }
+ ]
+
+ @classmethod
+ def validate_data_for(self, instance):
+ pm = instance.get_implementation()
+ api = pm.get_api()
+ try:
+ account = api.get_account()
+ return """Successfully retreived information:
+
+<pre>{0}</pre>
+""".format(self.prettyprint_address(account['balances'][0]['bankDetails']))
+ except Exception as e:
+ return "Verification failed: {}".format(e)
+
+ @classmethod
+ def prettyprint_address(self, a, indent=''):
+ s = StringIO()
+ for k, v in a.items():
+ if k == 'id':
+ continue
+
+ s.write(indent)
+ if isinstance(v, dict):
+ s.write(k)
+ s.write(":\n")
+ s.write(self.prettyprint_address(v, indent + ' '))
+ else:
+ s.write("{0:20s}{1}\n".format(k + ':', v))
+ return s.getvalue()
+
+
+class Transferwise(BaseManagedBankPayment):
+ backend_form_class = BackendTransferwiseForm
+ description = """
+Pay using a direct IBAN bank transfer in EUR. We
+<strong>strongly advice</strong> not using this method if
+making a payment from outside the Euro-zone, as amounts
+must be exact and all fees covered by sender.
+"""
+
+ def render_page(self, request, invoice):
+ return render(request, 'transferwise/payment.html', {
+ 'invoice': invoice,
+ 'bankinfo': self.config('bankinfo'),
+ })
+
+ def get_api(self):
+ return TransferwiseApi(self)
+
+ def _find_invoice_transaction(self, invoice):
+ r = re.compile('Bank transfer from {} with id (\d+)'.format(self.method.internaldescription))
+ 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
diff --git a/template/transferwise/payment.html b/template/transferwise/payment.html
new file mode 100644
index 00000000..3cca5b24
--- /dev/null
+++ b/template/transferwise/payment.html
@@ -0,0 +1,40 @@
+{%extends "navbase.html"%}
+{%block title%}Pay with bank transfer{%endblock%}
+{%block content%}
+<h1>Pay with bank transfer</h1>
+<p>
+To pay your invoice using bank transfer, please make a payment
+according to the following:
+</p>
+
+<table border="1" cellspacing="0" cellpadding="3">
+<tr>
+ <th>Account information</th>
+ <td>{{bankinfo|linebreaksbr}}</td>
+</tr>
+<tr>
+ <th>Payment reference</th>
+ <td><strong>{{invoice.payment_reference}}</strong></td>
+</tr>
+<tr>
+ <th>Amount</th>
+ <td>€{{invoice.total_amount}}</td>
+</tr>
+</table>
+
+<p>
+<b>Note</b> that it is <b><i>very</i></b> important that you provide the
+correct text on the transfer, or we may not be able to match your payment
+to the correct invoice. In particular, <b>do not</b> use the invoice number,
+use the specified payment reference!
+</p>
+
+<p>
+<b>Note</b> that bank transfers take a few days to process, so if your
+payment is nedeed repidly in order to confirm something, this is <strong>not</strong> a good
+choice of payment method.
+</p>
+
+{%if returnurl%}<a href="{{returnurl}}" class="btn btn-outline-dark">Return to payment options</a>{%endif%}
+
+{%endblock%}