From b134f29cf65656f5b3c02e3bf6e8624999028cfd Mon Sep 17 00:00:00 2001 From: Martin Gross Date: Fri, 21 Jul 2023 13:19:24 +0200 Subject: [PATCH] Fix #1749 -- Stripe: Rewrite for Payment Methods and Payment Intents (#2494) Co-authored-by: Raphael Michel Co-authored-by: Raphael Michel --- .../migrations/0004_auto_20211208_1407.py | 22 ++ src/pretix/plugins/stripe/payment.py | 359 +++++++++++++++--- src/pretix/plugins/stripe/signals.py | 9 +- .../pretixplugins/stripe/pretix-stripe.css | 14 +- .../pretixplugins/stripe/pretix-stripe.js | 162 ++++++-- .../stripe/checkout_payment_confirm.html | 16 +- ...c.html => checkout_payment_form_card.html} | 22 +- ...checkout_payment_form_sepadirectdebit.html | 67 ++++ .../pretixplugins/stripe/control.html | 6 + src/pretix/plugins/stripe/views.py | 1 + src/tests/plugins/stripe/test_checkout.py | 4 +- src/tests/plugins/stripe/test_provider.py | 40 +- 12 files changed, 585 insertions(+), 137 deletions(-) create mode 100644 src/pretix/plugins/stripe/migrations/0004_auto_20211208_1407.py rename src/pretix/plugins/stripe/templates/pretixplugins/stripe/{checkout_payment_form_cc.html => checkout_payment_form_card.html} (73%) create mode 100644 src/pretix/plugins/stripe/templates/pretixplugins/stripe/checkout_payment_form_sepadirectdebit.html diff --git a/src/pretix/plugins/stripe/migrations/0004_auto_20211208_1407.py b/src/pretix/plugins/stripe/migrations/0004_auto_20211208_1407.py new file mode 100644 index 0000000000..2501e6eeab --- /dev/null +++ b/src/pretix/plugins/stripe/migrations/0004_auto_20211208_1407.py @@ -0,0 +1,22 @@ +# Generated by Django 3.2.2 on 2021-12-08 14:07 +from django.core.cache import cache +from django.db import migrations + + +def cleanup(app, schema_editor): + EventSettingsStore = app.get_model('pretixbase', 'Event_SettingsStore') + for setting in EventSettingsStore.objects.filter(key='payment_stripe_method_cc'): + setting.key = 'payment_stripe_method_card' + cache.delete('hierarkey_{}_{}'.format('event', setting.object_id)) + setting.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ('stripe', '0003_registeredapplepaydomain'), + ] + + operations = [ + migrations.RunPython(cleanup, migrations.RunPython.noop) + ] diff --git a/src/pretix/plugins/stripe/payment.py b/src/pretix/plugins/stripe/payment.py index c648d64e53..dddd2bc3da 100644 --- a/src/pretix/plugins/stripe/payment.py +++ b/src/pretix/plugins/stripe/payment.py @@ -58,7 +58,10 @@ from django_countries import countries from pretix import __version__ from pretix.base.decimal import round_decimal from pretix.base.forms import SecretKeySettingsField -from pretix.base.models import Event, OrderPayment, OrderRefund, Quota +from pretix.base.forms.questions import guess_country +from pretix.base.models import ( + Event, InvoiceAddress, Order, OrderPayment, OrderRefund, Quota, +) from pretix.base.payment import ( BasePaymentProvider, PaymentException, WalletQueries, ) @@ -66,6 +69,8 @@ from pretix.base.plugins import get_all_plugins from pretix.base.services.mail import SendMailException from pretix.base.settings import SettingsSandbox from pretix.helpers import OF_SELF +from pretix.helpers.countries import CachedCountries +from pretix.helpers.http import get_client_ip from pretix.helpers.urls import build_absolute_uri as build_global_uri from pretix.multidomain.urlreverse import build_absolute_uri, eventreverse from pretix.plugins.stripe.forms import StripeKeyValidator @@ -75,9 +80,73 @@ from pretix.plugins.stripe.models import ( from pretix.plugins.stripe.tasks import ( get_stripe_account_key, stripe_verify_domain, ) +from pretix.presale.views.cart import cart_session logger = logging.getLogger('pretix.plugins.stripe') +# State of the payment methods +# +# Source: https://stripe.com/docs/payments/payment-methods/overview +# Last Update: 2023-04-24 +# +# Cards +# - Credit and Debit Cards: ✓ +# - Apple, Google Pay: ✓ +# +# Bank debits +# - ACH Debit: ✗ +# - Canadian PADs: ✗ +# - BACS Direct Debit: ✗ +# - SEPA Direct Debit: ✓ +# - BECS Direct Debit: ✗ +# +# Bank redirects +# - Bancontact: ✓ +# - BLIK: ✗ +# - EPS: ✓ +# - giropay: ✓ +# - iDEAL: ✓ +# - P24: ✓ +# - Sofort: ✓ +# - FPX: ✗ +# - PayNow: ✗ +# - UPI: ✗ +# - Netbanking: ✗ +# +# Bank transfers +# - ACH Bank Transfer: ✗ +# - SEPA Bank Transfer: ✗ +# - UK Bank Transfer: ✗ +# - Multibanco: ✗ +# - Furikomi (Japan): ✗ +# - Mexico Bank Transfer: ✗ +# +# Buy now, pay later +# - Affirm: ✗ +# - Afterpay/Clearpay: ✗ +# - Klarna: ✗ +# +# Real-time payments +# - PayNow: ✗ +# - PromptPay: ✗ +# - Pix: ✗ +# +# Vouchers +# - Konbini: ✗ +# - OXXO: ✗ +# - Boleto: ✗ +# +# Wallets +# - Apple Pay: ✓ (Cards) +# - Google Pay: ✓ (Cards) +# - Secure Remote Commerce: ✗ +# - Link: ✓ (PaymentRequestButton) +# - Cash App Pay: ✗ +# - MobilePay: ✗ +# - Alipay: ✓ +# - WeChat Pay: ✓ +# - GrabPay: ✓ + class StripeSettingsHolder(BasePaymentProvider): identifier = 'stripe_settings' @@ -250,7 +319,7 @@ class StripeSettingsHolder(BasePaymentProvider): d = OrderedDict( fields + [ - ('method_cc', + ('method_card', forms.BooleanField( label=_('Credit card payments'), required=False, @@ -283,6 +352,32 @@ class StripeSettingsHolder(BasePaymentProvider): help_text=_('Needs to be enabled in your Stripe account first.'), required=False, )), + ('method_sepa_debit', + forms.BooleanField( + label=_('SEPA Direct Debit'), + disabled=self.event.currency != 'EUR', + help_text=( + _('Needs to be enabled in your Stripe account first.') + + '
%s
' % _( + 'SEPA Direct Debit payments via Stripe are not processed ' + 'instantly but might take up to 14 days to be confirmed in some cases. ' + 'Please only activate this payment method if your payment term allows for this lag.' + )), + required=False, + )), + ('sepa_creditor_name', + forms.CharField( + label=_('SEPA Creditor Mandate Name'), + disabled=self.event.currency != 'EUR', + help_text=_('Please provide your SEPA Creditor Mandate Name, that will be displayed to the user.'), + required=False, + widget=forms.TextInput( + attrs={ + 'data-display-dependency': '#id_payment_stripe_method_sepa_debit', + 'data-required-if': '#id_payment_stripe_method_sepa_debit' + } + ), + )), ('method_sofort', forms.BooleanField( label=_('SOFORT'), @@ -757,44 +852,17 @@ class StripeMethod(BasePaymentProvider): le.save(update_fields=['data', 'shredded']) -class StripeCC(StripeMethod): - identifier = 'stripe' - verbose_name = _('Credit card via Stripe') - public_name = _('Credit card') - method = 'cc' - - @property - def walletqueries(self): - # ToDo: Check against Stripe API, if ApplePay and GooglePay are even activated/available - # This is probably only really feasable once the Payment Methods Configuration API is out of beta - # https://stripe.com/docs/connect/payment-method-configurations - if self.settings.get("walletdetection", True, as_type=bool): - return [WalletQueries.APPLEPAY, WalletQueries.GOOGLEPAY] - return [] - - def payment_form_render(self, request, total) -> str: - account = get_stripe_account_key(self) - if not RegisteredApplePayDomain.objects.filter(account=account, domain=request.host).exists(): - stripe_verify_domain.apply_async(args=(self.event.pk, request.host)) - - template = get_template('pretixplugins/stripe/checkout_payment_form_cc.html') - ctx = { - 'request': request, - 'event': self.event, - 'total': self._decimal_to_int(total), - 'settings': self.settings, - 'is_moto': self.is_moto(request) - } - return template.render(ctx) +class StripePaymentIntentMethod(StripeMethod): + identifier = '' + method = '' def payment_is_valid_session(self, request): - return request.session.get('payment_stripe_payment_method_id', '') != '' + return request.session.get('payment_stripe_{}_payment_method_id'.format(self.method), '') != '' def checkout_prepare(self, request, cart): - 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', '') + payment_method_id = request.POST.get('stripe_{}_payment_method_id'.format(self.method), '') + request.session['payment_stripe_{}_payment_method_id'.format(self.method)] = payment_method_id + if payment_method_id == '': messages.warning(request, _('You may need to enable JavaScript for Stripe payments.')) return False @@ -804,21 +872,13 @@ class StripeCC(StripeMethod): try: return self._handle_payment_intent(request, payment) finally: - del request.session['payment_stripe_payment_method_id'] + del request.session['payment_stripe_{}_payment_method_id'.format(self.method)] def is_moto(self, request, payment=None) -> bool: - # We don't have a payment yet when checking if we should display the MOTO-flag - # However, before we execute the payment, we absolutely have to check if the request-SalesChannel as well as the - # order are tagged as a reseller-transaction. Else, a user with a valid reseller-session might be able to place - # a MOTO transaction trough the WebShop. + return False - moto = self.settings.get('reseller_moto', False, as_type=bool) and \ - request.sales_channel.identifier == 'resellers' - - if payment: - return moto and payment.order.sales_channel == 'resellers' - - return moto + def _payment_intent_kwargs(self, request, payment): + return {} def _handle_payment_intent(self, request, payment, intent=None): self._init_api() @@ -828,6 +888,7 @@ class StripeCC(StripeMethod): params = {} params.update(self._connect_kwargs(payment)) params.update(self.api_kwargs) + params.update(self._payment_intent_kwargs(request, payment)) if self.is_moto(request, payment): params.update({ @@ -841,7 +902,8 @@ class StripeCC(StripeMethod): intent = stripe.PaymentIntent.create( amount=self._get_amount(payment), currency=self.event.currency.lower(), - payment_method=request.session['payment_stripe_payment_method_id'], + payment_method=request.session['payment_stripe_{}_payment_method_id'.format(self.method)], + payment_method_types=[self.method], confirmation_method='manual', confirm=True, description='{event}-{code}'.format( @@ -855,7 +917,7 @@ class StripeCC(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'], + idempotency_key=str(self.event.id) + payment.order.code + request.session['payment_stripe_{}_payment_method_id'.format(self.method)], return_url=build_absolute_uri(self.event, 'plugins:stripe:sca.return', kwargs={ 'order': payment.order.code, 'payment': payment.pk, @@ -1002,6 +1064,78 @@ class StripeCC(StripeMethod): raise PaymentException(_('We had trouble communicating with Stripe. Please try again and get in touch ' 'with us if this problem persists.')) + +class StripeCC(StripePaymentIntentMethod): + identifier = 'stripe' + verbose_name = _('Credit card via Stripe') + public_name = _('Credit card') + method = 'card' + + @property + def walletqueries(self): + # ToDo: Check against Stripe API, if ApplePay and GooglePay are even activated/available + # This is probably only really feasable once the Payment Methods Configuration API is out of beta + # https://stripe.com/docs/connect/payment-method-configurations + if self.settings.get("walletdetection", True, as_type=bool): + return [WalletQueries.APPLEPAY, WalletQueries.GOOGLEPAY] + return [] + + def payment_form_render(self, request, total, order=None) -> str: + account = get_stripe_account_key(self) + if not RegisteredApplePayDomain.objects.filter(account=account, domain=request.host).exists(): + stripe_verify_domain.apply_async(args=(self.event.pk, request.host)) + + template = get_template('pretixplugins/stripe/checkout_payment_form_card.html') + ctx = { + 'request': request, + 'event': self.event, + 'total': self._decimal_to_int(total), + 'settings': self.settings, + 'is_moto': self.is_moto(request) + } + return template.render(ctx) + + def _migrate_session(self, request): + # todo: remove after pretix 2023.8 was released + keymap = { + 'payment_stripe_payment_method_id': 'payment_stripe_card_payment_method_id', + 'payment_stripe_brand': 'payment_stripe_card_brand', + 'payment_stripe_last4': 'payment_stripe_card_last4', + } + for old, new in keymap.items(): + if old in request.session: + request.session[new] = request.session[old] + del request.session[old] + + def checkout_prepare(self, request, cart): + self._migrate_session(request) + request.session['payment_stripe_card_brand'] = request.POST.get('stripe_card_brand', '') + request.session['payment_stripe_card_last4'] = request.POST.get('stripe_card_last4', '') + + return super().checkout_prepare(request, cart) + + def payment_is_valid_session(self, request): + self._migrate_session(request) + return super().payment_is_valid_session(request) + + def _handle_payment_intent(self, request, payment, intent=None): + self._migrate_session(request) + return super()._handle_payment_intent(request, payment, intent) + + def is_moto(self, request, payment=None) -> bool: + # We don't have a payment yet when checking if we should display the MOTO-flag + # However, before we execute the payment, we absolutely have to check if the request-SalesChannel as well as the + # order are tagged as a reseller-transaction. Else, a user with a valid reseller-session might be able to place + # a MOTO transaction trough the WebShop. + + moto = self.settings.get('reseller_moto', False, as_type=bool) and \ + request.sales_channel.identifier == 'resellers' + + if payment: + return moto and payment.order.sales_channel == 'resellers' + + return moto + def payment_presale_render(self, payment: OrderPayment) -> str: pi = payment.info_data or {} try: @@ -1018,6 +1152,131 @@ class StripeCC(StripeMethod): f'{_("expires {month}/{year}").format(month=card.get("exp_month"), year=card.get("exp_year"))}' +class StripeSEPADirectDebit(StripePaymentIntentMethod): + identifier = 'stripe_sepa_debit' + verbose_name = _('SEPA Debit via Stripe') + public_name = _('SEPA Debit') + method = 'sepa_debit' + ia = InvoiceAddress() + + def payment_form_render(self, request: HttpRequest, total: Decimal, order: Order=None) -> str: + def get_invoice_address(): + if order and getattr(order, 'invoice_address', None): + request._checkout_flow_invoice_address = order.invoice_address + if not hasattr(request, '_checkout_flow_invoice_address'): + cs = cart_session(request) + iapk = cs.get('invoice_address') + if not iapk: + request._checkout_flow_invoice_address = InvoiceAddress() + else: + try: + request._checkout_flow_invoice_address = InvoiceAddress.objects.get(pk=iapk, order__isnull=True) + except InvoiceAddress.DoesNotExist: + request._checkout_flow_invoice_address = InvoiceAddress() + return request._checkout_flow_invoice_address + + cs = cart_session(request) + self.ia = get_invoice_address() + + template = get_template('pretixplugins/stripe/checkout_payment_form_sepadirectdebit.html') + ctx = { + 'request': request, + 'event': self.event, + 'settings': self.settings, + 'form': self.payment_form(request), + 'email': order.email if order else cs.get('email', '') + } + return template.render(ctx) + + @property + def payment_form_fields(self): + return OrderedDict( + [ + ('accountname', + forms.CharField( + label=_('Account Holder Name'), + initial=self.ia.name, + )), + ('line1', + forms.CharField( + label=_('Account Holder Street'), + required=False, + widget=forms.TextInput( + attrs={ + 'data-display-dependency': '#stripe_sepa_debit_country', + 'data-required-if': '#stripe_sepa_debit_country' + } + ), + initial=self.ia.street, + )), + ('postal_code', + forms.CharField( + label=_('Account Holder Postal Code'), + required=False, + widget=forms.TextInput( + attrs={ + 'data-display-dependency': '#stripe_sepa_debit_country', + 'data-required-if': '#stripe_sepa_debit_country' + } + ), + initial=self.ia.zipcode, + )), + ('city', + forms.CharField( + label=_('Account Holder City'), + required=False, + widget=forms.TextInput( + attrs={ + 'data-display-dependency': '#stripe_sepa_debit_country', + 'data-required-if': '#stripe_sepa_debit_country' + } + ), + initial=self.ia.city, + )), + ('country', + forms.ChoiceField( + label=_('Account Holder Country'), + required=False, + choices=CachedCountries(), + widget=forms.Select( + attrs={ + 'data-display-dependency': '#stripe_sepa_debit_country', + 'data-required-if': '#stripe_sepa_debit_country' + } + ), + initial=self.ia.country or guess_country(self.event), + )), + ]) + + def _payment_intent_kwargs(self, request, payment): + return { + 'mandate_data': { + 'customer_acceptance': { + 'type': 'online', + 'online': { + 'ip_address': get_client_ip(request), + 'user_agent': request.META['HTTP_USER_AGENT'], + } + }, + } + } + + def checkout_prepare(self, request, cart): + request.session['payment_stripe_sepa_debit_last4'] = request.POST.get('stripe_sepa_debit_last4', '') + request.session['payment_stripe_sepa_debit_bank'] = request.POST.get('stripe_sepa_debit_bank', '') + + return super().checkout_prepare(request, cart) + + def execute_payment(self, request: HttpRequest, payment: OrderPayment): + try: + super().execute_payment(request, payment) + finally: + fields = ['accountname', 'line1', 'postal_code', 'city', 'country'] + for field in fields: + if 'payment_stripe_sepa_debit_{}'.format(field) in request.session: + del request.session['payment_stripe_sepa_debit_{}'.format(field)] + + 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 2d700b0566..2f05896208 100644 --- a/src/pretix/plugins/stripe/signals.py +++ b/src/pretix/plugins/stripe/signals.py @@ -46,13 +46,14 @@ from pretix.presale.signals import html_head, process_response def register_payment_provider(sender, **kwargs): from .payment import ( StripeAlipay, StripeBancontact, StripeCC, StripeEPS, StripeGiropay, - StripeIdeal, StripeMultibanco, StripePrzelewy24, StripeSettingsHolder, - StripeSofort, StripeWeChatPay, + StripeIdeal, StripeMultibanco, StripePrzelewy24, StripeSEPADirectDebit, + StripeSettingsHolder, StripeSofort, StripeWeChatPay, ) return [ StripeSettingsHolder, StripeCC, StripeGiropay, StripeIdeal, StripeAlipay, StripeBancontact, - StripeSofort, StripeEPS, StripeMultibanco, StripePrzelewy24, StripeWeChatPay + StripeSofort, StripeEPS, StripeMultibanco, StripePrzelewy24, StripeWeChatPay, + StripeSEPADirectDebit, ] @@ -110,7 +111,7 @@ def pretixcontrol_logentry_display(sender, logentry, **kwargs): return _('Stripe reported an event: {}').format(text) -settings_hierarkey.add_default('payment_stripe_method_cc', True, bool) +settings_hierarkey.add_default('payment_stripe_method_card', True, bool) settings_hierarkey.add_default('payment_stripe_reseller_moto', False, bool) 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 c9e8a9faad..3ff086fc48 100644 --- a/src/pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.css +++ b/src/pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.css @@ -39,39 +39,39 @@ .sepText { left: 50%; } - #stripe-elements > div.hidden { + #stripe-card-elements > div.hidden { height: 0; padding-top: 0; padding-bottom: 0; overflow: hidden; display: block !important; } - #stripe-elements .stripe-or { + #stripe-card-elements .stripe-or { height: 16px; } - #stripe-elements .stripe-payment-request-button { + #stripe-card-elements .stripe-payment-request-button { height: 40px; } - #stripe-elements > div { + #stripe-card-elements > div { transition: height 0.3s ease-out, padding-top 0.3s ease-out, padding-bottom 0.3s ease-out; } } @media only screen and (min-width: 999px) { - #stripe-elements { + #stripe-card-elements { display: flex; flex-wrap: wrap; } .stripe-card-holder { flex-grow: 1; } - #stripe-elements > div.hidden { + #stripe-card-elements > div.hidden { width: 0; padding: 0; overflow: hidden; display: block !important; } - #stripe-elements > div { + #stripe-card-elements > div { transition: width 0.3s ease-out, padding-left 0.3s ease-out, padding-right 0.3s ease-out; } } 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 a9ab2049c6..beee9a4ab6 100644 --- a/src/pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js +++ b/src/pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js @@ -5,26 +5,31 @@ var pretixstripe = { stripe: null, elements: null, card: null, + sepa: null, paymentRequest: null, paymentRequestButton: null, - 'cc_request': function () { + 'pm_request': function (method, element, kwargs = {}) { waitingDialog.show(gettext("Contacting Stripe …")); $(".stripe-errors").hide(); - // ToDo: 'card' --> proper type of payment method - pretixstripe.stripe.createPaymentMethod('card', pretixstripe.card).then(function (result) { + pretixstripe.stripe.createPaymentMethod(method, element, kwargs).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_payment_method_id").closest("form"); + var $form = $("#stripe_" + method + "_payment_method_id").closest("form"); // Insert the token into the form so it gets submitted to the server - $("#stripe_payment_method_id").val(result.paymentMethod.id); - $("#stripe_card_brand").val(result.paymentMethod.card.brand); - $("#stripe_card_last4").val(result.paymentMethod.card.last4); + $("#stripe_" + method + "_payment_method_id").val(result.paymentMethod.id); + if (method === 'card') { + $("#stripe_card_brand").val(result.paymentMethod.card.brand); + $("#stripe_card_last4").val(result.paymentMethod.card.last4); + } + if (method === 'sepa_debit') { + $("#stripe_sepa_debit_last4").val(result.paymentMethod.sepa_debit.last4); + } // and submit $form.get(0).submit(); } @@ -36,10 +41,10 @@ var pretixstripe = { }); }, 'load': function () { - if (pretixstripe.stripe !== null) { - return; - } - $('.stripe-container').closest("form").find(".checkout-button-row .btn-primary").prop("disabled", true); + if (pretixstripe.stripe !== null) { + return; + } + $('.stripe-container').closest("form").find(".checkout-button-row .btn-primary").prop("disabled", true); $.ajax( { url: 'https://js.stripe.com/v3/', @@ -60,10 +65,10 @@ var pretixstripe = { try { pretixstripe.paymentRequest = pretixstripe.stripe.paymentRequest({ country: $("#stripe_merchantcountry").html(), - currency: $("#stripe_currency").val().toLowerCase(), + currency: $("#stripe_card_currency").val().toLowerCase(), total: { label: gettext('Total'), - amount: parseInt($("#stripe_total").val()) + amount: parseInt($("#stripe_card_total").val()) }, displayItems: [], requestPayerName: false, @@ -75,9 +80,9 @@ var pretixstripe = { pretixstripe.paymentRequest.on('paymentmethod', function (ev) { ev.complete('success'); - var $form = $("#stripe_payment_method_id").closest("form"); + var $form = $("#stripe_card_payment_method_id").closest("form"); // Insert the token into the form so it gets submitted to the server - $("#stripe_payment_method_id").val(ev.paymentMethod.id); + $("#stripe_card_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 @@ -112,25 +117,67 @@ var pretixstripe = { } }); pretixstripe.card.mount("#stripe-card"); + pretixstripe.card.on('ready', function () { + $('.stripe-container').closest("form").find(".checkout-button-row .btn-primary").prop("disabled", false); + }); + } + if ($("#stripe-sepa").length) { + pretixstripe.sepa = pretixstripe.elements.create('iban', { + 'style': { + 'base': { + 'fontFamily': '"Open Sans","OpenSans","Helvetica Neue",Helvetica,Arial,sans-serif', + 'fontSize': '14px', + 'color': '#555555', + 'lineHeight': '1.42857', + 'border': '1px solid #ccc', + '::placeholder': { + color: 'rgba(0,0,0,0.4)', + }, + }, + 'invalid': { + 'color': 'red', + }, + }, + supportedCountries: ['SEPA'], + classes: { + focus: 'is-focused', + invalid: 'has-error', + } + }); + pretixstripe.sepa.on('change', function (event) { + // List of IBAN-countries, that require the country as well as line1-property according to + // https://stripe.com/docs/payments/sepa-debit/accept-a-payment?platform=web&ui=element#web-submit-payment + if (['AD', 'PF', 'TF', 'GI', 'GB', 'GG', 'VA', 'IM', 'JE', 'MC', 'NC', 'BL', 'PM', 'SM', 'CH', 'WF'].indexOf(event.country) > 0) { + $("#stripe_sepa_debit_country").prop('checked', true); + $("#stripe_sepa_debit_country").change(); + } else { + $("#stripe_sepa_debit_country").prop('checked', false); + $("#stripe_sepa_debit_country").change(); + } + if (event.bankName) { + $("#stripe_sepa_debit_bank").val(event.bankName); + } + }); + pretixstripe.sepa.mount("#stripe-sepa"); + pretixstripe.sepa.on('ready', function () { + $('.stripe-container').closest("form").find(".checkout-button-row .btn-primary").prop("disabled", false); + }); } - pretixstripe.card.on('ready', function () { - $('.stripe-container').closest("form").find(".checkout-button-row .btn-primary").prop("disabled", false); - }); if ($("#stripe-payment-request-button").length && pretixstripe.paymentRequest != null) { - pretixstripe.paymentRequestButton = pretixstripe.elements.create('paymentRequestButton', { - paymentRequest: pretixstripe.paymentRequest, - }); + pretixstripe.paymentRequestButton = pretixstripe.elements.create('paymentRequestButton', { + paymentRequest: pretixstripe.paymentRequest, + }); - pretixstripe.paymentRequest.canMakePayment().then(function(result) { - if (result) { - pretixstripe.paymentRequestButton.mount('#stripe-payment-request-button'); - $('#stripe-elements .stripe-or').removeClass("hidden"); - $('#stripe-payment-request-button').parent().removeClass("hidden"); - } else { - $('#stripe-payment-request-button').hide(); - document.getElementById('stripe-payment-request-button').style.display = 'none'; - } - }); + pretixstripe.paymentRequest.canMakePayment().then(function (result) { + if (result) { + pretixstripe.paymentRequestButton.mount('#stripe-payment-request-button'); + $('#stripe-card-elements .stripe-or').removeClass("hidden"); + $('#stripe-payment-request-button').parent().removeClass("hidden"); + } else { + $('#stripe-payment-request-button').hide(); + document.getElementById('stripe-payment-request-button').style.display = 'none'; + } + }); } } } @@ -183,7 +230,7 @@ $(function () { pretixstripe.handleCardAction(payment_intent_client_secret); } - $(window).on("message onmessage", function(e) { + $(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(); @@ -200,11 +247,11 @@ $(function () { if (!$(".stripe-container").length) return; - if ($("input[name=payment][value=stripe]").is(':checked') || $(".payment-redo-form").length) { - pretixstripe.load(); + if ($("input[name=payment][value=stripe]").is(':checked') || $("input[name=payment][value=stripe_sepa_debit]").is(':checked') || $(".payment-redo-form").length) { + pretixstripe.load(); } else { $("input[name=payment]").change(function () { - if ($(this).val() === 'stripe') { + if (['stripe', 'stripe_sepa_debit'].indexOf($(this).val()) > -1) { pretixstripe.load(); } }) @@ -212,9 +259,9 @@ $(function () { $("#stripe_other_card").click( function (e) { - $("#stripe_payment_method_id").val(""); + $("#stripe_card_payment_method_id").val(""); $("#stripe-current-card").slideUp(); - $("#stripe-elements").slideDown(); + $("#stripe-card-elements").slideDown(); e.preventDefault(); return false; @@ -222,7 +269,26 @@ $(function () { ); if ($("#stripe-current-card").length) { - $("#stripe-elements").hide(); + $("#stripe-card-elements").hide(); + } + + $("#stripe_other_account").click( + function (e) { + $("#stripe_sepa_debit_payment_method_id").val(""); + $("#stripe-current-account").slideUp(); + // We're using a css-selector here instead of the id-selector, + // as we're hiding Stripe Elements *and* Django form fields + $('.stripe-sepa_debit-form').slideDown(); + + e.preventDefault(); + return false; + } + ); + + if ($("#stripe-current-account").length) { + // We're using a css-selector here instead of the id-selector, + // as we're hiding Stripe Elements *and* Django form fields + $('.stripe-sepa_debit-form').hide(); } $('.stripe-container').closest("form").submit( @@ -231,8 +297,24 @@ $(function () { return null; } if (($("input[name=payment][value=stripe]").prop('checked') || $("input[name=payment][type=radio]").length === 0) - && $("#stripe_payment_method_id").val() == "") { - pretixstripe.cc_request(); + && $("#stripe_card_payment_method_id").val() == "") { + pretixstripe.pm_request('card', pretixstripe.card); + return false; + } + + if (($("input[name=payment][value=stripe_sepa_debit]").prop('checked')) && $("#stripe_sepa_debit_payment_method_id").val() == "") { + pretixstripe.pm_request('sepa_debit', pretixstripe.sepa, { + billing_details: { + name: $("#id_payment_stripe_sepa_debit-accountname").val(), + email: $("#stripe_sepa_debit_email").val(), + address: { + line1: $("#id_payment_stripe_sepa_debit-line1").val(), + postal_code: $("#id_payment_stripe_sepa_debit-postal_code").val(), + city: $("#id_payment_stripe_sepa_debit-city").val(), + country: $("#id_payment_stripe_sepa_debit-country").val(), + } + } + }); return false; } } diff --git a/src/pretix/plugins/stripe/templates/pretixplugins/stripe/checkout_payment_confirm.html b/src/pretix/plugins/stripe/templates/pretixplugins/stripe/checkout_payment_confirm.html index 97f0e12332..cf555653a1 100644 --- a/src/pretix/plugins/stripe/templates/pretixplugins/stripe/checkout_payment_confirm.html +++ b/src/pretix/plugins/stripe/templates/pretixplugins/stripe/checkout_payment_confirm.html @@ -1,14 +1,24 @@ {% load i18n %} -{% if provider.method == "cc" %} +{% if provider.method == "card" %}

{% blocktrans trimmed %} The total amount will be withdrawn from your credit card. {% endblocktrans %}

{% trans "Card type" %}
-
{{ request.session.payment_stripe_brand }}
+
{{ request.session.payment_stripe_card_brand }}
{% trans "Card number" %}
-
**** **** **** {{ request.session.payment_stripe_last4 }}
+
**** **** **** {{ request.session.payment_stripe_card_last4 }}
+
+{% elif provider.method == "sepa_debit" %} +

{% blocktrans trimmed %} + The total amount will be withdrawn from your bank account. + {% endblocktrans %}

+
+
{% trans "Banking Institution" %}
+
{{ request.session.payment_stripe_sepa_debit_bank }}
+
{% trans "Account number" %}
+
**** **** **** {{ request.session.payment_stripe_sepa_debit_last4 }}
{% else %}

{% blocktrans trimmed %} diff --git a/src/pretix/plugins/stripe/templates/pretixplugins/stripe/checkout_payment_form_cc.html b/src/pretix/plugins/stripe/templates/pretixplugins/stripe/checkout_payment_form_card.html similarity index 73% rename from src/pretix/plugins/stripe/templates/pretixplugins/stripe/checkout_payment_form_cc.html rename to src/pretix/plugins/stripe/templates/pretixplugins/stripe/checkout_payment_form_card.html index a0a5cc8365..45200d10c4 100644 --- a/src/pretix/plugins/stripe/templates/pretixplugins/stripe/checkout_payment_form_cc.html +++ b/src/pretix/plugins/stripe/templates/pretixplugins/stripe/checkout_payment_form_card.html @@ -3,7 +3,7 @@

{% if is_moto %}

- MOTO + MOTO

{% endif %} @@ -17,18 +17,18 @@
- {% if request.session.payment_stripe_payment_method_id %} + {% if request.session.payment_stripe_card_payment_method_id %}

{% 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 }}
+
{{ request.session.payment_stripe_card_brand }}
{% trans "Card number" %}
**** **** **** - {{ request.session.payment_stripe_last4 }} + {{ request.session.payment_stripe_card_last4 }} @@ -37,7 +37,7 @@
{% endif %} -
+
@@ -64,12 +64,10 @@ Your payment will be processed by Stripe, Inc. Your credit card data will be transmitted directly to Stripe and never touches our servers. {% endblocktrans %} - - - - - + + + + +

diff --git a/src/pretix/plugins/stripe/templates/pretixplugins/stripe/checkout_payment_form_sepadirectdebit.html b/src/pretix/plugins/stripe/templates/pretixplugins/stripe/checkout_payment_form_sepadirectdebit.html new file mode 100644 index 0000000000..b16fe7fd5e --- /dev/null +++ b/src/pretix/plugins/stripe/templates/pretixplugins/stripe/checkout_payment_form_sepadirectdebit.html @@ -0,0 +1,67 @@ +{% load i18n %} +{% load bootstrap3 %} + +
+
+ +
+ + + {% if request.session.payment_stripe_sepa_debit_payment_method_id %} +
+

{% blocktrans trimmed %} + You already entered a bank account that we will use to charge the payment amount. + {% endblocktrans %}

+
+
{% trans "Banking Institution" %}
+
{{ request.session.payment_stripe_sepa_debit_bank }}
+
{% trans "Account number" %}
+
+ **** **** **** + {{ request.session.payment_stripe_sepa_debit_last4 }} + +
+
+
+ {% endif %} +
+
+ +
+
+
+
+ + +
+
+
+
+
+ {% bootstrap_form form layout='horizontal' %} +
+ +

+ {% blocktrans trimmed with sepa_creditor_name=settings.sepa_creditor_name %} + By providing your payment information and confirming this payment, you authorise (A) + {{ sepa_creditor_name }} and Stripe, our payment service provider and/or PPRO, its local service provider, + to send instructions to your bank to debit your account and (B) your bank to debit your account in + accordance with those instructions. As part of your rights, you are entitled to a refund from your bank + under the terms and conditions of your agreement with your bank. A refund must be claimed within 8 weeks + starting from the date on which your account was debited. Your rights are explained in a statement that you + can obtain from your bank. You agree to receive notifications for future debits up to 2 days before they + occur. + {% endblocktrans %} + + + + + +

+
diff --git a/src/pretix/plugins/stripe/templates/pretixplugins/stripe/control.html b/src/pretix/plugins/stripe/templates/pretixplugins/stripe/control.html index c5d0ce999d..45b1ab7090 100644 --- a/src/pretix/plugins/stripe/templates/pretixplugins/stripe/control.html +++ b/src/pretix/plugins/stripe/templates/pretixplugins/stripe/control.html @@ -17,6 +17,12 @@
{{ payment_info.source.owner.name }}
{% endif %} {% endif %} + {% if payment_info.source.type == "sepa_debit" %} +
{% trans "Bank" %}
+
{{ payment_info.source.sepadirectdebit.bank_name }}
+
{% trans "Payer name" %}
+
{{ payment_info.source.owner.verified_name|default:payment_info.source.owner.name }}
+ {% endif %} {% if payment_info.source.type == "giropay" %}
{% trans "Bank" %}
{{ payment_info.source.giropay.bank_name }} ({{ payment_info.source.giropay.bic }})
diff --git a/src/pretix/plugins/stripe/views.py b/src/pretix/plugins/stripe/views.py index 39c71d4e6d..89ca6307eb 100644 --- a/src/pretix/plugins/stripe/views.py +++ b/src/pretix/plugins/stripe/views.py @@ -259,6 +259,7 @@ SOURCE_TYPES = { 'sofort': 'stripe_sofort', 'three_d_secure': 'stripe', 'card': 'stripe', + 'sepa_debit': 'stripe_sepa_debit', 'giropay': 'stripe_giropay', 'ideal': 'stripe_ideal', 'alipay': 'stripe_alipay', diff --git a/src/tests/plugins/stripe/test_checkout.py b/src/tests/plugins/stripe/test_checkout.py index e3097e1253..f86c79112e 100644 --- a/src/tests/plugins/stripe/test_checkout.py +++ b/src/tests/plugins/stripe/test_checkout.py @@ -99,6 +99,8 @@ def test_payment(env, monkeypatch): monkeypatch.setattr("stripe.PaymentIntent.create", paymentintent_create) client, ticket = env + ticket.default_price = 13.37 + ticket.save() session_key = get_cart_session_key(client, ticket.event) CartPosition.objects.create( event=ticket.event, cart_id=session_key, item=ticket, @@ -111,7 +113,7 @@ def test_payment(env, monkeypatch): paymentintent_create.called = False response = client.post('/%s/%s/checkout/payment/' % (ticket.event.organizer.slug, ticket.event.slug), { 'payment': 'stripe', - 'payment_method': 'pm_189fTT2eZvKYlo2CvJKzEzeu', + 'stripe_card_payment_method_id': 'pm_189fTT2eZvKYlo2CvJKzEzeu', 'stripe_card_brand': 'visa', 'stripe_card_last4': '1234' }, follow=True) diff --git a/src/tests/plugins/stripe/test_provider.py b/src/tests/plugins/stripe/test_provider.py index 81fb445ff0..f6b4ac3e4c 100644 --- a/src/tests/plugins/stripe/test_provider.py +++ b/src/tests/plugins/stripe/test_provider.py @@ -125,13 +125,13 @@ def test_perform_success(env, factory, monkeypatch): prov = StripeCC(event) req = factory.post('/', { - 'stripe_payment_method_id': 'pm_189fTT2eZvKYlo2CvJKzEzeu', - 'stripe_last4': '4242', - 'stripe_brand': 'Visa' + 'stripe_card_payment_method_id': 'pm_189fTT2eZvKYlo2CvJKzEzeu', + 'stripe_card_last4': '4242', + 'stripe_card_brand': 'Visa' }) req.session = {} prov.checkout_prepare(req, {}) - assert 'payment_stripe_payment_method_id' in req.session + assert 'payment_stripe_card_payment_method_id' in req.session payment = order.payments.create( provider='stripe_cc', amount=order.total ) @@ -158,13 +158,13 @@ def test_perform_success_zero_decimal_currency(env, factory, monkeypatch): monkeypatch.setattr("stripe.PaymentIntent.create", paymentintent_create) prov = StripeCC(event) req = factory.post('/', { - 'stripe_payment_method_id': 'pm_189fTT2eZvKYlo2CvJKzEzeu', - 'stripe_last4': '4242', - 'stripe_brand': 'Visa' + 'stripe_card_payment_method_id': 'pm_189fTT2eZvKYlo2CvJKzEzeu', + 'stripe_card_last4': '4242', + 'stripe_card_brand': 'Visa' }) req.session = {} prov.checkout_prepare(req, {}) - assert 'payment_stripe_payment_method_id' in req.session + assert 'payment_stripe_card_payment_method_id' in req.session payment = order.payments.create( provider='stripe_cc', amount=order.total ) @@ -183,13 +183,13 @@ def test_perform_card_error(env, factory, monkeypatch): monkeypatch.setattr("stripe.PaymentIntent.create", paymentintent_create) prov = StripeCC(event) req = factory.post('/', { - 'stripe_payment_method_id': 'pm_189fTT2eZvKYlo2CvJKzEzeu', - 'stripe_last4': '4242', - 'stripe_brand': 'Visa' + 'stripe_card_payment_method_id': 'pm_189fTT2eZvKYlo2CvJKzEzeu', + 'stripe_card_last4': '4242', + 'stripe_card_brand': 'Visa' }) req.session = {} prov.checkout_prepare(req, {}) - assert 'payment_stripe_payment_method_id' in req.session + assert 'payment_stripe_card_payment_method_id' in req.session with pytest.raises(PaymentException): payment = order.payments.create( provider='stripe_cc', amount=order.total @@ -209,13 +209,13 @@ def test_perform_stripe_error(env, factory, monkeypatch): monkeypatch.setattr("stripe.PaymentIntent.create", paymentintent_create) prov = StripeCC(event) req = factory.post('/', { - 'stripe_payment_method_id': 'pm_189fTT2eZvKYlo2CvJKzEzeu', - 'stripe_last4': '4242', - 'stripe_brand': 'Visa' + 'stripe_card_payment_method_id': 'pm_189fTT2eZvKYlo2CvJKzEzeu', + 'stripe_card_last4': '4242', + 'stripe_card_brand': 'Visa' }) req.session = {} prov.checkout_prepare(req, {}) - assert 'payment_stripe_payment_method_id' in req.session + assert 'payment_stripe_card_payment_method_id' in req.session with pytest.raises(PaymentException): payment = order.payments.create( provider='stripe_cc', amount=order.total @@ -244,13 +244,13 @@ def test_perform_failed(env, factory, monkeypatch): monkeypatch.setattr("stripe.PaymentIntent.create", paymentintent_create) prov = StripeCC(event) req = factory.post('/', { - 'stripe_payment_method_id': 'pm_189fTT2eZvKYlo2CvJKzEzeu', - 'stripe_last4': '4242', - 'stripe_brand': 'Visa' + 'stripe_card_payment_method_id': 'pm_189fTT2eZvKYlo2CvJKzEzeu', + 'stripe_card_last4': '4242', + 'stripe_card_brand': 'Visa' }) req.session = {} prov.checkout_prepare(req, {}) - assert 'payment_stripe_payment_method_id' in req.session + assert 'payment_stripe_card_payment_method_id' in req.session with pytest.raises(PaymentException): payment = order.payments.create( provider='stripe_cc', amount=order.total