diff --git a/src/pretix/plugins/stripe/forms.py b/src/pretix/plugins/stripe/forms.py new file mode 100644 index 0000000000..341d853c98 --- /dev/null +++ b/src/pretix/plugins/stripe/forms.py @@ -0,0 +1,20 @@ +from django import forms +from django.utils.translation import ugettext_lazy as _ + + +class StripeKeyValidator(): + def __init__(self, prefix): + assert isinstance(prefix, str) + assert len(prefix) > 0 + self._prefix = prefix + + def __call__(self, value): + if not value.startswith(self._prefix): + raise forms.ValidationError( + _('The provided key "%(value)s" does not look valid. It should start with "%(prefix)s".'), + code='invalid-stripe-secret-key', + params={ + 'value': value, + 'prefix': self._prefix, + }, + ) diff --git a/src/pretix/plugins/stripe/payment.py b/src/pretix/plugins/stripe/payment.py index bd84e3a344..5f919172db 100644 --- a/src/pretix/plugins/stripe/payment.py +++ b/src/pretix/plugins/stripe/payment.py @@ -10,8 +10,12 @@ from django.conf import settings from django.contrib import messages from django.core import signing from django.template.loader import get_template -from django.utils.translation import ugettext, ugettext_lazy as _ +from django.urls import reverse +from django.utils.crypto import get_random_string +from django.utils.http import urlquote +from django.utils.translation import pgettext, ugettext, ugettext_lazy as _ +from pretix import __version__ from pretix.base.models import Event, Quota, RequiredAction from pretix.base.payment import BasePaymentProvider, PaymentException from pretix.base.services.mail import SendMailException @@ -19,6 +23,7 @@ from pretix.base.services.orders import mark_order_paid, mark_order_refunded from pretix.base.settings import SettingsSandbox from pretix.helpers.urls import build_absolute_uri as build_global_uri from pretix.multidomain.urlreverse import build_absolute_uri +from pretix.plugins.stripe.forms import StripeKeyValidator from pretix.plugins.stripe.models import ReferencedStripeObject logger = logging.getLogger('pretix.plugins.stripe') @@ -36,24 +41,6 @@ class RefundForm(forms.Form): ) -class StripeKeyValidator(): - def __init__(self, prefix): - assert isinstance(prefix, str) - assert len(prefix) > 0 - self._prefix = prefix - - def __call__(self, value): - if not value.startswith(self._prefix): - raise forms.ValidationError( - _('The provided key "%(value)s" does not look valid. It should start with "%(prefix)s".'), - code='invalid-stripe-secret-key', - params={ - 'value': value, - 'prefix': self._prefix, - }, - ) - - class StripeSettingsHolder(BasePaymentProvider): identifier = 'stripe_settings' verbose_name = _('Stripe') @@ -65,17 +52,69 @@ class StripeSettingsHolder(BasePaymentProvider): self.settings = SettingsSandbox('payment', 'stripe', event) def settings_content_render(self, request): - return "
%s
%s
" % ( - _('Please configure a Stripe Webhook to ' - 'the following endpoint in order to automatically cancel orders when charges are refunded externally ' - 'and to process asynchronous payment methods like SOFORT.'), - build_global_uri('plugins:stripe:webhook') - ) + if self.settings.connect_client_id and not self.settings.secret_key: + # Use Stripe connect + if not self.settings.connect_user_id: + request.session['payment_stripe_oauth_event'] = request.event.pk + if 'payment_stripe_oauth_token' not in request.session: + request.session['payment_stripe_oauth_token'] = get_random_string(32) + + return ( + "

{}

" + "{}" + ).format( + _('To accept payments via Stripe, you will need an account at Stripe. By clicking on the ' + 'following button, you can either create a new Stripe account connect pretix to an existing ' + 'one.'), + self.settings.connect_client_id, + request.session['payment_stripe_oauth_token'], + urlquote(build_global_uri('plugins:stripe:oauth.return')), + _('Connect with Stripe') + ) + else: + return ( + "" + ).format( + reverse('plugins:stripe:oauth.disconnect', kwargs={ + 'organizer': self.event.organizer.slug, + 'event': self.event.slug, + }), + _('Disconnect from Stripe') + ) + else: + return "
%s
%s
" % ( + _('Please configure a Stripe Webhook to ' + 'the following endpoint in order to automatically cancel orders when charges are refunded externally ' + 'and to process asynchronous payment methods like SOFORT.'), + build_global_uri('plugins:stripe:webhook') + ) @property def settings_form_fields(self): - d = OrderedDict( - [ + if self.settings.connect_client_id and not self.settings.secret_key: + # Stripe connect + if self.settings.connect_user_id: + fields = [ + ('connect_user_name', + forms.CharField( + label=_('Stripe account'), + disabled=True + )), + ('endpoint', + forms.ChoiceField( + label=_('Endpoint'), + initial='live', + choices=( + ('live', pgettext('stripe', 'Live')), + ('test', pgettext('stripe', 'Testing')), + ), + )), + ] + else: + return {} + else: + fields = [ ('publishable_key', forms.CharField( label=_('Publishable key'), @@ -94,6 +133,9 @@ class StripeSettingsHolder(BasePaymentProvider): StripeKeyValidator('sk_'), ), )), + ] + d = OrderedDict( + fields + [ ('ui', forms.ChoiceField( label=_('User interface'), @@ -146,7 +188,6 @@ class StripeSettingsHolder(BasePaymentProvider): )), ] + list(super().settings_form_fields.items()) ) - d.move_to_end('_enabled', last=False) return d @@ -175,9 +216,28 @@ class StripeMethod(BasePaymentProvider): places = settings.CURRENCY_PLACES.get(self.event.currency, 2) return int(order.total * 10 ** places) + @property + def api_kwargs(self): + if self.settings.connect_client_id and self.settings.connect_user_id: + if self.settings.get('endpoint', 'live') == 'live': + kwargs = { + 'api_key': self.settings.connect_secret_key, + 'stripe_account': self.settings.connect_user_id + } + else: + kwargs = { + 'api_key': self.settings.connect_test_secret_key, + 'stripe_account': self.settings.connect_user_id + } + else: + kwargs = { + 'api_key': self.settings.secret_key, + } + return kwargs + def _init_api(self): - stripe.api_version = '2017-06-05' - stripe.api_key = self.settings.get('secret_key') + stripe.api_version = '2018-02-28' + stripe.set_app_info("pretix", version=__version__, url="https://pretix.eu") def checkout_confirm_render(self, request) -> str: template = get_template('pretixplugins/stripe/checkout_payment_confirm.html') @@ -199,7 +259,8 @@ class StripeMethod(BasePaymentProvider): 'code': order.code }, # TODO: Is this sufficient? - idempotency_key=str(self.event.id) + order.code + source + idempotency_key=str(self.event.id) + order.code + source, + **self.api_kwargs ) except stripe.error.CardError as e: if e.json_body: @@ -330,7 +391,7 @@ class StripeMethod(BasePaymentProvider): return try: - ch = stripe.Charge.retrieve(payment_info['id']) + ch = stripe.Charge.retrieve(payment_info['id'], **self.api_kwargs) ch.refunds.create() ch.refresh() except (stripe.error.InvalidRequestError, stripe.error.AuthenticationError, stripe.error.APIConnectionError) \ @@ -426,7 +487,7 @@ class StripeCC(StripeMethod): if request.session['payment_stripe_token'].startswith('src_'): try: - src = stripe.Source.retrieve(request.session['payment_stripe_token']) + src = stripe.Source.retrieve(request.session['payment_stripe_token'], **self.api_kwargs) if src.type == 'card' and src.card and src.card.three_d_secure == 'required': request.session['payment_stripe_order_secret'] = order.secret source = stripe.Source.create( @@ -521,6 +582,7 @@ class StripeGiropay(StripeMethod): 'hash': hashlib.sha1(order.secret.lower().encode()).hexdigest(), }) }, + **self.api_kwargs ) return source finally: @@ -577,6 +639,7 @@ class StripeIdeal(StripeMethod): 'hash': hashlib.sha1(order.secret.lower().encode()).hexdigest(), }) }, + **self.api_kwargs ) return source @@ -618,6 +681,7 @@ class StripeAlipay(StripeMethod): 'hash': hashlib.sha1(order.secret.lower().encode()).hexdigest(), }) }, + **self.api_kwargs ) return source @@ -676,6 +740,7 @@ class StripeBancontact(StripeMethod): 'hash': hashlib.sha1(order.secret.lower().encode()).hexdigest(), }) }, + **self.api_kwargs ) return source finally: @@ -746,6 +811,7 @@ class StripeSofort(StripeMethod): 'hash': hashlib.sha1(order.secret.lower().encode()).hexdigest(), }) }, + **self.api_kwargs ) return source diff --git a/src/pretix/plugins/stripe/signals.py b/src/pretix/plugins/stripe/signals.py index 98c89fcbf3..1b8c380e3a 100644 --- a/src/pretix/plugins/stripe/signals.py +++ b/src/pretix/plugins/stripe/signals.py @@ -1,5 +1,7 @@ import json +from collections import OrderedDict +from django import forms from django.core.urlresolvers import resolve from django.dispatch import receiver from django.template.loader import get_template @@ -7,8 +9,10 @@ from django.utils.translation import ugettext_lazy as _ from pretix.base.settings import settings_hierarkey from pretix.base.signals import ( - logentry_display, register_payment_providers, requiredaction_display, + logentry_display, register_global_settings, register_payment_providers, + requiredaction_display, ) +from pretix.plugins.stripe.forms import StripeKeyValidator from pretix.presale.signals import html_head @@ -86,3 +90,44 @@ def pretixcontrol_action_display(sender, action, request, **kwargs): settings_hierarkey.add_default('payment_stripe_method_cc', True, bool) + + +@receiver(register_global_settings, dispatch_uid='stripe_global_settings') +def register_global_settings(sender, **kwargs): + return OrderedDict([ + ('payment_stripe_connect_client_id', forms.CharField( + label=_('Stripe Connect: Client ID'), + required=False, + validators=( + StripeKeyValidator('ca_'), + ), + )), + ('payment_stripe_connect_secret_key', forms.CharField( + label=_('Stripe Connect: Secret key'), + required=False, + validators=( + StripeKeyValidator('sk_live_'), + ), + )), + ('payment_stripe_connect_publishable_key', forms.CharField( + label=_('Stripe Connect: Publishable key'), + required=False, + validators=( + StripeKeyValidator('pk_live_'), + ), + )), + ('payment_stripe_connect_test_secret_key', forms.CharField( + label=_('Stripe Connect: Secret key (test)'), + required=False, + validators=( + StripeKeyValidator('sk_test_'), + ), + )), + ('payment_stripe_connect_test_publishable_key', forms.CharField( + label=_('Stripe Connect: Publishable key (test)'), + required=False, + validators=( + StripeKeyValidator('pk_test_'), + ), + )), + ]) diff --git a/src/pretix/plugins/stripe/urls.py b/src/pretix/plugins/stripe/urls.py index 7e56626b5a..30fde3043c 100644 --- a/src/pretix/plugins/stripe/urls.py +++ b/src/pretix/plugins/stripe/urls.py @@ -2,7 +2,9 @@ from django.conf.urls import include, url from pretix.multidomain import event_url -from .views import ReturnView, redirect_view, refund, webhook +from .views import ( + ReturnView, oauth_disconnect, oauth_return, redirect_view, refund, webhook, +) event_patterns = [ url(r'^stripe/', include([ @@ -15,5 +17,8 @@ event_patterns = [ urlpatterns = [ url(r'^control/event/(?P[^/]+)/(?P[^/]+)/stripe/refund/(?P\d+)/', refund, name='refund'), + url(r'^control/event/(?P[^/]+)/(?P[^/]+)/stripe/disconnect/', + oauth_disconnect, name='oauth.disconnect'), url(r'^_stripe/webhook/$', webhook, name='webhook'), + url(r'^_stripe/oauth_return/$', oauth_return, name='oauth.return'), ] diff --git a/src/pretix/plugins/stripe/views.py b/src/pretix/plugins/stripe/views.py index 294b395bee..c24c1c9fbf 100644 --- a/src/pretix/plugins/stripe/views.py +++ b/src/pretix/plugins/stripe/views.py @@ -2,6 +2,7 @@ import hashlib import json import logging +import requests import stripe from django.contrib import messages from django.core import signing @@ -17,10 +18,11 @@ from django.views.decorators.clickjacking import xframe_options_exempt from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_POST -from pretix.base.models import Order, Quota, RequiredAction +from pretix.base.models import Event, Order, Quota, RequiredAction from pretix.base.payment import PaymentException from pretix.base.services.locking import LockTimeoutException from pretix.base.services.orders import mark_order_paid, mark_order_refunded +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 @@ -44,6 +46,60 @@ def redirect_view(request, *args, **kwargs): return r +def oauth_return(request, *args, **kwargs): + if 'payment_stripe_oauth_event' not in request.session: + messages.error(request, _('An error occured during connecting with Stripe, please try again.')) + return redirect(reverse('control:index')) + + event = get_object_or_404(Event, pk=request.session['payment_stripe_oauth_event']) + + if request.GET.get('state') != request.session['payment_stripe_oauth_token']: + messages.error(request, _('An error occured during connecting with Stripe, please try again.')) + return redirect(reverse('control:event.settings.payment.provider', kwargs={ + 'organizer': event.organizer.slug, + 'event': event.slug, + 'provider': 'stripe_settings' + })) + + gs = GlobalSettingsObject() + + try: + resp = requests.post('https://connect.stripe.com/oauth/token', data={ + 'grant_type': 'authorization_code', + 'client_secret': ( + gs.settings.payment_stripe_connect_secret_key or gs.settings.payment_stripe_connect_test_secret_key + ), + 'code': request.GET.get('code') + }) + data = resp.json() + + if 'error' not in data: + account = stripe.Account.retrieve( + data['stripe_user_id'], + api_key=gs.settings.payment_stripe_connect_secret_key or gs.settings.payment_stripe_connect_test_secret_key + ) + except: + logger.exception('Failed to obtain OAuth token') + messages.error(request, _('An error occured during connecting with Stripe, please try again.')) + else: + if 'error' in data: + messages.error(request, _('Stripe returned an error: {}').format(data['error_description'])) + else: + messages.success(request, _('Your Stripe account is now connected to pretix. You can change the settings in ' + 'detail below.')) + event.settings.payment_stripe_publishable_key = data['stripe_publishable_key'] + # event.settings.payment_stripe_connect_access_token = data['access_token'] we don't need it, right? + event.settings.payment_stripe_connect_refresh_token = data['refresh_token'] + event.settings.payment_stripe_connect_user_id = data['stripe_user_id'] + event.settings.payment_stripe_connect_user_name = account['business_name'] + + return redirect(reverse('control:event.settings.payment.provider', kwargs={ + 'organizer': event.organizer.slug, + 'event': event.slug, + 'provider': 'stripe_settings' + })) + + @csrf_exempt @require_POST def webhook(request, *args, **kwargs): @@ -80,7 +136,7 @@ def charge_webhook(event, event_json, charge_id): prov = StripeCC(event) prov._init_api() try: - charge = stripe.Charge.retrieve(charge_id) + charge = stripe.Charge.retrieve(charge_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) @@ -135,7 +191,7 @@ def source_webhook(event, event_json, source_id): prov = StripeCC(event) prov._init_api() try: - src = stripe.Source.retrieve(source_id) + src = stripe.Source.retrieve(source_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) @@ -169,6 +225,24 @@ def source_webhook(event, event_json, source_id): return HttpResponse(status=200) +@event_permission_required('can_change_event_settings') +@require_POST +def oauth_disconnect(request, **kwargs): + del request.event.settings.payment_stripe_publishable_key + del request.event.settings.payment_stripe_connect_access_token + del request.event.settings.payment_stripe_connect_refresh_token + del request.event.settings.payment_stripe_connect_user_id + del request.event.settings.payment_stripe_connect_user_name + request.event.settings.payment_stripe__enabled = False + messages.success(request, _('Your Stripe account has been disconnected.')) + + return redirect(reverse('control:event.settings.payment.provider', kwargs={ + 'organizer': request.event.organizer.slug, + 'event': request.event.slug, + 'provider': 'stripe_settings' + })) + + @event_permission_required('can_view_orders') @require_POST def refund(request, **kwargs): @@ -219,7 +293,7 @@ class ReturnView(StripeOrderView, View): def get(self, request, *args, **kwargs): prov = self.pprov prov._init_api() - src = stripe.Source.retrieve(request.GET.get('source')) + src = stripe.Source.retrieve(request.GET.get('source'), **prov.api_kwargs) if src.client_secret != request.GET.get('client_secret'): messages.error(self.request, _('Sorry, there was an error in the payment process. Please check the link ' 'in your emails to continue.')) diff --git a/src/requirements/production.txt b/src/requirements/production.txt index a7559ffe79..2d30bedf6d 100644 --- a/src/requirements/production.txt +++ b/src/requirements/production.txt @@ -34,7 +34,7 @@ babel django-i18nfield>=1.2.1 django-hijack==2.1.* # Stripe -stripe==1.62.* +stripe==1.79.* # PayPal paypalrestsdk==1.12.* pycparser==2.13 # https://github.com/eliben/pycparser/issues/147 diff --git a/src/tests/plugins/stripe/test_webhook.py b/src/tests/plugins/stripe/test_webhook.py index 1743f7a859..ec988ba0c1 100644 --- a/src/tests/plugins/stripe/test_webhook.py +++ b/src/tests/plugins/stripe/test_webhook.py @@ -101,7 +101,7 @@ def get_test_charge(order: Order): @pytest.mark.django_db def test_webhook_all_good(env, client, monkeypatch): charge = get_test_charge(env[1]) - monkeypatch.setattr("stripe.Charge.retrieve", lambda *args: charge) + monkeypatch.setattr("stripe.Charge.retrieve", lambda *args, **kwargs: charge) client.post('/dummy/dummy/stripe/webhook/', json.dumps( { @@ -135,7 +135,7 @@ def test_webhook_mark_paid(env, client, monkeypatch): order.save() charge = get_test_charge(env[1]) - monkeypatch.setattr("stripe.Charge.retrieve", lambda *args: charge) + monkeypatch.setattr("stripe.Charge.retrieve", lambda *args, **kwargs: charge) client.post('/dummy/dummy/stripe/webhook/', json.dumps( { @@ -183,7 +183,7 @@ def test_webhook_partial_refund(env, client, monkeypatch): ], "total_count": 1 } - monkeypatch.setattr("stripe.Charge.retrieve", lambda *args: charge) + monkeypatch.setattr("stripe.Charge.retrieve", lambda *args, **kwargs: charge) client.post('/dummy/dummy/stripe/webhook/', json.dumps( { @@ -225,7 +225,7 @@ def test_webhook_global(env, client, monkeypatch): order.save() charge = get_test_charge(env[1]) - monkeypatch.setattr("stripe.Charge.retrieve", lambda *args: charge) + monkeypatch.setattr("stripe.Charge.retrieve", lambda *args, **kwargs: charge) ReferencedStripeObject.objects.create(order=order, reference="ch_18TY6GGGWE2Ias8TZHanef25")