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 %} +

+
+ {% csrf_token %} + + {% trans "No" %} + +   + {% trans "This action cannot be undone." %} +
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 %}

diff --git a/src/pretix/plugins/stripe/templates/pretixplugins/stripe/control.html b/src/pretix/plugins/stripe/templates/pretixplugins/stripe/control.html index 70aad8f85d..635a5de259 100644 --- a/src/pretix/plugins/stripe/templates/pretixplugins/stripe/control.html +++ b/src/pretix/plugins/stripe/templates/pretixplugins/stripe/control.html @@ -1,4 +1,4 @@ -{% load i18n %} + url(r'^webhook/$', webhook, name='webhook'),{% load i18n %} {% if payment_info %} {% if order.status == "p" %} diff --git a/src/pretix/plugins/stripe/views.py b/src/pretix/plugins/stripe/views.py index ef3924eff2..ff84cbf2b0 100644 --- a/src/pretix/plugins/stripe/views.py +++ b/src/pretix/plugins/stripe/views.py @@ -54,7 +54,7 @@ def webhook(request, *args, **kwargs): return HttpResponse('Not interested in this event', status=200) try: - order = request.event.orders.get(id=metadata['order']) + order = request.event.orders.get(id=metadata['order'], payment_provider='stripe') except Order.DoesNotExist: return HttpResponse('Order not found', status=200) diff --git a/src/tests/plugins/paypal/test_webhook.py b/src/tests/plugins/paypal/test_webhook.py new file mode 100644 index 0000000000..1231b3bc7d --- /dev/null +++ b/src/tests/plugins/paypal/test_webhook.py @@ -0,0 +1,268 @@ +import json +from datetime import timedelta +from decimal import Decimal + +import pytest +from django.utils.timezone import now + +from pretix.base.models import ( + Event, EventPermission, Order, Organizer, RequiredAction, User, +) + + +@pytest.fixture +def env(): + user = User.objects.create_user('dummy@dummy.dummy', 'dummy') + o = Organizer.objects.create(name='Dummy', slug='dummy') + event = Event.objects.create( + organizer=o, name='Dummy', slug='dummy', + date_from=now(), live=True + ) + EventPermission.objects.create(event=event, user=user) + o1 = Order.objects.create( + code='FOOBAR', event=event, email='dummy@dummy.test', + status=Order.STATUS_PAID, + datetime=now(), expires=now() + timedelta(days=10), + total=Decimal('13.37'), payment_provider='paypal', + payment_info=json.dumps({ + "id": "PAY-5YK922393D847794YKER7MUI", + "create_time": "2013-02-19T22:01:53Z", + "update_time": "2013-02-19T22:01:55Z", + "state": "approved", + "intent": "sale", + "payer": { + "payment_method": "credit_card", + "funding_instruments": [ + { + "credit_card": { + "type": "mastercard", + "number": "xxxxxxxxxxxx5559", + "expire_month": 2, + "expire_year": 2018, + "first_name": "Betsy", + "last_name": "Buyer" + } + } + ] + }, + "transactions": [ + { + "amount": { + "total": "7.47", + "currency": "USD", + "details": { + "subtotal": "7.47" + } + }, + "description": "This is the payment transaction description.", + "note_to_payer": "Contact us for any questions on your order.", + "related_resources": [ + { + "sale": { + "id": "36C38912MN9658832", + "create_time": "2013-02-19T22:01:53Z", + "update_time": "2013-02-19T22:01:55Z", + "state": "completed", + "amount": { + "total": "7.47", + "currency": "USD" + }, + "protection_eligibility": "ELIGIBLE", + "protection_eligibility_type": "ITEM_NOT_RECEIVED_ELIGIBLE", + "transaction_fee": { + "value": "1.75", + "currency": "USD" + }, + "parent_payment": "PAY-5YK922393D847794YKER7MUI", + "links": [ + { + "href": "https://api.paypal.com/v1/payments/sale/36C38912MN9658832", + "rel": "self", + "method": "GET" + }, + { + "href": "https://api.paypal.com/v1/payments/sale/36C38912MN9658832/refund", + "rel": "refund", + "method": "POST" + }, + { + "href": + "https://api.paypal.com/v1/payments/payment/PAY-5YK922393D847794YKER7MUI", + "rel": "parent_payment", + "method": "GET" + } + ] + } + } + ] + } + ], + "links": [ + { + "href": "https://api.paypal.com/v1/payments/payment/PAY-5YK922393D847794YKER7MUI", + "rel": "self", + "method": "GET" + } + ] + + }) + ) + return event, o1 + + +def get_test_charge(order: Order): + return { + "id": "36C38912MN9658832", + "create_time": "2013-02-19T22:01:53Z", + "update_time": "2013-02-19T22:01:55Z", + "state": "completed", + "amount": { + "total": "7.47", + "currency": "USD" + }, + "protection_eligibility": "ELIGIBLE", + "protection_eligibility_type": "ITEM_NOT_RECEIVED_ELIGIBLE,UNAUTHORIZED_PAYMENT_ELIGIBLE", + "transaction_fee": { + "value": "1.75", + "currency": "USD" + }, + "parent_payment": "PAY-5YK922393D847794YKER7MUI", + "links": [ + { + "href": "https://api.paypal.com/v1/payments/sale/36C38912MN9658832", + "rel": "self", + "method": "GET" + }, + { + "href": "https://api.paypal.com/v1/payments/sale/36C38912MN9658832/refund", + "rel": "refund", + "method": "POST" + }, + { + "href": "https://api.paypal.com/v1/payments/payment/PAY-5YK922393D847794YKER7MUI", + "rel": "parent_payment", + "method": "GET" + } + ] + } + + +@pytest.mark.django_db +def test_webhook_all_good(env, client, monkeypatch): + charge = get_test_charge(env[1]) + monkeypatch.setattr("paypalrestsdk.Sale.find", lambda *args: charge) + monkeypatch.setattr("pretix.plugins.paypal.payment.Paypal.init_api", lambda *args: None) + + client.post('/dummy/dummy/paypal/webhook/', json.dumps( + { + "id": "WH-2WR32451HC0233532-67976317FL4543714", + "create_time": "2014-10-23T17:23:52Z", + "resource_type": "sale", + "event_type": "PAYMENT.SALE.COMPLETED", + "summary": "A successful sale payment was made for $ 0.48 USD", + "resource": { + "amount": { + "total": "-0.01", + "currency": "USD" + }, + "id": "36C38912MN9658832", + "parent_payment": "PAY-5YK922393D847794YKER7MUI", + "update_time": "2014-10-31T15:41:51Z", + "state": "completed", + "create_time": "2014-10-31T15:41:51Z", + "links": [], + "sale_id": "9T0916710M1105906" + }, + "links": [], + "event_version": "1.0" + } + ), content_type='application_json') + + order = env[1] + order.refresh_from_db() + assert order.status == Order.STATUS_PAID + + +@pytest.mark.django_db +def test_webhook_mark_paid(env, client, monkeypatch): + order = env[1] + order.status = Order.STATUS_PENDING + order.save() + + charge = get_test_charge(env[1]) + monkeypatch.setattr("paypalrestsdk.Sale.find", lambda *args: charge) + monkeypatch.setattr("pretix.plugins.paypal.payment.Paypal.init_api", lambda *args: None) + + client.post('/dummy/dummy/paypal/webhook/', json.dumps( + { + "id": "WH-2WR32451HC0233532-67976317FL4543714", + "create_time": "2014-10-23T17:23:52Z", + "resource_type": "sale", + "event_type": "PAYMENT.SALE.COMPLETED", + "summary": "A successful sale payment was made for $ 0.48 USD", + "resource": { + "amount": { + "total": "-0.01", + "currency": "USD" + }, + "id": "36C38912MN9658832", + "parent_payment": "PAY-5YK922393D847794YKER7MUI", + "update_time": "2014-10-31T15:41:51Z", + "state": "completed", + "create_time": "2014-10-31T15:41:51Z", + "links": [], + "sale_id": "9T0916710M1105906" + }, + "links": [], + "event_version": "1.0" + } + ), content_type='application_json') + + order.refresh_from_db() + assert order.status == Order.STATUS_PAID + + +@pytest.mark.django_db +def test_webhook_partial_refund(env, client, monkeypatch): + charge = get_test_charge(env[1]) + charge['state'] = 'refunded' + + monkeypatch.setattr("paypalrestsdk.Sale.find", lambda *args: charge) + monkeypatch.setattr("pretix.plugins.paypal.payment.Paypal.init_api", lambda *args: None) + + client.post('/dummy/dummy/paypal/webhook/', json.dumps( + { + "id": "WH-2N242548W9943490U-1JU23391CS4765624", + "create_time": "2014-10-31T15:42:24Z", + "resource_type": "sale", + "event_type": "PAYMENT.SALE.REFUNDED", + "summary": "A 0.01 USD sale payment was refunded", + "resource": { + "amount": { + "total": "-0.01", + "currency": "USD" + }, + "id": "36C38912MN9658832", + "parent_payment": "PAY-5YK922393D847794YKER7MUI", + "update_time": "2014-10-31T15:41:51Z", + "state": "completed", + "create_time": "2014-10-31T15:41:51Z", + "links": [], + "sale_id": "9T0916710M1105906" + }, + "links": [], + "event_version": "1.0" + } + ), content_type='application_json') + + order = env[1] + order.refresh_from_db() + assert order.status == Order.STATUS_PAID + + ra = RequiredAction.objects.get(action_type="pretix.plugins.paypal.refund") + client.login(username='dummy@dummy.dummy', password='dummy') + client.post('/control/event/dummy/dummy/paypal/refund/{}/'.format(ra.pk)) + + order = env[1] + order.refresh_from_db() + assert order.status == Order.STATUS_REFUNDED diff --git a/src/tests/plugins/stripe/test_webhook.py b/src/tests/plugins/stripe/test_webhook.py index 2d65525ecc..3a12e6fa9a 100644 --- a/src/tests/plugins/stripe/test_webhook.py +++ b/src/tests/plugins/stripe/test_webhook.py @@ -23,7 +23,7 @@ def env(): code='FOOBAR', event=event, email='dummy@dummy.test', status=Order.STATUS_PAID, datetime=now(), expires=now() + timedelta(days=10), - total=Decimal('13.37'), payment_provider='banktransfer' + total=Decimal('13.37'), payment_provider='stripe' ) return event, o1