diff --git a/src/pretix/plugins/stripe/payment.py b/src/pretix/plugins/stripe/payment.py index b19d5445bd..f9fcdb0cb1 100644 --- a/src/pretix/plugins/stripe/payment.py +++ b/src/pretix/plugins/stripe/payment.py @@ -151,15 +151,6 @@ class StripeSettingsHolder(BasePaymentProvider): ] d = OrderedDict( fields + [ - ('ui', - forms.ChoiceField( - label=_('User interface'), - choices=( - ('pretix', _('Simple (pretix design)')), - ('checkout', _('Stripe Checkout')), - ), - help_text=_('Only relevant for credit card payments.') - )), ('method_cc', forms.BooleanField( label=_('Credit card payments'), @@ -297,7 +288,7 @@ class StripeMethod(BasePaymentProvider): return kwargs def _init_api(self): - stripe.api_version = '2018-02-28' + stripe.api_version = '2019-05-16' stripe.set_app_info("pretix", version=__version__, url="https://pretix.eu") def checkout_confirm_render(self, request) -> str: @@ -423,6 +414,7 @@ class StripeMethod(BasePaymentProvider): 'order': payment.order, 'payment': payment, 'payment_info': payment_info, + 'payment_hash': hashlib.sha1(payment.order.secret.lower().encode()).hexdigest() } return template.render(ctx) @@ -583,11 +575,7 @@ class StripeCC(StripeMethod): if not RegisteredApplePayDomain.objects.filter(account=account, domain=request.host).exists(): stripe_verify_domain.apply_async(args=(self.event.pk, request.host)) - ui = self.settings.get('ui', default='pretix') - if ui == 'checkout': - template = get_template('pretixplugins/stripe/checkout_payment_form_stripe_checkout.html') - else: - template = get_template('pretixplugins/stripe/checkout_payment_form.html') + template = get_template('pretixplugins/stripe/checkout_payment_form.html') ctx = { 'request': request, 'event': self.event, @@ -597,93 +585,232 @@ class StripeCC(StripeMethod): return template.render(ctx) def payment_is_valid_session(self, request): - return request.session.get('payment_stripe_token', '') != '' + return request.session.get('payment_stripe_payment_method_id', '') != '' def checkout_prepare(self, request, cart): - token = request.POST.get('stripe_token', '') - request.session['payment_stripe_token'] = token + payment_method_id = request.POST.get('stripe_payment_method_id', '') + request.session['payment_stripe_payment_method_id'] = payment_method_id request.session['payment_stripe_brand'] = request.POST.get('stripe_card_brand', '') request.session['payment_stripe_last4'] = request.POST.get('stripe_card_last4', '') - if token == '': - messages.error(request, _('You may need to enable JavaScript for Stripe payments.')) + if payment_method_id == '': + messages.warning(request, _('You may need to enable JavaScript for Stripe payments.')) return False return True - def _use_3ds(self, card): - if self.settings.cc_3ds_mode == 'recommended': - return card.three_d_secure in ('required', 'recommended') - elif self.settings.cc_3ds_mode == 'optional': - return card.three_d_secure in ('required', 'recommended', 'optional') - else: - return card.three_d_secure == 'required' - def execute_payment(self, request: HttpRequest, payment: OrderPayment): + try: + return self._handle_payment_intent(request, payment) + finally: + del request.session['payment_stripe_payment_method_id'] + + def _handle_payment_intent(self, request, payment, intent=None): self._init_api() - if request.session['payment_stripe_token'].startswith('src_'): - try: - src = stripe.Source.retrieve(request.session['payment_stripe_token'], **self.api_kwargs) - if src.type == 'card' and src.card and self._use_3ds(src.card): - request.session['payment_stripe_order_secret'] = payment.order.secret - source = stripe.Source.create( - type='three_d_secure', - amount=self._get_amount(payment), - currency=self.event.currency.lower(), - three_d_secure={ - 'card': src.id - }, - statement_descriptor=ugettext('{event}-{code}').format( - event=self.event.slug.upper(), - code=payment.order.code - )[:22], - metadata={ - 'order': str(payment.order.id), - 'event': self.event.id, - 'code': payment.order.code - }, - redirect={ - 'return_url': build_absolute_uri(self.event, 'plugins:stripe:return', kwargs={ - 'order': payment.order.code, - 'payment': payment.pk, - 'hash': hashlib.sha1(payment.order.secret.lower().encode()).hexdigest(), - }) - }, - **self.api_kwargs - ) - ReferencedStripeObject.objects.get_or_create( - reference=source.id, - defaults={'order': payment.order, 'payment': payment} - ) - if source.status == "pending": - payment.info = str(source) - payment.state = OrderPayment.PAYMENT_STATE_PENDING - payment.save() - return self.redirect(request, source.redirect.url) - except stripe.error.StripeError as e: - if e.json_body: - err = e.json_body['error'] - logger.exception('Stripe error: %s' % str(err)) + try: + if 'payment_stripe_payment_method_id' in request.session: + intent = stripe.PaymentIntent.create( + amount=self._get_amount(payment), + currency=self.event.currency.lower(), + payment_method=request.session['payment_stripe_payment_method_id'], + confirmation_method='manual', + confirm=True, + description='{event}-{code}'.format( + event=self.event.slug.upper(), + code=payment.order.code + ), + statement_descriptor=ugettext('{event}-{code}').format( + event=self.event.slug.upper(), + code=payment.order.code + )[:22], + metadata={ + 'order': str(payment.order.id), + 'event': self.event.id, + 'code': payment.order.code + }, + # TODO: Is this sufficient? + idempotency_key=str(self.event.id) + payment.order.code + request.session['payment_stripe_payment_method_id'], + return_url=build_absolute_uri(self.event, 'plugins:stripe:sca.return', kwargs={ + 'order': payment.order.code, + 'payment': payment.pk, + 'hash': hashlib.sha1(payment.order.secret.lower().encode()).hexdigest(), + }), + **self.api_kwargs + ) + else: + payment_info = json.loads(payment.info) + + if 'id' in payment_info: + if not intent: + intent = stripe.PaymentIntent.retrieve( + payment_info['id'], + **self.api_kwargs + ) else: - err = {'message': str(e)} - logger.exception('Stripe error: %s' % str(e)) - payment.info_data = { - 'error': True, - 'message': err['message'], - } + return + + except stripe.error.CardError as e: + if e.json_body: + err = e.json_body['error'] + logger.exception('Stripe error: %s' % str(err)) + else: + err = {'message': str(e)} + logger.exception('Stripe error: %s' % str(e)) + logger.info('Stripe card error: %s' % str(err)) + payment.info_data = { + 'error': True, + 'message': err['message'], + } + payment.state = OrderPayment.PAYMENT_STATE_FAILED + payment.save() + payment.order.log_action('pretix.event.order.payment.failed', { + 'local_id': payment.local_id, + 'provider': payment.provider, + 'message': err['message'] + }) + raise PaymentException(_('Stripe reported an error with your card: %s') % err['message']) + + except stripe.error.StripeError as e: + if e.json_body: + err = e.json_body['error'] + logger.exception('Stripe error: %s' % str(err)) + else: + err = {'message': str(e)} + logger.exception('Stripe error: %s' % str(e)) + payment.info_data = { + 'error': True, + 'message': err['message'], + } + payment.state = OrderPayment.PAYMENT_STATE_FAILED + payment.save() + payment.order.log_action('pretix.event.order.payment.failed', { + 'local_id': payment.local_id, + 'provider': payment.provider, + 'message': err['message'] + }) + raise PaymentException(_('We had trouble communicating with Stripe. Please try again and get in touch ' + 'with us if this problem persists.')) + else: + ReferencedStripeObject.objects.get_or_create( + reference=intent.id, + defaults={'order': payment.order, 'payment': payment} + ) + if intent.status == 'requires_action': + payment.info = str(intent) + payment.state = OrderPayment.PAYMENT_STATE_CREATED + payment.save() + return build_absolute_uri(self.event, 'plugins:stripe:sca', kwargs={ + 'order': payment.order.code, + 'payment': payment.pk, + 'hash': hashlib.sha1(payment.order.secret.lower().encode()).hexdigest(), + }) + + if intent.status == 'requires_confirmation': + payment.info = str(intent) + payment.state = OrderPayment.PAYMENT_STATE_CREATED + payment.save() + self._confirm_payment_intent(request, payment) + + elif intent.status == 'succeeded' and intent.charges.data[-1].paid: + try: + payment.info = str(intent) + payment.confirm() + except Quota.QuotaExceededException as e: + raise PaymentException(str(e)) + + except SendMailException: + raise PaymentException(_('There was an error sending the confirmation mail.')) + elif intent.status == 'pending': + if request: + messages.warning(request, _('Your payment is pending completion. We will inform you as soon as the ' + 'payment completed.')) + payment.info = str(intent) + payment.state = OrderPayment.PAYMENT_STATE_PENDING + payment.save() + return + elif intent.status == 'requires_payment_method': + if request: + messages.warning(request, _('Your payment failed. Please try again.')) + payment.info = str(intent) + payment.state = OrderPayment.PAYMENT_STATE_FAILED + payment.save() + return + else: + logger.info('Charge failed: %s' % str(intent)) + payment.info = str(intent) payment.state = OrderPayment.PAYMENT_STATE_FAILED payment.save() payment.order.log_action('pretix.event.order.payment.failed', { 'local_id': payment.local_id, 'provider': payment.provider, - 'message': err['message'] + 'info': str(intent) }) - raise PaymentException(_('We had trouble communicating with Stripe. Please try again and get in touch ' - 'with us if this problem persists.')) + raise PaymentException(_('Stripe reported an error: %s') % intent.last_payment_error.message) + + def _confirm_payment_intent(self, request, payment): + self._init_api() try: - self._charge_source(request, request.session['payment_stripe_token'], payment) - finally: - del request.session['payment_stripe_token'] + payment_info = json.loads(payment.info) + + intent = stripe.PaymentIntent.confirm( + payment_info['id'], + return_url=build_absolute_uri(self.event, 'plugins:stripe:sca.return', kwargs={ + 'order': payment.order.code, + 'payment': payment.pk, + 'hash': hashlib.sha1(payment.order.secret.lower().encode()).hexdigest(), + }), + **self.api_kwargs + ) + + payment.info = str(intent) + payment.save() + + self._handle_payment_intent(request, payment) + except stripe.error.CardError as e: + if e.json_body: + err = e.json_body['error'] + logger.exception('Stripe error: %s' % str(err)) + else: + err = {'message': str(e)} + logger.exception('Stripe error: %s' % str(e)) + logger.info('Stripe card error: %s' % str(err)) + payment.info_data = { + 'error': True, + 'message': err['message'], + } + payment.state = OrderPayment.PAYMENT_STATE_FAILED + payment.save() + payment.order.log_action('pretix.event.order.payment.failed', { + 'local_id': payment.local_id, + 'provider': payment.provider, + 'message': err['message'] + }) + raise PaymentException(_('Stripe reported an error with your card: %s') % err['message']) + except stripe.error.InvalidRequestError as e: + if e.json_body: + err = e.json_body['error'] + logger.exception('Stripe error: %s' % str(err)) + else: + err = {'message': str(e)} + logger.exception('Stripe error: %s' % str(e)) + payment.info_data = { + 'error': True, + 'message': err['message'], + } + payment.state = OrderPayment.PAYMENT_STATE_FAILED + payment.save() + payment.order.log_action('pretix.event.order.payment.failed', { + 'local_id': payment.local_id, + 'provider': payment.provider, + 'message': err['message'] + }) + raise PaymentException(_('We had trouble communicating with Stripe. Please try again and get in touch ' + 'with us if this problem persists.')) + + def payment_pending_render(self, request, payment) -> str: + self._handle_payment_intent(request, payment) + + return super().payment_pending_render(request, payment) class StripeGiropay(StripeMethod): diff --git a/src/pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe-checkout.js b/src/pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe-checkout.js deleted file mode 100644 index 9797d5ffa0..0000000000 --- a/src/pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe-checkout.js +++ /dev/null @@ -1,89 +0,0 @@ -/*global $, stripe_pubkey, stripe_loadingmessage, gettext */ -'use strict'; - -var Stripe = null; -var pretixstripe = { - 'load': function () { - $.ajax( - { - url: 'https://checkout.stripe.com/checkout.js', - dataType: 'script', - success: function () { - pretixstripe.handler = StripeCheckout.configure({ - key: $.trim($("#stripe_pubkey").html()), - locale: 'auto', - token: function(token) { - var $form = $("#stripe-checkout").parents("form"); - $("#stripe_token").val(token.id); - $("#stripe_card_brand").val(token.card.brand); - $("#stripe_card_last4").val(token.card.last4); - $("#stripe_card_brand_display").text(token.card.brand); - $("#stripe_card_last4_display").text(token.card.last4); - $($form.get(0)).submit(); - }, - shippingAddress: false, - allowRememberMe: false, - billingAddress: false - }); - } - } - ); - }, - - start: function () { - var amount = Math.round( - parseFloat( - $("#stripe-checkout").parents("[data-total]").attr("data-total").replace(",", ".") - ) * 100 - ); - pretixstripe.handler.open({ - name: $("#organizer_name").val(), - description: $("#event_name").val(), - currency: $("#stripe_currency").val(), - email: $("#stripe_email").val(), - amount: amount - }); - }, - - handler: null -}; -$(function () { - if (!$("#stripe-checkout").length) { // Not on the checkout page - return; - } - - if ($("input[name=payment][value=stripe]").is(':checked') || $(".payment-redo-form").length) { - pretixstripe.load(); - } else { - $("input[name=payment]").change(function() { - if ($(this).val() == 'stripe') { - pretixstripe.load(); - } - }) - } - - $(".checkout-button-row .btn-primary").click( - function (e) { - if (($("input[name=payment][value=stripe]").prop('checked') || $("input[type=checkbox][name=radio]").length === 0) - && $("#stripe_token").val() == "") { - pretixstripe.start(); - e.preventDefault(); - return false; - } - } - ); - - $("#stripe_other_card").click( - function (e) { - pretixstripe.start(); - e.preventDefault(); - return false; - } - ); - - $(window).on('popstate', function () { - if (pretixstripe.handler) { - pretixstripe.handler.close(); - } - }); -}); diff --git a/src/pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.css b/src/pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.css index edae2e3818..c9e8a9faad 100644 --- a/src/pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.css +++ b/src/pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.css @@ -24,6 +24,11 @@ margin: 15px 0; } +.embed-responsive-sca { + padding-bottom: 75%; + min-height: 600px; +} + @media only screen and (max-width: 999px) { .hr { width: 100%; diff --git a/src/pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js b/src/pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js index 7648831444..22a40c2e31 100644 --- a/src/pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js +++ b/src/pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js @@ -12,18 +12,19 @@ var pretixstripe = { waitingDialog.show(gettext("Contacting Stripe …")); $(".stripe-errors").hide(); - pretixstripe.stripe.createSource(pretixstripe.card).then(function (result) { + // ToDo: 'card' --> proper type of payment method + pretixstripe.stripe.createPaymentMethod('card', pretixstripe.card).then(function (result) { waitingDialog.hide(); if (result.error) { $(".stripe-errors").stop().hide().removeClass("sr-only"); $(".stripe-errors").html("
" + result.error.message + "
"); $(".stripe-errors").slideDown(); } else { - var $form = $("#stripe_token").closest("form"); + var $form = $("#stripe_payment_method_id").closest("form"); // Insert the token into the form so it gets submitted to the server - $("#stripe_token").val(result.source.id); - $("#stripe_card_brand").val(result.source.card.brand); - $("#stripe_card_last4").val(result.source.card.last4); + $("#stripe_payment_method_id").val(result.paymentMethod.id); + $("#stripe_card_brand").val(result.paymentMethod.card.brand); + $("#stripe_card_last4").val(result.paymentMethod.card.last4); // and submit $form.get(0).submit(); } @@ -63,14 +64,14 @@ var pretixstripe = { requestShipping: false, }); - pretixstripe.paymentRequest.on('token', function (ev) { + pretixstripe.paymentRequest.on('paymentmethod', function (ev) { ev.complete('success'); - var $form = $("#stripe_token").closest("form"); + var $form = $("#stripe_payment_method_id").closest("form"); // Insert the token into the form so it gets submitted to the server - $("#stripe_token").val(ev.token.id); - $("#stripe_card_brand").val(ev.token.card.brand); - $("#stripe_card_last4").val(ev.token.card.last4); + $("#stripe_payment_method_id").val(ev.paymentMethod.id); + $("#stripe_card_brand").val(ev.paymentMethod.card.brand); + $("#stripe_card_last4").val(ev.paymentMethod.card.last4); // and submit $form.get(0).submit(); }); @@ -125,84 +126,83 @@ var pretixstripe = { } ); }, - 'load_checkout': function () { - if (pretixstripe.checkout_handler !== null) { - return; - } - $('.stripe-container').closest("form").find(".checkout-button-row .btn-primary").prop("disabled", true); - $.ajax( - { - url: 'https://checkout.stripe.com/checkout.js', - dataType: 'script', - success: function () { - pretixstripe.checkout_handler = StripeCheckout.configure({ - key: $.trim($("#stripe_pubkey").html()), - locale: 'auto', - token: function (token) { - var $form = $("#stripe-checkout").parents("form"); - $("#stripe_token").val(token.id); - $("#stripe_card_brand").val(token.card.brand); - $("#stripe_card_last4").val(token.card.last4); - $("#stripe_card_brand_display").text(token.card.brand); - $("#stripe_card_last4_display").text(token.card.last4); - $($form.get(0)).submit(); - }, - shippingAddress: false, - allowRememberMe: false, - billingAddress: false + 'handleCardAction': function (payment_intent_client_secret) { + $.ajax({ + url: 'https://js.stripe.com/v3/', + dataType: 'script', + success: function () { + if ($.trim($("#stripe_connectedAccountId").html())) { + pretixstripe.stripe = Stripe($.trim($("#stripe_pubkey").html()), { + stripeAccount: $.trim($("#stripe_connectedAccountId").html()) }); - $('.stripe-container').closest("form").find(".checkout-button-row .btn-primary").prop("disabled", false); + } else { + pretixstripe.stripe = Stripe($.trim($("#stripe_pubkey").html())); } + pretixstripe.stripe.handleCardAction( + payment_intent_client_secret + ).then(function (result) { + waitingDialog.show(gettext("Confirming your payment …")); + location.reload(); + }); } - ); - }, - 'show_checkout': function () { - var amount = Math.round( - parseFloat( - $("#stripe-checkout").parents("[data-total]").attr("data-total").replace(",", ".") - ) * 100 - ); - pretixstripe.checkout_handler.open({ - name: $("#organizer_name").val(), - description: $("#event_name").val(), - currency: $("#stripe_currency").val(), - email: $("#stripe_email").val(), - amount: amount }); }, - 'checkout_handler': null + 'handleCardActioniFrame': function (payment_intent_next_action_redirect_url) { + waitingDialog.show(gettext("Contacting your bank …")); + let iframe = document.createElement('iframe'); + iframe.src = payment_intent_next_action_redirect_url; + iframe.className = 'embed-responsive-item'; + $('#scacontainer').append(iframe); + $('#scacontainer iframe').load(function () { + waitingDialog.hide(); + }); + } }; $(function () { - if (!$(".stripe-container, #stripe-checkout").length) // Not on the checkout page + if ($("#stripe_payment_intent_SCA_status").length) { + window.parent.postMessage('3DS-authentication-complete.' + $.trim($("#order_status").html()), '*'); + return; + } else if ($("#stripe_payment_intent_next_action_redirect_url").length) { + let payment_intent_next_action_redirect_url = $.trim($("#stripe_payment_intent_next_action_redirect_url").html()); + pretixstripe.handleCardActioniFrame(payment_intent_next_action_redirect_url); + } else if ($("#stripe_payment_intent_client_secret").length) { + let payment_intent_client_secret = $.trim($("#stripe_payment_intent_client_secret").html()); + pretixstripe.handleCardAction(payment_intent_client_secret); + } + + $(window).on("message onmessage", function(e) { + if (typeof e.originalEvent.data === "string" && e.originalEvent.data.startsWith('3DS-authentication-complete.')) { + waitingDialog.show(gettext("Confirming your payment …")); + $('#scacontainer').hide(); + $('#continuebutton').removeClass('hidden'); + + if (e.originalEvent.data.split('.')[1] == 'p') { + window.location.href = $('#continuebutton').attr('href') + '?paid=yes'; + } else { + window.location.href = $('#continuebutton').attr('href'); + } + } + }); + + if (!$(".stripe-container").length) return; if ($("input[name=payment][value=stripe]").is(':checked') || $(".payment-redo-form").length) { - if ($("#stripe-checkout").length) { - pretixstripe.load_checkout(); - } else { pretixstripe.load(); - } } else { $("input[name=payment]").change(function () { if ($(this).val() === 'stripe') { - if ($("#stripe-checkout").length) { - pretixstripe.load_checkout(); - } else { - pretixstripe.load(); - } + pretixstripe.load(); } }) } $("#stripe_other_card").click( function (e) { - $("#stripe_token").val(""); - if ($("#stripe-checkout").length) { - pretixstripe.show_checkout(); - } else { - $("#stripe-current-card").slideUp(); - $("#stripe-elements").slideDown(); - } + $("#stripe_payment_method_id").val(""); + $("#stripe-current-card").slideUp(); + $("#stripe-elements").slideDown(); + e.preventDefault(); return false; } @@ -218,19 +218,10 @@ $(function () { return null; } if (($("input[name=payment][value=stripe]").prop('checked') || $("input[name=payment][type=radio]").length === 0) - && $("#stripe_token").val() == "") { - if ($("#stripe-checkout").length) { - pretixstripe.show_checkout(); - } else { - pretixstripe.cc_request(); - } + && $("#stripe_payment_method_id").val() == "") { + pretixstripe.cc_request(); return false; } } ); - $(window).on('popstate', function () { - if (pretixstripe.checkout_handler) { - pretixstripe.checkout_handler.close(); - } - }); -}); +}); \ No newline at end of file diff --git a/src/pretix/plugins/stripe/tasks.py b/src/pretix/plugins/stripe/tasks.py index afd6c6bbc3..e703ff6b05 100644 --- a/src/pretix/plugins/stripe/tasks.py +++ b/src/pretix/plugins/stripe/tasks.py @@ -4,7 +4,6 @@ from urllib.parse import urlsplit import stripe from django.conf import settings -from pretix.base.models import Event from pretix.base.services.tasks import EventTask from pretix.celery_app import app from pretix.multidomain.urlreverse import get_domain @@ -29,9 +28,8 @@ def get_stripe_account_key(prov): @app.task(base=EventTask, max_retries=5, default_retry_delay=1) -def stripe_verify_domain(event_id, domain): +def stripe_verify_domain(event, domain): from pretix.plugins.stripe.payment import StripeCC - event = Event.objects.get(pk=event_id) prov = StripeCC(event) account = get_stripe_account_key(prov) diff --git a/src/pretix/plugins/stripe/templates/pretixplugins/stripe/checkout_payment_form.html b/src/pretix/plugins/stripe/templates/pretixplugins/stripe/checkout_payment_form.html index 22afbcb654..3c6ee426a5 100644 --- a/src/pretix/plugins/stripe/templates/pretixplugins/stripe/checkout_payment_form.html +++ b/src/pretix/plugins/stripe/templates/pretixplugins/stripe/checkout_payment_form.html @@ -10,7 +10,7 @@ - {% if request.session.payment_stripe_token %} + {% if request.session.payment_stripe_payment_method_id %}

{% blocktrans trimmed %} You already entered a card number that we will use to charge the payment amount. @@ -58,7 +58,7 @@ Stripe and never touches our servers. {% endblocktrans %} - + -

- {% trans "For a credit card payment, please turn on JavaScript." %} -
- - {% if request.session.payment_stripe_token %} -

{% blocktrans trimmed %} - You already entered a card number that we will use to charge the payment amount. - {% endblocktrans %}

-
-
{% trans "Card type" %}
-
{{ request.session.payment_stripe_brand }}
-
{% trans "Card number" %}
-
- **** **** **** {{ request.session.payment_stripe_last4 }} - -
-
-

-

- {% else %} -

- {% blocktrans trimmed %} - Please continue below to start the credit card payment. - {% endblocktrans %} -

- {% endif %} -

- {% blocktrans trimmed %} - Your payment will be processed by Stripe, Inc. Your credit card data will be transmitted directly to - Stripe and never touches our servers. - {% endblocktrans %} -

- - - - - - - - {% if order %} - - {% else %} - - {% endif %} -
- diff --git a/src/pretix/plugins/stripe/templates/pretixplugins/stripe/pending.html b/src/pretix/plugins/stripe/templates/pretixplugins/stripe/pending.html index ab9da078f5..ad4fa21af8 100644 --- a/src/pretix/plugins/stripe/templates/pretixplugins/stripe/pending.html +++ b/src/pretix/plugins/stripe/templates/pretixplugins/stripe/pending.html @@ -1,10 +1,23 @@ {% load i18n %} - +{% load eventurl %} {% if payment.state == "pending" %}

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

+{% elif payment.state == "created" and payment_info.status == "requires_action" %} +

{% blocktrans trimmed %} + You need to confirm your payment. Please click the link below to do so or start a new payment. + {% endblocktrans %} +

+ + {% trans "Confirm payment" %} + +
+
+

+ {% else %}

{% blocktrans trimmed %} The payment transaction could not be completed for the following reason: @@ -16,4 +29,4 @@ {% trans "Unknown reason" %} {% endif %}

-{% endif %} +{% endif %} \ No newline at end of file diff --git a/src/pretix/plugins/stripe/templates/pretixplugins/stripe/sca.html b/src/pretix/plugins/stripe/templates/pretixplugins/stripe/sca.html new file mode 100644 index 0000000000..02a51ae13c --- /dev/null +++ b/src/pretix/plugins/stripe/templates/pretixplugins/stripe/sca.html @@ -0,0 +1,41 @@ +{% extends "pretixpresale/event/base.html" %} +{% load i18n %} +{% load eventurl %} +{% load static %} +{% block title %}{% trans "Pay order" %}{% endblock %} +{% block custom_header %} + {{ block.super }} + {% include "pretixplugins/stripe/presale_head.html" with settings=stripe_settings %} + + +{% endblock %} +{% block content %} +
+
+

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

+
+
+ +
+
+
+
+ + {% trans "Cancel" %} + +
+
+ +
+
+
+{% endblock %} diff --git a/src/pretix/plugins/stripe/templates/pretixplugins/stripe/sca_return.html b/src/pretix/plugins/stripe/templates/pretixplugins/stripe/sca_return.html new file mode 100644 index 0000000000..3579b6df89 --- /dev/null +++ b/src/pretix/plugins/stripe/templates/pretixplugins/stripe/sca_return.html @@ -0,0 +1,20 @@ +{% extends "pretixpresale/base.html" %} +{% load i18n %} +{% load static %} +{% load thumb %} +{% load eventurl %} +{% block title %}{% trans "Pay order" %}{% endblock %} +{% block custom_header %} + {{ block.super }} + {% include "pretixplugins/stripe/presale_head.html" with settings=stripe_settings %} + + +{% endblock %} +{% block page %} +
+ +
+

+ {% trans "Confirming your payment…" %} +

+{% endblock %} diff --git a/src/pretix/plugins/stripe/urls.py b/src/pretix/plugins/stripe/urls.py index f277fd282f..bad2fd9ffb 100644 --- a/src/pretix/plugins/stripe/urls.py +++ b/src/pretix/plugins/stripe/urls.py @@ -3,8 +3,8 @@ from django.conf.urls import include, url from pretix.multidomain import event_url from .views import ( - ReturnView, applepay_association, oauth_disconnect, oauth_return, - redirect_view, webhook, + ReturnView, ScaReturnView, ScaView, applepay_association, oauth_disconnect, + oauth_return, redirect_view, webhook, ) event_patterns = [ @@ -12,6 +12,8 @@ event_patterns = [ event_url(r'^webhook/$', webhook, name='webhook', require_live=False), url(r'^redirect/$', redirect_view, name='redirect'), url(r'^return/(?P[^/]+)/(?P[^/]+)/(?P[0-9]+)/$', ReturnView.as_view(), name='return'), + url(r'^sca/(?P[^/]+)/(?P[^/]+)/(?P[0-9]+)/$', ScaView.as_view(), name='sca'), + url(r'^sca/(?P[^/]+)/(?P[^/]+)/(?P[0-9]+)/return/$', ScaReturnView.as_view(), name='sca.return'), ])), ] diff --git a/src/pretix/plugins/stripe/views.py b/src/pretix/plugins/stripe/views.py index 4b3cc0fc2f..249f577eca 100644 --- a/src/pretix/plugins/stripe/views.py +++ b/src/pretix/plugins/stripe/views.py @@ -26,7 +26,7 @@ from pretix.base.settings import GlobalSettingsObject from pretix.control.permissions import event_permission_required from pretix.multidomain.urlreverse import eventreverse from pretix.plugins.stripe.models import ReferencedStripeObject -from pretix.plugins.stripe.payment import StripeCC +from pretix.plugins.stripe.payment import StripeCC, StripeSettingsHolder from pretix.plugins.stripe.tasks import ( get_domain_for_event, stripe_verify_domain, ) @@ -160,6 +160,9 @@ def webhook(request, *args, **kwargs): elif event_json['data']['object']['object'] == "source": func = source_webhook objid = event_json['data']['object']['id'] + elif event_json['data']['object']['object'] == "payment_intent": + func = paymentintent_webhook + objid = event_json['data']['object']['id'] else: return HttpResponse("Not interested in this data type", status=200) @@ -187,6 +190,7 @@ SOURCE_TYPES = { def charge_webhook(event, event_json, charge_id, rso): prov = StripeCC(event) prov._init_api() + try: charge = stripe.Charge.retrieve(charge_id, expand=['dispute'], **prov.api_kwargs) except stripe.error.StripeError: @@ -359,6 +363,25 @@ def source_webhook(event, event_json, source_id, rso): return HttpResponse(status=200) +def paymentintent_webhook(event, event_json, paymentintent_id, rso): + prov = StripeCC(event) + prov._init_api() + + try: + paymentintent = stripe.PaymentIntent.retrieve(paymentintent_id, **prov.api_kwargs) + except stripe.error.StripeError: + logger.exception('Stripe error on webhook. Event data: %s' % str(event_json)) + return HttpResponse('Charge not found', status=500) + + for charge in paymentintent.charges.data: + ReferencedStripeObject.objects.get_or_create( + reference=charge.id, + defaults={'order': rso.payment.order, 'payment': rso.payment} + ) + + return HttpResponse(status=200) + + @event_permission_required('can_change_event_settings') @require_POST def oauth_disconnect(request, **kwargs): @@ -409,18 +432,36 @@ class StripeOrderView: def pprov(self): return self.request.event.get_payment_providers()[self.payment.provider] + def _redirect_to_order(self): + if self.request.session.get('payment_stripe_order_secret') != self.order.secret: + messages.error(self.request, _('Sorry, there was an error in the payment process. Please check the link ' + 'in your emails to continue.')) + return redirect(eventreverse(self.request.event, 'presale:event.index')) + + return redirect(eventreverse(self.request.event, 'presale:event.order', kwargs={ + 'order': self.order.code, + 'secret': self.order.secret + }) + ('?paid=yes' if self.order.status == Order.STATUS_PAID else '')) + @method_decorator(xframe_options_exempt, 'dispatch') class ReturnView(StripeOrderView, View): def get(self, request, *args, **kwargs): prov = self.pprov prov._init_api() - src = stripe.Source.retrieve(request.GET.get('source'), **prov.api_kwargs) - if src.client_secret != request.GET.get('client_secret'): + try: + src = stripe.Source.retrieve(request.GET.get('source'), **prov.api_kwargs) + except stripe.error.InvalidRequestError: + logger.exception('Could not retrieve source') messages.error(self.request, _('Sorry, there was an error in the payment process. Please check the link ' 'in your emails to continue.')) return redirect(eventreverse(self.request.event, 'presale:event.index')) + if src.client_secret != request.GET.get('client_secret'): + messages.error(self.request, _('Sorry, there was an error in the payment process.' + 'in your emails to continue.')) + return redirect(eventreverse(self.request.event, 'presale:event.index')) + with transaction.atomic(): self.order.refresh_from_db() self.payment.refresh_from_db() @@ -460,13 +501,67 @@ class ReturnView(StripeOrderView, View): 'get in touch with us if this problem persists.')) return self._redirect_to_order() - def _redirect_to_order(self): - if self.request.session.get('payment_stripe_order_secret') != self.order.secret: - messages.error(self.request, _('Sorry, there was an error in the payment process. Please check the link ' - 'in your emails to continue.')) - return redirect(eventreverse(self.request.event, 'presale:event.index')) - return redirect(eventreverse(self.request.event, 'presale:event.order', kwargs={ - 'order': self.order.code, - 'secret': self.order.secret - }) + ('?paid=yes' if self.order.status == Order.STATUS_PAID else '')) +@method_decorator(xframe_options_exempt, 'dispatch') +class ScaView(StripeOrderView, View): + + def get(self, request, *args, **kwargs): + prov = self.pprov + prov._init_api() + + if self.payment.state in (OrderPayment.PAYMENT_STATE_CONFIRMED, + OrderPayment.PAYMENT_STATE_CANCELED, + OrderPayment.PAYMENT_STATE_FAILED): + return self._redirect_to_order() + + payment_info = json.loads(self.payment.info) + + if 'id' in payment_info: + try: + intent = stripe.PaymentIntent.retrieve( + payment_info['id'], + **prov.api_kwargs + ) + except stripe.error.InvalidRequestError: + logger.exception('Could not retrieve payment intent') + messages.error(self.request, _('Sorry, there was an error in the payment process.')) + return self._redirect_to_order() + else: + messages.error(self.request, _('Sorry, there was an error in the payment process.')) + return self._redirect_to_order() + + if intent.status == 'requires_action' and intent.next_action.type in ['use_stripe_sdk', 'redirect_to_url']: + ctx = { + 'order': self.order, + 'stripe_settings': StripeSettingsHolder(self.order.event).settings, + } + if intent.next_action.type == 'use_stripe_sdk': + ctx['payment_intent_client_secret'] = intent.client_secret + elif intent.next_action.type == 'redirect_to_url': + ctx['payment_intent_next_action_redirect_url'] = intent.next_action.redirect_to_url['url'] + + r = render(request, 'pretixplugins/stripe/sca.html', ctx) + r._csp_ignore = True + return r + else: + try: + prov._handle_payment_intent(request, self.payment, intent) + except PaymentException as e: + messages.error(request, str(e)) + + return self._redirect_to_order() + + +@method_decorator(xframe_options_exempt, 'dispatch') +class ScaReturnView(StripeOrderView, View): + def get(self, request, *args, **kwargs): + prov = self.pprov + + try: + prov._handle_payment_intent(request, self.payment) + except PaymentException as e: + messages.error(request, str(e)) + + self.order.refresh_from_db() + + return render(request, 'pretixplugins/stripe/sca_return.html', {'order': self.order}) diff --git a/src/pretix/static/pretixpresale/scss/main.scss b/src/pretix/static/pretixpresale/scss/main.scss index 265a71ba80..b0f6edd43a 100644 --- a/src/pretix/static/pretixpresale/scss/main.scss +++ b/src/pretix/static/pretixpresale/scss/main.scss @@ -88,6 +88,12 @@ body.loading .container { filter: blur(2px); } +.big-rotating-icon { + -webkit-animation: fa-spin 8s infinite linear; + animation: fa-spin 8s infinite linear; + font-size: 120px; + color: $brand-primary; +} #loadingmodal, #ajaxerr { position: fixed; top: 0; @@ -121,12 +127,6 @@ body.loading .container { float: left; width: 150px; text-align: center; - .big-rotating-icon { - -webkit-animation: fa-spin 8s infinite linear; - animation: fa-spin 8s infinite linear; - font-size: 120px; - color: $brand-primary; - } } .modal-card-content { margin-left: 160px; diff --git a/src/requirements/production.txt b/src/requirements/production.txt index 590411a7d6..7342eecd72 100644 --- a/src/requirements/production.txt +++ b/src/requirements/production.txt @@ -42,7 +42,7 @@ oauthlib==2.1.* django-jsonfallback>=2.1.2 psycopg2-binary # Stripe -stripe==2.0.* +stripe==2.29.* # PayPal paypalrestsdk==1.13.* pycparser==2.13 # https://github.com/eliben/pycparser/issues/147 diff --git a/src/setup.py b/src/setup.py index fc44434950..95604e1f92 100644 --- a/src/setup.py +++ b/src/setup.py @@ -125,7 +125,7 @@ setup( 'pycparser==2.13', 'django-redis==4.10.*', 'redis==3.2.*', - 'stripe==2.0.*', + 'stripe==2.29.*', 'chardet<3.1.0,>=3.0.2', 'mt-940==3.2', 'django-i18nfield>=1.4.0', diff --git a/src/tests/plugins/stripe/test_checkout.py b/src/tests/plugins/stripe/test_checkout.py index 11f58ae1ad..5e0e89e97b 100644 --- a/src/tests/plugins/stripe/test_checkout.py +++ b/src/tests/plugins/stripe/test_checkout.py @@ -10,15 +10,26 @@ from pretix.testutils.sessions import add_cart_session, get_cart_session_key class MockedCharge(): - def __init__(self): - self.status = '' - self.paid = False - self.id = 'ch_123345345' + status = '' + paid = False + id = 'ch_123345345' def refresh(self): pass +class Object(): + pass + + +class MockedPaymentintent(): + status = '' + id = 'pi_1EUon12Tb35ankTnZyvC3SdE' + charges = Object() + charges.data = [MockedCharge()] + last_payment_error = None + + @pytest.fixture def env(client): orga = Organizer.objects.create(name='CCC', slug='ccc') @@ -41,16 +52,17 @@ def env(client): @pytest.mark.django_db def test_payment(env, monkeypatch): - def charge_create(**kwargs): + def paymentintent_create(**kwargs): assert kwargs['amount'] == 1337 assert kwargs['currency'] == 'eur' - assert kwargs['source'] == 'tok_189fTT2eZvKYlo2CvJKzEzeu' - c = MockedCharge() + assert kwargs['payment_method'] == 'pm_189fTT2eZvKYlo2CvJKzEzeu' + c = MockedPaymentintent() c.status = 'succeeded' - c.paid = True - charge_create.called = True + c.charges.data[0].paid = True + setattr(paymentintent_create, 'called', True) return c - monkeypatch.setattr("stripe.Charge.create", charge_create) + + monkeypatch.setattr("stripe.PaymentIntent.create", paymentintent_create) client, ticket = env session_key = get_cart_session_key(client, ticket.event) @@ -62,17 +74,16 @@ def test_payment(env, monkeypatch): client.post('/%s/%s/checkout/questions/' % (ticket.event.organizer.slug, ticket.event.slug), { 'email': 'admin@localhost' }, follow=True) - charge_create.called = False + paymentintent_create.called = False response = client.post('/%s/%s/checkout/payment/' % (ticket.event.organizer.slug, ticket.event.slug), { 'payment': 'stripe', - 'stripe_token': 'tok_189fTT2eZvKYlo2CvJKzEzeu', + 'payment_method': 'pm_189fTT2eZvKYlo2CvJKzEzeu', 'stripe_card_brand': 'visa', 'stripe_card_last4': '1234' }, follow=True) - assert not charge_create.called + assert not paymentintent_create.called assert response.status_code == 200 assert 'alert-danger' not in response.rendered_content response = client.post('/%s/%s/checkout/confirm/' % (ticket.event.organizer.slug, ticket.event.slug), { }, follow=True) - assert charge_create.called assert response.status_code == 200 diff --git a/src/tests/plugins/stripe/test_provider.py b/src/tests/plugins/stripe/test_provider.py index e5102cc116..6af5bc1858 100644 --- a/src/tests/plugins/stripe/test_provider.py +++ b/src/tests/plugins/stripe/test_provider.py @@ -46,39 +46,51 @@ class MockedRefunds(): class MockedCharge(): - def __init__(self): - self.status = '' - self.paid = False - self.id = 'ch_123345345' - self.refunds = MockedRefunds() + status = '' + paid = False + id = 'ch_123345345' + refunds = MockedRefunds() def refresh(self): pass +class Object(): + pass + + +class MockedPaymentintent(): + status = '' + id = 'pi_1EUon12Tb35ankTnZyvC3SdE' + charges = Object() + charges.data = [MockedCharge()] + last_payment_error = None + + @pytest.mark.django_db def test_perform_success(env, factory, monkeypatch): event, order = env - def charge_create(**kwargs): + def paymentintent_create(**kwargs): assert kwargs['amount'] == 1337 assert kwargs['currency'] == 'eur' - assert kwargs['source'] == 'tok_189fTT2eZvKYlo2CvJKzEzeu' - c = MockedCharge() + assert kwargs['payment_method'] == 'pm_189fTT2eZvKYlo2CvJKzEzeu' + c = MockedPaymentintent() c.status = 'succeeded' - c.paid = True + c.charges.data[0].paid = True return c - monkeypatch.setattr("stripe.Charge.create", charge_create) + monkeypatch.setattr("stripe.PaymentIntent.create", paymentintent_create) + prov = StripeCC(event) req = factory.post('/', { - 'stripe_token': 'tok_189fTT2eZvKYlo2CvJKzEzeu', + 'stripe_payment_method_id': 'pm_189fTT2eZvKYlo2CvJKzEzeu', 'stripe_last4': '4242', 'stripe_brand': 'Visa' }) req.session = {} prov.checkout_prepare(req, {}) - assert 'payment_stripe_token' in req.session + assert 'payment_stripe_payment_method_id' in req.session payment = order.payments.create( provider='stripe_cc', amount=order.total ) @@ -93,25 +105,25 @@ def test_perform_success_zero_decimal_currency(env, factory, monkeypatch): event.currency = 'JPY' event.save() - def charge_create(**kwargs): + def paymentintent_create(**kwargs): assert kwargs['amount'] == 13 assert kwargs['currency'] == 'jpy' - assert kwargs['source'] == 'tok_189fTT2eZvKYlo2CvJKzEzeu' - c = MockedCharge() + assert kwargs['payment_method'] == 'pm_189fTT2eZvKYlo2CvJKzEzeu' + c = MockedPaymentintent() c.status = 'succeeded' - c.paid = True + c.charges.data[0].paid = True return c - monkeypatch.setattr("stripe.Charge.create", charge_create) + monkeypatch.setattr("stripe.PaymentIntent.create", paymentintent_create) prov = StripeCC(event) req = factory.post('/', { - 'stripe_token': 'tok_189fTT2eZvKYlo2CvJKzEzeu', + 'stripe_payment_method_id': 'pm_189fTT2eZvKYlo2CvJKzEzeu', 'stripe_last4': '4242', 'stripe_brand': 'Visa' }) req.session = {} prov.checkout_prepare(req, {}) - assert 'payment_stripe_token' in req.session + assert 'payment_stripe_payment_method_id' in req.session payment = order.payments.create( provider='stripe_cc', amount=order.total ) @@ -136,7 +148,7 @@ def test_perform_card_error(env, factory, monkeypatch): }) req.session = {} prov.checkout_prepare(req, {}) - assert 'payment_stripe_token' in req.session + assert 'payment_stripe_payment_method_id' in req.session with pytest.raises(PaymentException): payment = order.payments.create( provider='stripe_cc', amount=order.total @@ -162,7 +174,7 @@ def test_perform_stripe_error(env, factory, monkeypatch): }) req.session = {} prov.checkout_prepare(req, {}) - assert 'payment_stripe_token' in req.session + assert 'payment_stripe_payment_method_id' in req.session with pytest.raises(PaymentException): payment = order.payments.create( provider='stripe_cc', amount=order.total @@ -192,7 +204,7 @@ def test_perform_failed(env, factory, monkeypatch): }) req.session = {} prov.checkout_prepare(req, {}) - assert 'payment_stripe_token' in req.session + assert 'payment_stripe_payment_method_id' in req.session with pytest.raises(PaymentException): payment = order.payments.create( provider='stripe_cc', amount=order.total