From 7648be7937046ff6cab5993f72b0221a5f9c3370 Mon Sep 17 00:00:00 2001 From: Martin Gross Date: Thu, 23 Nov 2023 13:02:29 +0100 Subject: [PATCH] Stripe: Add Support for Affirm Pay Later (#3737) Co-authored-by: Raphael Michel --- src/pretix/plugins/stripe/payment.py | 63 ++++++++++++++++++- src/pretix/plugins/stripe/signals.py | 9 +-- .../pretixplugins/stripe/pretix-stripe.js | 59 +++++++++++++---- .../stripe/checkout_payment_form_affirm.html | 20 ++++++ .../templates/pretixplugins/stripe/sca.html | 1 + .../pretixplugins/stripe/sca_return.html | 2 + src/pretix/plugins/stripe/views.py | 11 +++- 7 files changed, 144 insertions(+), 21 deletions(-) create mode 100644 src/pretix/plugins/stripe/templates/pretixplugins/stripe/checkout_payment_form_affirm.html diff --git a/src/pretix/plugins/stripe/payment.py b/src/pretix/plugins/stripe/payment.py index 87976f5217..895e05fa36 100644 --- a/src/pretix/plugins/stripe/payment.py +++ b/src/pretix/plugins/stripe/payment.py @@ -123,7 +123,7 @@ logger = logging.getLogger('pretix.plugins.stripe') # - Mexico Bank Transfer: ✗ # # Buy now, pay later -# - Affirm: ✗ +# - Affirm: ✓ # - Afterpay/Clearpay: ✗ # - Klarna: ✗ # @@ -431,6 +431,16 @@ class StripeSettingsHolder(BasePaymentProvider): 'before work properly.'), required=False, )), + ('method_affirm', + forms.BooleanField( + label=_('Affirm'), + disabled=self.event.currency not in ['USD', 'CAD'], + help_text=' '.join([ + str(_('Needs to be enabled in your Stripe account first.')), + str(_('Only available for payments between $50 and $30,000.')) + ]), + required=False, + )), ] + extra_fields + list(super().settings_form_fields.items()) + moto_settings ) if not self.settings.connect_client_id or self.settings.secret_key: @@ -866,6 +876,7 @@ class StripeMethod(BasePaymentProvider): class StripePaymentIntentMethod(StripeMethod): identifier = '' method = '' + redirect_action_handling = 'iframe' # or redirect def payment_is_valid_session(self, request): return request.session.get('payment_stripe_{}_payment_method_id'.format(self.method), '') != '' @@ -896,6 +907,9 @@ class StripePaymentIntentMethod(StripeMethod): try: if self.payment_is_valid_session(request): + payment_method_id = request.session.get('payment_stripe_{}_payment_method_id'.format(self.method), None) + idempotency_key_seed = payment_method_id if payment_method_id is not None else payment.full_id + params = {} params.update(self._connect_kwargs(payment)) params.update(self.api_kwargs) @@ -913,7 +927,7 @@ class StripePaymentIntentMethod(StripeMethod): intent = stripe.PaymentIntent.create( amount=self._get_amount(payment), currency=self.event.currency.lower(), - payment_method=request.session['payment_stripe_{}_payment_method_id'.format(self.method)], + payment_method=payment_method_id, payment_method_types=[self.method], confirmation_method='manual', confirm=True, @@ -928,7 +942,7 @@ class StripePaymentIntentMethod(StripeMethod): 'code': payment.order.code }, # TODO: Is this sufficient? - idempotency_key=str(self.event.id) + payment.order.code + request.session['payment_stripe_{}_payment_method_id'.format(self.method)], + idempotency_key=str(self.event.id) + payment.order.code + idempotency_key_seed, return_url=build_absolute_uri(self.event, 'plugins:stripe:sca.return', kwargs={ 'order': payment.order.code, 'payment': payment.pk, @@ -1288,6 +1302,49 @@ class StripeSEPADirectDebit(StripePaymentIntentMethod): del request.session['payment_stripe_sepa_debit_{}'.format(field)] +class StripeAffirm(StripePaymentIntentMethod): + identifier = 'stripe_affirm' + verbose_name = _('Affirm via Stripe') + public_name = _('Affirm') + method = 'affirm' + redirect_action_handling = 'redirect' + + def payment_is_valid_session(self, request): + # Affirm does not have a payment_method_id, so we set it manually to None during checkout. + # But we still need to check for its presence here. + if 'payment_stripe_{}_payment_method_id'.format(self.method) in request.session: + return True + return False + + def checkout_prepare(self, request, cart): + # Affirm does not have a payment_method_id, so we set it manually to None during checkout, so that we can + # verify later on if we are in or outside the checkout process. + request.session['payment_stripe_{}_payment_method_id'.format(self.method)] = None + return True + + def is_allowed(self, request: HttpRequest, total: Decimal=None) -> bool: + return Decimal(50.00) <= total <= Decimal(30000.00) and super().is_allowed(request, total) + + def order_change_allowed(self, order: Order, request: HttpRequest=None) -> bool: + return Decimal(50.00) <= order.pending_sum <= Decimal(30000.00) and super().order_change_allowed(order, request) + + def _payment_intent_kwargs(self, request, payment): + return { + 'payment_method_data': { + 'type': 'affirm', + } + } + + def payment_form_render(self, request, total, order=None) -> str: + template = get_template('pretixplugins/stripe/checkout_payment_form_affirm.html') + ctx = { + 'request': request, + 'event': self.event, + 'total': self._decimal_to_int(total), + } + return template.render(ctx) + + class StripeGiropay(StripeMethod): identifier = 'stripe_giropay' verbose_name = _('giropay via Stripe') diff --git a/src/pretix/plugins/stripe/signals.py b/src/pretix/plugins/stripe/signals.py index 2f05896208..f6a2a02d26 100644 --- a/src/pretix/plugins/stripe/signals.py +++ b/src/pretix/plugins/stripe/signals.py @@ -45,15 +45,16 @@ from pretix.presale.signals import html_head, process_response @receiver(register_payment_providers, dispatch_uid="payment_stripe") def register_payment_provider(sender, **kwargs): from .payment import ( - StripeAlipay, StripeBancontact, StripeCC, StripeEPS, StripeGiropay, - StripeIdeal, StripeMultibanco, StripePrzelewy24, StripeSEPADirectDebit, - StripeSettingsHolder, StripeSofort, StripeWeChatPay, + StripeAffirm, StripeAlipay, StripeBancontact, StripeCC, StripeEPS, + StripeGiropay, StripeIdeal, StripeMultibanco, StripePrzelewy24, + StripeSEPADirectDebit, StripeSettingsHolder, StripeSofort, + StripeWeChatPay, ) return [ StripeSettingsHolder, StripeCC, StripeGiropay, StripeIdeal, StripeAlipay, StripeBancontact, StripeSofort, StripeEPS, StripeMultibanco, StripePrzelewy24, StripeWeChatPay, - StripeSEPADirectDebit, + StripeSEPADirectDebit, StripeAffirm, ] 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 beee9a4ab6..bd3a409c9b 100644 --- a/src/pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js +++ b/src/pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js @@ -6,6 +6,7 @@ var pretixstripe = { elements: null, card: null, sepa: null, + affirm: null, paymentRequest: null, paymentRequestButton: null, @@ -163,6 +164,14 @@ var pretixstripe = { $('.stripe-container').closest("form").find(".checkout-button-row .btn-primary").prop("disabled", false); }); } + if ($("#stripe-affirm").length) { + pretixstripe.affirm = pretixstripe.elements.create('affirmMessage', { + 'amount': parseInt($("#stripe_affirm_total").val()), + 'currency': $("#stripe_affirm_currency").val(), + }); + + pretixstripe.affirm.mount('#stripe-affirm'); + } if ($("#stripe-payment-request-button").length && pretixstripe.paymentRequest != null) { pretixstripe.paymentRequestButton = pretixstripe.elements.create('paymentRequestButton', { paymentRequest: pretixstripe.paymentRequest, @@ -207,24 +216,44 @@ var pretixstripe = { } }); }, - 'handleCardActioniFrame': function (payment_intent_next_action_redirect_url) { + 'handlePaymentRedirectAction': 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').on("load", function () { - waitingDialog.hide(); - }); + + let payment_intent_redirect_action_handling = $.trim($("#stripe_payment_intent_redirect_action_handling").html()); + if (payment_intent_redirect_action_handling === 'iframe') { + let iframe = document.createElement('iframe'); + iframe.src = payment_intent_next_action_redirect_url; + iframe.className = 'embed-responsive-item'; + $('#scacontainer').append(iframe); + $('#scacontainer iframe').on("load", function () { + waitingDialog.hide(); + }); + } else if (payment_intent_redirect_action_handling === 'redirect') { + window.location.href = payment_intent_next_action_redirect_url; + } } }; $(function () { if ($("#stripe_payment_intent_SCA_status").length) { - window.parent.postMessage('3DS-authentication-complete.' + $.trim($("#order_status").html()), '*'); - return; + let payment_intent_redirect_action_handling = $.trim($("#stripe_payment_intent_redirect_action_handling").html()); + let order_status = $.trim($("#order_status").html()); + let order_url = $.trim($("#order_url").html()) + + if (payment_intent_redirect_action_handling === 'iframe') { + window.parent.postMessage('3DS-authentication-complete.' + order_status, '*'); + return; + } else if (payment_intent_redirect_action_handling === 'redirect') { + waitingDialog.show(gettext("Confirming your payment …")); + + if (order_status === 'p') { + window.location.href = order_url + '?paid=yes'; + } else { + window.location.href = order_url; + } + } } 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); + pretixstripe.handlePaymentRedirectAction(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); @@ -247,11 +276,15 @@ $(function () { if (!$(".stripe-container").length) return; - if ($("input[name=payment][value=stripe]").is(':checked') || $("input[name=payment][value=stripe_sepa_debit]").is(':checked') || $(".payment-redo-form").length) { + if ( + $("input[name=payment][value=stripe]").is(':checked') + || $("input[name=payment][value=stripe_sepa_debit]").is(':checked') + || $("input[name=payment][value=stripe_affirm]").is(':checked') + || $(".payment-redo-form").length) { pretixstripe.load(); } else { $("input[name=payment]").change(function () { - if (['stripe', 'stripe_sepa_debit'].indexOf($(this).val()) > -1) { + if (['stripe', 'stripe_sepa_debit', 'stripe_affirm'].indexOf($(this).val()) > -1) { pretixstripe.load(); } }) diff --git a/src/pretix/plugins/stripe/templates/pretixplugins/stripe/checkout_payment_form_affirm.html b/src/pretix/plugins/stripe/templates/pretixplugins/stripe/checkout_payment_form_affirm.html new file mode 100644 index 0000000000..27ed6a4592 --- /dev/null +++ b/src/pretix/plugins/stripe/templates/pretixplugins/stripe/checkout_payment_form_affirm.html @@ -0,0 +1,20 @@ +{% load i18n %} +{% load bootstrap3 %} + +
+
+ + +
+ +

+ {% blocktrans trimmed %} + After you submitted your order, we will redirect you to the payment service provider to complete your + payment. + You will then be redirected back here to get your tickets. + {% endblocktrans %} +

+ + + +
diff --git a/src/pretix/plugins/stripe/templates/pretixplugins/stripe/sca.html b/src/pretix/plugins/stripe/templates/pretixplugins/stripe/sca.html index 02a51ae13c..d8a47d5b41 100644 --- a/src/pretix/plugins/stripe/templates/pretixplugins/stripe/sca.html +++ b/src/pretix/plugins/stripe/templates/pretixplugins/stripe/sca.html @@ -8,6 +8,7 @@ {% include "pretixplugins/stripe/presale_head.html" with settings=stripe_settings %} + {% endblock %} {% block content %}
diff --git a/src/pretix/plugins/stripe/templates/pretixplugins/stripe/sca_return.html b/src/pretix/plugins/stripe/templates/pretixplugins/stripe/sca_return.html index 3579b6df89..2c0c1f109a 100644 --- a/src/pretix/plugins/stripe/templates/pretixplugins/stripe/sca_return.html +++ b/src/pretix/plugins/stripe/templates/pretixplugins/stripe/sca_return.html @@ -8,7 +8,9 @@ {{ block.super }} {% include "pretixplugins/stripe/presale_head.html" with settings=stripe_settings %} + + {% endblock %} {% block page %}
diff --git a/src/pretix/plugins/stripe/views.py b/src/pretix/plugins/stripe/views.py index e9c6aabc1b..cc86ad721f 100644 --- a/src/pretix/plugins/stripe/views.py +++ b/src/pretix/plugins/stripe/views.py @@ -599,6 +599,7 @@ class ScaView(StripeOrderView, View): 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'] + ctx['payment_intent_redirect_action_handling'] = prov.redirect_action_handling r = render(request, 'pretixplugins/stripe/sca.html', ctx) r._csp_ignore = True @@ -623,8 +624,16 @@ class ScaReturnView(StripeOrderView, View): messages.error(request, str(e)) self.order.refresh_from_db() + ctx = { + 'order': self.order, + 'payment_intent_redirect_action_handling': prov.redirect_action_handling, + 'order_url': eventreverse(self.request.event, 'presale:event.order', kwargs={ + 'order': self.order.code, + 'secret': self.order.secret + }), + } - return render(request, 'pretixplugins/stripe/sca_return.html', {'order': self.order}) + return render(request, 'pretixplugins/stripe/sca_return.html', ctx) class OrganizerSettingsFormView(DecoupleMixin, OrganizerDetailViewMixin, AdministratorPermissionRequiredMixin, FormView):