diff --git a/src/pretix/plugins/paypal/migrations/0001_initial.py b/src/pretix/plugins/paypal/migrations/0001_initial.py new file mode 100644 index 0000000000..7656b7679f --- /dev/null +++ b/src/pretix/plugins/paypal/migrations/0001_initial.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.3 on 2017-07-23 10:24 +from __future__ import unicode_literals + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('pretixbase', '0070_auto_20170719_0910'), + ] + + operations = [ + migrations.CreateModel( + name='ReferencedPayPalObject', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('reference', models.CharField(db_index=True, max_length=190, unique=True)), + ('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='pretixbase.Order')), + ], + ), + ] diff --git a/src/pretix/plugins/paypal/migrations/__init__.py b/src/pretix/plugins/paypal/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/pretix/plugins/paypal/models.py b/src/pretix/plugins/paypal/models.py new file mode 100644 index 0000000000..5e01d52829 --- /dev/null +++ b/src/pretix/plugins/paypal/models.py @@ -0,0 +1,6 @@ +from django.db import models + + +class ReferencedPayPalObject(models.Model): + reference = models.CharField(max_length=190, db_index=True, unique=True) + order = models.ForeignKey('pretixbase.Order') diff --git a/src/pretix/plugins/paypal/payment.py b/src/pretix/plugins/paypal/payment.py index 08382a671d..63637d401b 100644 --- a/src/pretix/plugins/paypal/payment.py +++ b/src/pretix/plugins/paypal/payment.py @@ -12,7 +12,9 @@ from pretix.base.models import Order, Quota, RequiredAction from pretix.base.payment import BasePaymentProvider, PaymentException from pretix.base.services.mail import SendMailException from pretix.base.services.orders import mark_order_paid, mark_order_refunded +from pretix.helpers.urls import build_absolute_uri as build_global_uri from pretix.multidomain.urlreverse import build_absolute_uri +from pretix.plugins.paypal.models import ReferencedPayPalObject logger = logging.getLogger('pretix.plugins.paypal') @@ -55,7 +57,7 @@ class Paypal(BasePaymentProvider): 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') + build_global_uri('plugins:paypal:webhook') ) def init_api(self): @@ -154,6 +156,7 @@ class Paypal(BasePaymentProvider): self.init_api() payment = paypalrestsdk.Payment.find(request.session.get('payment_paypal_id')) + ReferencedPayPalObject.objects.get_or_create(order=order, reference=payment.id) if str(payment.transactions[0].amount.total) != str(order.total) or payment.transactions[0].amount.currency != \ self.event.currency: logger.error('Value mismatch: Order %s vs payment %s' % (order.id, str(payment))) diff --git a/src/pretix/plugins/paypal/urls.py b/src/pretix/plugins/paypal/urls.py index 29fb078c2c..f1faa3a44c 100644 --- a/src/pretix/plugins/paypal/urls.py +++ b/src/pretix/plugins/paypal/urls.py @@ -1,12 +1,12 @@ from django.conf.urls import include, url -from .views import abort, refund, success, webhook +from .views import abort, event_webbook, 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'), + url(r'^webhook/$', event_webbook, name='webhook'), ])), ] @@ -14,4 +14,5 @@ event_patterns = [ urlpatterns = [ url(r'^control/event/(?P[^/]+)/(?P[^/]+)/paypal/refund/(?P\d+)/', refund, name='refund'), + url(r'^_paypal/webhook/$', webhook, name='webhook'), ] diff --git a/src/pretix/plugins/paypal/views.py b/src/pretix/plugins/paypal/views.py index 3f6dc4d5d2..3568b3ad44 100644 --- a/src/pretix/plugins/paypal/views.py +++ b/src/pretix/plugins/paypal/views.py @@ -16,6 +16,7 @@ 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.plugins.stripe.models import ReferencedStripeObject from pretix.presale.utils import event_view logger = logging.getLogger('pretix.plugins.paypal') @@ -74,28 +75,44 @@ def abort(request, *args, **kwargs): @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'] not in ('sale', 'refund'): return HttpResponse("Not interested in this resource type", status=200) + if event_json['resource_type'] == 'sale': + saleid = event_json['resource']['id'] + else: + saleid = event_json['resource']['sale_id'] + try: - if event_json['resource_type'] == 'sale': - sale = paypalrestsdk.Sale.find(event_json['resource']['id']) + refs = [saleid] + if event_json['resource'].get('parent_payment'): + refs.append(event_json['resource'].get('parent_payment')) + + rso = ReferencedStripeObject.objects.select_related('order', 'order__event').get( + reference__in=refs + ) + event = rso.order.event + except ReferencedStripeObject.DoesNotExist: + if hasattr(request, 'event'): + event = request.event else: - sale = paypalrestsdk.Sale.find(event_json['resource']['sale_id']) + return HttpResponse("Unable to detect event", status=200) + + prov = Paypal(event) + prov.init_api() + + try: + sale = paypalrestsdk.Sale.find(saleid) 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', + orders = Order.objects.filter(event=event, payment_provider='paypal', payment_info__icontains=sale['id']) order = None for o in orders: @@ -113,7 +130,7 @@ def webhook(request, *args, **kwargs): 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({ + event=event, action_type='pretix.plugins.paypal.refund', data=json.dumps({ 'order': order.code, 'sale': sale['id'] }) @@ -122,10 +139,10 @@ def webhook(request, *args, **kwargs): try: mark_order_paid(order, user=None) except Quota.QuotaExceededException: - if not RequiredAction.objects.filter(event=request.event, action_type='pretix.plugins.paypal.overpaid', + if not RequiredAction.objects.filter(event=event, action_type='pretix.plugins.paypal.overpaid', data__icontains=order.code).exists(): RequiredAction.objects.create( - event=request.event, action_type='pretix.plugins.paypal.overpaid', data=json.dumps({ + event=event, action_type='pretix.plugins.paypal.overpaid', data=json.dumps({ 'order': order.code, 'payment': sale['parent_payment'] }) @@ -134,6 +151,9 @@ def webhook(request, *args, **kwargs): return HttpResponse(status=200) +event_webbook = csrf_exempt(event_view(require_live=False)(webhook)) + + @event_permission_required('can_view_orders') @require_POST def refund(request, **kwargs): diff --git a/src/tests/plugins/paypal/test_webhook.py b/src/tests/plugins/paypal/test_webhook.py index cd422bdea7..0f796082aa 100644 --- a/src/tests/plugins/paypal/test_webhook.py +++ b/src/tests/plugins/paypal/test_webhook.py @@ -8,6 +8,7 @@ from django.utils.timezone import now from pretix.base.models import ( Event, Order, Organizer, RequiredAction, Team, User, ) +from pretix.plugins.stripe.models import ReferencedStripeObject @pytest.fixture @@ -185,6 +186,46 @@ def test_webhook_all_good(env, client, monkeypatch): assert order.status == Order.STATUS_PAID +@pytest.mark.django_db +def test_webhook_global(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) + ReferencedStripeObject.objects.create(order=order, reference="PAY-5YK922393D847794YKER7MUI") + + client.post('/_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_mark_paid(env, client, monkeypatch): order = env[1] diff --git a/src/tests/plugins/stripe/test_webhook.py b/src/tests/plugins/stripe/test_webhook.py index b805855bf5..3dd7f8a2fb 100644 --- a/src/tests/plugins/stripe/test_webhook.py +++ b/src/tests/plugins/stripe/test_webhook.py @@ -219,7 +219,7 @@ def test_webhook_partial_refund(env, client, monkeypatch): @pytest.mark.django_db -def test_webhook_organizer_level(env, client, monkeypatch): +def test_webhook_global(env, client, monkeypatch): order = env[1] order.status = Order.STATUS_PENDING order.save()