diff --git a/src/pretix/plugins/paypal/__init__.py b/src/pretix/plugins/paypal/__init__.py new file mode 100644 index 0000000000..ee5b78be2b --- /dev/null +++ b/src/pretix/plugins/paypal/__init__.py @@ -0,0 +1,21 @@ +from django.apps import AppConfig +from django.utils.translation import ugettext_lazy as _ +from pretix.base.plugins import PluginType + + +class PaypalApp(AppConfig): + name = 'pretix.plugins.paypal' + verbose_name = _("Stripe") + + class PretixPluginMeta: + type = PluginType.PAYMENT + name = _("PayPal") + author = _("the pretix team") + version = '1.0.0' + description = _("This plugin allows you to receive payments via PayPal") + + def ready(self): + from . import signals # NOQA + + +default_app_config = 'pretix.plugins.paypal.PaypalApp' diff --git a/src/pretix/plugins/paypal/payment.py b/src/pretix/plugins/paypal/payment.py new file mode 100644 index 0000000000..c81d6e334f --- /dev/null +++ b/src/pretix/plugins/paypal/payment.py @@ -0,0 +1,188 @@ +from collections import OrderedDict +import json +import logging +from django.contrib import messages +from django.core.urlresolvers import reverse +from django.template import Context +from django.template.loader import get_template +from django.utils.functional import cached_property +from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import ugettext as __ +from django import forms + +import paypalrestsdk + +from pretix.base.payment import BasePaymentProvider + + +logger = logging.getLogger('pretix.plugins.paypal') + + +class Paypal(BasePaymentProvider): + + identifier = 'paypal' + verbose_name = _('PayPal') + settings_form_fields = OrderedDict([ + ('client_id', + forms.CharField( + label=_('Client ID'), + required=False + )), + ('endpoint', + forms.CharField( + label=_('Endpoint'), + initial='api.paypal.com', + required=False + )), + ('secret', + forms.CharField( + label=_('Secret'), + required=False + )) + ]) + checkout_form_fields = OrderedDict([ + ]) + + def init_api(self): + paypalrestsdk.set_config( + mode="sandbox" if "sandbox" in self.settings.get('endpoint') else 'live', + client_id=self.settings.get('client_id'), + client_secret=self.settings.get('secret')) + + def checkout_is_valid_session(self, request): + return (request.session.get('payment_paypal_id', '') != '' + and request.session.get('payment_paypal_payer', '') != '') + + def checkout_form_render(self, request) -> str: + template = get_template('pretixplugins/paypal/checkout_payment_form.html') + ctx = Context({'request': request, 'event': self.event, 'settings': self.settings}) + return template.render(ctx) + + def checkout_prepare(self, request, cart): + self.init_api() + items = [] + for cp in cart['positions']: + items.append({ + "name": cp.item.name, + "description": str(cp.variation) if cp.variation else "", + "quantity": cp.count, + "price": str(cp.price), + "currency": request.event.currency + }) + if cart['payment_fee']: + items.append({ + "name": __('Payment method fee'), + "description": "", + "quantity": 1, + "currency": request.event.currency, + "price": str(cart['payment_fee']) + }) + payment = paypalrestsdk.Payment({ + 'intent': 'sale', + 'payer': { + "payment_method": "paypal", + }, + "redirect_urls": { + "return_url": request.build_absolute_uri(reverse('plugins:paypal.return')), + "cancel_url": request.build_absolute_uri(reverse('plugins:paypal.abort')), + }, + "transactions": [ + { + "item_list": { + "items": items + }, + "amount": { + "currency": request.event.currency, + "total": str(cart['total']) + }, + "description": __('Event tickets for %s') % request.event.name + } + ] + }) + return self._create_payment(request, payment) + + def _create_payment(self, request, payment): + if payment.create(): + if payment.state not in ('created', 'approved', 'pending'): + messages.error(request, _('We had trouble communicating with PayPal')) + logger.error('Invalid payment state: ' + str(payment)) + return + request.session['payment_paypal_id'] = payment.id + request.session['payment_paypal_event'] = self.event.id + for link in payment.links: + if link.method == "REDIRECT" and link.rel == "approval_url": + return str(link.href) + else: + messages.error(request, _('We had trouble communicating with PayPal')) + logger.error('Error on creating payment: ' + str(payment.error)) + + def checkout_confirm_render(self, request) -> str: + """ + Returns the HTML that should be displayed when the user selected this provider + on the 'confirm order' page. + """ + template = get_template('pretixplugins/paypal/checkout_payment_confirm.html') + ctx = Context({'request': request, 'event': self.event, 'settings': self.settings}) + return template.render(ctx) + + def checkout_perform(self, request, order) -> str: + """ + Will be called if the user submitted his order successfully to initiate the + payment process. + + It should return a custom redirct URL, if you need special behaviour, or None to + continue with default behaviour. + + On errors, it should use Django's message framework to display an error message + to the user (or the normal form validation error messages). + + :param order: The order object + """ + if (request.session.get('payment_paypal_id', '') == '' + or request.session.get('payment_paypal_payer', '') == ''): + messages.error(request, _('We were unable to process your payment. See below for details on how to ' + 'proceed.')) + + self.init_api() + payment = paypalrestsdk.Payment.find(request.session.get('payment_paypal_id')) + if str(payment.transactions[0].amount.total) != str(order.total) or payment.transactions[1].amount.currency != \ + self.event.currency: + messages.error(request, _('We were unable to process your payment. See below for details on how to ' + 'proceed.')) + logger.error('Value mismatch: Order %s vs payment %s' % (order.id, str(payment))) + return + + return self._execute_payment(payment, request, order) + + def _execute_payment(self, payment, request, order): + payment.execute({"payer_id": request.session.get('payment_paypal_payer')}) + + if payment.state == 'pending': + messages.warning(request, _('PayPal has not yet approved the payment. We will inform you as soon as the ' + 'payment completed.')) + order = order.clone() + order.payment_info = str(payment) + order.save() + return + + if payment.state != 'approved': + messages.error(request, _('We were unable to process your payment. See below for details on how to ' + 'proceed.')) + logger.error('Invalid state: %s' % str(payment)) + return + + order.mark_paid('paypal', str(payment)) + messages.success(request, _('We successfully received your payment. Thank you!')) + return None + + def order_pending_render(self, request, order) -> str: + retry = True + try: + if order.payment_info and json.loads(order.payment_info)['state'] != 'pending': + retry = False + except KeyError: + pass + template = get_template('pretixplugins/paypal/pending.html') + ctx = Context({'request': request, 'event': self.event, 'settings': self.settings, + 'retry': retry, 'order': order}) + return template.render(ctx) diff --git a/src/pretix/plugins/paypal/signals.py b/src/pretix/plugins/paypal/signals.py new file mode 100644 index 0000000000..28c49f2267 --- /dev/null +++ b/src/pretix/plugins/paypal/signals.py @@ -0,0 +1,10 @@ +from django.dispatch import receiver + +from pretix.base.signals import register_payment_providers + +from .payment import Paypal + + +@receiver(register_payment_providers) +def register_payment_provider(sender, **kwargs): + return Paypal diff --git a/src/pretix/plugins/paypal/templates/pretixplugins/paypal/checkout_payment_confirm.html b/src/pretix/plugins/paypal/templates/pretixplugins/paypal/checkout_payment_confirm.html new file mode 100644 index 0000000000..2420f5561f --- /dev/null +++ b/src/pretix/plugins/paypal/templates/pretixplugins/paypal/checkout_payment_confirm.html @@ -0,0 +1,6 @@ +{% load i18n %} + +

{% blocktrans trimmed %} + The total amount listed above will be withdrawn from your PayPal acocunt after the + confirmation of your purchase. +{% endblocktrans %}

diff --git a/src/pretix/plugins/paypal/templates/pretixplugins/paypal/checkout_payment_form.html b/src/pretix/plugins/paypal/templates/pretixplugins/paypal/checkout_payment_form.html new file mode 100644 index 0000000000..3ec5898118 --- /dev/null +++ b/src/pretix/plugins/paypal/templates/pretixplugins/paypal/checkout_payment_form.html @@ -0,0 +1,6 @@ +{% load i18n %} + +

{% blocktrans trimmed %} + After you clicked continue, we will redirect you to PayPal to fill in your payment + details. You will then be redirected back here to review and confirm your order. +{% endblocktrans %}

diff --git a/src/pretix/plugins/paypal/templates/pretixplugins/paypal/pending.html b/src/pretix/plugins/paypal/templates/pretixplugins/paypal/pending.html new file mode 100644 index 0000000000..7d6ed09e8a --- /dev/null +++ b/src/pretix/plugins/paypal/templates/pretixplugins/paypal/pending.html @@ -0,0 +1,15 @@ +{% load i18n %} + +{% if retry %} +

{% blocktrans trimmed %} + Our attempt to execute your Payment via PayPal has failed. Please try again or contact us. + {% endblocktrans %}

+

+ {% trans "Try again" %} +

+{% else %} +

{% blocktrans trimmed %} + We're waiting for an answer from PayPal regarding your payment. Please contact us, if this + takes more than a few hours. + {% endblocktrans %}

+{% endif %} diff --git a/src/pretix/plugins/paypal/urls.py b/src/pretix/plugins/paypal/urls.py new file mode 100644 index 0000000000..1c6e5f4cc5 --- /dev/null +++ b/src/pretix/plugins/paypal/urls.py @@ -0,0 +1,12 @@ +from django.conf.urls import url, include + +from .views import success, abort, retry + + +urlpatterns = [ + url(r'^paypal/', include([ + url(r'^abort/$', abort, name='paypal.abort'), + url(r'^return/$', success, name='paypal.return'), + url(r'^retry/(?P[^/]+)/', retry, name='paypal.retry') + ])), +] diff --git a/src/pretix/plugins/paypal/views.py b/src/pretix/plugins/paypal/views.py new file mode 100644 index 0000000000..3d5714b391 --- /dev/null +++ b/src/pretix/plugins/paypal/views.py @@ -0,0 +1,111 @@ +import logging +from django.contrib import messages +from django.contrib.auth.decorators import login_required +from django.core.urlresolvers import reverse +from django.shortcuts import redirect +import paypalrestsdk +from pretix.base.models import Event, Order +from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import ugettext as __ +from pretix.base.settings import SettingsSandbox +from pretix.plugins.paypal.payment import Paypal + + +logger = logging.getLogger('pretix.plugins.paypal') + + +@login_required +def success(request): + pid = request.GET.get('paymentId') + token = request.GET.get('token') + payer = request.GET.get('PayerID') + if pid == request.session['payment_paypal_id']: + request.session['payment_paypal_token'] = token + request.session['payment_paypal_payer'] = payer + try: + event = Event.objects.current.get(identity=request.session['payment_paypal_event']) + return redirect(reverse('presale:event.checkout.confirm', kwargs={ + 'event': event.slug, + 'organizer': event.organizer.slug, + })) + except Event.DoesNotExist: + pass # TODO: Handle this + else: + pass # TODO: Handle this + + +@login_required +def abort(request): + messages.error(request, _('It looks like you cancelled the PayPal payment')) + try: + event = Event.objects.current.get(identity=request.session['payment_paypal_event']) + return redirect(reverse('presale:event.checkout.payment', kwargs={ + 'event': event.slug, + 'organizer': event.organizer.slug, + })) + except Event.DoesNotExist: + pass # TODO: Handle this + + +@login_required +def retry(request, order): + try: + order = Order.objects.current.get( + user=request.user, + code=order, + ) + except Order.DoesNotExist: + return # TODO: Handle this + + provider = Paypal(order.event) + provider.init_api() + + if 'token' in request.GET: + if 'PayerID' in request.GET: + payment = paypalrestsdk.Payment.find(request.session.get('payment_paypal_id')) + provider._execute_payment(payment, request, order) + else: + messages.error(request, _('It looks like you cancelled the PayPal payment')) + else: + payment = paypalrestsdk.Payment({ + 'intent': 'sale', + 'payer': { + "payment_method": "paypal", + }, + "redirect_urls": { + "return_url": request.build_absolute_uri(reverse('plugins:paypal.retry', kwargs={ + 'order': order.code + })), + "cancel_url": request.build_absolute_uri(reverse('plugins:paypal.retry', kwargs={ + 'order': order.code + })), + }, + "transactions": [ + { + "item_list": { + "items": [ + { + "name": 'Order %s' % order.code, + "quantity": 1, + "price": str(order.total), + "currency": order.event.currency + } + ] + }, + "amount": { + "currency": order.event.currency, + "total": str(order.total) + }, + "description": __('Event tickets for %s') % order.event.name + } + ] + }) + resp = provider._create_payment(request, payment) + if resp: + return redirect(resp) + + return redirect(reverse('presale:event.order', kwargs={ + 'event': order.event.slug, + 'organizer': order.event.organizer.slug, + 'order': order.code, + })) diff --git a/src/pretix/settings.py b/src/pretix/settings.py index d7706fb7b0..472b9ef494 100644 --- a/src/pretix/settings.py +++ b/src/pretix/settings.py @@ -48,6 +48,7 @@ INSTALLED_APPS = ( 'pretix.plugins.timerestriction', 'pretix.plugins.banktransfer', 'pretix.plugins.stripe', + 'pretix.plugins.paypal', ) MIDDLEWARE_CLASSES = ( diff --git a/src/requirements/paypal.txt b/src/requirements/paypal.txt new file mode 100644 index 0000000000..19b7672089 --- /dev/null +++ b/src/requirements/paypal.txt @@ -0,0 +1,2 @@ +paypalrestsdk +