From 847997ea9b489c7965d805ea04311e19aefa563d Mon Sep 17 00:00:00 2001
From: Raphael Michel
Date: Wed, 4 Jan 2017 16:45:57 +0100
Subject: [PATCH] Fix #32 -- Add a PayPal webhook listener
---
src/pretix/plugins/paypal/payment.py | 7 +
src/pretix/plugins/paypal/signals.py | 43 ++-
.../pretixplugins/paypal/action_refund.html | 20 ++
src/pretix/plugins/paypal/urls.py | 9 +-
src/pretix/plugins/paypal/views.py | 88 +++++-
.../pretixplugins/stripe/action_refund.html | 2 +-
.../pretixplugins/stripe/control.html | 2 +-
src/pretix/plugins/stripe/views.py | 2 +-
src/tests/plugins/paypal/test_webhook.py | 268 ++++++++++++++++++
src/tests/plugins/stripe/test_webhook.py | 2 +-
10 files changed, 434 insertions(+), 9 deletions(-)
create mode 100644 src/pretix/plugins/paypal/templates/pretixplugins/paypal/action_refund.html
create mode 100644 src/tests/plugins/paypal/test_webhook.py
diff --git a/src/pretix/plugins/paypal/payment.py b/src/pretix/plugins/paypal/payment.py
index 1d328badf0..e9d34fb9a8 100644
--- a/src/pretix/plugins/paypal/payment.py
+++ b/src/pretix/plugins/paypal/payment.py
@@ -47,6 +47,13 @@ class Paypal(BasePaymentProvider):
]
)
+ def settings_content_render(self, request):
+ return "%s
%s
" % (
+ _('Please configure a PayPal Webhook to the following endpoint in order to automatically cancel orders '
+ 'when payments are refunded externally.'),
+ build_absolute_uri(self.event, 'plugins:paypal:webhook')
+ )
+
def init_api(self):
paypalrestsdk.set_config(
mode="sandbox" if "sandbox" in self.settings.get('endpoint') else 'live',
diff --git a/src/pretix/plugins/paypal/signals.py b/src/pretix/plugins/paypal/signals.py
index 38584cb0d4..d5d207c14c 100644
--- a/src/pretix/plugins/paypal/signals.py
+++ b/src/pretix/plugins/paypal/signals.py
@@ -1,9 +1,48 @@
-from django.dispatch import receiver
+import json
-from pretix.base.signals import register_payment_providers
+from django.dispatch import receiver
+from django.template.loader import get_template
+from django.utils.translation import ugettext_lazy as _
+
+from pretix.base.signals import (
+ logentry_display, register_payment_providers, requiredaction_display,
+)
@receiver(register_payment_providers, dispatch_uid="payment_paypal")
def register_payment_provider(sender, **kwargs):
from .payment import Paypal
return Paypal
+
+
+@receiver(signal=logentry_display, dispatch_uid="paypal_logentry_display")
+def pretixcontrol_logentry_display(sender, logentry, **kwargs):
+ if logentry.action_type != 'pretix.plugins.paypal.event':
+ return
+
+ data = json.loads(logentry.data)
+ event_type = data.get('event_type')
+ text = None
+ plains = {
+ 'PAYMENT.SALE.COMPLETED': _('Payment completed.'),
+ 'PAYMENT.SALE.DENIED': _('Payment denied.'),
+ 'PAYMENT.SALE.REFUNDED': _('Payment refunded.'),
+ 'PAYMENT.SALE.REVERSED': _('Payment reversed.'),
+ }
+
+ if event_type in plains:
+ text = plains[event_type]
+
+ if text:
+ return _('PayPal reported an event: {}').format(text)
+
+
+@receiver(signal=requiredaction_display, dispatch_uid="paypal_requiredaction_display")
+def pretixcontrol_action_display(sender, action, request, **kwargs):
+ if action.action_type != 'pretix.plugins.paypal.refund':
+ return
+
+ data = json.loads(action.data)
+ template = get_template('pretixplugins/paypal/action_refund.html')
+ ctx = {'data': data, 'event': sender, 'action': action}
+ return template.render(ctx, request)
diff --git a/src/pretix/plugins/paypal/templates/pretixplugins/paypal/action_refund.html b/src/pretix/plugins/paypal/templates/pretixplugins/paypal/action_refund.html
new file mode 100644
index 0000000000..dc537ade32
--- /dev/null
+++ b/src/pretix/plugins/paypal/templates/pretixplugins/paypal/action_refund.html
@@ -0,0 +1,20 @@
+{% load i18n %}
+
+
+ {% url "control:event.order" organizer=event.organizer.slug event=event.slug code=data.order as ourl %}
+ {% blocktrans trimmed with payment=data.resource.id order=""|add:data.order|add:""|safe %}
+ PayPal reported that the payment {{ payment }} has been refunded or reversed.
+ Do you want to mark the matching order ({{ order }}) as refunded?
+ {% endblocktrans %}
+
+
diff --git a/src/pretix/plugins/paypal/urls.py b/src/pretix/plugins/paypal/urls.py
index 00ee78ac9a..29fb078c2c 100644
--- a/src/pretix/plugins/paypal/urls.py
+++ b/src/pretix/plugins/paypal/urls.py
@@ -1,10 +1,17 @@
from django.conf.urls import include, url
-from .views import abort, success
+from .views import abort, refund, success, webhook
event_patterns = [
url(r'^paypal/', include([
url(r'^abort/$', abort, name='abort'),
url(r'^return/$', success, name='return'),
+ url(r'^webhook/$', webhook, name='webhook'),
])),
]
+
+
+urlpatterns = [
+ url(r'^control/event/(?P[^/]+)/(?P[^/]+)/paypal/refund/(?P\d+)/',
+ refund, name='refund'),
+]
diff --git a/src/pretix/plugins/paypal/views.py b/src/pretix/plugins/paypal/views.py
index 26aea7064f..c37980fca0 100644
--- a/src/pretix/plugins/paypal/views.py
+++ b/src/pretix/plugins/paypal/views.py
@@ -1,10 +1,19 @@
+import json
import logging
+import paypalrestsdk
from django.contrib import messages
-from django.shortcuts import redirect
+from django.db import transaction
+from django.http import HttpResponse
+from django.shortcuts import get_object_or_404, redirect
+from django.urls import reverse
from django.utils.translation import ugettext_lazy as _
+from django.views.decorators.csrf import csrf_exempt
+from django.views.decorators.http import require_POST
-from pretix.base.models import Order
+from pretix.base.models import Order, RequiredAction
+from pretix.base.services.orders import mark_order_paid, mark_order_refunded
+from pretix.control.permissions import event_permission_required
from pretix.multidomain.urlreverse import eventreverse
from pretix.plugins.paypal.payment import Paypal
from pretix.presale.utils import event_view
@@ -49,3 +58,78 @@ def success(request, *args, **kwargs):
def abort(request, *args, **kwargs):
messages.error(request, _('It looks like you canceled the PayPal payment'))
return redirect(eventreverse(request.event, 'presale:event.checkout', kwargs={'step': 'payment'}))
+
+
+@csrf_exempt
+@require_POST
+@event_view(require_live=False)
+def webhook(request, *args, **kwargs):
+ event_body = request.body.decode('utf-8').strip()
+ event_json = json.loads(event_body)
+
+ prov = Paypal(request.event)
+ prov.init_api()
+
+ # We do not check the signature, we just use it as a trigger to look the charge up.
+ if event_json['resource_type'] != 'sale':
+ return HttpResponse("Not interested in this resource type", status=200)
+
+ try:
+ sale = paypalrestsdk.Sale.find(event_json['resource']['id'])
+ except:
+ logger.exception('PayPal error on webhook. Event data: %s' % str(event_json))
+ return HttpResponse('Sale not found', status=500)
+
+ orders = Order.objects.filter(event=request.event, payment_provider='paypal',
+ payment_info__icontains=sale['id'])
+ order = None
+ for o in orders:
+ payment_info = json.loads(o.payment_info)
+ for res in payment_info['transactions'][0]['related_resources']:
+ for k, v in res.items():
+ if k == 'sale' and v['id'] == sale['id']:
+ order = o
+ break
+
+ if not order:
+ return HttpResponse('Order not found', status=200)
+
+ order.log_action('pretix.plugins.paypal.event', data=event_json)
+
+ if order.status == Order.STATUS_PAID and sale['state'] in ('partially_refunded', 'refunded'):
+ RequiredAction.objects.create(
+ event=request.event, action_type='pretix.plugins.paypal.refund', data=json.dumps({
+ 'order': order.code,
+ 'sale': sale['id']
+ })
+ )
+ elif order.status == Order.STATUS_PENDING and sale['state'] == 'completed':
+ mark_order_paid(order, user=None)
+
+ return HttpResponse(status=200)
+
+
+@event_permission_required('can_view_orders')
+@require_POST
+def refund(request, **kwargs):
+ with transaction.atomic():
+ action = get_object_or_404(RequiredAction, event=request.event, pk=kwargs.get('id'),
+ action_type='pretix.plugins.paypal.refund', done=False)
+ data = json.loads(action.data)
+ action.done = True
+ action.user = request.user
+ action.save()
+ order = get_object_or_404(Order, event=request.event, code=data['order'])
+ if order.status != Order.STATUS_PAID:
+ messages.error(request, _('The order cannot be marked as refunded as it is not marked as paid!'))
+ else:
+ mark_order_refunded(order, user=request.user)
+ messages.success(
+ request, _('The order has been marked as refunded and the issue has been marked as resolved!')
+ )
+
+ return redirect(reverse('control:event.order', kwargs={
+ 'organizer': request.event.organizer.slug,
+ 'event': request.event.slug,
+ 'code': data['order']
+ }))
diff --git a/src/pretix/plugins/stripe/templates/pretixplugins/stripe/action_refund.html b/src/pretix/plugins/stripe/templates/pretixplugins/stripe/action_refund.html
index 8edb969315..919596b130 100644
--- a/src/pretix/plugins/stripe/templates/pretixplugins/stripe/action_refund.html
+++ b/src/pretix/plugins/stripe/templates/pretixplugins/stripe/action_refund.html
@@ -4,7 +4,7 @@
{% url "control:event.order" organizer=event.organizer.slug event=event.slug code=data.order as ourl %}
{% blocktrans trimmed with charge=data.charge stripe_href="href='https://dashboard.stripe.com/payments/"|add:data.charge|add:"' target='_blank'"|safe order=""|add:data.order|add:""|safe %}
Stripe reported that the transaction {{ charge }} has been refunded.
- Do you want to refund the matching order ({{ order }}) as well?
+ Do you want to refund mark the matching order ({{ order }}) as refunded?
{% endblocktrans %}