diff --git a/doc/development/api/payment.rst b/doc/development/api/payment.rst index 59c109e8d6..e9b95de938 100644 --- a/doc/development/api/payment.rst +++ b/doc/development/api/payment.rst @@ -68,6 +68,8 @@ The provider class .. automethod:: is_allowed + .. automethod:: is_allowed_for_order + .. autoattribute:: payment_form_fields .. automethod:: checkout_prepare @@ -86,9 +88,11 @@ The provider class This is an abstract method, you **must** override this! + .. automethod:: order_change_allowed + .. automethod:: order_can_retry - .. automethod:: retry_prepare + .. automethod:: order_prepare .. automethod:: order_paid_render diff --git a/src/pretix/base/payment.py b/src/pretix/base/payment.py index 9d6ebb8c42..3b8f636969 100644 --- a/src/pretix/base/payment.py +++ b/src/pretix/base/payment.py @@ -314,20 +314,42 @@ class BasePaymentProvider: """ raise NotImplementedError() # NOQA + def order_change_allowed(self, order: Order) -> bool: + """ + Will be called to check whether it is allowed to change the payment method of + an order to this one. + + The default implementation always returns ``True``. + + :param order: The order object + """ + return True + def order_can_retry(self, order: Order) -> bool: """ Will be called if the user views the detail page of an unpaid order to determine whether the user should be presented with an option to retry the payment. The default implementation always returns False. + The retry workflow is also used if a user switches to this payment method for an existing + order! Therefore, they can only switch to your p + :param order: The order object """ return False def retry_prepare(self, request: HttpRequest, order: Order) -> "bool|str": + """ + Deprecated, use order_prepare instead + """ + raise DeprecationWarning('retry_prepare is deprecated, use order_prepare instead') + return self.order_prepare(request, order) + + def order_prepare(self, request: HttpRequest, order: Order) -> "bool|str": """ Will be called if the user retries to pay an unpaid order (after the user filled in - e.g. the form returned by :py:meth:`payment_form`). + e.g. the form returned by :py:meth:`payment_form`) or if the user changes the payment + method. It should return and report errors the same way as :py:meth:`checkout_prepare`, but receives an ``Order`` object instead of a cart object. @@ -469,6 +491,9 @@ class FreeOrderProvider(BasePaymentProvider): cart_id=request.session.session_key, event=request.event ).aggregate(sum=Sum('price'))['sum'] == 0 + def order_change_allowed(self, order: Order) -> bool: + return False + @receiver(register_payment_providers, dispatch_uid="payment_free") def register_payment_provider(sender, **kwargs): diff --git a/src/pretix/plugins/paypal/payment.py b/src/pretix/plugins/paypal/payment.py index 4c157aac20..e0ceca7531 100644 --- a/src/pretix/plugins/paypal/payment.py +++ b/src/pretix/plugins/paypal/payment.py @@ -246,7 +246,7 @@ class Paypal(BasePaymentProvider): def order_can_retry(self, order): return True - def retry_prepare(self, request, order): + def order_prepare(self, request, order): self.init_api() payment = paypalrestsdk.Payment({ 'intent': 'sale', diff --git a/src/pretix/plugins/stripe/payment.py b/src/pretix/plugins/stripe/payment.py index 4b98b59f0c..bea5a3a16f 100644 --- a/src/pretix/plugins/stripe/payment.py +++ b/src/pretix/plugins/stripe/payment.py @@ -46,7 +46,7 @@ class Stripe(BasePaymentProvider): def payment_is_valid_session(self, request): return request.session.get('payment_stripe_token') != '' - def retry_prepare(self, request, order): + def order_prepare(self, request, order): return self.checkout_prepare(request, None) def checkout_prepare(self, request, cart): diff --git a/src/pretix/presale/templates/pretixpresale/event/order.html b/src/pretix/presale/templates/pretixpresale/event/order.html index 3aae677b60..4646ab5276 100644 --- a/src/pretix/presale/templates/pretixpresale/event/order.html +++ b/src/pretix/presale/templates/pretixpresale/event/order.html @@ -33,6 +33,12 @@ {% if order.status == "n" %}
+

{% trans "Payment" %}

diff --git a/src/pretix/presale/templates/pretixpresale/event/order_pay_change.html b/src/pretix/presale/templates/pretixpresale/event/order_pay_change.html new file mode 100644 index 0000000000..85d8d4be61 --- /dev/null +++ b/src/pretix/presale/templates/pretixpresale/event/order_pay_change.html @@ -0,0 +1,65 @@ +{% extends "pretixpresale/event/base.html" %} +{% load i18n %} +{% load eventurl %} +{% block title %}{% trans "Change payment method" %}{% endblock %} +{% block content %} +

+ {% blocktrans trimmed with code=order.code %} + Change payment method: {{ code }} + {% endblocktrans %} +

+

+ {% blocktrans trimmed %} + Please note: If you change your payment method, your order total will change by the + amount displayed to the right of each method. + {% endblocktrans %} +

+
+ {% csrf_token %} +
+ {% for p in providers %} +
+
+

+ +

+
+ +
+ {% empty %} +
+ {% trans "There are no alternative payment providers available for this order." %} +
+ {% endfor %} +
+
+ +
+ {% if providers %} + + {% endif %} +
+
+
+
+ +{% endblock %} diff --git a/src/pretix/presale/urls.py b/src/pretix/presale/urls.py index 8f7615878c..fc94cb1438 100644 --- a/src/pretix/presale/urls.py +++ b/src/pretix/presale/urls.py @@ -40,6 +40,9 @@ event_patterns = [ url(r'^order/(?P[^/]+)/(?P[A-Za-z0-9]+)/pay/complete$', pretix.presale.views.order.OrderPayComplete.as_view(), name='event.order.pay.complete'), + url(r'^order/(?P[^/]+)/(?P[A-Za-z0-9]+)/pay/change', + pretix.presale.views.order.OrderPayChangeMethod.as_view(), + name='event.order.pay.change'), url(r'^order/(?P[^/]+)/(?P[A-Za-z0-9]+)/download/(?P[^/]+)$', pretix.presale.views.order.OrderDownload.as_view(), name='event.order.download'), diff --git a/src/pretix/presale/views/order.py b/src/pretix/presale/views/order.py index b4e152ad17..61335322e4 100644 --- a/src/pretix/presale/views/order.py +++ b/src/pretix/presale/views/order.py @@ -2,6 +2,8 @@ from datetime import timedelta from django.contrib import messages from django.core.urlresolvers import reverse +from django.db import transaction +from django.db.models import Sum from django.http import Http404 from django.shortcuts import redirect from django.utils.functional import cached_property @@ -14,7 +16,7 @@ from pretix.base.models import ( ) from pretix.base.models.orders import InvoiceAddress from pretix.base.services.invoices import ( - generate_invoice, invoice_pdf, invoice_qualified, + generate_cancellation, generate_invoice, invoice_pdf, invoice_qualified, ) from pretix.base.services.orders import OrderError, cancel_order from pretix.base.services.tickets import generate @@ -125,9 +127,9 @@ class OrderPay(EventViewMixin, OrderDetailMixin, TemplateView): return super().dispatch(request, *args, **kwargs) def post(self, request, *args, **kwargs): - resp = self.payment_provider.retry_prepare( - request, self.order - ) + resp = self.payment_provider.order_prepare(request, self.order) + if 'payment_change_{}'.format(self.order.pk) in request.session: + del request.session['payment_change_{}'.format(self.order.pk)] if isinstance(resp, str): return redirect(resp) elif resp is True: @@ -181,7 +183,8 @@ class OrderPayDo(EventViewMixin, OrderDetailMixin, TemplateView): self.request = request if not self.order: raise Http404(_('Unknown order code or not authorized to access this order.')) - if not self.payment_provider.order_can_retry(self.order) or not self.payment_provider.is_enabled: + can_do = self.payment_provider.order_can_retry(self.order) or 'payment_change_{}'.format(self.order.pk) in request.session + if not can_do or not self.payment_provider.is_enabled: messages.error(request, _('The payment for this order cannot be continued.')) return redirect(self.get_order_url()) if (not self.payment_provider.payment_is_valid_session(request) @@ -192,6 +195,8 @@ class OrderPayDo(EventViewMixin, OrderDetailMixin, TemplateView): def post(self, request, *args, **kwargs): resp = self.payment_provider.payment_perform(request, self.order) + if 'payment_change_{}'.format(self.order.pk) in request.session: + del request.session['payment_change_{}'.format(self.order.pk)] return redirect(resp or self.get_order_url()) def get_context_data(self, **kwargs): @@ -213,8 +218,8 @@ class OrderPayComplete(EventViewMixin, OrderDetailMixin, View): self.request = request if not self.order: raise Http404(_('Unknown order code or not authorized to access this order.')) - if (not self.payment_provider.payment_is_valid_session(request) - or not self.payment_provider.is_enabled): + if (not self.payment_provider.payment_is_valid_session(request) or + not self.payment_provider.is_enabled): messages.error(request, _('The payment information you entered was incomplete.')) return redirect(self.get_payment_url()) return super().dispatch(request, *args, **kwargs) @@ -230,6 +235,96 @@ class OrderPayComplete(EventViewMixin, OrderDetailMixin, View): }) +class OrderPayChangeMethod(EventViewMixin, OrderDetailMixin, TemplateView): + template_name = 'pretixpresale/event/order_pay_change.html' + + def dispatch(self, request, *args, **kwargs): + self.request = request + if not self.order: + raise Http404(_('Unknown order code or not authorized to access this order.')) + if self.order.status not in (Order.STATUS_PENDING, Order.STATUS_EXPIRED): + messages.error(request, _('The payment method for this order cannot be changed.')) + return redirect(self.get_order_url() + '?paid=yes') + return super().dispatch(request, *args, **kwargs) + + def get_payment_url(self): + return eventreverse(self.request.event, 'presale:event.order.pay', kwargs={ + 'order': self.order.code, + 'secret': self.order.secret + }) + + @cached_property + def _total_order_value(self): + return self.order.positions.aggregate(sum=Sum('price'))['sum'] + + @cached_property + def provider_forms(self): + providers = [] + responses = register_payment_providers.send(self.request.event) + for receiver, response in responses: + provider = response(self.request.event) + if provider.identifier == self.order.payment_provider: + continue + if not provider.is_enabled or not provider.order_change_allowed(self.order): + continue + fee = provider.calculate_fee(self._total_order_value) + providers.append({ + 'provider': provider, + 'fee': fee, + 'fee_diff': fee - self.order.payment_fee, + 'fee_diff_abs': abs(fee - self.order.payment_fee), + 'form': provider.payment_form_render(self.request) + }) + return providers + + def post(self, request, *args, **kwargs): + self.request = request + for p in self.provider_forms: + if p['provider'].identifier == request.POST.get('payment', ''): + request.session['payment'] = p['provider'].identifier + request.session['payment_change_{}'.format(self.order.pk)] = '1' + + resp = p['provider'].order_prepare(request, self.order) + if resp: + with transaction.atomic(): + new_fee = p['provider'].calculate_fee(self._total_order_value) + self.order.log_action('pretix.event.order.payment.changed', { + 'old_fee': self.order.payment_fee, + 'new_fee': new_fee, + 'old_provider': self.order.payment_provider, + 'new_provider': p['provider'].identifier + }) + self.order.payment_provider = p['provider'].identifier + self.order.payment_fee = new_fee + self.order._calculate_tax() + self.order.save() + + i = self.order.invoices.filter(is_cancellation=False).last() + if i: + generate_cancellation(i) + generate_invoice(self.order) + if isinstance(resp, str): + return redirect(resp) + elif resp is True: + return redirect(self.get_confirm_url()) + else: + return self.get(request, *args, **kwargs) + messages.error(self.request, _("Please select a payment method.")) + return self.get(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + ctx['order'] = self.order + ctx['providers'] = self.provider_forms + return ctx + + def get_confirm_url(self): + return eventreverse(self.request.event, 'presale:event.order.pay.confirm', kwargs={ + 'order': self.order.code, + 'secret': self.order.secret + }) + + class OrderModify(EventViewMixin, OrderDetailMixin, QuestionsViewMixin, TemplateView): template_name = "pretixpresale/event/order_modify.html"