Refs #33 -- Added UI and Stripe support for retrying failed payments

This commit is contained in:
Raphael Michel
2015-06-25 15:49:14 +02:00
parent 224eaeee48
commit f04c43abdc
14 changed files with 287 additions and 57 deletions

View File

@@ -129,7 +129,7 @@ class BasePaymentProvider:
pass
@property
def checkout_form_fields(self) -> dict:
def payment_form_fields(self) -> dict:
"""
This is used by the default implementation of :py:meth:`checkout_form`.
It should return an object similar to :py:attr:`settings_form_fields`.
@@ -138,7 +138,7 @@ class BasePaymentProvider:
"""
return {}
def checkout_form(self, request: HttpRequest) -> Form:
def payment_form(self, request: HttpRequest) -> Form:
"""
This is called by the default implementation of :py:meth:`checkout_form_render`
to obtain the form that is displayed to the user during the checkout
@@ -155,7 +155,7 @@ class BasePaymentProvider:
if k.startswith('payment_%s_' % self.identifier)
}
)
form.fields = self.checkout_form_fields
form.fields = self.payment_form_fields
return form
def is_allowed(self, request: HttpRequest) -> bool:
@@ -168,7 +168,7 @@ class BasePaymentProvider:
"""
return True
def checkout_form_render(self, request: HttpRequest) -> str:
def payment_form_render(self, request: HttpRequest) -> str:
"""
When the user selects this provider as his prefered payment method,
he will be shown the HTML you return from this method.
@@ -178,7 +178,7 @@ class BasePaymentProvider:
the user to fill out form fields, you should just return a paragraph
of explainatory text.
"""
form = self.checkout_form(request)
form = self.payment_form(request)
template = get_template('pretixpresale/event/checkout_payment_form_default.html')
ctx = {'request': request, 'form': form}
return template.render(ctx)
@@ -209,7 +209,7 @@ class BasePaymentProvider:
to the user (or the normal form validation error messages).
The default implementation stores the input into the form returned by
:py:meth:`checkout_form` in the user's session.
:py:meth:`payment_form` in the user's session.
If your payment method requires you to redirect the user to an external provider,
this might be the place to do so.
@@ -233,7 +233,7 @@ class BasePaymentProvider:
payment_fee:
The fee for the payment method.
"""
form = self.checkout_form(request)
form = self.payment_form(request)
if form.is_valid():
for k, v in form.cleaned_data.items():
request.session['payment_%s_%s' % (self.identifier, k)] = v
@@ -241,7 +241,7 @@ class BasePaymentProvider:
else:
return False
def checkout_is_valid_session(self, request: HttpRequest) -> bool:
def payment_is_valid_session(self, request: HttpRequest) -> bool:
"""
This is called at the time the user tries to place the order. It should return
``True``, if the user's session is valid and all data your payment provider requires
@@ -249,7 +249,7 @@ class BasePaymentProvider:
"""
raise NotImplementedError() # NOQA
def checkout_perform(self, request: HttpRequest, order: Order) -> str:
def payment_perform(self, request: HttpRequest, order: Order) -> str:
"""
After the user confirmed his purchase, this method will be called to complete
the payment process. This is the place to actually move the money, if applicable.
@@ -299,6 +299,32 @@ class BasePaymentProvider:
"""
raise NotImplementedError() # NOQA
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.
:param order: The order object
"""
return False
def retry_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`).
It should return and report errors the same way as :py:meth:`checkout_prepare`, but
receives an ``Order`` object instead of a cart object.
"""
form = self.payment_form(request)
if form.is_valid():
for k, v in form.cleaned_data.items():
request.session['payment_%s_%s' % (self.identifier, k)] = v
return True
else:
return False
def order_paid_render(self, request: HttpRequest, order: Order) -> str:
"""
Will be called if the user views the detail page of an paid order which is
@@ -377,14 +403,14 @@ class FreeOrderProvider(BasePaymentProvider):
def order_pending_render(self, request: HttpRequest, order: Order) -> str:
pass
def checkout_is_valid_session(self, request: HttpRequest) -> bool:
def payment_is_valid_session(self, request: HttpRequest) -> bool:
return True
@property
def verbose_name(self) -> str:
return _("Free of charge")
def checkout_perform(self, request: HttpRequest, order: Order):
def payment_perform(self, request: HttpRequest, order: Order):
mark_order_paid(order, 'free')
@property

View File

@@ -23,7 +23,7 @@ class BankTransfer(BasePaymentProvider):
]
)
def checkout_form_render(self, request) -> str:
def payment_form_render(self, request) -> str:
template = get_template('pretixplugins/banktransfer/checkout_payment_form.html')
ctx = {'request': request, 'event': self.event, 'settings': self.settings}
return template.render(ctx)
@@ -31,11 +31,11 @@ class BankTransfer(BasePaymentProvider):
def checkout_prepare(self, request, total):
return True
def checkout_is_valid_session(self, request):
def payment_is_valid_session(self, request):
return True
def checkout_confirm_render(self, request):
form = self.checkout_form(request)
form = self.payment_form(request)
template = get_template('pretixplugins/banktransfer/checkout_payment_confirm.html')
ctx = {'request': request, 'form': form, 'settings': self.settings}
return template.render(ctx)

View File

@@ -22,7 +22,7 @@ class Paypal(BasePaymentProvider):
identifier = 'paypal'
verbose_name = _('PayPal')
checkout_form_fields = OrderedDict([
payment_form_fields = OrderedDict([
])
@property
@@ -55,11 +55,11 @@ class Paypal(BasePaymentProvider):
client_id=self.settings.get('client_id'),
client_secret=self.settings.get('secret'))
def checkout_is_valid_session(self, request):
def payment_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:
def payment_form_render(self, request) -> str:
template = get_template('pretixplugins/paypal/checkout_payment_form.html')
ctx = {'request': request, 'event': self.event, 'settings': self.settings}
return template.render(ctx)
@@ -135,7 +135,7 @@ class Paypal(BasePaymentProvider):
ctx = {'request': request, 'event': self.event, 'settings': self.settings}
return template.render(ctx)
def checkout_perform(self, request, order) -> str:
def payment_perform(self, request, order) -> str:
"""
Will be called if the user submitted his order successfully to initiate the
payment process.

View File

@@ -3,7 +3,6 @@ import json
import logging
from django.contrib import messages
from django.core.urlresolvers import reverse
from django.template.loader import get_template
from django.utils.translation import ugettext_lazy as _
from django import forms
@@ -43,9 +42,12 @@ class Stripe(BasePaymentProvider):
build_absolute_uri('plugins:stripe:webhook')
)
def checkout_is_valid_session(self, request):
def payment_is_valid_session(self, request):
return request.session.get('payment_stripe_token') != ''
def retry_prepare(self, request, order):
return self.checkout_prepare(request, None)
def checkout_prepare(self, request, cart):
token = request.POST.get('stripe_token', '')
request.session['payment_stripe_token'] = token
@@ -56,7 +58,7 @@ class Stripe(BasePaymentProvider):
return False
return True
def checkout_form_render(self, request) -> str:
def payment_form_render(self, request) -> str:
template = get_template('pretixplugins/stripe/checkout_payment_form.html')
ctx = {'request': request, 'event': self.event, 'settings': self.settings}
return template.render(ctx)
@@ -70,7 +72,10 @@ class Stripe(BasePaymentProvider):
ctx = {'request': request, 'event': self.event, 'settings': self.settings}
return template.render(ctx)
def checkout_perform(self, request, order) -> str:
def order_can_retry(self, order):
return True
def payment_perform(self, request, order) -> str:
self._init_api()
try:
charge = stripe.Charge.create(
@@ -82,23 +87,31 @@ class Stripe(BasePaymentProvider):
'event': self.event.identity,
'code': order.code
},
idempotency_key=self.event.identity + order.code # TODO: Use something better
# TODO: Is this sufficient?
idempotency_key=self.event.identity + order.code + request.session['payment_stripe_token']
)
except stripe.error.CardError as e:
err = e.json_body['error']
messages.error(request, _('Stripe reported an error with your card: %s' % err['message']))
logger.info('Stripe card error: %s' % str(err))
order = order.clone()
order.payment_info = json.dumps({
'error': True,
'message': err['message'],
})
order.save()
except stripe.error.InvalidRequestError or stripe.error.AuthenticationError or stripe.error.APIConnectionError \
as e:
or stripe.error.StripeError as e:
err = e.json_body['error']
messages.error(request, _('We had trouble communicating with Stripe. Please try again and get in touch '
'with us if this problem persists.'))
logger.error('Stripe error: %s' % str(err))
except stripe.error.StripeError as e:
err = e.json_body['error']
messages.error(request, _('We had trouble processing the payment. Please try again and get '
'in touch with us if this problem persists.'))
logger.error('Stripe error: %s' % str(err))
order = order.clone()
order.payment_info = json.dumps({
'error': True,
'message': err['message'],
})
order.save()
else:
if charge.status == 'succeeded' and charge.paid:
try:
@@ -112,11 +125,16 @@ class Stripe(BasePaymentProvider):
order = order.clone()
order.payment_info = str(charge)
order.save()
del request.session['payment_stripe_token']
def order_pending_render(self, request, order) -> str:
if order.payment_info:
payment_info = json.loads(order.payment_info)
else:
payment_info = None
template = get_template('pretixplugins/stripe/pending.html')
ctx = {'request': request, 'event': self.event, 'settings': self.settings,
'order': order}
'order': order, 'payment_info': payment_info}
return template.render(ctx)
def order_control_render(self, request, order) -> str:

View File

@@ -11,15 +11,17 @@ from pretix.presale.signals import html_head
@receiver(register_payment_providers)
def register_payment_provider(sender, **kwargs):
from .payment import Stripe
return Stripe
@receiver(html_head)
def html_head_presale(sender, request=None, **kwargs):
from .payment import Stripe
provider = Stripe(sender)
url = resolve(request.path_info)
if provider.is_enabled and "checkout.payment" in url.url_name:
if provider.is_enabled and ("checkout.payment" in url.url_name or "order.pay" in url.url_name):
template = get_template('pretixplugins/stripe/presale_head.html')
ctx = Context({'event': sender, 'settings': provider.settings})
return template.render(ctx)

View File

@@ -85,7 +85,8 @@ $(function() {
.keyup(pretixstripe.validate_cvc)
$("#stripe_number").parents("form").submit(
function () {
if ($("input[name=payment][value=stripe]").prop('checked') && $("#stripe_token").val() == "") {
if (($("input[name=payment][value=stripe]").prop('checked') || $("input[name=payment]").length === 0)
&& $("#stripe_token").val() == "") {
pretixstripe.request();
return false;
}

View File

@@ -1,5 +1,12 @@
{% load i18n %}
<p>{% blocktrans trimmed %}
The credit card transaction could not be completed. Please contact us.
{% endblocktrans %}</p>
The credit card transaction could not be completed for the following reason:
{% endblocktrans %}
<br />
{% if payment_info and payment_info.error %}
{{ payment_info.message }}
{% else %}
{% trans "Unknown reason" %}
{% endif %}
</p>

View File

@@ -17,10 +17,15 @@
</h3>
</div>
<div class="panel-body">
{% if can_retry %}
<a href="{% url "presale:event.order.pay" organizer=request.event.organizer.slug event=request.event.slug order=order.code %}"
class="btn btn-primary pull-right"><i class="fa fa-money"></i> {% trans "Complete payment" %}</a>
{% endif %}
{{ payment }}
<strong>{% blocktrans trimmed with date=order.expires|date:"SHORT_DATE_FORMAT" %}
Please complete your payment before {{ date }}
{% endblocktrans %}</strong>
<div class="clearfix"></div>
</div>
</div>
{% endif %}

View File

@@ -0,0 +1,32 @@
{% extends "pretixpresale/event/base.html" %}
{% load i18n %}
{% block title %}{% trans "Cancel order" %}{% endblock %}
{% block content %}
<h2>
{% blocktrans trimmed with code=order.code %}
Pay order: {{ code }}
{% endblocktrans %}
</h2>
<form method="post" class="form-horizontal" href="">
{% csrf_token %}
<div class="form-horizontal">
{{ form }}
</div>
<div class="row checkout-button-row">
<div class="col-md-4">
<a class="btn btn-block btn-default btn-lg"
href="{% url "presale:event.order" event=request.event.slug organizer=request.event.organizer.slug order=order.code %}">
{% trans "Cancel" %}
</a>
</div>
<div class="col-md-4 col-md-offset-4">
<button class="btn btn-block btn-primary btn-lg" type="submit">
{% trans "Continue" %}
</button>
</div>
<div class="clearfix"></div>
</div>
</form>
{% endblock %}

View File

@@ -0,0 +1,43 @@
{% extends "pretixpresale/event/base.html" %}
{% load i18n %}
{% block title %}{% trans "Cancel order" %}{% endblock %}
{% block content %}
<h2>
{% blocktrans trimmed with code=order.code %}
Pay order: {{ code }}
{% endblocktrans %}
</h2>
<form method="post" class="form-horizontal" href="">
{% csrf_token %}
<p>{% trans "Please confirm the following payment details." %}</p>
<div class="row-fluid">
<div class="panel panel-primary">
<div class="panel-heading">
<h3 class="panel-title">
{{ payment_provider.verbose_name }}
</h3>
</div>
<div class="panel-body">
{{ payment }}
</div>
</div>
</div>
<div class="row checkout-button-row">
<div class="col-md-4">
<a class="btn btn-block btn-default btn-lg"
href="{% url "presale:event.order" event=request.event.slug organizer=request.event.organizer.slug order=order.code %}">
{% trans "Cancel" %}
</a>
</div>
<div class="col-md-4 col-md-offset-4">
<button class="btn btn-block btn-primary btn-lg" type="submit">
{% trans "Pay now" %}
</button>
</div>
<div class="clearfix"></div>
</div>
</form>
{% endblock %}

View File

@@ -23,6 +23,10 @@ urlpatterns = [
name='event.order.cancel'),
url(r'^order/(?P<order>[^/]+)/modify$', pretix.presale.views.order.OrderModify.as_view(),
name='event.order.modify'),
url(r'^order/(?P<order>[^/]+)/pay$', pretix.presale.views.order.OrderPay.as_view(),
name='event.order.pay'),
url(r'^order/(?P<order>[^/]+)/pay/confirm$', pretix.presale.views.order.OrderPayDo.as_view(),
name='event.order.pay.confirm'),
url(r'^order/(?P<order>[^/]+)/download/(?P<output>[^/]+)$', pretix.presale.views.order.OrderDownload.as_view(),
name='event.order.download'),
url(r'^login$', pretix.presale.views.event.EventLogin.as_view(), name='event.checkout.login'),

View File

@@ -156,7 +156,7 @@ class PaymentDetails(EventViewMixin, CartDisplayMixin, EventLoginRequiredMixin,
providers.append({
'provider': provider,
'fee': fee,
'form': provider.checkout_form_render(self.request),
'form': provider.payment_form_render(self.request),
})
return providers
@@ -220,7 +220,7 @@ class OrderConfirm(EventViewMixin, CartDisplayMixin, EventLoginRequiredMixin, Ch
if 'payment' not in request.session or not self.payment_provider:
messages.error(request, _('The payment information you entered was incomplete.'))
return redirect(self.get_payment_url())
if not self.payment_provider.checkout_is_valid_session(request) or \
if not self.payment_provider.payment_is_valid_session(request) or \
not self.payment_provider.is_enabled or \
not self.payment_provider.is_allowed(request):
messages.error(request, _('The payment information you entered was incomplete.'))
@@ -259,7 +259,7 @@ class OrderConfirm(EventViewMixin, CartDisplayMixin, EventLoginRequiredMixin, Ch
return redirect(self.get_confirm_url())
else:
messages.success(request, _('Your order has been placed.'))
resp = self.payment_provider.checkout_perform(request, order)
resp = self.payment_provider.payment_perform(request, order)
return redirect(resp or self.get_order_url(order))
def get_previous_url(self):

View File

@@ -13,6 +13,7 @@ from pretix.presale.views.checkout import QuestionsViewMixin
class OrderDetailMixin:
@cached_property
def order(self):
try:
@@ -24,6 +25,21 @@ class OrderDetailMixin:
except Order.DoesNotExist:
return None
@cached_property
def payment_provider(self):
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:
return provider
def get_order_url(self):
return reverse('presale:event.order', kwargs={
'event': self.request.event.slug,
'organizer': self.request.event.organizer.slug,
'order': self.order.code,
})
class OrderDetails(EventViewMixin, EventLoginRequiredMixin, OrderDetailMixin,
CartDisplayMixin, TemplateView):
@@ -35,14 +51,6 @@ class OrderDetails(EventViewMixin, EventLoginRequiredMixin, OrderDetailMixin,
return HttpResponseNotFound(_('Unknown order code or order does belong to another user.'))
return super().get(request, *args, **kwargs)
@cached_property
def payment_provider(self):
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:
return provider
@cached_property
def download_buttons(self):
buttons = []
@@ -75,11 +83,94 @@ class OrderDetails(EventViewMixin, EventLoginRequiredMixin, OrderDetailMixin,
)
if self.order.status == Order.STATUS_PENDING:
ctx['payment'] = self.payment_provider.order_pending_render(self.request, self.order)
ctx['can_retry'] = self.payment_provider.order_can_retry(self.order) and self.payment_provider.is_enabled
elif self.order.status == Order.STATUS_PAID:
ctx['payment'] = self.payment_provider.order_paid_render(self.request, self.order)
ctx['can_retry'] = False
return ctx
class OrderPay(EventViewMixin, EventLoginRequiredMixin, OrderDetailMixin, TemplateView):
template_name = "pretixpresale/event/order_pay.html"
def dispatch(self, request, *args, **kwargs):
self.request = request
if not self.order:
return HttpResponseNotFound(_('Unknown order code or order does belong to another user.'))
if not self.payment_provider.order_can_retry(self.order) or not self.payment_provider.is_enabled:
messages.error(request, _('The payment for this order cannot be continued.'))
return redirect(self.get_order_url())
return super().dispatch(request, *args, **kwargs)
def post(self, request, *args, **kwargs):
resp = self.payment_provider.retry_prepare(
request, 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)
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['order'] = self.order
ctx['form'] = self.form
return ctx
@cached_property
def form(self):
return self.payment_provider.payment_form_render(self.request)
def get_confirm_url(self):
return reverse('presale:event.order.pay.confirm', kwargs={
'event': self.request.event.slug,
'organizer': self.request.event.organizer.slug,
'order': self.order.code,
})
class OrderPayDo(EventViewMixin, EventLoginRequiredMixin, OrderDetailMixin, TemplateView):
template_name = "pretixpresale/event/order_pay_confirm.html"
def dispatch(self, request, *args, **kwargs):
self.request = request
if not self.order:
return HttpResponseNotFound(_('Unknown order code or order does belong to another user.'))
if not self.payment_provider.order_can_retry(self.order) 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) or \
not self.payment_provider.is_enabled or \
not self.payment_provider.is_allowed(request):
messages.error(request, _('The payment information you entered was incomplete.'))
return redirect(self.get_payment_url())
return super().dispatch(request, *args, **kwargs)
def post(self, request, *args, **kwargs):
resp = self.payment_provider.payment_perform(request, self.order)
return redirect(resp or self.get_order_url())
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['order'] = self.order
ctx['payment'] = self.payment_provider.checkout_confirm_render(self.request)
ctx['payment_provider'] = self.payment_provider
return ctx
@cached_property
def form(self):
return self.payment_provider.payment_form_render(self.request)
def get_payment_url(self):
return reverse('presale:event.order.pay', kwargs={
'event': self.request.event.slug,
'organizer': self.request.event.organizer.slug,
'order': self.order.code,
})
class OrderModify(EventViewMixin, EventLoginRequiredMixin, OrderDetailMixin,
QuestionsViewMixin, TemplateView):
template_name = "pretixpresale/event/order_modify.html"
@@ -96,12 +187,6 @@ class OrderModify(EventViewMixin, EventLoginRequiredMixin, OrderDetailMixin,
))
def post(self, request, *args, **kwargs):
self.request = request
self.kwargs = kwargs
if not self.order:
return HttpResponseNotFound(_('Unknown order code or order does belong to another user.'))
if not self.order.can_modify_answers:
return HttpResponseForbidden(_('You cannot modify this order'))
failed = not self.save()
if failed:
messages.error(self.request,
@@ -113,13 +198,16 @@ class OrderModify(EventViewMixin, EventLoginRequiredMixin, OrderDetailMixin,
order=self.order.code)
def get(self, request, *args, **kwargs):
return super().get(request, *args, **kwargs)
def dispatch(self, request, *args, **kwargs):
self.request = request
self.kwargs = kwargs
if not self.order:
return HttpResponseNotFound(_('Unknown order code or order does belong to another user.'))
if not self.order.can_modify_answers:
return HttpResponseForbidden(_('You cannot modify this order'))
return super().get(request, *args, **kwargs)
return super().dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)